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

LineWhy 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.
Key idea: this Makefile is data, not a script. The developer declares sources, flags, and targets; Make derives ordering, parallelism, and incremental behavior. Note how few commands are actually written โ€” most lines are declarations.

Why It Works: The Graph in Action

bin/app src/main.o src/util.o src/main.c src/util.c src/util.h dashed = header dep (.d files, Lesson 5)

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

Avoid recursive make (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?

Further Reading