Debugging with gdb

Compiling with -g, breakpoints, stepping through code, printing and watching variables, and a real bug found live.

Why print-statement debugging isn't enough

Sprinkling printf calls through a program to see what's happening is a legitimate technique, and plenty of real bugs get found this way — but it has real limits in C specifically: you have to guess in advance which variables matter, every added printf is a code change you have to remember to remove, and it's useless for inspecting things like the exact value of a pointer, the call stack at the moment of a crash, or memory contents at an arbitrary point in time. gdb (the GNU Debugger) lets you pause a running program at any line, inspect and even modify any variable's live value, and walk the call stack — all without changing a single line of the program's actual source.

Compiling with debug symbols: -g

By default, a compiled executable strips out the information that would let a debugger map machine instructions back to source lines and variable names — the -g flag tells the compiler to keep that information (debug symbols) in the binary:

Bash
gcc -std=c17 -Wall -Wextra -g -o buggy buggy.c

-g doesn't change how the program runs or its output — it only adds debugging metadata to the resulting binary, at the cost of a somewhat larger executable. It's standard practice to compile with -g throughout development (many projects just always include it, alongside -O0 to disable optimizations that can otherwise reorder code in ways that make stepping through it confusing) and strip it out only for a final release build.

Starting gdb

Bash
gdb ./buggy

This drops you into gdb's own (gdb) prompt, with the program loaded but not yet running. The most common commands from here:

Command Effect
run (or r) Start (or restart) the program running under gdb
break <location> (or b) Set a breakpoint — pause execution when that line/function is reached
next (or n) Execute the current line, stepping over any function call it makes
step (or s) Execute the current line, stepping into a function call if there is one
continue (or c) Resume running until the next breakpoint or the program ends
print <expr> (or p) Print the current value of a variable or expression
backtrace (or bt) Show the full call stack at the current paused point
list (or l) Show source code around the current line
quit (or q) Exit gdb

Setting a breakpoint and running

Bash
(gdb) break main       # pause as soon as main() starts
(gdb) run
Text
Breakpoint 1, main () at buggy.c:6
6           int total = sumArray(numbers, 5);

You can also break at a specific line number or a specific function anywhere in the program:

Bash
(gdb) break buggy.c:12
(gdb) break sumArray

Stepping through code and printing variables

Once paused at a breakpoint, next and step move forward one line at a time, and print inspects any variable currently in scope:

Bash
(gdb) next
7           printf("Total: %d\n", total);
(gdb) print total
$1 = 10

step is the one to reach for when you specifically want to follow execution into a function call rather than run it as one opaque step:

Bash
(gdb) step          # if the current line calls a function, this steps INSIDE it

A watchpoint pauses execution the instant a specific variable's value changes, anywhere in the program — invaluable for tracking down exactly where a value gets corrupted, without knowing in advance which line does it:

Bash
(gdb) watch total
Hardware watchpoint 2: total
(gdb) continue

A worked example: finding a real bug

Here's a small, genuinely buggy program — it's meant to sum the first 5 elements of a 5-element array, but the loop condition has an off-by-one error:

C
#include <stdio.h>

int sumArray(int *arr, int length) {
    int total = 0;
    for (int i = 0; i <= length; i++) { // BUG: should be i < length, not i <= length
        total += arr[i];
    }
    return total;
}

int main(void) {
    int numbers[5] = {1, 2, 3, 4, 5};
    int total = sumArray(numbers, 5);
    printf("Total: %d\n", total);
    return 0;
}

Run it a few times and the output is unpredictable — sometimes 15 (the correct sum, if the out-of-bounds arr[5] happens to read a stray 0 from adjacent memory), sometimes some other garbage number entirely. That inconsistency is itself a strong hint of undefined behavior rather than a straightforward logic error — exactly the kind of bug gdb is good at pinning down.

Bash
gcc -std=c17 -Wall -Wextra -g -O0 -o buggy buggy.c
gdb ./buggy
Text
(gdb) break sumArray
(gdb) run
Breakpoint 1, sumArray (arr=0x7ffee3a1b440, length=5) at buggy.c:4
4           int total = 0;

(gdb) next
5           for (int i = 0; i <= length; i++) {

(gdb) watch i
Hardware watchpoint 2: i

(gdb) continue
...
(gdb) print i
$1 = 5
(gdb) print length
$2 = 5

At this point, seeing i reach 5 while length is also 5 and the loop condition is i <= length makes the bug obvious: on the last iteration, i equals 5, which is a valid value for the condition but is one past the last valid index of a 5-element array (arr[0] through arr[4]) — arr[5] reads memory that doesn't belong to the array at all. Stepping through with next a few more times and printing arr[i] at i = 5 confirms it's reading garbage, not a real array element. The fix is exactly what the comment says: i < length instead of i <= length.

Bash
(gdb) print arr[5]
$3 = 32601   # garbage — this memory doesn't belong to the array

Common mistakes

  • Forgetting -g when compiling, then wondering why gdb shows raw memory addresses and ?? instead of source lines and variable names.
  • Compiling with optimizations (-O2 or higher) while trying to debug — the compiler is free to reorder, inline, or eliminate code entirely, which can make next/step jump around in ways that don't match the source's visual order. Use -O0 while actively debugging.
  • Using next when you actually needed step (or vice versa) — next treats a function call as one atomic step and never shows you what happens inside it, which is fine most of the time but useless if the bug is actually inside that called function.
  • Relying purely on print-once-and-guess instead of a watchpoint, when the real question is "where does this variable's value actually change" rather than "what is its value right now."

Interview questions

Q: What does the -g compiler flag actually do, and why doesn't it change the program's behavior? It tells the compiler to embed debug symbols — a mapping from machine instructions back to source file lines, function names, and variable names — into the compiled binary. It doesn't alter the generated machine code's logic at all, only adds this extra metadata alongside it, which is exactly what lets a debugger like gdb show source lines and variable names instead of raw addresses.

Q: What's the difference between next and step in gdb, and when would you specifically need step? Both execute the current line and pause on the next one, but next treats a function call on that line as a single opaque step — it runs the entire called function without pausing inside it — while step follows execution into the call, pausing at its very first line. You need step specifically when you suspect the bug is inside the function being called, rather than in the code that calls it.