Advanced Techniques for Serious Automation
Lesson 7: Advanced Techniques for Serious Automation
You now know enough Make to build real projects. This lesson collects the power tools: order-only prerequisites, grouped targets, shell control, parallelism, conditionals, and Make's function library โ the features that separate "Makefile author" from "Makefile wizard."
Order-Only Prerequisites: "Ensure Exists, Don't Trigger Rebuild"
Normally, a newer prerequisite triggers a rebuild โ always. But some prerequisites shouldn't: a directory you need to exist shouldn't cause the target to rebuild every time. Put them after |:
bin/app: $(OBJS) | bin
$(CC) -o $@ $^
bin:
mkdir -p bin
bin is an order-only prerequisite: Make ensures it exists (and builds it if missing), but its timestamp never triggers a rebuild of bin/app. Perfect for output directories โ without it, your binary would relink on every make.
Grouped Targets: One Command, Many Outputs
Some tools emit several files from a single invocation (a parser and its header; a report and its chart). GNU Make 4.3+ lets a recipe claim all targets at once with &::
parser.c parser.h &: grammar.y
yacc -d grammar.y # produces both files
mv y.tab.c parser.c
mv y.tab.h parser.h
With &:, if either output is stale, the recipe runs once and both files are considered updated. (Without it, Make may run the recipe twice or get confused โ a classic multi-output trap.)
Controlling the Shell: .ONESHELL and .SHELLFLAGS
By default, each recipe line runs in its own shell. That's why cd dir on one line doesn't affect the next โ and why you'll see cd dir && cmd crammed on one line. Two remedies:
.ONESHELL: # run the whole recipe in ONE shell
.SHELLFLAGS = -e -c # stop on the first failing command
target:
cd build
./configure
make -C . install
.ONESHELL is clean but changes semantics โ with it, a failing line no longer aborts the recipe by itself, so pair it with .SHELLFLAGS = -e -c to get fail-fast behavior. For most recipes, the classic cd dir && cmd is simpler.
Parallel Builds: -j, the Jobserver, and -l
Make can run independent recipes concurrently โ the graph guarantees only truly independent work is parallelized:
make -j8 # up to 8 jobs at once
make -j # unlimited (risky; usually omit)
make -l 4 # only start jobs if load average < 4
GNU Make shares a jobserver with sub-makes, so nested invocations don't oversubscribe your CPU. Typical use: make -j$(nproc) on Linux or make -j$(sysctl -n hw.ncpu) on macOS. Your recipe must be safe to run in parallel: no two targets writing the same file, and shared intermediates should be explicit prerequisites.
mkdir -p bin, that's fine. If two both write generated.h, you have a race. Rules of thumb: put shared outputs in the dependency graph, and use order-only prerequisites for directories.
Conditionals: ifeq / ifneq / ifdef
Make evaluates conditionals at parse time โ before any recipe runs:
ifeq ($(OS),Windows_NT)
EXE := .exe
else
EXE :=
endif
ifdef DEBUG
CFLAGS += -g -O0
else
CFLAGS += -O2
endif
Conditionals are how one Makefile serves many environments. Note the syntax quirks: ifeq needs the comma inside the parens, and the else/endif must start at column 0.
The Function Library: $(shell), $(foreach), $(call), and Friends
Make's real power lies in its functions โ text processing that runs at parse time:
| Function | What it does |
|---|---|
$(shell cmd) | Run a shell command, capture stdout (e.g. $(shell git rev-parse HEAD)). |
$(wildcard *.c) | Expand filesystem wildcards. |
$(foreach var,list,body) | Loop over words, building output. |
$(call name,args...) | Call a user-defined function. |
$(filter pat...,text) | Keep words matching a pattern. |
$(patsubst a%,b%,text) | Pattern substitution on words. |
$(notdir path) / $(dir path) | Strip to filename / keep directory. |
$(addprefix pre,list) | Prefix every word (e.g. obj/). |
A taste โ user-defined functions and $(foreach):
# User-defined function: compile one file
define COMPILE
$(1).o: $(1).c
$(CC) $(CFLAGS) -c $< -o $@
endef
# Generate a rule for each source (note $ escapes in define)
$(foreach src,$(SOURCES),$(eval $(call COMPILE,$(basename $(src)))))
# Capture version at parse time
VERSION := $(shell git describe --tags --always)
Yes, that's the deep end โ $(eval) writes rules at parse time, and $ defers expansion inside define blocks. You rarely need this, but when you do, nothing else can do it.
Self-Documenting Makefiles: The help Target
Teams love a make help. The trick: put a ## comment next to each target, then use $(shell grep ...) to render them:
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk -F':.*?## ' '{printf " %-14s %s\n", $1, $2}'
build: ## Compile the project
$(CC) -o app $(OBJS)
test: ## Run the test suite
./app --test
Running make help prints a tidy, always-current command list โ the poor man's CLI, and genuinely useful in repos where "how do I build this?" is a FAQ.
๐ง Knowledge Check
1. What does the | in bin/app: $(OBJS) | bin do?
2. Why is .ONESHELL usually paired with .SHELLFLAGS = -e -c?
3. What does $(shell git rev-parse HEAD) evaluate to?