If you're shipping Android apps with C or C++ native code, you know the pain: a crash report lands in your error tracker, and all you see is hex addresses and offsets. That's where Android NDK native crash symbolication ndk-stack tooling comes in. Unlike Java crashes, which map directly to your source, native crashes require you to map memory addresses back to function names and file locations using symbol tables. This guide walks you through symbolizing NDK crashes with the ndk-stack tool and setting up Firebase Crashlytics NDK support so your traces point to actual code.
Why Native Crashes Are Hard to Debug
When a C or C++ app crashes, the OS captures the instruction pointer, stack frames, and memory state—but the binary is compiled and stripped. The addresses mean nothing without the symbol table. Logcat gives you output like this:
#00 pc 0x0001a4c8 /data/app/com.example.myapp-xyz/lib/arm64-v8a/libnative.so
#01 pc 0x00019abc /data/app/com.example.myapp-xyz/lib/arm64-v8a/libnative.so
Without symbols, you're staring at memory offsets. With symbols, ndk-stack converts those addresses into readable function names, file paths, and line numbers. That's the difference between "crash at 0x1a4c8" and "division by zero in processFrame() at native.c:423".
Setting Up Build Artifacts
To symbolicate, you need the unstripped binaries or symbol files. By default, release builds strip symbols to reduce APK size, so you must preserve them separately.
For Android Studio / Gradle projects, add this to your build.gradle:
android {
buildTypes {
release {
debuggable false
ndk {
debugSymbolLevel 'full'
}
}
}
}
This instructs Gradle to keep a separate symbols directory. After building, your symbols live here:
build/intermediates/merged_native_debug_symbols/release/
Copy this directory somewhere safe—you'll need it to symbolicate crashes later. If you're already built and shipped without symbols, you can extract them from your build cache or gradle cache:
find ~/.gradle/caches -name "*.so" -path "*/symbols/*"
Using ndk-stack for Manual Symbolication
The NDK ships with ndk-stack, a tool that reads unstripped binaries and maps addresses back to source. It's in your NDK:
$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line
(Or use the wrapper script ndk-stack itself if available in your NDK distribution.)
To symbolicate a crash:
ndk-stack -sym build/intermediates/merged_native_debug_symbols/release/arm64-v8a < logcat.txt
ndk-stack reads the raw Logcat output (copy-paste from adb logcat or your crash report), matches the library and offset, looks up the symbol in your binary, and outputs human-readable names.
Example input:
#00 pc 0x0001a4c8 /data/app/com.example.myapp-xyz/lib/arm64-v8a/libnative.so
Example output after symbolication:
#00 pc 0x0001a4c8 /path/to/symbols/libnative.so (processFrame+124) [native.c:423]
If you're using Gradle 7.2+, symbol directories are auto-packaged. Check your build logs for "symbol directory" and ensure debugSymbolLevel is set to 'full' (not 'public', which omits internal symbols).
Firebase Crashlytics NDK Integration
Firebase Crashlytics has native crash support baked in. If you're already using Crashlytics, adding NDK support is straightforward:
1. Add the Crashlytics NDK dependency in your build.gradle:
dependencies {
implementation 'com.google.firebase:firebase-crashlytics-ndk:18.3.4'
}
2. Initialize in your Application class:
public class MyApp extends Application {
@Override
public void onCreate() {
super.onCreate();
FirebaseApp.initializeApp(this);
// Firebase Crashlytics auto-initializes NDK support
}
}
3. Upload symbols to Firebase Console:
After building a release, Gradle automatically uploads symbols if you're authenticated. If not, do it manually:
firebase crashlytics:symbols:upload \
--app=1:123456:android:abcdefg \
build/intermediates/merged_native_debug_symbols/release/
Firebase then matches incoming crashes to your symbols and serves you readable stack traces in the console. The big win: you get symbolication automatically on every crash, without running ndk-stack manually.
Integrating with Sentry / LightTrace
If you're using Sentry's Android SDK for error tracking, you can wire native crash capture to LightTrace by switching the DSN. The same SDK speaks both Sentry's protocol and LightTrace's:
import io.sentry.android.core.SentryAndroid;
SentryAndroid.init(this, options -> {
options.setDsn("https://<your-key>@light-trace.robomiri.com/1");
options.setAttachStacktrace(true);
// Sentry SDK auto-instruments JNI and handles native crashes
});
The SDK registers a native crash handler that captures the stack, attaches breadcrumbs, and sends it to your LightTrace instance. LightTrace then uses the same symbol lookup mechanism—it reads your uploaded symbols (from the same build artifacts) and delivers fully symbolicated traces.
LightTrace supports Sentry's native crash protocol. If you've already set up Sentry's @sentry/android, just change the DSN and ensure your symbols are uploaded to LightTrace's symbol store during or after your build.
Symbol Upload & Management
All the major error trackers—Firebase, Sentry, LightTrace—require symbols to live somewhere accessible. The workflow:
- Build your app with
debugSymbolLevel = 'full'. - Extract or copy symbols from
build/intermediates/merged_native_debug_symbols/or your NDK build output. - Upload them to your error tracker (via Gradle plugin, CLI, or upload endpoint).
- Tag each upload with the build version so crashes are matched correctly.
LightTrace's symbol store works just like Sentry's: POST your symbol archives (tar.gz or zip), tag them with release/version, and every incoming crash automatically looks up symbols by version and architecture.
Example upload via curl:
curl -X POST \
-H "Authorization: Bearer <token>" \
-F "file=@symbols.tar.gz" \
https://light-trace.robomiri.com/api/0/projects/org/project/releases/1.0.0/files/
Pro Tips
- Keep symbols for every release. Even if you never need them, losing them means lost debugging. Store them in a dedicated S3 bucket or artifact repository.
- Use architecture-specific directories. Crashes from armv7, arm64, x86, and x86_64 all need their own symbol sets.
- Test symbolication locally first. Run ndk-stack on a test crash before shipping so you're confident the symbols are correct and complete.
- Automate the upload. Add symbol upload to your CI/CD pipeline so it happens automatically on release builds—no manual step means no missed uploads.
The investment in proper symbolication pays off the first time a native crash lands in your inbox and you see the actual code that broke, not just hex offsets.
Start tracking errors in minutes
Debug native Android crashes with confidence. Start free on LightTrace today and get crash symbolication, native traces, and breadcrumb context—no setup required beyond changing your Sentry SDK's DSN.