Debugging Rust with gdb

A debugger answers the questions a print statement cannot: which value a variable had at that moment, which branch the program took, which branch ran, and who called the function. Rust works with the system debugger, and the binary keeps the names when it is built with -g.

This lesson drives GDB from the command line, which is the form that works in a container and in a script. Every session below is the real output from this machine, and the process numbers in square brackets change on every run. The two options at the start of each command silence features a container cannot use.

A breakpoint on a Rust function

The function is called sum_to::sum_to in the debug information: the module first and the function second. Breaking on the bare name fails, which is the first surprise.

The program sums zero to four. The breakpoint stops at the first line of the function, and the parameter is already visible.

fn sum_to(n: u32) -> u32 { let mut total = 0; for i in 0..n { total += i; } total } fn main() { println!("sum: {}", sum_to(5)); }

rustc --edition 2024 -g sum_to.rs
./sum_to
gdb -q -batch -iex 'set auto-load python-scripts off' -ex 'set disable-randomization off' -ex 'set print address off' -ex 'break sum_to::sum_to' -ex run -ex 'print n' -ex next -ex next -ex 'print total' -ex continue ./sum_to

Breakpoint 1: file sum_to.rs, line 2.

Breakpoint 1, sum_to::sum_to (n=5) at sum_to.rs:2
2 let mut total = 0;
$1 = 5
3 for i in 0..n {
4 total += i;
$2 = 0
sum: 10
[Inferior 1 (process 72) exited normally]

The breakpoint line, the two steps and the two prints are the whole session. The lesson on editors and gdb introduced the same commands on a smaller program.

Stepping and printing

next runs one line and stops at the next, stepping over calls, and step enters them. print evaluates an expression in the current frame, and print total + 1 shows what the next line will compute.

The program below adds one to a number in a loop, and the gdb session follows the first two iterations. The accumulator is the variable to watch when a loop is suspected.

fn average(values: &[i32]) -> i32 { let mut total = 0; for value in values { total += value; } total / (values.len() as i32 - 1) } fn main() { println!("{}", average(&[10, 20, 30])); }

rustc --edition 2024 -g buggy.rs
./buggy
gdb -q -batch -iex 'set auto-load python-scripts off' -ex 'set disable-randomization off' -ex 'set print address off' -ex 'break buggy.rs:6' -ex run -ex 'print total' -ex 'print values.len()' -ex next -ex 'print $rax' ./buggy

Breakpoint 1: file buggy.rs, line 6.

Breakpoint 1, buggy::average (values=...) at buggy.rs:6
6 total / (values.len() as i32 - 1)
$1 = 60
Could not find function named '&[i32]::len'
7 }
$2 = 30

The average is wrong because the divisor is the length minus one, and gdb shows the numerator is the right sixty. The divisor expression is on the line the breakpoint stopped at, which is why the source line matters more than the value.

Finding a wrong value

A wrong result is usually one wrong variable at one line. The method is to break where the value is computed, print the inputs, then step one line and print the output: the first line where the printed value disagrees with the expected one is the bug.

The average program is the example: the total is sixty for the three values, so the input is right, and the next line divides it by two. Printing the total before the division isolates the mistake from the formatting and from the caller.

A conditional breakpoint, break buggy.rs:6 if total > 100, stops only when the condition holds, which is how a loop of a million iterations is inspected without a million stops.

A method and its struct

A method is a function with a receiver, so a breakpoint on it shows self as one value and prints all its fields at once. That is the fastest way to see which object a call belongs to when the same method serves several values.

The program counts three pairs in a struct. The session breaks inside the method, prints the whole receiver and the argument, then moves one frame up to main to print the caller's own copy.

struct Counter { name: &'static str, value: i32, } impl Counter { fn bump(&mut self, by: i32) { self.value += by; } } fn main() { let mut counter = Counter { name: "hits", value: 0 }; for _ in 0..3 { counter.bump(2); } println!("{}: {}", counter.name, counter.value); }

rustc --edition 2024 -g counter_bump.rs
./counter_bump
gdb -q -batch -iex 'set auto-load python-scripts off' -ex 'set disable-randomization off' -ex 'set print address off' -ex 'break counter_bump::Counter::bump' -ex run -ex 'print *self' -ex 'print by' -ex bt -ex 'frame 1' -ex 'print counter' -ex continue ./counter_bump

hits: 6
Breakpoint 1: file counter_bump.rs, line 8.

Breakpoint 1, counter_bump::Counter::bump (self=, by=2) at counter_bump.rs:8
8 self.value += by;
$1 = counter_bump::Counter {name: "hits", value: 0}
$2 = 2
#0 counter_bump::Counter::bump (self=, by=2) at counter_bump.rs:8
#1 counter_bump::main () at counter_bump.rs:15
#1 counter_bump::main () at counter_bump.rs:15
15 counter.bump(2);
$3 = counter_bump::Counter {name: "hits", value: 0}

Breakpoint 1, counter_bump::Counter::bump (self=, by=2) at counter_bump.rs:8
8 self.value += by;

print *self prints the receiver as a struct with its type name, and frame 1 moves to the caller without running anything, which is how a value is compared with the copy the callee received. The receiver itself has no address printed because set print address off is in the command line, and an address would differ on every run anyway.

A panic and a backtrace

A panic is easier to read in gdb than in the terminal because the debugger stops before the unwind and the frames are still there. The breakpoint goes on the line, and bt lists the callers.

The program reads an index that does not exist. The session prints the index and one frame, which is enough to see that the value came from the parse.

fn main() { let values = [1, 2, 3]; let index: usize = "5".parse().unwrap(); println!("{}", values[index]); }

rustc --edition 2024 -g panic.rs
./panic
gdb -q -batch -iex 'set auto-load python-scripts off' -ex 'set disable-randomization off' -ex 'set print address off' -ex 'break panic.rs:4' -ex run -ex 'print index' -ex bt ./panic

Breakpoint 1: file panic.rs, line 4.

Breakpoint 1, panic::main () at panic.rs:4
4 println!("{}", values[index]);
$1 = 5
#0 panic::main () at panic.rs:4

The runtime run of the same program prints the panic message and status 101, which the lesson on panics reads. The debugger shows the same moment with the values still alive.

Exercises

  1. Break on sum_to with a condition on the loop variable and print the total each time it holds.
  2. Run the average program under gdb and print the divisor before the division.
  3. Read the backtrace of a panic in a function called from another function and name the two frames.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does rustc -g keep in the binary?

    Answer

    Debug information with names and lines

    The flag adds the symbols a debugger needs to map addresses to source lines.

  2. How does gdb name a Rust function in a module?

    Answer

    module::function

    The debug information stores the module path and the function name joined by colons.

  3. What does next do compared with step?

    Answer

    It runs the line without entering calls

    next steps over a call, step enters it.

  4. What is a backtrace?

    Answer

    The list of functions that called the current one

    bt walks the call stack from the current frame outwards.

  5. What does this program print? fn sum_to(n: u32) -> u32 { let mut total = 0; for i in 0..n { total += i; } total } fn main() { println!("sum: {}", sum_to(4)); }

    Answer

    sum: 6

    The range stops before four, so the sum is zero plus one plus two plus three.

  6. What does this program print? fn average(values: &[i32]) -> i32 { let mut total = 0; for value in values { total += value; } total / (values.len() as i32 - 1) } fn main() { println!("{}", average(&[10, 20, 30])); }

    Answer

    30

    The total is sixty and the divisor is two instead of three, so the result is thirty.

  7. What does this program print? fn main() { let values = [1, 2, 3]; let index = 1; println!("{}", values[index]); }

    Answer

    2

    Index one is the second element, and it is inside the array.

  8. What does the debugger show when a panic has not unwound yet?

    Answer

    The frames and the local values of the panic point

    Stopping before the unwind keeps the whole stack and its variables.

  9. What does break buggy.rs:6 if total > 100 do?

    Answer

    Stops at line six only when total is above one hundred

    A conditional breakpoint keeps a long loop from stopping on every iteration.

  10. What does this program print? fn main() { let values = [10, 20, 30]; let mut total = 0; for value in values { total += value; } println!("{total}"); }

    Answer

    60

    The three values add up to sixty, which is the number a debugger would show in total.

The code on this page was built with rustc 1.96.1 and GDB 16.3 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image