Cross-compiling a C++ Library Using the Android NDK (Part I)

Cross-compiling a C++ Library Using the Android NDK (Part I)

A common theme in the world of MLOps is the development of machine learning models on state-of-the-art cloud computing environments or desktop Linux computers with GPU capabilities and x86-64 hardware. However, we often do so with the intention of eventually using these models on edge devices such as smartphones, tablets, or embedded systems. These edge devices typically have completely different hardware, and actually deploying compute-heavy C/C++ library functionality to these devices represents a non-trivial step in the development cycle.

Nearly every tutorial detailing the deployment of native C/C++ code to Android makes heavy use of Android Studio’s integrated development environment (IDE) or a similar IDE such as Eclipse. While these IDEs provide a wide range of features, they can also act as a black box, obscuring details of the compilation and deployment process. In this blog post, we take a different approach, avoiding the Android Studio IDE entirely in order to detail the deployment process of C/C++ programs to an Android mobile device in a transparent way. The only things we’ll need are the Android SDK/NDK binary executables, CMake, and a terminal.

Your Android phone is a Linux box

User-interface (UI) and application development in Android is typically done in Java and/or Kotlin, which are the primary interfaces for interacting with the Android operating system. Despite that fact, as stated in the Android documentation, the Android kernel is based on a Linux Long Term Supported (LTS) kernel.

Therefore, in some ways, your Android phone operates like a mobile Linux machine, and heroic projects like rawdrawandroid underscore the fact that Android apps can be created in pure C, without Java, Kotlin, or the Java Native Interface (JNI) at all. Furthermore, it’s possible to interact with the Android device like a linux machine, without the UI/UX components.

Disclaimer: we won’t be using the Android Studio IDE, but it is necessary to install the Android Studio SDK from their website to obtain access to the SDK and NDK cross-compilation tools, as well as the platform tools.

First, locate the installation location of the Android SDK on your machine—for example, on my system it’s in /home/user/Android/Sdk/ . This directory will have a platform-tools subdirectory, which includes a useful binary executable called adb. This is the Android Debug Bridge, a “versatile command-line tool that lets you communicate with a device” according to the Android developer documentation. Once we’ve physically connected an Android device to our Linux machine (from here on, I’ll refer to the desktop machine as the “workstation”) via USB cable and allowed access to the phone, we can use adb to open a shell within the device.

workstation >> ls
Desktop
Downloads
... [ this is your workstation filesystem ]
workstation >> /path/to/adb shell
android-device >> ls
acct                efs                prism
apex                etc                proc
audit_filter_table  init               product
bin                 init.container.rc  sdcard
... [ this is the filesystem on your android device ]

As you’re now accessing your Android device as if it were a remote server, you can inspect the filesystem and run Linux commands (although most Android Linux systems are not as full-featured as a standard Ubuntu distribution).

Cross-compiling and deploying a trivial C++ program

Having started to interact with the Android device as if it were a remote server, can we compile a binary executable and run it natively on our mobile device? In our adb shell,

workstation >> /home/user/Android/Sdk/platform-tools/adb shell
android-device >> which gcc
android-device >> 

from which we can see that the gcc compiler is not directly available on the Android device. However, we don’t want to use our Android device itself as a development environment, rather, we’d like to compile an executable on our desktop machine which can be run on the Android device’s operating system (i.e., “natively”). This process of compiling an executable on a workstation which can be run as-is on an adroid device is what we mean by “cross-compilation.” To that end, on our workstation, we compile a simple C++ program

// hello_android.cc
#include <iostream>

int main() {
  std::cout << "paranoid android" << std::endl;
  return 0;
}

with the g++ compiler

g++ hello_android.cc -o hello_x86-64

and move the binary executable to the /data/local/tmp directory of our Android device’s filesystem with the adb push command:

workstation >> /path/to/adb push hello_x86-64 /data/local/tmp
hello_x86-64: 1 file pushed, 0 skipped. 233.0 MB/s (16536 bytes in 0.000s)

Now we open an adb shell and attempt to run the executable:

workstation >> /home/user/Android/Sdk/platform-tools/adb shell
android-device >> cd /data/local/tmp; ls
hello_x86-64

android-device >> ./hello_x86-64
/system/bin/sh: ./hello_x86-64: not executable: 64-bit ELF file

What happened? When we compiled the suggestively-named executable hello_x86-64 , we did so for our workstation’s hardware, namely an x86-64 (x86 instruction set, 64 bit) architecture, which we can see by running the Linux file command on our device.

android-device >> file hello_x86-64
hello_x86-64:  ELF shared object, 64-bit LSB x86-64, dynamic (/lib64/ld-linux-x86-64.so.2), BuildID=ec4ac, not stripped
                                             ^^^^^^^

However, our Android device does not use this hardware and therefore can not run the executable; this is because C++ compilation is hardware-specific. In fact, the hardware-specific nature of languages like C and C++ is one of the reasons that Java’s “write once, run anywhere” characteristic can be advantageous for mobile UI/UX development. Fortunately, the Android SDK comes complete with a set of (cross-)compilers that can compile an executable on our workstation for targeted deployment to the whatever hardware our Android device happens to be using. If you locate the installation location of the Android NDK bundle, these compilers are available as binary executables. For example, on my workstation, the NDK version 26.2 compilers are in the following directory:

workstation >> cd /home/user/Android/Sdk/ndk/26.2.11394342/toolchains/llvm/prebuilt/linux-x86_64/bin
workstation >> ls
aarch64-linux-android33-clang       armv7a-linux-androideabi31-clang    i686-linux-android23-clang++  x86_64-linux-android23-clang++
aarch64-linux-android33-clang++     armv7a-linux-androideabi31-clang++  i686-linux-android24-clang    x86_64-linux-android24-clang
... [ and many more ] ...

Looking up the hardware specification of the particular mobile device I’m using, I see that it uses an aarch64 (also sometimes called arm64) architecture. So let’s compile our C++ code with the aarch64 version of the clang++ compiler, and push the resulting executable to the device.

workstation >> /path/to/aarch64-linux-android34-clang++ hello_android.cc -o hello_aarch64
workstation >> /path/to/adb push hello_aarch64 /data/local/tmp
workstation >> adb shell
android-device >> cd /data/local/tmp; file *
hello_aarch64: ELF shared object, 64-bit LSB arm64, dynamic (/system/bin/linker64), for Android 34, built by NDK r26c (11342), not stripped
hello_x86-64:  ELF shared object, 64-bit LSB x86-64, dynamic (/lib64/ld-linux-x86-64.so.2), BuildID=ec52ac, not stripped

Now from the output of file , we see that this new executable has been compiled for arm64, and, this time, when we attempt to run it,

android-device >> ./hello_aarch64
paranoid android

we see that the program executes as expected on the Android device.

Details: to avoid the need for the Android system linker to resolve the dependencies for more complicated problems, it may be necessary to compile your C/C++ code as a static executable.

To summarize, we have successfully cross-compiled and deployed C++ code for Android. We didn’t need to build an app, write Java / Kotlin code, or interact with the UI at all in order to run the executable—using these tools we were able to execute the code on the OS itself, i.e., we were able to run the program “natively.”

Cross-compiling and deploying a C++ program with library dependencies

Deploying simple programs like the one in the previous section can go a long way towards demystifying the cross-compilation and deployment process. But typically, we are interested in compiling more complicated software with its own library dependencies. In such cases, attempting to use the NDK compilers directly via the command line is a labor-intensive and error-prone approach. Rather, we’d like to invoke the cross-compilers within a modern build system. In this post, we’ll use cmake to compile and deploy an executable making use of Google’s sentencepiece text tokenization library.

The first step is to write some code making use of the sentencepiece C++ API. In a new directory, we write some example code:

// example.cc
#include <iostream>
#include <sentencepiece_processor.h>

void call_sentencepiece() {

  std::cout << "CLIKA::call_sentencepiece" << std::endl;
  sentencepiece::SentencePieceProcessor processor;

  std::vector<std::string> pieces;
  processor.Encode("This is a test.", &pieces);

  int counter = 0;
  for (const std::string &token : pieces) {
    std::cout << "token " << counter << ": " << token << std::endl;
    counter++;
  }

}

int main() {
  std::cout << "hello, sentencepiece" << std::endl;
  call_sentencepiece();
}

As the sentencepiece library is a dependency, we need to clone its source code to the same directory. We will also create a build directory in which to do an out-of-source build:

workstation >> git clone git@github.com:google/sentencepiece.git
workstation >> mkdir build

We specify the library dependencies and the C++ standard in CMakeLists.txt , as is typical of cmake builds:

# CMakeLists.txt
cmake_minimum_required(VERSION 3.1)
project(sentencepiece-example)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)

# add sentencepiece as a subdirectory which will be built from source
add_subdirectory(sentencepiece)
include_directories(sentencepiece/src)

# link our executable to the sentencepiece-static cmake target 
add_executable(example example.cc)
target_link_libraries(example sentencepiece-static)

We also need to provide arguments to the cmake command so that it knows to use the appropriate Android NDK cross-compilers rather than the default compilers on our workstation. Since there are lots of options for configuring the cmake build, we will put them in their own standalone shell script, xcompile_cmake.sh, which calls the Android NDK-specific toolchain and the clang compiler, as well as specifying the arm64-v8a as the target architecture on which we will deploy our executable:

# xcompile_cmake.sh
/path/to/Android/Sdk/cmake/3.22.1/bin/cmake .. \
 -DANDROID_ABI=arm64-v8a \
 -DANDROID_NDK=/path/to/Android/Sdk/ndk/26.2.11394342/ \
 -DCMAKE_BUILD_TYPE=Debug \
 -DCMAKE_TOOLCHAIN_FILE=/path/to/Android/Sdk/ndk/26.2.11394342/build/cmake/android.toolchain.cmake \
 -DANDROID_NATIVE_API_LEVEL=21 \
 -DANDROID_TOOLCHAIN=clang \
 -DANDROID_LINKER_FLAGS="-landroid -llog" ..

Details: we are specifying that the source directory is .. since we will copy this script to the build directory (although we could just as easily make use of the -B and -S cmake flags). We also are linking to the android and log libraries so that we can make use of Android utilities.

After these steps, our project directory looks like the following:

workstation >> tree . 
├── build/
├── CMakeLists.txt
├── example.cc
├── sentencepiece/
│   ├── README.md
│   ├── src
   ...
└── xcompile_cmake.sh

Now we’re ready to build the project by first invoking cmake to generate a Makefile, then calling make. The binary executable will be in the build directory, which we can then push to our mobile device and run.

workstation >> cd build; cp ../xcompile_cmake.sh .
workstation >> ./xcompile_cmake.sh
-- The C compiler identification is Clang 17.0.2
-- The CXX compiler identification is Clang 17.0.2
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done

... [ additional CMake output ]

-- Configuring done
-- Generating done

workstation >> make -j8
workstation >> cd ..
workstation >> path/to/adb push build/example /data/local/tmp
workstation >> path/to/adb shell
android-device >> cd /data/local/tmp
android-device >> ls
example  hello_aarch64  hello_x86-64

android-device >> ./example
hello, sentencepiece
CLIKA::call_sentencepiece

And the executable is able to run the sentencepiece code.

Details: the reason that no tokenization output is printed is because sentencepiece tokenizers require a tokenization model to actually tokenize output. Since we didn’t load a model, processor.Encode(...) returns an empty vector. The point is to show that we were able to use the sentencepiece library—adb push can be used to load a HuggingFace tokenization model to the device to perform actual tokenization as a straightforward extension to what we’ve developed so far.

We’ve cross-compiled an executable with complicated dependencies using the cmake build system and deployed it natively on the Android device operating system. How to use this cross-compiled C++ code in the Java/Kotlin UI/UX ecosystem using the JNI will be the subject of a future post.