Skip to content

10. Build Systems: CMake

What & why

Typing g++ commands stops scaling at about three files. You need to rebuild only what changed, manage include paths and libraries, pull in dependencies, and build the same project with GCC, Clang, and MSVC on three operating systems. CMake is the de facto standard for this. It doesn't build anything itself — it generates build files for Make, Ninja, Visual Studio, or Xcode.

Install

cmake --version     # want 3.24+
ninja --version     # optional but recommended: a fast build tool
  • macOS: brew install cmake ninja
  • Linux: sudo apt install cmake ninja-build
  • Windows: bundled with Visual Studio, or winget install Kitware.CMake Ninja-build.Ninja

A minimal project

hello/
├── CMakeLists.txt
└── main.cpp
cmake_minimum_required(VERSION 3.24)
project(hello LANGUAGES CXX)

add_executable(hello main.cpp)
target_compile_features(hello PRIVATE cxx_std_20)

Build it — out of source, in a separate build/ directory:

cmake -S . -B build             # configure: generate build files into build/
cmake --build build             # compile and link
./build/hello

Re-run only cmake --build build after editing sources; CMake re-configures itself when CMakeLists.txt changes.

Useful configure options:

cmake -S . -B build -G Ninja                     # use Ninja
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug     # Debug | Release | RelWithDebInfo
cmake -S . -B build -DCMAKE_CXX_COMPILER=clang++
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON   # for clangd / IDEs

Targets: the core idea

Modern CMake is about targets (executables and libraries) and their properties (sources, include dirs, compile options, dependencies). Every property is set on a target with a visibility keyword:

Keyword Applies to this target Propagates to targets that link it
PRIVATE yes no
PUBLIC yes yes
INTERFACE no yes

A realistic layout

stats/
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   ├── stats/
│   │   ├── stats.hpp
│   │   └── stats.cpp
│   └── main.cpp
└── tests/
    ├── CMakeLists.txt
    └── test_stats.cpp

Top-level CMakeLists.txt:

cmake_minimum_required(VERSION 3.24)
project(stats VERSION 0.1.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)       # -std=c++20, not -std=gnu++20

add_subdirectory(src)

include(CTest)                      # defines BUILD_TESTING (ON by default) and enable_testing()
if(BUILD_TESTING)
  add_subdirectory(tests)
endif()

src/CMakeLists.txt:

# A library with the reusable logic
add_library(stats_lib stats/stats.cpp)
target_include_directories(stats_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_options(stats_lib PRIVATE
  $<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-Wall -Wextra -Wpedantic>
  $<$<CXX_COMPILER_ID:MSVC>:/W4>
)

# A thin executable on top
add_executable(stats main.cpp)
target_link_libraries(stats PRIVATE stats_lib)

Because stats_lib's include directory is PUBLIC, anything that links stats_lib — the executable, the tests — automatically gets it. #include "stats/stats.hpp" just works.

The $<...> expressions are generator expressions, evaluated at build time — here they pick warning flags per compiler.

Put your logic in a library and keep main.cpp thin. Tests can then link the library directly.

Dependencies

find_package: libraries already installed

find_package(Threads REQUIRED)
target_link_libraries(stats PRIVATE Threads::Threads)

find_package(fmt CONFIG REQUIRED)       # if installed via a package manager
target_link_libraries(stats PRIVATE fmt::fmt)

FetchContent: download at configure time

include(FetchContent)
FetchContent_Declare(
  Catch2
  GIT_REPOSITORY https://github.com/catchorg/Catch2.git
  GIT_TAG        v3.7.1
)
FetchContent_MakeAvailable(Catch2)

target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)

Always pin a tag or commit, never a branch.

Package managers

For many dependencies, use vcpkg or Conan. With vcpkg in manifest mode you list dependencies in vcpkg.json and pass -DCMAKE_TOOLCHAIN_FILE=<vcpkg>/scripts/buildsystems/vcpkg.cmake; then find_package finds them.

Presets

Remembering long configure commands is error-prone. CMakePresets.json checks them into the repo:

{
  "version": 6,
  "configurePresets": [
    {
      "name": "debug",
      "generator": "Ninja",
      "binaryDir": "build/debug",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
      }
    },
    {
      "name": "asan",
      "inherits": "debug",
      "binaryDir": "build/asan",
      "cacheVariables": {
        "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
      }
    },
    {
      "name": "release",
      "generator": "Ninja",
      "binaryDir": "build/release",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    }
  ],
  "buildPresets": [
    { "name": "debug", "configurePreset": "debug" },
    { "name": "asan", "configurePreset": "asan" },
    { "name": "release", "configurePreset": "release" }
  ],
  "testPresets": [
    { "name": "debug", "configurePreset": "debug", "output": { "outputOnFailure": true } }
  ]
}
cmake --preset debug
cmake --build --preset debug
ctest --preset debug

Install rules (briefly)

install(TARGETS stats RUNTIME DESTINATION bin)
cmake --install build --prefix ~/.local

The advanced track covers exporting libraries for other CMake projects.

Old-style CMake to avoid

You'll see these in tutorials and old projects. Don't write them:

include_directories(...)       # global — use target_include_directories
add_definitions(...)           # global — use target_compile_definitions
link_libraries(...)            # global — use target_link_libraries
set(CMAKE_CXX_FLAGS "... -Wall")   # global string munging — use target_compile_options
file(GLOB SOURCES src/*.cpp)   # new files aren't picked up until re-configure; list sources explicitly

Common mistakes

  • Building in the source directory (cmake .) — litters the tree. Always -B build.
  • Forgetting to re-run the build after changes, or deleting build/ to "fix" things instead of reading the error.
  • Directory-wide commands instead of target-based ones.
  • Wrong visibility — a PRIVATE include dir that users of the library also need should be PUBLIC.
  • Unpinned FetchContent dependencies.
  • Setting CMAKE_BUILD_TYPE with multi-config generators (Visual Studio, Xcode) — use cmake --build build --config Release instead.

Exercises

  1. Convert your beginner capstone (todo) to CMake with a todo_lib library and a todo executable.
  2. Add warning flags via a generator expression, and add a -DTODO_WERROR=ON option (option(...)) that adds -Werror.
  3. Create CMakePresets.json with debug, asan, and release presets. Build all three.
  4. Fetch fmt with FetchContent and use fmt::print in your program.
  5. Enable CMAKE_EXPORT_COMPILE_COMMANDS and point your editor's clangd at build/compile_commands.json. Notice how code navigation improves.

Recap

  • CMake generates build files; configure with cmake -S . -B build, build with cmake --build build.
  • Think in targets: add_library, add_executable, target_link_libraries, target_include_directories, target_compile_options.
  • PRIVATE / PUBLIC / INTERFACE control what propagates to dependents.
  • Keep logic in a library and main thin so tests can link it.
  • Dependencies via find_package, FetchContent (pinned), or vcpkg/Conan.
  • CMakePresets.json captures your build configurations.

Next: Testing with Catch2 →