Debugging C++
C++ bugs frequently don't crash where they happen — undefined behavior can corrupt memory silently and crash somewhere unrelated minutes later, which is exactly why C++ debugging leans so heavily on tools that catch problems the instant they occur.
A segfault at line 400 is often not a bug at line 400 — it's the visible symptom of memory that was corrupted at line 50 by a buffer overrun, a use-after-free, or an uninitialized read, and the program only happened to notice once it stumbled onto the damage. This is what makes C++ debugging different from debugging in memory-safe languages: the crash location and the bug location are frequently different places entirely. This page walks through the full toolchain for finding C++ bugs before they ship — starting with the free tools every build should already be using (compiler warnings, assertions, debug builds), moving into interactive debugging with GDB/LLDB, and finishing with the sanitizers and static/dynamic analysis tools that catch entire categories of undefined behavior the moment they happen rather than whenever they happen to manifest as a crash.
Compiler Diagnostics and Warnings
The cheapest bug-finding tool available is the compiler itself, and most C++ programmers leave most of its power turned off by default. -Wall -Wextra should be treated as a minimum baseline, not an optional extra — they catch uninitialized variables, signed/unsigned comparison mismatches, unused variables, and suspicious implicit conversions, all for free at compile time. -Wpedantic flags non-standard extensions the compiler otherwise silently accepts. -Werror turns every warning into a hard error, which forces warnings to be fixed rather than accumulating unnoticed.
total is never initialized before total += i runs, so its initial value is indeterminate (undefined behavior to read it). Modern GCC and Clang both catch this specific pattern under -Wall (as -Wuninitialized / -Wmaybe-uninitialized), turning a silent, non-deterministic bug into a compile-time error before the program ever runs.
// compile with: g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror -o prog prog.cpp
#include <iostream>
int main()
{
int total; // -Wall would flag: used uninitialized below
for (int i = 0; i < 5; i++)
total += i; // reading garbage on the first iteration
std::cout << total << "\n";
return 0;
}Debug Builds and Assertions
A debug build (typically -g -O0, or CMake's CMAKE_BUILD_TYPE=Debug) disables optimizations that would otherwise reorder or eliminate code in ways that make step-by-step debugging confusing, and it embeds debug symbols so a debugger can map machine instructions back to source lines and variable names. Debug builds also typically enable standard library debug checks (like libstdc++'s _GLIBCXX_ASSERTIONS or MSVC's iterator debugging), which catch out-of-bounds vector access and invalid iterator dereferences with a clear diagnostic instead of silent memory corruption.
assert() is the simplest tool for encoding assumptions directly into code: assert(index < vec.size()); crashes immediately with a clear message (file, line, failed condition) the moment the assumption is violated, rather than letting execution continue into undefined territory. Critically, assert() calls compile to nothing when NDEBUG is defined, which release builds typically do — so assertions are a debugging aid, never a substitute for real input validation or error handling on paths that must work in production.
The && "message" idiom works because a non-null string literal pointer is truthy, so it doesn't change the assertion's logic — it just makes the failure message self-explanatory when the assertion fires. In a release build with NDEBUG defined, this whole assert line vanishes and getElement(data, 5) would instead silently read out of bounds.
#include <cassert>
#include <vector>
#include <iostream>
int getElement(const std::vector<int>& v, std::size_t index)
{
assert(index < v.size() && "index out of range in getElement");
return v[index];
}
int main()
{
std::vector<int> data = {10, 20, 30};
std::cout << getElement(data, 1) << "\n"; // fine
std::cout << getElement(data, 5) << "\n"; // assertion failure in a debug build
return 0;
}GDB and LLDB: Breakpoints, Watchpoints, and the Call Stack
GDB (GNU Debugger) and LLDB (LLVM's debugger, the default on macOS and used by many Clang-based toolchains) let you pause a running program and inspect it interactively, rather than guessing from printf output. A breakpoint pauses execution the instant a specific line or function is reached; a watchpoint pauses execution the instant a specific memory location's value changes, which is invaluable for tracking down exactly where a variable gets unexpectedly overwritten — something print statements can't easily catch when the overwrite happens through a stray pointer far from where you'd think to add a print.
backtrace is often the single most useful command when a crash happens deep inside a call chain — it lists every function currently on the stack, from the point of the crash back to main, so you can see exactly which sequence of calls led there instead of only seeing the innermost frame.
// Compile with debug symbols first:
// g++ -g -O0 -o prog prog.cpp
//
// Then in gdb:
// gdb ./prog
// (gdb) break main.cpp:12 # pause when line 12 is reached
// (gdb) run
// (gdb) watch total # pause the instant 'total' changes
// (gdb) next # step over one line
// (gdb) step # step into a function call
// (gdb) print total # inspect a variable's current value
// (gdb) backtrace # show the full call stack
// (gdb) continue # resume until the next breakpoint/watchpointCore Dumps
A core dump is a snapshot of a crashed process's entire memory image, written to disk at the moment of a fatal signal (like SIGSEGV). Instead of trying to reproduce a crash interactively, a core dump lets you load the exact crashed state into a debugger after the fact — the call stack, every variable's value, and the full memory layout are all preserved exactly as they were at the instant of the crash.
Core dumps are especially valuable for bugs that only reproduce in production or under load that's hard to replicate locally — you get the exact failure state without needing to catch the bug live in a debugger.
# Enable core dumps for the current shell session (they're often disabled by default):
ulimit -c unlimited
# Run the program; on a crash, a core file (e.g. core, or core.<pid>) is written.
./prog
# Load the crash into gdb for post-mortem analysis:
gdb ./prog core
# (gdb) backtrace # see exactly where and how it crashedAddressSanitizer and UndefinedBehaviorSanitizer
AddressSanitizer (ASan) instruments a build to detect memory errors — buffer overflows (reading or writing past an array's bounds), use-after-free, use-after-return, and double-free — at the exact instruction where they occur, with a full stack trace, rather than letting corrupted memory silently propagate into a crash somewhere unrelated later. It's enabled with a single compiler flag and typically slows execution by roughly 2x, which is a trivial cost during testing for the bugs it catches.
Without a sanitizer, arr[5] = 42; might silently corrupt an unrelated heap allocation and cause a confusing crash much later, if it crashes at all. Compiled with -fsanitize=address, ASan detects the out-of-bounds write the instant it happens and prints a report naming the exact line, the allocation site of arr, and the type of error (heap-buffer-overflow), then aborts.
UndefinedBehaviorSanitizer (UBSan, -fsanitize=undefined) catches a different category of bugs: signed integer overflow, division by zero, misaligned pointer access, invalid enum values, and null-pointer dereference through a reference, among others — all of which are technically undefined behavior in C++ but usually compile and 'work' most of the time, right up until a compiler optimization exploits the undefined behavior to do something surprising. ASan and UBSan are commonly combined in one build: -fsanitize=address,undefined.
// Compile with: g++ -std=c++17 -g -fsanitize=address -o prog prog.cpp
#include <iostream>
int main()
{
int* arr = new int[5];
arr[5] = 42; // one past the end: heap-buffer-overflow
std::cout << arr[0] << "\n";
delete[] arr;
return 0;
}ThreadSanitizer and LeakSanitizer
ThreadSanitizer (TSan, -fsanitize=thread) detects data races: two threads accessing the same memory location concurrently, at least one of them writing, without synchronization. Data races are undefined behavior in C++ and are notoriously hard to find by inspection or even by testing, since they often depend on precise scheduling timing that may only manifest once in a thousand runs — TSan instruments every memory access and every synchronization operation (mutex lock/unlock, atomic operation) to detect a race deterministically, the first time it happens during a test run, rather than hoping to get unlucky enough to observe the resulting corruption.
Two threads both read, increment, and write counter without any synchronization (no mutex, no std::atomic). TSan reports this as a data race with both threads' stack traces the moment it detects the conflicting concurrent access, rather than the program just silently losing some increments (the actual, non-deterministic symptom this bug produces when run without TSan).
LeakSanitizer (LSan, often built into ASan and enabled automatically with it on Linux) detects memory that was allocated and never freed by the time the program exits, reporting the allocation's stack trace so you can see exactly where the leaked memory came from — far more actionable than noticing memory usage climbing in a monitoring dashboard weeks later.
// Compile with: g++ -std=c++17 -g -fsanitize=thread -o prog prog.cpp
#include <thread>
int counter = 0;
void increment()
{
for (int i = 0; i < 100000; ++i)
++counter; // unsynchronized read-modify-write: a data race
}
int main()
{
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
return 0;
}Static Analysis and Profiling
Static analyzers (clang-tidy, Cppcheck, PVS-Studio) examine source code without running it, flagging patterns that are likely bugs — resource leaks on early-return paths, suspicious null-pointer usage, uninitialized members, common misuse of the STL — often catching issues sanitizers can't, precisely because static analysis can reason about paths that a specific test run never actually executes. clang-tidy in particular can also enforce modernization rules (suggesting range-based for loops, auto, or nullptr in place of older idioms) as part of the same pass.
Profiling answers a different question than correctness tools do: not 'is this a bug?' but 'where is the time actually going?' Tools like perf (Linux), Valgrind's callgrind, or a sampling profiler in an IDE record where a program spends its execution time, which is essential before optimizing anything — intuition about which function is the bottleneck is wrong often enough that guessing without measuring routinely wastes effort optimizing code that was never actually slow.
Why is it common for a C++ crash to occur far away from the actual bug, and which tool category is specifically designed to close that gap?
Undefined behavior — like a buffer overrun or a use-after-free — can silently corrupt memory without any immediate visible effect; the program only crashes later, whenever something else happens to read or rely on the corrupted memory. Sanitizers (AddressSanitizer, UndefinedBehaviorSanitizer) are designed specifically to detect the invalid operation at the exact instruction where it happens, with a precise stack trace, instead of waiting for a downstream crash.
Why does assert() disappear in a release build, and why is that both useful and dangerous?
assert() compiles to nothing when the NDEBUG macro is defined, which release builds commonly do, so assertion checks add zero runtime overhead to shipped code. It's useful because debug-only checks can be as thorough as needed without a production performance cost, but dangerous if a programmer relies on assert() for something that must always be checked (like validating untrusted user input) — that check silently vanishes in release builds, leaving the real validation missing entirely.
What specific class of bugs does ThreadSanitizer catch that neither AddressSanitizer nor manual code review reliably catches, and why is that class of bug so hard to find otherwise?
ThreadSanitizer catches data races — concurrent, unsynchronized access to shared memory where at least one access is a write. These are hard to find otherwise because they're timing-dependent: the exact interleaving of thread execution that triggers visible corruption might only happen rarely, so a race can pass thousands of test runs and then fail in production under different load or hardware, whereas TSan instruments every access and detects the race deterministically as soon as it happens, without needing to get unlucky with timing.
Why is a debug build's iterator-checking useful for catching bugs that a plain optimized build would miss entirely?
Standard library debug modes (like libstdc++'s _GLIBCXX_ASSERTIONS) add runtime checks — verifying an index passed to vector::operator[] is in bounds, or that a dereferenced iterator hasn't been invalidated — that a normal build skips for performance. Without those checks, an out-of-bounds access is simply undefined behavior: it might read garbage silently and keep running, giving no indication anything went wrong until much later, if ever.
Why should you profile before optimizing, rather than optimizing the code that looks slowest by inspection?
Intuition about performance bottlenecks is frequently wrong — the actual hot path revealed by a profiler is often something unglamorous, like a hidden allocation, a logging call, or lock contention, rather than the nested loop that looks computationally heavy. Optimizing based on a guess risks spending effort (and adding complexity) on code that was never actually the bottleneck, while the real cost remains unaddressed.
The following program compiles and runs, but reading a specific line invokes undefined behavior via a heap-buffer-overflow. Identify the line and explain how AddressSanitizer would report it.
The loop condition i <= 3 lets i reach 3, and scores[3] reads one element past the allocated array of 3 ints (valid indices are 0, 1, 2) — a heap-buffer-overflow read. Compiled with -fsanitize=address, ASan would abort execution the instant scores[3] is read, printing a report showing the read address, that it's 0 bytes after a 12-byte heap region, the exact source line, and the stack trace of both the invalid access and the original new int[3] allocation.
Write a small C++ program with a deliberate memory leak (allocate with new but never delete), and explain what LeakSanitizer would report if it were compiled with -fsanitize=address and run.
buffer goes out of scope at the end of createBuffer() without ever being delete[]-ed, so the 1000-int heap allocation becomes unreachable but is never released — a classic memory leak. Compiled with ASan (which includes LeakSanitizer by default on Linux), the program would run normally but report a leak summary at exit, naming the exact allocation site (new int[1000] in createBuffer) and the number of leaked bytes, making the leak's origin immediately obvious instead of just noticing memory usage grow over time.
Write a short multi-threaded C++ program with an unsynchronized shared counter that demonstrates a data race, then describe how you would fix it using std::atomic or a std::mutex.
With a plain int counter and two threads both doing ++counter without synchronization, the read-modify-write isn't atomic, so increments can be lost when both threads read the same value before either writes back the incremented result — the final total is unpredictably less than 200000, and ThreadSanitizer would flag the unsynchronized concurrent access as a data race. Switching counter to std::atomic<int> (as shown) makes each increment a single indivisible hardware operation, guaranteeing the final result is exactly 200000 every run; a std::mutex protecting a plain int would also fix it, at the cost of lock overhead instead of a lock-free atomic operation.
#include <iostream>
int main()
{
int* scores = new int[3]{90, 85, 78};
for (int i = 0; i <= 3; ++i)
std::cout << scores[i] << "\n";
delete[] scores;
return 0;
}