Introduction to Makefile

Are you in the same boat as I was only a few weeks ago? That is, does writing a Makefile intimidates you beyond belief? Fear not! Makefiles appear to be insurmountable (and some of the complicated ones really are) but if you start with simple steps, they are really not that difficult.

The first thing you should do is read Makefiles for Golang. It does an excellent job of introducing the basic concepts of a Makefile. I'll attempt the same thing but with a different approach.

Rules

Makefiles are composed of one or more rules. A rule has three parts:

  • Target

  • Dependencies (or prerequisites) (zero or more)

  • Recipe (one or more steps

The format of a rule is,

target: dependency
    recipe

All lines in the recipe need to be single-indented with a tab (not spaces!). Let me repeat: use a tab to indent. Otherwise, make complains.

Beware: code snippets in this post may be rendered with spaces instead of tabs. I apologize that so far I've been unable to find a fix.

Variables

Makefiles can declare one or more variables. They can also use environment variables as if they were declared in the Makefile.

For our purposes, a variable is declared in this manner,

VAR := some_value

Notice the use of :=. In some Makefiles, you'll also see =. The difference is that := binds quickly while = binds lazily (when it's actually used for the first time). This is what I understand. I may be wrong. In that case, read the official documentation for make. In any case, I've seen it recommended that we always prefer := over =.

To use a variable, reference it as $(VAR).

Environment variables are referenced exactly like a variable declared in a Makefile i.e. $(ENVVAR).

I like to use environment variables extensively so my Makefiles are customizable.

Recipe

A recipe is zero or more commands to run when the target is invoked.

Target

There are two kinds of targets: file (or directory) or not-a-file (also called phony).

Let's see some examples to understand this concept a bit more.

temp_file:
    touch temp_file

.PHONY: ls
ls:
    ls -a

Save the above as Makefile in some empty directory.

This Makefile has two targets. One target (temp_file) is a file (called temp_file) and the other (ls) is not a file (but is a command? and is thus marked with .PHONY.

Let's see which files are in the current directory.

$ ls
Makefile

Run make like so,

$ make temp_file
touch temp_file

Let's see which files are in the current directory.

$ ls
Makefile   temp_file

We ran the temp_file target and it ran the recipe for it. The recipe basically created a file called temp_file (the name of the target).

Now let's run the ls target,

$ make ls
ls -a
.         ..        Makefile  temp_file

This time the target ls did what its recipe state i.e. run ls -a. No file was created.

Let's run target temp_file again,

$ make temp_file
make: `temp_file' is up to date.

Since temp_file (the file) was not modified since the last time we ran make temp_file, make recognizes this and does not run its recipe. We could repeat the same command again and again and as long as temp_file remains unmodified, make will not run its recipe.

It must be noted that make uses last modified time of a file to determine whether it was modified or not. This is unlike git, which tracks content to determine if a file was modified.

Edit Makefile and add a new target, no_file,

temp_file:
    touch temp_file

.PHONY: ls
ls:
    ls -a

no_file:
    touch temp_file

Run the new target,

$ make no_file
touch temp_file

Run it again for good measure,

$ make no_file
touch temp_file

Unlike when we repeated make temp_file -- and make didn't re-run the recipe because it didn't need to -- repeating make no_file runs the recipe every time.

The reason is that make does not see a file called no_file appear after running the target's recipe. So unless the recipe is changed that results in the creation of a file called no_file, make will always run its recipe.

In other words, the no_file target is a phony target, i.e. running its recipe does not create a file of the same name as the target name. In this sense, the ls target and no_file target are functionally equivalent.

For the sake of being proper, we should really mark no_file target with .PHONY, like we did with the ls target.

Dependencies

A target can depend on other target(s). In this case, recipes of all the dependency targets are run before the recipe of the invoked target. For example, we could have a Makefile,

one:
    touch one

two: one
    touch two

In this Makefile, the target two has target one as a prerequisite (or dependency). We'll expect make to always run the recipe of target one (when needed) before running the recipe of target two (when needed).

Run the target two,

$ make two
touch one
touch two

As is visible, make ran the recipe of target one before it ran the recipe of target two.

Let's run the same target (two) again,

$ make two
make: `two' is up to date.

Since file two was not modified, make did not run the target two.

Let's just update file one and run target two again.

$ touch one
$ make two
touch two

Here, as expected, make ran target two because it recognized that its dependency had been updated. Why did it not run target one? I don't know.

Let's update file two and run target two again.

$ touch two
$ make two
make: `two' is up to date.

This is weird. We touch-ed file two but make did not run its recipe unlike when we had touch-ed file one. I don't know why this is.

Let's explore it a bit more. Let's run target one and then target two,

$ make one
make: `one' is up to date.
$ make two
make: `two' is up to date.

Let's touch file one and run target one,

$ touch one
$ make one
make: `one' is up to date.

Now let's run target two,

$ make two
touch two

From observing these outcomes, I assume that if the last touched time of a file is updated and that file is a dependency of another target, the target's recipe is run. However, if the touched time of a file is updated and the file's own target is invoked, its recipe is not run. This is something worth exploring.

This also illustrates the sometimes odd-seeming behavior of make and why many people are not very excited to adopt it.

Default Target

The first target in the Makefile is the default target. When you run make without specifying a target, it invokes the default target.

Usually, people call the default target all. This is why many instructions for many projects ask users to call make configure && make all.

Let's create an example Makefile,

.PHONY: all
all:
    echo "Default target"

.PHONY: clean
clean:
    echo "Not the default target"

Let's run make without any target specified,

$ make
echo "Default target"
Default target

Let's now run it with the all target,

$ make all
echo "Default target"
Default target

In this case make and make all are functionally equivalent.

Alias

Creating an alias for a target is pretty easy. Create the alias target, with a dependency on the original target, but no recipe. This is mostly useful in phony targets.

The Makefile could look something like this,

original:
    recipe

.PHONY: alias
alias: original

List All Targets

make has no way to cleanly list all targets in a Makefile. Fear not, though, since there are awesome people who have figured out ways to work around such inconveniences.

The source of this "magical" solution is How do you get the list of targets in a makefile?.

Add the following to any Makefile and run make list to get a clean list of all targets in a Makefile.

.PHONY: list
list:
    @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | xargs

What if a Target has a Directory as a Dependency?

Let's start with a single, empty directory.

$ ls

Now create a Makefile in that empty directory that could look something like this:

files:
    mkdir -p files

files/mykey: files
    ssh-keygen -N "" -f files/mykey -t rsa -b 4096

Here, files is the name of a directory created by the recipe of the files target. Similarly, files/mykey is the name of the file created by the recipe of the files/mykey target.

Run the files/mykey target,

$ make files/mykey
mkdir -p files
ssh-keygen -N "" -f files/mykey -t rsa -b 4096
Generating public/private rsa key pair.
Your identification has been saved in files/mykey.
Your public key has been saved in files/mykey.pub.
<SKIP REMAINING OUTPUT>

Note that first the dependency target, files, is run and then the invoked target files/myfiles. The output above has been truncated because it's not relevant to our purposes here.

Run the same target again,

$ make files/mykey
make: `files/mykey' is up to date.

Create a file in the directory files,

$ touch files/tmp

Run the files/mykey target again,

$ make files/mykey
ssh-keygen -N "" -f files/mykey -t rsa -b 4096
Generating public/private rsa key pair.
files/mykey already exists.
Overwrite (y/n)? n
make: *** [files/mykey] Error 1

Since there was a change in the files directory, and it is a dependency of the files/mykey target, the recipe for files/mykey target is run. The files target was not run since the directory already exists.

Why did make run the recipe of the invoked target when that target already exists and was not modified? As I understand it, make runs any targets that depend on a directory target if there have been any changes in the directory (such as adding another file).

This is clearly not what we want. We know that files directory already exists and that no changes were made to files/mykey. We expect make to not run recipe of either target.

make differentiates between normal prerequisites and order-only prerequisites (Types of Prerequisites).

What we have learned so far are normal prerequisites (dependencies). When a target's dependency is updated, the target is updated (its recipe is run). In special cases, like the one described here, you don't want to run a target if its dependency is updated (it'll still run the target if the dependency is created.

The syntax for specifying an order-only prerequisite is to add a pipe (|). Any dependencies to the left of the pipe are normal prerequisites and those to the right are order-only prerequisites.

Our Makefile can be modified as below,

files:
    mkdir -p files

files/mykey: | files
    ssh-keygen -N "" -f files/mykey -t rsa -b 4096

Create another file in the directory files,

$ touch files/tmp2

Run the files/mykey target again,

$ make files/mykey
make: `files/mykey' is up to date.

It worked!

Let's just run through this modified Makefile yet again.

$ rm -rf files
$ make files/mykey
mkdir -p files
ssh-keygen -N "" -f files/mykey -t rsa -b 4096
Generating public/private rsa key pair.
Your identification has been saved in files/mykey.
Your public key has been saved in files/mykey.pub.
<SKIP REMAINING OUTPUT>
$ make files/mykey
make: `files/mykey' is up to date.
$ touch files/tmp
$ make files/mykey
make: `files/mykey' is up to date.

Embed Shell Script in Makefile

A Makefile can call any shell script. Sometimes, though, you just want a quick way to embed a small shell script in the Makefile itself. A primary reason for doing this could be that each command in a recipe is run in its own separate shell. This way any output from one command is difficult to use in a subsequent command. A workaround, you may think, is to save the output in a variable. That doesn't always work.

Let's say you have a target with a recipe that stores the output of a command into a variable. Store this in a Makefile that is in an otherwise empty directory.

.PHONY: shell-script
shell-script:
    touch tmp
    owner = $(shell ls tmp)
    echo $(owner)

What does $(shell ls tmp) mean? It means we're explicitly invoking the shell to run something for us, the output of which we wish to store in a variable.

Invoke the shell-script target,

$ make shell-script
ls: tmp: No such file or directory
touch tmp
owner =
make: owner: No such file or directory
make: *** [shell-script] Error 1

What the huh? Why did ls run before touch? We even used the lazy evaluation version of variable assignment (=) instead of the recommended version (:=).

make runs any $(shell foo) pieces in the Makefile before running any targets or their dependencies, no matter where they occur in the file. make also runs them exactly once.

Given this new information, let's review our Makefile. We create a file, run ls and store its output in a variable, and finally print the contents of the variable.

The way make looked at the file and decided to execute it is different from what we would (quite logically, I think) expect.

make first executes $(shell ls tmp) but comes back with an error because the file tmp does not exist yet. Its return value is an empty string. Then it executes touch tmp and a file called tmp is created. But that's too late for our purposes. Next, an empty string is assigned to the variable called owner. make then believes that owner is a file and since there is no file called owner in the directory throws another error saying the same. What a mess!

Unless you are ok with the behavior of running $(shell foo) before anything else, you will want to embed shell scripts in a more cumbersome but ultimately successful way.

Let's rewrite our Makefile,

.PHONY: shell-script
shell-script:
    touch tmp
    { \
        owner=$$(shell ls tmp) ; \
        echo $$owner ;\
    }

Here we have started a shell script block within which we handle all our logic. Unfortunately, I have yet to find a way to use a variable declared in that block outside of the block in the rest of the recipe.

There are serious limitations in embedding shell scripts directly in a Makefile but in certain circumstances where it's needed, it's certainly doable. Although, of course, you may want to write separate shell scripts and just execute them from within the Makefile instead.

Using $(shell foo) is usually done in variables at the beginning of a Makefile (and foo is replaced by something more useful and meaningful) with the expectation that anything that replaces foo here acts upon information and artifacts that exist prior to running any (or all) targets of the Makefile. For example,

CWD := $(shell pwd)
SOMEDIR := $(CWD)/somedir

.PHONY: all
all:
    echo $(CWD)
    echo $(SOMEDIR)

Hide Command

You'll have noticed that each step in the recipe is printed on stdout. To suppress this behavior, prepend each step with @. Now that line will not be printed before it's executed.

Your Makefile could look like this,

.PHONY: cmd
cmd:
    @echo "Only the message is printed"

Run make and see that only the message is printed while the command is not,

$ make
Only the message is printed

Let's remove the @ in our Makefile,

.PHONY: cmd
cmd:
    echo "Only the message is printed"

Run make again and see how the command is printed as well,

$ make
echo "Only the message is printed"
Only the message is printed

Default Shell

make uses /bin/sh as the default shell on Linux/UNIX(-like) systems. This behavior can be overriden by overriding the SHELL variable. For example, your Makefile could look like,

SHELL := /bin/bash

.PHONY: all
all:
    echo "I'm using $(SHELL)"