A Real-World Project Makefile
Lesson 6: A Real-World Project Makefile
Let's assemble everything so far into a Makefile that wouldn't look out of place in a serious C project: out-of-tree builds, automatic dependencies, user-overridable install paths, a test target, and tidy cleanup. Then we'll dissect each part.
The Project Layout
project/
โโโ Makefile
โโโ src/
โ โโโ main.c
โ โโโ util.c
โ โโโ util.h
โโโ tests/
โโโ test_util.c
The Makefile
# ---- User-overridable configuration ----
CC ?= cc
CFLAGS ?= -O2 -g
PREFIX ?= /usr/local
CFLAGS += -Wall -Wextra -MMD -MP
# ---- Source discovery ----
SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)
DEPS := $(OBJS:.o=.d)
BIN := bin/app
.PHONY: all clean test install uninstall
all: $(BIN)
$(BIN): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -o $@ $^
# Compile any src/*.c into its .o; -MMD -MP emits a .d dep file
%.o: %.c
$(CC) $(CFLAGS) -c {{LESSON_CONTENT}}lt; -o $@
# ---- Tests: link the test binary against the same objects ----
TEST_BIN := bin/test_util
$(TEST_BIN): tests/test_util.c $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -o $@ $^
test: $(TEST_BIN)
./$(TEST_BIN)
# ---- Install / uninstall ----
install: $(BIN)
install -Dm755 $(BIN) $(DESTDIR)$(PREFIX)/bin/$(notdir $(BIN))
uninstall:
rm -f $(DESTDIR)$(PREFIX)/bin/$(notdir $(BIN))
clean:
rm -rf bin $(OBJS) $(DEPS)
# ---- Pull in compiler-generated header dependencies ----
-include $(DEPS)
Dissecting It, Piece by Piece
| Line | Why it's there |
|---|---|
CC ?= cc | ?= sets a default that a user can override (make CC=clang). |
CFLAGS ?= -O2 -g then CFLAGS += ... | Provide a sane default, then always append warnings and dep-generation flags. |
SRCS := $(wildcard src/*.c) | Auto-discover sources โ add a file, no Makefile edit. |
OBJS := $(SRCS:.c=.o) | Substitution reference: the same list with .o endings. |
@mkdir -p $(dir $@) | @ silences the echo; $(dir $@) extracts bin/ from the target path. |
$(BIN): $(OBJS) | Link only after every object is built โ the graph handles ordering. |
test: $(TEST_BIN) | Building the test binary is a prerequisite of running it. make test always tests a fresh build. |
install -Dm755 ... | -D creates parent dirs; m755 sets executable mode; DESTDIR supports packaging (staged installs). |
-include $(DEPS) | Loads the auto-generated header dependencies (Lesson 5), tolerating their absence on a clean build. |
Why It Works: The Graph in Action
Edit util.h โ both objects rebuild (via the .d files) โ app relinks. Edit only main.c โ just main.o and the link. Run make -j8 and independent objects compile concurrently. That's the payoff.
Going Further: Layering and Includes
As projects grow, developers split configuration out of the main file:
# Makefile
include config.mk # version numbers, feature toggles
-include local.mk # optional per-developer overrides
Make's include works like C's #include: the included file's rules and variables merge into the same build. You can even generate files and include them (Lesson 7).
make -C subdir) for building: a sub-make can't see the parent's up-to-date state, so you lose cross-directory incremental builds and parallelism suffers. Keep one Makefile per project (or use include) unless you have a strong reason โ this is one of the most-debated topics in Make land.
๐ง Knowledge Check
1. What does CC ?= cc accomplish?
2. In the real-world Makefile, why does the test target have $(TEST_BIN) as a prerequisite?
3. Why is "recursive make" (sub-makes per directory) discouraged?