How to Debug C, C++, and Rust Programs with GDB and LLDB

GDB and LLDB are the two workhorses of compiled-language debugging. If you write C, C++, or Rust, knowing your way around these tools will save you hours of staring at printf output wondering where everything went wrong. GDB (currently at version 17.1) is the default debugger on Linux. LLDB (currently at version 22.1, shipping with the LLVM toolchain) is the default on macOS. Both handle Rust binaries natively through rustc’s DWARF debug info. This guide covers the practical commands and workflows you actually need - from setting your first breakpoint to diagnosing a segfault from a core dump.
When to Reach for a Debugger Instead of Print Statements
Most of us default to printf-debugging, and honestly it works fine for simple problems. But there are situations where scattering print calls through your code wastes more time than it saves.
Crashes are the obvious case. When a program hits a segfault (SIGSEGV), abort (SIGABRT), or bus error (SIGBUS), it terminates before flushing stdout. Your printf calls produce nothing. A debugger catches the signal and shows the exact instruction and full backtrace at the moment of the crash.
Race conditions are another. If you’re chasing a Heisenbug, adding I/O calls shifts timing enough to mask the problem. A debugger’s conditional breakpoints and watchpoints have far less timing impact than writing to stdout on every iteration.
Then there’s complex data structures. Inspecting a tree, linked list, or hash map with printf means writing throwaway formatting code every time. The debugger’s print command shows the full structure with one command, and both GDB and LLDB ship with pretty printers for standard library types. For finding bugs before they reach the debugger, property-based testing
is a powerful complement — it generates edge-case inputs that often trigger exactly the crashes debuggers are needed for.
Getting Started
The setup is minimal. Compile with debug symbols and optimizations disabled so variable values don’t get optimized away:
# C / C++
gcc -g -O0 main.c -o main
clang -g -O0 main.c -o main
# Rust (debug profile includes symbols by default)
cargo buildLaunch the debugger and run your program:
# GDB
gdb ./myprogram
(gdb) run arg1 arg2
# LLDB
lldb ./myprogram
(lldb) run arg1 arg2Both support attaching to already-running processes with attach <pid>, which is essential for debugging services and long-running programs.
For IDE users, VS Code’s C/C++ extension uses GDB or LLDB under the hood via launch.json. The CodeLLDB
extension provides Rust-aware LLDB integration in VS Code. CLion uses either debugger depending on your toolchain. But terminal debugging remains essential for SSH sessions and production machines where no GUI is available.

Breakpoints, Stepping, and Program Flow Control
Controlling where your program pauses and how it advances is the most fundamental debugger skill. The most common commands side by side:
| Action | GDB | LLDB |
|---|---|---|
| Break at function | break main | b main |
| Break at file:line | break main.c:42 | b main.c:42 |
| Break at address | break *0x400520 | b 0x400520 |
| Conditional break | break process_data if count > 100 | b process_data -c 'count > 100' |
| Temporary break | tbreak main.c:50 | b -o main.c:50 |
| List breakpoints | info breakpoints | br list |
| Disable breakpoint #3 | disable 3 | br disable 3 |
| Delete breakpoint #3 | delete 3 | br delete 3 |
Conditional breakpoints are particularly powerful. Instead of manually stepping through 500 loop iterations to reach the one that matters, break process_data if count > 100 pauses only when the condition is true.
Temporary breakpoints (tbreak in GDB, b -o in LLDB) delete themselves after being hit once. They’re perfect for “run to this line and stop” without cluttering your breakpoint list.
Stepping Commands
Once you’ve hit a breakpoint, four commands control execution:
next(step over) - executes the current line, including any function calls on it, without descending into themstep(step into) - enters the function call on the current linefinish(step out) - runs until the current function returnscontinue- resumes execution until the next breakpoint
These commands work identically in both GDB and LLDB.
Reverse Debugging (GDB Only)
GDB supports stepping backward through execution history, which is invaluable for finding the root cause of a corruption that shows up far from its origin:
(gdb) target record-full
(gdb) run
... program hits the bug ...
(gdb) reverse-next
(gdb) reverse-step
(gdb) reverse-continueThis records every instruction so you can rewind. The overhead is significant, so it works best on small test cases rather than full applications.
Inspecting Variables, Memory, and Data Structures
Once you’ve stopped at a breakpoint, you can examine any part of program state without having planned for it ahead of time. No need to anticipate what to print before running.
Printing Variables and Expressions
# Print a variable (both debuggers)
(gdb) print count
(lldb) p count
# Print an expression
(gdb) print count * 2 + offset
# Dereference a struct pointer
(gdb) print *my_struct_ptrFormat Specifiers
| Format | GDB | LLDB | Use case |
|---|---|---|---|
| Hexadecimal | print/x count | p/x count | Bit flags, addresses |
| Binary | print/t count | p/t count | Bitfields, masks |
| Address + symbol | print/a ptr | p/a ptr | Pointer debugging |
Raw Memory Examination
When you need to look at raw bytes - debugging buffer overflows, off-by-one errors, or protocol parsing - memory examination is essential:
# GDB: 16 bytes in hex starting at ptr
(gdb) x/16xb ptr
# GDB: 4 eight-byte words in hex
(gdb) x/4gx ptr
# LLDB equivalent
(lldb) memory read -c 16 -f x ptrPretty Printers
Both debuggers ship with pretty printers for standard library types. GDB includes Python-based pretty printers for std::vector, std::map, and std::string. Rust’s standard library includes GDB and LLDB pretty printers for Vec, HashMap, String, Option, and Result. Enable them in GDB with:
(gdb) set print pretty onRust provides wrapper scripts rust-gdb and rust-lldb that load the appropriate pretty-printing scripts automatically. Use these instead of bare gdb or lldb when debugging Rust programs. With pretty printers active, an Option::Some(42) displays as core::option::Option<i32>::Some(42) rather than a mess of struct fields and discriminant values.
Auto-Display
If you want to see a variable’s value every time the program stops:
# GDB
(gdb) display count
# LLDB
(lldb) target stop-hook add -o "p count"This prints count at every breakpoint hit, so you can watch it change step by step without retyping the print command.
Diagnosing Segfaults, Memory Errors, and Core Dumps
Crashes are the main reason people reach for a debugger in the first place. The workflow is straightforward once you know the commands.
Catching a Segfault Live
When a segfault occurs under the debugger, it stops immediately. Run backtrace (or just bt in both debuggers) to see the full call stack:
(gdb) bt
#0 0x0000555555555189 in process_item (item=0x0) at main.c:23
#1 0x00005555555551c4 in process_list (list=0x7fffffffe000, n=10) at main.c:31
#2 0x0000555555555210 in main (argc=1, argv=0x7fffffffe108) at main.c:42This tells you exactly where the crash happened and how you got there. Frame #0 shows a null pointer (item=0x0) - that’s your bug.
Core Dump Analysis
For crashes that happen outside the debugger (in production, in CI, during overnight test runs), core dumps let you perform post-mortem analysis:
# Enable core dumps
ulimit -c unlimited
# After a crash, load the core file
gdb ./myprogram core
# or
lldb ./myprogram -c core
# View the backtrace at crash time
(gdb) btThe backtrace works exactly as if you’d caught the crash live, even though the program is no longer running.
Common Segfault Patterns
- Null pointer dereference - faulting address is
0x0or a small value like0x8(accessing a struct field through a null pointer) - Use-after-free - accessing memory that was freed; the address looks valid but the content is garbage or a debug fill pattern
- Stack overflow - deeply recursive functions; the faulting address is near the stack limit
- Buffer overflow - writing past the end of an array, corrupting adjacent memory or the return address
Watchpoints
When a variable is getting corrupted but you don’t know where, watchpoints are the answer. They pause execution whenever a value changes:
# GDB
(gdb) watch variable
(gdb) watch *(int*)0x7fffe000 # watch a specific address
# LLDB
(lldb) w s v variableThis is essential when the crash site is far from the actual corruption. Set a watchpoint on the corrupted variable and the debugger will stop at the exact instruction that modifies it.
AddressSanitizer Integration
Compile with AddressSanitizer to catch more subtle memory bugs:
gcc -fsanitize=address -g main.c -o mainRun the sanitized binary under the debugger. ASan detects memory errors (use-after-free, buffer overflow, stack overflow) and triggers SIGABRT with a detailed report showing allocation, deallocation, and access stack traces. The debugger catches the signal so you can inspect program state at the point of detection.
Rust-Specific Considerations
Safe Rust should never segfault. If it does, the bug is in unsafe blocks, FFI calls, or (rarely) a compiler or standard library bug. The debugging workflow is identical: cargo build with the default debug profile includes symbols, and gdb target/debug/myprogram or rust-gdb target/debug/myprogram gives you the same experience as debugging C or C++. Rust’s drive to eliminate memory-safety bugs from systems code is gaining real traction — Linux kernel 7.0 now ships stable Rust support
for new driver development, cementing Rust as a first-class language for code where memory correctness matters most.
Advanced Techniques
TUI Mode (GDB)
GDB’s built-in Text User Interface gives you a split-screen terminal experience showing source code, assembly, and registers alongside the command prompt:
(gdb) tui enableUse layout src for source code, layout asm for assembly, layout split for both, and layout regs to add a register window. Press Ctrl+X A to toggle TUI mode on and off. This is particularly useful on remote servers where you can’t run a GUI debugger.

The split view (layout split) is especially handy when you need to correlate source lines with their generated assembly, for instance when debugging compiler optimization issues:

Init Files
Place common settings in ~/.gdbinit or ~/.lldbinit so they load automatically:
# ~/.gdbinit
set print pretty on
set pagination off
set disassembly-flavor intelFor project-specific settings, create a .gdbinit in your project directory. GDB 15+ requires you to explicitly trust it for security:
# In ~/.gdbinit
set auto-load safe-path /path/to/your/projectScripting
Both debuggers expose Python APIs for automating repetitive tasks.
GDB:
(gdb) python print(gdb.parse_and_eval("count"))You can write custom commands in ~/.gdbinit using define mycommand ... end syntax, or use full Python scripts for complex analysis.
LLDB:
(lldb) script import lldb; print(lldb.frame.FindVariable("count").GetValue())Create custom commands with command script add -f mymodule.my_function mycommand.
Multi-threaded Debugging
| Action | GDB | LLDB |
|---|---|---|
| List threads | info threads | thread list |
| Switch to thread 3 | thread 3 | thread select 3 |
| All thread backtraces | thread apply all bt | bt all |
In GDB, set scheduler-locking on stops only the current thread at breakpoints while others continue running, which is useful for isolating thread-specific behavior.
Multi-process Debugging (GDB)
When debugging programs that use fork():
(gdb) set follow-fork-mode child # debug the child after fork
(gdb) set detach-on-fork off # keep control of both processes
(gdb) info inferiors # list all processes under GDB's control
(gdb) inferior 2 # switch to process 2By default, GDB follows the parent after a fork and lets the child run freely. Setting detach-on-fork off lets you keep control of both processes and switch between them.
Remote Debugging
For debugging on production servers, embedded devices, or containers:
# On the remote machine, start gdbserver
gdbserver :1234 ./myprogram
# On your local machine, connect
(gdb) target remote hostname:1234For LLDB, use lldb-server on the remote side and process connect connect://hostname:1234 on the client.
Debugging in Containers
Docker’s default security profile blocks ptrace, which debuggers rely on. Add the SYS_PTRACE capability:
docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined myimageFor Docker Compose:
services:
myservice:
cap_add:
- SYS_PTRACE
security_opt:
- seccomp:unconfinedAvoid using --privileged just for debugging - SYS_PTRACE is the minimal capability needed. For Kubernetes deployments, gdbserver-based remote debugging is often simpler than modifying pod security contexts. If you’re evaluating container runtimes, Podman’s rootless architecture handles ptrace differently — see our Podman vs Docker comparison
for a full breakdown of security and capability trade-offs.
Valgrind Integration
Valgrind Memcheck detects memory leaks that don’t crash - the silent bugs that slowly consume memory over time. You can combine it with GDB for interactive debugging:
valgrind --vgdb=yes --vgdb-error=0 ./myprogramThen connect GDB to Valgrind’s built-in gdbserver. When Memcheck detects an error, the debugger breaks at that point so you can inspect program state. You can also trigger a leak check at any point during execution using the monitor command:
(gdb) monitor leak_check full reachable anyValgrind classifies leaks as “definitely lost” (no pointer to the allocated block exists), “indirectly lost” (lost because you lost the pointer to a parent structure), “possibly lost” (ambiguous), and “still reachable” (not freed but a pointer still exists at exit).
Debugging Optimized Builds
When a release build crashes in production and you can’t reproduce it in a debug build, you’ll face optimized code where variables live in registers or have been eliminated entirely:
(gdb) info registers
(lldb) register read
(gdb) disassembleReading assembly becomes necessary here. DWARF split debug info (-gsplit-dwarf flag) lets you keep debug symbols separate from the deployed binary, so you can ship a smaller binary and load symbols in the debugger when needed.
GDB vs LLDB Quick Reference
For anyone switching between the two debuggers, here are the key differences in a single table:
| Action | GDB | LLDB |
|---|---|---|
| Run program | run | run |
| Set breakpoint | break func | b func |
| Conditional break | break func if x>5 | b func -c 'x>5' |
| Print variable | print var | p var |
| Examine memory | x/16xb addr | memory read -c 16 addr |
| Backtrace | bt | bt |
| List threads | info threads | thread list |
| Watchpoint | watch var | w s v var |
| Step over | next | next |
| Step into | step | step |
| Step out | finish | finish |
| Continue | continue | continue |
Most basic commands like bt, next, step, and continue are identical in both debuggers. The biggest differences show up in memory examination syntax and breakpoint management flags.
If you’re just getting started, focus on break, bt, print, and next. Those four commands handle the majority of debugging sessions. Add watchpoints when you need them, learn the memory examination syntax when you’re debugging buffer issues, and pick up scripting when you find yourself typing the same commands repeatedly. The official LLDB GDB-to-LLDB command map
is worth bookmarking if you regularly switch between the two debuggers.
Botmonster Tech