<< Home

Chapter 28: Debugging, Testing, and Profiling

This chapter is your toolbox for finding problems and proving that your program behaves correctly. You will set up symbol information and work smoothly inside an IDE, write unit tests that catch regressions, unleash fuzzers and property based checks, turn on sanitizers to smoke out undefined behavior, and then chase performance with profilers and hardware counters. Treat these tools as companions during development rather than last minute rescue boats.

Debug info, symbols, and IDE setup

Compilers can include mapping data that links machine code back to source files and line numbers. This information is stored in formats such as DWARF on Unix like systems and PDB on Windows. With symbols present, debuggers can show file names, function names, variable values, and accurate stack traces. Most IDEs expect these symbols; they also assume a build with little or no optimization because highly optimized code can be hard to step through.

Compile with -g, /Zi, and sensible optimization

On GCC and Clang use -g to emit DWARF and pair it with -O0 or -Og during active debugging. On MSVC use /Zi to produce PDB files and pick /Od for minimal optimization. Keep frame pointers with -fno-omit-frame-pointer where possible; call stacks become clearer and sampling profilers often improve.

# GCC or Clang
g++ -g -Og -fno-omit-frame-pointer -std=c++23 main.cpp -o app

# MSVC
cl /std:c++23 /Zi /Od /MDd main.cpp /Fe:app.exe
💡 Use multi-config generators in CMake so you can switch between Debug, RelWithDebInfo, and Release without regenerating builds.

Load symbols and step with gdb, lldb, and Visual Studio

Modern IDEs wrap command line debuggers. VS Code drives gdb or lldb through launch.json. CLion bundles lldb on macOS. Visual Studio uses its own debugger and PDBs. Once attached you can set breakpoints, step, inspect locals, evaluate expressions, and watch values as they change.

# Minimal sessions
gdb ./app
(lldb) target create ./app

# Backtraces when something crashes
gdb -ex 'run' -ex 'bt' --args ./app arg1 arg2

Symbol files, stripping, and crash triage

Shipping builds often omit symbols to reduce size. Keep an external store of symbol files so production crash logs can be symbolicated later. On Unix like systems you can split symbols into a separate file with objcopy --only-keep-debug, then strip the binary and add a link to the external symbols.

# Split and reference debug symbols
objcopy --only-keep-debug app app.debug
strip --strip-debug --strip-unneeded app
objcopy --add-gnu-debuglink=app.debug app
⚠️ Optimizations can inline, reorder, or eliminate code. Expect stepping to feel surprising when using high optimization levels; prefer RelWithDebInfo for performance triage and Debug for logic debugging.

Unit testing libraries and patterns

Unit tests provide fast, deterministic checks that focus on small surfaces of behavior. Good tests are readable, isolated, and stable across refactors. Choose a lightweight framework and keep tests running in seconds so you execute them frequently.

Popular choices: GoogleTest, Catch2, and doctest

GoogleTest offers rich assertions and fixtures. Catch2 focuses on simplicity with a header only design. doctest mirrors Catch2 syntax and aims for tiny compile times. All three integrate with CTest and IDEs.

// GoogleTest example
#include <gtest/gtest.h>
int add(int a, int b) { return a + b; }
TEST(Math, Add){ EXPECT_EQ(add(2, 3), 5); }
int main(int argc, char** argv){ ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); }
// Catch2 single-file example
#define CATCH_CONFIG_MAIN
#include <catch2/catch_all.hpp>
int mul(int a, int b) { return a * b; }
TEST_CASE("mul works"){ REQUIRE(mul(3, 4) == 12); }

Arrange Act Assert and expressive names

Structure tests with Arrange Act Assert. Prepare inputs, call the function, then assert outcomes. Name tests by behavior so failure messages read like short stories. Parameterized tests reduce duplication by feeding multiple inputs through the same body.

// Parameterized example with GoogleTest
class PowerTests : public ::testing::TestWithParam<std::tuple<int,int,int>> {};
TEST_P(PowerTests, Computes){ auto [b,e,expect] = GetParam(); EXPECT_EQ(std::pow(b,e), expect); }
INSTANTIATE_TEST_SUITE_P(Small, PowerTests, ::testing::Values(
  std::make_tuple(2,3,8), std::make_tuple(3,3,27), std::make_tuple(10,0,1)
));
💡 Wire tests into your build with add_test and use ctest --output-on-failure so continuous integration captures clear logs.

Fixtures, test doubles, and seams

Fixtures manage setup and teardown when many tests share context. For code that touches clocks, I/O, or random sources, inject interfaces and provide fakes in tests. These seams let you verify logic deterministically and keep tests quick.

Fuzzing and property based testing

Fuzzing pushes huge volumes of generated inputs at your code to discover crashes and sanitizer findings. Property based testing states invariants that must hold for broad classes of inputs. Together they catch edge cases humans rarely think about.

Coverage guided fuzzing

libFuzzer integrates with Clang. You write a tiny entry point that consumes bytes and calls your parser or function. The engine mutates inputs and keeps those that increase coverage. AFL uses separate instrumentation and a runner process; both thrive when sanitizers are enabled.

// libFuzzer entry point
#include <cstddef>
#include <cstdint>
int parse(const char* data, size_t n);  // your code
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size){
  parse(reinterpret_cast<const char*>(data), size);
  return 0;
}
# Build and run with sanitizers
clang++ -std=c++23 -g -O1 -fsanitize=fuzzer,address,undefined fuzz.cpp -o fuzz_target
./fuzz_target -max_total_time=30 -artifact_prefix=crashes/
⚠️ Persist a seed corpus directory from interesting real inputs. Fuzzers learn faster with a curated starting set rather than empty seeds.

State properties with RapidCheck or Catch2 generators

Instead of single examples, declare properties like reversibility or idempotence. Generators produce many input combinations and shrink failing cases down to minimal examples that still trigger the bug.

// RapidCheck example
#include <rapidcheck.h>
#include <vector>
std::vector<int> rev(std::vector<int> v){ std::reverse(v.begin(), v.end()); return v; }
int main(){
  rc::check("reverse twice gives original", [](const std::vector<int>& v){
    RC_ASSERT(rev(rev(v)) == v);
  });
}

Minimize crashers and promote to regression tests

When a fuzzer finds a crashing input, reduce it and preserve the artifact in version control. Add a unit test that loads the failing bytes; this prevents the bug from sneaking back during future refactors.

Address, leak, and thread sanitizers

Sanitizers instrument your program to detect classes of undefined behavior. They are quick to enable and often turn silent memory bugs into loud, precise reports that point to the bad line and the allocation site.

Enable ASan, UBSan, TSan, and MSan

On Clang and GCC use -fsanitize=address for buffer issues and -fsanitize=undefined for type and arithmetic violations. Use -fsanitize=thread to detect data races; it slows programs more but pays off for concurrency. MemorySanitizer (-fsanitize=memory) tracks uninitialized reads and usually requires building all dependencies with the same flag.

# Typical development toggles
clang++ -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined app.cpp -o app_asan
clang++ -g -O1 -fno-omit-frame-pointer -fsanitize=thread -static-libstdc++ -static-libgcc app.cpp -o app_tsan
💡 Set ASAN_OPTIONS=halt_on_error=1:detect_leaks=1 and UBSAN_OPTIONS=print_stacktrace=1 to get crisp failures and helpful stacks.

Interpret reports and follow the allocation trail

Sanitizer messages show the invalid access, the stack where it happened, and often the stack where the memory was allocated. Fix the first reported problem before chasing later ones because one overflow can cause many downstream explosions.

// A tiny bug that ASan will catch
#include <cstring>
int main(){ char buf[4]; std::memcpy(buf, "hello", 5); return buf[4]; }

Combine sanitizers with fuzzers for high yield

A fuzzer explores input space while sanitizers turn subtle misbehavior into immediate failures. This combination finds deep parser bugs, off by one mistakes, and rare race conditions that barely show up in manual testing.

Profilers and performance counters

Debugging correctness differs from optimizing speed. Profilers show where time actually goes. Hardware counters reveal cache misses, branch mispredicts, and cycles. Start with sampling to locate hot code, then zoom in with instrumentation where needed.

Sampling vs instrumentation and build types

Sampling profilers interrupt the program at intervals and record stacks. They add small overhead and work well with optimized builds. Instrumentation inserts timing code around functions or blocks and can distort results; use it when sampling lacks resolution. For realistic hotspots prefer Release or RelWithDebInfo so the compiler performs inlining and vectorization.

Workflows with perf, Callgrind, and Visual Studio Profiler

On Linux start with perf. Record a session and open a report to see symbols and percentages. For call graph exploration, run under Valgrind’s Callgrind and visualize with KCachegrind. On Windows use Visual Studio Profiler or Windows Performance Analyzer. On macOS use Instruments for time profiles and allocations.

# Linux sampling
perf record -g -- ./app …
perf report

# Callgrind instrumentation
valgrind --tool=callgrind ./app …
kcachegrind callgrind.out.<pid>
⚠️ Microbenchmarks can lie if the CPU is not warmed up or the compiler optimizes away work. Use realistic inputs, pin CPU frequency if possible, and keep I/O out of inner loops during measurement.

Measure with std::chrono and read counters with perf stat

For ad hoc timing wrap code with std::chrono::steady_clock. For deeper signals use perf stat to collect cycles, instructions, and cache stats. Ratios like instructions per cycle help separate CPU bound code from memory bound code.

// Ad hoc timing
#include <chrono>
#include <iostream>
int main(){
  auto t0 = std::chrono::steady_clock::now();
  // work …
  auto t1 = std::chrono::steady_clock::now();
  std::cout << std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count() << " us\n";
}
# Hardware counters
perf stat -e cycles,instructions,branches,branch-misses,cache-misses ./app …

Create a repeatable performance lab

Stabilize measurements by fixing CPU governor settings, disabling turbo where appropriate, and isolating cores. Run multiple trials, report medians and percentiles, and version control benchmark inputs. Small process changes make results trustworthy.