NDK Development Guide: Getting Started with Native Code

admin
admin

Gradle

What is the Android NDK?

The Android Native Development Kit (NDK) is a toolset that allows you to implement parts of your Android app using languages such as C and C++. It provides native libraries, cross-compilers, build tools, and debugging capabilities. Unlike the Android SDK, which relies on the Java Virtual Machine (JVM) and Kotlin, the NDK enables direct execution of compiled machine code on the device’s CPU. This is essential for computationally intensive tasks—gaming engines, real-time audio processing, computer vision, cryptography, and scientific computing—where Dalvik or ART overhead becomes a bottleneck.

The NDK does not replace the SDK. It complements it. Most applications remain primarily Java or Kotlin, with native modules invoked through the Java Native Interface (JNI). Understanding when to use native code is critical; misusing it can increase complexity, reduce portability, and introduce security vulnerabilities.

Prerequisites and Installation

Before writing native code, ensure your environment is properly set up. You need:

  • Android Studio (Arctic Fox or later recommended)
  • NDK and CMake (install via SDK Manager > SDK Tools > NDK & CMake)
  • LLDB for native debugging
  • A physical device or emulator with ARM (or x86/x86_64) support

To verify installation, open a terminal and run:

ndk-build --version

If you see version output, the NDK is available. Additionally, confirm CMake is installed:

cmake --version

Project Structure Overview

A standard NDK project contains:

app/
  src/
    main/
      java/          # Java/Kotlin source
      cpp/           # Native C/C++ source
      jniLibs/       # Prebuilt .so libraries (optional)
  build.gradle.kts   # CMake or ndk-build configuration
CMakeLists.txt       # CMake build script (if using CMake)

The cpp/ directory holds your .c and .cpp files. The build system compiles them into shared libraries (.so files) for each target ABI (Application Binary Interface)—ARM, ARM64, x86, x86_64. These .so files are then packaged into your APK.

Choosing a Build System: CMake vs. ndk-build

Google recommends CMake for new NDK projects. It offers better readability, cross-platform support, and integration with Android Studio’s IDE features (code completion, refactoring, linting). The legacy ndk-build (based on Android.mk and Application.mk) is still supported but should be avoided for new development.

CMakeLists.txt Example

cmake_minimum_required(VERSION 3.22.1)
project("mynativeapp")

add_library(
    mynative
    SHARED
    native-lib.cpp
)

find_library(
    log-lib
    log
)

target_link_libraries(
    mynative
    ${log-lib}
)

This script creates a shared library called libmynative.so from native-lib.cpp, linking it to the Android logging library.

Writing Your First Native Function

Create a file native-lib.cpp inside app/src/main/cpp/. Here is a minimal example:

#include 
#include 

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_mynativeapp_MainActivity_stringFromJNI(
    JNIEnv* env,
    jobject /* this */) {
    std::string hello = "Hello from C++ via NDK!";
    return env->NewStringUTF(hello.c_str());
}

Key points:

  • The function name follows the convention Java_{package_name}_{class_name}_{method_name}.
  • JNIEXPORT and JNICALL are macros required by JNI.
  • JNIEnv* provides access to JNI functions (creating strings, arrays, calling Java methods).
  • jobject is a reference to the calling Java object (or class, for static methods).

In your MainActivity.kt (or Java), declare the native method and load the library:

class MainActivity : AppCompatActivity() {
    companion object {
        init {
            System.loadLibrary("mynative")
        }
    }

    external fun stringFromJNI(): String

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById(R.id.textView).text = stringFromJNI()
    }
}

Gradle Configuration for NDK

In your module-level build.gradle.kts (or build.gradle), add:

android {
    compileSdk = 34
    defaultConfig {
        minSdk = 21
        ndk {
            abiFilters += listOf("arm64-v8a", "x86_64")
        }
    }
    externalNativeBuild {
        cmake {
            path = file("CMakeLists.txt")
            version = "3.22.1"
        }
    }
}

The abiFilters limits which architectures are built, reducing APK size. For development, include all common ABIs; for release, target only the device’s architecture.

Understanding JNI: The Bridge Between Java and C++

JNI is the protocol for communication. Every native function receives at least two arguments: JNIEnv* (interface pointer) and jobject (or jclass for static methods). Through JNIEnv, you can:

  • Access Java fields and methods: GetFieldID, GetStaticMethodID
  • Convert between Java and native types: NewStringUTF, GetStringUTFChars
  • Handle exceptions: ExceptionCheck, ExceptionClear
  • Manage global references to prevent garbage collection

Memory Warning: JNI does not automatically clean up local references. In loops or functions that create many local references, use PushLocalFrame/PopLocalFrame or manually delete references with DeleteLocalRef.

Data Type Mapping

Java TypeNative TypeJNI Signature
booleanjbooleanZ
bytejbyteB
charjcharC
shortjshortS
intjintI
longjlongJ
floatjfloatF
doublejdoubleD
voidvoidV
StringjstringLjava/lang/String;
Object[]jobjectArray[Ljava/lang/Object;

For arrays, use GetArrayElements and ReleaseArrayElements to access primitive arrays efficiently.

Building and Running

Connect a device or start an emulator. In Android Studio, click Run. The build system will:

  1. Run CMake to generate Makefiles for each ABI.
  2. Compile all native sources.
  3. Generate .so files in build/intermediates/ndkBuild/.
  4. Package them into the APK.

If you encounter a Library not loaded error at runtime, verify the System.loadLibrary call matches the library name (without the lib prefix and .so extension). Also check abiFilters match your device’s architecture.

Debugging Native Code

Debugging native crashes or logic errors requires LLDB. Android Studio integrates LLDB natively:

  • Set breakpoints in .cpp files.
  • Use the Debug tool window to inspect variables (C++ types, not Java equivalents).
  • View the native call stack for JNI transitions.

For memory issues (buffer overflows, use-after-free), enable AddressSanitizer (ASan) by adding to your CMakeLists.txt:

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
set(CMAKE_LINKER_FLAGS "${CMAKE_LINKER_FLAGS} -fsanitize=address")

Then, in your AndroidManifest.xml, add:


Common Performance Pitfalls

  1. Excessive JNI crossings: Every call from Java to C++ (or vice versa) incurs overhead. Batch work in native functions rather than calling them in loops.

  2. Global reference leaks: Every time you cache a jclass or jmethodID, pin it as a global reference. Failing to do so can cause garbage collection to reclaim the class, leading to crashes.

  3. Incorrect type casting: Using jdouble where jint is expected corrupts the stack. Use the exact JNI signatures.

  4. Neglecting exception handling: A Java exception thrown in native code will not be automatically cleared. Always check ExceptionCheck() after calling JNI functions.

ABIs and Performance

Android supports five primary ABIs: armeabi-v7a (32-bit ARM), arm64-v8a (64-bit ARM), x86 (32-bit Intel), x86_64 (64-bit Intel), and riscv64 (emerging). For modern apps, target arm64-v8a and x86_64. 32-bit ABIs are legacy; omitting them can reduce APK size significantly.

Each ABI corresponds to a different CPU instruction set. The NDK cross-compiler generates optimized machine code for each. Always test on the target architecture; x86 emulators are faster but ARM devices dominate the market.

Advanced Considerations

  • Prebuilt libraries: If you have third-party .so files, place them in app/src/main/jniLibs//. No JNI wrapper is needed if you call them from C++ only.

  • C++ exceptions and RTTI: Enable with -fexceptions and -frtti in CMakeLists.txt. Disabled by default to reduce code size.

  • Thread safety: JNI is not thread-safe per se. Each thread gets its own JNIEnv*. Use AttachCurrentThread and DetachCurrentThread when spawning native threads.

  • Shared libraries vs. static libraries: Static libraries compile into your .so, increasing its size. Shared libraries stay separate, enabling code reuse across modules but requiring careful linking.

Security and Best Practices

  • Never obfuscate native code as a substitute for encryption; native code can be reverse-engineered. Use NDK only for performance or legacy C/C++ libraries.
  • Validate all input from Java in native code to prevent buffer overflows.
  • Use -D_FORTIFY_SOURCE=2 and -O2 for release builds.
  • Strip debug symbols from release .so files using the strip tool in the NDK toolchain.

Testing Native Code

Write unit tests with Google Test (included in NDK). Place tests in app/src/test/cpp/ and configure a test executable in CMakeLists.txt using add_executable and enable_testing. For integration tests, use Android’s Instrumentation framework to call native methods from Java.

Resources and Toolchain

The NDK includes a standalone toolchain for cross-compilation outside Android Studio. Invoke it via:

$NDK/build/tools/make_standalone_toolchain.py --arch arm64 --api 34 --install-dir /tmp/my-toolchain

This is useful for CI pipelines or integrating existing C/C++ projects.

The latest NDK version r27 (as of 2026) supports LLVM 17, C++20, and improved LTO (Link-Time Optimization). Always check the release notes for deprecated functions and new features.

Leave a Reply

Your email address will not be published. Required fields are marked *