CMake Tutorial for C++: CMakeLists.txt and Targets
[C++ Hands-On Guide #4] CMake Introduction
Cross-language build tools: compare with Go modules (getting started), Rust/Cargo (intro), and Node/npm (modules). Those ecosystems ship one official build tool and package manager; C++ never did, which is why CMake exists as a layer that describes the build once and hands it to whatever native tool each platform uses.
After reading: You can write a minimal CMakeLists.txt, add executables/libraries, link targets, and call find_package for common dependencies.
When projects grow, typing long g++ ... lines for dozens of files breaks down. CMake is a build-system generator: you describe what to build in CMakeLists.txt, and CMake emits Makefiles, Ninja files, or IDE projects for each platform.
flowchart LR A[CMakeLists.txt] --> B[cmake configure] B --> C[Makefile / .sln / Ninja] C --> D[Build] D --> E[Binaries & libraries]
Previous: #3 VS Code — editor, tasks, debugging.
Requirements: CMake 3.15+ (the examples use cmake_minimum_required(VERSION 3.15)), g++/Clang or MSVC. Linux/macOS: cmake + a compiler toolchain. Windows: Visual Studio or Build Tools + CMake recommended.
Manual build pain
A three-person team starts with main.cpp, then adds utils.cpp, parser.cpp, … Every new file means editing a giant command line. Windows vs macOS scripts diverge (build.bat vs build.sh). Changing one .cpp may force full rebuilds if your script always compiles everything. CMake tracks dependencies and enables incremental builds.
g++ -std=c++17 -I./include src/main.cpp src/utils.cpp src/parser.cpp \
src/database.cpp src/network.cpp -L./lib -lsqlite3 -lpthread -o myapp
flowchart TB
subgraph manual[Manual g++]
M1[Add file] --> M2[Edit command]
M2 --> M3[Full recompile]
M3 --> M4[Per-OS scripts]
end
subgraph cm[CMake]
C1[Edit CMakeLists.txt] --> C2[cmake --build]
C2 --> C3[Incremental compile]
C3 --> C4[One config, all OSes]
end
Why CMake?
Manual commands don’t scale: -std, -I, -L, -l, different MSVC cl flags on Windows, no reliable incremental builds, and no automatic header dependency tracking.
CMake generates native build files, tracks dependencies, integrates with find_package, and works with VS Code, Visual Studio, CLion, etc.
sequenceDiagram
participant Dev as Developer
participant CMake as CMake
participant Gen as Generator
participant Build as Build tool
Dev->>CMake: cmake ..
CMake->>CMake: Parse CMakeLists.txt
CMake->>CMake: Detect toolchain
CMake->>Gen: Emit Ninja/Make/VS
Dev->>Build: cmake --build .
Build->>Dev: Artifacts
Install & core concepts
choco install cmake # Windows (Chocolatey)
brew install cmake # macOS
sudo apt install cmake # Debian/Ubuntu
cmake --version
Terms
- CMakeLists.txt — what to build.
- Build directory — e.g.
build/, separate from sources (out-of-source build). - Generator — Ninja, Make, Visual Studio, …
- Target — an executable or a library.
CMake runs in two phases, and most beginner confusion comes from mixing them up. Configure (cmake .. or cmake -S . -B build) reads CMakeLists.txt, detects the compiler, and writes the generated build files plus a CMakeCache.txt that remembers every choice it made. Build (cmake --build build) just runs the generated Make/Ninja/MSBuild files. Because the cache remembers the compiler and options, changing them later often has no effect: setting CC=clang after the first configure does nothing, since the cache already recorded gcc. When a setting “won’t stick”, delete the build directory or run cmake --fresh (CMake 3.24+) and configure again.
Out-of-source builds follow from this. All generated files live in build/, so a clean build is rm -rf build, and you can keep build-debug/ and build-release/ side by side from the same sources. Running cmake . in the source directory instead scatters CMakeCache.txt, CMakeFiles/ and Makefiles among your sources, and once a CMakeCache.txt sits in the source directory, a later cmake .. from build/ treats the source directory as an existing build tree and keeps writing there. Delete the stray cache to recover.
| Unix Makefiles | Ninja | |
|---|---|---|
| Speed | OK | Faster parallel builds |
| Configure | slower | faster |
| Common use | defaults | CI, large trees |
cmake -G Ninja ..
cmake --build .
First CMakeLists.txt
main.cpp
// g++ -std=c++20 main.cpp -o hello && ./hello
#include <iostream>
int main() {
std::cout << "Hello, CMake!" << std::endl;
return 0;
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(HelloCMake VERSION 1.0)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(hello main.cpp)
Build
mkdir build && cd build
cmake ..
cmake --build .
./hello
Debug vs Release (single-config generators):
cmake -DCMAKE_BUILD_TYPE=Debug ..
cmake -DCMAKE_BUILD_TYPE=Release ..
CMAKE_BUILD_TYPE only applies to single-config generators (Make, Ninja). Visual Studio and Xcode are multi-config: one configure produces all configurations, CMAKE_BUILD_TYPE is ignored, and you choose at build time with cmake --build . --config Release. The binary then lands in build/Release/hello.exe, not build/hello, which is why ./hello “does not exist” on Windows right after a successful build. With a single-config generator and no build type set, you get neither debug info nor optimization (no -g, no -O2), which is a common reason a first benchmark looks mysteriously slow.
set(CMAKE_CXX_STANDARD 20) together with CMAKE_CXX_STANDARD_REQUIRED ON makes CMake add -std=c++20 (or /std:c++20) and fail at configure time if the compiler cannot do it. Without REQUIRED, CMake silently falls back to an older standard and you find out later from an unrelated-looking error about std::span or concepts.
Multi-file project (calculator-style)
Layout:
calculator/
├── CMakeLists.txt
├── main.cpp
├── src/operations.cpp
├── src/utils.cpp
├── include/operations.h
└── include/utils.h
CMakeLists.txt (library + executable)
cmake_minimum_required(VERSION 3.15)
project(Calculator VERSION 1.0)
set(CMAKE_CXX_STANDARD 20)
add_library(calculator_lib STATIC
src/operations.cpp
src/utils.cpp
)
target_include_directories(calculator_lib PUBLIC include)
add_executable(calculator main.cpp)
target_link_libraries(calculator PRIVATE calculator_lib)
mkdir build && cd build
cmake ..
cmake --build .
./calculator 10 + 5
flowchart TB
subgraph sources
main[main.cpp]
ops[operations.cpp]
util[utils.cpp]
end
subgraph headers
oh[operations.h]
uh[utils.h]
end
subgraph artifacts
lib[calculator_lib]
exe[calculator]
end
ops --> oh
util --> uh
ops --> lib
util --> lib
main --> exe
lib --> exe
Minimal copy-paste project: add_library(calc STATIC src/calc.cpp), target_include_directories(calc PUBLIC include), add_executable(calc_app main.cpp), target_link_libraries(calc_app PRIVATE calc).
The important idea here is that settings are attached to targets and travel with them. target_include_directories(calculator_lib PUBLIC include) says “anyone who links calculator_lib also needs include/ on their include path”, so main.cpp can #include "operations.h" without the executable repeating the path. Older tutorials use the directory-wide commands include_directories() and link_libraries() instead, which apply to every target defined after them. They still work, but in a project with several targets they make it impossible to tell which target actually needs which dependency, and they leak flags into test and tool targets that never asked for them. Prefer the target_* commands everywhere.
Why a library at all, instead of listing every .cpp in add_executable? Once there is a second consumer, such as a unit-test executable, both can link calculator_lib and the sources are compiled once. Listing the same .cpp files in two executables compiles them twice and duplicates the flags.
A related trap is file(GLOB SRC src/*.cpp) to avoid listing files. It works until you add a new .cpp: the glob runs only at configure time, so the new file is silently not compiled until someone reruns cmake, and the symptom is an undefined reference to a function that is clearly in the source tree. The CMake documentation recommends listing sources explicitly for this reason; CONFIGURE_DEPENDS on the glob helps with some generators but is not guaranteed on all.
External libraries — find_package
Example: nlohmann/json (adjust install command per OS).
find_package(nlohmann_json 3.11.0 REQUIRED)
add_executable(json_app main.cpp)
target_link_libraries(json_app PRIVATE nlohmann_json::nlohmann_json)
Discovery order (simplified): CMAKE_PREFIX_PATH, system paths, vcpkg/Conan toolchains, Find*.cmake / *Config.cmake.
find_package looks for one of two kinds of file: a Find<Name>.cmake module (shipped with CMake for older libraries such as ZLIB or OpenSSL) or a <Name>Config.cmake file installed by the library itself. When neither is found, the error is long but precise:
CMake Error at CMakeLists.txt:3 (find_package):
By not providing "Findnlohmann_json.cmake" in CMAKE_MODULE_PATH this project
has asked CMake to find a package configuration file provided by
"nlohmann_json", but CMake did not find one.
It almost always means one of three things: the package is not installed (on Debian/Ubuntu the -dev package, here nlohmann-json3-dev), it is installed somewhere CMake does not search (pass -DCMAKE_PREFIX_PATH=/path/to/install), or you installed it with vcpkg but configured without -DCMAKE_TOOLCHAIN_FILE=<vcpkg>/scripts/buildsystems/vcpkg.cmake. Note that the toolchain file must be given on the first configure; adding it to an existing build directory is ignored because of the cache. The find_package failures post goes through each case.
Always link the imported target (nlohmann_json::nlohmann_json), not raw variables like ${NLOHMANN_JSON_LIBRARIES}. The target carries include paths, compile definitions and transitive dependencies with it; the variables are a pre-target-era interface, and forgetting one of them is how you get headers that compile but symbols that do not link.
VS Code
- Install CMake Tools.
- CMake: Select a Kit → CMake: Configure → build (F7).
- Optional
.vscode/settings.json:
{
"cmake.configureOnOpen": true,
"cmake.buildDirectory": "${workspaceFolder}/build",
"cmake.generator": "Ninja"
}
Enable CMAKE_EXPORT_COMPILE_COMMANDS for IntelliSense with compile commands.
Common errors
| Issue | Fix |
|---|---|
cmake: command not found | Install CMake; fix PATH |
| Version too old | brew upgrade cmake / snap install cmake |
Could NOT find package ... | Install dev package; set CMAKE_PREFIX_PATH; vcpkg toolchain |
| Header not found | target_include_directories |
undefined reference | target_link_libraries with correct imported target |
| Stale cache | Delete build/ and reconfigure |
flowchart TD
A[undefined reference] --> B{Your code or third party?}
B -->|Yours| C[add_library + link target]
B -->|Third party| D[find_package + imported target]
Of all of these, the one I have spent the most time on is the header-found-but-link-fails case. target_include_directories makes #include work, so the code compiles, and it is tempting to think the dependency is set up. But the include path only covers declarations; without target_link_libraries the linker never sees the library’s object code and reports undefined reference to 'ops::add(int, int)' (GCC/Clang) or LNK2019 (MSVC). Header-only libraries such as nlohmann/json hide this distinction because they have nothing to link, which is exactly why the habit breaks the first time you use a compiled library. When a link error appears, ask which target provides the symbol and whether the failing target links it, directly or through a PUBLIC dependency.
Build performance
cmake --build . -j$(nproc) # Linux
cmake --build . -j$(sysctl -n hw.ncpu) # macOS
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release ..
Optional: ccache, PCH (target_precompile_headers), parallel jobs. cmake --build . --parallel (or -j with no number, CMake 3.12+) is the portable spelling, since it passes the right flag to Make, Ninja or MSBuild. Ninja already builds in parallel by default; Make does not, so a plain cmake --build . with the Makefile generator compiles one file at a time. ccache is enabled with -DCMAKE_CXX_COMPILER_LAUNCHER=ccache, and precompiled headers pay off mostly for large, stable headers (the standard library, Qt, Boost) included by many files.
Production patterns
- Modular trees with
add_subdirectory. - Use PRIVATE/PUBLIC/INTERFACE intentionally on
target_link_libraries. - CI: Ninja + ccache, pinned CMake & compiler versions.
- Shipping libraries:
install()rules +Config.cmakefor consumers.
Closing workflow
mkdir build && cd build
cmake ..
cmake --build .
./myapp
Next: Compilation process (#5)
Previous: VS Code setup (#3)
References
Frequently Asked Questions (FAQ)
Q. What is the difference between PUBLIC and PRIVATE in target_link_libraries and target_include_directories?
A. PRIVATE means the setting is used only to build this target, PUBLIC means it is used for this target and passed on to every target that links it, and INTERFACE means it is passed on without being used for the target itself. In the article’s example the library’s include directory is PUBLIC, so the executable that links it finds the headers automatically, while the executable links the library PRIVATE because nothing else depends on the executable.