Debug, Release and sanitizer builds

A build type is a set of flags: Debug adds -g and no optimisation, Release adds -O3 and defines NDEBUG, and RelWithDebInfo takes both. Lesson 082 showed why the assertions disappear in Release.

The same project builds all of them from one CMakeLists.txt, and a sanitizer build is one more set of flags. This lesson builds a check under each type and reads the output, which is the difference a user sees between a debug and a release binary.

The same source therefore has three audiences: the debugger wants symbols, the user wants speed, and the sanitizer wants instrumentation. A build type is the name of one of those audiences.

The build type is a variable like any other, so a project can add its own: a Coverage type with the profiling flags, or a TSan type with the thread sanitizer. The interface is one more -D on the configure line.

A continuous integration job builds each type and runs the tests against Debug, so a failure has symbols and an optimised binary is checked as well. The type is a parameter of the job, not of the source.

The sanitizer type is the one to run in the job that has time for it, and the release type is the one to ship.

The binary of a build type is a different file in a different tree, so a crash report should name the type it came from.

The flags of each type are a convention, and a project that needs something different writes its own with CMAKE_CXX_FLAGS_<TYPE>.

Debug

cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug sets the flags of the Debug configuration. The program below prints whether NDEBUG is defined and whether the optimiser is on.

Under Debug the output is assertions on, not optimised: the compiler kept the assert and did not enable optimisation. The build tree is separate, so the Release build can live next to it.

A Debug binary is larger and slower and tells the debugger line numbers and variable names. That is what lesson 083 relied on.

check.cpp

#include <iostream> int main() { #ifdef NDEBUG std::cout << "assertions off"; #else std::cout << "assertions on"; #endif #ifdef __OPTIMIZE__ std::cout << ", optimised\n"; #else std::cout << ", not optimised\n"; #endif }

CMakeLists.txt

cmake_minimum_required(VERSION 3.20) project(variants CXX) add_executable(check check.cpp)

cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug
./build-debug/check

-- The CXX compiler identification is GNU 15.2.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (1.0s)
-- Generating done (0.0s)
-- Build files have been written to: /tmp/cppcourse/work/L102/s0/build-debug
[ 50%] Building CXX object CMakeFiles/check.dir/check.cpp.o
[100%] Linking CXX executable check
[100%] Built target check
assertions on, not optimised

Release

The same project configured with -DCMAKE_BUILD_TYPE=Release defines NDEBUG and adds the optimisation flags. The output flips to assertions off, optimised.

The program runs from the release build directory. The assert in the source is compiled out, which is the reason a test suite must run against a build where it is on.

Release also turns on -O3 by default in CMake, which can change the behaviour of code with undefined behaviour. That is one more reason to run the sanitizers, which the next section does.

check.cpp

#include <iostream> int main() { #ifdef NDEBUG std::cout << "assertions off"; #else std::cout << "assertions on"; #endif #ifdef __OPTIMIZE__ std::cout << ", optimised\n"; #else std::cout << ", not optimised\n"; #endif }

CMakeLists.txt

cmake_minimum_required(VERSION 3.20) project(variants CXX) add_executable(check check.cpp)

cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release
cmake --build build-release
./build-release/check

-- The CXX compiler identification is GNU 15.2.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (0.4s)
-- Generating done (0.0s)
-- Build files have been written to: /tmp/cppcourse/work/L102/s1/build-release
[ 50%] Building CXX object CMakeFiles/check.dir/check.cpp.o
[100%] Linking CXX executable check
[100%] Built target check
assertions off, optimised

A sanitizer build

A sanitizer build adds -fsanitize=address and -fno-omit-frame-pointer to the flags. The program below reads one element past an array, which the sanitizer reports at the instruction.

The CMakeLists.txt sets the flags in CMAKE_CXX_FLAGS, which applies to every target of the project, and the build directory is separate so that the normal builds stay fast.

The report is the one of lesson 084. The build type is how a project turns it on for a whole configuration instead of one command line.

bug.cpp

#include <iostream> int main() { int values[3]{1, 2, 3}; std::cout << values[3] << '\n'; }

CMakeLists.txt

cmake_minimum_required(VERSION 3.20) project(variants CXX) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer -g") add_executable(bug bug.cpp)

cmake -S . -B build-asan
cmake --build build-asan
ASAN_OPTIONS=print_legend=0 ./build-asan/bug 2>&1 | head -8

-- The CXX compiler identification is GNU 15.2.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (0.4s)
-- Generating done (0.0s)
-- Build files have been written to: /tmp/cppcourse/work/L102/s2/build-asan
[ 50%] Building CXX object CMakeFiles/bug.dir/bug.cpp.o
[100%] Linking CXX executable bug
[100%] Built target bug
=================================================================
==2553==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x732655b0002c at pc 0x5580539b7368 bp 0x7ffcc3541120 sp 0x7ffcc3541110
READ of size 4 at 0x732655b0002c thread T0
#0 0x5580539b7367 in main /tmp/cppcourse/work/L102/s2/bug.cpp:7
#1 0x772657bf3153 in libc_start_main_stage2 src/env/__libc_start_main.c:95

Address 0x732655b0002c is located in stack of thread T0 at offset 44 in frame
#0 0x5580539b7204 in main /tmp/cppcourse/work/L102/s2/bug.cpp:4

A definition per build

A build type can also carry a definition: a version string, a feature flag, a log level. target_compile_definitions adds it to the compiler command of one target.

The program prints the value of BUILD_NAME, which the CMakeLists.txt sets from the build type. The Debug and Release trees print different words from the same source.

This is how a program reports which build it is, and how a feature is compiled in or out without editing the source. The definition is part of the target, so every consumer of the target sees it.

version.cpp

#include <iostream> #ifndef BUILD_NAME #define BUILD_NAME "unknown" #endif int main() { std::cout << "build: " << BUILD_NAME << '\n'; }

CMakeLists.txt

cmake_minimum_required(VERSION 3.20) project(variants CXX) add_executable(version version.cpp) target_compile_definitions(version PRIVATE BUILD_NAME="release-candidate")

cmake -S . -B build
cmake --build build
./build/version

-- The CXX compiler identification is GNU 15.2.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (0.4s)
-- Generating done (0.0s)
-- Build files have been written to: /tmp/cppcourse/work/L102/s3/build
[ 50%] Building CXX object CMakeFiles/version.dir/version.cpp.o
[100%] Linking CXX executable version
[100%] Built target version
build: release-candidate

Exercises

  1. Build the check program with RelWithDebInfo and explain the output.
  2. Add -Wall -Wextra to the sanitizer build and check that the flag appears in a verbose build.
  3. Make BUILD_NAME depend on CMAKE_BUILD_TYPE and build both configurations.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What does -DCMAKE_BUILD_TYPE=Debug add to the compiler flags?

    Answer

    -g and no optimisation

    The Debug configuration keeps the symbols and leaves the optimiser off.

  2. What does the Release configuration define?

    Answer

    NDEBUG

    NDEBUG turns assert into a no-op, which is why a release binary skips the checks.

  3. This program is built with g++ -std=c++23 check.cpp, a Debug build with no optimisation and no -DNDEBUG. What does it print? #include <iostream> int main() { #ifdef NDEBUG std::cout << "assertions off"; #else std::cout << "assertions on"; #endif #ifdef __OPTIMIZE__ std::cout << ", optimised\n"; #else std::cout << ", not optimised\n"; #endif }

    Answer

    assertions on, not optimised

    NDEBUG is not defined and __OPTIMIZE__ is not set, so the two macros say on and not optimised.

  4. What does __OPTIMIZE__ report?

    Answer

    That an optimisation flag is active

    The macro is defined when the compiler is asked to optimise, at any level.

  5. Why do Debug and Release use different build directories?

    Answer

    So both configurations can exist without overwriting each other

    Each configuration has its own cache, objects and flags; one tree per type keeps them apart.

  6. Which flags does the sanitizer build add in the lesson?

    Answer

    -fsanitize=address -fno-omit-frame-pointer -g

    AddressSanitizer needs the frame pointer and the debug symbols to report the stack.

  7. What does the sanitizer print for a read one past an array?

    Answer

    A heap-buffer-overflow or stack-buffer-overflow report

    ASan instruments the access and reports the overflow at the instruction.

  8. What does target_compile_definitions(version PRIVATE BUILD_NAME="x") do?

    Answer

    Adds -DBUILD_NAME="x" to the compile of that target

    The definition is passed to the compiler for the target's sources.

  9. Why should the tests run in a build where NDEBUG is not defined?

    Answer

    Because the asserts they rely on are removed in Release

    A test that uses assert checks a real condition only when NDEBUG is absent.

  10. What does this program print when it is built without BUILD_NAME defined? #include <iostream> #ifndef BUILD_NAME #define BUILD_NAME "unknown" #endif int main() { std::cout << "build: " << BUILD_NAME << '\n'; }

    Answer

    build: unknown

    The #ifndef fallback defines BUILD_NAME as unknown, and the program prints it after the label.

The code on this page was built with g++ 15.2.0 and CMake 4.2.3 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev