C · lesson 23 of 25
Makefiles for C Projects
Write Makefiles for C projects: rules, variables, pattern rules, automatic header dependencies, .PHONY targets and static libraries, run for real.
By SkillByExample · Updated
Every command below was run with GNU Make on Linux against a small real project. The Makefiles are shown exactly as used.
Rules, and Why make Skips Work
A rule says how to build a target from its prerequisites. The recipe lines must start with a tab character. Given this hello.c:
#include <stdio.h>
int main(void) { printf("hello from make\n"); return 0; }
and this Makefile next to it:
hello: hello.c
gcc -Wall -Wextra -std=c17 -o hello hello.c
clean:
rm -f hello
$ make
gcc -Wall -Wextra -std=c17 -o hello hello.c
$ ./hello
hello from make
$ make
make: 'hello' is up to date.
$ touch hello.c
$ make
gcc -Wall -Wextra -std=c17 -o hello hello.c
$ make clean
rm -f hello
Running make with no target builds the first rule. The second make did nothing because hello was newer than hello.c. Timestamps are make’s entire decision rule, which is why touch alone was enough to force a rebuild. make echoes each command before running it, so you always see what happened.
Here is Makefile.spaces, the same rule with the recipe indented by four spaces instead of a tab:
hello: hello.c
gcc -Wall -Wextra -std=c17 -o hello hello.c
$ make -f Makefile.spaces
Makefile.spaces:2: *** missing separator. Stop.
“Missing separator” is make’s unhelpful way of saying “this line isn’t a rule and isn’t a tab-indented recipe”. Configure your editor to keep real tabs in Makefiles.
Variables: =, :=, ?=, +=
The four assignment operators differ in when the value is worked out and whether the caller can override it:
BASE = -Wall
LAZY = $(BASE) -O2
EAGER := $(BASE) -O2
BASE = -Wextra
CFLAGS ?= -std=c17
CFLAGS += -g
show:
@echo "LAZY = $(LAZY)"
@echo "EAGER = $(EAGER)"
@echo "CFLAGS = $(CFLAGS)"
$ make
LAZY = -Wextra -O2
EAGER = -Wall -O2
CFLAGS = -std=c17 -g
$ make CFLAGS=-O3
LAZY = -Wextra -O2
EAGER = -Wall -O2
CFLAGS = -O3
$ CFLAGS=-O1 make
LAZY = -Wextra -O2
EAGER = -Wall -O2
CFLAGS = -O1 -g
LAZY (=) picked up the later value of BASE, because it is expanded when used, not when defined. EAGER (:=) captured -Wall at its definition. The two overrides behave differently. A command-line CFLAGS=-O3 beats every assignment in the file, including the +=, so -g was dropped. An environment variable only fills in where ?= allows it, and the file’s += still appended -g. The @ before echo stops make from printing the command itself.
A Real Multi-File Project
The project has three source files:
/* src/stats.h */
#ifndef STATS_H
#define STATS_H
double mean(const double *xs, int n);
double max_of(const double *xs, int n);
#endif
/* src/stats.c */
#include "stats.h"
double mean(const double *xs, int n) {
double sum = 0;
for (int i = 0; i < n; i++) sum += xs[i];
return n ? sum / n : 0;
}
double max_of(const double *xs, int n) {
double m = xs[0];
for (int i = 1; i < n; i++) if (xs[i] > m) m = xs[i];
return m;
}
/* src/main.c */
#include <stdio.h>
#include "stats.h"
int main(void) {
double temps[] = {21.5, 23.0, 19.5, 25.0};
printf("mean %.2f, max %.1f\n", mean(temps, 4), max_of(temps, 4));
return 0;
}
This Makefile at the project root builds it with a pattern rule, automatic variables, a separate build directory and automatic header tracking:
CC := gcc
CFLAGS := -Wall -Wextra -std=c17 -g
CPPFLAGS := -MMD -MP
LDLIBS :=
TARGET := stats
SRC_DIR := src
BUILD_DIR := build
SRCS := $(wildcard $(SRC_DIR)/*.c)
OBJS := $(SRCS:$(SRC_DIR)/%.c=$(BUILD_DIR)/%.o)
DEPS := $(OBJS:.o=.d)
.PHONY: all clean run
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $^ $(LDLIBS) -o $@
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c | $(BUILD_DIR)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(BUILD_DIR):
mkdir -p $@
run: $(TARGET)
./$(TARGET)
clean:
rm -rf $(BUILD_DIR) $(TARGET)
-include $(DEPS)
$ make
mkdir -p build
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/main.c -o build/main.o
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
$ make run
./stats
mean 22.25, max 25.0
$ make
make: Nothing to be done for 'all'.
The pattern rule $(BUILD_DIR)/%.o: $(SRC_DIR)/%.c covers every source file. In its recipe, $< is the first prerequisite (the .c file) and $@ is the target (the .o file). In the link rule, $^ is all the prerequisites, meaning every object file. The | $(BUILD_DIR) is an order-only prerequisite: the directory must exist first, but its changing timestamp never forces a rebuild. $(wildcard ...) means a new .c file in src/ is picked up with no Makefile edit.
Rebuilding Only What Changed
Here’s what one edited source file costs, compared with one edited header:
$ touch src/stats.c
$ make
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
$ touch src/stats.h
$ make
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/main.c -o build/main.o
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
$ cat build/main.d
build/main.o: src/main.c src/stats.h
src/stats.h:
Touching stats.c recompiled only stats.o and relinked. main.o was reused, which is the whole point of separate compilation. Touching stats.h rebuilt both objects, because both include it. The pattern rule never mentions headers. That knowledge came from -MMD, which made GCC write build/main.d as a side effect of compiling, and -include $(DEPS) pulled those rules into the Makefile. The leading - makes a missing .d (on the very first build) harmless. -MP adds the empty src/stats.h: rule, so deleting a header produces a normal rebuild instead of a “no rule to make target” error.
Dry Runs and Parallel Builds
$ make -n clean
rm -rf build stats
$ make clean
rm -rf build stats
$ make -j4
mkdir -p build
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/main.c -o build/main.o
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
make -n prints what would run without running it, which is a safe way to check a clean rule before trusting it. make -j4 runs up to four recipes at once. The two compiles are independent and may run in parallel, but the link always waits for both, because the dependency graph says so. A Makefile with correct prerequisites parallelizes for free. One with missing prerequisites breaks randomly under -j.
Target-Specific Variables Don’t Force a Rebuild
A common pattern adds a debug target that changes the flags:
.PHONY: debug
debug: CFLAGS += -DDEBUG -O0
debug: all
$ make clean
rm -rf build stats
$ make
mkdir -p build
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/main.c -o build/main.o
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
$ make debug
make: Nothing to be done for 'debug'.
The new flags never reached the compiler. make decides what to rebuild from timestamps alone, and the objects were already newer than their sources. Flags aren’t part of that check. Run make clean before switching build flavours, or give each flavour its own build directory (for example BUILD_DIR := build/debug) so the objects never mix.
Building and Linking a Static Library
A static library is an archive of object files that the linker copies code from. This second project puts the same stats.c in src/, stats.h in include/, and a small program app.c at the top:
/* app.c */
#include <stdio.h>
#include "stats.h"
int main(void) {
double xs[] = {1, 2, 3, 10};
printf("mean %.2f\n", mean(xs, 4));
return 0;
}
Its Makefile:
CC := gcc
CFLAGS := -Wall -Wextra -std=c17 -Iinclude
LIB := libstats.a
OBJS := $(patsubst src/%.c,build/%.o,$(wildcard src/*.c))
app: app.c $(LIB)
$(CC) $(CFLAGS) app.c -L. -lstats -o $@
$(LIB): $(OBJS)
ar rcs $@ $^
build/%.o: src/%.c
@mkdir -p build
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
rm -rf build $(LIB) app
$ make
gcc -Wall -Wextra -std=c17 -Iinclude -c src/stats.c -o build/stats.o
ar rcs libstats.a build/stats.o
gcc -Wall -Wextra -std=c17 -Iinclude app.c -L. -lstats -o app
$ ./app
mean 4.00
$ ar t libstats.a
stats.o
$ nm libstats.a
stats.o:
000000000000006f T max_of
0000000000000000 T mean
make worked backwards from app. It needed libstats.a, which needed build/stats.o, so it ran the three recipes in dependency order. ar rcs means replace members, create the archive if needed, and write a symbol index (s). nm shows the two functions the archive exports (T = defined in the text section). -Iinclude tells the compiler where stats.h is, -L. tells the linker where to look for libraries, and -lstats means libstats.a.
Why .PHONY Matters
A target name that happens to match a file name is treated as that file. Take this Makefile, which has no .PHONY:
clean:
rm -f *.o
@echo cleaned
$ make clean
rm -f *.o
cleaned
$ touch clean
$ make clean
make: 'clean' is up to date.
Once a file called clean existed, make saw a target with no prerequisites that already existed, so it declared it up to date and skipped the recipe. Adding .PHONY: clean at the top fixes it:
.PHONY: clean
clean:
rm -f *.o
@echo cleaned
$ make clean
rm -f *.o
cleaned
Declare every action-style target (all, clean, run, test, install) as .PHONY.
Practice
- Add a
testtarget to the stats project that builds the program and checks its output, failing the build if the output is wrong.
Solution
Add to the project Makefile (and add test to .PHONY):
test: $(TARGET)
./$(TARGET) | grep -q "mean 22.25" && echo "PASS" || (echo "FAIL"; exit 1)
$ make test
./stats | grep -q "mean 22.25" && echo "PASS" || (echo "FAIL"; exit 1)
PASS
A recipe fails when its shell command exits non-zero, and make then stops with an error. That’s what lets a CI job run make test and trust its exit status.
- Build the stats project with
-O2instead of-gwithout editing the Makefile.
Solution
$ make clean >/dev/null
$ make CFLAGS="-Wall -Wextra -std=c17 -O2"
mkdir -p build
gcc -MMD -MP -Wall -Wextra -std=c17 -O2 -c src/main.c -o build/main.o
gcc -MMD -MP -Wall -Wextra -std=c17 -O2 -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
A command-line variable overrides the := in the file. The dependency flags survived because they live in a separate CPPFLAGS variable. Had they been appended to CFLAGS, this override would have silently switched header tracking off. The make clean first matters for the same reason as the debug target: otherwise the existing objects are “up to date”.
- What does
make -Bdo to the stats project when nothing has changed? Predict, then check.
Solution
$ make -B
mkdir -p build
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/main.c -o build/main.o
gcc -MMD -MP -Wall -Wextra -std=c17 -g -c src/stats.c -o build/stats.o
gcc build/main.o build/stats.o -o stats
-B treats every target as out of date, even the build directory, so mkdir runs again too. That makes it the one-shot equivalent of make clean && make, and the quick fix for the target-specific-flags problem above, but it throws away the incremental builds that make exists to provide, so it isn’t a habit to rely on.