Build Systems: make Introduction and the Compile/Link Cycle
What This Concept Is
make automates the compile/link cycle. You describe:
- targets - files (or phony names) you want produced
- prerequisites - files the target depends on
- recipes - shell commands that rebuild the target when any prerequisite is newer
make uses file-modification times to decide what must be rebuilt. That is the whole model. Everything else is notation on top of it.
A minimal Makefile:
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -O2
app: main.o list.o
$(CC) $(CFLAGS) -o $@ $^
main.o: main.c list.h
$(CC) $(CFLAGS) -c $< -o $@
list.o: list.c list.h
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f app *.o
.PHONY: clean
Automatic variables: $@ is the target, $^ is all prerequisites, $< is the first prerequisite. Recipes must start with a TAB, not spaces.
Why It Matters Here
Compiling a C project by hand is fine for one file. Past that, you need:
- incremental builds (rebuild only what changed)
- a single entry point (
make) that everyone can run - consistent compiler flags
make is the oldest, most installed, smallest build tool you will meet. CMake, Meson, Ninja, and Bazel are strictly more capable, but all of them assume you already understand the compile/link cycle, which is exactly what make exposes.
Concrete Example
Walk through what happens on a fresh make:
- No
appexists.makepicks the first target,app. appdepends onmain.oandlist.o. Neither exists.- For
main.o:main.candlist.hexist; nomain.oyet, so run the recipegcc -Wall -Wextra -std=c11 -O2 -c main.c -o main.o. - Same for
list.o. - Run the
apprecipe:gcc -Wall -Wextra -std=c11 -O2 -o app main.o list.o.
Now change list.c and rerun make:
list.cis newer thanlist.o-> rebuildlist.o.list.ois newer thanapp-> relinkapp.main.cunchanged -> do not rebuildmain.o.
Edit only main.c: main.o rebuilt, app relinked, list.o untouched.
Edit only list.h: both object files rebuild, because both list list.h as a prerequisite.
Common Confusion / Misconception
"Make figures out dependencies automatically." It does not. You tell it. Missing a prerequisite is the most common reason a change to a header is not picked up. For large projects, use gcc -MMD -MP to auto-generate dependency fragments.
"Indentation with spaces works." No. make recipes require a literal tab. Spaces produce the infamous missing separator error.
".PHONY is decorative." Without it, make clean will not run if a file named clean exists.
How To Use It
Practical rules:
- Start with a flat Makefile for each small project. Graduate to auto-generated dependencies when it grows.
- Put
CC,CFLAGS, andLDFLAGSat the top so you can override withmake CFLAGS='-g -O0'when debugging. - Add
.PHONY: all clean testfor targets that are not files. - Use
-Wall -Wextra -std=c11during development; add-Werrorwhen you can. - Use
gcc -MMD -MPto have the compiler emit.dfiles that list header dependencies, then-include *.din the Makefile.
Check Yourself
- What does
$@,$^, and$<mean in a recipe? - Why must recipe lines start with a tab?
- Why is
.PHONY: cleanimportant?
Mini Drill or Application
Build a four-file project: main.c, util.c, util.h, Makefile.
- Write the Makefile above from memory.
- Confirm
makebuilds,makeagain does nothing, andtouch util.h && makerebuilds both.os. - Add
-MMD -MPtoCFLAGSand an-include $(wildcard *.d)line; deleteutil.hdependency from the rules; confirm header-driven rebuilds still work. - Add
make clean,make test(run the binary), andmake debug(setsCFLAGS += -g -O0). - Compare with
gcc -Wall -Wextra -std=c11 *.c -o appand explain the tradeoff.