We’re Officially Live — Lifetime Founding Membership Available for a Limited Time
Free preview

The Build Process & Makefiles

Automating compile-and-link and rebuilding only what changed: Make rules (target/prereq/recipe), pattern rules and variables, and auto-generated header dependencies that avoid stale builds.

22 min read

In this lesson:


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

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.o

Edit 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:

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 *.bin

Header 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 rules

Now 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


TL;DR

This is just the start

Sign up free to track progress on this lesson, get coding problems with the Run button, and unlock the full interview Q&A.

More in Debugging & Toolchain

The ToolchainLinker Scripts & Memory Sections
Debugging with GDB
On-Chip Debugging: JTAG & SWD
Breakpoints & Watchpoints
Logic Analyzer & Oscilloscope
The Build Process & Makefiles | EmbeddedPrep.io