An Introduction to Makefiles (Using Test-Driven Development)

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.