When println! isn't enough
Your Rust program crashes with a segfault, or worse, it silently produces wrong data. You've scattered println! statements everywhere, but the output is a mess of interleaved logs, and the bug only appears after thousands of iterations. You need to pause execution at the exact moment the state goes wrong, inspect the variables, and step forward one line at a time.
That's where LLDB comes in. It's the default debugger on macOS and a top choice on Linux. It lets you halt your Rust binary, peek inside memory, and control the flow of execution. You don't guess what's happening. You stop the program and look.
What LLDB actually does
LLDB stands for Low Level Debugger. It attaches to a running process or launches one. It reads debug symbols embedded in the binary. These symbols map machine code back to your source code. Without them, the debugger sees only addresses and hex values. With them, it knows you're in src/lib.rs line 42, inside calculate_total, and that x is a u64.
Think of your compiled binary as a black box with wires. LLDB is a set of probes that let you tap into those wires, freeze the electricity, and measure the voltage at any point. The debugger doesn't change how your code runs. It just gives you the ability to pause it and inspect it.
Rust's compiler emits DWARF debug information by default in the dev profile. This is the format LLDB understands. When you build with cargo build, the resulting binary contains a map from instructions to source lines, variable names, and type definitions. LLDB uses this map to translate raw memory into the structs and enums you wrote.
Minimal debugging session
Start with a simple program. You need a loop to step through and a variable to inspect.
/// Demonstrates a simple loop for debugging practice.
fn main() {
let mut counter = 0;
// Loop runs until counter reaches 5.
while counter < 5 {
counter += 1;
// Print current value to verify logic.
println!("Count: {}", counter);
}
}
Build the program. The default dev profile includes debug symbols and disables optimizations. Optimizations can reorder code or eliminate variables, making debugging confusing. The dev profile keeps the code close to what you wrote.
cargo build
Launch LLDB with the binary path. The binary lives in target/debug/. Replace your_binary_name with the actual name from your Cargo.toml.
lldb target/debug/your_binary_name
You're now in the LLDB REPL. Set a breakpoint on main. This tells LLDB to pause execution when the program enters the main function.
(lldb) break set -n main
Run the program. Execution stops immediately at the breakpoint.
(lldb) run
Step over the next line. next executes the current line and stops at the following line. It doesn't step into function calls.
(lldb) next
Print the variable. print evaluates an expression and shows the result.
(lldb) print counter
You'll see the value. Continue stepping and printing to watch the variable change. When you're done, quit LLDB.
(lldb) quit
Set the breakpoint before you run. If you run first, the program finishes before you can stop it.
Inspecting real Rust code
Real programs have structs, methods, and collections. LLDB handles Rust types natively. It knows how to print Vec, String, and custom structs.
/// A simple calculator that tracks operations.
struct Calculator {
history: Vec<String>,
}
impl Calculator {
/// Creates a new calculator with an empty history.
fn new() -> Self {
Calculator { history: Vec::new() }
}
/// Adds two numbers and records the operation.
fn add(&mut self, a: i32, b: i32) -> i32 {
let result = a + b;
// Record the operation in history.
self.history.push(format!("{} + {} = {}", a, b, result));
result
}
}
fn main() {
let mut calc = Calculator::new();
let sum = calc.add(10, 20);
println!("Result: {}", sum);
}
Build and launch LLDB. Set a breakpoint on the add method.
(lldb) break set -n Calculator::add
(lldb) run
When the breakpoint hits, inspect the local variables. frame variable lists all variables in the current stack frame. This is faster than printing each variable individually.
(lldb) frame variable
You'll see self, a, b, and result. Inspect self to see the struct fields.
(lldb) print self
LLDB shows the Calculator struct with the history vector. Print the vector to see its contents.
(lldb) print self.history
Community convention: Use frame variable to dump locals. It's the standard way to survey the current state. Use print for specific expressions or calculations.
Inspect the struct fields directly. The debugger sees the memory layout, not just the API.
Navigating the stack and memory
Bugs often hide deep in call chains. You need to see where execution came from. Use bt to print the backtrace. This shows every function that led to the current point.
(lldb) bt
The backtrace lists frames. Frame 0 is the current function. Frame 1 is the caller. Frame 2 is the caller's caller. Jump to a different frame to inspect its variables.
(lldb) frame select 1
Now you're in the caller. Use frame variable to see its locals. Navigate up and down the stack with up and down.
(lldb) up
(lldb) down
Community convention: Use bt full to see local variables in each frame. It gives context without switching frames.
Sometimes variables aren't enough. You need raw memory. Use memory read to dump bytes at an address. This is useful for FFI bugs or pointer arithmetic errors.
(lldb) memory read 0x7fffffffe000
Specify the number of bytes to read.
(lldb) memory read -c 32 0x7fffffffe000
Jump frames to inspect arguments. The caller holds the context you need.
Pitfalls and gotchas
Optimizations break debugging. If you build with --release, the compiler inlines functions, eliminates variables, and reorders instructions. Breakpoints might hit the wrong line or not at all. Variables might appear as garbage because they live in registers, not memory.
Keep optimizations off while debugging. Use cargo build for development. Use --release only when you are ready to ship.
Generics produce verbose type names. LLDB prints the full monomorphized type. A Vec<String> might show as std::vec::Vec<std::string::String>. This is normal. The debugger is showing the concrete type the compiler generated.
Closures capture their environment. A closure variable in LLDB looks like a struct containing the captured variables. Inspect the closure to see what it captured.
Panic handling requires setup. By default, LLDB doesn't catch Rust panics. Use catch RustPanic to stop execution when a panic occurs.
(lldb) catch RustPanic
This sets a catchpoint on the panic runtime function. When the program panics, LLDB stops and shows the backtrace.
Community convention: Add catch RustPanic to your LLDB init file. This ensures you catch panics automatically in every session.
Keep optimizations off while debugging. Optimized code lies to the debugger.
When to use LLDB versus alternatives
Use LLDB when you are on macOS or want a modern debugger with Python scripting support. Use GDB when you are on a Linux system where LLDB is unavailable or when debugging kernel modules. Use dbg! for quick, temporary inspection during development when you don't need to step through code. Use tracing or log crates for production debugging where you cannot attach a debugger. Use cargo expand when you need to see the desugared code to understand macro expansion or trait resolution.