Skip to content

08 · Makefiles & Build Systems

Once a project has more than three source files, typing gcc a.c b.c c.c -o app gets old — and it recompiles everything even when you changed one line. make fixes both problems. You describe what depends on what, and make figures out the minimum set of commands needed to bring the build up to date, by comparing file timestamps.

make is decades old, present on every Unix system, and still the backbone of the Linux kernel, SQLite, and countless C projects. Even when a project uses CMake or Meson, those generate Makefiles underneath.

The anatomy of a rule

target: prerequisites
    recipe
  • target — the file to produce (or a name like clean).
  • prerequisites — files it depends on. If any is newer than the target, the target is stale.
  • recipe — shell commands to rebuild it. Must be indented with a real TAB, not spaces. This is the single most common Makefile error, and the message (missing separator) does not mention tabs.

A first Makefile

Assume the module layout from Module 7: main.c, counter.c, counter.h.

# Makefile
app: main.o counter.o
    gcc main.o counter.o -o app

main.o: main.c counter.h
    gcc -Wall -Wextra -c main.c -o main.o

counter.o: counter.c counter.h
    gcc -Wall -Wextra -c counter.c -o counter.o

clean:
    rm -f app main.o counter.o
$ make
gcc -Wall -Wextra -c main.c -o main.o
gcc -Wall -Wextra -c counter.c -o counter.o
gcc main.o counter.o -o app

$ make                    # nothing changed
make: 'app' is up to date.

$ touch counter.c && make # only the affected parts rebuild
gcc -Wall -Wextra -c counter.c -o counter.o
gcc main.o counter.o -o app

Running make with no arguments builds the first target in the file, so put your main artifact at the top. Note that touching counter.h would rebuild both object files — that's the dependency graph doing its job.

Variables kill the repetition

CC      := gcc
CFLAGS  := -Wall -Wextra -std=c11 -g
LDFLAGS :=
TARGET  := app
OBJS    := main.o counter.o stats.o

$(TARGET): $(OBJS)
    $(CC) $(OBJS) $(LDFLAGS) -o $(TARGET)

clean:
    rm -f $(TARGET) $(OBJS)
Assignment Behaviour
:= Evaluated once, immediately. Use this by default.
= Re-evaluated every time it's used (recursive; can surprise you)
?= Set only if not already defined — lets make CC=clang override it
+= Append

make CFLAGS="-O2" on the command line overrides the file's value entirely, which is how you switch to a release build without editing anything.

Pattern rules and automatic variables

Writing one rule per .o doesn't scale. A pattern rule covers them all:

CC      := gcc
CFLAGS  := -Wall -Wextra -std=c11 -g
TARGET  := app
SRCS    := $(wildcard *.c)          # every .c in this directory
OBJS    := $(SRCS:.c=.o)            # main.c counter.c -> main.o counter.o

.PHONY: all clean run

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) $^ -o $@

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

run: $(TARGET)
    ./$(TARGET)

clean:
    rm -f $(TARGET) $(OBJS)
Automatic variable Expands to
$@ The target being built (app, or main.o)
$< The first prerequisite (main.c)
$^ All prerequisites, deduplicated (main.o counter.o)
$? Only the prerequisites newer than the target
$* The stem matched by % (main for main.o)

.PHONY — targets that aren't files

clean doesn't produce a file called clean. If someone ever creates a file with that name, make clean would report "up to date" and do nothing. Declaring .PHONY: clean tells make to always run the recipe. Every non-file target (all, clean, run, test, install) should be listed.

Automatic header dependencies

The pattern rule above has a real bug: %.o: %.c never mentions headers, so editing counter.h won't trigger a rebuild — and you get a mysteriously stale binary. Generating the dependencies with the compiler fixes it permanently:

CC      := gcc
CFLAGS  := -Wall -Wextra -std=c11 -g
DEPFLAGS := -MMD -MP                 # emit a .d file listing every header used

SRCS := $(wildcard *.c)
OBJS := $(SRCS:.c=.o)
DEPS := $(SRCS:.c=.d)

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

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

-include $(DEPS)                     # leading '-' = ignore if not there yet

.PHONY: clean
clean:
    rm -f app $(OBJS) $(DEPS)

gcc -MMD writes main.d containing something like:

main.o: main.c counter.h stats.h

-include pulls those generated rules in, so make now knows exactly which headers each object depends on. -MP adds harmless empty targets for each header, so deleting a header doesn't break the build with "no rule to make target". These four extra characters eliminate an entire category of "it works after make clean" bugs.

Useful flags to have in CFLAGS

Flag Why
-Wall -Wextra Turn on the warnings that catch real bugs. Non-negotiable.
-std=c11 Pin the language standard so builds are reproducible
-g Debug symbols, so gdb can show source (Module 9)
-O0 / -O2 No optimization while debugging / optimized for release
-Werror Treat warnings as errors — good in CI, harsh while learning
-fsanitize=address,undefined Catch memory and UB bugs at run time
-pedantic Warn about non-standard extensions

A debug/release split, driven by one variable:

CFLAGS := -Wall -Wextra -std=c11

ifeq ($(BUILD),release)
    CFLAGS += -O2 -DNDEBUG
else
    CFLAGS += -O0 -g -fsanitize=address,undefined
    LDFLAGS += -fsanitize=address,undefined
endif
make                  # debug build with sanitizers
make BUILD=release    # optimized, asserts compiled out

Note -DNDEBUG in the release branch: that's what disables assert from Module 6.

A complete project Makefile

With sources in src/, headers in include/, and build output in build/:

CC       := gcc
CFLAGS   := -Wall -Wextra -std=c11 -g -Iinclude
DEPFLAGS := -MMD -MP

SRC_DIR   := src
BUILD_DIR := build
TARGET    := $(BUILD_DIR)/inventory

SRCS := $(wildcard $(SRC_DIR)/*.c)
OBJS := $(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)

.PHONY: all clean run

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) $^ -o $@

$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c | $(BUILD_DIR)
    $(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@

$(BUILD_DIR):
    mkdir -p $(BUILD_DIR)

run: $(TARGET)
    ./$(TARGET)

clean:
    rm -rf $(BUILD_DIR)

-include $(DEPS)

The | $(BUILD_DIR) is an order-only prerequisite: the directory must exist before compiling, but its timestamp (which changes whenever a file is added) must not make objects look stale. Without the |, every new file would trigger a full rebuild.

Useful make invocations

Command Effect
make Build the first target
make clean all Full rebuild
make -j8 Run up to 8 recipes in parallel — often a 4–8× speedup
make -n Dry run: print the commands without executing them
make -B Force rebuild, ignoring timestamps
make --debug=b Explain why each target was considered out of date

Beyond make

Makefiles are hand-written and platform-specific. Larger projects usually add a generator on top:

Tool What it is
CMake Describes the build abstractly; generates Makefiles, Ninja files, or IDE projects. The de-facto standard for cross-platform C/C++.
Ninja A very fast, deliberately dumb build executor. Almost always generated, not written by hand.
Meson Modern, readable syntax; generates Ninja files. Popular in the GNOME/GStreamer world.
Autotools The classic ./configure && make && make install. Powerful, portable, and painful.

Learn make first regardless — every one of these ultimately runs the same compile-and-link commands, and being able to read a Makefile tells you what any build is actually doing. Scaling builds across many modules and platforms is picked up again in Level 4.

How It Actually Works

make's entire decision procedure rests on one filesystem primitive: each file's modification timestamp (mtime), which the kernel maintains for every file and which make reads via the stat() system call before running any recipe. "Is app stale relative to main.o" is literally stat("app").mtime < stat("main.o").mtime — a single timestamp comparison, repeated across the whole dependency graph. This is exactly why touch counter.c && make triggers a rebuild without changing a single byte of content: touch only updates the mtime, and make has no way (nor any need) to know the content is identical — it trusts the timestamp completely, which is also why clock skew (a file copied with a timestamp in the past, or a networked filesystem with unsynchronized clocks) can make make either skip a rebuild it should do or redo one it doesn't need.

The -MMD -MP dependency-generation trick works because the compiler itself already builds the complete list of every header a .c file transitively includes — it has to, in order to preprocess the file at all — so -MMD just asks it to dump that list it already computed into a .d file in Makefile syntax, rather than throwing it away after compilation. -include $(DEPS) then splices those generated rules into make's own dependency graph, extending main.o's prerequisites to include every header it actually uses (transitively) rather than just the .c file named in the pattern rule — closing the exact gap that made %.o: %.c alone miss header changes.

-O0 versus -O2 changes what "the code" even means at the machine level, which is why gdb needs -O0 to make sense of a running program: an optimizing compiler is free to keep a variable in a register for its entire lifetime and never write it to its "normal" stack slot, eliminate a variable entirely if its value is never observed, or reorder instructions across source lines for pipeline efficiency. -g embeds a mapping (DWARF debug info) from machine addresses back to source lines and variable locations, but at -O2 that mapping often has to describe "this variable's value only exists in this register during these three instructions," which is why stepping through optimized code in a debugger frequently looks like it's skipping lines or reporting <optimized out> for a variable you expect to see.

Exercise

Take the Stack module from Module 7's exercise and give it a real build. Write a Makefile with CC/CFLAGS variables, a pattern rule for %.o: %.c, -MMD -MP dependency generation, and .PHONY targets all, clean, run, and test. Verify three things: (1) running make twice only builds once, (2) touch stack.h rebuilds both objects, and (3) make -n clean prints the rm command without deleting anything. Then add a BUILD=release branch that swaps -O0 -g for -O2 -DNDEBUG, and confirm with make -n BUILD=release that the flags actually change.