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:
-> targets: named tasks or objectives to be accomplished, these become arguments for the
makecommand, ifmakeis executed with no arguments, it’ll execute the first target in the makefile (unless this is changed with the.DEFAULT_GOALvariable)-> dependencies: files or targets required for the execution of a target, the dependency is usually assumed to be a file, but if the dependency of a target is not a file, then the target should be declared as a
.PHONY, which tellsmaketo ignore the filesystem, and always run commands for the target. Otherwise, this dependency could conflict if a folder or file of the same name exists in the directory.-> variables: reusable parameters, they work similarly as they do in bash, they begin with $ just except when declaring
-> commands: instructions to execute for achieving a target
-> rules: relationship between targets, dependencies and commands
-> comments: much like bash, anything that begins with # is a comment that does not get executed
->
.PHONY: a special target that is used to explicitly mark task-based targets, it is used to ensure that commands always run
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.