5.3.7 · D4Build Systems & Toolchain

Exercises — Phony targets, pattern rules, implicit rules

3,844 words17 min readBack to topic

This page is a self-test ladder. Every problem states its level, then hides a complete solution inside a collapsible callout. Work each one on paper first, then reveal.

The ladder climbs: L1 Recognition (name the thing) → L2 Application (run the machine) → L3 Analysis (trace timestamps) → L4 Synthesis (design a Makefile) → L5 Mastery (predict edge-case cascades).

Everything here builds on the parent topic. If a symbol feels unfamiliar, that is a signal to re-read §1 of the parent — but each exercise below re-states what it needs.

Read the figure below before continuing. It shows two buckets — the prerequisite main.c stamped mtime = 200 on the left, the target main.o stamped mtime = 20 on the right. The red arrow carries the comparison 200 > 20: because the input clock is bigger (newer), the target is stale and the recipe fires. Flip the numbers so the target is the newer one and the arrow's verdict flips to "skip". Everything on this page is just this one picture applied again and again.

Figure — Phony targets, pattern rules, implicit rules

Level 1 — Recognition

L1.1

Classify each target below as phony or file:

all: program
program: main.o
	gcc main.o -o program
clean:
	rm -f *.o program
main.o: main.c
	gcc -c main.c -o main.o

Which two should be marked .PHONY, and why?

Recall Solution L1.1
  • allphony. It never produces a file named all; it just groups the "real" goal program.
  • programfile. The recipe writes an actual file program.
  • cleanphony. It deletes things; there is no file clean to produce.
  • main.ofile. Produces main.o.

Should be marked phony: all and clean. Why: if a file literally named all or clean ever appears on disk, Make would see it as "already built" (it exists, has no newer prereqs) and skip the recipe. .PHONY: all clean forces them to always run.

L1.2

Match each automatic variable to its meaning, given a rule building parser.o from parser.c parser.h. First the four everyday ones — $@, $<, $^, $* — then the two that trip people up: $? and $+.

Recall Solution L1.2
Var Meaning Value here
$@ the target parser.o
$< first prerequisite parser.c
$^ all prerequisites, deduped parser.c parser.h
$* the stem parser
$? only prerequisites newer than the target depends on mtimes — e.g. just parser.h if only the header changed
$+ all prerequisites, NOT deduped (order preserved, duplicates kept) parser.c parser.h (would keep dups if any)

Memory hook: @ looks like a target/bullseye, < is an arrow into the recipe (first input), ^ points up at all inputs (cleaned up), * is the wildcard stem, ? asks "what's newer?", + is ^ that keeps everything (plus the duplicates).

Why $? matters: in an archive/incremental rule like lib.a: a.o b.o c.o, using $? lets you re-archive only the objects that actually changed, not all of them — a real Incremental compilation optimisation. Why $+ matters: for linkers where repeated -l flags or object order matters, $^ would wrongly collapse a duplicate you needed; $+ preserves it.


Level 2 — Application

L2.1

Given the pattern rule

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

Make wants to build network.o. Fill in: the stem, the substituted prerequisite, and the exact command line if CC := clang and CFLAGS := -Wall.

Read the figure below. It shows the target network.o on top, the pattern %.o beneath it aligned character-by-character. The red bracket marks the substring network that the % binds — this is the stem. The literal .o must line up on both sides; whatever is left over is the stem. That same red network is then dropped into %.c to manufacture the prerequisite network.c.

Figure — Phony targets, pattern rules, implicit rules
Recall Solution L2.1
  • Stem $* = network (the % matches everything before .o).
  • Prerequisite %.c → substitute the same stem → network.c.
  • $< = network.c, $@ = network.o.
  • Command line:
    clang -Wall -c network.c -o network.o
    

L2.2

This complete Makefile has no %.o rule written:

CC     := gcc
CFLAGS := -O2
app: a.o b.o
	$(CC) $^ -o $@

Does make app succeed if a.c and b.c exist? Explain which rule builds a.o and b.o, and where that rule comes from.

Recall Solution L2.2

Yes, it succeeds. Make has a built-in implicit rule roughly:

%.o: %.c
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

Where does this rule live, and how does Make find it? These built-ins are compiled into the GNU Make binary itself — not read from any file on disk. You can dump the entire catalogue with make -p (print database), which lists every built-in rule and variable; you can disable them all with make -r (--no-builtin-rules). When Make needs a.o and finds no explicit or user pattern rule, it walks its internal table of built-in patterns in a fixed order and picks the first whose prerequisite (a.c) exists or can itself be made. The .c → .o pattern is near the top of that table, so it wins for objects. This ordered walk is exactly the precedence chain from L5.3: explicit, then your patterns, then this built-in table.

What is CPPFLAGS? It is the built-in variable for C PreProcessor flags — things like -I<dir> (include search paths) and -D<name> (macro defines). It sits before CFLAGS on the compile line. It defaults to empty, so if you never set it, this Makefile compiles exactly as if it read gcc -O2 -c a.c -o a.o. (CFLAGS = compiler flags like -O2 -Wall; see Compiler flags CFLAGS LDFLAGS.)

So a.o builds from a.c and b.o from b.c using CC=gcc, CFLAGS=-O2, CPPFLAGS empty. Then the explicit app rule links them:

gcc a.o b.o -o app

This is the whole point of implicit rules — 90% of C projects need no .o rule at all.


Level 3 — Analysis (timestamp tracing)

For these, mtimes are shown as integers — bigger = newer. When a recipe rebuilds a target, its new mtime is the current wall-clock time, which is larger than every number in the table (imagine "now" = 1000).

L3.1

prog: main.o util.o
	gcc $^ -o prog
%.o: %.c
	gcc -c $< -o $@

Files and mtimes: main.c=10, main.o=20, util.c=30, util.o=25, prog=26

Run make prog. List every recipe that runs, in order.

Recall Solution L3.1

Apply the law to each target bottom-up.

util.o: prereq util.c=30 vs target util.o=25. Prereq newer → rebuild util.o. Its new mtime becomes "now" = 1000 (larger than any table value).

main.o: prereq main.c=10 vs target main.o=20. Prereq older → skip.

prog: prereqs are main.o and util.o. util.o was just rebuilt to 1000 > prog=26 → prog is stale → relink prog.

Recipes that run, in order:

  1. gcc -c util.c -o util.o
  2. gcc util.o main.o -o prog (relink)

main.o is untouched.

L3.2

Same Makefile as L3.1. Now touch main.c sets main.c=200 (everything else unchanged from the original table). Predict the recipes.

Recall Solution L3.2
  • main.o: main.c=200 > main.o=20rebuild main.o. Its new mtime becomes "now" = 1000 (a rebuild always stamps the current time, which is far larger than the touched value 200 — there is no ordering conflict).
  • util.o: util.c=30 > util.o=25rebuild util.o too (it was already stale in the original table!). New mtime = 1000.
  • prog: at least one prereq now newer → relink.

All three fire: main.o, util.o, then prog. The trap is forgetting util.o was already stale before you touched anything.

Read the figure below. Black circles are source files with their mtimes; red circles are the three targets whose recipes fire. Arrows are dependency edges (source → object → program). Notice util.o glows red even though you only touched main.c — its staleness came from the original table, not your edit.

Figure — Phony targets, pattern rules, implicit rules

Level 4 — Synthesis (design)

L4.1

Write a complete, correct Makefile that:

  • compiles main.c, parser.c, lexer.c into a binary compiler,
  • uses CC=gcc, CFLAGS=-Wall -O2, and passes LDFLAGS at link time,
  • has working all and clean targets,
  • gives only parser.o an extra flag -DDEBUG (others don't get it).
Recall Solution L4.1
CC      := gcc
CFLAGS  := -Wall -O2
LDFLAGS :=
OBJ     := main.o parser.o lexer.o
 
.PHONY: all clean
all: compiler
 
compiler: $(OBJ)
	$(CC) $(LDFLAGS) $^ -o $@
 
# generic pattern for the ordinary objects
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@
 
# explicit rule beats the pattern for parser.o only
parser.o: parser.c
	$(CC) $(CFLAGS) -DDEBUG -c $< -o $@
 
clean:
	rm -f $(OBJ) compiler

Why this works:

  • .PHONY: all clean → those always run (L1 law).
  • The %.o: %.c pattern handles main.o and lexer.o generically.
  • The explicit parser.o rule wins by precedence (explicit > pattern > implicit), so only it gets -DDEBUG.
  • $^ = all objects at link; $< = the single .c per compile; $@ = the output.
  • LDFLAGS sits on the link line only — that's the conventional slot.

L4.2

Your teammate wrote parser.o: parser.c (the explicit override) but forgot to list parser.h as a prerequisite. parser.c #includes parser.h. Describe the bug that appears and fix it.

Recall Solution L4.2

Bug: editing parser.h does not rebuild parser.o. Make only sees parser.c as a prereq; if parser.c's mtime didn't change, parser.o is considered up to date even though the header it depends on changed. You get a stale object — the compiled code doesn't match the new header. This is a classic Incremental compilation correctness hole.

Fix: list the header:

parser.o: parser.c parser.h
	$(CC) $(CFLAGS) -DDEBUG -c $< -o $@

Note $< is still just parser.c (first prereq → the file passed to the compiler), while $^ would now be parser.c parser.h. The header appears only to trigger the rebuild, not on the compile line.

L4.3 — order-only prerequisites (|)

You want every object placed inside a build/ directory. The directory must exist before any object is compiled, but you do not want objects to rebuild every time the directory's mtime changes (a directory's mtime bumps whenever a file is added to it). Write a pattern rule that creates build/ first without making it a "normal" timestamp-tracked prerequisite.

Recall Solution L4.3

Use an order-only prerequisite, written after a | (pipe) in the prerequisite list:

build/%.o: %.c | build
	$(CC) $(CFLAGS) -c $< -o $@
 
build:
	mkdir -p build

What the | does: everything left of the pipe (%.c) is a normal prerequisite — its mtime is compared to the target. Everything right of the pipe (build) is order-only: Make guarantees it is built first, but its timestamp is ignored when deciding whether build/%.o is stale.

Why you need this here: without | build, listing build as a normal prereq would make every object rebuild the moment a sibling object dropped into build/ (bumping the directory's mtime). Order-only breaks that false dependency while still guaranteeing the directory exists.

Portability caveat (important for cross-platform Makefiles): order-only prerequisites and the | syntax are a GNU Make extension (available since GNU Make 3.80, released 2002). They are not part of POSIX make, so a Makefile using | will fail to parse under BSD make or a strict POSIX make. If you must support non-GNU make, fall back to a plain recipe line like mkdir -p build inside the compile recipe (idempotent, but re-runs mkdir each time), or gate the feature on detecting GNU Make. See CMake and Ninja for tools that abstract this portability problem away, and Dependency graphs and topological build order for the ordering concept itself.


Level 5 — Mastery (edge-case cascades)

L5.1

.PHONY: version
version:
	echo "1.0" > version.txt
 
build: version main.o
	gcc main.o -o build
%.o: %.c
	gcc -c $< -o $@

version is phony but is listed as a prerequisite of build. What happens to build on every single make build, even when nothing else changed? Why? How would you fix it if you only need version to run before build without forcing a relink?

Recall Solution L5.1

build relinks on every invocation. A phony prerequisite is treated as always out of date (effectively mtime = +∞ from the dependent's view). So build's staleness test always finds a "newer" prereq (version) → the build recipe fires every time, even if main.o and main.c are untouched.

The cascade, step by step:

  1. make build → Make must satisfy prereq version first.
  2. version is phony → its recipe always runs (writes version.txt).
  3. Back at build: one of its prerequisites (version) is phony, hence always considered newer than the file build.
  4. Staleness law fires → build relinks unconditionally, even though main.o never changed.

This is a common accidental performance bug: never put a phony target in the normal prerequisite list of a file target unless you truly want unconditional rebuilds.

The fix — make version order-only:

build: main.o | version
	gcc main.o -o build

Now version still runs before build (ordering preserved), but because it sits after the | its "always newer" timestamp is ignored in build's staleness test. So build relinks only when main.o genuinely changes — exactly the order-only mechanism from L4.3.

L5.2

A directory contains a real file named test (someone ran make test > test once). The Makefile is:

test: program
	./program --selftest
program: main.o
	gcc main.o -o program
%.o: %.c
	gcc -c $< -o $@

Assume program is already newer than main.o, and the file test is newer than program. Run make test. Does the self-test run? Now add .PHONY: test. Does it run now?

Recall Solution L5.2

Without .PHONY: Make sees a real file test. Its prereq is program. Since test is newer than program, the law says: target present, no prereq newer → up to date → self-test does NOT run. You'd see make: 'test' is up to date. The bug: a stale file silently disables your test target.

With .PHONY: test: test is now unconditionally out of date → the recipe always runs, ignoring the file entirely. The self-test executes every time.

This is exactly the parent's "phony bug live" scenario, now with a nontrivial timestamp to expose that even when the file is newer, phony wins.

L5.3

Precedence stress test. Given:

%.o: %.c
	gcc -O2 -c $< -o $@          # your pattern rule
foo.o: foo.c
	gcc -O0 -g -c $< -o $@       # your explicit rule

Make also ships a built-in implicit %.o: %.c. When building foo.o and bar.o, which recipe builds each? Give the exact gcc command per file.

Recall Solution L5.3

Precedence: explicit > your pattern > built-in implicit.

  • foo.o: an explicit rule exists → it wins:
    gcc -O0 -g -c foo.c -o foo.o
    
  • bar.o: no explicit rule → your pattern rule wins (it shadows the built-in):
    gcc -O2 -c bar.c -o bar.o
    

The built-in implicit rule is never reached here — your own pattern shadows it, and the explicit rule shadows both for foo.o.

L5.4 — static & multiple-target pattern rules

Two related edge cases:

  1. You want a %.o: %.c compile rule to apply only to the objects in $(OBJ) = main.o util.o, and to nothing else in the tree. Write it.
  2. Explain what the rule %.o %.d: %.c (a multiple-target pattern rule) claims about how it runs.
Recall Solution L5.4

Part 1 — static pattern rule. A static pattern rule restricts a pattern to an explicit target list. Syntax: targets: target-pattern: prereq-pattern.

OBJ := main.o util.o
 
$(OBJ): %.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

What each part does: $(OBJ) is the exact set of targets this may build; %.o is matched against each of those targets to extract the stem; %.c builds the prerequisite from that stem. So it behaves like %.o: %.c but cannot accidentally build any object outside main.o util.o — the rest of the tree falls through to the built-in implicit rule (or errors).

Part 2 — multiple-target pattern rule. In a rule with several % targets sharing one recipe, GNU Make (3.79+) treats it as: one run of the recipe produces ALL the listed targets at once. For %.o %.d: %.c, a single invocation is understood to generate both foo.o and foo.d from foo.c. (This differs from an ordinary rule with multiple explicit targets, which would run the recipe once per target.) It is the standard trick for compilers that emit an object and a dependency file (.d) in one pass via -MMD. Related: secondary expansion (.SECONDEXPANSION:) lets a prerequisite reference $$@/$$* and be re-expanded after the target name is known — used when a prereq list must be computed per-target; treat it as an advanced escape hatch, not everyday syntax.