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.
#include <stdio.h>
int 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
}
#include <stdio.h>
void init_io() {
printf("IO Initialized.\n");
}
1. What is the main problem with using gcc main.c math.c io.c -o app every time you fix a bug?
gcc has no memory — it blindly reprocesses every file you hand it. Fix one file? It still recompiles all three. In large projects, this means minutes-long rebuilds for single-line changes.
2. In a 500-file C project, you fix a typo in one file and rerun the same gcc command. How many files does gcc recompile?
gcc has no dependency tracking. It processes every file you list, every time. This is the core pain point that build tools like Make solve.
3. What key capability does Make have that raw gcc does NOT?
Make tracks file modification timestamps (and a dependency graph) to determine which targets are out of date. It only rebuilds what’s actually needed.
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!
target: prerequisites
[TAB]recipe
If you see 4 or 8 spaces, it will NOT work. Most GUI editors silently insert spaces when you press Tab — so you need to fix it in the terminal.
sed to the rescue. sed is a stream editor: it reads a file line by line, applies a substitution, and writes the result. The substitution syntax is s/pattern/replacement/:
# Replace the leading spaces on the recipe line with a real Tab:
sed -i 's/^ /\t/' Makefile
Breaking this down:
s/^ /\t/ — replace four leading spaces (^ ) with a tab character (\t)-i — edit the file in-place (overwrite it directly)Run cat -A Makefile after — recipe lines starting with ^I have a real Tab (^I is how cat -A displays the Tab character). Then run make again.
app: main.c math.c io.c
gcc main.c math.c io.c -o app
1. In a Makefile rule, what is the recipe?
A Makefile rule has three parts: target: prerequisites on the first line, then the recipe (the shell command) indented on the next line. The recipe is what actually runs to produce the target.
2. What error does Make print when recipe lines use spaces instead of a real Tab character?
Make’s parser uses a leading Tab character to identify recipe lines. Spaces look identical on screen but cause the cryptic missing separator error — one of Make’s most famous gotchas.
3. Which of the following correctly describes the three parts of a Makefile rule? (select all that apply)
A rule is target: prerequisites followed by a recipe on the next line. The recipe must use a literal Tab. Prerequisites can be in any order — Make builds a dependency graph from them.
4. The command sed -i 's/^ /\t/' Makefile is used to fix the Tab Trap. What does the s/^ /\t/ part mean?
sed’s s/pattern/replacement/ syntax substitutes the pattern with the replacement. ^ anchors the match to the start of the line, so only leading spaces are replaced — not spaces that appear elsewhere on the line. \t is the escape sequence for a real Tab character. The -i flag makes the edit in-place.
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
1. What is the correct syntax to expand a Makefile variable named CFLAGS inside a recipe?
Make uses $(VAR) or ${VAR} to expand variables. $(CFLAGS) is the standard convention. Note that %CFLAGS% is Windows CMD syntax and has no meaning in Make.
2. You define CC = gcc at the top of your Makefile and use $(CC) in all four recipes. You want to switch to clang. How many lines must you edit?
This is the DRY (Don’t Repeat Yourself) principle in action. All four recipes reference $(CC), so changing CC = gcc to CC = clang updates every recipe at once.
3. Which of the following are benefits of using CC and CFLAGS variables in a Makefile? (Select all that apply)
(select all that apply)
Variables provide a single point of change for repeated values. They do NOT affect build speed or the Tab requirement — those are separate concerns entirely.
4. In the rule app: $(OBJS), which part is the target?
Even when using variables like $(OBJS), the basic Rule structure remains target: prerequisites. Everything to the left of the colon is the target (what you want to build).
5. [Interleaved: Revisit Step 1] What is the core problem that Make solves compared to running a manual gcc command on all files?
As we felt in Step 1, manual compilation is slow because it rebuilds everything. Make’s superpower is its ability to track changes and only run necessary commands.
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
1. In a Makefile recipe, what does $@ expand to?
$@ always expands to the target name. In app: $(OBJS), using $@ in the recipe gives you app. Think: @ looks like a target symbol.
2. The pattern rule %.o: %.c with recipe $(CC) $(CFLAGS) -c $< -o $@ compiles math.c. What do $< and $@ expand to?
$< is the first prerequisite (here, math.c) and $@ is the target (here, math.o). The % wildcard matches the common stem math, so %.o becomes math.o and %.c becomes math.c.
3. After replacing explicit .o rules with one pattern rule, which of the following are true? (Select all that apply)
(select all that apply)
The pattern rule %.o: %.c handles any .c→.o compilation automatically. Adding newfile.c to OBJS is all you need — no new rule required. $^ gives all prerequisites (all .o files for the app rule), and $< gives the first prerequisite (the .c file for each pattern match).
4. You use %.o: %.c and $(CC) $(CFLAGS) -c $< -o $@. You get makefile:10: *** missing separator. Stop. What is the most likely cause?
The ‘missing separator’ error is Make’s cryptic way of saying it found spaces where it expected a Tab. This remains the #1 cause of build failures, even in advanced professional Makefiles.
5. [Interleaved: Revisit Step 2] What invisible character is required at the start of every recipe line in a Makefile?
As you discovered in Step 2, the ‘Tab Trap’ is real! Make strictly requires a literal Tab character to identify recipe lines. Spaces will cause a ‘missing separator’ error.
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.
1. How does Make decide whether to rebuild a target file?
Make compares modification timestamps. If a prerequisite (e.g. math.c) is newer than the target (e.g. math.o), the target is considered out of date and its recipe runs. This simple heuristic enables powerful incremental builds.
2. You run touch math.c (without changing its content) then immediately run make. What does Make do?
touch updates a file’s timestamp, making it look newer than its dependent targets. Make sees math.c is newer than math.o, recompiles just that one file, then re-links app since math.o changed. main.o and io.o are untouched.
3. After a successful build with no changes, you run make again. What message appears and why?
When all targets are newer than their prerequisites, Make prints make: 'app' is up to date and does nothing. This is the incremental build in action — skipping all work when nothing needs rebuilding.
4. [Interleaved: Revisit Step 3] What is the correct syntax to reference a variable named CC inside a Makefile recipe?
As we practiced in Step 3, Make uses either parentheses ( ) or curly braces { } to expand variables. Both are technically correct, though $(CC) is the more common convention.
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.
1. What is the primary purpose of .PHONY?
.PHONY tells Make to ignore any files on disk with the same name as the target. This ensures that commands like make clean always run, even if a file named clean happens to exist.
2. What happens if a target name (like test) matches a directory name in your project, but is NOT declared .PHONY?
By default, Make looks for a file OR directory matching the target name. If a directory named test exists and has no dependencies, Make thinks its job is done. .PHONY: test forces it to run the recipe regardless.
3. How can you use Phony targets to bundle multiple independent builds together?
The conventional all target is usually a phony target that depends on every program you want to build. Running make all triggers all those prerequisites in sequence (or parallel).
4. Why is it generally a bad idea to make a real file target (like app) depend on a .PHONY target?
Because a Phony target is NEVER up-to-date, any real file that depends on it will also be considered out-of-date every time. This forces constant, unnecessary recompilation.
5. [Interleaved: Revisit Step 4] In the pattern rule %.o: %.c, which automatic variable expands to the target (the .o file)?
As we used in Step 4, $@ is the target (think ‘@’ = ‘at the target’). $< is the first prerequisite (the .c file).
You’ve mastered the essentials of Make! You can now:
As your projects grow, you might be tempted to put a Makefile in every subdirectory and call make -C subdir from a top-level Makefile. This is known as Recursive Make.
[!WARNING] Recursive Make is often considered harmful. It breaks the global visibility of the dependency graph, which can lead to subtle bugs where files aren’t recompiled when they should be. For larger projects, consider modern alternatives or a single, “non-recursive” top-level Makefile that includes sub-makefiles.