How to Debug Rust Code Running on Mobile Devices

Debugging Rust code on mobile devices requires leveraging the native toolchains of your target platform (Android NDK or iOS Xcode) rather than relying solely on standard `cargo` commands.

When the phone crashes and cargo run won't help

Your Rust logic works perfectly on your laptop. You build the APK, sideload it onto your phone, and tap the button. The app crashes instantly. No stack trace. No error message. Just a silent failure or a native crash dialog. You're staring at a device that refuses to tell you what went wrong, and cargo run isn't an option.

This is the reality of mobile development. You cannot run Rust code directly on a phone using your local toolchain. The phone runs a different operating system, a different architecture, and a different process model. Debugging requires bridging that gap. You have to compile for the device, deploy the binary, and attach a debugger that understands both the mobile OS and Rust's type system.

The translator at the border

Debugging Rust on mobile is less about Rust and more about translation. Rust compiles to machine code. Android and iOS execute that machine code, but they manage memory, threads, and crashes using their own native tools. When a crash happens, the OS reports it in terms of memory addresses and native frames. Your code, however, lives in terms of structs, enums, and line numbers.

Think of the debugger as a translator at a border crossing. The OS speaks C or Swift. Your code speaks Rust. The debugger has to take the crash report from the OS and translate it back into Rust symbols so you can see which function panicked and what the variables contained. If the translation fails, you get a wall of hex addresses. If it succeeds, you get a readable stack trace pointing to the exact line of Rust code.

The quality of that translation depends on two things. First, you need debug symbols embedded in your binary. These symbols map machine instructions back to source code. Second, you need a debugger that knows how to interpret Rust's internal data structures. Plain debuggers often struggle with Rust enums, tuples, and smart pointers. You need tools that speak Rust.

Setting up the build for debugging

The first step is ensuring your build produces debuggable artifacts. The default cargo build command optimizes for development speed, which sometimes sacrifices debug quality. For mobile, you need to be explicit.

Create or update your Cargo.toml to enforce a profile that preserves all debug information.

[profile.dev]
# Generate full debug info. Level 2 includes line tables and variable locations.
debug = 2

# Disable optimizations. Optimizations reorder code and eliminate variables.
opt-level = 0

# Keep panic unwinding. Abort mode kills the process without a backtrace.
panic = "unwind"

The debug = 2 setting is the community standard for debugging. It tells the compiler to emit full DWARF debug info, including variable locations and line mappings. The debug = 1 setting is faster to build but can make variable inspection unreliable because the compiler may omit location data for local variables.

The opt-level = 0 setting disables optimizations. Optimizations can reorder instructions, inline functions, and eliminate variables that the compiler thinks are unused. This makes stepping through code confusing and can cause the debugger to show stale values. Keep optimizations off while debugging.

The panic = "unwind" setting ensures that panics generate a stack trace. The alternative, panic = "abort", terminates the process immediately. This saves binary size but destroys your ability to see where the panic occurred. Use abort only for final release builds where size is critical and you have crash reporting set up.

Convention aside: RUST_BACKTRACE=1 is the environment variable that enables backtraces. On mobile, setting environment variables is harder than on a desktop. You often need to inject them via the deployment tool or rely on the debugger to catch the panic before it prints.

Debugging on Android

Android uses the Linux kernel, which makes debugging somewhat familiar if you have used gdb or lldb before. The workflow involves building for the ARM architecture, pushing the binary to the device, and attaching a debugger.

Start by building for the target architecture. Modern phones use aarch64.

# Build for ARM64 Android. The --debug flag is redundant but explicit.
cargo build --target aarch64-linux-android --debug

# Push the binary to a temporary directory on the device.
adb push target/aarch64-linux-android/debug/my_app /data/local/tmp/

# Run the app in the background. The ampersand returns control to the shell.
adb shell /data/local/tmp/my_app &

Once the app is running, find its process ID and attach the debugger.

# Find the process ID. The grep filters for your app name.
adb shell ps | grep my_app

# Attach rust-lldb to the process. Use rust-lldb, not lldb, for Rust type support.
adb shell rust-lldb -p <PID>

The command adb shell rust-lldb -p <PID> attaches the debugger to the running process. You must use rust-lldb, not plain lldb. The rust-lldb binary includes pretty-printers for Rust types. Without them, lldb will display Vec<i32> as a raw pointer and Result<T, E> as an opaque struct. rust-lldb shows the actual contents.

Convention aside: rust-lldb is the community standard for Rust debugging. It is installed via rustup component add rust-lldb. If you see raw bytes when inspecting variables, you are likely using the wrong debugger.

Once attached, you can use standard debugger commands.

(lldb) bt
# Prints the backtrace. Shows Rust function names and line numbers.

(lldb) frame variable
# Prints local variables in the current frame. Respects Rust types.

(lldb) print data
# Evaluates an expression. Useful for inspecting complex structs.

If the app crashes before you can attach, you need logs. Android logs go to logcat. Set RUST_BACKTRACE=1 in the app's launch configuration to print stack traces to the log.

Don't fight the architecture. Verify your target triple matches the device. Building for x86_64 and pushing to an ARM phone results in a "cannot execute binary file" error.

Debugging on iOS

iOS debugging is tightly integrated with Xcode. The workflow involves building a Rust framework, linking it into an Xcode project, and using Xcode's debugger.

Use cargo-lipo to build a universal binary that includes both simulator and device architectures. Xcode expects this format.

# Build a universal binary for iOS. cargo-lipo combines architectures.
cargo lipo --target aarch64-apple-ios --debug

The output is a .dylib or .a file. Add this file to your Xcode project. You also need the .dSYM files generated during the build. These files contain the debug symbols.

In Xcode, go to Build Settings and set "Debug Information Format" to "DWARF with dSYM File". This ensures Xcode generates its own dSYM files and links them correctly.

Convention aside: dSYM files are the source of truth for symbols. If you lose the dSYM, you lose the ability to map addresses to line numbers. Store dSYM files in version control or a build artifact store. Never delete them after building.

Run the app in Xcode. When it crashes, Xcode stops at the crash point. You can inspect variables using the debugger console.

(lldb) po my_struct
# Prints the object. Xcode's lldb has some Rust support built-in.

(lldb) frame variable
# Prints local variables. May require rust-lldb for full type support.

Xcode's debugger is good, but it can struggle with complex Rust types. If you need deep inspection, consider using rust-lldb in the terminal and attaching to the simulator process.

Trust the dSYM files. If they're missing, your debug session is blind.

Realistic scenario: The FFI bridge

Most mobile apps use Rust for logic and a native language for the UI. You call Rust from Kotlin or Swift via FFI. This adds complexity to debugging.

/// Exports a function to be called from Kotlin or Swift.
#[no_mangle]
pub extern "C" fn rust_calculate(input: *const u8, len: usize) -> i32 {
    // SAFETY: Caller must provide a valid pointer and length.
    // 1. `input` must point to valid memory for `len` bytes.
    // 2. `len` must not exceed the allocated buffer size.
    unsafe {
        // Convert raw pointer to a safe slice.
        let data = std::slice::from_raw_parts(input, len);
        // Process data...
        data.len() as i32
    }
}

When this function crashes, the stack trace might show rust_calculate. You need to see the Rust frame. Ensure your build includes debug symbols. If the crash happens inside the unsafe block, the debugger will stop at the line with the error.

Common pitfalls include mismatched types between Rust and the native side. If Kotlin passes a byte[] but Rust expects a u8*, the memory layout might differ. Use #[repr(C)] structs for FFI to ensure stable layouts.

If you encounter "no debug info" errors, check your profile. The compiler optimized the variables away. Also check that you haven't stripped the binary. The strip command removes debug symbols. Never strip a debug build.

Treat panic = "abort" as a last resort. It saves bytes but destroys your ability to debug.

Decision matrix

Use rust-lldb when you need to inspect Rust types like enums, tuples, or Vec on Android. Plain lldb often displays these as raw bytes or opaque structs. Use Xcode's integrated debugger when you are developing an iOS app with a Swift or Objective-C frontend. Xcode handles the symbolication of .dSYM files automatically and lets you step through Rust code alongside Swift. Use RUST_BACKTRACE=1 when the app crashes before you can attach a debugger. This prints a stack trace to the log, which is often enough to locate the panic source. Use cargo-lipo when building for iOS to generate universal binaries that include both simulator and device architectures. Xcode expects this format for seamless integration.

Where to go next