C Basics · intermediate · ~20 min
- Compile a C program with the right flags (`-O0 -g`) so the debugger can see your variable and function names. - Start a program under gdb and control its execution with `run`, `continue`, and `start`. - Set breakpoints at functions and specific source lines, and step through code with `next`, `step`, and `finish`. - Inspect program state: print variables and expressions, dump memory, and list local variables. - Read a backtrace to reconstruct the chain of function calls that led to a crash. - Use a watchpoint to catch the exact moment a variable's value changes — the fastest way to find who is corrupting your data.
Up to now you have run C programs and watched what they print. But programs do not always cooperate: a value comes out wrong, a loop runs one time too many, or the program crashes with a bare Segmentation fault and no clue about where. printf debugging — sprinkling print statements into the code — works for a while, but it has limits. The bug might disappear when you add a print, the program might crash before any output flushes, or you might not even know which variable to print.
gdb (the GNU Debugger) is a tool that takes control of your running program. Instead of editing source and recompiling for each guess, you load the program into gdb, pause it wherever you want, and look directly inside: read any variable, walk the call stack, inspect raw memory, and step forward one line at a time. It is the difference between guessing and observing.
This lesson builds directly on Compiling with gcc. There you learned how to turn source into an executable; here you add two compile flags — -g and -O0 — that embed debugging information into that executable so gdb can map machine code back to your source. Everything you debug must first be compiled, so the toolchain skills carry straight over.
A debugger sounds intimidating, but in practice about a dozen commands cover almost every session. Once you learn to pause, inspect, and step, the rest is just applying those three ideas. The terminology — breakpoint, watchpoint, frame, backtrace — names things you already intuitively understand: places to stop, things to watch, and the history of how you got here.
Sooner or later, printf debugging stops working. The bug only appears on certain inputs, the crash happens before any output prints, or adding a print statement changes the timing enough to hide the problem. That is when you reach for a real debugger.
Most C developers hit this wall within their first months of writing nontrivial code. The ones who learn gdb get unstuck quickly; the ones who don't keep re-running the same intermittent bug, hoping it reveals itself. A debugger turns a vague "it crashes sometimes" into a precise "line 42 dereferences a NULL pointer because find_node returned NULL and we never checked."
In professional work the stakes are higher. Production C powers operating systems, databases, browsers, and embedded firmware. When one of those crashes, you often have only a core dump — a snapshot of memory at the moment of death. gdb can load that core file and show you the backtrace and variables as they were when it crashed, even though the program is long gone. Security researchers reading a CVE write-up, kernel developers diagnosing a panic, and application engineers chasing a heisenbug all reach for the same handful of gdb commands you are about to learn.
Before gdb can help you, the executable must carry debug symbols — a table mapping machine instructions back to your source lines, variable names, and types. You add them at compile time.
-g tells gcc to embed debug symbols. Without it, gdb only sees raw addresses and assembly, not int count or factorial.-O0 disables optimization. Optimizers reorder code, inline functions, and keep variables in CPU registers instead of memory — all of which make the running program no longer match your source line by line. With -O0, what you see in the debugger matches what you wrote.How it works internally: the compiler writes a section (DWARF debug info on Linux) into the executable file describing every line, scope, and variable. gdb reads that section to translate between addresses and source.
When NOT to use it: never ship a release build with -O0; it is slower and the binary is larger. Debug symbols themselves are usually fine to keep (or split into a separate .debug file), but -O0 is a development-only choice.
Pitfall: compiling with -O2 and then wondering why info locals shows nothing or why a variable "has been optimized out." The fix is to rebuild with -O0 -g.
Knowledge check: A teammate says "gdb shows
<optimized out>when I printtotal." Which compile flag most likely caused this, and how do you fix it?
A breakpoint is a marker that pauses the program when execution reaches a chosen spot, so you can look around before continuing.
break main — stop at the first line of mainbreak file.c:42 — stop at line 42 of file.cbreak factorial — stop each time factorial is enteredHow it works internally: gdb temporarily replaces the instruction at the breakpoint address with a special trap instruction. When the CPU hits the trap, control returns to gdb, which restores the original instruction and hands you the prompt.
When to use: when you know roughly where the problem is and want to inspect state at that point. When not to: if you have no idea where the bug is, a breakpoint on every line is useless — start broad (break main) and narrow down, or use a watchpoint instead.
Pitfall: setting break factorial but the function is never called because of a logic bug earlier — the program just runs to completion. That absence of a pause is itself a clue.
Source line What gdb does
----------- -------------
int factorial(int n) { <--- break factorial installs a trap here
if (n <= 1)
return 1; program pauses BEFORE running this body
return n * factorial(n-1);
}
Once paused, you advance the program in small increments.
| Command | What it does |
|---|---|
next (n) |
Run the current line, stepping over any function calls (the call runs but you don't enter it). |
step (s) |
Run the current line, stepping into a function call so you can watch it from the inside. |
finish |
Run until the current function returns, then pause; prints the return value. |
continue (c) |
Resume normal execution until the next breakpoint or the end. |
Pitfall: using step on a line full of library calls (like printf) and ending up deep inside C library source you did not mean to debug. Use next for lines you trust and step only for your own functions.
Knowledge check: You are paused on the line
int r = factorial(5);and you want to watchfactorialrun from the inside. Do you typenextorstep?
Pausing is only useful if you can see what the program holds.
print x (p x) — show the value of x. Works on expressions too: print a[i] + 1, print *p.print *p — follow a pointer and show what it points to.info locals — list all local variables in the current function.info args — list the current function's arguments.x/16xb addr — examine memory: dump 16 bytes (b) in hex (x) starting at addr. Format is x/NFU = count, format, unit.x/16xb &buf → 0x7fffffffe2a0: 0x48 0x65 0x6c 0x6c 0x6f 0x00 0x00 0x00
'H' 'e' 'l' 'l' 'o' '\0'
Pitfall: print p shows an address; print *p shows the pointed-to value. Confusing the two leads to misreading pointer bugs.
Each function call pushes a stack frame holding that call's locals and return address. A backtrace (bt) prints the chain of frames — the history of who called whom to get here.
#0 divide (a=10, b=0) at math.c:5 <- where we are now (innermost)
#1 compute (x=10) at math.c:14
#2 main () at math.c:22 <- outermost
After a crash, bt is usually the single most valuable command: it tells you exactly which line failed and the path that reached it. Use frame N (or f N) to switch into a frame and inspect that caller's locals.
Pitfall: reading only frame #0 and missing that the real bug is a bad argument passed in frame #1.
A watchpoint pauses the program whenever a chosen variable's value changes, no matter where in the code that happens.
watch x — stop every time x is modified, and show the old and new values.This answers the classic question: who is corrupting this variable? You set the watchpoint, type continue, and gdb stops at the exact line that wrote to it. Hardware watchpoints (used automatically when possible) are fast; watching a large region or many variables can be slow.
When to use: memory corruption, a value that becomes wrong with no obvious culprit. When not to: when you already know the line — a breakpoint is simpler.
Knowledge check: A global counter is correct at the start of
mainbut garbage by the time you print it. In your own words, which gdb feature finds the exact line that changed it, and why is that better than addingprintfeverywhere?
gdb commands are typed at the (gdb) prompt. Most have a short alias (shown in parentheses). A blank line repeats the last command — handy for next.
# Compile with debug info and no optimization
gcc -O0 -g -Wall -Wextra prog.c -o prog
# Launch the debugger on the program
gdb ./prog
(gdb) break main # set a breakpoint (alias: b)
(gdb) run [args...] # start the program, optionally with arguments (alias: r)
(gdb) next # step over one source line (alias: n)
(gdb) step # step into a call (alias: s)
(gdb) finish # run until the current function returns
(gdb) continue # resume until next breakpoint (alias: c)
(gdb) print expr # evaluate and show an expression (alias: p)
(gdb) info locals # all locals in the current frame
(gdb) backtrace # the call stack (alias: bt)
(gdb) watch var # break when var changes
(gdb) x/16xb addr # dump 16 bytes of memory in hex
(gdb) quit # leave gdb (alias: q)
The x (examine) format string reads as x/ then count, format (x=hex, d=decimal, c=char, s=string, i=instruction), and unit (b=byte, h=2 bytes, w=4 bytes, g=8 bytes).
gdb is the GNU debugger. It gives you full control over a running program.
With gdb you can:
Always compile with -O0 -g before debugging.
-g adds debug symbols, so gdb knows your variable and function names.-O0 turns off optimization, so local variables stay in scope and the code matches your source line by line.Save this program, which has a deliberate off-by-one bug, then walk it under gdb.
#include <stdio.h>
/* Returns the sum of the first n elements of a.
* BUG: the loop condition uses <= instead of <, so it reads one
* element past the end of the array (undefined behaviour). */
int sum_array(const int *a, int n) {
int total = 0;
for (int i = 0; i <= n; i++) { /* off-by-one: should be i < n */
total += a[i];
}
return total;
}
int main(void) {
int values[] = {10, 20, 30, 40};
int n = (int)(sizeof values / sizeof values[0]); /* n == 4 */
int result = sum_array(values, n);
printf("sum = %d\n", result);
return 0;
}
Compile and debug:
gcc -O0 -g -Wall -Wextra bug.c -o bug
gdb ./bug
(gdb) break sum_array # pause when we enter the buggy function
(gdb) run # start; stops at the start of sum_array
(gdb) info args # shows: a = <addr>, n = 4
(gdb) next # advance to the loop
(gdb) print i # watch i climb: 0, 1, 2, 3, then 4
(gdb) print a[i] # at i == 4 this reads past the array end
(gdb) finish # run to return; see the (wrong) total
(gdb) bt # confirm who called sum_array
(gdb) quit
What it does: main builds a 4-element array and asks sum_array to add its elements. Because the loop uses i <= n, it iterates for i = 0,1,2,3,4 — five times instead of four — reading a[4], which is outside the array. The expected correct sum is 100; the buggy version adds an extra garbage value from whatever sits past the array, so the printed result is unpredictable (often 100 + junk).
Expected output (correct version, i < n):
sum = 100
Edge cases: if n is 0, the correct loop runs zero times and returns 0; the buggy loop still runs once and reads a[0] out of an empty logical range. Passing a NULL pointer for a would crash on the first a[i] — gdb would stop there and bt would point straight at the dereference.
Walking the buggy run under gdb, here is what happens and how the state changes.
break sum_array installs a breakpoint at the function's first line.run starts the program. main runs until it calls sum_array, where gdb pauses. The stack now has two frames: main (#1) and sum_array (#0).info args shows the arguments to the current frame: a is the array's address and n is 4.next advances past int total = 0; and into the loop. info locals would now show total = 0 and i = 0.next (or pressing Enter to repeat) runs each iteration. Tracing the key values:Iteration (i) |
i <= n? |
a[i] read |
total after |
|---|---|---|---|
| 0 | yes | 10 | 10 |
| 1 | yes | 20 | 30 |
| 2 | yes | 30 | 60 |
| 3 | yes | 40 | 100 |
| 4 | yes (bug!) | a[4] = garbage past the array |
100 + garbage |
| 5 | no | — | loop ends |
i reaches 4, print a[i] reads one element beyond the array — this is the out-of-bounds access. The value is whatever happens to sit in memory there, so total becomes wrong.finish runs sum_array to its return and prints the (incorrect) return value gdb captured.bt shows the call chain #0 sum_array ... #1 main, confirming the path. Fixing the condition to i < n makes the trace stop after iteration 3 with total = 100.The lesson of the trace: the bug is not a crash but a wrong value, and gdb let you watch the exact iteration where i went out of range.
Mistake 1 — Debugging an optimized build.
gcc -O2 bug.c -o bug # WRONG for debugging: no -g, full optimization
gdb ./bug
(gdb) info locals
# (often empty, or values show <optimized out>)
Why it's wrong: without -g there are no symbols, and -O2 may delete or relocate locals so they no longer exist when you look. Corrected:
gcc -O0 -g -Wall bug.c -o bug # symbols on, optimization off
Recognize it when variable names are missing or print as <optimized out>; prevent it by keeping a dedicated debug build.
Mistake 2 — Confusing step and next. Typing step on a line that calls printf drops you into C library internals. Use next to step over trusted calls and step only to enter your own functions. If you land somewhere unexpected, finish runs back out.
Mistake 3 — Reading only the top of the backtrace. A crash often shows up inside a library or a leaf function, but the cause is a bad argument from a caller. After bt, use frame 1, frame 2, ... and info args / info locals in each to find where the bad value originated.
Mistake 4 — Forgetting that gdb does not fix bugs, it reveals them. Stepping past a crash with continue just lets the program die. The goal is to stop at the fault, inspect, then fix the source and recompile.
Mistake 5 — Not passing program arguments to run. If your program needs argv, you must type them after run (e.g. run input.txt), not on the gdb command line.
Compiler/setup errors
No symbol "x" in current context — you are not stopped inside the function where x lives, or you compiled without -g. Check your frame with bt and rebuild with -O0 -g.No debugging symbols found — you forgot -g. Recompile.Runtime errors
bt to see the call path, then print the pointers on that line — a 0x0 address means a NULL dereference.Program received signal SIGABRT usually means an assertion failed or the heap detected corruption (e.g. a double free). bt shows where.Logic errors (wrong value, no crash)
print to find the first moment it goes bad.watch it and continue to land on the exact write.Questions to ask when it "doesn't work":
-O0 -g?bt, info frame)?info locals, info args)?p p vs p *p)?gdb observes memory bugs; it does not prevent them. That makes it ideal for understanding undefined behaviour after the fact, but you must still know what to look for in C.
a[i] at i == n reads past the array. gdb will happily show you the garbage value, but reading or writing out of bounds is undefined behaviour — sometimes it "works," sometimes it crashes or corrupts neighbours. Use print &a[0] and print &a[n] to reason about valid ranges.info locals right after entering a function shows garbage values until they are assigned. Reading them before initialization is undefined behaviour.print *p may show stale or random data. gdb cannot tell you the memory is dead — that is on you to track.For automatic detection, gdb pairs well with sanitizers: rebuild with -fsanitize=address (AddressSanitizer) and run; ASan reports the exact out-of-bounds or use-after-free with a stack trace, and you can still attach gdb to pause at the report. Treat gdb as the microscope and sanitizers as the smoke detector — use both.
Concrete uses
gdb ./service core to load the snapshot and read the backtrace and variables exactly as they were at death — no need to reproduce the crash live.gdb vmlinux core or attach gdb over a serial/JTAG link to firmware on a microcontroller.bt, print, and x.Professional best practices
Beginner habits:
-O0 -g -Wall -Wextra) alongside your release build.bt first, every time.Advanced habits:
break file.c:42 if i == 4) to stop only on the interesting iteration instead of stepping hundreds of times..gdbinit or gdb -x commands.txt for reproducible debugging.tui enable (text UI) to see source and code side by side, and frame/up/down to navigate the stack fluently.Beginner 1 — First pause and print.
Objective: get comfortable starting and stopping a program. Take any small program you have written (or the bug.c above), compile it with -O0 -g, set break main, run, then use next to step line by line and print each local variable as it changes. Requirement: reach the end of main using only stepping and printing. Hint: press Enter to repeat the last command. Concepts: breakpoints, stepping, print.
Beginner 2 — Read a segfault backtrace.
Objective: turn a crash into a precise location. Write a tiny program that dereferences a NULL pointer (e.g. int *p = NULL; printf("%d\n", *p);), compile with -O0 -g, run it under gdb, and when it stops, run bt and print p. Requirement: state in one sentence which line crashed and why. Concepts: running under gdb, backtrace, print.
Intermediate 1 — Trace the off-by-one.
Objective: confirm a logic bug by observation. Using the bug.c example, break on sum_array, step through the loop, and print i and print total on every iteration. Requirement: identify the exact iteration where i exceeds the valid range and record the values from the trace table. Input/output: input array {10,20,30,40}; correct output 100. Hint: compare i against n each pass. Concepts: stepping, print, info locals, out-of-bounds reasoning.
Intermediate 2 — Catch a variable change with a watchpoint.
Objective: find who modifies a value. Write a program with a global int counter = 0; that several functions increment, then set watch counter, continue, and note each line that changes it. Requirement: list, in order, the lines where counter was modified and its old/new values. Constraint: do not add any printf. Concepts: watchpoints, continue, frames.
Challenge — Find a corrupting out-of-bounds write.
Objective: locate memory corruption. Write a program with a local array int buf[4]; and a loop that writes one element too far (buf[i] for i up to and including 4), overwriting an adjacent variable. Set a watch on the adjacent variable, run, and catch the exact write that corrupts it; then read the backtrace to explain the path. Requirement: report the corrupting line, the old and new values of the adjacent variable, and the source fix. Hint: also try rebuilding with -fsanitize=address and compare what it reports to what your watchpoint found. Concepts: watchpoints, backtrace, out-of-bounds writes, sanitizers.
-O0 -g so debug symbols and locals are available and the code matches your source line by line.break, run/continue, next/step/finish, print, info locals/info args, backtrace, watch, and x/16xb.bt first to see where you are and how you got there; to catch a value that goes bad with no obvious cause, use watch.step with next, reading only the top frame, and forgetting that gdb reveals bugs but you must fix the source. Pair gdb with AddressSanitizer for automatic detection of memory bugs.