Skip to main content

Build Systems: make Introduction and the Compile/Link Cycle

What This Concept Is

make automates the compile/link cycle. You describe:

  • targets - files (or phony names) you want produced
  • prerequisites - files the target depends on
  • recipes - shell commands that rebuild the target when any prerequisite is newer

make uses file-modification times to decide what must be rebuilt. That is the whole model. Everything else is notation on top of it.

A minimal Makefile:

CC      = gcc
CFLAGS = -Wall -Wextra -std=c11 -O2

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

main.o: main.c list.h
$(CC) $(CFLAGS) -c $< -o $@

list.o: list.c list.h
$(CC) $(CFLAGS) -c $< -o $@

clean:
rm -f app *.o

.PHONY: clean

Automatic variables: $@ is the target, $^ is all prerequisites, $< is the first prerequisite. Recipes must start with a TAB, not spaces.

Why It Matters Here

Compiling a C project by hand is fine for one file. Past that, you need:

  • incremental builds (rebuild only what changed)
  • a single entry point (make) that everyone can run
  • consistent compiler flags

make is the oldest, most installed, smallest build tool you will meet. CMake, Meson, Ninja, and Bazel are strictly more capable, but all of them assume you already understand the compile/link cycle, which is exactly what make exposes.

Concrete Example

Walk through what happens on a fresh make:

  1. No app exists. make picks the first target, app.
  2. app depends on main.o and list.o. Neither exists.
  3. For main.o: main.c and list.h exist; no main.o yet, so run the recipe gcc -Wall -Wextra -std=c11 -O2 -c main.c -o main.o.
  4. Same for list.o.
  5. Run the app recipe: gcc -Wall -Wextra -std=c11 -O2 -o app main.o list.o.

Now change list.c and rerun make:

  • list.c is newer than list.o -> rebuild list.o.
  • list.o is newer than app -> relink app.
  • main.c unchanged -> do not rebuild main.o.

Edit only main.c: main.o rebuilt, app relinked, list.o untouched.

Edit only list.h: both object files rebuild, because both list list.h as a prerequisite.

Common Confusion / Misconception

"Make figures out dependencies automatically." It does not. You tell it. Missing a prerequisite is the most common reason a change to a header is not picked up. For large projects, use gcc -MMD -MP to auto-generate dependency fragments.

"Indentation with spaces works." No. make recipes require a literal tab. Spaces produce the infamous missing separator error.

".PHONY is decorative." Without it, make clean will not run if a file named clean exists.

How To Use It

Practical rules:

  1. Start with a flat Makefile for each small project. Graduate to auto-generated dependencies when it grows.
  2. Put CC, CFLAGS, and LDFLAGS at the top so you can override with make CFLAGS='-g -O0' when debugging.
  3. Add .PHONY: all clean test for targets that are not files.
  4. Use -Wall -Wextra -std=c11 during development; add -Werror when you can.
  5. Use gcc -MMD -MP to have the compiler emit .d files that list header dependencies, then -include *.d in the Makefile.

Check Yourself

  1. What does $@, $^, and $< mean in a recipe?
  2. Why must recipe lines start with a tab?
  3. Why is .PHONY: clean important?

Mini Drill or Application

Build a four-file project: main.c, util.c, util.h, Makefile.

  1. Write the Makefile above from memory.
  2. Confirm make builds, make again does nothing, and touch util.h && make rebuilds both .os.
  3. Add -MMD -MP to CFLAGS and an -include $(wildcard *.d) line; delete util.h dependency from the rules; confirm header-driven rebuilds still work.
  4. Add make clean, make test (run the binary), and make debug (sets CFLAGS += -g -O0).
  5. Compare with gcc -Wall -Wextra -std=c11 *.c -o app and explain the tradeoff.

Read This Only If Stuck