Build Systems

Makefiles

Make is the original C build tool, created in 1976. STM32CubeIDE uses it internally. Understanding Makefiles makes you a better embedded engineer — even if you primarily use CMake and Ninja day-to-day.


The Golden Rule

TABs — not spaces

The indentation before every recipe line MUST be a real TAB character (\t). If you use spaces, Make will fail with missing separator. This is the most common Make error.


Anatomy of a Makefile

A Makefile is made of rules. Each rule has three parts:

makefile
# target: dependency1 dependency2 ...
# <TAB>recipe

hello: main.c
	gcc main.c -o hello
  • Target — the file to produce, or a phony action name
  • Dependencies — files the target depends on
  • Recipe — the shell command to run (indented with TAB)

How Make Decides What to Rebuild

Make compares timestamps: if a dependency is newer than its target, the target is stale and Make re-runs the recipe.

text
main.c  modified at  10:05
main.o  created at   10:00   → main.o is STALE → recompile
main.o  created at   10:10   → main.o is UP TO DATE → skip

This is why a 3-minute full build becomes a 2-second incremental build after changing one file.


Example 1 — Single File (Desktop)

makefile
# Simplest possible Makefile
CC     = gcc
CFLAGS = -Wall -O2

hello: main.c
	$(CC) $(CFLAGS) main.c -o hello

clean:
	rm -f hello
bash
make          # Builds hello
make clean    # Removes hello
make hello    # Explicitly build the hello target

Example 2 — Multiple Files with Object Files (Desktop)

Compiling each .c file into a .o first, then linking, is faster because Make can skip unchanged files.

makefile
CC      = gcc
CFLAGS  = -Wall -O2 -Iinclude
TARGET  = sensor_app

# List all object files
OBJS = main.o sensor.o uart.o

# Link: final binary depends on all object files
$(TARGET): $(OBJS)
	$(CC) $(OBJS) -o $(TARGET)

# Pattern rule: compile ANY .c file into a .o file
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Phony targets are not real files
.PHONY: clean all

clean:
	rm -f $(OBJS) $(TARGET)

all: $(TARGET)

Automatic Variables

VariableMeaning
$@The target name (left side of :)
$<The first dependency (first item right of :)
$^All dependencies
$*The stem matched by % in a pattern rule

Example 3 — Multi-Directory Project (Desktop Library)

Real projects separate source files, headers, and build output into different directories.

text
project/
├── include/
│   ├── sensor.h
│   └── uart.h
├── src/
│   ├── main.c
│   ├── sensor.c
│   └── uart.c
├── build/
└── Makefile
makefile
CC      = gcc
CFLAGS  = -Wall -O2 -Iinclude
TARGET  = build/sensor_app

SRC_DIR = src
OBJ_DIR = build

# Auto-discover all .c files in src/
SRCS = $(wildcard $(SRC_DIR)/*.c)

# Replace src/*.c with build/*.o
OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))

# Final binary
$(TARGET): $(OBJS)
	$(CC) $(OBJS) -o $(TARGET)

# Compile each .c to build/*.o, creating build/ if needed
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
	@mkdir -p $(OBJ_DIR)
	$(CC) $(CFLAGS) -c $< -o $@

.PHONY: clean
clean:
	rm -rf $(OBJ_DIR)

Example 4 — Building a Static Library

Packaging your code as a static library .a so other projects can link against it.

makefile
CC  = gcc
AR  = ar
CFLAGS = -Wall -O2 -Iinclude

# Build object file
sensor.o: src/sensor.c
	$(CC) $(CFLAGS) -c src/sensor.c -o sensor.o

# Archive into a static library
libsensor.a: sensor.o
	$(AR) rcs libsensor.a sensor.o

# Link main app against the library
app: src/main.c libsensor.a
	$(CC) $(CFLAGS) src/main.c -L. -lsensor -o app

.PHONY: clean
clean:
	rm -f *.o *.a app

Example 5 — STM32 Cross-Compilation Makefile

This is the style of Makefile STM32CubeIDE generates. You can run it from the terminal without the IDE.

makefile
TARGET   = my_project
CPU      = -mcpu=cortex-m4
FPU      = -mfpu=fpv4-sp-d16 -mfloat-abi=hard
LDSCRIPT = STM32F411RETx_FLASH.ld

CC  = arm-none-eabi-gcc
AS  = arm-none-eabi-as
CP  = arm-none-eabi-objcopy
SZ  = arm-none-eabi-size

C_DEFS = -DSTM32F411xE -DUSE_HAL_DRIVER -DDEBUG

C_INCLUDES = \
  -ICore/Inc \
  -IDrivers/STM32F4xx_HAL_Driver/Inc \
  -IDrivers/CMSIS/Device/ST/STM32F4xx/Include \
  -IDrivers/CMSIS/Include

CFLAGS = $(CPU) $(FPU) $(C_DEFS) $(C_INCLUDES) \
         -Os -Wall -fdata-sections -ffunction-sections \
         -g -gdwarf-2

LDFLAGS = $(CPU) $(FPU) \
          -specs=nano.specs \
          -T$(LDSCRIPT) \
          -lc -lm -lnosys \
          -Wl,-Map=$(TARGET).map,--cref \
          -Wl,--gc-sections

# Source files
C_SOURCES = Core/Src/main.c \
            Core/Src/stm32f4xx_it.c \
            Core/Src/stm32f4xx_hal_msp.c \
            Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c \
            Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.c \
            Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_uart.c

# Object files in build/
OBJECTS = $(addprefix build/, $(notdir $(C_SOURCES:.c=.o)))
vpath %.c $(sort $(dir $(C_SOURCES)))

all: build/$(TARGET).elf build/$(TARGET).bin

# Compile C files
build/%.o: %.c | build
	$(CC) -c $(CFLAGS) $< -o $@

# Link ELF
build/$(TARGET).elf: $(OBJECTS)
	$(CC) $(OBJECTS) $(LDFLAGS) -o $@
	$(SZ) $@

# Convert ELF to flashable BIN
build/$(TARGET).bin: build/$(TARGET).elf
	$(CP) -O binary $< $@

build:
	mkdir -p build

# Flash via OpenOCD (ST-LINK)
flash: build/$(TARGET).bin
	openocd -f interface/stlink.cfg \
	        -f target/stm32f4x.cfg \
	        -c "program build/$(TARGET).bin verify reset exit 0x08000000"

.PHONY: clean flash
clean:
	rm -rf build

Build and flash:

bash
make -j$(nproc)   # Build using all CPU cores
make flash        # Flash via ST-LINK

Example 6 — ESP32 Cross-Compilation (Manual Makefile)

ESP-IDF normally uses CMake+Ninja, but understanding a raw Makefile for ESP32 shows you what idf.py does under the hood.

makefile
IDF_PATH ?= $(HOME)/esp/esp-idf
TARGET   = esp32
CC       = xtensa-esp32-elf-gcc

CFLAGS = \
  -mlongcalls \
  -mtext-section-literals \
  -falign-functions=4 \
  -Os -Wall -g \
  -DESP_PLATFORM \
  -DIDF_VER=\"v6.0\" \
  -I$(IDF_PATH)/components/freertos/FreeRTOS-Kernel/include \
  -I$(IDF_PATH)/components/esp_common/include \
  -Imain

main.o: main/main.c
	$(CC) $(CFLAGS) -c main/main.c -o main.o

# Note: In practice, use idf.py build.
# This is shown for educational understanding only.

Useful Make Flags

bash
make -j$(nproc)      # Build in parallel (use all CPU cores) — fastest
make -j4             # Build with 4 parallel jobs
make -n              # Dry run — print commands without executing
make -B              # Force rebuild everything (ignore timestamps)
make V=1             # Verbose mode — show full compiler commands
make clean all       # Clean then rebuild in one command
make CFLAGS="-O0 -g" # Override a variable from the command line

Makefile Quick Reference

FeatureSyntax
VariableCC = gcc
Use variable$(CC)
Pattern rule%.o: %.c
All .c files$(wildcard src/*.c)
Replace extension$(patsubst %.c, %.o, $(SRCS))
Add prefix$(addprefix build/, $(OBJS))
Phony target.PHONY: clean all
Target name$@
First dep$<
All deps$^
Parallelmake -j$(nproc)
Previous
Introduction