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.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free C++ course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

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 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.

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.

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.

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.

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 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:

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.

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.

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.