Makefiles and build systems
Automate C project builds with make — writing Makefile rules, using pattern rules and variables, and only recompiling changed files.
- Write a Makefile with explicit rules for a multi-file C project
- Use automatic variables like $@, $<, and $^ in rules
- Define phony targets like clean and all
- Explain how make determines which files need recompilation
When a project has ten source files and you change one, you do not want to recompile all ten. make reads a Makefile that describes which files depend on which other files and how to build them. It then recompiles only the files whose source has changed since the last build.
The anatomy of a Makefile rule
target: prerequisites
recipetarget: the file to build (or a phony name likeallorclean).prerequisites: files that must be up to date before the target can be built.recipe: the shell command(s) to build the target. Must be indented with a tab, not spaces.
A simple Makefile
For a project with main.c, vector.c, and vector.h:
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGET = myprogram
$(TARGET): main.o vector.o
$(CC) $(CFLAGS) main.o vector.o -o $(TARGET)
main.o: main.c vector.h
$(CC) $(CFLAGS) -c main.c -o main.o
vector.o: vector.c vector.h
$(CC) $(CFLAGS) -c vector.c -o vector.o
.PHONY: clean
clean:
rm -f *.o $(TARGET)Running make builds myprogram. Running make clean removes all build artefacts.
make checks timestamps: if vector.c was modified after vector.o, it recompiles vector.o. If only main.c changed, only main.o and the final link are redone.
Automatic variables
Typing filenames explicitly everywhere is tedious and error-prone. Make provides automatic variables:
| Variable | Meaning |
|---|---|
$@ | The target name |
$< | The first prerequisite |
$^ | All prerequisites |
$* | The stem of a pattern rule |
Rewrite:
$(TARGET): main.o vector.o
$(CC) $(CFLAGS) $^ -o $@
main.o: main.c vector.h
$(CC) $(CFLAGS) -c $< -o $@
vector.o: vector.c vector.h
$(CC) $(CFLAGS) -c $< -o $@Pattern rules
Instead of writing a rule for every .o file, use a pattern rule:
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@% matches any stem. This one rule handles all .c to .o compilations. Combine with a variable listing all objects:
CC = gcc
CFLAGS = -Wall -Wextra -g
TARGET = myprogram
SRCS = main.c vector.c
OBJS = $(SRCS:.c=.o)
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
rm -f $(OBJS) $(TARGET)$(SRCS:.c=.o) replaces .c with .o in SRCS — a Make substitution reference.
Handling header dependencies
The pattern rule above recompiles main.o if main.c changes, but not if vector.h changes. Fix with explicit per-file dependencies or automatic dependency generation:
# Explicit (manageable for small projects):
main.o: main.c vector.h
vector.o: vector.c vector.h
# Automatic (better for larger projects):
DEPFLAGS = -MMD -MP
DEPS = $(OBJS:.o=.d)
%.o: %.c
$(CC) $(CFLAGS) $(DEPFLAGS) -c $< -o $@
-include $(DEPS)-MMD -MP makes gcc generate a .d dependency file listing which headers each .o depends on. -include reads those files, automatically adding the right prerequisites.
Useful targets to add
.PHONY: all clean test valgrind
all: $(TARGET)
test: $(TARGET)
./run_tests.sh
valgrind: $(TARGET)
valgrind --leak-check=full ./$(TARGET)cmake and meson are the modern alternatives. For large projects with cross-platform requirements, CMake or Meson generates platform-specific build files from a higher-level description. Learning make first is worthwhile — it is used in millions of projects and CMake generates Makefiles on Linux anyway.
Where to go next
Next: the multi-file project lab — you will split a previous project across multiple files, write the header and Makefile, and confirm that incremental builds work correctly.
static and extern
Control symbol visibility across C translation units — using static to restrict visibility to one file and extern to share across files.
Lab: Multi-file project with a Makefile
Split a C program into multiple files with a proper header and build it with a Makefile — practising the full compilation model workflow.