CMake and Ninja
What this module covers
CMake configure, generate, build, and install stages CMakeLists.txt structure and minimum version
Projects, targets, sources, and target types Target-scoped include directories, definitions, and features
Linking libraries and transitive usage requirements Build types and generator expressions
Out-of-source builds and build directories CMake cache and reconfiguration
Find packages, imported targets, and package config files Testing with CTest
Installation and exported package configuration Presets and toolchain files
Ninja basics and why fast graph execution matters CMake diagnostics and common configuration errors Reusable CMake project structure
5.1 What CMake does
CMake is a build-system generator. It reads project configuration and generates native build files for a selected generator, such as Ninja or a platform-specific IDE project. The generated build tool then executes the build graph.
Typical workflow:
_The terminal session under 5.7 runs these four commands on a complete project._
Ninja must be installed if the Ninja generator is selected. CMake's generator choices depend on platform and installed tools.
Text equivalent: CMake configures (detects compilers, reads CMakeLists.txt, fills the cache), generates files for the chosen generator, and then the generator's tool — here Ninja — performs the build; CTest runs registered tests and cmake --install stages outputs.
cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build --output-on-failure
cmake --install buildflowchart TD
CFG[Configure] --> GEN[Generate build.ninja]
GEN --> BLD[Build with Ninja]
BLD --> TST[Test with CTest]
TST --> INS[Install / package]5.2 Basic project
Terminal session (CMake 3.31.6, Ninja 1.12.1, GCC 11.2, Ubuntu 22.04 on WSL2) with include/sensor.h, src/sensor.c and src/main.c; the absolute project path is shown as <project> and timings as …:
Prefer target-based commands over global flags because they express which target needs a setting and how usage requirements propagate.
cmake_minimum_required(VERSION 3.20)
project(SensorDemo VERSION 1.0.0 LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)
add_executable(sensor_demo
src/main.c
src/sensor.c
)
target_include_directories(sensor_demo
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_options(sensor_demo
PRIVATE
$<$<C_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>
)$ cmake -S . -B build -G Ninja | tidy
-- The C compiler identification is GNU 11.2.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Configuring done (…s)
-- Generating done (…s)
-- Build files have been written to: <project>/build
$ cmake --build build
[1/3] Building C object CMakeFiles/sensor_demo.dir/src/sensor.c.o
[2/3] Building C object CMakeFiles/sensor_demo.dir/src/main.c.o
[3/3] Linking C executable sensor_demo
$ ./build/sensor_demo
sensor_scale(21) = 42
$ cmake --build build
ninja: no work to do.5.3 Libraries and target relationships
Terminal session (CMake 3.31.6, Ninja 1.12.1): the include directory reaches app through PUBLIC: Common visibility keywords:
PRIVATE: applies to the target itself.
PUBLIC: applies to the target and consumers.
INTERFACE: applies to consumers, not to the target's own compilation.
Use these deliberately. Incorrect visibility can leak internal build flags or omit required consumer settings.
add_library(sensor STATIC src/sensor.c)
target_include_directories(sensor
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
add_executable(app src/main.c)
target_link_libraries(app PRIVATE sensor)$ cmake -S . -B build -G Ninja > /dev/null
$ cmake --build build -v | grep "app.dir/src/main.c.o -c" | grep -o -- "-I[^ ]*" | sed "s#$PWD#<project>#g"
-I<project>/include
$ ./build/app
sensor_scale(21) = 425.4 Build types
Single-configuration generators often use CMAKE_BUILD_TYPE:
Terminal session (CMake 3.31.6): the compile command CMake generated for the same file in each configuration:
Multi-configuration generators (such as common IDE generators) select configuration at build time:
_Not run: this machine has only single-configuration generators (Ninja, Unix Makefiles), where --config does not select the build type._
Do not assume CMAKE_BUILD_TYPE behaves identically with all generators.
cmake -S . -B build-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release$ cmake -S . -B build-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug > /dev/null
$ cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release > /dev/null
$ cmake --build build-debug -v | grep -o "/usr/bin/cc .*sensor_demo.dir/src/sensor.c.o -MF" | sed -e "s#$PWD#<project>#g" -e "s# -MD.*##"
/usr/bin/cc -I<project>/include -g -std=c11 -Wall -Wextra -Wpedantic
$ cmake --build build-release -v | grep -o "/usr/bin/cc .*sensor_demo.dir/src/sensor.c.o -MF" | sed -e "s#$PWD#<project>#g" -e "s# -MD.*##"
/usr/bin/cc -I<project>/include -O3 -DNDEBUG -std=c11 -Wall -Wextra -Wpedanticcmake --build build --config Release5.5 CMake presets
CMakePresets.json example:
Terminal session (CMake 3.31.6) with the CMakePresets.json above:
Preset schema versions depend on CMake versions. Validate against the version your team supports.
{
"version": 6,
"configurePresets": [
{
"name": "debug",
"displayName": "Debug with Ninja",
"generator": "Ninja",
"binaryDir": "${sourceDir}/out/build/debug",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
}
],
"buildPresets": [
{
"name": "debug",
"configurePreset": "debug"
}
]
}cmake --preset debug
cmake --build --preset debug$ cmake --preset debug | tidy | tail -3
-- Configuring done (…s)
-- Generating done (…s)
-- Build files have been written to: <project>/out/build/debug
$ cmake --build --preset debug
[1/6] Building C object CMakeFiles/sensor_test.dir/src/sensor.c.o
[2/6] Building C object CMakeFiles/sensor_demo.dir/src/main.c.o
[3/6] Building C object CMakeFiles/sensor_demo.dir/src/sensor.c.o
[4/6] Building C object CMakeFiles/sensor_test.dir/tests/sensor_test.c.o
[5/6] Linking C executable sensor_demo
[6/6] Linking C executable sensor_test
$ ls out/build/debug
CMakeCache.txt
CMakeFiles
CTestTestfile.cmake
build.ninja
cmake_install.cmake
sensor_demo
sensor_test5.6 Testing with CTest
For larger projects, prefer a test framework and register each test target appropriately. A build succeeding does not mean the tests passed.
A correction found by running the example The add_executable(sensor_test tests/sensor_test.c src/sensor.c) line on its own does not build in the project layout from 5.2, because the headers live in include/ and the include directory was given only to sensor_demo:
Terminal session (CMake 3.31.6, Ninja 1.12.1), with the three CTest lines appended unchanged:
The test target needs its own usage requirement — target_include_directories(sensor_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) — or, better, link it to a sensor library target that carries a PUBLIC include directory (5.3). The full session below the 5.1 workflow uses the added line.
enable_testing()
add_executable(sensor_test tests/sensor_test.c src/sensor.c)
add_test(NAME sensor_unit_test COMMAND sensor_test)ctest --test-dir build --output-on-failure$ cmake -S . -B build -G Ninja > /dev/null && cmake --build build 2>&1 | tidy | head -8
[1/6] Building C object CMakeFiles/sensor_test.dir/src/sensor.c.o
FAILED: CMakeFiles/sensor_test.dir/src/sensor.c.o
/usr/bin/cc -std=c11 -MD -MT CMakeFiles/sensor_test.dir/src/sensor.c.o -MF CMakeFiles/sensor_test.dir/src/sensor.c.o.d -o CMakeFiles/sensor_test.dir/src/sensor.c.o -c <project>/src/sensor.c
<project>/src/sensor.c:1:10: fatal error: sensor.h: No such file or directory
1 | #include "sensor.h"
| ^~~~~~~~~~
compilation terminated.
[2/6] Building C object CMakeFiles/sensor_test.dir/tests/sensor_test.c.o5.7 Installation
Terminal session (CMake 3.31.6, Ninja 1.12.1, CTest 3.31.6): the full workflow from 5.1, on the project from 5.2 with the CTest lines from 5.6 (plus the include fix described there) and this install rule. --prefix stage keeps the install inside the project instead of writing to /usr/local:
Installation rules define how built outputs are staged for use or packaging. Installation layout and runtime dependency handling vary by operating system and packaging model.
install(TARGETS sensor_demo
RUNTIME DESTINATION bin
)$ tail -12 CMakeLists.txt
PRIVATE
$<$<C_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>
)
enable_testing()
add_executable(sensor_test tests/sensor_test.c src/sensor.c)
add_test(NAME sensor_unit_test COMMAND sensor_test)
target_include_directories(sensor_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
install(TARGETS sensor_demo
RUNTIME DESTINATION bin
)
$ cmake -S . -B build -G Ninja | tidy | tail -3
-- Configuring done (…s)
-- Generating done (…s)
-- Build files have been written to: <project>/build
$ cmake --build build
[1/6] Building C object CMakeFiles/sensor_demo.dir/src/sensor.c.o
[2/6] Building C object CMakeFiles/sensor_test.dir/src/sensor.c.o
[3/6] Building C object CMakeFiles/sensor_demo.dir/src/main.c.o
[4/6] Building C object CMakeFiles/sensor_test.dir/tests/sensor_test.c.o
[5/6] Linking C executable sensor_demo
[6/6] Linking C executable sensor_test
$ ctest --test-dir build --output-on-failure | tidy
Internal ctest changing into directory: <project>/build
Test project <project>/build
Start 1: sensor_unit_test
1/1 Test #1: sensor_unit_test ................. Passed … sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) =
…5.8 Toolchain file concept
A cross-compilation toolchain file can define target system information and compiler paths:
Terminal session (CMake 3.31.6) on a machine without an ARM toolchain — the skeleton fails exactly where expected:
This is a teaching skeleton, not a complete production toolchain file. Real embedded projects often also need a sysroot, processor details, compiler flags, linker scripts, startup objects, and platform-specific try-compile configuration.
# toolchains/example-toolchain.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)$ cmake -S . -B build-arm -G Ninja --toolchain toolchains/example-toolchain.cmake 2>&1 | tidy | sed -n '/CMake Error/,+9p'
CMake Error at CMakeLists.txt:2 (project):
The CMAKE_C_COMPILER:
arm-none-eabi-gcc
is not a full path and was not found in the PATH.
Tell CMake where to find the compiler by setting either the environment
variable "CC" or the CMake cache entry CMAKE_C_COMPILER to the full path to
the compiler, or to the compiler name if it is in the PATH.5.9 Ninja
Ninja focuses on executing a build graph quickly and efficiently; it generally expects another tool such as CMake to generate its build file. Its small syntax and scheduling engine are designed for fast incremental builds.
Useful commands:
Terminal session (Ninja 1.12.1) in the build directory from above:
Tool subcommands and output depend on Ninja version and generated build file.
ninja -C build
ninja -C build -t targets
ninja -C build -t graph$ ninja -C build
ninja: Entering directory `build'
ninja: no work to do.
$ ninja -C build -t targets | head -6
test: phony
edit_cache: phony
rebuild_cache: phony
list_install_components: phony
install: phony
install/local: phony
$ ninja -C build -t graph sensor_demo | grep -c -- '->'
10
$ ninja -C build -t commands sensor_demo | sed "s#$PWD#<project>#g"
/usr/bin/cc -I<project>/include -std=c11 -Wall -Wextra -Wpedantic -MD -MT CMakeFiles/sensor_demo.dir/src/main.c.o -MF CMakeFiles/sensor_demo.dir/src/main.c.o.d -o CMakeFiles/sensor_demo.dir/src/main.c.o -c <project>/src/main.c
/usr/bin/cc -I<project>/include -std=c11 -Wall -Wextra -Wpedantic -MD -MT CMakeFiles/sensor_demo.dir/src/sensor.c.o -MF CMakeFiles/sensor_demo.dir/src/sensor.c.o.d -o CMakeFiles/sensor_demo.dir/src/sensor.c.o -c <project>/src/sensor.c
: && /usr/bin/cc CMakeFiles/sensor_demo.dir/src/main.c.o CMakeFiles/sensor_demo.dir/src/sensor.c.o -o sensor_demo && :5.10 Common CMake errors
A source file was not added to the target.
A dependency's include directory is not propagated.
A target links to a library that was never created or found.
A stale cache points to an old compiler or path.
A variable is set globally when a target-specific property was intended.
A build directory is reused for a different generator or toolchain.
A test executable is built but not registered with CTest.
When changing compilers or generators, a fresh build directory is often safer than trying to reuse an incompatible cache.
Two of these errors, for real Reusing a build directory with a different generator:
Terminal session (CMake 3.31.6): A toolchain file whose compiler is not installed is shown under 5.8.
$ cmake -S . -B build -G Ninja > /dev/null
$ cmake -S . -B build -G 'Unix Makefiles' 2>&1 | tidy | head -6
CMake Error: Error: generator : Unix Makefiles
Does not match the generator used previously: Ninja
Either remove the CMakeCache.txt file and CMakeFiles directory or choose a different binary directory.Quiz
? `cmake -S . -B build -G Ninja` — what do `-S` and `-B` name?
+ The source directory and the build directory :: Out-of-source builds keep generated files separate.
- The script and the binary :: Not CMake's meaning.
- Static and binary libraries :: No.
- Settings and build type :: Build type is `CMAKE_BUILD_TYPE`.
? A library declares `target_include_directories(sensor PUBLIC include)` and `app` links it. What happened in the real run?
+ `app`'s compile line received `-I<project>/include` automatically. :: PUBLIC usage requirements propagate to consumers.
- `app` failed to find `sensor.h`. :: PUBLIC propagation prevented that.
- The include directory was applied globally to every target. :: Only `sensor` and its consumers.
- CMake copied the header into `app`'s folder. :: It adds a flag; it copies nothing.
? What flags did CMake 3.31 add for `CMAKE_BUILD_TYPE` in the real runs? (select all that apply)
+ Debug: `-g` :: Debug information.
+ Release: `-O3 -DNDEBUG` :: Optimisation and assertions disabled.
- Release: `-O0` :: That is unoptimised.
- Debug: `-DNDEBUG` :: Debug keeps assertions.