From "you need one" to "here is one"
Guide 1 made the case that once a project grows past a single file, typing the compile-and-link commands by hand becomes slow, error-prone, and impossible to remember. A build system is the kitchen assistant that knows the whole recipe and runs it the same way every time. This guide introduces the oldest and most widespread such assistant on Unix-like systems: Make. It is decades old, it is everywhere, and being able to read its instructions lets you build a staggering amount of existing software.
You use Make by writing your build instructions into a plain-text file named Makefile, then typing `make` in the directory that contains it. If a build system is a generic kitchen assistant, Make is one particular, very famous assistant, and the Makefile is the written recipe card you hand it. Nothing magic happens: Make reads the file, works out what to do, and runs ordinary shell commands — the same `gcc` commands you would have typed yourself, just chosen and ordered for you.
Recall from the Toolchain rung that building a C program is really two phases: compile each source file on its own into an object file (`gcc -c main.c` produces main.o), then link all the object files into one executable. That split is separate compilation, and it is precisely what makes Make worthwhile: because each .c can be compiled independently, Make can recompile just the one file you changed and reuse the rest. Our whole job in a Makefile is to describe those compile-and-link steps and how they connect.
The grammar of a rule: target, prerequisites, recipe
A Makefile is mostly a list of rules, and every rule has the same three-part shape. Think of building a sandwich: the sandwich is what you want, the bread and filling are what it is made from, and "put the filling between the slices" is how you make it. In Make's vocabulary those three parts are the target, the prerequisites, and the recipe. The target is the file you want to produce; the prerequisites are the files it is made from; the recipe is the exact shell commands that turn one into the other.
Written down, a rule puts the target and its prerequisites on one line separated by a colon, then one or more recipe lines beneath it. Suppose we have main.c and util.c, and util.c relies on a header util.h. Here is a complete, honest Makefile for that little project — three rules to compile the two object files and one rule to link them into a program called `app`.
app: main.o util.o <TAB>gcc -o app main.o util.o main.o: main.c util.h <TAB>gcc -c main.c util.o: util.c util.h <TAB>gcc -c util.c ( each <TAB> above is one real TAB character, not spaces )
Make figures out the order by itself
Here is the part that feels like magic the first time. You never tell Make the order to build things in. You typed three rules above in some arbitrary order, yet `make` will always compile main.o and util.o before linking app. It works this out from how the targets and prerequisites connect: app lists main.o and util.o as prerequisites, so Make knows those must exist first; each .o in turn lists a .c and a .h, which already exist as files you wrote. Make reads the connections and derives the order — you describe dependencies, and the sequence falls out of them.
What Make has effectively built in its head is a build graph: picture every file as a dot, with an arrow from each thing to the things it is made from. app points to main.o and util.o; main.o points to main.c and util.h. When you run `make`, it walks this graph from the goal you asked for (app, the first target, by default) down to the source files, deciding at each dot whether that file needs to be (re)built. The graph is not something you draw; it is something Make assembles automatically from the prerequisites you wrote.
Listing the prerequisites honestly is the one responsibility Make hands back to you, and the classic mistake is shirking it. Notice we wrote util.h as a prerequisite of both main.o and util.o. If main.c does `#include "util.h"` but you forget to list util.h, then editing util.h won't make Make rebuild main.o — Make simply doesn't know they're connected. The build silently succeeds with a stale main.o, and you get a program that doesn't match your source, with no error message at all. An incomplete graph is worse than a slow build, because it lies to you.
Why it skips work: timestamps and incremental rebuilds
The real payoff of the graph is that Make does not blindly rebuild everything every time — it does only the work that is actually needed, an incremental rebuild. The trick it uses is delightfully simple: file timestamps. Every file on disk carries a last-modified time. Make's rule is, a target is up to date if it exists and is newer than all of its prerequisites; otherwise it is out of date and its recipe must run.
- You edit util.c and save. Its last-modified time jumps to now, ahead of util.o.
- You run `make`. Make compares util.o against its prerequisites and sees util.o is now older than util.c — out of date — so it reruns `gcc -c util.c`, producing a fresh util.o.
- Make then checks app against its prerequisites and sees app is older than the new util.o, so it relinks: `gcc -o app main.o util.o`.
- main.o is left completely alone — main.c didn't change, so main.o is still newer than its sources, and Make never reruns its recipe.
That is the entire mechanism, and it is what makes iterating on a big program bearable: change one file and rebuild in a second instead of recompiling the whole project. But be honest about the limits — a timestamp is a proxy for "did the contents change," not the truth itself. If your system clock is wrong, if a file is touched without really being edited, or if a prerequisite is missing from the graph, Make can wrongly skip work (a stale build) or wrongly redo it. When a build behaves inexplicably, the standard reset is a clean build: delete all the generated build artifacts — main.o, util.o, app — and build again from source with nothing left over to confuse the timestamp logic.
Less repetition: phony targets, variables, patterns
Our first Makefile works, but it has two annoyances: there is no easy way to delete the build artifacts, and the .o rules repeat the same `gcc -c` shape. Three small features fix this. A phony target is a rule whose name is an action rather than a file; a variable lets you write a value once and reuse it; and a pattern rule writes one rule for a whole family of files instead of copying it.
A phony target you declare with `.PHONY: clean`. Normally `make clean` runs a rule named clean — but if a file literally named clean ever appeared in the folder, Make would see it as a target that is already up to date and refuse to run the recipe. Marking it `.PHONY` tells Make "this name is an action to always run, never a file to check." A variable is written `CC = gcc` or `CFLAGS = -O2 -Wall` and used as `$(CC)` or `$(CFLAGS)`, so the compiler and flags live in one place you can change in a single edit. And a pattern rule uses `%` as a wildcard: one rule `%.o: %.c` says how to make any .o from the matching .c, where `<` means the first prerequisite (the .c) and `@` means the target (the .o).
CC = gcc CFLAGS = -O2 -Wall app: main.o util.o <TAB>$(CC) -o app main.o util.o %.o: %.c util.h <TAB>$(CC) $(CFLAGS) -c $< # $< = the .c ; $@ would be the .o .PHONY: clean clean: <TAB>rm -f app main.o util.o
These features turn a wall of copy-pasted lines into something short and maintainable, where changing a compiler flag is a one-line edit. The honest catch is readability: automatic variables like `$@`, `<`, and `^` (all the prerequisites) are powerful but cryptic to a newcomer, and an over-clever Makefile can become genuinely hard to debug. Used in moderation, they are exactly what keeps a real project's build sane. And mind one real limitation of plain Make: it does not by itself know where compilers or libraries live on different operating systems — which is why the meta-build tool CMake, the subject of the last guide in this rung, exists one level up to generate Makefiles for you.