GNU Make and gmake
The dependency-graph and timestamp model that still underlies almost every C and C++ build, plus the rules, automatic variables, parallelism hazards, and debugging flags an interviewer expects you to know cold.
Overview
Fifty years old and still the substrate
make is still the substrate under most C and C++ builds. GNU Make is the dialect everyone means (called gmake on the BSDs and Solaris, where make is a different, weaker program). Version 4.4.1 is the current documented edition, and the pattern rules, functions and jobserver real builds depend on are GNU extensions, not POSIX make.
The model in one sentence
A target is out of date if it does not exist or if any prerequisite has a newer modification time; an out-of-date target is rebuilt by running its recipe. Pattern rules, automatic variables, order-only prerequisites and the function library all exist to describe that graph without writing it out by hand.
Why timestamps are fast and fragile
- A stat call is cheap — Timestamps carry no information about *why* a file changed
- The graph walk parallelizes trivially — Editing a compiler flag changes no source mtime, so nothing rebuilds and you get mixed flags
- A file restored from backup can have an old mtime and silently skip its consumers
> [!NOTE] Content-addressed systems such as Bazel and Buck were built to fix exactly this weakness. Knowing the trade-off interests an interviewer more than knowing the syntax.
What separates makefiles that survive
- Automatic header dependencies — Hand-maintained header lists are always wrong
- .PHONY and .DELETE_ON_ERROR — The defaults are wrong for actions and for failed recipes
- A complete graph — Parallel make is the only real test of whether dependencies are declared
Recursive Make Considered Harmful
Peter Miller's 1997 paper is the other thing to have an opinion about.
- Each sub-make sees an incomplete graph — Unnecessary rebuilds and, worse, missed rebuilds across directories
- Sub-makes serialize at directory granularity — Defeats parallel scheduling
What interviewers probe
make is where a candidate either understands incremental builds or has only typed make -j8.
- How does make decide to rebuild? — The timestamp rule and its failure modes
- What does -j expose? — Missing edges in the graph
- Why is recursive expansion dangerous? — = vs :=
- The build rebuilds everything every time: debug it — --trace, -p, order-only prerequisites
Key concepts
- Rule
- A target, its prerequisites, and a recipe. Recipe lines must begin with a real tab character, which is why a makefile pasted through an editor that expands tabs fails with the message "missing separator". Explicit rules name files literally; pattern rules use % as a stem and match a family of files.
- Automatic variables
- Per-recipe variables set by make: $@ is the target, $< the first prerequisite, $^ all prerequisites with duplicates removed and order-only excluded, $+ all prerequisites with duplicates preserved, $? those newer than the target, $* the pattern stem, $| the order-only prerequisites. Each has D and F variants such as $(@D) for the target directory.
- Order-only prerequisite
- Listed after a pipe character. Must exist before the target is built, but its timestamp never triggers a rebuild. The canonical case is an output directory, whose mtime changes on every write and would otherwise force a full rebuild on every run.
- Variable flavor
- Recursively expanded (=) re-evaluates the right-hand side at every reference. Simply expanded (:=) evaluates once at assignment. ?= assigns only if undefined, += appends and inherits the original flavor, != captures shell output. Default to := and reach for = only when deferred evaluation is genuinely wanted.
- .PHONY
- Declares that a target is an action, not a file. Without it, the presence of a file named clean or test or install makes that target permanently up to date and the recipe never runs. Also skips the implicit-rule search, which measurably speeds up large makefiles.
- .DELETE_ON_ERROR
- Deletes the target when its recipe exits nonzero. Not the default. Without it a killed or failed compiler leaves a partially written output file whose mtime is newer than its source, so the next invocation treats corrupt output as up to date. Every serious makefile declares this.
- Generated dependencies (-MMD -MP)
- The compiler emits a .d file listing the header prerequisites it actually opened. -MMD excludes system headers, -MD includes them, and -MP adds a phony target per header so deleting a header does not break the build with "No rule to make target". The .d files are pulled in with -include so a first build tolerates their absence.
- Jobserver
- The mechanism by which a top-level make -j N shares its job budget with recursive sub-makes, historically a pipe of tokens and since 4.4 also a named FIFO. It is why -j16 on a recursive build does not become 16 sub-makes each running 16 jobs, and why a sub-make invoked without the $(MAKE) variable prints the warning about disabling the jobserver.
When to use it
- Any C or C++ project small enough that a generator would be overhead, or any project that must build with nothing but a compiler and a shell
- Debugging a CMake, autotools, kernel, or buildroot build — the generated layer you actually have to read is make
- Wrapping heterogeneous tooling (codegen, protobuf, docs, containers) where the unit of work is a file and the dependency is a timestamp
- Investigating why an incremental build rebuilds too much or too little, which is always a question about the declared graph
- Interviews and JDs that name gmake explicitly, which usually signals a long-lived C or C++ codebase with a hand-maintained build
Interview questions
- Explain exactly how make decides to rebuild, and where that model breaks down.
- Write a pattern rule for compiling C++ with automatic header dependencies, and explain every piece.
- What breaks when you switch from make to make -j, and how do you find it?
- Explain variable flavors in make and give a case where recursive expansion causes a real problem.
- What is the argument in "Recursive Make Considered Harmful", and what does the non-recursive alternative look like?
- A build rebuilds everything on every invocation even with no source changes. How do you debug it?
Sign up free to read the full topic
More DevOps topics
Start free on Cariara · Coding problems · System design studio