GDB Debugging
The GNU Debugger (GDB) lets you pause a running program, inspect its state, step through code line by line, and watch variables change. It is the standard debugger for C on Linux. LLDB is the equivalent on macOS. Learning to use a debugger is faster than scattering printf statements through your code, especially for complex bugs involving pointers, memory corruption, and race conditions.
Compiling for Debugging
The debugger needs debug symbols — a mapping from machine instructions back to source code lines, variable names, and types. Compile with the -g flag.
gcc -g -O0 main.c utils.c -o program
The -O0 flag disables optimization. Optimized code reorders instructions, inlines functions, and eliminates variables, which makes debugging confusing because the program does not execute in the order you wrote it. Always use -O0 during debugging.
Starting GDB
gdb ./program
GDB loads the program but does not run it. You see a (gdb) prompt where you enter commands.
gdb --args ./program input.txt --verbose
The --args flag passes command-line arguments to the program.
Essential Commands
Setting Breakpoints
A breakpoint pauses execution at a specific location.
(gdb) break main # Break at the start of main()
(gdb) break parser.c:42 # Break at line 42 of parser.c
(gdb) break process_data # Break at the start of process_data()
Running the Program
(gdb) run # Start execution
(gdb) run input.txt # Start with arguments
The program runs until it hits a breakpoint, crashes, or finishes.
Stepping Through Code
(gdb) next # Execute current line, step over function calls
(gdb) step # Execute current line, step into function calls
(gdb) finish # Run until the current function returns
(gdb) continue # Resume execution until next breakpoint
next treats a function call as a single step. step enters the function and stops at its first line. Use next to skip library functions and step to enter your own functions.
Inspecting Variables
(gdb) print x # Print the value of x
(gdb) print *ptr # Dereference a pointer
(gdb) print arr[5] # Print array element
(gdb) print node->next # Follow a struct pointer
(gdb) print/x value # Print in hexadecimal
(gdb) print sizeof(struct Data) # Print the size of a type
Viewing the Call Stack
(gdb) backtrace # Show the full call stack
(gdb) bt # Short form
(gdb) frame 3 # Switch to frame 3 to inspect its variables
(gdb) up # Move up one frame
(gdb) down # Move down one frame
The backtrace shows you how you got to the current location. This is invaluable for understanding crash sites — you can see the chain of function calls that led to the problem.
Conditional Breakpoints
Sometimes a bug only occurs on the 1000th iteration. Conditional breakpoints handle this.
(gdb) break process_item if item_id == 42
(gdb) break parser.c:100 if count > 1000
(gdb) break malloc if size == 0
The breakpoint only triggers when the condition is true. This saves you from manually continuing through hundreds of irrelevant stops.
Watchpoints
A watchpoint pauses execution when a variable's value changes.
(gdb) watch counter # Break when counter changes
(gdb) watch *ptr # Break when the value pointed to by ptr changes
(gdb) rwatch buffer[0] # Break when buffer[0] is read
Watchpoints are powerful for finding where a variable gets corrupted. Instead of searching through code for every place that modifies a variable, set a watchpoint and let the debugger find it.
Inspecting Memory
The x command examines memory directly.
(gdb) x/10d &array # 10 decimal integers starting at array
(gdb) x/20x ptr # 20 hex words starting at ptr
(gdb) x/s str # Print as a null-terminated string
(gdb) x/10i main # 10 assembly instructions at main
(gdb) x/4xb &value # 4 bytes in hex (shows byte order)
The format is x/NFU address where N is the count, F is the format (d decimal, x hex, s string, i instructions), and U is the unit size (b byte, h halfword, w word, g giant/8 bytes).
(gdb) x/16xb buffer
0x7fffffffe440: 0x48 0x65 0x6c 0x6c 0x6f 0x00 0x00 0x00
0x7fffffffe448: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
This shows the raw bytes in memory. You can see that "Hello" is stored as 0x48 0x65 0x6c 0x6c 0x6f followed by the null terminator 0x00.
Debugging a Real Crash
Consider a program that segfaults:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct Node {
int value;
struct Node *next;
};
void print_list(struct Node *head) {
struct Node *current = head;
while (current != NULL) {
printf("%d -> ", current->value);
current = current->next;
}
printf("NULL\n");
}
int main(void) {
struct Node *head = malloc(sizeof(struct Node));
head->value = 1;
head->next = malloc(sizeof(struct Node));
head->next->value = 2;
head->next->next = NULL;
free(head->next);
/* Bug: accessing freed memory */
print_list(head);
return 0;
}
$ gcc -g -O0 crash.c -o crash
$ gdb ./crash
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555186 in print_list (head=0x5555555592a0) at crash.c:13
13 printf("%d -> ", current->value);
(gdb) bt
#0 0x0000555555555186 in print_list (head=0x5555555592a0) at crash.c:13
#1 0x0000555555555210 in main () at crash.c:27
(gdb) print current
$1 = (struct Node *) 0x5555555592c0
(gdb) print *current
Cannot access memory at address 0x5555555592c0
The debugger shows exactly where the crash occurred, what function we were in, and that current points to freed memory. This takes seconds compared to minutes of adding print statements.
Core Dumps
When a program crashes, the operating system can save its memory state to a core dump file. You can then analyze the crash after the fact.
ulimit -c unlimited # Enable core dumps
./program # Run and let it crash
gdb ./program core # Analyze the core dump
(gdb) bt # See where it crashed
(gdb) print variable # Inspect the state at crash time
Core dumps are essential for debugging crashes in production. The program does not need to be running under GDB when it crashes — you analyze the dump later.
LLDB on macOS
macOS ships with LLDB instead of GDB. The commands are similar but not identical.
lldb ./program
(lldb) breakpoint set --name main # or: b main
(lldb) run
(lldb) next
(lldb) step
(lldb) print x
(lldb) bt
(lldb) frame variable # Print all local variables
(lldb) memory read ptr # Equivalent to GDB's x command
Most GDB concepts transfer directly. LLDB's command structure is more consistent (breakpoint set, breakpoint list, breakpoint delete) but more verbose. Both debuggers accomplish the same tasks.
Remote Debugging
GDB can debug programs running on another machine. This is common for embedded systems where you cannot run GDB on the target device.
# On the target machine
gdbserver :1234 ./program
# On your development machine
gdb ./program
(gdb) target remote 192.168.1.100:1234
(gdb) break main
(gdb) continue
The program runs on the target, but you control it from your development machine with the full GDB interface.
Common Pitfalls
- Debugging optimized code — Compile with
-O0for debugging. With optimization enabled, the compiler reorders, inlines, and removes code. Variables show as "optimized out," lines execute out of order, and function calls disappear. - Forgetting -g — Without debug symbols, GDB shows assembly instead of source code. Always compile with
-gduring development. - Not using backtrace first — When a crash occurs,
btshould be your first command. It tells you where you are, how you got there, and which function call chain triggered the bug. - Stepping into library code — If you
stepintoprintformalloc, you end up in glibc source code. Usenextto step over library calls, orfinishto escape if you stepped in accidentally. - Ignoring watchpoints — Many developers only use breakpoints. Watchpoints find memory corruption bugs that breakpoints cannot: set a watchpoint on the corrupted variable and the debugger tells you exactly which line changes it.
- Relying solely on printf —
printfdebugging works for simple bugs but fails when the bug changes behavior when you add print statements (timing-dependent bugs), when the output buffer is not flushed before a crash, or when you need to inspect complex data structures.
Key Takeaways
- Compile with
-g -O0for debugging. Debug symbols map machine code back to source lines and variable names. - The core GDB workflow is: set breakpoints, run, step through code, and inspect variables with
printandbacktrace. - Conditional breakpoints (
break func if x == 42) and watchpoints (watch var) find specific bugs without manual searching. - The
xcommand examines raw memory, which is essential for debugging pointer and buffer issues. - Core dumps let you analyze crashes after the fact. Enable them with
ulimit -c unlimited. - LLDB on macOS provides the same capabilities as GDB with slightly different syntax. The concepts transfer directly.
- The debugger is faster than
printffor any bug involving pointers, memory corruption, complex control flow, or crashes.