Skip to main content
    Courses/C++/Advanced Debugging

    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

    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.

    Worked example: an off-by-one bug and its fix

    Read the buggy version, then the commented fix below it. Run it.

    Try it Yourself »
    C++
    #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.

    Try it Yourself »
    C++
    #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.

    Try it Yourself »
    C++
    #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.

    Try it Yourself »
    C++
    #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 -g turns "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=address and 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 at 1 instead of 0. Use i < v.size() and add an assert on 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 like long 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 to cerr, which is unbuffered and survives the crash.

    📋 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

    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.

    Try it Yourself »
    C++
    #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
    • assert documents and checks assumptions; it's stripped in release (-DNDEBUG)
    • ✅ Trace with cerr (unbuffered), not cout, so output survives a crash
    • gdb: break, run, next/step, print, backtrace
    • -fsanitize=address,undefined catches 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service

    Install LearnCodingFast

    Learn faster with the app on your home screen.