5 min read
On this page

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 -O0 for 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 -g during development.
  • Not using backtrace first — When a crash occurs, bt should 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 step into printf or malloc, you end up in glibc source code. Use next to step over library calls, or finish to 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 printfprintf debugging 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 -O0 for 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 print and backtrace.
  • Conditional breakpoints (break func if x == 42) and watchpoints (watch var) find specific bugs without manual searching.
  • The x command 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 printf for any bug involving pointers, memory corruption, complex control flow, or crashes.