Makefiles for C Projects
Learn Makefile syntax, variables, pattern rules, phony targets, and how to manage multi-file C projects with make.
Makefile Basics
A Makefile automates compilation by describing the relationship between source files and build outputs. Without it, you must manually type every compiler command and remember which files changed. Make tracks file timestamps and only rebuilds what is out of date, making large projects much faster to iterate on.
A Makefile consists of rules with this structure:
target: dependencies
recipe
The recipe must be indented with a tab, not spaces. This is the most common Makefile beginner mistake.
# Simplest possible Makefile
hello: hello.c
gcc -Wall -std=c11 -o hello hello.c
clean:
rm -f hello
Run with make (builds hello) or make clean.
Variables
Variables reduce repetition and make it easy to change the compiler or flags in one place. Simply expanded variables (:=) are evaluated once when defined; recursively expanded variables (=) are re-evaluated every time they are used.
# Simply expanded (:=) — evaluated once at definition time
CC := gcc
CFLAGS := -Wall -Wextra -std=c11 -g
LDFLAGS :=
LDLIBS :=
# Recursive (=) — evaluated each time the variable is used
# Useful when the value depends on other variables defined later
SOURCES = $(wildcard src/*.c)
OBJECTS = $(SOURCES:.c=.o)
TARGET := myprogram
# Appending to an existing variable
CFLAGS += -Wpedantic
LDLIBS += -lm
# Conditional assignment (?=) — only sets if not already defined
# Lets callers override: make BUILD_DIR=out
BUILD_DIR ?= build
Automatic Variables
Inside a recipe, make provides automatic variables that expand to parts of the current rule. They eliminate the need to repeat file names and make pattern rules possible.
| Variable | Meaning |
|---|---|
$@ | The target name |
$< | The first dependency |
$^ | All dependencies (deduplicated) |
$* | The stem matched by % in a pattern rule |
$(@D) | Directory part of $@ |
$(@F) | File part of $@ |
# Without automatic variables — repetitive and error-prone:
main.o: main.c main.h
gcc -Wall -std=c11 -c main.c -o main.o
# With automatic variables — $< is the first dep, $@ is the target:
main.o: main.c main.h
$(CC) $(CFLAGS) -c $< -o $@
Pattern Rules
Pattern rules match multiple targets with a single rule using % as a wildcard. This is how you compile every .c file to a .o file without writing one rule per source file.
CC := gcc
CFLAGS := -Wall -Wextra -std=c11 -g
# Compile any .c file into a .o file in one rule
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
Complete Multi-File Project Makefile
This production-quality Makefile handles automatic dependency tracking with -MMD -MP — the compiler generates .d files listing each source file’s header dependencies, so changing a header triggers recompilation of all files that include it.
# ─── Configuration ────────────────────────────────────────────────────────────
CC := gcc
CFLAGS := -Wall -Wextra -Wpedantic -std=c11 -g
CFLAGS += -MMD -MP # auto-generate .d dependency files
LDFLAGS :=
LDLIBS := -lm
TARGET := myprogram
SRC_DIR := src
BUILD_DIR := build
# ─── Source Discovery ─────────────────────────────────────────────────────────
SRCS := $(wildcard $(SRC_DIR)/*.c)
OBJS := $(SRCS:$(SRC_DIR)/%.c=$(BUILD_DIR)/%.o)
DEPS := $(OBJS:.o=.d)
# ─── Default Target ───────────────────────────────────────────────────────────
.PHONY: all
all: $(TARGET)
# ─── Link ─────────────────────────────────────────────────────────────────────
$(TARGET): $(OBJS)
$(CC) $^ $(LDFLAGS) $(LDLIBS) -o $@
@echo "Built: $@"
# ─── Compile (with auto-created build directory) ──────────────────────────────
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c | $(BUILD_DIR)
$(CC) $(CFLAGS) -c $< -o $@
# ─── Create build directory if it doesn't exist ───────────────────────────────
$(BUILD_DIR):
mkdir -p $@
# ─── Auto-generated dependencies (header changes trigger recompilation) ───────
-include $(DEPS)
# ─── Utility Targets ──────────────────────────────────────────────────────────
.PHONY: clean
clean:
rm -rf $(BUILD_DIR) $(TARGET)
.PHONY: rebuild
rebuild: clean all
.PHONY: run
run: all
./$(TARGET)
.PHONY: debug
debug: CFLAGS += -DDEBUG -O0
debug: all
.PHONY: release
release: CFLAGS := -Wall -Wextra -std=c11 -O2 -DNDEBUG
release: clean all
.PHONY: valgrind
valgrind: all
valgrind --leak-check=full --show-leak-kinds=all ./$(TARGET)
.PHONY: asan
asan: CFLAGS += -fsanitize=address,undefined
asan: LDFLAGS += -fsanitize=address,undefined
asan: all
./$(TARGET)
.PHONY: help
help:
@echo "Available targets:"
@echo " all - Build the project (default)"
@echo " clean - Remove build artifacts"
@echo " rebuild - Clean and build"
@echo " run - Build and run"
@echo " debug - Build with debug defines"
@echo " release - Build optimized release"
@echo " valgrind - Run under Valgrind"
@echo " asan - Build and run with AddressSanitizer"
Library Projects
Static libraries (.a) bundle multiple object files into a single archive that is linked at compile time. Shared libraries (.so) are loaded at runtime and can be shared between multiple programs.
Building a static library:
CC := gcc
CFLAGS := -Wall -std=c11 -g
AR := ar
ARFLAGS := rcs
LIB_NAME := libmylib.a
SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)
$(LIB_NAME): $(OBJS)
$(AR) $(ARFLAGS) $@ $^ # rcs: replace, create, use index
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
install: $(LIB_NAME)
cp $(LIB_NAME) /usr/local/lib/
cp include/*.h /usr/local/include/
.PHONY: clean
clean:
rm -f $(OBJS) $(LIB_NAME)
Building a shared library:
CC := gcc
CFLAGS := -Wall -std=c11 -fPIC # -fPIC required: position-independent code
LIB := libmylib.so.1.0
$(LIB): $(OBJS)
$(CC) -shared -Wl,-soname,libmylib.so.1 -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
Makefile for Tests
Running tests as a make target ensures tests are always built against the latest code and run with the same sanitizer flags used during development.
CC := gcc
CFLAGS := -Wall -std=c11 -g -fsanitize=address,undefined
LDFLAGS := -fsanitize=address,undefined
SRC_DIR := src
TEST_DIR := tests
# Exclude main.c so it doesn't conflict with the test runner's main
SRCS := $(filter-out $(SRC_DIR)/main.c, $(wildcard $(SRC_DIR)/*.c))
TEST_SRC := $(wildcard $(TEST_DIR)/*.c)
TEST_BIN := $(TEST_DIR)/run_tests
$(TEST_BIN): $(TEST_SRC) $(SRCS)
$(CC) $(CFLAGS) $^ $(LDFLAGS) -o $@
.PHONY: test
test: $(TEST_BIN)
./$(TEST_BIN)
.PHONY: clean
clean:
rm -f $(TEST_BIN)
Useful make Flags
make -j4 # parallel build using 4 jobs — speeds up large projects
make -n # dry run — print commands without executing them
make -B # unconditionally rebuild all targets
make V=1 # verbose output (if Makefile supports it)
make CFLAGS="-O2" # override a variable from the command line
For large projects, consider CMake or Meson which generate Makefiles (or Ninja build files) and handle cross-platform builds automatically. For small to medium projects, a well-written Makefile is often all you need.