5.3.7 · D3Build Systems & Toolchain

Worked examples — Phony targets, pattern rules, implicit rules

3,761 words17 min readBack to topic

Before we start, three pieces of vocabulary we'll use constantly.

We'll also touch three flag variables that steer the compiler and linker (see Compiler flags CFLAGS LDFLAGS):


The scenario matrix

Every situation this topic can produce is one of these cells. The examples below each announce which cell(s) they cover, so together they fill the whole grid.

Cell Scenario class The question it tests
C1 Target missing Does the recipe fire from nothing?
C2 Target up to date (newer than all prereqs) Does Make correctly skip?
C3 One prereq newer → partial rebuild Which recipes fire, which don't?
C4 Cascade — a rebuilt file makes its dependents stale Does the wave propagate up the graph?
C5 Phony collision — a real file shares a phony name Does .PHONY force the run?
C6 Degenerate stem — weird/empty/dotted names Does % match what you expect?
C7 Precedence fight — explicit vs pattern vs implicit Which rule wins?
C8 Zero-prerequisite target (a real rule with no prereqs) Out of date or not?
C9 Real-world word problem — a small project from scratch Put it all together
C10 Exam twist — order of touch, -B, -t flags Do you predict Make's exact output?

The figure below is the dependency graph we will keep pointing back to. Follow the arrows: an arrow from A to B means "A is a prerequisite of B", i.e. B depends on A.

Figure — Phony targets, pattern rules, implicit rules

Example 1 — C1: building from nothing

Steps.

  1. Make wants the default goal hello (the first rule's target — see the definition above). Its prereq hello.o doesn't exist. Why this step? The rebuild rule (§1 of parent): missing target ⇒ run. So we must first make hello.o.
  2. To make hello.o, Make checks its prereq hello.c. It exists and has no rule of its own — it is a plain source file, not a target. Why does this matter? A source with no rule is just an input: if it exists, Make treats it as up to date and never tries to build it. (Contrast this with cell C8, a genuine rule whose target has zero prerequisites — see Example 4b.)
  3. hello.o is missing ⇒ its recipe runs: gcc -c hello.c -o hello.o. Why does it fire? The rebuild rule says a missing target is always out of date, so its recipe must run to create it. Now hello.o exists with a fresh mtime.
  4. Back to hello: it's missing ⇒ its recipe runs: gcc hello.o -o hello. Why? Same reason — a missing target is out of date, so the recipe runs to produce it. Make could only reach this step after step 3 created hello.o, since hello depends on it.

Order of recipes: hello.o first, then hello. This is topological order — you build a node only after everything it depends on.

Verify: run make twice. First run prints both gcc lines. Second run prints make: 'hello' is up to date. — because now both targets exist and no source changed (that's Example 2's cell).


Example 2 — C2: everything up to date

Steps.

  1. Check hello: it exists. Is any prereq newer? Its prereq is hello.o, and . Why compare? The rebuild rule fires only if a prereq is strictly newer. It isn't. So hello is up to date.
  2. But Make still recurses into hello.o to be sure it's current. Why recurse? An up-to-date-looking target could still be stale if its prereq changed; Make must verify the whole subtree. hello.o exists; its prereq hello.c is older. So hello.o is up to date too.
  3. Nothing is out of date ⇒ zero recipes run. Output: make: 'hello' is up to date.

Verify: the answer is 0 recipes. This is the payoff of Incremental compilation — unchanged inputs cost only a couple of stat() calls.


Example 3 — C3 & C4: one edit, a cascade

Look at figure s01: editing one leaf sends a wave upward.

Steps.

  1. touch util.c makes the newest thing in the project. Why? touch bumps the clock reading without changing contents — a clean way to simulate an edit.
  2. Check app → recurse to prereqs main.o, util.o. Why recurse before deciding on app? Make can't know whether app is stale until it knows whether its prerequisites are stale (and possibly rebuilt) — so it resolves children first, exactly the topological order from Example 1.
  3. main.o: its prereq main.c is not newer ⇒ skip (cell C3, the "not-affected" side). Recall $< here is main.c.
  4. util.o: its prereq util.c is now newer ⇒ out of date ⇒ run gcc -c util.c -o util.o (the pattern rule with $< = util.c, $@ = util.o). Now is fresh.
  5. Back at app: one prereq (util.o) is now newer than appapp is out of date ⇒ relink gcc main.o util.o -o app. This is the cascade, cell C4.

Recipes that fire, in order: util.o, then app. main.o is untouched.

Verify: 2 recipes. The cascade count = (number of stale nodes on the path from the edited leaf up to the goal). Here the path is util.c → util.o → app; the two buildable nodes on it are util.o and app. ✓


Example 4a — C5: the phony collision bug, live

Steps.

  1. Make treats clean as a target. Does the file clean exist? Yes (you just touched it).
  2. Does clean have prerequisites? No. Why does this matter? Rebuild rule: run iff missing OR a prereq is newer. It's not missing, and there are zero prereqs, so no prereq can be newer.
  3. Conclusion: Make declares clean up to date and prints make: 'clean' is up to date. — the rm never runs. The bug bites.
  4. Fix: add .PHONY: clean. Now Make knows clean names an action, ignores the on-disk file, and treats it as always out of daterm runs every time.

Verify: without .PHONY0 recipes run despite make clean. With .PHONY1 recipe runs. This exact behaviour is why phony targets exist.


Example 4b — C8: a real rule with zero prerequisites

Steps.

  1. First make version.txt: the file is missing ⇒ out of date ⇒ recipe runs, creating version.txt. Why? Missing target is always out of date, regardless of having zero prereqs.
  2. Second make version.txt: the file now exists, and with zero prerequisites no prereq can be newer ⇒ Make declares it up to date ⇒ recipe does not run. Why? This is the same logic that caused the clean bug in 4a — a zero-prereq real file is "sticky": once it exists, Make leaves it alone.
  3. First and second make greet: greet is phony, so it is treated as permanently out of date ⇒ recipe runs both times. Why? Phony strips away the timestamp reasoning entirely.

So C8 splits by phoniness: a real zero-prereq target runs once (then sticks); a phony zero-prereq target runs every time. This is exactly the distinction Example 1 avoided — there hello.c was a source with no rule at all, which is a third, separate case (never built).

Verify: version.txt ⇒ recipe runs 1 time over two invocations; greet ⇒ recipe runs 2 times over two invocations. ✓


Example 5 — C6: degenerate stems (what does % actually match?)

The % is greedy but literal-anchored. Let's push it on odd names.

Figure — Phony targets, pattern rules, implicit rules

Steps.

  1. parser.o: strip the literal .o ⇒ stem parser ⇒ needs parser.c. Straightforward. Why? The rule's target part is %.o; matching it against parser.o forces % to equal everything before the literal .o.
  2. a.b.o: the literal suffix is .o; % matches everything before it, dots included ⇒ stem a.b ⇒ needs a.b.c. Why? % is not "up to the first dot" — it grabs the longest string that leaves .o at the end. Extra dots are fine.
  3. .hidden.o: stem .hidden ⇒ needs .hidden.c. Leading dot is part of the stem. Why? % just requires a non-empty match; a leading dot is legal.
  4. o: to match %.o there must be a literal .o at the end. o has no .o suffix (it's a single char) ⇒ no match, the pattern is skipped entirely. Why care? Degenerate names like this teach that % cannot match the empty stem and still supply the literal — the literal .o must physically appear.

Completed table:

Requested target Stem $* Prereq built
parser.o parser parser.c
a.b.o a.b a.b.c
.hidden.o .hidden .hidden.c
o — (no match) rule skipped

Verify: 3 of 4 rows match; the o row does not, because %.o requires an actual .o suffix. ✓


Example 6 — C7: precedence fight (explicit vs pattern vs implicit)

Steps.

  1. parser.o: there is an explicit rule with a recipe (E). Why does it win? Parent §4 precedence: explicit ≻ your pattern ≻ built-in. So E fires: gcc -O2 -DDEBUG -c parser.c -o parser.o. Note $< = parser.c (first prereq only); parser.h is listed so that editing the header forces a rebuild, but it is not passed to gcc.
  2. util.o: no explicit rule ⇒ fall to your pattern (P) ⇒ gcc -O2 -c util.c -o util.o. The built-in implicit rule is never reached because your P already matched. Why? Your pattern shadows the built-in.
  3. If you deleted both E and P, util.o would still build — the built-in implicit %.o:%.c catches it. That built-in recipe is roughly $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@: it splices in the preprocessor flags CPPFLAGS (header paths, -D macros) and the compiler flags CFLAGS (see the flag-variable definition above). Why mention CPPFLAGS here? It's the hook the built-in gives you to add -Iinclude without writing any rule.

Verify: parser.o ⇒ rule E (has -DDEBUG); util.o ⇒ rule P (no -DDEBUG, has -O2). Precedence order confirmed. ✓ See also Compiler flags CFLAGS LDFLAGS for how these flags flow in.


Example 7 — C9: a real-world project from scratch

The Makefile (using Makefile variables and expansion):

CC       := gcc
CFLAGS   := -Wall -O2       # compiler flags: warnings on, optimise
LDFLAGS  :=                 # linker flags — empty here; e.g. put -lm to link libm
OBJ      := main.o geometry.o
 
.PHONY: all clean
all: shapes
 
shapes: $(OBJ)
	$(CC) $(LDFLAGS) $^ -o $@          # $^ = main.o geometry.o ; LDFLAGS steers the linker
 
main.o: main.c geometry.h              # header dep so edits to the .h rebuild main.o
	$(CC) $(CFLAGS) -c $< -o $@        # $< = main.c only ; $@ = main.o
 
geometry.o: geometry.c geometry.h
	$(CC) $(CFLAGS) -c $< -o $@
 
clean:
	rm -f $(OBJ) shapes

Why the empty LDFLAGS? We define it explicitly (even as blank) so the link line has a documented place to add linker options later — e.g. LDFLAGS := -lm to pull in the math library. An undefined LDFLAGS would expand to nothing anyway, but naming it makes the intent clear and keeps the build reproducible.

Steps (first make, nothing built yet).

  1. You type make ⇒ default goal is all (first non-dot rule), and all is phony, so it's always out of date and Make proceeds to its single prereq shapes. Why start here? The default-goal definition picks the first rule's target; all exists only to point at the real deliverable shapes.
  2. shapes is missing ⇒ out of date. But to build it Make must first resolve its prereqs main.o and geometry.o. Why resolve prereqs first? Topological order: you cannot link objects that don't exist yet.
  3. main.o is missing ⇒ its recipe runs (recipe #1): gcc -Wall -O2 -c main.c -o main.o. Why does it fire? Missing target ⇒ out of date. Here $< = main.c (only the source is compiled), while geometry.h is listed purely to trigger future rebuilds.
  4. geometry.o is missing ⇒ its recipe runs (recipe #2): gcc -Wall -O2 -c geometry.c -o geometry.o. Why? Same missing-target rule; its $< is geometry.c.
  5. Now both objects exist and shapes is still missing ⇒ its recipe runs (recipe #3): gcc $^ -o shapes with $^ = main.o geometry.o and the (empty) LDFLAGS. Why last? The link step is the top of the graph — it can only run once every object beneath it is present.

Total: 2 object files, 3 recipe runs on a clean first build.

Now edit only the header: touch geometry.h. Both main.o and geometry.o list geometry.h as a prereq, so both recompile, then shapes relinks ⇒ 3 recipes again. Why include the header on both? Because a struct change in geometry.h invalidates every translation unit that includes it — miss one and you get a silent ABI mismatch. This is the crux of correct Incremental compilation.

Verify: clean first build ⇒ 3 recipes; touch geometry.h3 recipes (both .o + relink); touch geometry.c only ⇒ 2 recipes (geometry.o + relink). ✓


Example 8 — C10: the exam twist (touch, -t, -B)

Steps.

  1. (a) Nothing changed ⇒ no target is missing and no prereq is newer than its target ⇒ 0 recipes, prints make: 'shapes' is up to date. (this is cell C2). Why? Straight application of the rebuild rule to an all-current tree.
  2. (b) -B means "unconditionally make all targets", ignoring timestamps entirely. Why does this flag exist? As a sledgehammer when you suspect stale or corrupt intermediate state (e.g. after switching compilers). ⇒ all 3 recipes run: main.o, geometry.o, then the relink of shapes.
  3. (c) -t is "touch mode": Make does not run recipes; it just bumps every target's mtime so they look up to date. Why does it exist? To mark a tree as "already built" without spending compile time (e.g. after you know the outputs are fine). So make -t runs 0 real recipes (it only touches main.o, geometry.o, shapes); the following plain make then sees everything current ⇒ also 0 recipes. Danger: -t can mask a genuinely-needed rebuild and silently break Build reproducibility — use sparingly.
  4. (d) touch geometry.o makes the object newer than its own source (so geometry.o is itself up to date — no prereq is newer than it) but newer than shapes. Make recurses: main.o current, geometry.o current, then shapes sees a prereq (geometry.o) newer than itself ⇒ out of date ⇒ relink only. ⇒ 1 recipe (the link step). Why no recompile? Nothing under geometry.o changed; only the link's freshness check failed — a neat illustration that staleness is checked per edge, not globally.

Verify: (a) 0, (b) 3, (c) 0 recipes (both the -t pass and the follow-up make), (d) 1. ✓


Recall

Recall Which cell does each behaviour belong to?

A phony action target runs every time regardless of on-disk files ::: C5 — .PHONY strips away timestamp reasoning. A real target with its own rule but zero prerequisites builds once then sticks ::: C8 — once the file exists, no prereq can be newer, so it's declared up to date. Editing one leaf triggers recompile of its object AND a relink ::: C4, the cascade. make -B runs every recipe ignoring timestamps ::: C10, the flag twist. %.o matches a.b.o with stem a.b ::: C6, degenerate stem — % grabs everything before the literal .o. Explicit parser.o: beats the pattern %.o: ::: C7, precedence: explicit ≻ pattern ≻ implicit.