make and the build dependency graph
make is not "a shell script with tabs." It is a small graph engine. Targets depend on
prerequisites, rules describe how to rebuild targets, and timestamps decide whether a node
is stale. That model exists because C projects are split across translation units: touching
one .c should not rebuild the world, but touching a shared header should rebuild every
object that depends on it.
The reset: a
Makefileis a dependency graph plus recipes. The graph decides whether to rebuild; the recipe says how.
How it really works
A rule has a target, prerequisites, and a recipe:
target: prerequisite ...
command
When you ask for make demo, make checks whether demo exists and whether any prerequisite
is newer. If main.o or math.o is newer than demo, the link recipe runs. To decide
whether those objects are current, make recursively checks their own prerequisites. This
turns a pile of files into a directed dependency graph.
Variables keep command lines centralized. Pattern rules keep repeated compile recipes from
being copy-pasted. Automatic variables such as $@ and $< mean "the target" and "the
first prerequisite." A tiny C project can therefore express "every .o comes from its .c
and shared header" in one rule.
| Make idea | Meaning |
|---|---|
| Target | file or phony name you ask make to build |
| Prerequisite | file or target that must be current first |
| Recipe | shell commands run when target is stale |
| Pattern rule | reusable rule such as %.o: %.c |
| Phony target | command-like target that is not a real output file |
| Timestamp | default freshness signal |
Make does not parse C semantically by itself. If your object depends on a header, the
Makefile must say so, or the compiler must emit dependency files with options such as
-MMD -MP. Without correct edges, make can honestly decide the graph is current while the
program is stale.
Executable artifact: watch the graph rebuild
The example lives in
examples/toolchain-and-linking/make-and-the-build-dependency-graph/. Its Makefile
contains one link rule, one pattern compile rule, and a clean target:
CC ?= gcc
CFLAGS ?= -O0 -Wall -Wextra
OBJS = main.o math.o
demo: $(OBJS)
@echo "LINK $@"
$(CC) $(OBJS) -o $@
%.o: %.c math.h
@echo "CC $< -> $@"
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
rm -f $(OBJS) demo
The C source is deliberately small:
#include <stdio.h>
#include "math.h"
int main(void) {
printf("make graph result: %d\n", add_bias(31));
return 0;
}
Run it:
cd examples/toolchain-and-linking/make-and-the-build-dependency-graph
./run.sh
Real output from this machine:
== clean build ==
CC main.c -> main.o
cc -O0 -Wall -Wextra -c main.c -o main.o
CC math.c -> math.o
cc -O0 -Wall -Wextra -c math.c -o math.o
LINK demo
cc main.o math.o -o demo
== run ==
make graph result: 42
== no-op build query ==
make -q: graph is up to date
== header timestamp change rebuilds dependents ==
CC main.c -> main.o
cc -O0 -Wall -Wextra -c main.c -o main.o
CC math.c -> math.o
cc -O0 -Wall -Wextra -c math.c -o math.o
LINK demo
cc main.o math.o -o demo
The header edge is visible. Touching math.h makes both main.o and math.o stale because
the pattern rule says every object depends on that header. If the rule only listed %.c,
make would miss the header change and the build could silently use stale object files.
Failure modes & trade-offs
- Missing dependency edges. The classic bug is changing a header and not rebuilding all objects that include it.
- Timestamp granularity. Very fast file changes or clock skew can confuse timestamp checks. Generated files make this worse.
- Tabs are syntax. Recipes must begin with a tab in traditional make syntax; spaces can produce confusing errors.
- Phony targets can collide with files. If a real file named
cleanexists and the target is not.PHONY,make cleanmay do nothing. - Recursive make hides global graph edges. Splitting into many sub-makes can lose cross-directory dependency information.
- Make is low-level. It is excellent for direct control, but it does not discover platform features or package dependencies the way higher-level generators do.
In practice
- Model the graph first. Ask "what file produces what file, and what inputs make it stale?"
- Use pattern rules for compile edges. They reduce duplication and make flags consistent across objects.
- Generate header dependencies.
-MMD -MPplus included.dfiles is the common C/C++ production pattern. - Keep recipes boring. Make should decide the graph; shell recipes should do the one build action named by the target.
- Use make as the execution layer. Even when CMake or another generator writes the build files, the underlying idea is still a dependency graph.
Connects to: Toolchain & Linking · Translation units, declarations vs definitions, and linkage · gcc vs clang and the compiler driver model · Static linking and archives
Sources
- GNU Make Manual — Rules — targets, prerequisites, recipes, and update logic. https://www.gnu.org/software/make/manual/make.html#Rule-Introduction
- GNU Make Manual — Pattern Rules — reusable
%.o: %.c-style rules and automatic variables. https://www.gnu.org/software/make/manual/make.html#Pattern-Rules - GNU Make Manual — Phony Targets — why command-like targets should be marked
.PHONY. https://www.gnu.org/software/make/manual/make.html#Phony-Targets - GCC Manual — dependency generation options —
-M,-MM,-MMD, and related flags for header dependency files. https://gcc.gnu.org/onlinedocs/gcc/Preprocessor-Options.html - David Drysdale — Beginner's Guide to Linkers — context for why separate objects and link steps create real build dependencies. https://www.lurklurk.org/linkers/linkers.html