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 concatinnate 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
cating 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.