Foundations · Module 1 · Lesson 7 of 9
Stepping Through a Program With a Debugger
Build a program for debugging, pause it at a breakpoint, step through it line by line and inspect variables, and learn how a debugger stops a running program without changing your source.
By the end you can
- Build a program so that a debugger can show source lines and variable names
- Choose between step over, step into, step out and continue for a given situation
- Explain how a breakpoint stops a running program
- Use a debugger to find where a computed value first becomes wrong
Builds on Variables Are Names for Bytes, Reading Compiler Errors and Warnings.
01 · A question first
Your program compiles without a single warning and prints 7 when the answer should be 7.5. You could add print statements, rebuild and rerun again and again. Or you could freeze the program mid-run, ask it what its variables hold, and watch it go line by line. How does a debugger stop a running program at exactly the line you choose?
Make a guess, then step through the trace below to check it.
02 · Watch it run
int total = 7 + 8;int count = 2;double avg = total / count;std::cout << avg << "\n";- var 0total: ?
- var 1count: ?
- var 2avg: ?
The program prints 7 but we expected 7.5. Instead of guessing, we run it under a debugger with a breakpoint on line 1. The debugger stops the program before that line runs. The three cells show total, count and avg, which hold leftover bytes for now.
Machine work: debugger: break at line 1, then run; the program is paused before line 1
03 · The idea
Start with the problem
A program compiles with no warnings and prints the wrong answer:
#include <iostream>
int main() {
int total = 7 + 8;
int count = 2;
double avg = total / count;
std::cout << avg << "\n";
}It prints 7, and the answer should be 7.5. You could add std::cout lines, rebuild and rerun, but each experiment costs an edit-compile-run cycle, and you only see what you thought to print in advance. A debugger works the other way around. It pauses the running program, lets you read any variable, and walks forward one line at a time, so you watch what happens instead of guessing.
How the idea works
Build for debugging. Compile with debug information and without optimization:
g++ -std=c++20 -Wall -Wextra -g -O0 main.cpp -o mainThe -g flag records a table that maps machine-code addresses back to source lines, and memory locations back to variable names and types. This is the table lesson 3 meant when it said that names survive only as debug information. The -O0 flag turns the optimizer off, so the machine code stays in step with the source.
Run under a debugger. Use gdb ./main on Linux, lldb ./main on macOS, or the Debug button of an IDE such as VS Code, CLion or Visual Studio. The commands below are gdb's, and IDEs show the same operations as buttons:
break 6orbreak mainsets a breakpoint: pause before that line runs.runstarts the program, which stops at the first breakpoint.nextis step over: run the current line completely, including any calls on it, and stop at the next line.stepis step into: if the line calls a function, enter that function.finishis step out: run until the current function returns.continueruns until the next breakpoint or the end of the program.print total / countevaluates an expression using the program's own types and current values, andinfo localslists every local variable.backtraceshows the call stack, the chain of functions that led to the current line.watch totalsets a watchpoint: stop whenever that variable changes.x/4xb &totalexamines memory: four bytes in hexadecimal, starting at the address oftotal.
The session in the trace. The trace above debugs the four lines inside main. Each step highlights the line that is about to run, not the line that ran before it. Steps 2 and 3 use next to execute the two declarations. Step 4 is the one that matters: before line 3 runs, the debugger is asked to evaluate total / count and answers 7. The bug is visible without running the line, because both operands are int, so the division drops the fraction before the result is stored in the double. A real session looks like this:
(gdb) break 6
(gdb) run
(gdb) print total / count
$1 = 7
(gdb) x/4xb &total
0x7ffc2b3a9e5c: 0x0f 0x00 0x00 0x00The last command connects back to lesson 3: 15 is 0x0f, stored as the four bytes 0f 00 00 00.
Why it is correct
A breakpoint needs no cooperation from your program. The debugger takes control of your process through the operating system, looks up the address of the chosen line in the debug table, and overwrites the first byte of the instruction there with a one-byte trap instruction, which is int3, byte 0xCC, on x86-64. When the CPU reaches it, it traps into the operating system, which stops your program and notifies the debugger. To resume, the debugger puts the original byte back, executes that one instruction, re-inserts the trap and lets the program continue. The breakpoint never existed in your source, which is why you can add and remove breakpoints without rebuilding.
Stepping with next and step works by running to the next line boundary in the table. Watchpoints use debug registers inside the CPU, typically four on x86, that trap when a chosen address is written, so they cost almost nothing while the program runs. And print follows the same C++ rules as compiled code, which is why it reports 7 for total / count: the debugger is not guessing what you meant, it is telling you what the language does.
What it costs
Slower, larger debug builds. Without optimization, code commonly runs several times slower than an -O2 build. Debug information can make the file much larger, though it is not loaded into memory when the program runs normally.
Changed timing. Pausing a program changes when things happen. Bugs that depend on timing, such as races between threads, may vanish under a debugger, and a network program may time out while you stand at a breakpoint.
Your time. For a one-value question, a print statement is quicker. As soon as you add a second print statement, or need to see many variables or the call stack, the debugger wins, because asking a new question never requires an edit, a rebuild and a rerun.
Pitfalls and variations
Debugging an optimized build. With -O2 the debugger may show optimized out instead of a value, and execution jumps around the source because the compiler reordered and merged code. Use -O0, or -Og, which is GCC's debug-friendly optimization level.
Stepping into the library. Using step on a line such as std::cout << avg can take you into the standard library. Use next for lines you trust, and finish to climb back out.
Reading the wrong line. The highlighted line has not run yet, so the variables you inspect show the state before it.
Garbage before initialization. Before line 1 runs, the variables hold leftover bytes, the ? cells in the trace. Do not mistake them for results.
Variations. Conditional breakpoints, such as break 20 if i == 99, pause only when a condition is true. A crashed program can leave a core dump, and gdb ./main core then shows its state at the crash. Sanitizers, such as -fsanitize=address, catch memory errors without stepping, and a later module covers them. VS Code, CLion and Visual Studio wrap all of this in a graphical interface.
Where it is used
Debuggers sit behind every IDE's Debug button, and in production they are how engineers read crash dumps: the first thing to look at after a crash is the backtrace. Later modules lean on this skill when pointers, memory and object lifetimes enter the picture, because a debugger shows addresses and bytes directly, exactly as in lesson 3.
That completes the first module. You can now build a program, read its source, understand how its values sit in memory, take input safely, read compiler messages, and step through a running program. The next module builds on this with decisions, loops and functions.
04 · Prove it
Question 1 of 4 · answer from memory
Which build gives the best experience when you want to step through a program in a debugger?
05 · Practice
Asked in interviews
Something wrong with this lesson? Report a problem