Learn how to write Makefiles, understand dependency graphs, and automate your C builds through hands-on practice in an interactive terminal.
Welcome to the Makefiles Tutorial!
Before you care how a Makefile works, you need to feel why it exists. Every build tool exists to solve a real pain. Let’s feel that pain first.
We have a small C project with three files: main.c, math.c, and io.c. Let’s compile them the hard way:
cd make_project
gcc main.c math.c io.c -o app
Oh no! The compilation failed. There is a syntax error in math.c.
math.c in the editor.return statement.gcc command to try compiling again.Notice what just happened: to fix one file, you had to recompile all three. gcc has no memory — it blindly reprocesses everything you hand it. In a 500-file project, fixing a single typo means a minutes-long recompile of every untouched file. We need a smarter tool.
#includeint add(int a, int b); void init_io(); int main() { init_io(); printf("Math test: 2 + 3 = %d\n", add(2, 3)); return 0; }
int add(int a, int b) {
return a + b // BUG: missing semicolon
}
#includevoid init_io() { printf("IO Initialized.\n"); }
Makefiles are made of rules that describe a dependency graph. A rule looks like this:
target: prerequisites
recipe
.c files).Make reads these rules, builds a graph of what depends on what, and only runs the recipes that are needed.
A basic Makefile has been added to your project. Try running it:
make
Error! You should see: Makefile:2: *** missing separator. Stop.
Makefiles have one notoriously strict, invisible rule: Recipes MUST be indented with a true Tab character, not spaces!
In many text editors (like the one on the right) it is often hard to replace spaces with tabs. So for this task you will need to be a bit more creative. Hint: terminal-based editors like nano or vi might help or the sed command!
app: main.c math.c io.c
gcc main.c math.c io.c -o app
Our single-rule Makefile still recompiles everything together. To let Make skip unchanged files, we must compile each .c file into an object file (.o) separately, then link the .o files into the final executable.
Look at the new Makefile. It does this — but notice the problem: gcc -Wall -std=c11 is hardcoded four times. If we ever switch to clang, we’d have to edit four lines. This violates the DRY principle (Don’t Repeat Yourself).
In Makefiles, you define variables at the top and reference them with $(VAR_NAME).
Makefile.CC = gcc
CFLAGS = -Wall -std=c11
gcc with $(CC).-Wall -std=c11 with $(CFLAGS).make to confirm it still compiles successfully.Now a compiler change is a one-line edit at the top of the file.
app: main.o math.o io.o gcc -Wall -std=c11 main.o math.o io.o -o app main.o: main.c gcc -Wall -std=c11 -c main.c math.o: math.c gcc -Wall -std=c11 -c math.c io.o: io.c gcc -Wall -std=c11 -c io.c
Look at your current Makefile. The three .o rules are almost identical:
main.o: main.c
$(CC) $(CFLAGS) -c main.c
math.o: math.c
$(CC) $(CFLAGS) -c math.c
io.o: io.c
$(CC) $(CFLAGS) -c io.c
Each filename appears twice per rule. With 50 source files you’d have 50 nearly identical rules. There must be a better way.
Make provides special variables that expand to parts of the current rule being evaluated:
| Variable | Expands to |
|---|---|
$@ |
The target name (left of the :) |
$< |
The first prerequisite (first item after the :) |
$^ |
All prerequisites |
A pattern rule uses % as a wildcard to match any filename stem:
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
This single rule tells Make: “to build any .o file, compile the matching .c file.” It replaces all three of your explicit .o rules.
CFLAGS), add an OBJS variable:
OBJS = main.o math.o io.o
app rule to use $(OBJS) and the automatic variable $^ (all prereqs):
app: $(OBJS)
$(CC) $(CFLAGS) $^ -o $@
.o rules (main.o, math.o, io.o).%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
make to confirm it still builds correctly.Your Makefile shrinks from 14 lines to 8 — and it handles any number of source files with zero changes to the rules.
CC = gcc CFLAGS = -Wall -std=c11 app: main.o math.o io.o $(CC) $(CFLAGS) main.o math.o io.o -o app main.o: main.c $(CC) $(CFLAGS) -c main.c math.o: math.c $(CC) $(CFLAGS) -c math.c io.o: io.c $(CC) $(CFLAGS) -c io.c
Make’s central trick is brutally simple: it compares the last-modified timestamp of each target against its prerequisites. If a prerequisite is newer than the target, the target is out of date and Make runs its recipe. Otherwise, it skips it.
This timestamp heuristic is what turns a 2-hour full rebuild into a 2-second incremental one.
Run make right now:
make
Make should tell you: make: 'app' is up to date. It skipped all work because the .o files and app are all newer than the .c files.
The touch command updates a file’s timestamp without changing its content — it tricks Make into thinking you just edited it.
Run this to “update” only math.c:
touch math.c
Run make one more time:
make
Look closely at the output! Make compiled math.c → math.o and then re-linked app. It completely skipped main.c and io.c. They were still up to date — so Make left them alone. In a massive codebase this is the difference between waiting seconds and waiting hours.
Make is fundamentally about building files. But sometimes we want a target that just runs a command — like cleaning up build artifacts. There’s no output file; you just want the action.
Add this to the very bottom of your Makefile:
clean:
rm -f *.o app
Run make clean in the terminal. Your build artifacts are gone!
Because Make assumes targets are files, what happens when a file actually named clean exists?
touch clean
make app to generate the build files again.make clean.It fails! Make says make: 'clean' is up to date. It finds the file named clean, sees it has no prerequisites, decides it’s already “built,” and does nothing.
We must tell Make that clean is a phony target — a command name, not a filename.
Right above the clean: target, add:
.PHONY: clean
Save and run make clean again. Even though a file named clean exists, Make ignores it and correctly removes your build files.