Lesson • Advanced
Advanced Debugging
By the end of this lesson you'll be able to read a C++ compiler error and fix the real cause, add assertions and print/cerr traces, drive gdb to pause and inspect a running program, and switch on AddressSanitizer and UBSan to catch memory bugs and undefined behaviour automatically.
What You'll Learn
- Read a compiler error and fix the FIRST real cause, not the noise
- Catch your own mistakes early with assert and static_assert
- Trace a running program with print and cerr debugging
- Drive gdb: break, run, next, step, print, backtrace
- Find memory bugs and UB with -fsanitize=address,undefined
- Pick the right build (debug vs release) and know the common bug types
💡 Real-World Analogy
Debugging is detective work, not guesswork. A symptom (a crash, a wrong number) is the body at the scene; your job is to follow the evidence back to the cause. assert statements are tripwires you set so the alarm goes off the instant an assumption is broken — close to the crime, not three rooms away. cerr traces are the detective's notebook. gdb is the freeze-frame: stop time, walk the room, and read every variable. And sanitizers are the forensics lab — they spot the fingerprints (a stray pointer, an overflow) that your eyes would never catch. The golden rule: reproduce, then narrow, then fix one thing at a time.
🐞 The Common Bug Categories
| Category | Looks like | Best tool |
|---|---|---|
| Compile error | Won't build; type / syntax mismatch | read first error |
| Logic / off-by-one | Builds, wrong answer | assert + gdb |
| Use-after-free | Random crash, "works sometimes" | ASan |
| Buffer overflow | Corruption / crash near arrays | ASan |
| Undefined behaviour | Overflow, bad cast, null deref | UBSan |
| Memory leak | Memory grows over time | Valgrind / LeakSanitizer |
The skill isn't memorising every tool — it's matching the symptom to the right tool. A random "works sometimes" crash screams memory bug, so reach straight for AddressSanitizer instead of staring at the code.
1. Reading Errors & a Worked Fix
Bugs come in two waves. Compile errors stop the build, and the rule is simple: fix the first error first — later errors are usually knock-on noise from the same mistake. Read the file:line at the start, then the short phrase after error:. The second wave is runtime bugs: the code builds but does the wrong thing. The single most common one is the off-by-one — looping one step too far. Study this worked example: a buggy average function, and right below it the commented fix.
Worked example: an off-by-one bug and its fix
Read the buggy version, then the commented fix below it. Run it.
#include <iostream>
#include <vector>
using namespace std;
// A program that should print the average of some scores.
// It has a classic off-by-one bug. The FIX is shown below it.
double averageBuggy(const vector<int>& scores) {
int sum = 0;
// 🐞 BUG: <= reads scores[scores.size()], one past the end.
// That index does not exist -> undefined behaviour (garbage / crash).
for (size_t i = 0; i <= scores.size(); i++) {
sum += scores[i];
}
return static_cast<double
...Why "works sometimes" is the worst outcome: the buggy loop reads one slot past the array. That memory might happen to be readable today and crash tomorrow. A bug that hides is more dangerous than one that crashes loudly — which is exactly why we use the tools below to force it into the open.
2. Assertions & Print/cerr Debugging
An assert(condition) aborts the program with a message the moment condition is false. It does two jobs at once: it documents an assumption and it checks it, so a broken assumption trips the alarm right where it happens instead of corrupting data and crashing far away. Print debugging is the other workhorse — but send it to cerr, the error stream, not cout. cerr is unbuffered, so the line appears immediately even if the program crashes on the very next statement; cout is buffered and can swallow your last message.
Worked example: assert + cerr tracing
See a precondition assert and a running cerr trace.
#include <iostream>
#include <cassert>
#include <vector>
using namespace std;
// assert(cond) aborts with a message if cond is FALSE. It documents
// an assumption AND checks it. (Stripped out in release via -DNDEBUG.)
int factorial(int n) {
assert(n >= 0 && "factorial needs a non-negative n"); // precondition
int result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
// cerr is the ERROR stream: unbuffered, so it prints even if we
// crash next line. Great
...Pro Tip: never put work with side effects inside an assert. In a release build (-DNDEBUG) the whole assert(...) is removed, so assert(file.open()) would silently skip opening the file. Assert on a value you already computed, not on the act of computing it.
Now you try. The loop below should print all four names, but it walks one index too far — the same off-by-one from the worked example. Replace the ___ with the correct comparison and run it.
🎯 Your turn: find and fix the off-by-one
Replace the ___ comparison, then check the four expected lines.
#include <iostream>
#include <vector>
using namespace std;
int main() {
// 🎯 YOUR TURN — this loop should print all 4 names, but it
// crashes / prints garbage on the last step. Find and FIX the bug.
vector<string> names = {"Ada", "Bjarne", "Grace", "Linus"};
// 👉 Replace ___ with the correct comparison so i never reaches
// names.size() (the first index that does NOT exist).
for (size_t i = 0; i ___ names.size(); i++) { // 👉 use < not <=
cout << i <
...One more. This function divides by price, which blows up to inf/nan when the price is zero. Add a guard so the second call returns 0 safely instead of dividing by zero.
🎯 Your turn: guard against divide-by-zero
Add the if-guard on the ___ line, then check both cases.
#include <iostream>
using namespace std;
// This should report a discount as a percentage of the price.
double discountPercent(double saved, double price) {
// 🐞 BUG: if price is 0 we divide by zero (result is inf / nan).
// 👉 Add a guard that returns 0.0 when price is 0, THEN do the maths.
___ // 👉 if (price == 0) return 0.0;
return saved / price * 100.0;
}
int main() {
// 🎯 YOUR TURN — fix discountPercent above so the 2nd call is safe
...3. Driving gdb: Pause and Inspect
When prints start multiplying, switch to a real debugger. gdb (the GNU Debugger; lldb is the equivalent on macOS) lets you pause a program, read any variable, and step through line by line. First compile with debug info and no optimisation — -g adds the symbols the debugger needs, and -O0 stops the optimiser from reordering code or deleting variables you want to watch. Then it's a small set of commands you'll use constantly:
🧭 The gdb commands you'll actually use
g++ -g -O0 program.cpp -o program # compile with debug info, no optimisation gdb ./program # start the debugger break main # (b) pause at the start of main break 42 # pause at line 42 run # (r) start the program; stops at the first breakpoint next # (n) run the next line, stepping OVER function calls step # (s) run the next line, stepping INTO a function call print scores # (p) show a variable's current value print scores[i] # inspect any expression backtrace # (bt) show the call stack — how you got here continue # (c) resume until the next breakpoint or the end quit # (q) leave gdb
The two that unlock everything: print answers "what is this value right now?" and backtrace answers "how did I get here?" — invaluable when a crash happens deep inside a chain of calls. Run gdb on a program that crashes and backtrace points straight at the offending line.
4. Sanitizers & Build Types
Sanitizers are the closest thing C++ has to a superpower. You add one compiler flag, run your program normally, and it reports the exact file and line of a memory bug or undefined behaviour. AddressSanitizer (-fsanitize=address) catches use-after-free, buffer overflow, and double free. UBSan (-fsanitize=undefined) catches undefined behaviour such as signed integer overflow and bad casts. You can switch both on together:
🧪 Turn on the sanitizers
# Build with BOTH AddressSanitizer and UBSan, plus debug info: g++ -g -O1 -fsanitize=address,undefined program.cpp -o program ./program # just run it — a bug prints an exact, readable report # Example ASan report for a buffer overflow: # ERROR: AddressSanitizer: heap-buffer-overflow ... # READ of size 4 at 0x... thread T0 # #0 0x... in averageBuggy(...) program.cpp:11 # ^ the exact line!
ASan adds roughly 2x runtime overhead — cheap enough to keep on for every test run and in CI. Make it your default debug build and most memory bugs reveal themselves the first time they execute, instead of mysteriously months later.
Debug vs release builds matter here. A debug build uses -g -O0 (and leaves assert on) so tools can see everything — this is what you develop and test with. A release build uses -O2 -DNDEBUG: the optimiser makes it fast and -DNDEBUG strips out every assert. Ship the release build, but never test only in release — you'd be running code your tools can't see into.
Pro Tips
- 💡 Reproduce first. A bug you can't trigger on demand, you can't fix. Get a reliable repro before you change a single line.
- 💡 Fix the FIRST compiler error and rebuild — the rest are often phantoms caused by the first.
- 💡 Make ASan your default debug build.
-fsanitize=address,undefined -gturns "works sometimes" crashes into precise reports. - 💡 Change one thing at a time. Two simultaneous fixes mean you won't know which one worked.
Common Errors (and the fix)
- "Segmentation fault (core dumped)": you read or wrote memory you don't own — often a null/dangling pointer or an out-of-range index. Rebuild with
-g -fsanitize=addressand run; ASan names the exact line. - Wrong answer, no crash (off-by-one): a loop using
i <= v.size()reads one slot too far, or starts at1instead of0. Usei < v.size()and add anasserton the index. - "AddressSanitizer: heap-use-after-free": you used a pointer after
delete(or after the owning object went out of scope). The fix is ownership — prefer smart pointers so the lifetime is clear. - "runtime error: signed integer overflow" (UBSan): a calculation exceeded
INT_MAX. Use a wider type likelong long, or check the range before multiplying. - Debug trace vanished before the crash: you used
cout(buffered) so the last line was lost. Switch the trace tocerr, which is unbuffered and survives the crash.
📋 Quick Reference
| Goal | Command / Flag | What it does |
|---|---|---|
| Debug build | g++ -g -O0 a.cpp | Symbols + no optimisation |
| Release build | g++ -O2 -DNDEBUG a.cpp | Fast; strips asserts |
| Start debugger | gdb ./program | Load program in gdb |
| Set breakpoint | break main / break 42 | Pause at a function/line |
| Run / step | run · next · step | Start, step over, step in |
| Inspect | print x · backtrace | Value now · call stack |
| Memory + UB | -fsanitize=address,undefined | ASan + UBSan reports |
| Leaks | valgrind --leak-check=full | Find memory leaks |
Frequently Asked Questions
Q: How do I read a long C++ compiler error?
Always fix the FIRST error first — later ones are often knock-on noise. Read the file:line at the start of the message, then the short phrase after 'error:'. With templates the wall of text can be huge; the real cause is usually the top line that names YOUR file, not the deep std::vector internals below it.
Q: What is the difference between a debug build and a release build?
A debug build (-g -O0) keeps debug symbols and turns optimisation off, so the debugger can map every line and variable. A release build (-O2 -DNDEBUG) optimises for speed and strips assert() checks. Develop and test with a debug build; ship the release build.
Q: Should I use print/cerr debugging or a real debugger like gdb?
Both. A quick cerr line is fastest for 'which branch ran?' questions. gdb wins when you need to pause, inspect many variables, walk the call stack, or catch a crash you cannot reproduce on paper. Reach for gdb the moment print statements start multiplying.
Q: Why use cerr instead of cout for debug messages?
cerr is the standard ERROR stream and is unbuffered, so its output appears immediately — even if the program crashes a line later. cout is buffered, so a crash can swallow your last cout. cerr also stays separate from real program output, so you can filter it out.
Q: What do AddressSanitizer and UBSan actually catch?
AddressSanitizer (ASan) catches memory errors: use-after-free, buffer overflow, and double free. UndefinedBehaviorSanitizer (UBSan) catches undefined behaviour like signed integer overflow, out-of-range casts, and null-pointer use. Build with -fsanitize=address,undefined -g and just run your program — they report the exact line.
Mini-Challenge: Safe Range Sum
No blanks this time — just a brief and a blank canvas (with an outline to keep you on track). Build it with assert preconditions and a cerr trace, run it, and check your output against the example in the comments. This combines everything from the lesson into one small, defensive function.
🎯 Mini-Challenge: write a defensive sumRange
Add assert preconditions and a cerr trace, then sum a sub-range.
#include <iostream>
#include <cassert>
#include <vector>
using namespace std;
// 🎯 MINI-CHALLENGE: a safe sum function with assertions + a debug trace
//
// 1. Write int sumRange(const vector<int>& v, size_t start, size_t end)
// that returns v[start] + ... + v[end-1].
// 2. assert that start <= end AND end <= v.size() (your preconditions).
// 3. Inside the loop, print a debug line to cerr showing i and the
// running total (so you can trace it without a debugger).
// 4. In main, call
...🎉 Lesson Complete
- ✅ Fix the first compiler error first; later ones are usually noise
- ✅
assertdocuments and checks assumptions; it's stripped in release (-DNDEBUG) - ✅ Trace with
cerr(unbuffered), notcout, so output survives a crash - ✅
gdb:break,run,next/step,print,backtrace - ✅
-fsanitize=address,undefinedcatches memory bugs and UB with exact lines - ✅ Develop in a debug build (
-g -O0); ship a release build (-O2 -DNDEBUG) - ✅ Next lesson: Profiling & Optimization — make correct code fast
Sign up for free to track which lessons you've completed and get learning reminders.