Rule Anatomy
Part 2 of 16 — GNU Make Mastery Series. In Part 1 we covered why build systems exist and Makefile fundamentals. Now we go deeper into how Make actually executes rules and makes decisions about what to rebuild.
1
Build Systems Foundations
Why Make, compilation pipeline, basics
2
Targets, Prerequisites & Execution
Rule anatomy, PHONY, build workflows
You Are Here
3
Variables, Expansion & Scope
= vs :=, ?=, +=, CLI overrides
4
Automatic Variables & Pattern Rules
$@, $<, $^, %.o: %.c patterns
5
Built-in Functions & Make Language
subst, wildcard, foreach, $(shell)
6
Conditionals & Configurable Builds
ifeq, ifdef, platform detection, flags
7
Automatic Dependency Generation
-M, -MM, -MD, .d files, header deps
8
Compilation Workflow & Libraries
Static/shared libs, ar, -fPIC, SONAME
9
Project Architecture & Multi-Directory
Recursive make, include, out-of-source
10
Cross-Compilation & Toolchains
Toolchain prefixes, sysroots, embedded
11
Parallel Builds & Performance
make -j, jobserver, race conditions
12
Testing, Coverage & Debug Tooling
Test targets, gcov/lcov, sanitizers
13
Make as Automation & DevOps Tool
Task runner, Docker, install, packaging
14
CI/CD Integration
Deterministic builds, GitHub Actions, cache
15
Advanced Make & Debugging
--debug, dynamic rules, evaluation traps
16
Ecosystem, Alternatives & Mastery
CMake, Ninja, Meson, Bazel, production
Every Make rule follows the same three-part structure. Understanding this structure — and the precise semantics of each part — is the foundation for everything else in Make.
target [target2 ...]: [prerequisites] [| order-only-prerequisites]
recipe-line-1
recipe-line-2
Default Target Behaviour
When you run make with no arguments, Make builds the first target it encounters in the Makefile (excluding targets whose names begin with .). This is called the default goal.
Best Practice: Always make the first target either all or your main build artefact. Use .DEFAULT_GOAL := all to make the intent explicit and immune to file ordering accidents.
# Explicit default goal — immune to Makefile reordering
.DEFAULT_GOAL := all
.PHONY: all clean test
all: myapp
myapp: main.o utils.o
$(CC) -o $@ $^
Multiple Targets
A rule can list several targets on the left-hand side. This is shorthand: Make creates one rule per target, all sharing the same prerequisites and recipe.
# Build two executables from separate main files
server client: common.o config.o
$(CC) $(CFLAGS) -o $@ $@.o $^
# Equivalent to writing:
# server: common.o config.o
# $(CC) $(CFLAGS) -o server server.o common.o config.o
# client: common.o config.o
# $(CC) $(CFLAGS) -o client client.o common.o config.o
Types of Prerequisites
Normal Prerequisites
Normal prerequisites (target: prereq1 prereq2) impose two constraints on Make:
- All prerequisites must be up to date before the target's recipe runs.
- If any prerequisite is newer than the target, the target is considered out of date and its recipe runs.
Order-Only Prerequisites (|)
Order-only prerequisites (listed after |) impose execution order but do not trigger a rebuild if they are newer than the target. This is ideal for ensuring directories exist before writing files into them.
BUILD_DIR := build
# build/ directory must exist before writing .o files,
# but a timestamp change on the dir should NOT force recompiling all objects
$(BUILD_DIR)/%.o: %.c | $(BUILD_DIR)
$(CC) $(CFLAGS) -c $< -o $@
$(BUILD_DIR):
mkdir -p $@
Common Mistake
Directory as Normal Prerequisite
If you list a directory as a normal prerequisite, Make will rebuild all its targets every time any file in the directory changes (since the directory mtime updates). Use | to make it order-only — you only need the directory to exist, not to be unchanged.
File vs Phony Targets
By default, every Make target is assumed to be a file. If a file named clean exists in the directory, running make clean will report "Nothing to be done" — the target appears up to date because the file exists and has no prerequisites.
Declaring a target as .PHONY tells Make: this target is not a file — always run its recipe when requested.
# Declare these targets as "not real files" — always run their recipes
.PHONY: all clean build test install uninstall
clean: # remove all build outputs
rm -rf build/ *.o
test: all # build first, then run tests
./run_tests.sh
install: all # build first, then copy binary
cp myapp /usr/local/bin/
Common Phony Conventions
Convention
Standard Phony Targets
| Target | Purpose |
all | Build everything (default goal) |
clean | Remove all generated files |
distclean | Remove everything including configuration |
test | Run test suite |
install | Install built artefacts |
uninstall | Remove installed artefacts |
help | Print available targets |
Clean/Build/Run Workflows
The following complete Makefile expects a src/ directory with C files. Create them first:
# --- Setup: create project structure ---
mkdir -p src
cat > src/main.c << 'EOF'
#include <stdio.h>
void greet(const char *name);
int main(void) {
greet("Make");
printf("Build system demo running!\n");
return 0;
}
EOF
cat > src/utils.c << 'EOF'
#include <stdio.h>
void greet(const char *name) {
printf("Hello from %s!\n", name);
}
EOF
# ── Variables ────────────────────────────────────────
CC := gcc
CFLAGS := -Wall -Wextra -std=c11
TARGET := myapp
SRCS := $(wildcard src/*.c) # auto-discover all .c files under src/
OBJS := $(patsubst src/%.c, build/%.o, $(SRCS)) # map src/foo.c → build/foo.o
.PHONY: all clean run rebuild help
# ── Default target ─────────────────────────────────
all: $(TARGET)
# ── Link: combine .o files into final binary ─────
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $^ # $@ = target name, $^ = all prerequisites
# ── Compile: .c → .o (order-only prereq creates build/ dir first)
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@ # $< = first prerequisite (the .c file)
build: # create the output directory if missing
mkdir -p build
# ── Utility targets ───────────────────────────────
clean: # delete all generated files
rm -rf build $(TARGET)
run: all # build then execute
./$(TARGET)
rebuild: clean all # fresh build from scratch
help:
@echo "Available targets:"
@echo " all - Build $(TARGET) (default)"
@echo " clean - Remove build artefacts"
@echo " run - Build and run"
@echo " rebuild - Clean then build"
@echo " info - Display variable values"
@echo " help - Show this message"
# ── Info: display variable values — run: make info ──
info:
@echo "CC = $(CC)"
@echo "CFLAGS = $(CFLAGS)"
@echo "TARGET = $(TARGET)"
@echo "SRCS = $(SRCS)"
@echo "OBJS = $(OBJS)"
# --- Try the workflow targets ---
make # build the project
make info # display all variable values
make run # build and run
make clean # remove artefacts
make rebuild # clean + build in one step
make help # list available targets
Hands-On Milestone
Milestone: By the end of Part 2 you should be able to:
- ✅ Build a simple input → processed → output pipeline with Make rules
- ✅ Use order-only prerequisites (
|) for build directories
- ✅ Declare all non-file targets with
.PHONY
- ✅ Add
clean, run, rebuild, and help workflow targets
Next in the Series
In Part 3: Variables, Expansion & Scope, we explore the full variable system — recursive vs simple expansion, conditional assignment, CLI overrides, target-specific variables, and multiline define/endef macros.
Continue the Series
Part 3: Variables, Expansion & Scope
Master recursive vs simple variables, conditional assignment, CLI overrides, target-specific and pattern-specific variables, and multiline macros.
Read Article
Part 4: Automatic Variables & Pattern Rules
Use $@, $<, $^, and $* to write concise pattern rules that compile any number of source files automatically.
Read Article
Part 1: Build Systems Foundations
Start from scratch: why build systems exist, the compilation pipeline, and your first Makefile.
Read Article