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:
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
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
(gdb) break main # pause as soon as main() starts
(gdb) run
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:
(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:
(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:
(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:
(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:
#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.
gcc -std=c17 -Wall -Wextra -g -O0 -o buggy buggy.c
gdb ./buggy
(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.
(gdb) print arr[5]
$3 = 32601 # garbage — this memory doesn't belong to the array
Common mistakes
- Forgetting
-gwhen compiling, then wondering why gdb shows raw memory addresses and??instead of source lines and variable names. - Compiling with optimizations (
-O2or higher) while trying to debug — the compiler is free to reorder, inline, or eliminate code entirely, which can makenext/stepjump around in ways that don't match the source's visual order. Use-O0while actively debugging. - Using
nextwhen you actually neededstep(or vice versa) —nexttreats 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 awatchpoint, 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.