Contents

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 build

Launch the debugger and run your program:

# GDB
gdb ./myprogram
(gdb) run arg1 arg2

# LLDB
lldb ./myprogram
(lldb) run arg1 arg2

Both 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.

VS Code debugging session with LLDB showing variables panel and breakpoint hit
VS Code with CodeLLDB paused at a breakpoint, showing variable inspection in the sidebar
Image: Debugging Rust with VS Code

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:

ActionGDBLLDB
Break at functionbreak mainb main
Break at file:linebreak main.c:42b main.c:42
Break at addressbreak *0x400520b 0x400520
Conditional breakbreak process_data if count > 100b process_data -c 'count > 100'
Temporary breaktbreak main.c:50b -o main.c:50
List breakpointsinfo breakpointsbr list
Disable breakpoint #3disable 3br disable 3
Delete breakpoint #3delete 3br 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 them
  • step (step into) - enters the function call on the current line
  • finish (step out) - runs until the current function returns
  • continue - 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-continue

This 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_ptr

Format Specifiers

FormatGDBLLDBUse case
Hexadecimalprint/x countp/x countBit flags, addresses
Binaryprint/t countp/t countBitfields, masks
Address + symbolprint/a ptrp/a ptrPointer 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 ptr

Pretty 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 on

Rust 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:42

This 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) bt

The 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 0x0 or a small value like 0x8 (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 variable

This 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 main

Run 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 enable

Use 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.

GDB TUI mode showing source code in the top pane with current execution line highlighted
GDB TUI source layout with the current line highlighted and command prompt below
Image: Making GDB Easier: The TUI Interface

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:

GDB TUI split view showing source code and assembly side by side
GDB TUI split layout displaying both source and disassembly panes simultaneously
Image: Making GDB Easier: The TUI Interface

Init Files

Place common settings in ~/.gdbinit or ~/.lldbinit so they load automatically:

# ~/.gdbinit
set print pretty on
set pagination off
set disassembly-flavor intel

For 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/project

Scripting

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

ActionGDBLLDB
List threadsinfo threadsthread list
Switch to thread 3thread 3thread select 3
All thread backtracesthread apply all btbt 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 2

By 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:1234

For 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 myimage

For Docker Compose:

services:
  myservice:
    cap_add:
      - SYS_PTRACE
    security_opt:
      - seccomp:unconfined

Avoid 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 ./myprogram

Then 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 any

Valgrind 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) disassemble

Reading 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:

ActionGDBLLDB
Run programrunrun
Set breakpointbreak funcb func
Conditional breakbreak func if x>5b func -c 'x>5'
Print variableprint varp var
Examine memoryx/16xb addrmemory read -c 16 addr
Backtracebtbt
List threadsinfo threadsthread list
Watchpointwatch varw s v var
Step overnextnext
Step intostepstep
Step outfinishfinish
Continuecontinuecontinue

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.