Debugging native code on Android requires specialized tools and techniques. This guide covers debugging crashes, setting breakpoints, and inspecting native code execution.
The NDK provides several debugging tools:
- Android Studio Debugger - Integrated debugging with source-level stepping
- LLDB - Command-line debugger for native code
- ndk-stack - Symbolicate native stack traces from logcat
- GDB - Alternative debugger (deprecated, use LLDB instead)
For most developers, the Android Studio debugger provides the best debugging experience with native code support.
Preparing your build for debugging
Enable debuggable builds
In your AndroidManifest.xml:
<application
android:debuggable="true"
...>
</application>
Or in build.gradle:
android {
buildTypes {
debug {
debuggable true
jniDebuggable true
}
}
}
Include debug symbols
For CMake builds, in CMakeLists.txt:
# Add debug symbols
set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} -O0 -g")
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -O0 -g")
# Keep symbols in separate files (reduces APK size)
set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--build-id")
For ndk-build, in Android.mk:
LOCAL_CFLAGS := -O0 -g
APP_OPTIM := debug
Debug builds with symbols are significantly larger. Use separate debug and release configurations.
Debugging with Android Studio
Setting up native debugging
Configure debug type
In Run/Debug Configuration, set Debug type to Dual (Java + Native) or Native.
Set breakpoints in native code
Open your C/C++ source file and click in the gutter next to the line number to set a breakpoint.void processData(int* data, int size) {
for (int i = 0; i < size; i++) { // <- Set breakpoint here
data[i] = data[i] * 2;
}
}
Start debugging
Click the Debug button or press Shift+F9. The debugger will attach to your app and pause at breakpoints.
Inspect variables and step through code
Use the debugger controls:
- Step Over (F8) - Execute current line
- Step Into (F7) - Step into function calls
- Step Out (Shift+F8) - Return from current function
- Resume (F9) - Continue execution
Evaluating expressions
In the Variables pane, right-click and select Evaluate Expression or press Alt+F8:
// Evaluate:
data[0] + data[1]
strlen(myString)
&myVariable // Get address
Conditional breakpoints
Right-click a breakpoint and add a condition:
i > 100
strstr(name, "error") != NULL
ptr != nullptr && ptr->value == 42
Using LLDB from command line
Attach LLDB to running app
# Find the app's process ID
adb shell ps | grep your.package.name
# Forward LLDB server port
adb forward tcp:5039 tcp:5039
# Start LLDB server on device
adb shell /data/local/tmp/lldb-server platform --listen "*:5039" --server
# On your development machine, start LLDB
lldb
In the LLDB prompt:
(lldb) platform select remote-android
(lldb) platform connect connect://localhost:5039
(lldb) attach <pid>
Setting breakpoints in LLDB
# Break at function
(lldb) breakpoint set --name processData
(lldb) br s -n processData # Shorthand
# Break at file:line
(lldb) breakpoint set --file myfile.cpp --line 42
(lldb) br s -f myfile.cpp -l 42 # Shorthand
# Break at address
(lldb) breakpoint set --address 0x7f8a4c2010
# Conditional breakpoint
(lldb) breakpoint set -n processData -c "size > 100"
# List breakpoints
(lldb) breakpoint list
Inspecting variables
# Print variable
(lldb) print myVariable
(lldb) p myVariable # Shorthand
# Print as specific type
(lldb) print (int)myVariable
# Print memory
(lldb) memory read --size 4 --count 10 0x7f8a4c2010
(lldb) x/10x 0x7f8a4c2010 # Shorthand
# Print array
(lldb) parray 10 myArray
# Print string
(lldb) print (char*)myString
Stepping through code
# Continue execution
(lldb) continue
(lldb) c # Shorthand
# Step over
(lldb) next
(lldb) n # Shorthand
# Step into
(lldb) step
(lldb) s # Shorthand
# Step out
(lldb) finish
Examining the call stack
# Show backtrace
(lldb) thread backtrace
(lldb) bt # Shorthand
# Show all threads
(lldb) thread list
# Select thread
(lldb) thread select 2
# Show frame variables
(lldb) frame variable
(lldb) fr v # Shorthand
Analyzing crashes with ndk-stack
ndk-stack symbolizes native stack traces from logcat output.
Capture crash log
# Capture logcat to file
adb logcat > crash.log
# Wait for crash, then Ctrl+C
Symbolicate with ndk-stack
# Point to your libraries with debug symbols
export NDK_PATH=/path/to/ndk
$NDK_PATH/ndk-stack -sym /path/to/obj/local/arm64-v8a -dump crash.log
Output shows source file locations:
stack frame #00 pc 00012345 libmyapp.so (processData+42) myfile.cpp:123
stack frame #01 pc 00023456 libmyapp.so (main+100) main.cpp:456
Use ndk-stack even when you don’t have the device physically available - just save the logcat output.
Understanding tombstones
When native code crashes, Android creates a tombstone file with detailed crash information.
Retrieve tombstones
# List tombstones
adb shell ls -l /data/tombstones/
# Pull latest tombstone
adb pull /data/tombstones/tombstone_00 .
Tombstone contents
A tombstone includes:
- Signal that caused the crash (SIGSEGV, SIGABRT, etc.)
- Registers at time of crash
- Stack trace
- Memory near the crash address
- Thread information
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
r0 00000000 r1 00000001 r2 7f8a4c20 r3 00000000
...
backtrace:
#00 pc 00012345 /data/app/libmyapp.so (processData+42)
#01 pc 00023456 /data/app/libmyapp.so (main+100)
Common crash signals
- SIGSEGV - Segmentation fault (null pointer, invalid memory access)
- SIGABRT - Abort called (often from assertions or memory corruption)
- SIGILL - Illegal instruction (corrupted code or wrong architecture)
- SIGFPE - Floating point exception (division by zero)
- SIGBUS - Bus error (misaligned memory access)
Null pointer dereferences are the most common cause of native crashes. Always validate pointers before use.
Common debugging scenarios
Debugging JNI calls
Enable CheckJNI to catch JNI errors:
adb shell setprop debug.checkjni 1
Common JNI errors:
// Bad: Using local reference after function returns
JNIEXP jobject getObject(JNIEnv* env) {
jclass cls = env->FindClass("com/example/MyClass");
jobject obj = env->AllocObject(cls);
return obj; // Local reference, will be invalid
}
// Good: Create global reference
JNIEXP jobject getObject(JNIEnv* env) {
jclass cls = env->FindClass("com/example/MyClass");
jobject obj = env->AllocObject(cls);
return env->NewGlobalRef(obj);
}
Debugging memory issues
Use AddressSanitizer to detect memory errors:
In build.gradle:
android {
defaultConfig {
externalNativeBuild {
cmake {
arguments "-DANDROID_STL=c++_shared",
"-DANDROID_ARM_MODE=arm"
cFlags "-fsanitize=address -fno-omit-frame-pointer"
cppFlags "-fsanitize=address -fno-omit-frame-pointer"
}
}
}
}
Debugging threading issues
Use Thread Sanitizer for race conditions:
cFlags "-fsanitize=thread"
cppFlags "-fsanitize=thread"
Sanitizers increase memory usage and reduce performance. Use only in debug builds.
Logging from native code
Using Android log
#include <android/log.h>
#define TAG "MyApp"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
void myFunction() {
LOGD("Debug message: value = %d", value);
LOGE("Error occurred: %s", strerror(errno));
}
Add to CMakeLists.txt:
find_library(log-lib log)
target_link_libraries(your-app ${log-lib})
Viewing logs
# Filter by tag
adb logcat -s MyApp
# Filter by priority (V=Verbose, D=Debug, I=Info, W=Warn, E=Error, F=Fatal)
adb logcat *:E # Show only errors
# Clear log buffer
adb logcat -c
Best practices
- Use debug builds - Include symbols and disable optimizations for debugging
- Log strategically - Add logs at key points, but not in tight loops
- Validate pointers - Check for null before dereferencing
- Enable sanitizers - Catch memory and threading issues during development
- Save crash logs - Always capture logcat when debugging crashes
- Test on real devices - Emulators may not reproduce device-specific issues
- Use assertions - Add
assert() calls to catch invalid assumptions early
Additional resources