Skip to main content
C intermediate Lesson 21 of 23

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.

VariableMeaning
$@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.

Frequently Asked Questions

Why does make say 'Nothing to be done for all'?
make checks file modification timestamps. If the target file exists and is newer than all its dependencies, make considers it up to date and skips rebuilding. Delete the target or touch a source file to force a rebuild.
What does .PHONY mean?
.PHONY declares targets that are not actual files — like 'clean', 'all', 'install'. Without it, make would skip the target if a file with that name happened to exist in the directory.
What is the difference between := and = in Makefiles?
= is recursively expanded — the variable is expanded each time it is used, which allows self-referential definitions but can cause unexpected results. := is simply expanded — evaluated once when the variable is defined. Prefer := for performance and predictability.