6 June 2025
Part of a series: Tooling for C
One of the quirks of C is that source files are compiled one at a time. The C compiler reads a source file and produces an object file. Once all source files are compiled, the linker (now usually part of the compiler) combines the object files into an executable.
That means building a C project requires multiple commands, although you don’t need to run all of them every time you re-build your program after a change.
The classic tool to automate this process is Make -- typically, you’ll just run make
on the command-line and it automatically runs whatever compiler commands are needed to rebuild your code. You do need to write a configuration file called a “Makefile” once to set it up for your C project. In this article I’ll give a beginner-friendly introduction to Make and Makefiles, explain the most common features, and finish with a sample Makefile that you can adapt for your projects.
There’s various different implementations and variants of Make, but the defacto standard (at least on Linux and Mac) is GNU Make, which is what I’ve used when writing this article.
To have a concrete example, I set up a minimalist C project with six files:
main.c main function
math.h declares an “add” function
math.c implements the “add” function
math_test.c unit tests for math.h
io.h declares a “print_number” function
io.c implements the “print_number” function
You can find the code here: sample-code/c/make/v1/src.
A Makefile consists mainly of a set of “rules” that define the commands that we want Make to run. Let’s dive in and look at a basic Makefile for the sample project:
main: main.o io.o math.o
cc -o main main.o io.o math.o
io.o: io.c io.h
cc -c -o io.o io.c
main.o: main.c io.h math.h
cc -c -o main.o main.c
math.o: math.c math.h
cc -c -o math.o math.c
math_test.o: math_test.c math.h
cc -c -o math_test.o math_test.c
math_test: math_test.o math.o
cc -o math_test math_test.o math.o
This is pretty repetitive -- we’ll see techniques to avoid that soon! For now, notice that there’s six rules in the Makefile, all following this format:
target: dependencies
command
One thing to watch out for is that the command in a rule has to be indented by a tab character -- spaces don’t work and can result in confusing error messages.
In the rule, “target” is a file that we want to create, “command” is the command-line that creates it, and “dependencies” is a list of input files for the command. A dependency can be the target of another rule; in that case Make will automatically figure out which rules it needs to run in which order. For example, here’s what happens when we build the math_test
target:
$ make math_test
cc -c -o math_test.o math_test.c
cc -c -o math.o math.c
cc -o math_test math_test.o math.o
By default, Make prints each command before running it. If you prefer an uncluttered terminal, you can pass the flag -s
(or --silent
). Let’s try that to build and run the main
executable. The first target in the Makefile is always the default, so we can just run make -s
by itself:
$ make -s
$ ./main
2 + 2 is 4
Nice!
To avoid wasting your time, Make will skip commands where the target already exists and the dependencies haven’t changed since the target was created. Here’s what happens if we run make
twice in a row:
$ make
cc -c -o main.o main.c
cc -c -o io.o io.c
cc -c -o math.o math.c
cc -o main main.o io.o math.o
v1$ make
make: 'main' is up to date.
We can trigger a re-compilation with the touch
command, which updates a file’s modification time without changing its content:
$ touch math.c
$ make
cc -c -o math.o math.c
cc -o main main.o io.o math.o
Notice that it ran the commands for math.o
(where math.c
is listed as a dependency) and main
(where math.c
is a dependency of a dependency), but not the ones for io.o
and main.o
.
Of course, this re-building logic requires that we keep the dependencies in the Makefile up-to-date, which is tedious and error-prone even in a small project. Fortunately, modern compilers like gcc and clang can do the work for us. If we run clang -MM
it prints the dependencies in the right format:
$ clang -MM *.c
io.o: io.c io.h
main.o: main.c io.h math.h
math.o: math.c math.h
math_test.o: math_test.c math.h
Neat! The usual convention is to save the output in a file called depend
:
clang -MM *.c > depend
and use it through an “include directive” in the Makefile. The Makefile is a lot shorter now:
main: main.o io.o math.o
cc -o main main.o io.o math.o
math_test: math_test.o math.o
cc -o math_test math_test.o math.o
include depend
But wait -- the rules in the depend
file don’t include any commands. How does Make know how to build these targets?
The answer is that Make has a set of built-in rules (called “implicit rules”), and one of them is for building .o
files from .c
files. You can see the whole list in the manual: Catalogue of Built-In Rules.
We might still want to customize the command for building object files, though. To do that, we need to learn about variables and patterns…
The manual says that the implicit rule for compiling C uses this command:
$(CC) $(CPPFLAGS) $(CFLAGS) -c
Each $(…) here will be replaced by the value of a variable. The ones used here are pre-defined (“implicit”) variables: CC is the name of the C compiler (“cc” by default), CFLAGS defines flags to pass to the compiler (empty by default), and CPPFLAGS is a set of flags for the preprocessor (also empty). We can see the resulting command-line when running Make:
$ make main.o
cc -c -o main.o main.c
We can override a variable either in the Makefile or on the command-line. For example, we may want to add this line to the Makefile to enable compiler warnings:
CFLAGS = -Wall -Wextra
and then set CC
on the command line:
$ make main.o CC=clang
clang -Wall -Wextra -c -o main.o main.c
Tweaking the command with variables is useful, but sometimes we need to replace it completely. Let’s say we’ve decided to put all source code in a “src” directory and the object files in a “build” directory. How do we make that work without going back to writing a separate rule for each one?
The solution is to write a “pattern rule”:
build/%.o: src/%.c
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
The % signs tell make that this rule can be used to build any file build/something.o
with dependency src/something.c
. When Make runs, these will be replaced with the real filenames.
To use these filenames in the compiler command, we use the automatic variables $@
and $<
. $@
will be replaced with the target of the rule and $<
with the first dependency.
Putting it all together, when we run make build/math.h
, Make recognizes that build/math.h
matches the pattern build/%.o
with math
for %
and replaces $@
and $<
with the two filenames:
$ make build/math.o
cc -Wall -Wextra -c -o build/math.o src/math.c
We still need to change the command that generates the depend file. As a reminder, this is how we did it until now:
$ clang -MM *.c
io.o: io.c io.h
main.o: main.c io.h math.h
math.o: math.c math.h
math_test.o: math_test.c math.h
Changing the location of the the source files is easy, we’ll just use src/*.c
instead of *.c
. It’s less obvious how to change the location of the object files. Take another look at the output, though -- all we need to do is to add build/
to the start of each line. We can use sed to do that:
clang -MM src/*.c | sed -E -e 's,^,build/,' > depend
Keep in mind that you need to re-run this command whenever you add a source file or change the dependencies between source and header files (by adding/removing include directives).
Here’s the full Makefile:
CFLAGS = -Wall -Wextra
build/%.o: src/%.c
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
build/main: build/main.o build/io.o build/math.o
cc -o build/main build/main.o build/io.o build/math.o
build/math_test: build/math_test.o build/math.o
cc -o build/math_test build/math_test.o build/math.o
include depend
In Part 2 we’ll look at some more advanced features, to make the Makefile a bit more flexible and a bit more useful. This is a good point to take a break, though. For a really simple project, a Makefile like the one here is already perfectly usable.