Constructing things with Make

Make is a very interesting tool, and has been around for nearly 50 years (at the time of writing this article). There’s an overwhelming amount of build tools out there in the wild, but none of them are as accessible as make, it being available right away on most linux boxes.

This easy availability is one of the main reasons why I use it to build and deploy this website amongst other things. It is also a good idea to have an understanding of make, because having to compile applications from source is not that uncommon. Therefore, this article aims to give a quick, non-exhaustive overview on using make.

Introduction to Makefiles

Make relies on makefiles, which contain a set of instructions, for example the build process of the application, though make can be used to run any task besides compiling software, such as deployment.

Let’s take a look at the key elements of makefiles:

A simple example

Let’s consider a basic program:

#include <stdio.h>

int main() {
    printf("Hello world\n");
    return 0;
}

Naturally, we can compile this by calling the compiler directly from the shell:

gcc program.c -o program

If we want it as a makefile, we could naively try and compose it like this:

program:
    gcc program.c -o program

Make sure you use only TAB, not spaces. Attempting to use spaces will greet you with a not so descriptive error:

Makefile:4: *** missing separator.  Stop.

The first time we run this, program will be created. The second time, make will state that 'program' is up to date. That is because the binary file already exists. But this is where the problem will be evident with this approach: if we modify program.c and then run make, we will see that program has not been recompiled.

We can fix this by adding a dependency:

program: program.c
    gcc program.c -o program

If we now run make again, it will run if program does not exist, or if program.c is newer than program.

This difference is critical, and very important to the basic idea of how make works. Basically, it is now checking if the dependencies of target have changed since program was last compiled. In other words, if program.c is modified, running make should recompile the file. Otherwise, if program.c has not changed, then it will not.

There is a small caveat here: make uses the timestamps from the filesystem to figure out if something has been changed. Normally, this is good enough, given that in general, file timestamps will only change if the files themselves are modified. But it is important to realize that this is not always the case, and for example, it is possible to modify the timestamps of files into the past (or future) without modifying the actual file. make would have no way to realize if such timestamp modifications took place.

As a final note, the overall structure in the above snippet is referred to as a rule. Makefiles will usually consist of multiple rules. So to recap:

target: dependency
    commands
# this whole block is a rule

A Sidenote on some important Special Variables and Quirks

Make provides a few special variables to allow us to take shortcuts. For example, we have a bit of a duplication going on with referencing program.c twice. This can be shortened with the $< variable. This variable will reference the first dependency:

program: program.c
    gcc $< -o program

We can also deal with multiple dependencies in a similar way, using the $^ special variable. Assuming we have some files a b c, we can grab them all like this:

multi: a b c
    for i in $^; do echo "Listing: $$i"; done

Executing this target:

$ make multi
for i in a b c; do echo "Listing: $i"; done
Listing: a
Listing: b
Listing: c

What is also useful to note here, is that make executes commands using the shell, which is what makes the looping above possible. However, bash’s $ needs to be escaped with a $, otherwise it would conflict with the syntax of make itself. Though, if the bash is going to be longer than a few lines, it’s probably a good idea to store it in a separate file and call it from the makefile. This is also because each line in a command is executed in a separate shell, that is:

test:
    cd src
    gcc program.c

Would not work as intuition might suggest. The working directory of the gcc command would not be affected by the cd of the previous line.

As you may notice, make prints out any commands that it’s about to execute. If that bothers you, you can silence it either by sticking a @ in front of it, e.g.

multi: a b c
    @for i in $^; do echo "Listing: $$i"; done

or by using a special flag for declaring targets to be silenced:

multi: a b c
    for i in $^; do echo "Listing: $$i"; done

.SILENT: multi

Variables

Variables can only be strings, they can be defined with the := operator. Introducing variables to our previous makefile:

files := a b c

multi: $(files)
    for i in $^; do echo "Listing: $$i"; done

.SILENT: multi

Referencing variables requires the use of $() or ${}. Variables in makefiles do not need quotes, and quotes have no special meaning, and will be treated as any other character:

files := 'a b c'

This will literally set the value to a b c.

Keep in mind though, that when writing bash into makefiles, quotes continue to apply their usual significance, so they are still needed for functions like echo.

User Inputs

It’s also possible to have user inputs as variables, e.g. using ?=. The ?= operator just means that this variable doesn’t yet have a set value. Then, the user can call make with the variable and a desired value:

ASK ?=

display:
        @echo $(ASK)

Output:

$ make ASK=hi
hi

Include

It may be the case that you want to keep variables in a separate file, to make it easier to adjust specific values to different environment using other tools, like sed. In that case, simply create another file, like such:

$ cat config.mk
ROOT_SRC:=/opt

And then use the include keyword in your Makefile to access and load the contents of the file:

include config.mk

display:
        @echo $(ROOT_SRC)

Output:

$
/opt

Targets

Now that we have a handle on variables, it’s worth to spend a minute or two on how targets can be structured to make the most of them.

By default, make runs the first target when no target is specified as an argument. For this reason, it’s a popular choice to create a target like all to make every target run. E.g.

all: a b c

a:
    touch a
b:
    touch b
c:
    touch c

clean:
    rm -f a b c

The above can be shortened by yet another special variable, $@, which is a variable containing the target name. So here, instead of three rules with a target each, we can have one rule with three targets, and use the $@ variable to fill it out:

all: a b c

a b c:
    touch $@

clean:
    rm -f a b c

This is functionally equivalent to the previous example.

Conditionals

No need to escape to bash for basic conditionals, make has built-in support:

if/else

is_ok ?=

all:
ifeq ($(is_ok), ok)
    @echo "is_ok equals ok"
else
    @echo "is_ok does not equal ok"
endif

Output

$ make is_ok=ok
is_ok equals ok
$ make is_ok=no
is_ok does not equal ok

Check if a variable is defined

ifdef just sees if something is defined at all, does not care about the contents of the variable

example = stuff

all:
ifdef example
    @echo "example is defined"
endif
ifndef example2
    @echo "but example2 is not"
endif

Output:

$ make
example is defined
but example2 is not

Functions

Make has a number of built-in functions to make life easier. We’ll walk through a small sample of them:

Filter

This one can be used to apply a command only to a selection of dependencies. For example:

$ ls
aaa.txt  aab.txt  aba.txt  abb.txt  Makefile

If we want to operate only on files that begin with ab, we can use the filter:

filter: aaa.txt aab.txt abb.txt aba.txt
        @echo $(filter ab%.txt, $^)

In this instance, the special % character acts as a placeholder to mean “any non-empty string”.

Shell

Though bash can be naturally weaved into commands, this is not the case when declaring variables. If we do need bash when declaring a variable, the shell function allows us to reach into the shell environment:

shellvar := $(shell echo "100-1" | bc -l)
echo_shellvar:
    @echo $(shellvar)

This is something we can easily combine with other inputs, such as other variables:

ASK ?=

shellvar := $(shell echo "100-$(ASK)" | bc -l)
echo_shellvar:
        @echo $(shellvar)

Output:

$ make ASK=2
98

If

Checks if the first argument is empty. If it is, then it runs the second argument, otherwise runs the third:

empty :=
important_variable := $(if $(empty),then,else)

display:
    @echo $(important_variable)

Output:

$ make
else

User defined functions

The call function of make allows us to create custom definitions and re-use them elsewhere:

define customfunc
  @echo "$1"
endef

helloworld:
        $(call customfunc, "hello world")

Output:

$ make
hello world

Each argument passed into call corresponds to a number, like $1, $2, etc. It’s possible to chain function calls this way, by using another function as an argument into call.

Revisiting .PHONY

Before wrapping up, let’s revisit .PHONY. This is the real power of it: it allows us to easily use make for things other than compiling C programs, and have it act as a general task runner. Let’s take this example:

all: build deploy fixpermissions clean

build:
    gcc program.c -o program

deploy: build
    scp program root@192.168.2.99:/opt/program

fixpermissions: deploy
    chown somebody:somebody /opt/program

clean:
    rm -f program

.PHONY: deploy build fixpermissions clean

Checking what make would do without execution:

$ make -n
gcc program.c -o program
scp program root@192.168.2.99:/opt/program
chown somebody:somebody /opt/program
rm -f program

We can not only compile some program, but copy it over to another server, assign it to the correct user, etc. As mentioned earlier, make allows us to include files, so it’s easy to see how this could be used to create something very configurable.

If we want to be nice, we can add a help target that prints out a quick “what is what” (not forgetting to put help into .PHONY), and then do .DEFAULT_GOAL := help. This way, if someone just runs make, they can get a useful message instead of a mysterious build execution.

Conclusion

This list is only the tip of the iceberg on the list of functionalities make really provides, but I hope that this introduction has given enough of a start to not feel intimidated by this tool.

In practice, my own Makefile for building and deploying this website is a bit more involved. It handles two different environments a local test VM vs a production VPS, optional rebuilding of the git repository, and synchronizing files with rsync. But basically, the core idea is simple: targets describe tasks, and dependencies ensure they run in the correct order.