BUILD: Makefile Introduction — Automating C and C++ Builds

Makefiles have powered C and C++ builds for nearly 50 years. By defining targets, dependencies, and commands, Make rebuilds only what has changed, dramatically reducing compile times. From simple projects to embedded firmware, Makefiles remain an essential tool in every systems developer's toolkit.

Every C or C++ project must answer the same fundamental question: given a set of source files, how do you compile them into a working program, and how do you avoid recompiling everything after every small change? The make utility and its associated Makefile format answered this question in 1976 and have remained essential tools ever since.


A Brief History of Make

In the early days of software development, especially in C and UNIX environments, developers had to manually recompile source files whenever changes occurred. This was tedious and error-prone: change one header file, and suddenly dozens of source files need to be recompiled — but which ones?

Stuart Feldman created the make utility in 1976 to solve this problem. It analyzes file timestamps and dependency relationships to determine exactly which files need to be rebuilt. Make became a standard tool on UNIX systems and transformed software development by:

  • Automating the compilation process
  • Rebuilding only what changed
  • Making builds reproducible
  • Documenting the build process in a human-readable format

What Is a Makefile?

A Makefile is a plain text file that defines rules for building a project. It tells make:

  • What files to build (targets)
  • What they depend on (dependencies)
  • How to build them (commands)
  • When to rebuild them (by comparing timestamps)

Basic Rule Structure

make
target: dependencies
	command to build the target

Critical: Commands must be indented with a tab character, not spaces. This is a syntactic requirement of Make and one of the most common sources of frustration for beginners.

text
Rule Anatomy:
target: dep1 dep2 dep3
<TAB>command1
<TAB>command2
  ^
  This MUST be a tab, not spaces


A Complete Simple Example

Consider a project with two C source files:

c
// main.c
#include "utils.h"
int main() {
    hello();
    return 0;
}

c
// utils.c
#include <stdio.h>
void hello() {
    printf("Hello, Make!\n");
}

c
// utils.h
void hello();

The Makefile for this project:

make
CC = gcc
CFLAGS = -Wall

all: main

main: main.o utils.o
	$(CC) $(CFLAGS) -o main main.o utils.o

main.o: main.c utils.h
	$(CC) $(CFLAGS) -c main.c

utils.o: utils.c utils.h
	$(CC) $(CFLAGS) -c utils.c

clean:
	rm -f *.o main

Run the build:

bash
make         # builds the 'all' target (default)
make clean   # removes generated files
make main.o  # builds only main.o


How Make Determines What to Rebuild

Make uses file timestamps to decide what needs rebuilding:

text
Dependency Resolution:
main depends on main.o and utils.o
  main.o depends on main.c and utils.h
  utils.o depends on utils.c and utils.h

If utils.h changes:
  -> main.o must be rebuilt (it includes utils.h)
  -> utils.o must be rebuilt (it includes utils.h)
  -> main must be rebuilt (its dependencies changed)

If only utils.c changes:
  -> utils.o must be rebuilt
  -> main must be rebuilt
  -> main.o does NOT need to be rebuilt

This incremental build behavior is the core value of Make: in a large project, only the affected parts are recompiled.


Variables in Makefiles

Variables allow you to define values once and reuse them throughout:

make
# Compiler and flags
CC = gcc
CXX = g++
CFLAGS = -Wall -O2 -std=c11
CXXFLAGS = -Wall -O2 -std=c++17

# Directories
SRC_DIR = src
OBJ_DIR = build
INC_DIR = include

Variables are referenced with $(VARIABLE_NAME):

make
all: main

main: main.o utils.o
	$(CC) $(CFLAGS) -o $@ $^

Automatic Variables

Make provides special automatic variables for use within rules:

VariableMeaning
$@The target name of the current rule
$<The first dependency
$^All dependencies (unique)
$?All dependencies newer than the target
$*The stem of a pattern rule

make
main: main.o utils.o
	$(CC) -o $@ $^
# $@ = main
# $^ = main.o utils.o


Pattern Rules

Pattern rules use % as a wildcard to avoid repeating similar rules for every source file:

make
# This rule compiles any .c file into a .o file
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@
# $< = the .c file (first dependency)
# $@ = the .o file (target)

With this pattern rule, you do not need a separate rule for main.o, utils.o, parser.o, etc. Any .c file will be compiled to .o automatically.


Phony Targets

A phony target is a target that does not represent a real file. It is used for commands like clean, test, or install:

make
.PHONY: all clean test install

clean:
	rm -f *.o main

test: main
	./run_tests.sh

install: main
	cp main /usr/local/bin/

The .PHONY declaration ensures that Make always runs these targets even if a file with the same name exists.


Intermediate and Advanced Features

Linking System Libraries

make
LIBS = -lm -lpthread

main: main.o
	$(CC) $(CFLAGS) -o $@ $^ $(LIBS)

Automatic Source File Discovery

Use wildcard and patsubst to automatically find source files:

make
SRC_DIR = src
OBJ_DIR = build

SRCS = $(wildcard $(SRC_DIR)/*.c)
OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
	$(CC) $(CFLAGS) -Iinclude -c $< -o $@

  • wildcard: Expands a glob pattern into a list of matching files
  • patsubst: Pattern-substitutes one set of filenames for another

Automatic Header Dependency Generation

A common challenge: if you change a header file, Make needs to know which .c files include it. The compiler can generate this information:

make
# Generate .d dependency files alongside .o files
%.d: %.c
	$(CC) -M $(CFLAGS) $< > $@

# Include the generated dependency files
-include $(SRCS:.c=.d)

The -include (with a leading dash) silently ignores missing .d files on first build.


Professional Project Structure

Directory Layout

text
project/
├── Makefile
├── src/
│   ├── main.c
│   └── utils.c
├── include/
│   └── utils.h
└── build/
    └── (generated .o files)

Professional Makefile

make
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -O2
INC_DIR = include
SRC_DIR = src
OBJ_DIR = build

SRCS = $(wildcard $(SRC_DIR)/*.c)
OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))
TARGET = $(OBJ_DIR)/main

.PHONY: all clean test

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $@ $^

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c
	@mkdir -p $(OBJ_DIR)
	$(CC) $(CFLAGS) -I$(INC_DIR) -c $< -o $@

clean:
	rm -rf $(OBJ_DIR)

test: $(TARGET)
	./run_tests.sh


Recursive Make for Multi-Module Projects

For projects with multiple subdirectories, each with its own Makefile:

make
SUBDIRS = core drivers tests

all:
	for dir in $(SUBDIRS); do \
	    $(MAKE) -C $$dir; \
	done

clean:
	for dir in $(SUBDIRS); do \
	    $(MAKE) -C $$dir clean; \
	done

$(MAKE) -C <dir> invokes make in the specified subdirectory.


Makefiles in Embedded Systems

Makefiles are particularly well-suited to embedded systems development, where cross-compilers and custom linker scripts are common:

make
# Cross-compilation for ARM Cortex-M3
CC = arm-none-eabi-gcc
OBJCOPY = arm-none-eabi-objcopy
CFLAGS = -mcpu=cortex-m3 -mthumb -std=c11 -Wall -O2 -Iinclude -ffunction-sections

TARGET = firmware
OBJS = main.o startup.o drivers/gpio.o

# Build ELF binary
$(TARGET).elf: $(OBJS)
	$(CC) $(CFLAGS) -Tlinker.ld -Wl,--gc-sections -o $@ $^

# Convert to binary for flashing
$(TARGET).bin: $(TARGET).elf
	$(OBJCOPY) -O binary $< $@

# Flash to the device
flash: $(TARGET).bin
	st-flash write $(TARGET).bin 0x8000000

.PHONY: flash clean


When to Move Beyond Make

Make is excellent for embedded systems and smaller C/C++ projects. For larger projects or when cross-platform support is critical:

ToolBest For
GNU MakeSmall-to-medium C/C++ projects, embedded systems
CMakeCross-platform projects, generates Makefiles or Ninja builds
NinjaLarge projects where build speed is critical
MesonModern alternative with better syntax than Make
BazelVery large monorepos with multiple languages

The choice of CMake + Ninja is increasingly common in professional C++ development, with CMake handling the project description and Ninja providing the fast parallel build execution.


Best Practices

text
Makefile Best Practices:
1. Always declare phony targets with .PHONY
2. Use $(CC) and $(CXX), never hardcode compiler paths
3. Always use -Wall -Werror for production builds
4. Keep a clean target, document it
5. Use separate build directories for object files
6. Break large Makefiles into includes: config.mk, rules.mk
7. Document non-obvious targets with comments
8. Use pattern rules to avoid duplication


Conclusion

Makefiles remain one of the most efficient and transparent build tools available. They are lightweight, universally available, and give you complete control over the build process. For embedded systems, bare-metal firmware, or any C/C++ project where simplicity and control matter more than cross-platform portability, a well-written Makefile is often the best choice.

The combination of targets, dependencies, variables, pattern rules, and phony targets gives you everything you need to build complex projects incrementally, correctly, and efficiently.