Lesson • Intermediate
CMake & Build Systems
By the end of this lesson you'll be able to write a CMakeLists.txt from scratch, build a project with two commands, split reusable code into a library, switch between Debug and Release builds, and pull in third-party libraries with find_package — the way every professional C++ project is built.
What You'll Learn
- Write a CMakeLists.txt with cmake_minimum_required and project()
- Build any project with cmake -B build && cmake --build build
- Create targets with add_executable and add_library
- Wire targets together with target_link_libraries and target_include_directories
- Choose Debug vs Release builds with CMAKE_BUILD_TYPE
- Pull in third-party libraries with find_package
g++ main.cpp -o app) and splitting code across files — see Header Best Practices. CMake is what you reach for once "one big compiler command" stops scaling. Note: CMake and terminal commands can't run in the C++ editor, so the build recipes appear in comments and <pre> blocks with their expected output noted; the runnable snippets show the C++ that each target compiles.💡 Real-World Analogy
Think of CMakeLists.txt as a recipe and CMake as the head chef who reads it. The recipe lists ingredients (your source files) and the steps. CMake doesn't cook — it writes a precise, station-by-station prep list (the build/ folder) that the line cooks (your compiler, g++ or MSVC) actually follow. That's why building is two steps: first the chef plans the kitchen (cmake -B build — the configure step), then the cooks execute the plan (cmake --build build — the build step). Write the recipe once and the same kitchen runs on Linux, macOS, or Windows.
1. Your First CMakeLists.txt & the Build Flow
A CMakeLists.txt sits at the root of your project and declares the minimum CMake version, the project name and language, the C++ standard, and the program to build. You build with two commands: cmake -B build reads the recipe and writes build files into a fresh build/ folder (the configure step), then cmake --build build runs the compiler (the build step). Keeping generated files in build/ — an "out-of-source" build — means you can delete that folder any time without touching your code.
Worked example: a minimal CMakeLists.txt + build flow
Read the commented recipe, then run the C++ that the 'hello' target compiles.
#include <iostream>
using namespace std;
// CMake config is NOT C++, so it cannot run in this editor.
// Read the commented CMakeLists.txt below, then run the program
// to see the exact terminal commands that build it.
// ============================================================
// project/CMakeLists.txt (the build recipe for your project)
// ============================================================
//
// cmake_minimum_required(VERSION 3.20) # oldest CMake allowed
// project(Hell
...Every recipe opens with the same three lines: cmake_minimum_required, project(), and a target created by add_executable. A target is just a thing CMake knows how to build — usually a program or a library.
Worked example: the three lines every CMakeLists.txt starts with
cmake_minimum_required, project(), and add_executable explained.
#include <iostream>
using namespace std;
// Every CMakeLists.txt starts with the same three lines.
// They are shown here as comments because CMake is not C++.
// ------------------------------------------------------------
// cmake_minimum_required(VERSION 3.20)
// Refuse to run on a CMake older than 3.20. This guarantees
// the features you use below actually exist.
//
// project(MyApp VERSION 1.0 LANGUAGES CXX)
// Name = MyApp, version = 1.0, language = C++ (CXX).
// S
...set(CMAKE_CXX_STANDARD 20) with set(CMAKE_CXX_STANDARD_REQUIRED ON). Without the second line, CMake silently falls back to an older standard if the compiler can't do C++20 — so your modern code mysteriously fails to compile instead of giving a clear error.Your turn. Complete the CMakeLists.txt in the comments by filling in the three ___ blanks using the // 👉 hints, then run the program to see the target it would build.
🎯 Your turn: complete the CMakeLists.txt header
Fill in the project name, C++ standard, and source file.
#include <iostream>
using namespace std;
// 🎯 YOUR TURN — complete the CMakeLists.txt written in the comments.
// (Edit the comment text, then run the C++ to see the goal output.)
//
// cmake_minimum_required(VERSION 3.20)
//
// # 1) Name the project "Greeter" with language C++:
// project(___ LANGUAGES CXX) // 👉 replace ___ with Greeter
//
// # 2) Build C++17:
// set(CMAKE_CXX_STANDARD ___) // 👉 replace ___ with 17
//
// # 3) Build an executable target "greeter" from sr
...2. Libraries, Executables & Linking
add_executable() builds a runnable program; add_library() builds reusable code other targets link against (a STATIC library is bundled straight into the programs that use it). You join them with target_link_libraries(program PRIVATE the_lib), and you expose a library's headers with target_include_directories(the_lib PUBLIC include) so anything linking it can #include them. This is what makes testing clean: your test program links the same library as your app, so it exercises real code.
Worked example: a library, an app, and a test target
add_library, target_include_directories, and target_link_libraries together.
#include <iostream>
using namespace std;
// Real projects split reusable code into a LIBRARY that programs LINK against.
// ============================================================
// CMakeLists.txt (a library + two executables that use it)
// ============================================================
//
// project(Calculator LANGUAGES CXX)
//
// # A static library built from the calculator sources:
// add_library(calc_lib STATIC src/calculator.cpp)
//
// # Anyone linking calc_lib
...include_directories() instead of the per-target target_include_directories(). Global includes leak into every target and cause baffling build issues in big projects. Prefer the target_* commands — they scope each dependency to exactly the target that needs it.Now you try. The library and program already exist — add the one line that links them so the program can call the library's code.
🎯 Your turn: link the library to the program
Fill in target_link_libraries so 'notes' uses 'notes_lib'.
#include <iostream>
using namespace std;
// 🎯 YOUR TURN — wire up the library so the app can use it.
//
// project(Notes LANGUAGES CXX)
//
// # A library target already exists:
// add_library(notes_lib STATIC src/notes.cpp)
// target_include_directories(notes_lib PUBLIC include)
//
// # The program:
// add_executable(notes src/main.cpp)
//
// # 1) Link notes_lib into the "notes" executable:
// target_link_libraries(notes PRIVATE ___) // 👉 replace ___ with notes_lib
int main() {
...3. Build Types: Debug vs Release
The same source code can be compiled two ways, and you choose which at configure time with CMAKE_BUILD_TYPE. Debug keeps debug symbols and turns optimisation off so a debugger maps cleanly to your lines — use it while developing. Release turns optimisation up to -O3 and strips the symbols, producing a much faster program — use it for what you ship. RelWithDebInfo is the middle ground: optimised but still debuggable.
Worked example: Debug vs Release builds
How CMAKE_BUILD_TYPE changes the compiler flags.
#include <iostream>
using namespace std;
// The SAME source can be built two ways. You pick the build TYPE
// at configure time; CMake passes the right compiler flags for you.
// ============================================================
// Debug — for developing:
// $ cmake -B build -DCMAKE_BUILD_TYPE=Debug
// $ cmake --build build
// -> keeps debug symbols (-g), turns optimisation OFF (-O0)
// -> step through code in a debugger; build is fast, runs slower
//
// Release — for
...4. Using Other Libraries with find_package
You rarely write everything yourself. find_package(Name REQUIRED) locates a library already installed on the machine and hands you a target — like Threads::Threads or OpenSSL::SSL — that you drop straight into target_link_libraries. The REQUIRED keyword tells CMake to stop with a clear "package not found" message during configure, instead of letting you hit a confusing compile error much later.
Worked example: find_package and linking a found target
Discover an installed library and link it into your program.
#include <iostream>
using namespace std;
// find_package() locates a library already installed on the machine
// and gives you a target you can link, e.g. Threads::Threads.
// ============================================================
// CMakeLists.txt (using an installed library)
// ============================================================
//
// find_package(Threads REQUIRED) # find the system threads lib
// # REQUIRED = fail now if missing
//
...🔎 Deep Dive: configure vs build, and the build/ folder
Configure (cmake -B build) reads CMakeLists.txt and generates real build files (Makefiles or Ninja files) inside build/. Build (cmake --build build) runs those generated files to compile your code. You only re-configure when CMakeLists.txt changes; editing a .cpp just needs a re-build.
# One-time per machine / after CMakeLists.txt edits: cmake -B build -DCMAKE_BUILD_TYPE=Release # configure # Every time you change a .cpp: cmake --build build # build ./build/myapp # run # Start fresh (safe — build/ is generated, not source): rm -rf build && cmake -B build
Add build/ to your .gitignore — it is generated output, never source you commit.
Pro Tips
- 💡 Prefer the
target_*commands:target_link_librariesandtarget_include_directoriesscope dependencies; the old global versions leak everywhere. - 💡 Start dependencies as
PRIVATE: only switch toPUBLICwhen a library's own headers expose another library to its users. - 💡 One library, many executables: put logic in a library and link it into both the app and the tests so tests use real code.
- 💡 Build into a folder you can delete: out-of-source builds (
cmake -B build) keep your source tree clean.
Common Errors (and the fix)
- "CMake Error: ... does not appear to contain CMakeLists.txt": you ran CMake from the wrong folder. Run
cmake -B buildfrom the directory that holds yourCMakeLists.txt. - "undefined reference to ...": the function exists in a library you forgot to link. Add
target_link_libraries(your_target PRIVATE the_lib). - "fatal error: foo.h: No such file or directory": the compiler can't find the header. Expose it with
target_include_directories(the_lib PUBLIC include). - "Could NOT find Boost (missing: ...)":
find_packagecouldn't locate an installed library. Install it (e.g. with vcpkg/apt) or point CMake at it;REQUIREDis why it stops here with a clear message. - Stale build after editing CMakeLists.txt: sometimes the cache is confused. Delete and reconfigure:
rm -rf build && cmake -B build.
📋 Quick Reference
| Command | What it does |
|---|---|
| cmake_minimum_required(VERSION 3.20) | Require CMake 3.20+ |
| project(MyApp LANGUAGES CXX) | Name the project; set language |
| add_executable(app main.cpp) | Build a program target |
| add_library(lib STATIC a.cpp) | Build a reusable library |
| target_link_libraries(app PRIVATE lib) | Link a library into a target |
| target_include_directories(lib PUBLIC include) | Expose header folders |
| find_package(Threads REQUIRED) | Find an installed library |
| cmake -B build | Configure (generate build files) |
| cmake --build build | Compile the project |
| cmake -B build -DCMAKE_BUILD_TYPE=Release | Optimised release build |
Frequently Asked Questions
Q: What is the difference between CMake and a compiler like g++?
g++ compiles one or more source files into a program. CMake does not compile anything itself — it is a build-system generator that reads your CMakeLists.txt and writes the actual build files (Makefiles or Ninja files) that then call the compiler for you. CMake is the manager; g++ is the worker.
Q: Why do I run two cmake commands (configure then build)?
The first command, cmake -B build, is the configure step: it reads CMakeLists.txt and generates build files inside a folder called build. The second, cmake --build build, is the build step: it runs the compiler using those generated files. You only re-run the configure step when CMakeLists.txt itself changes.
Q: What does PRIVATE vs PUBLIC mean in target_link_libraries?
It controls who inherits the dependency. PRIVATE means only this target uses the library. PUBLIC means this target and anything that links to it both get it (used for libraries whose headers expose the dependency). When unsure, start with PRIVATE — it keeps dependencies from leaking.
Q: Should I use add_executable or add_library?
Use add_executable when the output is a runnable program (it has a main). Use add_library when the output is reusable code other targets link against — a static .a/.lib or shared .so/.dll. Real projects often have one library plus several executables (the app and its tests) that link to it.
Q: What build type should I use, Debug or Release?
Use Debug while developing — it keeps debug symbols and turns optimisation off so a debugger maps cleanly to your code. Use Release for the version you ship — it turns on optimisation (-O3) and strips debug info, making the program much faster. Set it with cmake -B build -DCMAKE_BUILD_TYPE=Release.
Mini-Challenge: A CMakeLists.txt for a Game
No blanks this time — just a brief and an outline. Write the full CMakeLists.txt in the comments (library + executable + linking), list the three build commands, then run the program to confirm the expected output. This is exactly the shape of a real project's build file.
🎯 Mini-Challenge: write the build recipe
Write a CMakeLists.txt with a library, an executable, and linking.
#include <iostream>
using namespace std;
// 🎯 MINI-CHALLENGE: write a CMakeLists.txt for a small game
//
// In the COMMENTS below, write a CMakeLists.txt that:
// 1. Requires CMake 3.20 and names the project "Snake" (LANGUAGES CXX).
// 2. Sets CMAKE_CXX_STANDARD to 20 and marks it REQUIRED.
// 3. Builds a static library "snake_lib" from src/game.cpp,
// with its headers exposed via target_include_directories(... PUBLIC include).
// 4. Builds an executable "snake" from src/main.cpp
...🎉 Lesson Complete
- ✅ Every
CMakeLists.txtstarts withcmake_minimum_required+project() - ✅ Build any project with
cmake -B build(configure) thencmake --build build(build) - ✅
add_executablemakes programs;add_librarymakes reusable libraries - ✅
target_link_librariesjoins targets;target_include_directoriesexposes headers - ✅
CMAKE_BUILD_TYPE=Debugfor developing,Releasefor shipping - ✅
find_package(Name REQUIRED)pulls in installed third-party libraries - ✅ Next lesson: Header Best Practices — organise declarations and avoid duplicate-definition bugs
Sign up for free to track which lessons you've completed and get learning reminders.