Advanced Debugging

Reviewed & published by Brayan K

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.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 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

CategoryLooks likeBest tool
Compile errorWon't build; type / syntax mismatchread first error
Logic / off-by-oneBuilds, wrong answerassert + gdb
Use-after-freeRandom crash, "works sometimes"ASan
Buffer overflowCorruption / crash near arraysASan
Undefined behaviourOverflow, bad cast, null derefUBSan
Memory leakMemory grows over timeValgrind / 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.

#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>(sum) / scores.size();
}

// ✅ THE FIX: stop at < size, and guard against an empty vector
// so we never divide by zero.
double averageFixed(const vector<int>& scores) {
    if (scores.empty()) return 0.0;          // guard: avoid /0
    int sum = 0;
    for (size_t i = 0; i < scores.size(); i++) {  // <  not  <=
        sum += scores[i];
    }
    return static_cast<double>(sum) / scores.size();
}

int main() {
    vector<int> scores = {90, 80, 70};

    // The buggy version may print a wrong number, crash, or "work"
    // by luck — that unpredictability IS the symptom of the bug.
    cout << "Buggy avg:  " << averageBuggy(scores) << endl;

    // The fixed version is correct every time: (90+80+70)/3 = 80
    cout << "Fixed avg:  " << averageFixed(scores) << endl; // 80
    return 0;
}

// ✅ Expected output:
//    Buggy avg:  80
//    Fixed avg:  80

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.

#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 for "what is this value right now?".
        cerr << "[debug] i=" << i << " result=" << result << endl;
    }
    return result;
}

int main() {
    cout << "5! = " << factorial(5) << endl; // 120

    // assert catches the bug AT THE SOURCE instead of much later:
    // factorial(-3);  // would abort: "factorial needs a non-negative n"

    // Tip: in a real terminal, run  ./program 2>/dev/null  to hide the
    // [debug] lines (they go to cerr) and keep only the real output.
    return 0;
}

// ✅ Expected output:
//    5! = 120

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.

#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 << ": " << names[i] << endl;
    }

    // ✅ Expected output:
    //    0: Ada
    //    1: Bjarne
    //    2: Grace
    //    3: Linus
    return 0;
}

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.

#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.
    cout << "Case 1: " << discountPercent(20.0, 80.0) << "%" << endl; // 25
    cout << "Case 2: " << discountPercent(0.0, 0.0)  << "%" << endl;  // 0

    // ✅ Expected output:
    //    Case 1: 25%
    //    Case 2: 0%
    return 0;
}

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

Common Errors (and the fix)

📋 Quick Reference

GoalCommand / FlagWhat it does
Debug buildg++ -g -O0 a.cppSymbols + no optimisation
Release buildg++ -O2 -DNDEBUG a.cppFast; strips asserts
Start debuggergdb ./programLoad program in gdb
Set breakpointbreak main / break 42Pause at a function/line
Run / steprun · next · stepStart, step over, step in
Inspectprint x · backtraceValue now · call stack
Memory + UB-fsanitize=address,undefinedASan + UBSan reports
Leaksvalgrind --leak-check=fullFind memory leaks

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.

#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 it on {10, 20, 30, 40} with start=1, end=3.
//
// ✅ Expected output (real output on cout):
//    sum = 50           // 20 + 30
// (cerr will also show your [debug] trace lines)

int main() {
    // your code here
    return 0;
}

🎉 Lesson Complete

Practice quiz

When facing a wall of compiler errors, which one should you fix first?

  • The last error in the list
  • Any error mentioning std::vector internals
  • The first error — later ones are often knock-on noise from it
  • The shortest error message

Answer: The first error — later ones are often knock-on noise from it. Fix the first error first; later errors are usually phantoms caused by the same mistake.

Why use cerr instead of cout for debug messages?

  • cerr is unbuffered, so its output appears immediately even if the program crashes a line later
  • cerr is faster to type
  • cout cannot print strings
  • cerr automatically formats numbers better

Answer: cerr is unbuffered, so its output appears immediately even if the program crashes a line later. cerr is the unbuffered error stream, so its output survives a crash; buffered cout can swallow the last message.

What flags describe a typical debug build?

  • -O2 -DNDEBUG
  • -O3 only
  • -fsanitize=address only
  • -g -O0

Answer: -g -O0. A debug build uses -g (symbols) and -O0 (no optimisation) so the debugger can map every line and variable.

What does AddressSanitizer (ASan) catch?

  • Signed integer overflow and bad casts
  • Memory errors: use-after-free, buffer overflow, and double free
  • Compile-time syntax errors
  • Slow algorithms

Answer: Memory errors: use-after-free, buffer overflow, and double free. ASan catches memory errors; UBSan is the one that catches undefined behaviour like signed overflow and bad casts.

What does assert(condition) do?

  • Aborts the program with a message if the condition is false
  • Logs the condition to a file and continues
  • Returns the condition's value
  • Throws a std::runtime_error you can catch

Answer: Aborts the program with a message if the condition is false. assert documents and checks an assumption: if the condition is false it aborts with a message, tripping the alarm at the source.

In gdb, which command shows the call stack — how you got to the current line?

  • print
  • next
  • backtrace
  • step

Answer: backtrace. backtrace (bt) shows the call stack; print shows a variable's value right now.

What is the difference between gdb's next and step?

  • They are identical
  • next steps OVER function calls; step steps INTO them
  • next runs to the end; step pauses forever
  • step skips lines; next repeats the last line

Answer: next steps OVER function calls; step steps INTO them. next runs the next line stepping over calls, while step steps into a function call.

Why should you never put work with side effects inside an assert?

  • assert is too slow for side effects
  • assert only accepts boolean literals
  • Side effects make assert always pass
  • In a release build (-DNDEBUG) the whole assert(...) is removed, so the work would silently not run

Answer: In a release build (-DNDEBUG) the whole assert(...) is removed, so the work would silently not run. Because asserts are stripped in release builds, assert(file.open()) would skip opening the file entirely.

A loop using i <= v.size() to read v[i] is a classic example of which bug?

  • Memory leak
  • Off-by-one (reading one slot past the end)
  • Race condition
  • Stack overflow

Answer: Off-by-one (reading one slot past the end). i <= v.size() reads v[v.size()], one element past the end — an off-by-one. Use i < v.size().

Which build should you develop and test with, and which should you ship?

  • Develop in release, ship debug
  • Always ship the debug build
  • Develop in debug (-g -O0); ship release (-O2 -DNDEBUG)
  • It does not matter which you use

Answer: Develop in debug (-g -O0); ship release (-O2 -DNDEBUG). Develop and test with a debug build so tools can see everything, then ship the optimised release build.

Continue this course

Frequently asked questions

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.

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.

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.

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.

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.

Related lessons