5.3.6 · D5Build Systems & Toolchain

Question bank — Makefiles — targets, prerequisites, recipes, variables, automatic variables

2,225 words10 min readBack to topic

Figure 1 (below) is the timestamp model — refer back to it for every True/False and Edge case about staleness (the "is an input newer?" traps). Figure 2 is the DAG walk order — refer to it for the post-order, cycle, and parallelism traps.

The picture below is the whole mental model in one frame: files as nodes, timestamps as a timeline, and the recipe firing only when an input arrow is newer than the target.

Figure — Makefiles — targets, prerequisites, recipes, variables, automatic variables

And here is the walk order Make takes over that graph — build the leaves first, the root last:

Figure — Makefiles — targets, prerequisites, recipes, variables, automatic variables

True or false — justify

Make rebuilds a target whenever its recipe is different from last time.
False. Make has no memory of past recipes — it compares only file timestamps (Figure 1): it rebuilds if the target is missing or any prerequisite is newer. Change a recipe without touching timestamps and Make won't notice.
If a target file exists, Make will never run its recipe.
False. Existence is not enough — the target must also be newer than every prerequisite (see the timeline in Figure 1). A newer input makes an existing target stale, so the recipe runs.
$^ and $? always expand to the same list.
False. $^ is all prerequisites; $? is only the ones newer than the target — the red-highlighted arrows in Figure 1. On a full rebuild they may match, but on an incremental build $? is usually a small subset.
$^ and $+ are interchangeable.
False. $^ removes duplicate prerequisites; $+ keeps every duplicate in order. This only matters when a prereq is repeated (e.g. a library that must appear twice on a linker line) — then $+ preserves it and $^ silently drops it.
A = 1 and A := 1 behave identically here.
True — for this one line. Both make $(A) equal 1. The difference only shows when the value contains a reference to another variable that changes later; = re-reads it, := snapshots it.
.PHONY: clean makes the clean target run faster.
False. It has nothing to do with speed. It tells Make clean is not a real file, so Make never does a timestamp check and always runs the recipe — even if a file literally named clean exists.
A prerequisite must be a file that already exists on disk.
False. A prerequisite can be another target that Make builds first — an interior node in Figure 2. That recursion is exactly how the whole dependency DAG gets walked in post-order.
Listing a header like parser.h as a prerequisite is pointless because the compiler doesn't take .h files as arguments.
False. It matters for the timestamp decision: if parser.h changes, Make must recompile parser.o. The recipe uses $< (the .c), but the .h still triggers the rebuild.
$< is guaranteed to be the source file you want to compile.
Only if the source is listed first. $< is literally the first prerequisite, whatever it is. If you write foo.o: foo.h foo.c, then $< is foo.h and your compile breaks.
An order-only prerequisite (after |) triggers a rebuild when it changes, just like a normal one.
False. An order-only prereq only forces build order — it must exist first — but its timestamp is ignored. So updating it never makes the target stale. That's exactly why output directories are put there.
A double-colon rule (::) is just cosmetic — same as :.
False. With :: you may write several independent rules for one target, each with its own recipe, and Make runs each whose prerequisites are out of date. With single : a target may have only one recipe.

Spot the error

all:
    gcc main.c -o app
(the recipe line is indented with spaces) — what fails and why?
The *** missing separator. Stop. error. Make needs a literal TAB to mark a recipe line; spaces look identical but Make refuses to treat the line as a recipe.
app: main.o utils.o
	gcc $< -o app
Why does linking fail?
$< is only the first prerequisite (main.o), so utils.o is never linked → undefined-reference errors. The linker needs all objects, so use $^. See Linkers and Object Files.
OBJS = $(SRCS:.c=.o)
SRCS := main.c
Why might $(OBJS) be surprising?
OBJS uses recursive (=) assignment, so it is expanded when used, re-reading SRCS at that moment. Combined with later reassignment this can shift unexpectedly; prefer := for derived lists to snapshot a stable value.
%.o: %.c
	gcc -c $@ -o $<
What's swapped?
$@ (target, the .o) and $< (first prereq, the .c) are reversed. It tries to compile the object file into the source — nonsense. Correct: -c $< -o $@.
clean:
	rm -f *.o app
A file named clean exists in the folder — what breaks?
Make sees clean as an up-to-date file (no prerequisites, already exists) and skips the recipe. Fix with .PHONY: clean so it always runs.
CFLAGS = -Wall
build:
	gcc $(CFLAGS) main.c
CFLAGS = -O2
Which flags actually get used?
-O2. Because CFLAGS is recursive (=), $(CFLAGS) is expanded when the recipe runs, by which point the last assignment -O2 has overwritten -Wall.
FLAGS := -Wall
FLAGS += $(EXTRA)
EXTRA := -O2
What is in FLAGS, and why?
==-Wall, with an empty append.== Step by step: (1) FLAGS := -Wall makes FLAGS a simple (already-expanded) variable. (2) The rule for += is: if the left side is simple, the appended text is expanded immediately at the += line; if it is recursive, the text is stored unexpanded. Since FLAGS is simple, $(EXTRA) is expanded right there — but EXTRA is still undefined at that point, so it expands to the empty string. (3) The later EXTRA := -O2 runs too late to affect the value already appended. Result: FLAGS is -Wall (trailing space, no -O2). Moral: with := + +=, define your dependency variables before you append them, or the append silently snapshots an empty value.
%.o: %.c
	gcc -c $< -o $@
build/main.o: main.c
build/ does not exist — what fails?
gcc errors because it can't write into a missing directory. Fix with an order-only prerequisite: build/%.o: %.c | build and a rule build:\n\tmkdir -p build. The | makes build exist first without its timestamp forcing rebuilds.

Why questions

Why does Make build prerequisites before checking the target's timestamp?
Two reasons (trace the arrows in Figure 2): (1) a prerequisite might itself be out of date, and building it updates its timestamp, which then correctly drives the target's decision; and (2) a prerequisite's build may generate new files or dependencies (e.g. a code generator emitting .c/.h), so its recipe must run before Make can even know the full input set. This is the post-order walk of the DAG.
Why do automatic variables like $@ exist instead of just typing the filename?
They keep the recipe in sync with the target: prereqs line. Hard-coded names can drift apart when you edit one but not the other; $@/$</$^ always refer to this rule's actual files.
Why is ?= useful for something like CC ?= gcc?
It assigns only if the variable isn't already set, so a user can override it (e.g. make CC=clang) without editing the Makefile. Good for overridable defaults.
Why prefer $+ over $^ on a linker line sometimes?
Some libraries must appear more than once on the command line to resolve circular references. $^ collapses duplicates and breaks that; $+ keeps every occurrence in order.
Why does the graph of files have to be acyclic?
A cycle (a depends on b depends on a) has no valid post-order build sequence — Make could never decide what to build first (there is no bottom leaf in Figure 2's walk). See Directed Acyclic Graphs (DAG).
Why does $(SRCS:.c=.o) save you from bugs?
It derives OBJS from SRCS automatically, so adding a source in one place updates the object list. No second list to forget to update.
Why does incremental building make the recipe use $? sometimes instead of $^?
$? gives only the stale prerequisites, so tools that append or re-process only changed inputs do less work — the heart of Incremental Compilation.

Edge cases

What does Make do when the target does not exist at all?
It rebuilds unconditionally — there is nothing that can be "up to date," so the existence check ¬exists(T) is true and the recipe runs.
What if a prerequisite and the target have the exact same timestamp?
Make treats the target as up-to-date. The rule triggers only when a prerequisite is strictly newer (t(P_i) > t(T)), so equal times count as "not stale."
What happens if a rule has no prerequisites and its target file exists?
With nothing newer possible, Make considers it up-to-date and skips the recipe — unless it's declared .PHONY, in which case it always runs.
What does $* expand to for a non-pattern rule (no \%)?
It's empty. $* is defined as the stem matched by \%; with no pattern there is no stem, so relying on it there is a bug.
What if two prerequisites are identical, like app: a.o a.o?
$^ removes duplicates, so it expands to just a.o once; use $+ if you actually need both listed.
What happens if a prerequisite has no rule and doesn't exist on disk?
Make first tries every implicit rule to build it; if none matches and the file is absent, it errors with No rule to make target '...'. Newcomers hit this when a filename is misspelled or a source directory isn't reachable by any pattern rule.

When can a static pattern rule surprise you? Consider:

OBJS := main.o data.txt
$(OBJS): %.o: %.c
	gcc -c $< -o $@

::: The three colons read as: targets : target-pattern : prereq-pattern. So $(OBJS) is the target list, \%.o is the pattern each target must match, and \%.c builds the prereq. The stem \% is matched only against the listed targets, not every file. Here main.o matches \%.o (stem main, recipe compiles main.c), but data.txt does not match \%.o, so it silently gets no recipe from this rule — the classic "why isn't it building?" trap.

Under make -j (parallel builds), why can order-only directory prerequisites still race?
-j runs independent branches of Figure 2's DAG at the same time. If two targets each list | build and build's recipe is mkdir build (no -p), the second mkdir can fire while the first is mid-flight and crash with "File exists." Always use mkdir -p (idempotent), and give the directory its own order-only rule so Make builds it exactly once instead of racing.
What else can -j expose that a serial build hides?
Missing prerequisites. A recipe might secretly rely on a file another rule happens to produce first in serial order. Under -j that ordering is gone, so the two rules run concurrently and the reader sees intermittent failures. The fix is to make the hidden dependency explicit as a real prerequisite so the DAG (Figure 2) forces the correct order.

Connections

  • Compilation Pipeline — where these rebuild decisions sit in the flow.
  • CMake and Build Generators — tools that generate Makefiles for you.
  • Shell Scripting Basics — recipes are shell commands, one process per line.