← Back to Course
# Debugging C and Rust with GDB and LLDB ## CS 631 Systems Foundations — Feb 24, 2026 --- ## Today's Agenda 1. What is a debugger and how does it work? 2. The toolchain: GDB, LLDB, rust-gdb, rust-lldb 3. Essential commands reference 4. Debugging C code with GDB (Linux) 5. Debugging C code with LLDB (macOS) 6. Debugging Rust code with rust-gdb / rust-lldb 7. Practice exercises --- ## What is a Debugger? A debugger **controls the execution** of another program: - **Pause** at any function or line - **Inspect** variables, struct fields, memory - **Step** through code one line at a time - **Navigate** the call stack
The target:
./project01
— our ntlang parser/evaluator from project01-starter
--- ## How a Debugger Works
graph LR A["Your Program\n(./project01)"] -->|controlled via| B["Debugger\n(GDB / LLDB)"] B -->|"ptrace (Linux)\nMach APIs (macOS)"| C["OS"] C -->|"registers, memory,\nprocess state"| B B -->|"commands,\ndisplay"| D["You"]
The OS gives the debugger special access to pause/resume the process and read/write its memory. --- ## Why a Debugger vs. Printf?
**`printf` / `eprintln!`** - Recompile for each new print - Output fixed at compile time - Hard to follow struct chains - No call stack visibility - ✓ Quick sanity checks
**Debugger** - No recompile — interactive - Inspect any variable on the fly - Full pointer traversal - Complete backtrace - ✓ Systematic investigation
--- ## The Toolchain | Tool | Platform | Use for | |------|----------|---------| | `gdb` | Linux | C programs | | `lldb` | macOS | C programs | | `rust-gdb` | Linux | Rust programs | | `rust-lldb` | macOS | Rust programs | `rust-gdb` and `rust-lldb` are drop-in replacements that load **Rust pretty-printers** — they display `Vec
`, `String`, `Option
`, and enum variants in readable form. --- ## Debug Builds Debuggers need symbol information to map machine code → source lines. **C** — the project01 `c/Makefile` already uses `-g`: ```sh cd ~/project01-starter/c && make ``` **Rust** — `cargo build` (no `--release`) always includes debug info: ```sh cd ~/project01-starter/rust && cargo build # Binary: ./target/debug/project01 ```
Never debug a release build: optimizations inline functions and eliminate variables.
--- ## Essential Commands Cheatsheet | Action | GDB | LLDB | Short | |--------|-----|------|-------| | Run with args | `run "1 + 2"` | `run "1 + 2"` | `r` | | Break at function | `break main` | `b main` | `b` | | Continue | `continue` | `continue` | `c` | | Step over | `next` | `next` | `n` | | Step into | `step` | `step` | `s` | | Print expression | `print expr` | `print expr` | `p` | | Call stack | `backtrace` | `thread backtrace` | `bt` | | Run until return | `finish` | `finish` | — | | Quit | `quit` | `quit` | `q` | --- ## GDB vs. LLDB: Key Differences | Task | GDB | LLDB | |------|-----|------| | Show local variables | `info locals` | `frame variable` | | Show call stack | `backtrace` | `thread backtrace` | | Select frame | `frame 2` | `frame select 2` | | Break at file:line | `break scan.c:50` | `b scan.c:50` | | Examine memory | `x/4xb addr` | `memory read -s1 -fx -c4 addr` | | Watchpoint | `watch i` | `watchpoint set variable i` |
Most single-letter shortcuts work in both:
b
,
r
,
c
,
n
,
s
,
p
,
bt
,
q
--- ## Demo: C / GDB — Launch & Set Breakpoints ```sh $ cd ~/project01-starter/c && make $ gdb ./project01 ``` ``` (gdb) break main Breakpoint 1 at 0x11a0: file project01.c, line 6. (gdb) break scan_table_scan Breakpoint 2 at 0x1370: file scan.c, line 110. (gdb) run "1 + 2" Starting program: ./project01 "1 + 2" Breakpoint 1, main (argc=2, argv=...) at project01.c:6 6 struct config_st config; ``` --- ## Demo: C / GDB — Run and Inspect Scan Table ``` (gdb) continue Breakpoint 2, scan_table_scan (st=..., input="1 + 2") at scan.c:110 110 struct scan_token_st *tp; (gdb) print st->len $1 = 0 (gdb) finish ← run scan_table_scan to completion main () at project01.c:22 (gdb) print scan_table.len $2 = 4 (gdb) print scan_table.table[0] $3 = {id = TK_INTLIT, value = "1", '\000'
} (gdb) print scan_table.table[1] $4 = {id = TK_PLUS, value = "+", '\000'
} ``` --- ## Demo: C / GDB — Inspect Parse Tree ``` (gdb) break parse_program (gdb) continue Breakpoint 3, parse_program (pt=..., st=...) at parse.c:38 (gdb) finish main () at project01.c:27 (gdb) print parse_tree->type $5 = EX_OPER2 (gdb) print parse_tree->oper2.oper $6 = OP_PLUS (gdb) print parse_tree->oper2.left->intval.value $7 = 1 (gdb) print parse_tree->oper2.right->intval.value $8 = 2 ``` GDB follows pointers and displays enum values by name. --- ## Demo: C / GDB — Backtrace Set a breakpoint inside `scan_intlit` and examine the call stack: ``` (gdb) break scan_intlit (gdb) run "5 - 3" Breakpoint, scan_intlit (...) at scan.c:51 (gdb) backtrace #0 scan_intlit (p=..., end=..., tp=...) at scan.c:51 #1 scan_token (p=..., end=..., tp=...) at scan.c:94 #2 scan_table_scan (st=..., input=...) at scan.c:125 #3 main (argc=2, argv=...) at project01.c:21 ``` ``` (gdb) frame 3 ← jump to main's frame (gdb) info locals config = {input = "5 - 3"} scan_table = {table = {...}, len = 0, cur = 0} ``` --- ## Demo: C / LLDB — Launch ```sh $ lldb -- ./project01 "1 + 2" ``` ``` (lldb) breakpoint set --name main Breakpoint 1: project01`main at project01.c:6 (lldb) breakpoint set --name scan_table_scan Breakpoint 2: project01`scan_table_scan at scan.c:110 (lldb) run Process 12345 stopped * thread #1, stop reason = breakpoint 1.1 frame #0: main(argc=2, argv=...) at project01.c:6 6 struct config_st config; ``` --- ## Demo: C / LLDB — Inspect Locals ``` (lldb) continue Process stopped at scan_table_scan (lldb) frame variable (struct scan_table_st *) st = 0x00007ffeefbff6b0 (char *) input = 0x00007ffeefbff8c8 "1 + 2" (lldb) p st->len (int) $0 = 0 (lldb) finish (lldb) p scan_table.table[0] (scan_token_st) $1 = {id = TK_INTLIT, value = "1"} (lldb) thread backtrace * frame #0: main at project01.c:22 frame #1: libdyld.dylib`start + 1 ``` --- ## GDB ↔ LLDB Command Map | Task | GDB | LLDB | |------|-----|------| | Local variables | `info locals` | `frame variable` | | Call stack | `backtrace` | `thread backtrace` | | Select frame | `frame 2` | `frame select 2` | | Memory dump | `x/4xb addr` | `memory read -s1 -fx -c4 addr` | | Watchpoint on var | `watch i` | `watchpoint set variable i` | | Conditional break | `break f if i==31` | `b f` then `breakpoint modify -c "i==31"` |
Most workflows are identical — the main difference is
info locals
vs
frame variable
and the
breakpoint set
syntax.
--- ## rust-gdb: What it Adds Without pretty-printers (`gdb`): ``` $1 = alloc::vec::Vec
{ buf: alloc::raw_vec::RawVec<...> { inner: alloc::raw_vec::RawVecInner<...> { ptr: core::ptr::unique::Unique<...> { ... }, cap: alloc::raw_vec::Cap (4), ... } }, len: 4 } ``` With pretty-printers (`rust-gdb`): ``` $1 = Vec(size=4) = { scan::Token::IntLit("1"), scan::Token::Plus, scan::Token::IntLit("2"), scan::Token::Eot } ``` --- ## Demo: Rust / rust-gdb — Launch & Inspect ```sh $ cd ~/project01-starter/rust $ rust-gdb ./target/debug/project01 ``` ``` (gdb) break project01::main (gdb) run "1 + 2" Breakpoint 1, project01::main () at src/main.rs:13 (gdb) break project01::scan::ScanTable::scan (gdb) continue (gdb) finish (gdb) print scan_table $1 = scan::ScanTable { tokens: Vec(size=4) = { scan::Token::IntLit("1"), scan::Token::Plus, scan::Token::IntLit("2"), scan::Token::Eot }, cur: 0 } ``` --- ## Demo: Rust / rust-gdb — ParseNode Enum ``` (gdb) break project01::parse::parse_program (gdb) continue (gdb) finish (gdb) print parse_tree $2 = parse::ParseNode::Oper2 { oper: parse::Operator::Plus, left: parse::ParseNode::IntVal { value: 1 }, right: parse::ParseNode::IntVal { value: 2 } } ``` Rust enums are tagged unions. `rust-gdb` decodes the discriminant automatically and shows the variant name and all named fields — no manual memory arithmetic needed. --- ## Demo: Rust / rust-lldb (macOS) ```sh $ rust-lldb ./target/debug/project01 ``` ``` (lldb) b project01::main (lldb) run "1 + 2" (lldb) b project01::scan::ScanTable::scan (lldb) c (lldb) finish (lldb) p scan_table (scan::ScanTable) $0 = { tokens: vec![ Token::IntLit("1"), Token::Plus, Token::IntLit("2"), Token::Eot, ], cur: 0 } ``` Same workflow as `rust-gdb` — just LLDB syntax. --- ## Common Debugging Workflow
graph LR A["1. Compile\nwith -g / cargo build"] --> B["2. Launch\ngdb / lldb"] B --> C["3. Set\nbreakpoints"] C --> D["4. run"] D --> E["5. Inspect\nprint / frame variable"] E --> F["6. Step\nnext / step"] F --> G{Bug found?} G -->|No| E G -->|Yes| H["7. Fix\n& verify"]
--- ## The Buffer Overflow Bug `scan_intlit` in `scan.c` — no bounds check on `i`: ```c char * scan_intlit(char *p, char *end, struct scan_token_st *tp) { int i = 0; while (scan_is_digit(*p) && (p < end)) { tp->value[i] = *p; /* NO bounds check! */ p += 1; i += 1; /* can exceed 31 */ } tp->value[i] = '\0'; /* also overflows */ ... } ``` `SCAN_TOKEN_LEN = 32` → valid indices are `0`–`31`. With 33+ digits, `tp->value[32]` corrupts the **next token** in the table. **Fix**: add `i < SCAN_TOKEN_LEN - 1` to the `while` condition. --- ## Catching the Overflow with GDB ``` (gdb) break scan_intlit (gdb) run "123456789012345678901234567890123" Breakpoint 1, scan_intlit (...) at scan.c:51 (gdb) break scan.c:53 if i == 31 (gdb) continue Breakpoint 2, scan_intlit (...) at scan.c:53 (gdb) print i $1 = 31 ← about to write last valid byte (gdb) next ← write tp->value[31], i → 32 (gdb) print i $2 = 32 ← NEXT write will overflow! (gdb) x/6xb tp->value + 30 0x...: 0x31 0x32 0x33 0x00 0x00 0x00 ↑ index 31 ↑ index 32 (overflow) ``` --- ## Practice Exercise **Input**: `"10 - 3 + 2"` Using GDB or LLDB on the C version of project01: 1. Set a breakpoint at `scan_table_scan`, run, then `finish`. Print `scan_table.len`. How many tokens? 2. Set a breakpoint at `eval`. How many times is it called? What is `pt->type` each time? 3. Set a breakpoint at `parse_program`, let it finish, then print `parse_tree->oper2.left->intval.value` and `parse_tree->oper2.right->intval.value`.
Hint for #1: there are 6 tokens. Hint for #2: eval is called 5 times for a 3-operand expression.
--- ## Key Takeaways 1. **Debuggers** pause execution via OS APIs and let you inspect any variable, follow any pointer, and read the full call stack. 2. **Compile with debug info**: `-g` for C (already in Makefile), `cargo build` for Rust. 3. **Core commands**: `break`, `run`, `next`/`step`, `print`, `backtrace`, `finish`. 4. **GDB vs. LLDB**: mostly the same — main difference is `info locals` vs. `frame variable`. 5. **rust-gdb / rust-lldb** decode `Vec`, `String`, `Option`, and enum variants into readable output. 6. **Buffer overflows** are observable in real time with a debugger — watch the exact write that goes out of bounds. --- ## Further Reading - [Beej's Quick Guide to GDB](https://beej.us/guide/bggdb/) - [GDB Documentation](https://sourceware.org/gdb/current/onlinedocs/gdb/) - [LLDB Tutorial](https://lldb.llvm.org/use/tutorial.html) - [GDB to LLDB Command Map](https://lldb.llvm.org/use/map.html) - Course GDB Usage Guide: `Guides → GDB Usage`