1

The Pain of Manual Compilation

Important Note On the terminal

The terminal will automatically, silently change directories for each step. This means you don’t have to worry about cding into the right directory — it’s done for you. But it also means when you start typing a command before you switch steps, the terminal will not save this even though it might look like it in the UI. You can copy & paste the beginning of a terminal command if you still need it when switching between steps.

Why this matters

Before you care how a Makefile works, you need to feel why it exists. Every build tool exists to solve a real pain — and you’ll appreciate Make’s design only after you’ve suffered through manual compilation. Let’s feel that pain first.

Prerequisites

You should be comfortable reading C source code at the level of “a function that takes parameters and returns a value.” You don’t need to know what static does or how pointers work — the C in this tutorial is deliberately tiny. If C is rusty, the C for C++ Programmers tutorial is a focused warm-up that complements this one.

You also need shell basics: cd, ls, running an executable. No prior Make exposure required.

Total time: ~60 min for all 7 chapters.

🎯 You will learn to

  • Apply gcc to compile a multi-file C project by hand
  • Analyze why manual recompilation does not scale beyond a handful of files

Task 1: Compile the project manually

We have a small C project with three files: main.c, math.c, and io.c — your terminal is already inside make_project/step1/ (check the prompt). Let’s compile them the hard way:

gcc main.c math.c io.c -o app

Oh no! The compilation failed. There is a syntax error in math.c.

Task 2: Fix the error and recompile

  1. Open math.c in the editor.
  2. Fix the missing semicolon at the end of the return statement.
  3. Save the file.
  4. Go back to the terminal and re-type the entire gcc command from scratch (don’t shortcut with Up arrow on this attempt — feel the friction of typing all three filenames 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.

📖 Yes, you can press Up arrow next time

Real shells let you scroll through history with the Up arrow. We made you re-type the command on purpose — the typing time is the lesson. In real projects, the typing time per command is small but the recompile time per command is huge, and the recompile time is what makes manual builds untenable. Once you’ve felt that, use Up arrow / Ctrl-R / shell aliases as much as you like.

Starter files
make_project/step1/main.c
#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;
}
make_project/step1/math.c
int add(int a, int b) {
    return a + b // BUG: missing semicolon
}
make_project/step1/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
2

Your First Makefile & The Tab Trap

Why this matters

A Makefile is just a list of rules describing a dependency graph — and learning the rule anatomy is the gateway to every other Make feature. But Make hides one infamous trap right at the start: recipe lines must be indented with a real Tab, not spaces. Stumbling into that trap once will save you hours of confusion later.

🎯 You will learn to

  • Apply Makefile rule syntax (target: prerequisites followed by an indented recipe)
  • Analyze the cryptic missing separator. Stop. error and recognize the Tab Trap
  • Apply sed -i to substitute leading spaces with a Tab character

The Anatomy of a Rule

Makefiles are made of rules that describe a dependency graph. A rule looks like this:

target: prerequisites
    recipe
  • Target: The file you want to build (e.g., your executable).
  • Prerequisites: The files the target depends on (e.g., your .c files).
  • Recipe: The shell command to create the target.

Make reads these rules, builds a graph of what depends on what, and only runs the recipes that are needed.

Task 1: Run your first Make command

A basic Makefile has been added to your project. Try running it:

make

Error! You should see: Makefile:2: *** missing separator. Stop.

Task 2: Fix the Tab Trap

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.

Starter files
make_project/step2/main.c
#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;
}
make_project/step2/math.c
int add(int a, int b) {
    return a + b;
}
make_project/step2/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
make_project/step2/Makefile
app: main.c math.c io.c
    gcc main.c math.c io.c -o app
3

Don’t Repeat Yourself (DRY) with Variables

Why this matters

A single-rule Makefile recompiles everything any time anything changes. To unlock incremental builds in later steps, you first need to split compilation into per-file rules — and the moment you do, duplication explodes. Variables are how Make lets you express the build configuration in one place and reuse it everywhere, so a compiler swap is one edit instead of four.

🎯 You will learn to

  • Apply Make variables (CC, CFLAGS) to eliminate repeated literals
  • Evaluate the trade-off between recursive (=) and simple (:=) variable assignment

Enabling Incremental Builds

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).

Task: Refactor using Variables

In Makefiles, you define variables at the top and reference them with $(VAR_NAME).

  1. Open Makefile.
  2. At the very top, define two variables (these are Make’s standard names for C builds):
    CC = gcc
    CFLAGS = -Wall -std=c11
    
  3. Replace all 4 instances of gcc with $(CC).
  4. Replace all 4 instances of -Wall -std=c11 with $(CFLAGS).
  5. Save the file and run make to confirm it still compiles successfully.
📖 `=` vs `:=` — recursive vs simple expansion

Make has two assignment operators. They look almost identical and behave very differently:

CC  = gcc                  # Recursive — re-evaluated every time CC is used
CC := gcc                  # Simple — evaluated once, at the moment of the assignment

The difference bites when one variable references another:

VERSION = 1.0
ARCHIVE = app-$(VERSION).tar.gz
VERSION = 2.0              # ARCHIVE expands to "app-2.0.tar.gz" because = is lazy

VERSION := 1.0
ARCHIVE := app-$(VERSION).tar.gz
VERSION := 2.0             # ARCHIVE is still "app-1.0.tar.gz" — captured at assignment time

Recursive (=) evaluates the right-hand side every time the variable is used; simple (:=) evaluates it once, at the assignment. Use := when you want a snapshot — especially for shell commands like $(shell date +%s) (you don’t want a different timestamp every time the variable is read).

For this tutorial we use = everywhere — the simpler one to learn first. In real-world Makefiles, := is often the safer default for anything that involves shell calls or builds incrementally on prior values.

Now a compiler change is a one-line edit at the top of the file.

Starter files
make_project/step3/main.c
#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;
}
make_project/step3/math.c
int add(int a, int b) {
    return a + b;
}
make_project/step3/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
make_project/step3/Makefile
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
4

Smarter Rules: Automatic Variables & Patterns

Why this matters

Three near-identical rules for main.o, math.o, and io.o is annoying at three files and unbearable at fifty. Pattern rules and automatic variables ($@, $<, $^) are Make’s mechanism for expressing “do the same thing for any matching pair” — they shrink your Makefile while letting it scale to arbitrary numbers of source files with no edits.

🎯 You will learn to

  • Apply automatic variables ($@, $<, $^) to eliminate filename repetition
  • Create a pattern rule (%.o: %.c) that compiles any source file
  • Analyze how an OBJS list combines with pattern rules to scale to N files

The Repetition Problem

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.

✏️ Predict before you read on

Make has three “automatic variables” that solve this. Their names use punctuation, not words. From the names alone, guess which one means what.

Given the rule app: main.o math.o io.o, what should each of these expand to inside the recipe?

  • $@ → ?
  • $< → ?
  • $^ → ?

Pick from: app · main.o · main.o math.o io.o · gcc. Commit to a mapping (you can guess from the punctuation — @ looks like a target, < looks like an arrow pointing into the rule, ^ looks like… something).

⚠️ Open after you've committed
  • $@app — the target (mnemonic: @ looks like the target reticule).
  • $<main.o — the first prerequisite (mnemonic: < is an arrow pointing into the rule from the left).
  • $^main.o math.o io.oall prerequisites (mnemonic: think “caret” → “carry-all”).

The most common bug: confusing $< with $^ in compile-vs-link rules. In a per-file rule (%.o: %.c), you want $< (single source). In the link rule (app: main.o math.o io.o), you want $^ (all objects). Hit the wrong one and you’ll either re-compile every file at link time ($^ in pattern rule) or link only the first object ($< in link rule).

Automatic Variables

Here’s the table — match it against your guesses above:

Variable Expands to
$@ The target name (left of the :)
$< The first prerequisite (first item after the :)
$^ All prerequisites

Pattern Rules

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.

Task: Refactor with OBJS, automatic variables, and a pattern rule

  1. At the very top (after CFLAGS), add an OBJS variable:
    OBJS = main.o math.o io.o
    
  2. Update the app rule to use $(OBJS) and the automatic variable $^ (all prereqs):
    app: $(OBJS)
    	$(CC) $(CFLAGS) $^ -o $@
    
  3. Delete the three explicit .o rules (main.o, math.o, io.o).
  4. Replace them with one pattern rule:
    %.o: %.c
    	$(CC) $(CFLAGS) -c $< -o $@
    
  5. Save and run 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.

Starter files
make_project/step4/main.c
#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;
}
make_project/step4/math.c
int add(int a, int b) {
    return a + b;
}
make_project/step4/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
make_project/step4/Makefile
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
5

The Magic of Incremental Builds

Why this matters

This is the payoff for everything you’ve built so far. Make’s timestamp-based dependency graph is what turns a multi-hour full rebuild into a few seconds of incremental work — and it’s the single feature that makes Make worth its quirks. You’ll watch Make skip work it doesn’t need to do, and learn the one footgun (header dependencies) that catches even seasoned C developers.

🎯 You will learn to

  • Analyze Make’s timestamp heuristic to predict which targets will rebuild
  • Apply touch to simulate a file edit and observe selective recompilation
  • Evaluate when implicit header dependencies will silently sabotage a build

The Core Idea: a Dependency Graph + Timestamps

Make’s central trick is brutally simple: it builds a dependency graph from your rules, then walks the graph comparing 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.

For our 3-file project, the graph Make builds from your Makefile looks like:

flowchart TD
    app["app"] --> mainO["main.o"]
    app --> mathO["math.o"]
    app --> ioO["io.o"]
    mainO --> mainC["main.c"]
    mathO --> mathC["math.c"]
    ioO --> ioC["io.c"]

When you run make, Make starts at the top (app), walks down to the leaves (.c files), and rebuilds any node whose timestamp is older than at least one of its prerequisites. Make is a graph algorithm, not a script.

📈 The graph on the right is your graph

Look at the Make DAG pane next to the editor — that’s not a static diagram from this tutorial, that’s the dependency graph computed live from your current Makefile in /tutorial/make_project/step5. Every time you edit the Makefile or run a make / touch command, the graph re-renders:

  • Solid green ✓ — target is up to date
  • Pulsing red ● — target is stale (make would rebuild it)
  • Dashed border — phony target (always considered stale)
  • Dashed arrow — order-only prerequisite

Click any node to jump to its rule in the Makefile. Use the Editor / Make DAG toggle at the top-right to flip between the two views.

This timestamp-on-a-DAG heuristic is what turns a 2-hour full rebuild into a 2-second incremental one.

Your new best friend: make -n (dry run)

Before we run any make command for real, let’s introduce dry-run mode — the single most useful Make flag for debugging build behavior:

make -n          # show what `make` would do, without running anything

-n (short for --dry-run) prints the recipe lines make would execute, but doesn’t run them. It’s read-only and risk-free. Use it whenever you’re about to type make and aren’t 100% sure what’s about to happen — especially before destructive commands like make clean install.

A close cousin is make --trace, which runs the build for real but also prints why each command runs (e.g. “target X is older than prerequisite Y”). Both flags surface the otherwise-invisible reasoning Make is doing.

Task 1: Check if up to date

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.

Task 2: Simulate a file change

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

✏️ Predict before you run make

You’re about to run make. Commit to a number, then run it.

How many gcc invocations will Make produce?

  • (a) 0 — touch doesn’t change content, so Make should skip everything.
  • (b) 1 — only math.cmath.o.
  • (c) 2 — math.cmath.o and the link step that produces app.
  • (d) 4 — Make plays it safe and rebuilds the whole project.
⚠️ Open after you've committed

The answer is (c). math.c is now newer than math.o, so Make recompiles it (1). That makes math.o newer than app, so Make also re-links (2). main.c and io.c are untouched, so their .o files stay valid and aren’t recompiled.

The trap is (a): “but the content didn’t change, so why rebuild?” Make doesn’t read file contents — it compares timestamps. From its point of view, “you touched this file” and “you edited this file” look identical. This is a feature, not a bug: a content-aware Make would have to checksum every file every build, which would be slow. Modern build tools like Bazel do checksum, paying that cost in exchange for false-positive immunity.

Task 3: Observe the magic

Run make one more time:

make

Look closely at the output! Make compiled math.cmath.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.

Task 4: Modify — try it on a different file

Now touch main.c and run make. Predict first: which files get recompiled this time? (Hint: the dependency graph hasn’t changed — only which leaf was touched.) Verify your prediction with make -n before running make — it’ll print the commands without executing them. Then run make for real and confirm make -n’s prediction matched what actually happened.

Then try touch Makefile and predict again, again checking with make -n first. (Surprise: the Makefile itself isn’t a prerequisite of any rule, so nothing rebuilds. The dependency graph is only what’s written between colons. make -n would print nothing.)

Task 5: Try --trace to see why

Reset to a known state, then re-run with --trace:

touch math.c
make --trace

Notice the extra lines like Makefile:7: target 'math.o' does not exist or target 'app' is older than prerequisite 'math.o'. --trace is what you reach for when make rebuilds something you didn’t expect and you can’t figure out which prerequisite tripped it. It prints the causal reason at every node.

Habit to build: when in doubt, make -n first. When make -n itself surprises you, escalate to make --trace. These two flags are your X-ray vision into the dependency graph — and you’ll reach for them often once you start writing real Makefiles.

⚠️ The classic dependency-tracking footgun: header-file changes

Make’s incremental rebuild only tracks the dependencies you tell it about. The Makefile says main.o: main.c — so editing main.c rebuilds main.o. But what if main.c does #include "math.h" and you edit math.h?

main.o will not rebuild. Your Makefile never told Make that main.o depends on math.h. The compiled object is now out of sync with the header it was built against — sometimes catastrophically (struct layout mismatches → silent memory corruption), sometimes obviously (compile errors at link time).

In real C/C++ projects, this is solved with auto-generated dependency files:

# gcc's -MMD flag emits .d files that list every header each .c includes
%.o: %.c
	$(CC) $(CFLAGS) -MMD -c $< -o $@

-include $(OBJS:.o=.d)   # pull in the generated .d files

We don’t do that here — it’s beyond essentials. But know: plain Makefiles silently miss header dependencies. If you ever wonder “why does my code segfault even though everything compiled?”, a stale .o against a changed .h is the #1 suspect. Always run make clean && make after pulling header changes from a teammate.

Starter files
make_project/step5/main.c
#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;
}
make_project/step5/math.c
int add(int a, int b) {
    return a + b;
}
make_project/step5/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
make_project/step5/Makefile
CC = gcc
CFLAGS = -Wall -std=c11
OBJS = main.o math.o io.o

app: $(OBJS)
	$(CC) $(CFLAGS) $^ -o $@

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@
6

The .PHONY Sabotage

Why this matters

Every real-world Makefile has command-style targets like clean, test, or install — and every one of them can silently break the day someone creates a file or directory with the same name. .PHONY is the one-line declaration that immunizes those targets, and seeing the sabotage in action is the only way to remember to use it.

🎯 You will learn to

  • Analyze why a same-named file on disk causes Make to skip a command target
  • Apply .PHONY to declare command targets that always run

Non-File Targets

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.

Task 1: Add a clean target

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!

Task 2: The Sabotage

Because Make assumes targets are files, what happens when a file actually named clean exists?

  1. Create a dummy file named clean:
    touch clean
    
  2. Run make app to generate the build files again.
  3. Try running 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.

Task 3: The Fix — .PHONY

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.

Task 4: Generalize — add an all phony target

One phony target is enough to learn the concept. Two is enough to generalize it: every real Makefile has multiple phony targets (clean, all, test, install, run). Conventionally they’re declared together on a single .PHONY: line.

Add a second phony target run that builds and executes the program. The convention for phony targets that depend on real ones is to list the prerequisites on the rule line:

.PHONY: clean run
run: app
	./app

Now make run will (1) build app if it’s out of date — Make follows the prerequisite graph — and (2) execute it. That’s the same .PHONY mechanism applied to a different command verb.

Don’t forget to also delete the dummy clean file you created in Task 2 (rm clean) — otherwise it sticks around forever.

⚠️ One recipe line, one shell — the cd trap

Before you write more complex recipes, lock in this rule: each recipe line runs in its own fresh shell. State doesn’t survive across lines.

That means a recipe like this doesn’t do what it looks like:

run: app
	cd build
	./app          # WRONG — `cd build` was in a different shell

The first line cd build runs in shell A and exits. The second line ./app starts shell B in the original working directory — cd from shell A had no effect on shell B. Your build-directory recipe will silently look for ./app in the wrong place.

The fix is to chain commands with && inside one shell line:

run: app
	cd build && ./app   # ✔ both commands share one shell

You’ll meet this trap the moment you start using subdirectories or environment variables (CFLAGS=-O2; gcc ... on two lines doesn’t export the flag). Make has a .ONESHELL: directive that flips the model — but treat that as an advanced override; the standard mental model is “one recipe line = one shell”.

Starter files
make_project/step6/main.c
#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;
}
make_project/step6/math.c
int add(int a, int b) {
    return a + b;
}
make_project/step6/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
make_project/step6/Makefile
CC = gcc
CFLAGS = -Wall -std=c11
OBJS = main.o math.o io.o

app: $(OBJS)
	$(CC) $(CFLAGS) $^ -o $@

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@
7

Mastering Make

Why this matters

Knowing each Make feature in isolation is not the same as knowing how they fit together. This synthesis step shows the entire Makefile in its final form — every concept from Steps 1–6 in ten lines — and points to the next gotcha you’ll meet when you scale beyond a single directory.

🎯 You will learn to

  • Evaluate a complete Makefile and explain how each feature contributes
  • Analyze when Recursive Make is appropriate versus harmful

You’ve mastered the essentials of Make! You can now:

  • Navigate the Tab Trap with confidence.
  • Use Variables for DRY (Don’t Repeat Yourself) builds.
  • Leverage Pattern Rules and Automatic Variables for scalable automation.
  • Understand the Incremental Build magic via the Dependency Graph.
  • Use .PHONY to create reliable command shortcuts.

Your debugging toolkit

Most Make problems aren’t syntax problems — they’re graph reasoning problems (“why did this rebuild?”, “why didn’t this rebuild?”, “why did -j break my build?”). These six flags are the X-ray machines that surface what Make is doing internally:

Flag What it does Reach for it when…
make -n (or --dry-run) Prints recipes without running them About to run an unfamiliar / risky make command
make --trace Runs and prints which prerequisite triggered each recipe A target rebuilt and you don’t know why
make -p Dumps Make’s internal database — every rule, variable, and pattern it knows about Wondering “is there an implicit rule fighting mine?”
make --warn-undefined-variables Warns when an undefined variable is referenced (typo catcher) Tracking down a typo like $(CFLAS) instead of $(CFLAGS)
make -j N Runs N recipes in parallel Speeding up a clean rebuild on a multi-core machine
make -j N --shuffle=random Parallel + randomized prerequisite order Stress-testing for missing prerequisites — see below

Memorize -n and --trace first; the rest you’ll meet on demand.

The --shuffle stress test

Here’s a deceptively important habit. After your Makefile seems to work, run:

make clean && make -j4 --shuffle=random

--shuffle=random randomizes the order in which Make picks prerequisites at each node. A correct Makefile produces the same result regardless of order; an incorrect one — one with missing prerequisite declarations — produces failures that look random. This is the cheapest way to surface “I forgot to declare that app depends on lib.o” bugs that hide silently when prerequisites happen to be processed in a lucky order. CI pipelines for serious build systems run this in their pre-merge checks for exactly this reason.

Going further: two ideas worth exploring

📖 Idea 1: Order-only prerequisites for build directories

Real projects don’t dump .o files next to source files — they put them in a build/ directory. The naive way to add a dir prerequisite causes Make to over-rebuild because directory timestamps update whenever a file is added. The fix is order-only prerequisites — listed after a | separator:

$(BUILD)/%.o: %.c | $(BUILD)
	$(CC) $(CFLAGS) -c $< -o $@

$(BUILD):
	mkdir -p $(BUILD)

The | $(BUILD) says: “this directory must exist before the recipe runs, but don’t rebuild me just because the directory’s timestamp changed.” This separates “must exist” from “must be newer.” It’s one of the highest-leverage tricks in real-world Makefiles.

📖 Idea 2: Auto-generated header dependencies (`-MMD`)

The footgun from Step 5 — header changes don’t trigger rebuilds — is solved in the real world with auto-generated .d files. Two changes:

CFLAGS = -Wall -std=c11 -MMD -MP        # gcc emits .d files alongside .o

-include $(OBJS:.o=.d)                   # pull them in (- means: don't error if missing)

The first time you compile, gcc’s -MMD flag writes out a .d file per .o containing all the headers each .c includes. The -include line pulls those into the Makefile on subsequent runs. Now make automatically knows that main.o depends on math.h — no manual maintenance.

-MP adds phony targets for each header so deleting a header doesn’t break the build. Both flags together are the production-grade way to handle C/C++ header dependencies.

Final Pro-Tip: Recursive Make

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.

Starter files
make_project/step7/main.c
#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;
}
make_project/step7/math.c
int add(int a, int b) {
    return a + b;
}
make_project/step7/io.c
#include <stdio.h>
void init_io() {
    printf("IO Initialized.\n");
}
make_project/step7/Makefile
CC = gcc
CFLAGS = -Wall -std=c11
OBJS = main.o math.o io.o

app: $(OBJS)
	$(CC) $(CFLAGS) $^ -o $@

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

.PHONY: clean run
run: app
	./app

clean:
	rm -f *.o app