The moment your C project grows past one file, compiling gets tedious: three gcc commands, typed in the right order, every single time — and if you forget one, you’re debugging a stale binary. Make fixes this with a small file of build rules, and it’s been the standard way to build C for four decades. This tutorial builds a real two-module project and grows a Makefile from three lines to production shape, with every command’s actual output shown (GNU Make 4.3, gcc 13.3, Ubuntu 24.04).
The Project We’re Building
A tiny calculator split across three files — the smallest project where Make starts paying rent:
/* utils.h */
#ifndef UTILS_H
#define UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
/* utils.c */
#include "utils.h"
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
/* main.c */
#include <stdio.h>
#include "utils.h"
int main(void)
{
printf("3 + 4 = %d\n", add(3, 4));
printf("3 * 4 = %d\n", multiply(3, 4));
return 0;
}
Building it by hand means compiling each .c to an object file, then linking:
$ gcc -Wall -Wextra -g -c main.c $ gcc -Wall -Wextra -g -c utils.c $ gcc -Wall -Wextra -g -o calc main.o utils.o
Three commands, every time, forever. Let’s not.
Anatomy of a Rule
A Makefile is a list of rules, each with the same three-part shape:
target: prerequisites <TAB> recipe
- target — the file this rule produces (
calc,main.o) - prerequisites — the files it’s built from; if any is newer than the target, the rule runs
- recipe — the shell command(s) that build it, indented with a real TAB character, not spaces
That TAB is the most famous gotcha in all of Unix: indent a recipe with spaces and Make dies with *** missing separator. Stop. — see the issues table below.
A First Working Makefile
Save this as Makefile (capital M, no extension) next to the sources:
CC = gcc
CFLAGS = -Wall -Wextra -g
OBJS = main.o utils.o
TARGET = calc
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $(OBJS)
%.o: %.c utils.h
$(CC) $(CFLAGS) -c $<
.PHONY: clean
clean:
rm -f $(TARGET) $(OBJS)
Piece by piece:
CC,CFLAGS,OBJS,TARGETare variables — change the compiler or flags in one place, referenced as$(NAME).$@and$<are automatic variables: inside a recipe,$@is the target’s name and$<is the first prerequisite. They keep rules generic.%.o: %.c utils.his a pattern rule: one rule that builds any.ofrom its matching.c. Listingutils.has a prerequisite means “if the header changes, recompile” — the detail most beginner Makefiles get wrong..PHONY: cleantells Make thatcleanis a command, not a file — so it runs even if a file namedcleanever exists.
Watch It Work
First build — Make runs all three steps and echoes each command:
$ make gcc -Wall -Wextra -g -c main.c gcc -Wall -Wextra -g -c utils.c gcc -Wall -Wextra -g -o calc main.o utils.o $ ./calc 3 + 4 = 7 3 * 4 = 12
Run make again without changing anything:
$ make make: 'calc' is up to date.
This is Make’s whole superpower: it compares file timestamps and does nothing unless something changed. Now edit only utils.c and rebuild:
$ touch utils.c $ make gcc -Wall -Wextra -g -c utils.c gcc -Wall -Wextra -g -o calc main.o utils.o
Only utils.o was recompiled, then the link — main.o was untouched because main.c didn’t change. On a 3-file project that saves a second; on a 300-file project it turns a ten-minute rebuild into two seconds. And because our pattern rule lists utils.h, a header edit correctly rebuilds both objects:
$ touch utils.h $ make gcc -Wall -Wextra -g -c main.c gcc -Wall -Wextra -g -c utils.c gcc -Wall -Wextra -g -o calc main.o utils.o
Finally, housekeeping:
$ make clean rm -f calc main.o utils.o
Reading the Dependency Graph
It helps to see the Makefile as the graph Make sees:
calc ← main.o ← main.c, utils.h
← utils.o ← utils.c, utils.h
When you type make, it starts at the first target (calc), walks down the arrows checking timestamps, and re-runs exactly the recipes on any path where a source is newer than its product. Everything else — variables, patterns, phony targets — is convenience layered on this one idea.
Common Issues
| Problem | Fix |
|---|---|
*** missing separator. Stop. |
Recipe indented with spaces — replace with a real TAB (check your editor’s “tabs to spaces” setting) |
make: 'calc' is up to date but your change isn’t in |
You edited a header that isn’t listed as a prerequisite — add it to the pattern rule (or use gcc’s -MMD auto-dependencies as the next step up) |
make: *** No targets specified and no makefile found |
File must be named Makefile or makefile, in the directory where you run make |
undefined reference to 'add' at link |
An object file is missing from OBJS — every .c module needs its .o listed |
make clean says “up to date” |
A file named clean exists and shadows the target — this is exactly what .PHONY prevents |
What’s Next
Your Makefile already compiles with -g, so every build is debugger-ready — learn to use that in How to Use GDB to Debug C Programs, and add a leak check to your workflow with Valgrind.
New to the command-line toolchain entirely? Start from installing GCC on Ubuntu or the complete C environment setup guide.
For bigger projects with multiple directories and third-party libraries, the industry step after Make is CMake — a tutorial is coming in this series.
As an Amazon Associate we earn from qualifying purchases.
Recommended Book
Multi-file program structure — headers, modules, linkage — is exactly what Chapter 4 of The C Programming Language by Kernighan & Ritchie (Amazon.com) teaches. Make is how you build what K&R shows you how to design.