Debugging C++ with a debugger is faster and more informative than scattering [[std::cout]] calls through your code. A debugger lets you stop the program at a chosen line, look at every variable in scope, step through the code one statement at a time and see exactly where things go wrong — including in code you did not write, which is where most real bugs live.
Building with debug information
A debugger needs to know the source lines that produced a given machine instruction, and that mapping lives in the debug symbols. Compile with -g.
# build with debug symbols and no optimisation
g++ -std=c++17 -Wall -Wextra -g -O0 main.cpp -o inventory
# run it under the debugger
gdb ./inventoryThe -g flag adds the symbol table and the line number table. The -O0 flag turns off optimisation, because an optimised binary hides the code you wrote behind the code the compiler produced, and stepping through it becomes baffling.
Breakpoints and stepping
Inside GDB, the essential commands are few.
(gdb) break Inventory::add_item set a breakpoint on a function
(gdb) break inventory.cpp:42 set a breakpoint on a line
(gdb) run start the program
(gdb) next execute the next line, skipping into calls
(gdb) step execute the next line, entering calls
(gdb) continue run until the next breakpoint
(gdb) print item.sku show the value of a variable
(gdb) backtrace show the call stack
(gdb) quit leave the debuggernext and step are the pair you will use most. next runs the current line and stops at the next line in the *current* function. step runs the current line and stops in whatever it calls, which is how you follow a call into a function you did not write.
The Windows equivalent is LLDB, which is also available on Linux and macOS, and uses almost the same commands.
Watching values as they change
A watch expression is re-evaluated every time the program stops, which is how you catch the moment a value becomes wrong rather than the moment you notice.
#include
#include
#include
struct Item {
std::string sku;
int quantity;
};
int main() {
std::vector stock = {{"A-100", 5}, {"B-200", 3}};
int adjustments[3] = {2, -10, 4};
for (int i = 0; i < 3; ++i) {
for (Item& item : stock) {
item.quantity += adjustments[i];
}
std::cout << "after adjustment " << i << '\n';
}
for (const Item& item : stock) {
std::cout << item.sku << ' ' << item.quantity << '\n';
}
return 0;
}The output shows what happens when the adjustments are applied one at a time:
after adjustment 0
after adjustment 1
after adjustment 2
A-100 1
B-200 -1With the program stopped at the line inside the inner loop, a watch on item.quantity shows the value at every iteration, and the watch on adjustments[i] shows which adjustment is being applied. That turns a question about the state into a question you answer by looking.
The sanitizers find what a debugger cannot
A debugger finds the bug you are looking at. The sanitizers find the bugs you were not looking for, and they report them with the exact line.
# address sanitizer: out-of-bounds, use-after-free, double free
g++ -std=c++17 -Wall -Wextra -g -fsanitize=address main.cpp -o inventory_asan
# undefined behaviour sanitizer: signed overflow, bad shifts, misaligned access
g++ -std=c++17 -Wall -Wextra -g -fsanitize=undefined main.cpp -o inventory_ubsan
# both at once, plus leak detection
g++ -std=c++17 -Wall -Wextra -g -fsanitize=address,undefined main.cpp -o inventory_sanThe address sanitizer intercepts every memory access and checks it against the allocation that owns it. When it finds a problem it prints the file, the line and the stack trace, and then aborts.
#include
#include
int main() {
std::vector values = {1, 2, 3};
std::cout << "reading past the end\n";
std::cout << values[7] << '\n';
return 0;
}Run under the sanitizer, this reports the out-of-bounds read, the line it happened on, and the allocation that owned the memory.
Core dumps and post-mortem debugging
If a program crashes in production, the shell can be asked to write a core dump, and the debugger can then open it and show you where the program was.
ulimit -c unlimited
./inventory
gdb ./inventory coreFrom there, backtrace shows the call stack at the moment of the crash, and info locals shows the variables in the crashing frame. This is often the only evidence you will get of a bug that only happens under real load.
Debugging checklist
When a program misbehaves, work through these in order.
- Build with
-g -O0and with-Wall -Wextra. - Run it under the sanitizers. Most memory bugs are reported immediately.
- Reproduce it reliably. A bug you cannot reproduce is a bug you cannot fix.
- Break on the line where the value first looks wrong, not where the symptom appeared.
- Check the inputs with a watch expression before checking the logic.
- Read the whole stack with
backtrace; the interesting frame is often not the top one. - Bisect. If the bug appears after a change, use
git bisectto find the commit that introduced it.
Quick reference
- Build with
-g -O0so the debugger can map machine code to your source. break,run,next,step,continueandprintare the six commands you need.nextsteps over a call;stepsteps into it.- Watch expressions re-evaluate at every stop and show when a value changed.
-fsanitize=address,undefinedfinds memory andundefined-behaviour bugs and names the line.- A core dump plus
gdb ./program coreturns a production crash into a stack trace.
Try it yourself: write a program with a deliberate out-of-bounds read, build it with the sanitizers, and read the report. Then delete the [[std::cout]] calls from a function, set a breakpoint on it, and use [[next]] and [[step]] to follow the execution. Finally, break a program deliberately and open the core dump to see the stack trace.