I use Makefiles a lot for development. Makefiles have been around for a long
time and though there are other tools out there that have come along for
building large software projects, I find that Makefiles are still a great way
to perform relatively simple, yet repetitive tasks. And the best thing about
make
is that it is language independent. You don't have to remember if it's
npm test
or pytest
or go test
or some combination. You can just run
make test
.
In this post I want to give a simple introduction to Makefiles. And while I'm at it I'm going to use test-driven development (TDD).
Let's start with a "Hello World" example. First let's write the test. In
Makefiles there are "targets" and "recipes". The "target" is the thing you
want to create. Usually this is a file. Since Makefiles are primarily
file-driven, the target is often the path of the file. For each target there
is the "recipe" for how to build it. A "recipe" is a series of instructions
(like a food recipe). Each recipe step goes below the target line and is
indented with a TAB
character (not spaces!). Recipes also have "ingredients",
and in Makefiles these are called "prerequisites". So an example might look
like this:
program: file1.c file2.c
gcc -o program file1.c file2.c
Here we're saying, "To build program
we require file1.c
and file2.c
. The
recipe is to use gcc
to compile the .c
files into the program
executable.
Then to build program
one would run make program
:
$ make program
gcc -o program file1.c file2.c
make
is smart. So that if you run make program
again and the program is
already built and none of its prerequisites have changed, then it won't re-run
the recipe:
$ make program
make: 'program' is up to date.
This can speed up the compilation process significantly. Moreover, make
keeps track of files and prerequisites so if, say file2.c
has changed (the
file's timestamp is newer than program
, then make
knows that program
is
outdated and will rebuild it. But it will only rebuild targets whose
prerequisites have changed. We only have one target in the above example, so
it doesn't make a difference, but for our "Hello World" program it will.
make helloworld
Let's build our own helloworld
Makefile. But I want to be programming
language agnostic. So our programming language will be "text" and our
"compiler" will be cat
. Since we're doing TDD, let's write the test first.
# Makefile
.PHONY: test
test: helloworld
test "Hello World!" = "`cat helloworld`"
First some explanation. The .PHONY
thing: what is that? Well, remember I
told you that make
usually works with filenames as targets. In our case, our
target, test
is not an actual filename. Rather it is a "phony" target. The
.PHONY:
target tells make that test
is a phony target and to not actually
consider a file named test
, whether such file exists or not. Also the test
command is a command is a simple program that will fail (exit with a non-zero
status). If the test fails. If any program in the recipe exits with non-zero
then make
considers the build a failure.
Let's run our test:
$ make test
make: *** No rule to make target 'helloworld', needed by 'test'. Stop.
What's going on here? Well we put in our Makefile
that the test
target
depends on helloworld
. We don't have a file called helloworld
and we
haven't told make
how to build helloworld
, so it gives up.
This is how test-driven development works. You write the test first and then
you write the code to make the test pass. In our test we're saying "when we
cat helloworld
then we expect the output "Hello, World!". Our first error
is that make
doesn't know how to build helloworld
. Let's first fix that.
Add this to the Makefile
:
helloworld: hello.out space.out world.out
cat hello.out space.out world.out > helloworld
Here we've said, "helloworld
depends on 3 .out
files and the way we build
hellworld
is to concat
innate the 3 files together. Let's try it now:
$ make test
make: *** No rule to make target 'hello.out', needed by 'helloworld'. Stop.
Ok, we got further, make now knows how to build helloworld
but now it doesn't
know how to make hello.out
(nor the other two .out
files).
Let's say our .out
files are generated from .in
files, and the .in
files
are our "source code" while the .out
files are our "compiled" object files.
Our "compiler", again, is cat
. So creating the .out
files is as easy as
cat
ing the .in
files. Let's add these recipes to our Makefile
.
hello.out: hello.in
cat hello.in > hello.out
space.out: space.in
cat space.in > space.out
world.out: world.in
cat world.in > world.out
$ make
make: *** No rule to make target 'hello.in', needed by 'hello.out'. Stop.
We got further this time, but make
doesn't know how to build our .in
files.
It shouldn't though; that's our source code. We're in charge of doing that.
So let's do it.
echo -n "Hello" > hello.in
echo -n " " > space.in
echo -n "World!" > world.in
There. Now we actually have source code to "build" our program.
$ make test
cat hello.in > hello.out
cat space.in > space.out
cat world.in > world.out
cat hello.out space.out world.out > helloworld
test "Hello World!" = "`cat helloworld`"
$
Boom! Our test passes. We're done, but we can do better.
Advanced topics
Our build requires multiple .in
files to create multiple .out
files.
However the "recipe" to build them is basically the same. What if we had 40
.in
files? Would we have to create 40 rules? Luckily, make
has what are
called "pattern rules". The three .out
rules can therefore be simplified.
But before we do that, let's create a clean
target to put our project in its
pristine condition:
.PHONY: clean
clean:
rm -f hello.out space.out world.out helloworld
The -f
flag to rm
ensures that it doesn't return a non-zero exit status if
a given file happens to not exist.
$ make clean
rm -f hello.out space.out world.out helloworld
$ ls
hello.in Makefile space.in world.in
$
Makefiles often have a clean
target. It comes in handy.
Now let's simplify the .out
rules by creating a single pattern rule. Replace
the three .out
rules with:
%.out: %.in
cat $< > $@
The %
character is a pattern. So this rule basically says, "To create
foo.out
we need foo.in
. The actual recipe is a bit confusing. The $<
is
a variable that is evaluated to the first listed prerequisite. In our case we
only have one which is %.in
. The $@
is another variable that always
expands to the name of the target (e.g. foo.out
). So now we've told make
that any .out
file can be created by catting the respective .in
file.
Let's make sure it works.
$ make test
cat hello.in > hello.out
cat space.in > space.out
cat world.in > world.out
cat hello.out space.out world.out > helloworld
test "Hello World!" = "`cat helloworld`"
So now we know that Makefiles have these "magic" variables. But you can also
define your own variables. Let's take advantage of this: Our new Makefile
becomes:
# Makefile
SRC := hello.out space.out world.out
OBJ := $(SRC:.in=.out)
EXE := helloworld
.PHONY: test
test: $(EXE)
test "Hello World!" = "`cat $<`"
$(EXE): $(OBJ)
cat $(OBJ) > $@
%.out: %.in
cat $< > $@
.PHONY: clean
clean:
rm -f $(OBJ) $(EXE)
The SRC
variable defines our "source" files. The line below it uses "suffix
replacement" to define our "object" files from the list of source files. The
EXE
variable defines our "executable" (the helloworld file itself). Using
these variables and the magic variables described we can write our Makefile
more generically and compact and it will still work as desired:
$ make clean
rm -f hello.out space.out world.out helloworld
$ make test
cat hello.in > hello.out
cat space.in > space.out
cat world.in > world.out
cat hello.out space.out world.out > helloworld
test "Hello World!" = "`cat helloworld`"
make
can do even more. For full documentation on (GNU) make, go
here.