Makefiles: a build graph you write by hand¶
Level: 101 → 201 · for anyone building C
One line: A Makefile is a list of rules — this file is made from those files, by this command — and make reruns a command only when one of its inputs is newer than its output; that timestamp comparison is the whole engine, which is also why a dependency you forgot to write down leaves you a stale program and no error.
A whole Makefile¶
A C program in three files: hello.c calls greet("Ada"), greet.c prints the greeting, and both include this header:
The Makefile that builds it:
CC = cc
CFLAGS = -Wall -O2
hello: hello.o greet.o
$(CC) -o hello hello.o greet.o
hello.o: hello.c greet.h
$(CC) $(CFLAGS) -c hello.c
greet.o: greet.c greet.h
$(CC) $(CFLAGS) -c greet.c
.PHONY: clean
clean:
rm -f hello hello.o greet.o
It is demo/Makefile, beside hello.c, greet.c and greet.h and the three variants this page comes to — trap.mk, deps.mk and terse.mk. The Makefiles on this page are those files byte for byte, TABs included.
| In the file | Called | What make does with it |
|---|---|---|
greet.o: greet.c greet.h |
a rule | target, colon, prerequisites — and the recipe on the lines under it |
greet.o |
the target | a file name; its modification time is what gets compared |
greet.c greet.h |
the prerequisites | brought up to date first, then compared against the target |
| the indented line | the recipe | shell commands, run only if the target is missing or older than a prerequisite |
CC = cc, then $(CC) |
a variable | text substitution, done before the command runs |
.PHONY: clean |
a phony target | declares that clean is not a file, so make clean always runs |
hello coming first |
the default goal | what a bare make builds |
What make does with it¶
Every run on this page is a script in examples/ that copies demo/ to a scratch folder and drives make there, and CI runs each one on Ubuntu (GNU Make 4.3, GCC) and on macOS (GNU Make 3.81, Apple clang). Where make prints a message the two versions word differently, the script asks make -q instead — question mode, which runs nothing and answers in its exit status: 0 for up to date, 1 for something to rebuild.
Verified output of makefiles_rebuild_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ make
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
$ ./hello
Hello, Ada
$ make -q
exit 0
$ touch greet.c
$ make -q
exit 1
$ make
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
$ touch greet.h
$ make -n
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
$ make
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
The first run built in dependency order: both objects, then the link. make -q then found nothing newer than hello. touch greet.c changed the file's timestamp and not one byte of it — and that was enough to recompile greet.c and relink, but not to recompile hello.c, whose inputs had not moved. touch greet.h reached both objects, because both rules list it.
make -n prints the commands it would run and runs none of them — the first thing to type in a Makefile you did not write.
The whole rule¶
For each target, depth first: bring every prerequisite up to date, then run the recipe if the target does not exist or any prerequisite is newer than it. make never opens greet.c; it reads names from the Makefile and modification times from the filesystem. Two consequences, in one run:
Verified output of makefiles_names_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ make
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
$ sed 's/Hello/Goodbye/' greet.h > greet.h.new && mv greet.h.new greet.h
$ touch -t 202001010000 greet.h
$ make -q
exit 0
$ ./hello
Hello, Ada
$ touch clean
$ make -q clean
exit 1
$ grep -v '^[.]PHONY' Makefile > nophony.mk
$ make -q -f nophony.mk clean
exit 0
- An older file is not an edit.
greet.hnow saysGoodbye, but its timestamp was set back to 2020 — older than the objects built from the file it replaced — somake -qanswers up to date and the program still saysHello. A restore from backup,cp -pandtar -xall do this to a build: they put back a file's old date along with its contents. - A target is only a name. The
cleanrecipe never makes a file calledclean, somake cleannormally finds its target missing and runs. Put a stray file of that name in the folder, and the Makefile with.PHONY: cleanstill countscleanas out of date (exit 1), while the same Makefile without that line counts it as up to date (exit 0) — a target that exists and has no prerequisites to be older than. A phony target is never looked up on disk, which is the whole of what.PHONYdoes.
The TAB¶
A recipe line must start with a TAB character — the single byte 0x09 — not with spaces. expand does to the Makefile what a tab-to-spaces editor does to it:
Verified output of makefiles_tab_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ expand -t 4 Makefile > spaces.mk
$ make -f spaces.mk
spaces.mk:5: *** missing separator. Stop.
$ head -5 Makefile | cat -et
CC = cc$
CFLAGS = -Wall -O2$
$
hello: hello.o greet.o$
^I$(CC) -o hello hello.o greet.o$
$ head -5 spaces.mk | cat -et
CC = cc$
CFLAGS = -Wall -O2$
$
hello: hello.o greet.o$
$(CC) -o hello hello.o greet.o$
cat -et shows the TAB as ^I and marks each line end with $; apart from that one byte the two files are identical, and on screen you cannot tell them apart. When the indentation is exactly eight spaces, make adds a hint — missing separator (did you mean TAB instead of 8 spaces?) — and for the four an editor usually inserts it says nothing more.
Copying a Makefile off a web page is an easy way to lose the TAB. Python-Markdown 3.10.3, which builds this site, expands every TAB in a page to spaces before it renders a code block, so on a site built with its defaults a Makefile arrives with four spaces where each TAB was, and a copy of it fails exactly as spaces.mk did. This site turns on SuperFences' preserve_tabs ↗, which keeps the TABs inside a code block, so every recipe line on this page reaches your browser starting with its 0x09, as it does on GitHub — and the site's build fails if a page ever loses one.
Since GNU Make 3.82 (2010) a Makefile can choose another prefix with .RECIPEPREFIX = >, and 4.3 and 4.4.1 honour it; 3.81 ignores the assignment and stops at the first > with missing separator.
The trap: a header nobody listed¶
Most hand-written Makefiles list the .c file and stop. trap.mk writes the two object rules that way:
Build, change the greeting in greet.h, ask again:
Verified output of makefiles_trap_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ make -f trap.mk
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
$ ./hello
Hello, Ada
$ sed 's/Hello/Goodbye/' greet.h > greet.h.new && mv greet.h.new greet.h
$ make -q -f trap.mk
exit 0
$ ./hello
Hello, Ada
$ make -B -f trap.mk
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
$ ./hello
Goodbye, Ada
No rule mentions greet.h, so as far as make can see nothing is newer than anything: make -q says up to date, there is no error and no warning, and the program is still built from the old header. Here the stale part is a greeting. When the header defines a struct and only one of the two .c files is edited, only that object is rebuilt: two object files compiled against two layouts of one struct, linked into one program without complaint, because the linker matches names, not layouts. make -B rebuilds everything regardless of timestamps, which fixes it once; the lasting fix is to stop writing the header lists by hand.
Let the compiler write the graph¶
The compiler knows exactly which headers a file includes — it opened them. -MMD makes it write that list down as a side effect of compiling, in Makefile syntax, one .d file per object; -MP adds an empty rule for each header. deps.mk gains two flags and one line, and loses its header lists:
CC = cc
CFLAGS = -Wall -O2 -MMD -MP
hello: hello.o greet.o
$(CC) -o hello hello.o greet.o
hello.o: hello.c
$(CC) $(CFLAGS) -c hello.c
greet.o: greet.c
$(CC) $(CFLAGS) -c greet.c
-include hello.d greet.d
Verified output of makefiles_deps_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ make -f deps.mk
cc -Wall -O2 -MMD -MP -c hello.c
cc -Wall -O2 -MMD -MP -c greet.c
cc -o hello hello.o greet.o
$ cat greet.d
greet.o: greet.c greet.h
greet.h:
$ cat hello.d
hello.o: hello.c greet.h
greet.h:
$ sed 's/Hello/Goodbye/' greet.h > greet.h.new && mv greet.h.new greet.h
$ make -q -f deps.mk
exit 1
$ make -f deps.mk
cc -Wall -O2 -MMD -MP -c hello.c
cc -Wall -O2 -MMD -MP -c greet.c
cc -o hello hello.o greet.o
$ ./hello
Goodbye, Ada
The .d files are the same bytes from Apple clang and from GCC, which is what lets them sit in an answer key. -include reads them as more rules, and its dash means carry on if they are missing — which on the first build they are, harmlessly, since everything is out of date then anyway.
The greet.h: lines are -MP's. Without them, deleting a header breaks the next build, because the old .d file still names it and nothing can make it. The same project compiled with -MMD alone, after greet.h is deleted and its two #include lines removed:
$ make
make: *** No rule to make target `greet.h', needed by `hello.o'. Stop.
With -MP, the empty rule is one make can always satisfy — a missing file with no recipe counts as freshly made — so the same deletion just rebuilds both objects. GCC's preprocessor options ↗ document the whole -M family; Apple clang wrote the .d files above from the same flags.
The Makefile you will actually meet¶
Most Makefiles in the wild are shorter than the one at the top, because make already knows how to turn a .c file into a .o, and .o files into a program named after one of them. terse.mk is a complete Makefile for the same project:
Verified output of makefiles_terse_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ make -f terse.mk
cc -Wall -O2 -MMD -MP -c -o hello.o hello.c
cc -Wall -O2 -MMD -MP -c -o greet.o greet.c
cc hello.o greet.o -o hello
$ ./hello
Hello, Ada
$ make -q -f terse.mk
exit 0
$ mv hello.c main.c
$ printf 'CFLAGS = -Wall -O2\n\nhello: main.o greet.o\n' > named.mk
$ make -f named.mk
cc -Wall -O2 -c -o main.o main.c
cc -Wall -O2 -c -o greet.o greet.c
$ make -f named.mk
exit 0
$ ls
greet.c
greet.h
greet.o
main.c
main.o
named.mk
Nobody wrote the first three commands. They come from make's built-in rules; make -qp -f /dev/null prints the whole database — and the -q matters, because plain -p prints the database and then runs the build. The first and last rule here did the work; the middle one is the C++ version:
| Built-in rule | Its recipe |
|---|---|
%.o: %.c |
$(COMPILE.c) $(OUTPUT_OPTION) $< |
%.o: %.cpp |
$(COMPILE.cpp) $(OUTPUT_OPTION) $< |
%: %.o |
$(LINK.o) $^ $(LOADLIBES) $(LDLIBS) -o $@ |
| Variable | Expands to |
|---|---|
COMPILE.c |
$(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c |
COMPILE.cpp |
$(COMPILE.cc), which is $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c |
LINK.o |
$(CC) $(LDFLAGS) $(TARGET_ARCH) |
OUTPUT_OPTION |
-o $@ |
The runs of spaces in -MMD -MP -c are the evidence: two variables nobody set, CPPFLAGS and TARGET_ARCH, expanded to nothing in place.
The second half of that run is the catch. The link rule is %: %.o — a program named after one of its objects, hello from hello.o — which is why the main file here is hello.c. Rename it main.c, and a Makefile saying hello: main.o greet.o compiles both objects, links nothing, and exits 0: the ls has no hello in it. With no recipe and no built-in rule that fits, hello is a target make has nothing to do for, and it says so successfully. Name the program after an object, or write the link recipe yourself.
Three notations carry most Makefiles:
| Notation | Means |
|---|---|
%.o: %.c |
a pattern rule — % matches the same stem on both sides |
$@ · $< · $^ |
automatic variables — the target, the first prerequisite, all the prerequisites |
$(OBJS:.o=.d) |
a substitution reference — hello.o greet.o with each .o swapped for .d |
The conventional variables are how you configure the built-in rules without rewriting them, and they are what the first lines of most C and C++ Makefiles set:
| Set | For | Reaches |
|---|---|---|
CC, CXX |
the compilers | the compile rules; CC also links |
CFLAGS, CXXFLAGS |
warnings, optimization, language standard | C and C++ compiles |
CPPFLAGS |
-I include paths, -D macros |
both compiles |
LDFLAGS |
-L library paths |
the link |
LDLIBS |
-l libraries |
the end of the link line, after the objects |
CC also links is the trap for C++. %: %.o uses LINK.o, and LINK.o is $(CC), so a C++ program built entirely from built-in rules is compiled with c++ and linked with cc, which does not bring in the C++ standard library:
$ make
g++ -c -o hello.o hello.cpp
cc hello.o -o hello
/usr/bin/ld: hello.o: in function `main':
hello.cpp:(.text+0xa): undefined reference to `std::cout'
collect2: error: ld returned 1 exit status
make: *** [<builtin>: hello] Error 1
macOS fails the same way, as Undefined symbols for architecture x86_64. One line above the rule fixes it — LINK.o = $(CXX) $(LDFLAGS) $(TARGET_ARCH):
$ make
c++ -c -o hello.o hello.cpp
c++ hello.o -o hello
$ ./hello
Hello, Ada
Two makes, on two machines¶
/usr/bin/make on macOS is GNU Make 3.81 — make --version dates it 2006. Linux distributions ship 4.x: CI's Ubuntu 24.04 runner has 4.3 and Debian's gcc:14 image has 4.4.1, and Homebrew installs 4.4.1 on a Mac as gmake. Every verified block above prints the same bytes on all three. What differs:
| GNU Make 3.81, macOS | GNU Make 4.3 and 4.4.1, Linux | |
|---|---|---|
| Nothing to do | make: `hello' is up to date. |
make: 'hello' is up to date. |
| A recipe failed | make: *** [greet.o] Error 1 |
make: *** [Makefile:11: greet.o] Error 1 |
| A built-in recipe failed | make: *** [hello] Error 1 |
make: *** [<builtin>: hello] Error 1 |
.RECIPEPREFIX |
ignored | honoured |
CXX if unset |
c++ |
g++ |
| Timestamps compared to | the whole second | a fraction of a second |
In Error 1, the 1 is the exit status of the command that failed — the compiler's or the linker's — and make itself then exits with 2.
The last row is the one that bites. Build and edit within the same second, and 3.81 cannot see the edit:
$ make && touch greet.c && make
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
make: `hello' is up to date.
$ make && touch greet.c && make
cc -Wall -O2 -c hello.c
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
cc -Wall -O2 -c greet.c
cc -o hello hello.o greet.o
The filesystem knew better. In one of six such trials on the Mac, stat put greet.o at …891.253720030 and greet.c at …891.326716000, 73 ms later, and 3.81 compared only the …891: five of the six trials missed the edit, and the sixth straddled a second boundary and caught it. Ubuntu's 4.3 caught all six. A person at a keyboard is rarely that fast; a script, a test harness or a code generator is — which is why every script on this page waits a second before each edit.
If you are coming from another language¶
Rust. Cargo keeps the same graph and applies the same newer-than test to your own crate's files, blind spot included — but rustc writes the file list, in Makefile syntax, so there is no header to forget. The Rust library's Makefiles ↗ page shows it with Cargo's own output.
Python. A .pyc file in __pycache__/ is a target with one prerequisite, and it carries its own timestamp check: the header of a default .pyc records the modification time and size of its .py — on Python 3.14.7 they match the source's exactly — and the import system recompiles when either stops matching. Matching, not older: touch recompiles a module, as it rebuilds an object file, and so does an older copy put back over one — on 3.14.7 the .pyc took the restored file's 2020 timestamp and its new contents, where make said up to date. What Python lacks is the header trap: there is no textual #include, so no file is ever compiled against another file's text.
ABAP. (Not machine-checked — CI cannot run ABAP.) There is no Makefile because the repository holds the graph. Activating a changed dictionary structure also activates the dictionary objects built on it, and a program's generated load is invalidated when its source or a dictionary type it uses changes, then regenerated the next time the program runs — so the where-used list is a query the system answers, not a file somebody maintains.
See also¶
- Reading a real Makefile — thirty-one lines from a conference talk's repository that write their own rules
- Makefiles, in the Rust library ↗ — the same Makefile from the Rust side, and Cargo's dependency file shown line by line
- The GNU
makemanual ↗ — in particular phony targets ↗, automatic variables ↗, the built-in rules ↗, the variables they read ↗, and generating prerequisites automatically ↗