In this lesson:
- what a build system does and why incremental builds matter
- the anatomy of a Make rule: target, prerequisites, recipe
- pattern rules, variables, and automatic variables
- auto-generated header dependencies (the fix for stale builds)
- an embedded Makefile, and the gotchas (tabs, missing deps,
-j)
The problem: don't rebuild everything
A real project has dozens to hundreds of .c files. Recompiling them all when you change one line wastes minutes. A build system automates the compile→link steps and rebuilds only what changed. The classic tool is Make, driven by a Makefile.
The model maps onto the toolchain: each .c compiles to a .o independently, then all .o files link into the ELF. Change one .c → recompile just its .o → relink. That's an incremental build.
A Make rule
A Makefile is a set of rules:
target: prerequisites
recipe # MUST be tab-indented, not spaces- target, what to build (a file, usually).
- prerequisites, what it depends on.
- recipe, the shell commands to build it.
The rule: Make rebuilds the target if it's missing or any prerequisite is newer than it (by file timestamp). So:
main.o: main.c
arm-none-eabi-gcc -c main.c -o main.o # runs only if main.c is newer than main.oEdit main.c → its timestamp updates → main.o is out of date → Make recompiles it (and anything that depends on main.o). Untouched files are skipped. That timestamp comparison is the incremental build.
Variables, pattern rules, automatic variables
Writing a rule per file doesn't scale. Variables and pattern rules generalize:
CC := arm-none-eabi-gcc
CFLAGS := -mcpu=cortex-m4 -mthumb -O2 -g
OBJS := main.o uart.o startup.o
# Pattern rule: how to make ANY %.o from the matching %.c
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# ^ ^
# $< $@ (automatic variables)The automatic variables fill in per target:
$@, the target (main.o)$<, the first prerequisite (main.c)$^, all prerequisites
Phony targets are names that aren't files, declared .PHONY so Make always runs them:
.PHONY: all clean
all: firmware.bin
clean:
rm -f *.o *.elf *.binHeader dependencies: the stale-build trap
Here's the subtle, important one. The pattern rule above says main.o depends on main.c, but main.c includes uart.h. If you change uart.h, Make sees main.c's timestamp unchanged and won't recompile main.o, you get a stale build that uses the old header, with baffling bugs (a struct changed size but half the code still uses the old layout).
The fix: have the compiler emit dependency files and include them. gcc -MMD writes a main.d listing every header main.c pulled in; Make includes those so header changes trigger rebuilds:
CFLAGS += -MMD -MP # generate main.d with header prerequisites
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
-include $(OBJS:.o=.d) # pull in the generated dependency rulesNow editing uart.h recompiles every .o that includes it. Auto-generated dependencies are essential, without them, incremental builds are silently wrong on header changes (the #1 "but I rebuilt it!" bug).
A small embedded Makefile
CC := arm-none-eabi-gcc
OBJCOPY := arm-none-eabi-objcopy
CFLAGS := -mcpu=cortex-m4 -mthumb -O2 -g -ffunction-sections -fdata-sections -MMD -MP
LDFLAGS := -T stm32f4.ld -nostartfiles -Wl,--gc-sections
OBJS := main.o uart.o startup.o
.PHONY: all clean flash
all: firmware.bin
firmware.elf: $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) $^ -o $@ # link
arm-none-eabi-size $@ # report sizes (fits flash/RAM?)
firmware.bin: firmware.elf
$(OBJCOPY) -O binary $< $@ # ELF → flashable image
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
flash: firmware.elf
openocd -f board.cfg -c "program $< verify reset exit" # next lessons
clean:
rm -f *.o *.d *.elf *.bin
-include $(OBJS:.o=.d)make builds incrementally; make flash programs the board; make clean resets. The whole toolchain (compile, link, objcopy, flash) is captured as a dependency graph.
Beyond Make
Make is foundational but verbose for big projects. CMake generates build files (Makefiles or Ninja) from higher-level descriptions and handles cross-compilation toolchain files; Ninja is a fast low-level build executor; vendor IDEs wrap their own. Understanding Make makes all of them legible, they're solving the same dependency-graph problem.
Gotchas
- Recipes need a TAB, not spaces. Make's most infamous error (
*** missing separator) is an indented recipe line that used spaces. Recipe commands must start with a literal tab. - Missing header dependencies → stale builds. Without
-MMD/-include, changing a header doesn't recompile dependents, so you run old code against a new header, subtle, dangerous bugs. Always generate and include.dfiles. - Declare
.PHONYfor non-file targets.all,clean,flasharen't files; if a file namedcleanever exists, Make would skip the recipe..PHONYforces them to always run. - Parallel builds (
-j) expose missing deps.make -j8runs rules concurrently; if your dependencies are incomplete, you get race conditions and intermittent failures. Correct, complete dependencies are required for parallel correctness. - Trust a clean build in CI. Incremental builds can mask problems (a deleted file's stale
.ostill linked). CI should do a clean build from scratch so the committed sources actually build correctly.
TL;DR
- A build system automates compile→link and rebuilds only what changed; Make uses a Makefile of rules and timestamp-based incremental builds.
- A rule is
target: prerequisites+ a tab-indented recipe; Make rebuilds a target when a prerequisite is newer, the heart of incremental builds. - Scale with variables, pattern rules (
%.o: %.c), and automatic variables ($@,$<,$^); mark non-file targets.PHONY. - Auto-generate header dependencies with
gcc -MMD -MPand-includethe.dfiles, without this, changing a header doesn't trigger rebuilds and you get stale, wrong builds. - Watch the tab-vs-spaces recipe error, missing dependencies (especially with
-jparallel builds), and always do a clean build in CI; CMake/Ninja sit on top of this same dependency-graph idea.