TRT
Sign in

Foundations · Module 1 · Lesson 8 of 9

Build Systems and Toolchains: CMake Basics

Turn a multi-file C++ program into a repeatable build with CMake, explicit language versions and separate debug and release configurations.

FreeAbout 11 minutes

By the end you can

  • Explain why a build system tracks dependencies instead of running one giant compiler command
  • Write a small CMake project with a library, executable and C++ standard requirement
  • Choose debug, release and sanitizer configurations deliberately

Builds on From Source Text to a Running Program.

01 · A question first

A project works on your laptop with one long compiler command, then fails when a teammate adds a source file or uses a different compiler. Which files belong in the build, which flags are part of the contract, and how can the build redo only the stale work?

Make a guess, then step through the trace below to check it.

02 · Watch it run

CMake configures then buildsstep 1 of 4
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debugcmake --build build./build/contact_book
  1. stage 0sources
  2. stage 1build graph
  3. stage 2binary
  4. stage 3run

CMake reads the project description and discovers the compiler, platform and requested C++ standard. No program has been compiled yet.

Machine work: configure targets and toolchain

03 · The idea

Start with the problem

The contact book now spans a header and several source files. A command such as c++ -std=c++20 -Wall -Wextra parse.cpp io.cpp main.cpp -o contact_book is useful while learning, but it is a poor project contract. It is easy to forget a source file, apply a flag to one target but not another, or rebuild everything after changing one header. A build system records the relationships once and regenerates the exact commands for the current machine.

CMake is a build-system generator. It does not replace the compiler. It reads a portable project description, discovers a compiler and generator, and writes a native build graph such as Ninja or Make. The trace above separates configuration, compilation, linking and execution so a failure can be assigned to the right stage.

How the idea works

A minimal project has a CMakeLists.txt:

cmake
cmake_minimum_required(VERSION 3.25)
project(contact_book LANGUAGES CXX)
 
add_library(contact_lib parse.cpp io.cpp)
target_compile_features(contact_lib PUBLIC cxx_std_20)
target_include_directories(contact_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(contact_lib PRIVATE -Wall -Wextra -Wpedantic)
 
add_executable(contact_book main.cpp)
target_link_libraries(contact_book PRIVATE contact_lib)

add_library creates a target from implementation files. add_executable creates the final program. target_link_libraries records the dependency: the executable needs the library, so the build must compile the library before linking the executable. target_compile_features states that this target requires C++20. The setting travels to consumers through PUBLIC, while warnings remain a local policy here.

The build graph is the dependency network behind those declarations. A source file depends on the headers it includes. An executable depends on its object files and libraries. When contact.h changes, every translation unit that includes it may need recompilation; when only main.cpp changes, the library objects can remain valid. The generator and native build tool use timestamps or a stronger dependency database to select the smallest correct rebuild.

Why it is correct

The build is reproducible because the important decisions are named: target sources, include paths, language standard and link dependencies. A teammate can configure a fresh build directory without copying a private command from shell history. Separate build directories prevent debug objects and release objects from being mixed:

sh
cmake -S . -B build/debug -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build/debug
 
cmake -S . -B build/release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build/release

The build directory is generated state, not source. It can be deleted and recreated without losing the project description. In a real repository, the compiler and dependency versions still belong in documented toolchain files or CI images; CMake makes the command graph explicit but cannot make two different compilers identical.

What it costs

Configuration happens once per build directory and is usually cheap compared with compilation. Incremental builds save time by avoiding valid work. A clean build remains necessary after changing compiler versions, ABI flags or generated configuration because old objects may no longer match the new contract. Release optimization can improve runtime speed while increasing compile time, memory use and debugging difficulty.

Sanitizers are configurations, not decorations:

sh
cmake -S . -B build/asan \
  -DCMAKE_CXX_FLAGS="-g -O1 -fsanitize=address,undefined"
cmake --build build/asan

Keep sanitizer and release builds separate. ThreadSanitizer generally needs its own build because its instrumentation is not compatible with AddressSanitizer in one executable.

Pitfalls and variations

Do not put compiler-specific commands in every lesson or script when a target property expresses the intent. Prefer target-scoped include directories and options over global flags. Do not commit generated build directories. On Windows, use a generator and compiler appropriate to the project rather than assuming Unix shell commands. Package managers such as vcpkg or Conan can supply dependencies, but the project should still state which targets consume them.

The next step is not a more complicated build file. It is using the build graph to run tests, sanitizers and installation rules consistently. CMake is the map; the compiler and linker still produce the program.

Production guidance

Treat a CMake target as the unit of ownership. Put public headers in target_include_directories with PUBLIC or INTERFACE scope, and keep implementation-only include paths PRIVATE. The same rule applies to compile features, warning flags and linked libraries. Directory-wide commands such as include_directories() or add_definitions() make it difficult to see why a target compiles on one machine and fails on another. An interface library is useful for shared warnings or a header-only component, but it still needs a narrow, named purpose.

Prefer imported targets such as Threads::Threads, fmt::fmt or a package-provided target over spelling raw -l flags. An imported target carries include directories, transitive dependencies and platform details. find_package should be paired with a clear required or optional policy, and optional features should be tested in configuration rather than discovered with ad-hoc compiler probes. Keep third-party versions in the dependency mechanism or toolchain file so a clean checkout can reproduce the decision.

Separate configuration from the build. A configure preset can select a compiler, architecture and dependency root; a build preset can select a configuration and parallelism; a test preset can define labels, environment and sanitizers. Do not bake a developer’s absolute path into the project. Toolchain files are the right place for cross-compilers, sysroots and platform defaults. Cache variables are inputs to configuration, not a substitute for documenting required options.

The graph should also express verification. Register tests with CTest, add install rules for the public artifact, and provide an export package if another project consumes it. Generate compile commands for tooling, but do not assume that a compile database is the build itself. Build a fresh tree in CI, use warnings as errors only under a deliberate policy, and run debug, release and sanitizer configurations where their findings are actionable. When diagnosing a failure, inspect the target’s compile and link lines before editing source; most “works locally” problems are missing graph edges, inconsistent language standards or leaked host settings.

04 · Prove it

Question 1 of 3 · answer from memory

What is the main job of a build system?

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save