Zygote
Builder trackintermediate

I08 — NDK hello

Call C++ from Kotlin through JNI: a native library, a CMake build, an ABI split, and the JNI naming rules that make the call work. Then watch what happens when the ABI is wrong.

3 minAPI 21–37 · 17verified 2026-10-27

JNI is the seam where managed code meets native code, and it is where most Android crashes that are not Java exceptions actually happen. A minimal project makes the seam visible and the failure modes concrete.

What you are building#

An app that calls a C++ function to compute a string and an integer, with a CMake build and a declared ABI. A second button calls a function that deliberately crashes, so you can see a native crash and its tombstone.

Steps#

  1. Add the NDK and CMake

    Install the NDK and CMake through the SDK manager, and point the module's build file at the CMake list. The build system generates the native build for each ABI.1

    You should seeThe SDK manager installs both.
  2. Write the C++ function

    Write a function with extern "C" and JNIEXPORT/JNICALL, returning a jstring built with env->NewStringUTF. The naming convention encodes the Java package and class.

    You should seeThe function compiles.
  3. The native side and the Kotlin declaration must agree on the name:

    extern "C" JNIEXPORT jstring JNICALL
    Java_com_example_ndkhello_MainActivity_greet(JNIEnv* env, jobject /* this */) {
        return env->NewStringUTF("hello from C++");
    }
    class MainActivity : ComponentActivity() {
        external fun greet(): String
     
        companion object {
            init { System.loadLibrary("hello") }
        }
    }
  4. Declare the Kotlin method

    Declare an external fun in the Kotlin class with the same name as the native symbol. Load the library in a companion object's init block.

    You should seeThe method resolves at runtime.
  5. Match the signature

    Ensure the native name is Java_<package>_<class>_<method> with underscores escaped. A mismatch is the most common JNI failure: the method throws UnsatisfiedLinkError at first call, not at load.

    You should seeA working call.
  6. Build for two ABIs

    Configure ndk.abiFilters for two architectures and inspect the APK. Each ABI has its own lib/<abi>/ directory, which is why APKs grow with native code.2

    You should seeTwo sets of native libraries in the APK.
  7. Inspect the APK

    Unzip the APK and list lib/. The class loader picks the directory matching the device's ABI; a missing one is a UnsatisfiedLinkError on that device only.

    You should seeA `lib/` directory with one subdirectory per ABI.
  8. Cause a native crash

    Dereference a null pointer in a native function. The app dies with a SIGSEGV, and the crash buffer has a tombstone with a backtrace.

    You should seeA tombstone and a process death.
  9. Read the tombstone

    Pull the tombstone or read the crash buffer. The backtrace names your function and the library, which is more information than a Java stack trace gives you.

    You should seeThe signal, the backtrace and the fault address.
Inspect and crash
unzip -l app/build/outputs/apk/debug/app-debug.apk | grep "lib/"
adb logcat -b crash -d
adb shell ls /data/tombstones
Example output
Illustrative output — paths and symbols differ.

Verification#

The working call should return the string and the integer, and the crash should produce a tombstone. The most instructive check is the ABI one: build for one ABI, install on a device with another, and observe the UnsatisfiedLinkError that only that device sees.

What you learned#

  • JNI is a naming and calling convention, not a compiler feature. The symbol name is the link.
  • ABI is part of the APK's identity. A missing ABI is a device-specific crash, not a build error.
  • Native crashes are process deaths. The evidence is a tombstone, not an exception.
  • The NDK is for code that must be native, not for making code faster by default.

Read JNI, the NDK and ABIs for the ABI table, and Memory safety for why new platform code is moving to Rust.

Extend it#

  1. Add a native thread and call back into Kotlin with AttachCurrentThread, then handle the detach correctly.
  2. Add a 16 KB page-size check to the build and see whether your native library is compatible.
  3. Add AddressSanitizer to a debug build and reproduce a buffer overflow with a useful report.

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsJNI tipsdeveloper.android.comaccessed 2026-10-27↩
  2. docsConfigure your builddeveloper.android.comaccessed 2026-10-27↩
  3. docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩
  4. docs16 KB page size: developer optionsource.android.comaccessed 2026-10-27↩
  5. docsMemory safetysource.android.comaccessed 2026-10-27↩