When println! isn't enough
Your Rust program crashes with a segmentation fault, and the backtrace points to assembly code you don't recognize. Or you're stuck in an infinite loop and println! is flooding your terminal with garbage that masks the real issue. You need to pause execution, inspect the values of variables that aren't printing correctly, and step through the code one instruction at a time. That's where GDB comes in.
GDB is the GNU Debugger. It attaches to your binary and gives you control over the CPU. You can freeze the program, look at memory, modify variables on the fly, and nudge execution forward. It works for any compiled language, but Rust has some quirks that make debugging slightly different from C or Python.
GDB as a microscope
Think of GDB as a microscope for your running program. Normally, code executes too fast to see what's happening inside. GDB lets you freeze time, zoom in on specific variables, and move forward one step at a time. It doesn't change your code; it just gives you eyes inside the machine.
Rust's borrow checker prevents many bugs at compile time, but it can't catch logic errors, race conditions in unsafe blocks, or bugs in third-party C libraries. GDB is your tool for those runtime mysteries. It also helps you understand how Rust code maps to machine instructions, which is useful when you're optimizing performance or debugging FFI boundaries.
Setting up the environment
Rust compiles to native binaries, so GDB works directly on the output. Cargo handles the build process, and by default, it includes debug symbols in the debug profile. You don't need special flags to get GDB working.
// src/main.rs
/// A simple function to demonstrate debugging.
fn calculate_sum(numbers: &[i32]) -> i32 {
let mut total = 0;
for num in numbers {
total += num;
}
total
}
fn main() {
let data = vec![10, 20, 30, 40];
let result = calculate_sum(&data);
println!("Result: {}", result);
}
Build the project and launch GDB on the binary. Cargo puts the binary in target/debug.
# Build with debug info. Cargo does this by default in debug mode.
cargo build
# Launch GDB on the binary.
gdb ./target/debug/your_binary_name
Inside GDB, you'll see a prompt like (gdb). This is where you type commands. The convention in the Rust community is to use rust-gdb instead of plain gdb. rust-gdb is a wrapper that automatically loads pretty-printers for Rust types. If you have Rust installed via rustup, rust-gdb is likely available. Using it saves you from sourcing scripts manually and makes types like Vec and String readable.
# Use rust-gdb if available. It auto-loads pretty-printers.
rust-gdb ./target/debug/your_binary_name
Don't fight the wrapper. rust-gdb is the standard way to debug Rust.
Basic navigation
Once GDB is running, you need to tell it where to stop and how to run. Breakpoints pause execution at specific lines. You can set a breakpoint on a function name or a line number.
# Set a breakpoint on the main function.
(gdb) break main
# Run the program. It will stop at the breakpoint.
(gdb) run
When the program hits the breakpoint, GDB stops execution and shows you the current line. You can now inspect variables and step through code.
The next command executes the current line and moves to the next line. It steps over function calls. If the current line calls a function, next runs the entire function and stops at the line after the call.
The step command also executes the current line, but if it's a function call, step enters the function and stops at the first line inside it. Use step when you want to dive into a function's implementation. Use next when you trust the function and just want to move forward.
# Step over the next line.
(gdb) next
# Step into the next line if it's a function call.
(gdb) step
# Print the value of a variable.
(gdb) print total
# Continue execution until the next breakpoint or exit.
(gdb) continue
The print command evaluates an expression and shows the result. You can print variables, call functions, or do arithmetic. GDB keeps a history of prints, so you can reference previous results with $1, $2, etc.
# Print the length of the data vector.
(gdb) print data.len()
# Reference the previous print result.
(gdb) print $1 * 2
The backtrace command (or bt) shows the call stack. It lists all the functions currently active, from the current frame back to main. This is essential for understanding how the program reached its current state.
# Show the call stack.
(gdb) backtrace
Trust the backtrace. It tells the story of the crash.
Inspecting complex types
Rust types like Vec, String, and HashMap are structs with pointers and metadata. Without help, GDB shows you the raw memory layout: a pointer to the data, a length, and a capacity. This is hard to read.
Pretty-printers transform this raw data into a readable format. The rust-gdb script includes pretty-printers for all standard library types. If you're using plain gdb, you need to source the script manually.
# Source the pretty-printer script if using plain gdb.
(gdb) source ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/etc/gdb_load_rust_pretty_printers.py
For custom types, you can provide your own pretty-printer script. The #[debugger_visualizer] attribute tells GDB where to find the script. This is useful for complex domain types that need special formatting.
// src/main.rs
/// A custom point type with a pretty-printer script.
#[debugger_visualizer(gdb_script_file = "debugger/point_gdb.py")]
struct Point {
x: f64,
y: f64,
}
In GDB, source the script to enable the printer.
# Load the custom pretty-printer.
(gdb) source debugger/point_gdb.py
The community convention is to keep pretty-printer scripts in a debugger directory or use the rust-gdb wrapper. Writing custom printers is rare; most of the time, the standard library printers cover what you need. If you're debugging a crate with complex types, check if the crate provides a pretty-printer script.
Source the pretty printers. Reading raw memory structs is a waste of time.
Debugging release builds
By default, Cargo strips debug symbols and enables optimizations in the release profile. Optimizations can inline functions, reorder code, and keep variables in CPU registers. This makes the binary faster but harder to debug. GDB might report "value optimized out" because the variable doesn't exist in memory anymore.
If you need to debug a release build, you can enable debug symbols without disabling optimizations. Add debug = true to your Cargo.toml. This keeps the binary fast but adds the symbols GDB needs. The binary gets larger, but you can actually inspect variables.
# Cargo.toml
[profile.release]
# Keep optimizations but include debug info for GDB.
debug = true
This is the standard approach for debugging performance-critical code. You get the optimized behavior with the ability to step through and inspect state.
Don't fight the optimizer. If GDB can't see your variable, turn on debug info.
Core dumps and crashes
When a program crashes with a signal like SIGSEGV or SIGABRT, the operating system can generate a core dump. A core dump is a snapshot of the program's memory at the moment of the crash. You can load this into GDB to analyze the crash without reproducing it.
Enable core dumps by setting the ulimit. This allows the OS to write core files.
# Allow core dumps.
ulimit -c unlimited
Run your program. If it crashes, a core file appears in the current directory. Load it into GDB along with the binary.
# Load the binary and the core dump.
gdb ./target/debug/your_binary_name core
GDB shows you where the program crashed. Use backtrace to see the call stack. Use print to inspect variables. This is invaluable for crashes that happen in production or under load.
# Show where the crash happened.
(gdb) backtrace
# Inspect variables at the crash site.
(gdb) print *ptr
Core dumps capture the exact state of the crash. They are your best friend for intermittent failures.
Pitfalls and quirks
GDB has a few quirks when debugging Rust code. Lifetimes are a compile-time concept. GDB doesn't see them. It just sees pointers. This means GDB can't enforce borrowing rules; it's too late for that. You might see dangling pointers in GDB that the borrow checker prevented at compile time. This is normal. GDB shows the raw memory, not the safety guarantees.
Optimizations can hide variables. If you see "value optimized out", the compiler moved the variable to a register or eliminated it. You can try info registers to see register values, but it's often easier to compile with debug = true or disable optimizations for the specific function using #[inline(never)] or #[no_optimize] (nightly).
Closures and async blocks can have complex memory layouts. GDB might show them as opaque structs. Pretty-printers help, but sometimes you need to dig into the raw fields. The Rust compiler generates synthetic names for closures. You might see names like <main::calculate_sum::{closure#0}>. This is normal. Use break with the closure name to set breakpoints inside closures.
Rust panics unwind the stack. If your program panics, GDB might stop at the panic handler. Use backtrace to see the original call stack. The panic message usually includes a backtrace, but GDB gives you more detail.
Lifetimes are invisible to GDB. It just sees pointers.
Choosing your debugging tool
Rust offers several debugging tools. Pick the one that matches your problem.
Use GDB when you need to inspect a running program's state, step through code line by line, or analyze a crash that produces no output. Use dbg! for quick, temporary inspection of values during development; it prints the file, line, and value without the overhead of launching a debugger. Use rr (record and replay) when you're chasing a heisenbug that disappears when you attach GDB; rr records a run and lets you rewind time to inspect the state before the crash. Use Valgrind when you suspect memory leaks or invalid memory accesses that GDB might miss; it instruments memory operations to catch errors GDB can't see. Use Miri when you need to detect undefined behavior in safe code or unsafe blocks; it interprets your code to find issues that only manifest under specific memory layouts.
Pick the tool that matches the bug. GDB for state, dbg! for speed, rr for ghosts.