5.3.9 · D4Build Systems & Toolchain

Exercises — CMake build types — Debug, Release, RelWithDebInfo

2,552 words12 min readBack to topic

Before we start, one picture to anchor the only idea this whole topic rests on: a build type is a named point in the 3-knob space .

Figure — CMake build types — Debug, Release, RelWithDebInfo
  • The axis (left→right) = how hard the compiler optimizes, from -O0 (nothing) to -O3 (aggressive). Higher = faster program, but variables get reordered or deleted so stepping in a debugger gets confusing.
  • The axis (up) = whether we embed the debug symbol table (-g), the little map from machine instructions back to your source lines. It costs disk space but zero runtime.
  • The NDEBUG marker = whether assert() is switched off (-DNDEBUG present = asserts vanish).

Keep this picture open. Every exercise is really "where does this build type sit in that space, and why?"


Level 1 — Recognition

(Can you read a flag string and name the type? Can you name the flags from the type?)

Recall Solution 1.1

Answer: Release. Walk the two flags:

  • -O3 = the maximum standard optimization level → this is a "make it fast" build.
  • -DNDEBUG = the NDEBUG macro is defined → assert() expands to nothing.

On our picture that's the point — the far-right, ground-level "clean printed final copy" corner. No -g, so no symbols. That is exactly Release.

Recall Solution 1.2
Type ? NDEBUG?
Debug 0 ✔ (-g)
RelWithDebInfo 2 ✔ (-g)
MinSizeRel s (size)

Reasoning per row:

  • Debug = "messy draft with margin notes": symbols on, no optimization, asserts kept (you want the safety checks firing while developing).
  • RelWithDebInfo = "clean copy with a key in the back": nearly-fast (-O2) and carries -g, asserts off like a real release.
  • MinSizeRel = optimize for size (-Os), not speed; asserts off, no symbols.
Recall Solution 1.3

False. -g only appends a symbol table to the binary file — data that sits there for a debugger to read. The CPU never executes it. The only cost is a bigger file on disk. This is why -g and -O are called orthogonal: you can freely combine them (that combination is literally what RelWithDebInfo is). See Debug Symbols & gdb.


Level 2 — Application

(Can you produce the right command / cache state for a stated goal?)

Recall Solution 2.1
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
  • -S . = source dir is here; -B build = put generated files in build/ (out-of-source, keeps your repo clean).
  • -DCMAKE_BUILD_TYPE=Release = select the Release bundle → CMake will append CMAKE_CXX_FLAGS_RELEASE = -O3 -DNDEBUG.
  • The second line runs the actual compiler. See Ninja vs Make for which underlying tool runs.
Recall Solution 2.2

You got -O0 (effectively no optimization). When CMAKE_BUILD_TYPE is left empty, CMake appends none of the per-type flag variables — no -O, no -g, no -DNDEBUG. There is no hidden default of "Release." On our picture you're stuck at the origin , which runs at Debug speed while looking like a normal build. Fix: always pass -DCMAKE_BUILD_TYPE=... explicitly.

Recall Solution 2.3
cmake -S . -B build            # configure once, no build type here
cmake --build build --config Release

Multi-config generators ignore CMAKE_BUILD_TYPE at configure time — they carry all configs at once and you choose at build time with --config. Setting -DCMAKE_BUILD_TYPE=Release would be silently useless here. See Single-config vs Multi-config Generators.


Level 3 — Analysis

(Given a symptom, diagnose the cause from first principles.)

Recall Solution 3.1

Cause: Release uses -O3 without -g. With no symbol table embedded, the debugger has no map from machine addresses back to source, so every frame prints ??. Fix: RelWithDebInfo = -O2 -g -DNDEBUG.

  • -O2 ≈ nearly Release speed (and behaves like the real workload, unlike -O0 Debug).
  • -g re-embeds the symbol table → readable backtrace.
  • -DNDEBUG keeps it a true release (asserts off).

On the picture this is the point that is far right on and up on — the only standard type living in that upper-right region.

Recall Solution 3.2

NDEBUG is innocent. The NDEBUG macro gates only:

  1. the standard assert() macro (makes it expand to nothing), and
  2. any code the teammate personally wrapped in #ifndef NDEBUG ... #endif.

A plain runtime if (debugMode) is ordinary C++ — the preprocessor never touches it. So -DNDEBUG cannot have turned it off. The real cause is elsewhere (e.g. debugMode is false, or an optimizer removed provably-dead code, or a different config was built). See assert and the NDEBUG macro.

Recall Solution 3.3

Switching to Release adds -DNDEBUG, which makes assert(x > 0) expand to nothing — the check disappears entirely. Risk: the assert was catching x <= 0, a genuine bug. In Release that bug is no longer reported; it silently proceeds with bad data (undefined behaviour, wrong results, or a crash later, far from the cause). The assert firing is the good outcome. The right move is to fix why x <= 0, not to silence the alarm. (For catching such bugs even in optimized builds, see Sanitizers (ASan, UBSan).)


Level 4 — Synthesis

(Build something new by combining the pieces.)

Recall Solution 4.1
set(CMAKE_CXX_FLAGS_ASAN
    "-O1 -g -fsanitize=address"
    CACHE STRING "" FORCE)
cmake -S . -B build -DCMAKE_BUILD_TYPE=ASAN
cmake --build build

Why this works: a build type is nothing but a variable lookup of the form CMAKE_CXX_FLAGS_<TYPE>. Once CMAKE_CXX_FLAGS_ASAN exists in the cache, the name ASAN becomes a valid CMAKE_BUILD_TYPE. Choices explained:

  • -O1 = light optimization so the sanitizer stays fast but code still resembles a real build.
  • -g = symbols, so ASan can print source lines in its reports.
  • -fsanitize=address = the actual ASan instrumentation.
  • CACHE STRING "" FORCE = store it as a cache variable so CMake treats it exactly like a built-in per-type flag string.
Recall Solution 4.2

Target point on the picture: optimize for size and carry symbols.

set(CMAKE_CXX_FLAGS_MINSIZEDBG
    "-Os -g -DNDEBUG"
    CACHE STRING "" FORCE)
  • -Os = optimize for size (the MinSizeRel optimization).
  • -g = symbols — remember, zero runtime cost, so it doesn't slow the firmware; it only enlarges the on-disk / debug file. In practice you often strip -g out of the flashed image but keep a separate symbol file, so the device image stays tiny while you can still symbolicate.
  • -DNDEBUG = it's a shipping build; asserts off.

Level 5 — Mastery

(Reason about the whole system; predict end-to-end behaviour.)

Recall Solution 5.1

Rule: . So for RelWithDebInfo: Full line: -Wall -fPIC -O2 -g -DNDEBUG.

  • -Wall (always-on base) = enable warnings.
  • -fPIC (always-on base) = position-independent code.
  • -O2 -g -DNDEBUG = the per-type RelWithDebInfo bundle. Order note: the per-type flags come after the base, so where flags conflict, the per-type ones win.
Recall Solution 5.2

The source is identical; the flags cause both differences:

  1. Missing assert in Release: Release adds -DNDEBUG, so every assert() compiled to nothing. Not a source change — a preprocessor change.
  2. Tiny FP differences: Release uses -O3, which lets the compiler reorder/associate floating-point operations, use fused multiply-add, keep values in wider registers, etc. Under -O0 (Debug) these optimizations are off, so rounding happens at different moments → slightly different last bits. See Compiler Optimization Levels (-O0 to -O3). Conclusion: neither the source nor either compiler is "wrong." This is exactly why RelWithDebInfo exists — you debug at production-like -O2 so the behaviour you observe matches the shipped binary, instead of chasing bugs that only exist (or only vanish) at -O0.
Recall Solution 5.3

Speed ranking (fastest → slowest):

  • Release (-O3) is generally fastest.
  • RelWithDebInfo (-O2) is very close — usually within a few percent.
  • MinSizeRel (-Os) trades some speed for size.
  • Debug (-O0) is dramatically slower (often several times).

For profiling production performance: RelWithDebInfo. It runs at near-Release speed (so timings are realistic) and carries -g symbols (so the profiler attributes hot spots to real function names and lines). Profiling Debug would measure the wrong thing; profiling bare Release would leave the profiler blind.


Recall One-line self-check before you leave

Say out loud: "A build type is a named bundle of flags = a point in space; single-config bakes it into the cache, multi-config picks it at build time." If that sentence feels obvious, you've mastered the topic.