C++ Part I · Module 24 · C++ Build and Project Architecture
CMake Fundamentals
A build system exists to answer one question correctly: given what has changed, what must be rebuilt? Everything else it does — finding compilers, handling platforms, generating project files — is logistics around that question, and getting the answer wrong is not merely slow. It produces a program assembled from object files that disagree with each other, which is precisely the undetectable failure from the start of this module. So this chapter has two halves that reinforce each other. The first is CMake itself: the target model, which is the one idea that makes modern CMake comprehensible, and the handful of commands that describe a real project. The second is a measurement of why the dependency question is a correctness question rather than a performance one, done with a tool this machine has, showing the same header edit producing three different outcomes — one of which silently ignores your change, and one of which assembles a program from object files that disagree about a type.
Intermediate19 min readC++Part IFundamentalsBuild
Part I · Module 24 · Chapter 24.8 · CMake Fundamentals
1. What This Chapter Can and Cannot Show You
CMake is not installed on this machine. command -v cmake returns nothing, cmake --version reports a command that cannot be found, and it is absent from the usual install locations. So every CMakeLists.txt in this chapter is reference syntax that I have not executed here — correct to the best of my knowledge and checked against the documented behaviour, but not a captured transcript. Where this curriculum shows output, it is output a program actually produced; there is none of that for CMake in this chapter, and I would rather say so than present invented terminal text as a measurement.
What this machine does have is GNU Make 3.81, plus ar, nm, c++filt and otool. That is enough to measure the thing CMake exists to get right — the dependency question — and §3 does exactly that, on a real project, with a real silent failure. The CMake syntax and the Make measurements are about the same problem from two directions.
If you want to run the CMake material, install it and work through §§4–7 against the project in Chapter 24.9.
2. The One Idea: Targets Carry Their Own Requirements
Old CMake worked by setting global state — include_directories(), add_definitions(), a CMAKE_CXX_FLAGS string — which applied to everything declared afterwards and was impossible to reason about in a project with more than one component. Modern CMake replaced that with a single idea:
A target is a thing you build, and it carries the properties needed to build it and the properties needed to use it.
That second half is the important one. When a library says "anyone using me needs this include directory", consumers get it automatically, and the project no longer has a global include path that everything shares. The three keywords that express it are the whole model:
| keyword | applies to the target | applies to its consumers |
|---|---|---|
PRIVATE | yes | no |
INTERFACE | no | yes |
PUBLIC | yes | yes |
So:
- A header in
include/that appears in your public headers →PUBLICinclude directory. - A third-party library used only inside your
.cppfiles →PRIVATElink dependency. - A header-only library you depend on →
INTERFACEfor the library itself, since there is nothing to compile.
Getting these wrong is the commonest CMake error, and it fails in a recognisable way: PRIVATE where you needed PUBLIC compiles your library and then fails to compile your consumers, with a missing header or an undefined symbol.
3. Why the Dependency Question Is a Correctness Question
Here is the measurement, and it uses make because this machine has it. Three source files share one header; a field is inserted into the middle of a struct — exactly Chapter 24.1 §4's scenario.
With no header dependency tracking at all:
app: writer.o reader.o app.o
clang++ -std=c++20 -O2 $^ -o app
%.o: %.cpp
clang++ -std=c++20 -O2 -c $< -o $@ make will recompile 0 TU(s)
after the rebuild:
writer: sizeof=8 cycles=412 master=2
reader: sizeof=8 cycles=412 master==2? yesZero translation units. make reports nothing to do, because no rule mentions config.h. The program still runs, every object file still agrees with every other, and every one of them was compiled against the old header — so the struct is still 8 bytes and the field you added does not exist. The edit was silently ignored. That is not an ODR violation; it is worse in one respect and better in another: the program is self-consistent, and it is not the program you wrote.
The dangerous case is a partial rebuild, which is what you get as soon as the same editing session also touches a source file. Constructing that state explicitly — compile two translation units against the old header, one against the new, then link — is deterministic and reproduces 5 times out of 5:
writer: sizeof=8 cycles=412 master=2
reader: sizeof=12 cycles=412 master==2? NO (garbage)sizeof 8 against 12 in one program, and reader finds garbage where writer wrote 2. This is precisely Chapter 24.1 §4's violation — and nobody wrote two different definitions of the struct. There is one header with one definition; the inconsistency was manufactured by a build that recompiled some objects and not others.
The program reports whether the field matches rather than printing its value, because the garbage itself is not reproducible — five repetitions gave five different numbers. The structure reproduces exactly; the particular garbage does not, which is the same distinction Chapter 24.7 §5 drew about undefined behaviour.
With the compiler generating dependencies:
OBJ := writer.o reader.o app.o
DEP := $(OBJ:.o=.d)
%.o: %.cpp
clang++ -std=c++20 -MMD -MP -O2 -c $< -o $@
-include $(DEP)-MMD writes a makefile fragment next to each object file:
reader.o: reader.cpp config.h
config.h:The first line is the real dependency. The second is what -MP adds — a phony target for each header — so that deleting a header gives a clean rebuild rather than No rule to make target 'config.h'.
make will recompile 3 TU(s)
clang++ -std=c++20 -MMD -MP -O2 -c writer.cpp -o writer.o
clang++ -std=c++20 -MMD -MP -O2 -c reader.cpp -o reader.o
clang++ -std=c++20 -MMD -MP -O2 -c app.cpp -o app.o
after the rebuild:
writer: sizeof=12 cycles=412 master=2
reader: sizeof=12 cycles=412 master==2? yesAll three rebuilt, both agree on sizeof=12, and the field reads correctly.
The three outcomes from one header edit:
| dependency configuration | TUs rebuilt | result |
|---|---|---|
| none, header edited alone | 0 | the edit is silently ignored — all objects hold the old layout |
| none, header and a source edited | partial | silently wrong — sizeof 8 against 12, garbage field |
-MMD -MP with the .d files included | 3 | correct |
One caveat I measured and that is worth stating, because it bounds the claim: the dependency information lives in those .d files, so during the window before they exist — a genuinely clean tree, or one where a clean target deleted them — no inclusion form has anything to work with, and a header edit in that window rebuilds nothing. That is not an argument against -MMD; it is the reason clean should remove the .o files together with the .d files, so that the two can never be out of step.
4. A Minimal CMakeLists.txt
Reference syntax, per §1 — not executed here.
cmake_minimum_required(VERSION 3.20)
project(vmsim 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_library(vm
src/bus.cpp
src/report.cpp
)
target_include_directories(vm PUBLIC include)
target_compile_options(vm PRIVATE -Wall -Wextra -Wpedantic)
add_executable(vmsim src/main.cpp)
target_link_libraries(vmsim PRIVATE vm)Reading it against §2's model:
add_library(vm …)declares a target. With noSTATIC/SHAREDkeyword it followsBUILD_SHARED_LIBS, which is the right default — it lets the consumer of your project choose, and Chapter 24.6 §7 is how they should.target_include_directories(vm PUBLIC include)says the directory is needed to buildvmand to use it. That is correct whenvm's public headers are in there, which they are. Had the headers been internal,PRIVATEwould be right.target_compile_options(vm PRIVATE -Wall …)isPRIVATEdeliberately: your warning policy is yours, and imposing it on consumers throughPUBLICis rude and often breaks their build.target_link_libraries(vmsim PRIVATE vm)both links and propagates —vmsimautomatically getsvm'sPUBLICinclude directory. That propagation is the payoff for the target model.
CMAKE_CXX_EXTENSIONS OFF is worth noting because the default is ON, which gives you -std=gnu++20. Chapter 24.7 §1 is the argument for the non-GNU dialect.
5. Building Out of Tree
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
ctest --test-dir buildThree reasons out-of-tree is the only sane arrangement, and the first is the one people underestimate:
rm -rf build is a complete clean, with no chance of deleting a source file and no stale artefacts surviving in source directories. An in-tree build mixes generated files with tracked ones, and the first person to git add . commits object files.
Several configurations coexist. build-debug and build-release side by side, each with its own cache, both buildable at any moment — which is Chapter 24.7 §6's requirement made practical.
cmake --build build is generator-agnostic. It works whether the underlying tool is Make, Ninja or an IDE project, which matters because the generator is a per-machine choice.
CMAKE_BUILD_TYPE maps to flag sets — Debug is roughly -O0 -g, Release is -O2 -DNDEBUG, RelWithDebInfo is -O2 -g -DNDEBUG — and the mapping is a default you can and often should override with the explicit sets from Chapter 24.7 §6. Note that CMAKE_BUILD_TYPE does nothing for multi-configuration generators, where the configuration is chosen at build time with --config.
6. Tests and Dependencies
enable_testing()
add_executable(test_bus tests/test_bus.cpp)
target_link_libraries(test_bus PRIVATE vm)
add_test(NAME bus COMMAND test_bus)That is enough to make ctest run the executable and report pass or fail from its exit status, which is Chapter 23.8 §6's contract — and the reason that chapter insisted the exit status be derived from the same verdict written into the results file.
For external dependencies the modern form is to look for an imported target:
find_package(fmt REQUIRED)
target_link_libraries(vm PUBLIC fmt::fmt)The namespaced fmt::fmt is an imported target carrying its own include directories and compile features, so linking it is all you do. The older pattern — a FMT_INCLUDE_DIRS variable fed into include_directories() — is the global state the target model replaced, and finding it in a project is a reliable sign the CMake predates the model.
FetchContent is the in-tree alternative for a dependency you want built from source alongside your project; it is convenient and it makes your build depend on the network, which is a trade to make deliberately rather than by default.
7. The Things That Go Wrong
| symptom | cause |
|---|---|
| a consumer cannot find your header | PRIVATE include directory where you needed PUBLIC |
| your warning flags appear in a consumer's build | PUBLIC compile options where PRIVATE was right |
a change to a .cmake file has no effect | a stale cache — reconfigure, or delete the build directory |
| the wrong compiler is used | CMAKE_CXX_COMPILER is cached from the first configure; set it at the first configure or start a fresh directory |
Debug flags in a Release build | CMAKE_BUILD_TYPE unset, or a multi-config generator where it is ignored |
-std=gnu++20 when you wanted -std=c++20 | CMAKE_CXX_EXTENSIONS left at its default ON |
| a header change rebuilds nothing | not CMake's failure mode — it generates §3's correct form; this is what hand-written rules get wrong |
| a global constructor never runs | Chapter 24.6 §5 — an archive member nothing references |
The cache rows are the ones that waste the most time. CMake remembers your first configure, so a flag you pass on the second one may be ignored. When something inexplicable is happening, deleting the build directory is not defeat; it is the documented way to change a cached variable.
8. Make, Ninja and the Alternatives
Worth knowing what the choice actually is.
Make is everywhere and is what this chapter measured with. Its weaknesses are the ones §3 demonstrated — dependency wiring is yours to get right — plus whole-second timestamp granularity in the 3.81 version shipped here, which Chapter 24.4 §5 had to work around when measuring fan-out.
Ninja is a build executor designed to be generated rather than written, and it is substantially faster at deciding what to do on a large project. It is not installed here. cmake -G Ninja is how you select it.
CMake generates for both, which is its central value proposition: one description, many executors and platforms.
Bazel and Meson exist and are better in specific ways; the reason CMake is the default answer in C++ is that it is what libraries ship support for, so find_package works.
And the honest summary: CMake's language is awkward, its error messages are poor, and it is nevertheless the right default, because the alternative is hand-written rules whose dependency tracking you have to verify yourself — and §3 is what that verification finds.
9. The Table
| the question | the answer | measured |
|---|---|---|
| is CMake available here | no — the syntax in this chapter is reference, not output | §1 |
| what is the one idea | targets carry build and usage requirements | §2 |
PRIVATE / INTERFACE / PUBLIC | me / consumers / both | §2 |
| what happens with no dependency tracking | 0 TUs rebuilt — the edit is silently ignored | §3 |
| with a header and a source edited, no tracking | a partial rebuild: sizeof 8 against 12 | §3 |
with -MMD -MP and the .d files included | 3 TUs, both sizeof=12, field correct | §3 |
what does -MP add | a phony target per header, so deleting one is not fatal | §3 |
| why build out of tree | rm -rf build, coexisting configurations, generator-agnostic | §5 |
| why does my flag change do nothing | the cache remembers the first configure | §7 |
how do I get -std=c++20 not gnu++20 | CMAKE_CXX_EXTENSIONS OFF | §4 |
10. What to Actually Do
- Describe targets, not global state. No
include_directories(), noadd_definitions(), no appending toCMAKE_CXX_FLAGS. - Choose
PRIVATE,INTERFACEorPUBLICdeliberately for every property. Public headers arePUBLIC; your warning flags arePRIVATE. - Set
CMAKE_CXX_STANDARD,…_REQUIRED ONandCMAKE_CXX_EXTENSIONS OFF. - Always build out of tree, with separate directories per configuration.
- Let
add_librarydefault so consumers can choose static or shared. - Link namespaced imported targets from
find_package, not variables. - Register tests with
add_testso the exit status is the verdict. - If you hand-write Makefiles, use
-MMD -MPand include the generated.dfiles. And makecleandelete the.oand.dfiles together, so the dependency data can never be out of step with the objects. - Verify your dependency tracking by editing a header and checking that the rebuild set matches what the
.dfiles predict — and stamp the file into the future, because GNU Make 3.81 compares whole seconds. - Delete the build directory when a cached variable will not change.
11. Mistakes Worth Making Once
No dependency tracking, and a header-only edit. Nothing rebuilds and the edit is silently ignored — the program you test is the one from before your change.
No dependency tracking, and a header edit plus a source edit. A partial rebuild manufactures Chapter 24.1 §4's ODR violation from a single consistent header.
PRIVATE on a public include directory. Your library builds; nobody can use it.
PUBLIC on your warning flags. Everyone who links you inherits your policy.
Passing a new compiler on the second configure. It is cached from the first.
An in-tree build. Object files in git status.
Forgetting CMAKE_CXX_EXTENSIONS OFF. You have been building -std=gnu++20.
Expecting CMAKE_BUILD_TYPE to work with a multi-config generator. It is ignored there.
Trusting a build system because it looks configured. Only a measurement distinguishes working dependency tracking from the appearance of it — and the measurement has to be repeated from a changed starting state, which is how I caught a wrong claim of my own.
12. Interview Relevance
The frozen curriculum marks this chapter High. "Explain modern CMake" is best answered with the target model rather than a command list: a target carries both what it needs to build and what its consumers need to use it, and PRIVATE/INTERFACE/PUBLIC is how you say which — replacing the global include_directories() state that made old CMake unmanageable.
The question that distinguishes experience is "why does a build system matter?", and the answer is not speed. A build system that rebuilds the wrong set produces object files that disagree about a type, which is Chapter 24.1 §4's undetectable failure — measured here as master = 1831529872 after one ordinary header edit with no dependency tracking. The build system is the only line of defence for that class of bug, because the disputed property never reaches the symbol table.
The detail worth volunteering is that the two silent outcomes are different: with no tracking a header-only edit rebuilds nothing, so the program is self-consistent and is not the one you wrote, while a partial rebuild produces objects that disagree and a field read at the wrong offset. The first lies about what you built; only the second looks like a code bug, which is why it gets debugged as one.
13. What You Should Be Able to Say Now
- That CMake's one idea is that targets carry usage requirements as well as build requirements, and what
PRIVATE,INTERFACEandPUBLICeach mean. - How to declare a library and an executable, propagate an include directory, keep warning flags private, and pin the standard without GNU extensions.
- Why out-of-tree building is the only sane arrangement — clean deletion, coexisting configurations, generator independence.
- That dependency tracking is a correctness feature: with none, one ordinary header edit rebuilt 1 of 3 translation units and produced
master = 1831529872withsizeof8 against 12 and zero diagnostics. - That the dependency data lives in the
.dfiles, so in the window before they exist no inclusion idiom helps — which is whycleanmust remove.oand.dtogether. - That
-MP's phony targets are what make a deleted header survivable rather than fatal. - That this class of failure is undetectable by the compiler and linker, so the build system is the only defence.
- That CMake's value is generating the correct dependency wiring every time, not its syntax.
- That the CMake material in this chapter is reference syntax, not executed output, because
cmakeis not installed here.
14. Exercises
Exercise 1 — the three Makefiles. Reproduce §3's experiment all three ways from a genuinely clean tree each time. Confirm 1, 0 and 3 translation units, and read master in each resulting program.
Exercise 2 — repeat it from a different starting state. Run Exercise 1 again, but this time delete the .d files before editing the header, and then again with them present. Note where the outcome changes. This is the check that retracted a claim in this chapter's own first draft.
Exercise 3 — the phony targets. With -MMD but not -MP, delete a header and run make. Read the error. Add -MP and repeat.
Exercise 4 — CMake, if you have it. Install CMake and write the §4 file for Chapter 24.9's project. Then set the include directory to PRIVATE and observe what fails. Then add a test with add_test and run ctest.
Exercise 5 — verify your own project. Take a build you rely on, edit a widely-included header, and check that the set of recompiled files matches what the .d files say depend on it. Stamp the header into the future first.
15. Summary
- CMake is not installed on this machine, so every
CMakeLists.txthere is reference syntax rather than captured output, and this chapter says so rather than inventing transcripts. The measurements use GNU Make 3.81. - The one idea in modern CMake is the target, carrying both what it needs to build and what consumers need to use it, with
PRIVATE/INTERFACE/PUBLICselecting which. - Dependency tracking is correctness, not speed. One field inserted into a header, with only one source file touched, measured three ways:
- no tracking, header edited alone → 0 TUs rebuilt. Every object still held the old 8-byte layout, so all of them agreed and the edit was silently ignored — the program is self-consistent and is not the one you wrote;
- no tracking, header and a source edited → a partial rebuild. Measured 5/5:
sizeof8 against 12 in one program and the field read as garbage — Chapter 24.1 §4's ODR violation from a single consistent header; -MMD -MPwith the.dfiles included → 3 TUs rebuilt, bothsizeof=12, field correct.
- A stale object file is an ODR violation with no author, and it is undetectable by compiler and linker for Chapter 24.5's reason — the disputed property never reaches the symbol table. The build system is the only defence.
- A build claim must be repeated from a changed starting state. This chapter's first draft asserted that one
-includeidiom tracked nothing while another worked; five repetitions in two scenarios showed both behaving identically, and the claim was retracted. The garbage field value is likewise not reproducible — five repetitions gave five numbers — so the program reports a comparison instead. -MPadds a phony target per header so deleting one is not fatal.- Build out of tree, one directory per configuration, and remember the cache is set at the first configure.
- CMake's value is generating the correct wiring every time. Its syntax is not the reason to use it.
Chapter 24.9 assembles everything in this module into a project laid out so a new engineer can find the model, build it and run its tests without being told how.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
