A native crash on Android or iOS leaves a forensic artifact, not just a dialog. On Android it's the tombstone; on iOS it's the Jetsam or exception crash log. Learn to read them and you can pinpoint a faulty JNI library, a stack overflow, or a use-after-free in minutes.
1. What a Tombstone Actually Is
On Android, when a native process dies from an unhandled signal, debuggerd intercepts it. The kernel notifies debuggerd via a ptrace connection, which freezes the crashing threads, unwinds their stacks, dumps registers, memory maps, and open file descriptors, then writes /data/tombstones/tombstone_XX.
This is fundamentally different from a Java/Kotlin exception. A Java crash is an uncaught exception handled by Thread.UncaughtExceptionHandler and reported by logcat. A tombstone is a POSIX signal that bypassed every handler. It means memory corruption, ABI violation, or deliberate abort() — not a null pointer in Kotlin.
On iOS, the equivalent is the crash report in Settings > Privacy & Analytics or Xcode Organizer. ReportCrash and the kernel's exc_handler capture Mach exceptions, BSD signals, and Jetsam memory kills. The format differs, but the anatomy — exception type, thread state, binary images, and backtrace — is identical in purpose.
If you work with NDK, game engines, or chiplet-based mobile SoCs where drivers are tightly coupled to user space, tombstones are your primary debug interface. Without symbols, they look like hex soup. With symbols, they are precise.
2. Anatomy of an Android Tombstone
Every tombstone follows the same header. Learn it once and you can triage any crash without a debugger attached.
| Feature | 5G NR | Wi-Fi 7 | Bluetooth 5.4 |
|---|---|---|---|
| Peak Throughput | 20 Gbps | 46 Gbps | 2 Mbps |
| Latency | 1–4 ms | 1–2 ms | 3–6 ms |
| Range | Several km | 30–50 m | 100–400 m |
| Power Draw | High | Medium | Ultra-low |
| Primary Use | Cellular data | Local networking | Peripherals/IoT |
Decoding the header line by line
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x10 — The most valuable line. Signal 11 is segmentation fault. SEGV_MAPERR means the address was not mapped. SEGV_ACCERR would mean permission fault. fault addr 0x10 is a classic null-pointer dereference with offset 16 (e.g., accessing field at offset 0x10 of a null object). If you see 0x00000000 with low offset, suspect a null this.
ABI: arm64, pc, sp, pstate — pc is the faulting instruction address. On ARM64, if pc is 4-byte aligned and points into executable .text, it's a real code fault. If pc is 0x0 or a heap address, you jumped to a corrupted function pointer.
backtrace — Already symbolized if libgame.so was built with -g and not stripped. RenderFrame+84 means 84 bytes into the function. This is accurate to the instruction. Compare this to a managed stack trace which only shows method names — here you get exact offsets.
Newer Android 12+ tombstones are protobuf-based and include open files, memory map, and tagged pointer info for MTE (Memory Tagging Extension). If MTE is enabled, you'll see SEGV_MTESERR or SEGV_MTEAERR — invaluable for detecting heap buffer overflows synchronously.
3. Signals: The Language of Native Crashes
Every native crash is a signal. Memorize the six you will see in production:
SIGABRT deserves special attention. It rarely means you called abort() directly. On Android, CHECK() from base/logging.h, failed FORTIFY checks, Scudo heap canaries, or __stack_chk_fail all raise SIGABRT and print a diagnostic in the tombstone's Abort message: field. That message often contains the exact C++ condition string — read it before the backtrace.
On modern ARM cores like the Cortex-X925, SIGBUS increasingly signals MTE violations. Synchronous MTE gives you the faulting address and allocation tag, letting you trace a heap overflow to the exact allocation site with HWASAN style reports. Asynchronous MTE only reports eventually — check Cause: [MTE] in the tombstone to distinguish.
4. Stack Traces: Unwinding Without Symbols
A raw backtrace is a list of program counters. Symbolication turns pc 00000000000123d4 into RenderFrame+84 at renderer.cpp:142. Three layers matter:
1. Unwinding: libunwindstack walks the stack using .eh_frame and .debug_frame (DWARF), or .ARM.exidx on 32-bit. If the stack is corrupted or you omitted -funwind-tables, unwinding stops early and you get a truncated trace. Always keep -funwind-tables even in release builds — cost is ~2% binary size.
2. Symbolization: Use ndk-stack or llvm-symbolizer with unstripped .so files:
3. Inline frames and tail calls: At -O2 the compiler inlines hot functions. The tombstone may skip them. Rebuild that library with -fno-omit-frame-pointer for the suspect module or capture a perf profile to correlate. For cloud gaming and latency-sensitive engines, frame-pointer omission is common — expect noisier traces.
iOS symbolication is similar but uses atos and dSYMs. The crash log lists Binary Images: with load addresses and UUIDs. Without the matching dSYM, atos -o MyApp.app/MyApp -l 0x100000000 0x100023d4 is your manual fallback.
5. Crash Log vs Tombstone vs ANR
Teams confuse three artifacts. Using the wrong one wastes hours.
| Artifact | Trigger | Where to Find | What It Tells You |
|---|---|---|---|
| Tombstone | Unhandled native signal (C/C++/Rust) | /data/tombstones/, Firebase Crashlytics NDK, Play Console | Registers, fault addr, native backtrace, memory maps. Ground truth for memory errors. |
| Java Crash Log | Uncaught Java/Kotlin exception | logcat, Crashlytics, dropbox/SYSTEM_TOMBSTONE | Exception type + managed stack trace. No registers. Symbolication via mapping.txt (R8). |
| ANR Trace | Input or Broadcast timeout (5s/10s) | /data/anr/traces.txt | All thread states + held locks. Shows main thread blocked, not crashed. Look for BLOCKED and waiting on <0x...>. |
| iOS Crash Log | Mach exception, signal, Jetsam, watchdog | Xcode Organizer, ips files | Exception codes, termination reason (Namespace JETSAM), thread backtraces, memory footprint. |
A real-world tip: if you see both a Java stack trace and a tombstone for the same PID close in time, the Java crash likely triggered a native abort inside libart (e.g., JNI GetStringChars on a null jobject). Fix the JNI boundary first.
6. Field-Tested Reading Workflow
Use this checklist for every tombstone. It takes under 10 minutes once practiced:
Step 1 — Classify the signal and fault address. fault addr 0x10 = null+offset. 0x7f... near stack top = stack overflow. 0x78... = heap address — check memory map for [heap] or [anon:scudo]. If address is 0x5a5a5a5a or 0xaaaaaaaa, it's a use-after-free poison value from allocator debugging.
Step 2 — Check the faulting thread vs main thread. A RenderThread or Binder:xxx crash points to threading. If tid != pid, you are off main. Cross-reference with backtrace of all threads at the end of the tombstone for deadlocks.
Step 3 — Symbolicate the top 3 frames. You only need the crash site and its caller. Ignore libart.so or libc.so frames unless the fault is inside them — then suspect bad arguments passed from your code.
Step 4 — Inspect the maps and registers. Is pc inside r-xp (executable) or rw-p (writable)? Writable + executable = JIT or corrupted. Check x0 — on ARM64 the first argument is often the null pointer. If x0 == 0 and fault addr is x0 + offset, confirm in source that argument is not null-checked.
Step 5 — Reproduce with sanitizers. Build with -fsanitize=address,hwaddress (HWASAN on ARM64) or run under MTE in sync mode. HWASAN will abort at the overflow with allocation and free stack traces — far richer than a tombstone. For memory pressure issues, correlate with RAM and low-memory killer tests to rule out Jetsam/LMK before blaming code.
Common pitfall: third-party crash SDKs install their own sigaction handlers and may swallow the signal before debuggerd. If tombstones disappear after adding a SDK, verify handler chaining with sigaction returning previous handler and calling it. Otherwise you lose register dumps forever.
Mastering tombstones turns native crashes from black boxes into surgical fixes. Keep symbols, keep unwind tables, enable MTE in testing, and always read the fault address before the stack.
Frequently Asked Questions
What is the difference between a tombstone and logcat crash?
Logcat shows Java/Kotlin uncaught exceptions with managed stack traces. A tombstone is generated by debuggerd for native POSIX signals (SIGSEGV, SIGABRT) and contains registers, memory maps, and unwound native backtraces. You need both: logcat for managed code, tombstones for NDK, drivers, and game engines.
Why does my tombstone show fault addr 0x00000000 or a small offset like 0x10?
This is a null-pointer dereference. Accessing a field at offset 0x10 of a null object yields fault addr 0x10. Check the register holding this or the first argument (x0 on ARM64) — it will be zero. Fix by null-checking at the JNI boundary or callee.
How do I symbolicate a stripped release build?
Keep unstripped .so files or breakpad symbols from your CI build. Use ndk-stack -sym <unstripped-dir> -dump tombstone or llvm-symbolizer --obj=unstripped_lib.so <pc>. For iOS, use the matching dSYM with atos -o MyApp -l <load_addr> <pc>. Without matching symbols you only get offsets.
Can a crash handler prevent tombstones from being written?
Yes. If an SDK installs a signal handler with sigaction and does not chain to the previous handler or re-raise, debuggerd never sees the signal. Always chain handlers or use debuggerd's tombstoned intercept. Test with kill -11 <pid> after integrating any crash reporter.