Rust Debugger: How to Look Under the Hood Step by Step
\n\nHave you ever wondered: «Step by step interactive debugger for Rust: how does it work under the hood?» If you've written Rust code and tried to catch a tricky bug, you've likely encountered GDB, LLDB, or even tools like rust-gdb. But how exactly do they manage to pause program execution, show variable values, and step through source code?
In this article, we won't just tell you how to use a debugger. We'll look inside: from machine code to breakpoints, from stack frames to debug symbols. If you're interested not only in what to do, but also why it works — you've come to the right place.
\n\nWhy is Rust a Special Case for Debugging?
\n\nRust compiles to native code via LLVM. This means we use the same tools for debugging as for C/C++: GDB and LLDB. But Rust adds its own complexities:
- \n
- Ownership and borrowing — variables can be moved, and the debugger must track this. \n
- Monomorphization of generics — each
Tbecomes a concrete type, and function names become "mangled." \n - Optimizations — the compiler can reorder code, and the debugger must "unwind" this back. \n
That's why a step by step interactive debugger for Rust is not just running a program under GDB. It's a whole system that translates machine addresses back into source code lines.
\n\nHow Does a Breakpoint Work Under the Hood?
\n\nImagine: you set a breakpoint on the line println!("Hello");. What happens in the processor?
When you run a program under a debugger, it does the following:
\n\n- \n
- Reads debug information (DWARF format) from the executable file. It stores the mapping: memory address → code line. \n
- Replaces the instruction at that address with a special
INT3command (on x86 —0xCC). This is a "trap" for the processor. \n - When execution reaches this point, the processor generates a SIGTRAP signal. \n
- The debugger intercepts the signal, saves the register state, and shows you the current line. \n
Here's an example of a simple Rust program:
\n\nfn main() {\n let x = 42;\n let y = x + 10; // <-- breakpoint\n println!("y = {}", y);\n}\n\nWhen you set a breakpoint on let y = x + 10;, the debugger finds the address of this instruction in the binary and replaces the first byte with 0xCC. After stopping, it restores the original byte, executes the step, and sets the trap again — if you want to continue.
Step Over, Step Into, Step Out — How Does the Debugger Know Where the "Next Line" Is?
\n\nThis is perhaps the most magical part. How does the debugger know that after let y = x + 10; comes println!?
It's all about debug tables. The Rust compiler (via LLVM) generates a .debug_line section in DWARF format. This is a compressed table that maps instruction addresses to line numbers in the source code.
For example, for our code, the table might look like this:
\n\nAddress 0x1000 → line 2 (let x = 42;)\nAddress 0x1010 → line 3 (let y = x + 10;)\nAddress 0x1020 → line 4 (println!)\n\nWhen you press step over, the debugger calculates the address of the next instruction that does not belong to the current line and sets a temporary breakpoint there. Execution continues until this address — and you move to the next line.
\n\nFor step into — the debugger checks if there is a function call at the current address. If so, it looks for the first line of that function.