C Basics · beginner · ~15 min

Makefiles — the C build system

- Read a real Makefile and explain what each rule builds and when it runs. - Write a small Makefile from scratch: variables, a link rule, a pattern rule, and a `clean` target. - Explain how Make decides what to rebuild using file timestamps (the newer-than rule). - Use the automatic variables `$@`, `$<`, and `$^` correctly. - Mark non-file targets as `.PHONY` and understand why it matters. - Debug a broken build with `make -n`, `make -p`, and by reading the recipe Make actually ran.

Overview

Imagine a C project with ten source files. Every time you change one line, you could recompile all ten files and re-link the program by hand. That is slow and easy to get wrong. Make automates it. You describe your project once as a set of rules, and Make figures out the smallest set of commands needed to bring the program up to date.

The key idea is simple: Make compares file timestamps. If a source file is newer than the compiled output built from it, that output is stale and Make rebuilds it. If nothing changed, Make does nothing and tells you so. This turns the vague goal "build the program" into a precise one: "rebuild exactly what changed, and nothing more."

This builds directly on Compiling with gcc. There you learned to run gcc by hand: compile each .c into a .o object file with -c, then link the .o files into an executable. A Makefile is just those same gcc commands, written down as rules so a tool can run them for you in the right order. If you understand the compile-then-link pipeline, you already understand what a Makefile automates.

In plain language: a Makefile is a recipe book. Each recipe says "to make this file, you need these ingredients, and here is the command to combine them." The formal terms are target (the thing being built), prerequisites (what it depends on), and recipe (the shell commands that build it).

Why it matters

Almost every C codebase you will ever open builds with Make or something that generates Make-style steps. Reading a Makefile is part of reading any C project — it tells you how the pieces fit together, which compiler flags are used, and how to build and test the code.

  • Direct Make: the Linux kernel, glibc, OpenSSL, SQLite, countless embedded firmware projects, and most classic Unix tools.
  • Make-adjacent tools: CMake and Meson generate Ninja or Make build files under the hood, so the mental model transfers.
  • Speed: on a large project, a full rebuild can take minutes. Make turns a one-line edit into a one-file recompile that finishes in under a second.
  • Reproducibility: the build is written down, not stored in someone's shell history. A new teammate types make and gets the same binary you do.

If you can follow a Makefile, you can understand how a project is put together — and you can fix it when the build breaks.

Core concepts

1. The rule: target, prerequisites, recipe

A rule is the atom of a Makefile. It answers three questions: what am I building, what does it depend on, and how do I build it.

target: prerequisites
<TAB>recipe
  • target — usually the name of a file to create (e.g. prog, main.o).
  • prerequisites — files the target depends on. If any is newer than the target, the target is stale.
  • recipe — one or more shell commands that build the target. Each recipe line must begin with a real tab character.

How it works internally: when you run make target, Make looks at the timestamp of target and the timestamps of its prerequisites. If the target does not exist, or any prerequisite is newer, Make runs the recipe. Before doing so, it first makes sure each prerequisite is itself up to date — so Make walks a dependency graph from the bottom up.

When to use / not use: write one rule per output file. Do not cram unrelated commands into one recipe just to save typing — that defeats incremental rebuilds, because Make can only skip work it can reason about.

Pitfall: the tab. If you indent a recipe with spaces, Make reports missing separator or silently misbehaves. This is the single most common Makefile bug.

Dependency graph for `make prog`:

        prog                (final executable)
        /   \
   main.o    util.o          (object files)
      |         |
   main.c    util.c          (source you edit)

Make checks timestamps bottom-up:
  edit util.c  ->  util.c newer than util.o  ->  rebuild util.o
               ->  util.o newer than prog    ->  re-link prog
  main.c untouched -> main.o still fresh -> NOT recompiled

Knowledge check: You edit util.c and run make prog. Which files get rebuilt, and which are left alone? Why is main.o skipped?

2. How Make decides what is stale (timestamps)

Make does not read the contents of your files to see if they changed. It compares modification times (mtimes) from the filesystem. A target is out of date when it is missing, or when any prerequisite has a newer mtime.

When NOT to rely on it: if your clock is wrong, or you copy files in a way that preserves old timestamps, Make can think a stale file is fresh. Running make clean (delete outputs) then make forces a full rebuild.

Pitfall: editing a header that many .c files include will not trigger recompiles unless your rules list the header as a prerequisite. Make only knows about dependencies you tell it about.

Knowledge check: A build works, but after editing a struct in types.h, the program still behaves as if the old struct is in use, with no compile errors. What is missing from the Makefile?

3. Variables

Variables let you name things once. The two you will see constantly:

CC := gcc            # the compiler
CFLAGS := -Wall -O2  # flags passed to every compile

Use them with $(CC) and $(CFLAGS). There are two assignment styles:

Operator Name When the right side is evaluated
:= simple once, immediately (recommended default)
= recursive every time the variable is used (can surprise you)

Prefer := unless you specifically need lazy evaluation. CC, CFLAGS, CPPFLAGS, and LDFLAGS are conventional names that many built-in rules already understand — sticking to them pays off.

4. Automatic variables: $@, $<, $^

Inside a recipe, Make fills in these shortcuts so you do not repeat filenames:

Variable Means Example value
$@ the target prog
$< the first prerequisite main.c
$^ all prerequisites (deduplicated) main.o util.o

They make rules generic and DRY. $< is what you want for compiling one .c into one .o; $^ is what you want for linking all the objects into the binary.

Knowledge check: In the link rule prog: main.o util.o with recipe $(CC) -o $@ $^, what exact command does Make run?

5. Pattern rules

Writing a separate rule for every .o is tedious. A pattern rule uses % as a wildcard to build a whole class of files with one recipe:

%.o: %.c
<TAB>$(CC) $(CFLAGS) -c -o $@ $<

This reads: "to build anything.o, use the matching anything.c, and compile it with -c." The % matches the same text (the stem) on both sides.

When to use: whenever many targets share one build command. Almost every C Makefile has this exact %.o: %.c rule.

Pitfall: forgetting -c. Without -c, gcc tries to link and produce an executable, fails because there is no main in a lone file, and you get confusing linker errors.

6. Phony targets

Some targets are commands, not files — clean, all, test, install. They should always run when asked. But if a file named clean ever exists in the directory, Make sees the target as "up to date" and refuses to run it. Declaring the target phony fixes this:

.PHONY: all clean test

How it works: .PHONY tells Make "these names are not files; never check timestamps, always run the recipe." all is a convention for the default build; clean deletes generated files.

Knowledge check: Explain in your own words why make clean might silently do nothing if clean is not declared .PHONY and a file called clean happens to exist.

Syntax notes

# --- Variables (define once, reuse everywhere) ---
CC := gcc                       # simple assignment, evaluated now
CFLAGS := -Wall -Wextra -O2 -g  # warnings + optimization + debug info

# --- A normal rule ---
#   target : prerequisites
#   <TAB>    recipe   (the leading whitespace MUST be a real tab)
prog: main.o util.o
	$(CC) $(CFLAGS) -o $@ $^     # $@ = prog, $^ = main.o util.o

# --- A pattern rule (% is the stem wildcard) ---
%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<  # $< = the matching .c file

# --- Phony targets are commands, not files ---
.PHONY: clean
clean:
	rm -f *.o prog

Key points: the first target in the file is the default (what plain make builds). Comments start with #. A recipe line is any line that starts with a tab, and only those lines are run as shell commands.

Lesson

A Makefile encodes rules in the form: target -> prerequisites -> recipe.

When any prerequisite is newer than its target, Make runs the recipe to rebuild it.

For C, the common pattern is:

  • Each .o object file depends on its .c source file.
  • The final binary depends on every .o file.

Code examples

The Makefile below builds a two-file program. Save the three files in one directory and run make.

# Makefile
CC := gcc
CFLAGS := -Wall -Wextra -O2 -g   # warnings on, optimized, with debug symbols

# Default target (first in the file): build the executable.
prog: main.o util.o
	$(CC) $(CFLAGS) -o $@ $^      # link both objects into `prog`

# Pattern rule: build any .o from its matching .c
%.o: %.c util.h
	$(CC) $(CFLAGS) -c -o $@ $<   # compile one source to one object

# Housekeeping target (not a real file).
.PHONY: clean
clean:
	rm -f *.o prog
/* util.h */
#ifndef UTIL_H
#define UTIL_H

long sum_range(int lo, int hi);  /* sum of lo..hi inclusive, 0 if lo > hi */

#endif
/* util.c */
#include "util.h"

long sum_range(int lo, int hi) {
    long total = 0;
    for (int i = lo; i <= hi; i++) {   /* if lo > hi the loop never runs -> 0 */
        total += i;
    }
    return total;
}
/* main.c */
#include <stdio.h>
#include "util.h"

int main(void) {
    long s = sum_range(1, 100);
    printf("sum 1..100 = %ld\n", s);
    return 0;
}

What it does: make compiles main.c and util.c into main.o and util.o, then links them into an executable called prog.

Expected output of the build and run:

$ make
gcc -Wall -Wextra -O2 -g -c -o main.o main.c
gcc -Wall -Wextra -O2 -g -c -o util.o util.c
gcc -Wall -Wextra -O2 -g -o prog main.o util.o
$ ./prog
sum 1..100 = 5050

Run make again with no edits and it prints make: 'prog' is up to date. — the timestamps show nothing changed. Touch util.c (touch util.c) and re-run: only util.o and the final link rebuild; main.o is left alone.

Edge cases: listing util.h as a prerequisite of the %.o rule means editing the header correctly forces a recompile of every object. If you omit it, a header change would be silently ignored. sum_range(5, 1) returns 0 because the loop body never executes when lo > hi.

Line by line

Walk through what happens on a clean make (no .o files exist yet):

  1. CC := gcc and CFLAGS := ... — Make records these variables immediately. := means they are evaluated once, right now.
  2. prog: main.o util.o — this is the first rule, so prog is the default target. Make wants to build prog, but first it must ensure main.o and util.o exist and are fresh.
  3. Make looks for a rule to build main.o. The pattern rule %.o: %.c util.h matches with stem main, giving prerequisites main.c and util.h. Neither main.o exists yet, so the recipe runs. $@ expands to main.o and $< to main.c (the first prerequisite — note util.h is not $<).
  4. Same for util.o: stem util, recipe compiles util.c.
  5. Now both objects exist. Make returns to prog. $@ = prog, $^ = main.o util.o. The link recipe runs, producing the executable.

Trace of a second run after editing only util.c:

Step File checked Timestamp comparison Action
1 main.o vs main.c, util.h sources older skip (fresh)
2 util.o vs util.c util.c newer recompile util.o
3 prog vs main.o, util.o util.o now newer re-link prog

The payoff: one edited file caused exactly one recompile plus one relink, not a full rebuild. This is the whole point of Make.

Common mistakes

1. Spaces instead of a tab.

prog: main.o
    $(CC) -o $@ $^      # WRONG: this line is indented with spaces

Why it is wrong: Make requires a literal tab to recognize a recipe line. With spaces you get Makefile:2: *** missing separator. Stop. Fix: replace the leading spaces with one tab. Configure your editor to show whitespace or to insert a real tab (not "soft tabs") inside Makefiles.

2. Forgetting -c in the object rule.

%.o: %.c
	$(CC) $(CFLAGS) -o $@ $<   # WRONG: no -c, so gcc tries to link

Why it is wrong: without -c, gcc attempts to produce a full executable from one file and fails at the link step (undefined reference to main, or a bad .o that is actually an executable). Correct version adds -c:

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

3. Not listing headers as prerequisites.

%.o: %.c            # WRONG: header changes are invisible to Make

Why it is wrong: edit a shared .h and Make will not recompile the .c files that include it, so you run stale code with no warning. Fix: add the header (%.o: %.c util.h), or generate dependencies automatically with gcc -MMD. Recognize it when a code change "has no effect" until you make clean.

4. clean not marked phony. If a file literally named clean appears, make clean reports "up to date" and does nothing. Fix: .PHONY: clean.

Debugging tips

Compiler / Make errors

  • missing separator → a recipe line uses spaces, not a tab. Show whitespace in your editor and fix the indent.
  • No rule to make target 'foo.o' → the prerequisite file name is misspelled or missing, or no pattern rule matches it.
  • undefined reference to ... at the link step → an object file is missing from the link rule's prerequisites, or -c is missing on the compile rule.

Runtime / logic errors

  • Program behaves as if you never edited it → a stale object was not rebuilt. Confirm with make clean && make. If a clean rebuild fixes it, a prerequisite (often a header) is missing from a rule.
  • Nothing rebuilds even though you edited a file → check the file's timestamp with ls -l; a wrong clock or a preserved mtime can fool Make.

Concrete debugging steps

  • make -n (dry run) prints the recipes Make would run without running them — perfect for seeing what it thinks needs rebuilding.
  • make -p dumps Make's full database: every variable and rule, including built-in ones. Search it to see how a variable expanded.
  • make --debug=b explains why Make decided each target was or was not out of date.
  • Read the exact command Make printed and try running it by hand — the failure is almost always in that command, not in Make itself.

Questions to ask when it will not work: Is the recipe indented with a real tab? Does every target list all the files it truly depends on (including headers)? Did I mark command-like targets .PHONY? Does the failing command work when I paste it into the shell?

Memory safety

Make itself does not touch program memory — it only runs commands. But the flags you put in a Makefile directly control the memory-safety of the binary Make produces, so this is where safety is configured for the whole project.

  • Warnings catch bugs early. Always compile with -Wall -Wextra (and consider -Wpedantic). Many memory bugs — uninitialized variables, format-string mismatches, implicit conversions — show up as warnings you would otherwise miss.
  • Sanitizers catch bugs at runtime. In a development build add -fsanitize=address,undefined. AddressSanitizer flags out-of-bounds reads/writes and use-after-free; UBSan flags undefined behavior like signed overflow. Rebuild and run your tests under them.
# Development build: maximum diagnostics
CFLAGS := -Wall -Wextra -Wpedantic -g -fsanitize=address,undefined
  • Hardening flags for release. Production builds commonly add -fstack-protector-strong (stack-canary checks) and -D_FORTIFY_SOURCE=2 (safer versions of memcpy, sprintf, etc.). These add cheap runtime checks that turn some buffer overflows into clean crashes instead of exploitable corruption.
  • Reading a build defensively. When you open an unfamiliar C project, read the Makefile first. Missing -fstack-protector-strong, -D_FORTIFY_SOURCE=2, or even -Wall is a signal the code has not been checked for basic memory-safety issues. This is a lab/code-review habit, not an attack technique — you are auditing your own or a teammate's build.

One caution: sanitizer and hardening flags belong in separate build variants. Do not ship -fsanitize=address in production — it is a debugging tool with real overhead, not a shipping safeguard.

Real-world uses

Concrete use case. The Linux kernel is built with an enormous Make-based system (make, make menuconfig, make modules). A kernel developer who edits one driver file recompiles that one file and relinks in seconds instead of rebuilding millions of lines. The same pattern powers glibc, OpenSSL, SQLite, Git, and most embedded firmware toolchains.

Professional habits (beginner):

  • Put CC, CFLAGS, and a .PHONY: clean in every project from day one.
  • Keep -Wall -Wextra on and fix warnings rather than silencing them.
  • Make the first target the default build, and give it an obvious name.
  • Provide a clean target so anyone can reset to a known state.

Professional habits (advanced):

  • Auto-generate header dependencies with gcc -MMD -MP and -include $(DEPS) so you never hand-maintain header prerequisites.
  • Separate debug and release variants (different CFLAGS), and support out-of-tree builds so generated files do not clutter the source directory.
  • Use make -j$(nproc) for parallel builds, and keep rules correct so parallelism is safe (every target must declare all its real prerequisites).
  • Prefer conventional variable names so implicit rules and downstream tools (CMake, packagers) cooperate with your Makefile.

Practice tasks

1. Beginner — single-file build. Write hello.c that prints a greeting, and a Makefile whose default target builds an executable named hello from it. Requirement: use a CC variable and a CFLAGS := -Wall -Wextra variable. Verify make builds it and ./hello runs. Concepts: variables, a basic rule, the default target.

2. Beginner — add a clean target. Extend task 1 with a clean target that removes hello and any .o files, and declare it .PHONY. Requirement: after make then make clean, the directory should contain only your source and the Makefile. Test that creating an empty file named clean does not break make clean. Concepts: .PHONY, housekeeping targets.

3. Intermediate — two files with a pattern rule. Split your program into main.c and math.c (plus math.h). Write a Makefile that uses a %.o: %.c math.h pattern rule and a link rule using $@ and $^. Requirement: editing only math.c must recompile just math.o and relink. Confirm with make, then touch math.c && make, checking the printed commands. Concepts: pattern rules, automatic variables, header prerequisites, incremental rebuild.

4. Intermediate — debug and release variants. Add two configurations: a default build with -g -fsanitize=address,undefined and a release target/variant with -O2 -DNDEBUG -fstack-protector-strong. Requirement: make produces the sanitized binary; make release produces the hardened one. Hint: you can override CFLAGS per target or use a variable you reassign. Concepts: variables, hardening flags, build variants.

5. Challenge — auto-generated dependencies. Modify the pattern rule to emit dependency files with -MMD -MP, collect them into a DEPS variable, and pull them in with -include $(DEPS) so header changes trigger the right recompiles without listing headers by hand. Requirement: edit a header included by only one source file and confirm that only that source recompiles. Make clean also remove the .d files. Constraints: no hard-coded header names anywhere in the Makefile. Concepts: automatic dependency generation, -include, pattern rules, variables. (Look up gcc -MMD -MP and Make's wildcard function — do not expect a full solution here.)

Summary

  • A rule is target: prerequisites followed by a tab-indented recipe. The tab is mandatory; spaces cause missing separator.
  • Make rebuilds a target when it is missing or when any prerequisite is newer than it. That is the entire decision — file timestamps, not file contents.
  • Variables (CC, CFLAGS) name things once; prefer :=. Automatic variables you reach for most: $@ (target), $< (first prerequisite), $^ (all prerequisites).
  • Pattern rules like %.o: %.c build a whole class of files with one recipe. List headers as prerequisites (or auto-generate them) or header edits are silently ignored.
  • Mark command targets like clean and all as .PHONY so they always run.
  • Set -Wall -Wextra everywhere; use -fsanitize=address,undefined for development and hardening flags for release. Debug builds with make -n, make -p, and by reading the exact command Make ran.
  • Remember: a Makefile is just your gcc compile-and-link commands, written down so a tool runs only the ones that are needed.

Practice with these exercises