Skip to content
C++
Build System
Updated 2025-01-01T00:00:00.000Z

CMake

The de facto C++ build system. Project structure, targets, dependencies, install rules, and modern CMake best practices.

TL;DR

CMake generates build files (Makefiles, Ninja, VS solutions) from a platform-neutral description. The modern CMake idiom revolves around targets and properties — not global variables and directory-level flags.

cmake
cmake_minimum_required(VERSION 3.25)
project(myapp VERSION 1.0 LANGUAGES CXX)

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

Hello World project

cpp
myapp/
├── CMakeLists.txt
├── src/
│   └── main.cpp
└── include/
    └── myapp/
        └── utils.h
cmake
# CMakeLists.txt
cmake_minimum_required(VERSION 3.25)
project(myapp VERSION 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_executable(myapp src/main.cpp)
target_include_directories(myapp PRIVATE include)

Build it:

bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
./build/myapp

Targets — the core concept

Everything in modern CMake is a target. Properties on targets propagate through the dependency graph via PRIVATE, PUBLIC, and INTERFACE keywords.

cmake
add_library(mylib STATIC
    src/foo.cpp
    src/bar.cpp
)

target_include_directories(mylib
    PUBLIC  include/           # callers also get this include path
    PRIVATE src/               # only mylib itself sees this
)

target_compile_options(mylib
    PRIVATE -Wall -Wextra -Wpedantic
)

target_compile_features(mylib PUBLIC cxx_std_20)

# Executable links against the library — inherits its PUBLIC properties
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE mylib)
Keywordmylib itselfCallers of mylib
PRIVATE✅❌
PUBLIC✅✅
INTERFACE❌✅

Finding & using external packages

cmake
# Find system-installed packages (uses Find*.cmake modules)
find_package(OpenSSL REQUIRED)
target_link_libraries(myapp PRIVATE OpenSSL::SSL OpenSSL::Crypto)

# Find packages installed via vcpkg or Conan
find_package(fmt CONFIG REQUIRED)
target_link_libraries(myapp PRIVATE fmt::fmt)

find_package(nlohmann_json REQUIRED)
target_link_libraries(myapp PRIVATE nlohmann_json::nlohmann_json)

FetchContent — download dependencies at configure time

cmake
include(FetchContent)

FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG        v1.14.0
)
FetchContent_MakeAvailable(googletest)

add_executable(tests test_main.cpp)
target_link_libraries(tests PRIVATE GTest::gtest_main mylib)

Build types

bash
# Debug — no optimization, debug symbols
cmake -B build -DCMAKE_BUILD_TYPE=Debug

# Release — optimized, no debug symbols
cmake -B build -DCMAKE_BUILD_TYPE=Release

# RelWithDebInfo — optimized + debug symbols (for profiling)
cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo

# Multi-config generators (VS, Xcode, Ninja Multi-Config)
cmake -B build -G "Ninja Multi-Config"
cmake --build build --config Release

Testing with CTest

cmake
enable_testing()

add_executable(tests test_suite.cpp)
target_link_libraries(tests PRIVATE GTest::gtest_main mylib)

include(GoogleTest)
gtest_discover_tests(tests)
bash
cmake --build build
ctest --test-dir build -j$(nproc) --output-on-failure

Installation rules

cmake
install(TARGETS myapp mylib
    RUNTIME DESTINATION bin
    LIBRARY DESTINATION lib
    ARCHIVE DESTINATION lib
)

install(DIRECTORY include/ DESTINATION include)

# Generate a config file for find_package(myapp)
include(CMakePackageConfigHelpers)
write_basic_package_version_file(
    "${CMAKE_CURRENT_BINARY_DIR}/myappConfigVersion.cmake"
    COMPATIBILITY SameMajorVersion
)
install(FILES
    cmake/myappConfig.cmake
    "${CMAKE_CURRENT_BINARY_DIR}/myappConfigVersion.cmake"
    DESTINATION lib/cmake/myapp
)

Compiler flags — platform-aware

cmake
# Add warnings without hardcoding compiler names
target_compile_options(myapp PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4 /WX>
    $<$<NOT:$<CXX_COMPILER_ID:MSVC>>:-Wall -Wextra -Wconversion -Wshadow>
)

# Enable sanitizers in debug builds
target_compile_options(myapp PRIVATE
    $<$<AND:$<CONFIG:Debug>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-fsanitize=address,undefined>
)
target_link_options(myapp PRIVATE
    $<$<AND:$<CONFIG:Debug>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-fsanitize=address,undefined>
)

Presets (CMakePresets.json)

Replace shell scripts with versioned, shareable presets:

json
{
  "version": 6,
  "configurePresets": [
    {
      "name": "default",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Debug",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
      }
    },
    {
      "name": "release",
      "inherits": "default",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    }
  ],
  "buildPresets": [
    { "name": "default", "configurePreset": "default" },
    { "name": "release", "configurePreset": "release" }
  ]
}
bash
cmake --preset default
cmake --build --preset default

Common pitfalls

Don't set CMAKE_CXX_FLAGS globally

cmake
# BAD — affects every target, no PRIVATE/PUBLIC control
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")

# GOOD — scoped to the target
target_compile_options(myapp PRIVATE -Wall -Wextra)
cmake
# BAD — directory-level, hard to reason about
include_directories(include)
link_libraries(mylib)

# GOOD — target-scoped
target_include_directories(myapp PRIVATE include)
target_link_libraries(myapp PRIVATE mylib)

Always set CXX_EXTENSIONS OFF

Without this, -std=gnu++20 is used instead of -std=c++20, pulling in GCC extensions and breaking portability.


Version requirements

FeatureMinimum CMake
cmake_minimum_required + project()3.0
target_compile_features3.1
FetchContent3.11
FetchContent_MakeAvailable3.14
cmake --build --target3.15
cmake --install3.15
CMakePresets.json3.19
--preset flag3.20

Generator Expressions — Deep Dive

Generator expressions are evaluated at build-system generation time, not at configure time — which makes them the right tool for values that differ per config, per compiler, or between the build tree and the install tree.

The building blocks are logical operators and query expressions:

cmake
# Boolean primitives
$<BOOL:string>          # 1 if string is non-empty/non-zero/non-false
$<AND:a,b>             # 1 if both a and b are 1
$<OR:a,b>              # 1 if either a or b is 1
$<NOT:a>               # inverts a

# Common queries
$<CXX_COMPILER_ID:Clang>   # 1 when the C++ compiler is Clang
$<CONFIG:Release>           # 1 in Release builds
$<COMPILE_FEATURES:cxx_std_20>  # 1 if cxx_std_20 is available
$<TARGET_FILE:target>       # full path to the output file of a target

Pattern 1 — compiler-specific warnings without a chain of if() blocks:

cmake
target_compile_options(mylib PRIVATE
    $<$<CXX_COMPILER_ID:MSVC>:/W4 /wd4251>
    $<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra -Wshadow -Wconversion>
    $<$<CXX_COMPILER_ID:Clang,AppleClang>:-Wall -Wextra -Wshadow -Weverything -Wno-c++98-compat>
)

Pattern 2 — config-specific preprocessor defines:

cmake
target_compile_definitions(myapp PRIVATE
    $<$<CONFIG:Debug>:MY_APP_DEBUG=1>
    $<$<CONFIG:Release>:MY_APP_NDEBUG=1 MY_APP_INLINE=inline>
)

Pattern 3 — build-time vs install-time include paths ($<BUILD_INTERFACE:...> and $<INSTALL_INTERFACE:...> are the key pair for installable libraries):

cmake
target_include_directories(mylib PUBLIC
    # When building: headers are relative to the source tree
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    # When installed: headers land under the install prefix
    $<INSTALL_INTERFACE:include>
)

Without this split, consumers who call find_package(mylib) after installing it would inherit absolute paths that only exist on the build machine.

Pattern 4 — conditional link flags using $<TARGET_FILE:...> to copy a DLL next to an executable on Windows:

cmake
add_custom_command(TARGET myapp POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        $<TARGET_FILE:mylib_shared>
        $<TARGET_FILE_DIR:myapp>
)

Toolchain Files — Cross-Compilation

A toolchain file tells CMake which compilers, sysroot, and search paths to use for a target platform instead of the host. Specify it once at configure time; every find_package, find_library, and compiler invocation in the build respects it.

Complete toolchain file for ARM64 Linux (e.g., targeting a Raspberry Pi 4 from an x86-64 host):

cmake
# toolchains/aarch64-linux-gnu.cmake

# Identify the target system
set(CMAKE_SYSTEM_NAME    Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

# Cross compilers — adjust the prefix to match your toolchain
set(CMAKE_C_COMPILER   aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)

# Sysroot containing target headers and libraries
set(CMAKE_SYSROOT /opt/sysroots/aarch64-linux-gnu)

# Tell CMake where to look for target libraries/headers
set(CMAKE_FIND_ROOT_PATH
    /opt/sysroots/aarch64-linux-gnu
    /opt/aarch64-libs
)

# Only search the sysroot for libraries and headers;
# use the host for programs (cmake, python, etc.)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

Invoke it at configure time:

bash
cmake -B build-arm64 \
      -DCMAKE_TOOLCHAIN_FILE=toolchains/aarch64-linux-gnu.cmake \
      -DCMAKE_BUILD_TYPE=Release
cmake --build build-arm64 -j$(nproc)

vcpkg and Conan integrate via their own toolchain files. They each emit a toolchain file during install/install-setup, and you pass that file to CMake the same way — you can chain them with include() inside your own toolchain file:

cmake
# toolchains/aarch64-linux-gnu.cmake  (extended for vcpkg)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER   aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)

# Pull in vcpkg's triplet + library paths after the target is set
include($ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake)

Custom Commands and Targets

add_custom_command teaches CMake how to produce derived files — protobuf stubs, shader headers, version stamps — so they participate in the dependency graph and rebuild only when inputs change.

The canonical pattern: generate version.h from git describe so every build embeds the exact commit:

cmake
# Find git once
find_package(Git REQUIRED)

# Step 1 — a helper script that writes version.h (avoids re-running cmake)
# Alternatively, inline the command directly:
add_custom_command(
    OUTPUT  ${CMAKE_CURRENT_BINARY_DIR}/version.h
    COMMAND ${CMAKE_COMMAND}
            -DGIT_EXECUTABLE=${GIT_EXECUTABLE}
            -DOUTPUT_FILE=${CMAKE_CURRENT_BINARY_DIR}/version.h
            -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/GenerateVersion.cmake
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/.git/HEAD   # re-run on new commits
    COMMENT "Generating version.h from git describe"
    VERBATIM   # don't let CMake mangle the arguments
)

# Step 2 — a target that depends on the generated file
add_custom_target(generate_version
    DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/version.h
)

# Step 3 — sequence: myapp can't compile until generate_version finishes
add_executable(myapp src/main.cpp)
add_dependencies(myapp generate_version)
target_include_directories(myapp PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

cmake/GenerateVersion.cmake (the -P script, shown in the next section):

cmake
execute_process(
    COMMAND ${GIT_EXECUTABLE} describe --tags --always --dirty
    OUTPUT_VARIABLE GIT_DESCRIBE
    OUTPUT_STRIP_TRAILING_WHITESPACE
)
file(WRITE ${OUTPUT_FILE}
    "#pragma once\n#define PROJECT_VERSION \"${GIT_DESCRIBE}\"\n"
)

For protobuf generation the pattern is the same — list .proto files as DEPENDS, list .pb.cc / .pb.h as OUTPUT, and use BYPRODUCTS to tell Ninja about side-effect files so it doesn't mark the build stale:

cmake
add_custom_command(
    OUTPUT  ${CMAKE_CURRENT_BINARY_DIR}/foo.pb.cc
            ${CMAKE_CURRENT_BINARY_DIR}/foo.pb.h
    BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/foo.grpc.pb.cc   # Ninja needs this
    COMMAND protobuf::protoc
            --cpp_out=${CMAKE_CURRENT_BINARY_DIR}
            -I${CMAKE_CURRENT_SOURCE_DIR}/proto
            ${CMAKE_CURRENT_SOURCE_DIR}/proto/foo.proto
    DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/proto/foo.proto
    VERBATIM
)

Always use VERBATIM — without it, CMake applies platform-specific escaping that silently corrupts paths with spaces.


CPack — Packaging

include(CPack) turns your install rules into distributable packages with a single command. CPack reads CPACK_* variables you set before the include, so configure everything first.

Minimal working setup targeting a Linux .deb:

cmake
# Near the top of CMakeLists.txt, after project()
set(CPACK_PACKAGE_NAME        "myapp")
set(CPACK_PACKAGE_VERSION     ${PROJECT_VERSION})   # e.g. 1.2.0
set(CPACK_PACKAGE_VENDOR      "Acme Corp")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "My application")
set(CPACK_PACKAGE_CONTACT     "pkg@example.com")

# Generators to enable (can specify several at once)
set(CPACK_GENERATOR           "DEB;TGZ")

# Debian-specific
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libssl3 (>= 3.0), libstdc++6 (>= 12)")
set(CPACK_DEBIAN_PACKAGE_SECTION "utils")
set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "amd64")  # or use CMAKE_SYSTEM_PROCESSOR

# Install rules must be defined before this line
include(CPack)

Build the package:

bash
# Option A — via cmake --build (calls cpack internally via the "package" target)
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
cmake --build build --target package

# Option B — run cpack directly from the build directory
cd build && cpack -G DEB
cd build && cpack -G TGZ

# Option C — build multiple generators in one shot
cd build && cpack

The result is myapp-1.2.0-Linux.deb (and .tar.gz) in the build directory. Other common CPACK_GENERATOR values:

ValueOutputPlatform
TGZ.tar.gz tarballAll
ZIP.zip archiveAll
DEB.deb packageDebian/Ubuntu
RPM.rpm packageFedora/RHEL
NSISWindows installer (NSIS)Windows
WIXWindows installer (WiX)Windows

For multi-component installs, set CPACK_COMPONENTS_ALL and tag each install() call with a COMPONENT argument; CPack will split them into separate packages when CPACK_DEB_COMPONENT_INSTALL ON is set.


CMake Scripting Mode

cmake -P script.cmake runs a CMake script without configuring a build tree, making it ideal for portable CI glue that avoids a bash/PowerShell dependency. Scripts have access to all CMake commands except target-related ones.

cmake
# scripts/prepare_artifacts.cmake
cmake_minimum_required(VERSION 3.20)

message(STATUS "Preparing release artifacts...")

# Collect built binaries
file(GLOB artifacts "${CMAKE_BINARY_DIR}/bin/*")

# Create output directory
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/dist")

# Copy each artifact
foreach(artifact IN LISTS artifacts)
    get_filename_component(name "${artifact}" NAME)
    message(STATUS "  Copying ${name}")
    file(COPY "${artifact}" DESTINATION "${CMAKE_BINARY_DIR}/dist")
endforeach()

message(STATUS "Done.")

Run it from a CI step:

bash
cmake -P scripts/prepare_artifacts.cmake

Pass variables in with -D — they arrive as CMake cache variables:

bash
cmake -DCMAKE_BINARY_DIR=./build -P scripts/prepare_artifacts.cmake

The cmake -E sub-commands are portable single operations you can call inline from any shell or from another CMake script:

bash
cmake -E copy_if_different src.txt dst.txt   # copy only if changed
cmake -E make_directory    path/to/dir        # mkdir -p equivalent
cmake -E remove_directory  old/               # rm -rf equivalent
cmake -E tar czf out.tar.gz file1 dir/       # create archive
cmake -E sha256sum file.bin                   # compute checksum

Use execute_process inside a -P script when you need to shell out and capture output:

cmake
execute_process(
    COMMAND git describe --tags --always
    OUTPUT_VARIABLE version
    OUTPUT_STRIP_TRAILING_WHITESPACE
    RESULT_VARIABLE git_exit_code
)

if(NOT git_exit_code EQUAL 0)
    message(FATAL_ERROR "git describe failed (exit ${git_exit_code})")
endif()

message(STATUS "Version: ${version}")
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z