Build Systems: Make, CMake, Ninja¶
When your project has one file, you type cc file.c -o file and go home. When it has three files, you can still get away with that. When it has thirty, or when you want incremental builds so you don’t recompile everything on every keystroke, you need a build system. This file walks you from make (which every C project on Earth has touched at some point) to CMake (the industry default) to Ninja (the fast backend everyone uses under CMake).
You will spend Phase 0 in raw make and only start touching CMake in Phase 2 or later. That’s on purpose. make teaches you the dependency graph explicitly; CMake abstracts it away and if you learn CMake first you will not understand what it’s doing.
Make From First Principles¶
make has one idea: a target depends on prerequisites; if any prerequisite is newer than the target, run the recipe. Everything else is sugar.
A minimal Makefile:
hello: hello.c
cc -std=c17 -Wall -Wextra -Wpedantic -Werror -g -O0 hello.c -o hello
Rules:
The indent must be a literal TAB. Not spaces. This is the #1 make gotcha of all time.
Target on the left of
:, prerequisites on the right.Recipe lines below, TAB-indented.
Default target is the first one in the file.
A Realistic Multi-file Template¶
Use this. Copy it into every Phase 0 project.
# ---- config ----
CC := cc
CSTD := -std=c17
WARN := -Wall -Wextra -Wpedantic -Werror -Wshadow -Wstrict-prototypes
DEBUG := -g -O0 -fsanitize=address,undefined -fno-omit-frame-pointer
RELEASE := -O2 -DNDEBUG
CFLAGS := $(CSTD) $(WARN) $(DEBUG)
LDFLAGS := -fsanitize=address,undefined
SRC := $(wildcard src/*.c)
OBJ := $(SRC:src/%.c=build/%.o)
DEP := $(OBJ:.o=.d)
BIN := build/app
# ---- default target ----
all: $(BIN)
$(BIN): $(OBJ) | build
$(CC) $(LDFLAGS) $^ -o $@
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
build:
mkdir -p build
# auto-include header dependency files (-MMD produces them)
-include $(DEP)
# ---- housekeeping ----
clean:
rm -rf build
release: CFLAGS := $(CSTD) $(WARN) $(RELEASE)
release: LDFLAGS :=
release: clean all
.PHONY: all clean release
What this gives you and why it matters:
Automatic variables:
$@= target,$<= first prereq,$^= all prereqs. Learn these three.Pattern rules:
build/%.o: src/%.ccompiles any.cinsrc/to a matching.oinbuild/.-MMD -MP: These flags makeccemit a.dfile listing which headers each.odepends on. Including them with-include $(DEP)means when you edit a header, only files that include it recompile. Without this,makedoesn’t know about header dependencies and you end up doingmake clean && makeconstantly.Order-only prereq
| build: creates thebuild/directory once, without re-triggering when it’s just “newer”..PHONY: declares targets that are not files, somake cleanstill works when a file namedcleanexists.releasetarget: same graph, different flags.make releasegives you an optimized non-sanitized binary.
Run make -n (dry run) whenever you want to see what recipes would fire without executing them. Run make -j$(nproc) (Linux) or make -j$(sysctl -n hw.ncpu) (macOS) to parallelize.
The Make Gotchas That Waste Your Weekend¶
Spaces instead of tabs in recipes → cryptic
missing separatorerror.=vs:=—=is lazy/recursive (re-evaluated every use),:=is immediate (evaluated once). Use:=unless you specifically need laziness.Forgetting
.PHONY— ifclean(the file) ever exists,make cleanbecomes a no-op.Recursive make considered harmful — Peter Miller’s <phone_number_or_numberic_id_or_random_id_25> paper is still the reference. Don’t
cd sub && makein a rule; write one Makefile that knows the whole graph, or move to CMake.
CMake: When and Why¶
make breaks down when you need any of these:
Portability across Windows/macOS/Linux (Makefiles are Unix-ish).
Detecting whether a library is installed (
find_package).Building with multiple toolchains without rewriting the file.
Anything with more than ~10 modules.
CMake is a build system generator: it reads CMakeLists.txt and emits Makefiles, Ninja files, Xcode projects, VS solutions. It’s the default for essentially every serious open-source C/C++ project as of 2026.
Minimal CMakeLists.txt for the same layout:
cmake_minimum_required(VERSION 3.20)
project(app C)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # for clangd
add_compile_options(-Wall -Wextra -Wpedantic -Werror)
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
add_compile_options(-g -O0 -fsanitize=address,undefined)
add_link_options(-fsanitize=address,undefined)
endif()
file(GLOB SRC CONFIGURE_DEPENDS "src/*.c")
add_executable(app ${SRC})
Usage:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
CMAKE_EXPORT_COMPILE_COMMANDS=ON is the flag that makes clangd (your LSP) work — it emits build/compile_commands.json. Symlink it to your project root: ln -s build/compile_commands.json .. See 05_editor_setup.md for the full story.
For Phase 0 you do not need CMake. Learn make first. Come back here in Phase 2.
Ninja: The Fast Backend¶
Ninja is a build system with almost no user-facing syntax — it’s designed to be generated by CMake or gn, not written by hand. Its selling point is speed: for large projects (~10k files) it’s typically 2-5× faster to figure out “what needs rebuilding” than make, because it stores dependency info in a binary log rather than re-stating everything.
To use it under CMake:
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
Install on macOS: brew install ninja. On Debian/Ubuntu: apt install ninja-build.
For projects under a few hundred files you will not notice the difference. Beyond that, Ninja is worth switching to. This is not a Phase 0 concern.
What Most People Get Wrong About Build Systems¶
They copy-paste a Makefile from Stack Overflow, it “works”, and they never internalize what it’s doing. Then when a header change doesn’t trigger a rebuild, or a new .c file is silently not compiled, they don’t know where to look. Write the Makefile in this file yourself, by hand, at least once. Type it. Understand each line. Then you can copy-paste for the next 12 months, but only after that.
The second mistake: reaching for CMake too early. If your project has three files, CMake is overkill; you’re learning CMake’s abstraction instead of learning the underlying build graph. Learn make first. Reach for CMake when you actually feel the pain of not having it.
Cheat Sheet¶
make # build default target
make -n # dry run, print recipes without executing
make -j8 # parallel with 8 jobs
make clean # remove build artifacts (if .PHONY declared)
make VAR=value target # override a variable from the command line
make -p | less # dump make's internal database (huge)
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --parallel
cmake --build build --target clean
ctest --test-dir build # if you add add_test() rules
Return to README.md · Next: 03_debugging_stack.md