This article is not a “Hello world!”-type tutorial for NDK. Although I will still provide a quick walk-through of the very basic knowledge of ndk-build, but it is not the focus of this article. Instead, I will summarize some very useful NDK techniques and tips I have been using in my projects. Hope those tips can be very useful for anyone who wants to build some practical projects rather than a toy project to learn NDK. So, the target readers are medium or advanced android developers. The article contains two parts:
Part 1: ndk-build
In this part, we will discuss how to flexibly use ndk-build to build your projects, and how to organize the file structure of your projects.
Part 2: standalone toolchain
In part 2, the set up and usage of standalone toolchain will be discussed.
The source code of all examples can be found here: https://github.com/robertwgh/mastering-ndk.
Table of Contents
Introduction
Prerequisites
Basics of ndk-build
Building Native Executable Using NDK
Useful Techniques
5.1 How to compile source code not in jni directory
5.2 Get rid of jni folder
5.3 Use custom names for makefiles
5.4 Using include to embed .mk files
5.5 About LOCAL_PATH and CLEAR_VARS
5.6 How to debug .mk makefiles
5.7 Build for multiple target architectures
Summary
Comments
1. Introduction
Android NDK (Native Development Kit) is a power tool for Android application developers who want efficient and high performance native code, or who has to deal with low-level hardware details (such as OpenGL, OpenCL and so on).
The Android NDK official documents (an online version) are kind of OK, if you have been working with NDK for a while. However, it is really not designed for someone just starting with Android NDK development. The problem with the official documents is that there is no emphasis, so that important information might be overlooked easily.
There are also many online tutorials and articles showing the basics of NDK and the usage of NDK building tools. However, the information are distributed everywhere. There is no single place discussing these topics and techniques in depth. Hopefully, this article will cover some of them.
In this article, I make the following assumptions:
you know what NDK is;
you know C/C++;
you have already installed Android NDK on your computer. In my setting, I install NDK under D:\development\android-ndk-r10d. In later part of this article, I will call that path NDK_ROOT.
to avoid long path name when using ndk-build, I added NDK_ROOT to system PATH environment variable.
3. Basics of ndk-build
Of course, the first step of using the Android NDK is downloading the NDK installation package from Android Developer network. After installing the NDK package, these are what you got:
NDK_ROOT\ndk-build.cmd script;
documents under NDK_ROOT\docs;
toolchains and compilers;
source code for some native libraries;
some sample codes.
The sample codes could be very useful if you want to learn the basic set up and the syntax of the makefiles for NDK. Going through the sample code provided by NDK, you will find that most of the code samples assume that you are working on an Android application project, and will use NDK to build the JNI part of the Android application. That’s why you notice that all project put C/C++ source code and makefiles under a jni folder.
The following is a typical file structure of NDK samples:
+– project_root
| +– jni
| +– Android.mk
| +– Application.mk
| +– main.c
| +– obj
| +– libs
As you can see, the jni directory is the heart of the whole NDK project, which contains C/C++ source code, two makefiles Android.mk and Application.mk. As will be discussed later, you will see that the C/C++ source code is not necessary to sit inside jni folder. Moreover, you don’t need to have exactly the same name for makefiles. But as a starting point, using Android.mk and Application.mk will be easiest way to Go and can save you a lot of effort, unless you really don’t like the current names of the makefiles. By default, the ndk-build will try to locate
The other two folders obj and libs are generated by the NDK building system, and contains the intermediate files and final binary code, respectively.
The Android.mk and Application.mk are the most important makefiles for a NDK project. The Android.mk is more like a traditional makefile, defining source code, path to include header files, path for the linker to locate the libraries, module name, build type, and so on. The Application.mk defines Android application related properties, such as the Android SDK version, debug or release mode, target platform ABI (architecture binary interface), standard C/C++ library, and so on.
A typical Android.mk
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := # name your module here.
LOCAL_SRC_FILES := main.c
include $(BUILD_SHARED_LIBRARY)
A minimal Application.mk
APP_ABI := all
To build such project, we can go to the project_root, and type ndk-build (assuming you have the NDK_ROOT in the system PATH, the NDK building script will automatically find the native code under jni folder.
$ cd project_root
$ ndk-build
If there is no bug in the code, the compiled shared library lib.so will be generated under libs// directory. Once you get this shared library file, your Android application building system will pack it into the final APK installer file. You will be able to call the native functions using JNI in your Java code.
In this article, we focus more on how to build executable binaries using NDK, since we will be easier to test our results immediately in that way. But remember that all the techniques talked here will be the same and can be directly applied to a shared library project without any change.
Let’s first build a “Hello World!” test. The project structure will be like this:
+– ex1_helloworld
| +– jni
| +– Android.mk
| +– Application.mk
| +– hello.c
Hello.cpp
int main()
{
std::cout << “Hello World!” << std::endl;
return 0;
}
Android.mk
Please notice that a comment starts with “#” in .mk files.
LOCAL_PATH:= $(call my-dir) # Get the local path of the project.
include $(CLEAR_VARS) # Clear all the variables with a prefix “LOCAL_”
LOCAL_SRC_FILES:=hello.cpp # Indicate the source code.
LOCAL_MODULE:= hello # The name of the binary.
include $(BUILD_EXECUTABLE) # Tell ndk-build that we want to build a native executable.
Application.mk
APP_OPTIM := debug # Build the target in debug mode.
APP_ABI := armeabi-v7a # Define the target architecture to be ARM.
APP_STL := stlport_static # We use stlport as the standard C/C++ library.
APP_CPPFLAGS := -frtti -fexceptions # This is the place you enable exception.
APP_PLATFORM := android-19 # Define the target Android version of the native application.
You may already found out that, the major difference between a shared library project and a native project is only one line in the Android.mk.
For a shared library, we use:
include $(BUILD_SHARED_LIBRARY)
For an executable binary, we use:
include $(BUILD_EXECUTABLE)
To this point, we can build our “Hello World!” NDK project:
$ cd project_root
$ ndk-build
[armeabi-v7a] Cygwin : Generating dependency file converter script
[armeabi-v7a] Compile++ thumb: hello <= hello.cpp
[armeabi-v7a] Executable : hello
[armeabi-v7a] Install : hello => libs/armeabi-v7a/hello
Then, we can push the hello native program to an Android device and run it (to achieve this, we need Android ADB tool. Please install Android SDK, and set the ANDROID_SDK_ROOT/platform-tools to system PATH. Of course, you may also find ADB install package from internet if you don’t want to bother to install Android SDK.)
$ adb root
$ adb shell “mkdir -p /data/mastering_ndk && chmod 777 /data/mastering_ndk”
$ adb push ./libs/armeabi-v7a/hello /data/mastering_ndk
$ adb shell “cd /data/mastering_ndk && chmod 777 ./hello && ./hello”
$ Hello World!
Tip: The above command only works for rooted devices. If you have a stock version device without root permission, you can utilize the Android Native Program Launcher tool to launch the native executable on any Android device.
We have recalled the basics of ndk-build. From the next section, we will show some techniques to better utilize the NDK build system for some larger projects.
5.1 How to compile source code not in jni directory
Assume you have a big project, presumably a cross-platform project, so chances are high that you cannot easily move all your source code to under the jni folder. This is actually not to do with some small modification to the existing Android.mk makefile. The following example will show how to achieve that. You can find the complete project in example 2: ex2_src_not_in_jni_folder.
Project structure:
+– ex2_src_not_in_jni_folder
| +– jni
| +– Android.mk
| +– Application.mk
| +– src
| +– hello.c
Android.mk
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_SRC_FILES:= ../src/hello.cpp
LOCAL_MODULE:= hello
include $(BUILD_EXECUTABLE)
Application.mk remains the same. We can build the project and execute the binary using the same way as in example 1.
5.2 Get rid of jni folder
The jni folder makes more sense for a JNI native project in an Android application project. If we want something more meaningful to our project, we can get rid of that specific folder which is used by ndk-build by default. To achieve that, a few variables in the makefiles should be set appropriately. The following steps will achieve that goal. The complete example project can be found in example 3.
Project structure:
+– ex3_get_rid_of_jni_folder
| +– Android.mk
| +– Application.mk
| +– src
| +– hello.c
As you can see, we removed jni folder and moved the Android.mk and Application.mk up to the project_root folder.
The new Android.mk
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_SRC_FILES:= src/hello.cpp # This has changed!
LOCAL_MODULE:= hello
include $(BUILD_EXECUTABLE)
The new Application.mk
APP_OPTIM := debug
APP_ABI := armeabi-v7a
APP_STL := stlport_static
APP_CPPFLAGS := -frtti -fexceptions
APP_PLATFORM := android-19
APP_BUILD_SCRIPT := Android.mk # this line is new!
Please notice that the APP_BUILD_SCRIPT indicates the major makefile entry for the whole application. In our case, it is Android.mk. Everything looks good so far, then, we use the following command to build the project, please notice that we add NDK_APPLICATION_MK variable to the ndk-build command to tell the ndk-build where to find the Application.mk. In this example, we use the following command:
$ ndk-build NDK_APPLICATION_MK=./Application.mk
However, we will have the following error:
$ ndk-build NDK_APPLICATION_MK=./Application.mk
Android NDK: Could not find application project directory !
Android NDK: Please define the NDK_PROJECT_PATH variable to point to it.
/cygdrive/d/development/android-ndk-r10d/build/core/build-local.mk:148: * Android NDK: Aborting . Stop.
The NDK_PROJECT_PATH is a system environmental variable. Let’s define it to where the Application.mk is located and re-build.
$ export NDK_PROJECT_PATH=.
$ ndk-build NDK_APPLICATION_MK=./Application.mk
[armeabi-v7a] Cygwin : Generating dependency file converter script
[armeabi-v7a] Compile++ thumb: hello <= hello.cpp
[armeabi-v7a] Executable : hello
[armeabi-v7a] Install : hello => libs/armeabi-v7a/hello
There is another way to fix the above build error. The solution is to create an empty AndroidManifest.xml file in the same folder with Application.mk.
After adding this dummy AndroidManifest.xml file, we can build the project without defining NDK_PROJECT_PATH variable. The final project structure is:
+– ex3_get_rid_of_jni_folder
| +– Android.mk
| +– AndroidManifest.xml
| +– Application.mk
| +– src
| +– hello.c
5.3 Use custom names for makefiles
We can push the previous technique even further to define our own makefiles. The following example can be found in example 4.
In the following example, we rename the application makefile to MyApplication.mk, and the module makefile to MyAndroid.mk.
Project structure:
+– ex4_custom_make_files
| +– MyAndroid.mk
| +– AndroidManifest.xml
| +– MyApplication.mk
| +– src
| +– hello.c
MyAndroid.mk is the same as the previous Android.mk.
The new MyApplication.mk
APP_OPTIM := debug
APP_ABI := armeabi-v7a
APP_STL := stlport_static
APP_CPPFLAGS := -frtti -fexceptions
APP_PLATFORM := android-19
APP_BUILD_SCRIPT := MyAndroid.mk
And the build command becomes:
$ ndk-build NDK_APPLICATION_MK=./MyApplication.mk
[armeabi-v7a] Compile++ thumb: hello <= hello.cpp
[armeabi-v7a] Executable : hello
[armeabi-v7a] Install : hello => libs/armeabi-v7a/hello
Everything builds well and we successful got the binary hello.
5.4 Using include to embed .mk files
To better handle large projects containing multiple submodules, in the form of static libraries, shared libraries, or pre-built files, the NDK build system allows a makefile to include another makefiles. The following is the syntax:
include PATH_TO_MK_FILE/Android.mk
This will include Android.mk file under PATH_TO_MK_FILE directory to the current makefile. This “include” feature provides us tremendous flexibility to create some very creative way to utilize the building system.
Let’s look at a simple example (example 5) and see how it works. Please notice that this example contains a very simple makefiles. With the power of “include”, I am confident to say that you can create much more complicated building scripts, which can almost do anything for any project, no matter how complex that project is.
In the following example project, we have a main() function inside source file compute.cpp which calls add() and mul() functions to perform addition and multiplication on the input numbers. We define add() and mul() functions in two submobules, and compile them into two static libraries. Finally, when build the executable, the linker will link everything together and generate the final executable binary.
So, to better handle the submodules and separate every submobules, in this project, we create a Android.mk for each module. As you will see soon, by organizing the makefiles this way, the project now has a very scalable structure. To be more specific, if you want to add one more submobule to the same project, you just simply add another submodule folder (whatever it is called, let’s say, divide), and create a new Android.mk for that new submodule “divide”. Then, you just need to change one line in the makefile of the main module. All the existing submodules remain untouched. The project is very easy to maintain and extend to support more functions.
Top level
First, let’s look at the project structure and have a overall picture:
+– ex5_using_include_to_embed_make_files
| +– makefiles
| +– Android.mk
| +– Application.mk
| +– src
| +– main
| +– compute.cpp
| +– Android.mk
| +– submodules
| +– add
| +– add.cpp
| +– Android.mk
| +– mul
| +– mul.cpp
| +– Android.mk
| +– Android.mk
| +– AndroidManifest.xml
makefiles/Application.mk
APP_OPTIM := debug
APP_ABI := armeabi-v7a
APP_STL := stlport_static
APP_CPPFLAGS := -frtti -fexceptions
APP_PLATFORM := android-19
APP_BUILD_SCRIPT := makefiles/Android.mk
makefiles/Android.mk
TOP_LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
include $(TOP_LOCAL_PATH)/../src/submodules/Android.mk
include $(TOP_LOCAL_PATH)/../src/main/Android.mk
Here, this Android.mk serves as the top level makefile and includes another two Android.mk, one for the submodules, and the other for the main module.
Main module : compute
We first look at the main module.
src/main/compute.cpp
int add(int a, int b);
int mul(int a, int b);
int main()
{
int a = 2;
int b = 3;
std::cout << “a = ” << a << std::endl;
std::cout << “b = ” << b << std::endl;
std::cout << “add(a, b) = ” << add(a, b) << std::endl;
std::cout << “mul(a, b) = ” << mul(a, b) << std::endl;
return 0;
}
src/main/Android.mk
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_SRC_FILES:= compute.cpp
LOCAL_MODULE:= compute
LOCAL_STATIC_LIBRARIES:= add mul
include $(BUILD_EXECUTABLE)
We build the main module as an executable by defining include $(BUILD_EXECUTABLE). And we also define the LOCAL_STATIC_LIBRARIES to add mul, which means this main module depends on two static libraries with the module names add and mul, respectively. But where are these two modules are defined, let’s continue to look at the submobules.
Sub-modules: add and mul
src/submodules/Android.mk
include $(call all-subdir-makefiles)
Here, in the submodules folder, the makefiles only contains one line, which calls a function in the NDK build system. This command include $(call all-subdir-makefiles) is basically equivalent to including all the Android.mk files in all the sub-directories manually. In our case, this will help us include src/submodules/add/Android.mk and src/submodules/mul/Android.mk.
Let’s take a look at submodule add.
src/submodules/add/add.cpp
int add(int a, int b)
{
return a + b;
}
src/submodules/add/Android.mk
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_SRC_FILES:= ./add.cpp
LOCAL_MODULE:= add
include $(BUILD_STATIC_LIBRARY)
We define module add to be a static library.
Similarly, we have the submodule mul, which almost has the same makefile as add module.
src/submodules/mul/mul.cpp
int mul(int a, int b)
{
return a * b;
}
src/submodules/mul/Android.mk
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_SRC_FILES:= ./mul.cpp
LOCAL_MODULE:= mul
include $(BUILD_STATIC_LIBRARY)
So far, we have listed all the files in the projects. And the build flow is clearly shown as follows:
Build static library add using add.cpp;
Build static library mul using mul.cpp;
Build main module compute using compute.cpp;
Link compute to static libraries libadd.a and libmul.a, generate executable compute.
Build and Execute
We use the same command from the previous examples to build the project.
$ ndk-build NDK_APPLICATION_MK=./makefiles/Application.mk
[armeabi-v7a] Cygwin : Generating dependency file converter script
[armeabi-v7a] Compile++ thumb: compute <= compute.cpp
[armeabi-v7a] Compile++ thumb: add <= add.cpp
[armeabi-v7a] StaticLibrary : libadd.a
[armeabi-v7a] Compile++ thumb: mul <= mul.cpp
[armeabi-v7a] StaticLibrary : libmul.a
[armeabi-v7a] Executable : compute
[armeabi-v7a] Install : compute => libs/armeabi-v7a/compute
We execute the binary.
$ adb shell “mkdir -p /data/mastering_ndk && chmod 777 /data/mastering_ndk”
$ adb push ./libs/armeabi-v7a/compute /data/mastering_ndk
$ adb shell “cd /data/mastering_ndk && chmod 777 ./compute && ./compute”
a = 2
b = 3
add(a, b) = 5
mul(a, b) = 6
We can see that the results are exactly what we expected. Clearly, by using “include”, the project becomes more structured. All the modules are built separately, but have the ability to share variables in the makefiles. One can imagine that in a big project, there must be much more settings, such as LOCAL_C_INCLUDES, LOCAL_CFLAGS, LOCAL_LDFLAGS, LOCAL_LDLIBS and so on. Many submobules may have the same values for these settings. In those cases, we can extract the common part and put the common ones in a common makefile, and then include it in makefiles for each submodule. Doing that will save a lot of coding effort, and will make the makefiles easier to modify and maintain. When adding new submodules, the effort of writing new makefiles will be minimal.
5.5 About LOCAL_PATH and CLEAR_VARS
The following two NDK built-in functions are quite important:
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
The first one (LOCAL_PATH:= $(call my-dir)) retrieves the current local path of the Android.mk file, so that all the variables in the same file can generate absolute path based on this local path. The command LOCAL_PATH:= $(call my-dir) clears all the NDK built-in variables starting with LOCAL_, such as LOCAL_SRC_FILES, LOCAL_C_INCLUDES, LOCAL_CFLAGS, LOCAL_LDFLAGS, LOCAL_LDLIBS and so on, except for the LOCAL_PATH.
This works perfectly fine when you have just a single Android.mk in the project. However, if you use “include” to put multiple makefiles together, you need to be careful about the above two commands.
The reason is that the LOCAL_PATH variable can be overwritten by the subsequent call to the command LOCAL_PATH:= $(call my-dir). For example, in the following case:
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
include subfolder/Android.mk
LOCAL_SRC_FILES:= $(LOCAL_PATH)/test.cpp
The problem with the above makefiles is that after the “include”, in the subfolder/Android.mk, the LOCAL_PATH may be modified by the included Android.mk. Then, when you try to locate the test.cpp, the ndk-build will fail, since the path to the file is wrong now.
If you pay enough attention to example 5, you will see a small trick has been used there. Let’s take a look at the top level makefile of example 5.
ex5_using_include_to_embed_make_files/makefiles/Android.mk
TOP_LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
include $(TOP_LOCAL_PATH)/../src/submodules/Android.mk
include $(TOP_LOCAL_PATH)/../src/main/Android.mk
To avoid using the wrong LOCAL_PATH, I defined TOP_LOCAL_PATH to guarantee the two include use the same local path.
Some people use another trick, that is in the include Android.mk, the first thing is to back up the LOCAL_PATH variable to a temporary variable, and before exiting, restore the LOCAL_PATH. For example:
LOCAL_PATH_BACK_UP := $(LOCAL_PATH)
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
…
LOCAL_PATH:=$(LOCAL_PATH_BACK_UP )
Similarly, if your modules share some common settings, be careful to use the include $(CLEAR_VARS), only use it when you know it is really safe and necessary.
5.6 How to debug .mk makefiles
For a small project, it might be easier to find out issues in the makefiles. However, as your project gets bigger, and especially when you include multiple modules including shared library, static library and executables, things get more complicated. You may face issues like “cannot find the rule to build foo“ or “cannot link the library bar“. Sometimes, it may be just due to a typo in some corner, but it may cause you several hours to find it, since the information provided by ndk-build is indeed very limited, and often times inadequate.
Fortunately, there are some extremely useful tools:
$(error error message)
$(warning warning message)
$(info information message)
These commands can be inserted into your Android.mk or Application.mk.
There are some differences among the above three commands. The $(info) command simply prints out some information, like printf() in C. The $(warning) command not only prints information, but also inserts the line number indicating where this warning is generated. The $(error) command will print information and also stop the subsequent building process. Normally, $(info) is enough for us to show information we are interested, for example, if we want to check the current LOCAL_PATH variable, we can do the following:
$(info LOCAL_PATH=$(LOCAL_PATH))
Or, if we defined a variable in our Android.mk file, we can check the value of it too:
BUILD_MODE:=NATIVE_MODE
DEVICE_NAME:=NEXUS-5
$(info BUILD_MODE is $(BUILD_MODE) for device $(BUILD_MODE))
Another approach
Another useful approach to help debug the makefiles is use the V=1 option for ndk-build. For example, in our example 5, if we use ndk-build V=1, the following is what we will see:
$ ndk-build NDK_APPLICATION_MK=./MyApplication.mk V=1
[Robert: Here I have skipped some print information…]
[armeabi-v7a] Executable : hello
/cygdrive/d/development/android-ndk-r10d/toolchains/arm-Linux-androideabi-4.8/prebuilt/windows-x86_64/bin/arm-linux-androideabi-g++ -Wl,–gc-sections -Wl,-z,nocopyreloc –sysroot=D:/development/android-ndk-r10d/platforms/android-19/arch-arm -Wl,-rpath-link=D:/development/android-ndk-r10d/platforms/android-19/arch-arm/usr/lib -Wl,-rpath-link=./obj/local/armeabi-v7a ./obj/local/armeabi-v7a/objs-debug/hello/src/hello.o D:/development/android-ndk-r10d/sources/cxx-stl/stlport/libs/armeabi-v7a/thumb/libstlport_static.a -lgcc -no-canonical-prefixes -march=armv7-a -Wl,–fix-cortex-a8 -Wl,–no-undefined -Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now -fPIE -pie -lc -lm -o ./obj/local/armeabi-v7a/hello
[armeabi-v7a] Install : hello => libs/armeabi-v7a/hello
install -p ./obj/local/armeabi-v7a/hello ./libs/armeabi-v7a/hello
/cygdrive/d/development/android-ndk-r10d/toolchains/arm-linux-androideabi-4.8/prebuilt/windows-x86_64/bin/arm-linux-androideabi-strip –strip-unneeded ./libs/armeabi-v7a/hello
The most useful part is the line starting with /cygdrive/d/development/android-ndk-r10d/toolchains/arm-linux-androideabi-4.8/prebuilt/windows-x86_64/bin/arm-linux-androideabi-g++. This basically shows what command line is exactly used to build the target output. You can see all the compilation and linking information here. For a complicated projects, especially the ones with embedded makefiles, this would allow us to check if the path for the libraries, path to the header files, and project dependencies are correctly set.
5.7 Build for multiple target architectures
Finally, we will discuss the method to build binaries for multiple target ABIs (ABI=Architecture Binary Interface). This will be useful when you want to release your native program for multiple platforms, such as ARM-v5, ARM-v7, x86 and so on. This will be extremely useful when building a native JNI shared library for an Android application, since in that case, you need to seriously consider how you publish and release your final applications. And the method you build your native libraries and release them will affect the compatibility of your applications.
For a typical project without linking external/3rd-party shared libraries, this could be achieved easily by setting the APP_ABI variable in the Application.mk. This has been discussed in details in the official NDK documents: Android Native CPU ABI Management. Basically, we can choose the target ABI in just one line in Application.mk:
APP_ABI := armeabi armeabi-v7a mips x86
You can simply set APP_ABI := all to build native code for all the above target ABIs.
This method works perfectly for the projects in which you do not rely on any external libraries. Often times, we need to link some existing libs to our projects. That means there will chances that the external libs are provided in binary format. This will make the problem a little tricky. In these cases, we need to detect the current target APP_ABI for the current build, and then perform the right operations according to that architecture.
I will use a very practical problem I have met as an example to show why this is nontrivial. The problem I met was during my development of an Android application with OpenCL-accelerated native code. (For those who don’t know OpenCL: OpenCL is a open spec for heterogeneous computing maintained by Khronos Group; most of the modern desktop CPUs, GPUs and the latest generation of mobile GPUs, support OpenCL, therefore, you can utilize OpenCL to harness the power of GPU’s parallel architecture to accelerate your algorithms.). The way the OpenCL works is that the SoC chip vendors implement the OpenCL software stack including drivers, compilers in shared libraries. The problem here is that for different devices with application SoC chipsets provided by different vendors, the OpenCL support is enabled by the driver library, which is typically a shared library residing in the directories such as /system/vendor/lib, or /system/lib, or some other directories. Therefore, for different chipsets used in the mobile devices (smartphones or tablets), the shared library will be very different. A problem of building OepnCL program is that you need to dynamically link to these OpenCL driver library, such that the OpenCL API functions called by your application can be resolved during linking time.
The following table shows the OpenCL libraries for mobile GPU from a few major mobile SoC chip vendors.
SoC Chipset GPU Arch OpenCL library
Samsung Exynos (5420 or 5433) ARM Mali T628 or T760 ARM /system/vendor/lib/egl/libGLES_mali.so
Qualcomm Snapdragon 800, 801, 805, 810 Adreno A330, A420, A430 ARM /system/vendor/lib/libOpenCL.so
Intel Atom Z3560 PowerVR G6430 x86 /system/vendor/lib/libPVROCL.so
As we can easily see that the shared libraries are located in different places in the Android system, with different names, and even with different architectures. If you want to build an application support all the above platforms, build the native part with correct dynamic linking with be crucial.
We can achieve this goal by using the TARGET_ARCH variable or TARGET_ARCH_ABI variables, which indicate the current target architecture or target architecture ABIs.
The TARGET_ARCH and TARGET_ARCH_ABI is slightly different: TARGET_ARCH reports the architecture name; while TARGET_ARCH_ABI reports architecture along with the instruction set version. For example, if the current APP_ABI is “armeabi-v7a”, the TARGET_ARCH will only show “arm”, while the TARGET_ARCH_ABI will be “armeabi-v7a”. Keep this in mind, so that you can take full advantage of these two variables.
The following is a example in which we build a native program (or shared library used by JNI) targeting at the support of multiple architectures:
ifeq ($(TARGET_ARCH),x86)
GPU_FAMILY=powervr
OPENCL_LIB := PVROCL
OPENCL_INC_DIR := $(OPENCL_COMMON)/include/CL12/
OPENCL_LIB_DIR := $(OPENCL_COMMON)/libs/$(GPU_FAMILY)_$(TARGET_ARCH)/
LOCAL_LDLIBS := -llog -L$(OPENCL_LIB_DIR) -l$(OPENCL_LIB)
MYMODULE_NAME:=mymodule_$(GPU_FAMILY)
include OTHER_MK_FILES.mk
endif
ifeq ($(TARGET_ARCH),arm)
#Adreno
GPU_FAMILY:=adreno
OPENCL_LIB := OpenCL
OPENCL_INC_DIR := $(OPENCL_COMMON)/include/CL12/
OPENCL_LIB_DIR := $(OPENCL_COMMON)/libs/$(GPU_FAMILY)_$(TARGET_ARCH)/
LOCAL_LDLIBS := -llog -L$(OPENCL_LIB_DIR) -l$(OPENCL_LIB)
MYMODULE_NAME:=mymodule_$(GPU_FAMILY)
include OTHER_MK_FILES.mk
#Mali
GPU_FAMILY:=mali
OPENCL_LIB := GLES_mali
OPENCL_INC_DIR := $(OPENCL_COMMON)/include/CL11/
OPENCL_LIB_DIR := $(OPENCL_COMMON)/libs/$(GPU_FAMILY)_$(TARGET_ARCH)/
LOCAL_LDLIBS := -llog -L$(OPENCL_LIB_DIR) -l$(OPENCL_LIB)
MYMODULE_NAME:=mymodule_$(GPU_FAMILY)
include OTHER_MK_FILES.mk
endif
This is part of the complete makefile, which only shows the section of using TARGET_ARCH variable. After this section, we will include a few makefiles to perform the regular build process. Please notice that the $(OPENCL_COMMON) folder contains the shared libraries for different target architectures (we can adb pull them from the actual devices).
By doing this, we basically achieve a few different versions of the native code. Assume the module name for the native program is “mymodule”. Then the final generated binary files will have the following structure.
+– libs
| +– armeabi-v7a
| +– libmymodule_adreno.so
| +– libmymodule_mali.so
| +– x86
| +– libmymodule_powervr.so
These libraries will be packed into the Android APK installer. During installation, the correct architecture version will be installed based on the primary ABI property (please read here for details) of the given device.
In our example, if we install the APK on an device with ARM CPU, the first two .so files will be installed. In your program, you can easily detect the device chip vendor, and load the corresponding shared library. If the APK is installed on a x86 device, then the only x86 library will installed and will be loaded automatically when the application executes.
Of course, you need to call System.loadLibrary() function in your java code or link the shared library dynamically using dlopen() and dlsym() functios in the native code, but once we get the above libraries, the effort of loading the correct libraries becomes trivial.
In this article, we have covered the basics of ndk-build, followed by the discussion of some semi-hidden features or techniques in the ndk-build system, which I personally think are quite useful. I hope this article is useful for Android NDK developers.
Although every single techniques might be easy and straightforward, when you combine them all together, I am sure that you will be able to create some not only powerful but also efficient build script. Please download the examples from github and give them a try, what you need is only the Android NDK, and an Android device (or Virtual devices such as Genymotion).
If you think the article is useful, or if you think there is room I could improve. Please comment.
Or if you have some even better techniques I do not cover, please also leave a comment.