AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Android NDK Native Crash Debugging Guide

NFNourin Mahfuj Finick··10 min read

Android NDK native crash debugging is one of the most challenging aspects of mobile development. When your C++ code running through the Android NDK crashes, you're not dealing with friendly Java stack traces — you're facing raw signal handlers, memory dumps, and cryptic tombstone files that can leave even experienced developers scratching their heads. Unlike managed runtime crashes that produce readable stack traces with line numbers, native C++ crashes in Android manifest as low-level signals like SIGSEGV, SIGABRT, and SIGBUS, often buried inside .so shared libraries where symbol information has been stripped away during the build process. According to Google's Android NDK documentation, over 60% of performance-critical mobile applications now include native code, making native crash debugging an essential skill for modern Android engineers.

Android NDK native crash debugging becomes critical as soon as your app integrates C or C++ code — whether you're building a game engine with Unreal or Unity, processing audio with Superpowered or Oboe, running computer vision algorithms through OpenCV, or simply calling into a third-party native library. When these components fail, the resulting crash often bypasses your Java-level exception handlers entirely, terminating your app at the operating system level before any managed code can intervene. Bugspulse's crash reporting platform captures these native crashes alongside managed ones, but understanding how to interpret and fix them requires a specialized toolkit.

Understanding Android Native Crash Signals

When the Linux kernel detects a fatal fault in native code, it sends a signal to the offending process. On Android, the most common native crash signals are:

  • SIGSEGV (Signal 11) — Segmentation violation. Your code accessed memory it doesn't own. The most common native crash, typically caused by null pointer dereferences, use-after-free bugs, or buffer overflows that corrupt the heap.
  • SIGABRT (Signal 6) — Abort signal. Usually triggered by an explicit abort() call or a failed assertion (assert()). Often seen when malloc() detects heap corruption or when __cxa_throw encounters a corrupted exception table.
  • SIGBUS (Signal 7) — Bus error. Typically caused by unaligned memory access on ARM architectures, or attempting to access a memory-mapped file region that has been truncated.
  • SIGILL (Signal 4) — Illegal instruction. Your code tried to execute an invalid CPU instruction, often caused by compiler mismatches, corrupted function pointers, or executing data as code.
  • SIGFPE (Signal 8) — Floating-point exception. Integer division by zero is the most common trigger on ARM devices.

The Android linker also generates specialized error messages. According to the Android linker documentation, you'll encounter messages like dlopen failed: library "libfoo.so" not found or cannot locate symbol "_ZN3Foo3barEv" when shared libraries have compatibility issues.

Reading Android Tombstone Files

When a native crash occurs on Android, the system writes a tombstone file — a detailed crash report containing register dumps, stack traces, memory maps, and signal information. On Android 12 (API 31) and above, you can access tombstones programmatically through the ApplicationExitInfo API. For older versions and development debugging, tombstones are stored at /data/tombstones/ on rooted devices or emulators.

# Pull tombstones from a connected device
adb shell ls -la /data/tombstones/
adb pull /data/tombstones/tombstone_00 .

A tombstone file contains several critical sections. The signal header tells you which signal killed the process, including the fault address for SIGSEGV:

signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x00000000

The register dump shows the state of CPU registers at crash time, including the program counter (PC), stack pointer (SP), and link register (LR). The backtrace section is the stack trace, but in its raw form it only shows library offsets — not function names:

#00 pc 0001a2c4  /data/app/~~xxx==/com.example.app/lib/arm64/libnative.so
#01 pc 0001b830  /data/app/~~xxx==/com.example.app/lib/arm64/libnative.so

To make sense of these offsets, you need symbolication tools. This is where the real debugging begins, and it's fundamentally different from the Java/Kotlin symbolication we covered in our guide to mobile stack trace symbolication — native symbolication requires a separate toolchain.

Symbolicating Native Stacks with ndk-stack

The ndk-stack tool, bundled with the Android NDK, is the fastest way to convert those raw tombstone offsets into readable function names and source locations. It requires your unstripped .so files (the ones with debug symbols that you built, not the stripped versions in your APK).

# Basic ndk-stack usage
adb logcat | ndk-stack -sym ./app/build/intermediates/cmake/debug/obj/
 
# Or process a saved tombstone file
ndk-stack -sym ./obj/local/arm64-v8a/ -dump tombstone_00

The -sym flag points to the directory containing your unstripped shared libraries. ndk-stack reads the input line by line, looking for pc addresses, and resolves each one against the symbol tables in your libraries. The output transforms:

#00 pc 0001a2c4  libnative.so

Into something actionable:

#00 pc 0001a2c4  libnative.so  AudioEngine::processBuffer(float*, int)+124

Critical: ndk-stack only works if you've kept your debug symbols. If you're using Gradle with CMake, your unstripped .so files live under build/intermediates/cmake/ or build/intermediates/ndkBuild/. Set up your CI pipeline to archive these alongside your release artifacts — you'll need them when crashes happen in production.

For more advanced symbolication, the NDK provides aarch64-linux-android-addr2line (substituting the appropriate ABI). This tool resolves individual addresses and is more flexible for scripting:

$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \
  -e ./obj/local/arm64-v8a/libnative.so -f -C 0001a2c4
AudioEngine::processBuffer(float*, int)
/path/to/src/audio/AudioEngine.cpp:247

The -C flag demangles C++ symbol names, and -f shows function names alongside source locations. GNU addr2line documentation covers additional options for inlined function resolution.

Advanced Unwinding with libunwind and libunwindstack

While ndk-stack handles basic symbolication, complex crash scenarios require deeper stack unwinding. The libunwind library provides programmatic access to call stacks, and Android's own libunwindstack (part of AOSP) is specifically optimized for Android's process model.

When a crash occurs inside a signal handler — or when the stack has been corrupted — standard backtracing can produce incomplete or incorrect results. libunwindstack uses multiple unwinding strategies:

  1. DWARF-based unwinding using .eh_frame and .debug_frame sections — the most reliable method
  2. ARM exception handling tables (.ARM.exidx) — compact unwinding for ARM32
  3. Frame pointer unwinding — fallback when debug info is unavailable

For NDK developers building custom crash handlers, you can integrate libunwindstack directly:

#include <unwindstack/Unwinder.h>
 
void custom_crash_handler(int sig, siginfo_t* info, void* ucontext) {
    unwindstack::UnwinderFromPid unwinder(
        256, android::base::GetThreadId(), unwindstack::Regs::CreateFromUcontext(
            unwindstack::Regs::CurrentArch(), ucontext));
    
    unwinder.Unwind();
    
    for (const auto& frame : unwinder.frames()) {
        // Access frame.pc, frame.sp, frame.map_name, frame.function_name
        __android_log_print(ANDROID_LOG_ERROR, "NativeCrash",
            "#%02zu pc %016" PRIx64 "  %s (%s+%" PRIu64 ")",
            frame.num, frame.pc, frame.map_name.c_str(),
            frame.function_name.c_str(), frame.function_offset);
    }
}

This custom handler gives you programmatic access to every frame in the stack, which you can then forward to Bugspulse's native crash reporting endpoint for aggregation and analysis alongside your managed crashes.

Common Native Crash Patterns and Fixes

Null Pointer Dereference in Native Code

The most frequent native crash is dereferencing a null or invalid pointer. In C++, this often happens when object lifetimes aren't properly managed across JNI boundaries:

// BUG: Global reference deleted, but native pointer still valid
static jlong native_init(JNIEnv* env, jobject thiz) {
    auto* engine = new AudioEngine();
    return reinterpret_cast<jlong>(engine);
}
 
static void native_process(JNIEnv* env, jobject thiz, jlong ptr) {
    auto* engine = reinterpret_cast<AudioEngine*>(ptr);
    engine->process();  // CRASH if engine was deleted elsewhere
}

Fix: Use shared ownership with std::shared_ptr, or implement explicit lifecycle management with guard flags.

Use-After-Free in Threaded Native Code

When native objects are shared across threads without synchronization, use-after-free becomes a common source of SIGSEGV crashes:

// Thread A
void cleanup_engine(Engine* e) {
    delete e;  // Freed here
}
 
// Thread B (racing)
void process_audio(Engine* e) {
    e->mix_buffers();  // SIGSEGV if Thread A deleted e
}

Fix: Use std::atomic<std::shared_ptr<Engine>> (C++20) or mutex-guarded access patterns. Consider single-threaded ownership models where possible.

Stack Buffer Overflow in Native Array Operations

Native arrays have no bounds checking. A simple off-by-one error can corrupt the stack and produce confusing backtraces:

void process_samples(float* input, int num_samples) {
    float buffer[256];
    memcpy(buffer, input, num_samples * sizeof(float));  // No bounds check!
}

Fix: Use std::vector or std::array with bounds-checked .at() access. Enable -fsanitize=address during development builds to catch these at runtime.

Integrating Native Crashes with Your Crash Reporting Pipeline

A comprehensive crash reporting strategy for NDK-heavy apps requires capturing native crashes alongside Java/Kotlin exceptions. Here's how to set up a unified pipeline with Bugspulse:

First, install a native crash signal handler that complements your existing JVM-level exception handler:

#include <signal.h>
#include <bugspulse/native_crash.h>
 
static struct sigaction prev_sigsegv;
 
void native_signal_handler(int sig, siginfo_t* info, void* ucontext) {
    // Capture the native crash context
    bugspulse_capture_native_crash(sig, info, ucontext);
    
    // Forward to the previous handler or terminate
    if (prev_sigsegv.sa_handler != nullptr && 
        prev_sigsegv.sa_handler != SIG_DFL) {
        prev_sigsegv.sa_handler(sig);
    }
    _exit(1);
}
 
void install_native_crash_handler() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = native_signal_handler;
    sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
    sigaction(SIGSEGV, &sa, &prev_sigsegv);
    sigaction(SIGABRT, &sa, nullptr);
    sigaction(SIGBUS, &sa, nullptr);
    sigaction(SIGFPE, &sa, nullptr);
}

Use SA_ONSTACK with an alternate signal stack (sigaltstack()) to handle crashes caused by stack overflow. Without an alternate stack, a stack overflow crash itself can't run the signal handler because there's no stack space left.

Build System Configuration for Debuggable Native Code

Your CMake or ndk-build configuration determines how debuggable your native crashes are. Enable these settings for development and staging builds:

# CMakeLists.txt
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -g -O0 -fno-omit-frame-pointer")
set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g -O2 -fno-omit-frame-pointer")
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE} -Wl,--build-id=sha1")

Key flags explained:

  • -g: Include debug symbols. Keep these in your unstripped .so files archived separately from the APK.
  • -fno-omit-frame-pointer: Preserves frame pointers for reliable stack unwinding. Costs ~1-3% performance but dramatically improves crash debuggability.
  • -Wl,--build-id=sha1: Embeds a unique build ID in your library, making it trivial to match production crashes with the correct debug symbols.

For release builds going to the Play Store, you can enable split debug info:

set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g -gsplit-dwarf")

This creates separate .dwo debug files, keeping your APK small while preserving full debug capability for crash analysis.

Best Practices for NDK Crash Prevention

Preventing native crashes starts at the development level. Here are battle-tested practices from teams shipping NDK-heavy apps in production:

  1. Enable sanitizers during development. Google's AddressSanitizer (ASan) for Android NDK catches use-after-free, buffer overflows, and memory leaks at runtime. Add to your CMakeLists.txt: target_link_options(yourlib PUBLIC -fsanitize=address).

  2. Use UndefinedBehaviorSanitizer (UBSan) for catching integer overflow, null pointer dereference, and alignment violations: -fsanitize=undefined.

  3. Archive unstripped libraries per build. Store libnative.so (unstripped) alongside version codes and build IDs. When a production crash comes in with build ID a1b2c3d4, you can immediately find the matching symbols.

  4. Use the ANDROID_JNI logging pattern. Instead of raw __android_log_print, create a macro that includes file, line, and function:

#define LOG_CRASH(fmt, ...) \
    __android_log_print(ANDROID_LOG_ERROR, "Native", "[%s:%d] " fmt, \
        __FILE__, __LINE__, ##__VA_ARGS__)
  1. Implement incremental JNI binding. When transitioning from Java to native code, validate every JNI call's return value. A single failed FindClass() or GetMethodID() in a render loop produces thousands of crashes.

  2. Run native crash simulations in CI. Create a test suite that deliberately triggers each signal type and verifies your crash reporting pipeline captures it with correct symbolication.

Conclusion

Android NDK native crash debugging demands a different mindset from managed code debugging. You're working at the operating system level, interpreting low-level signals, reading tombstones, and manually symbolicating stack frames. But with the right toolkit — ndk-stack, addr2line, libunwindstack, and a unified crash reporting platform — native crashes become as debuggable as any Java exception.

The key to success is infrastructure: archive your unstripped .so files, embed build IDs in every library, and configure your crash reporting to capture native frames alongside managed ones. When the SIGSEGV arrives, you'll know exactly where it happened, in which function, on which line — and you'll have the data to fix it before your users notice.

Ready to bring native crash reporting to your NDK application? Start your free Bugspulse trial and get full native stack traces with automatic symbolication, cross-platform aggregation, and real-time alerting — all in one dashboard.