Makefiles

This page is a light-weight introduction to the tool make and to Makefiles.

Makefiles are quite powerful and, while sometimes considered antiquated compared to tools like cmake, gradle, and scons, it remains the dominant build automation tool in the GNU and Linux ecosystems. You’re welcome to read a little more or read a lot more than this one example summary if you wish.

1 Build Management

There are many build management tools. They exist for many reasons, but a few of the main ones are

Broadly speaking, there are three approaches to managing build processes.

2 make Basics

This section outlines the most basic, common approach to creating a Makefile. Everything in this section has exceptions or alternatives, but this section does not mention them.

2.1 Naming

Capitalization matters. There are several implementations of make and it runs on many file systems; some treat upper- and lower-case letters as interchangeable, others do not. To be safe, always assume that everything make does is case-sensitive, and never have two file names that differ only in capitalization.

When you run make, it looks for a file named Makefile in the current working directory.

make works best if file names lack spaces. Don’t name a file my file.c, nor a folder some folder; use something like my_file.c or some-folder instead.

Many advanced Makefile features depend on file extensions. While the C compiler doesn’t care whether your file names end in .c and .h or not, make runs more smoothly if they do.

2.2 Variables

Makefiles allow you to define variables. These are traditionally given all-upper-case names. They are defined by having = between the variable name and its meaning (the spaces are important).

The following line in a Makefile defines a variable named CFLAGS:

CFLAGS = -g -Wall -Werror -pedantic-errors -std=c17

The value of the variable would be written in C as "-g -Wall -Werror -pedantic-errors -std=c17".

Variables are not automatically expanded; instead, to get their value, you have to place them inside parentheses preceded by a dollar sign.

Continuing the previous example,

    cc CFLAGS -c dll.c

would literally run the shell command cc CFLAGS -c dll.c.

    cc $(CFLAGS) -c dll.c

would instead expand the variable CFLAGS to its definition and run the shell command cc -g -Wall -Werror -pedantic-errors -std=c17 -c dll.c.

2.3 File-creation Rules

Most programming languages have some process whereby one file is created from one or more other files by invoking some compilation command. This is so common and universal, it is the main feature that make supports.

There are three parts of a file-creation rule:

  1. The file name to be created.

    If any other rule needs this file, this rule will be used to create it.

  2. The dependencies of the file.

    These all need to exist (either be created by another rule or be present on disk) for the rule to work.

    If the file being created exists but is older than any of its dependencies (as reported by the file system’s tracking of file modification times), the rule will be used to re-create the file.

  3. The commands to run to create the file.

In the Makefile, these three parts are presented as follows:

  1. The file name to be created
  2. A colon and space
  3. Each dependency, separated by spaces
  4. Each command to be used to create the file, preceded by a newline and a tab character1 This must be a tab character U+0009 "\t", not space characters U+0020 " ".

Both the dependencies and the commands are optional, though at least one of the two must be included.

The following is a file-creation rule

dll.o: dll.c dll.h
    cc $(CFLAGS) -c dll.c

It explains how to create the file dll.o.

The dependencies of dll.o are dll.c and dll.h, so if either of those is newer than dll.o the rule will be used to re-create dll.o.

There is just one command used to create dll.o; assuming the same CFLAGS variable as in previous examples, that command is cc -g -Wall -Werror -pedantic-errors -std=c17 -c dll.c.

2.4 Phony Rules

If you run make, it will try to build the first filename that has a file creation rule.

If you run make test, it will try to build a file named test, if there’s a rule for that.

Often we want to have a few rules that don’t make any files. These are provided by using a special line in the Makefile identifying those rules as phony: they don’t create a file, so there’s no checking to compare the age of the file to the age of its dependencies. This is indicated by a rule-like line starting .PHONY: following by the rules that are not file names.

The following shows three common phony rules:

.PHONY: all clean test

all: dll_c dll_cpp

clean:
    rm -f dll_cpp dll_c dll.o

test:
    python3 tests.py

The first line indicates that all, clean, and test are all phony rules, not creating files.

The all rule has only dependencies; this means that make all will make both dll_c and dll_cpp.

The clean and test rules have only commands. clean removes some files (typically, clean is used to remove all files that other rules could create); and test runs a program that does not create any files (typically, test is used to run any unit tests supplied with the code).

2.5 Special command variables

It is common for commands to refer to the file name and dependencies of a rule. Repeating those filenames in both places leads to potential for mistakes where they don’t match, so make has special variables that can be used in commands to avoid having to type them twice.

$@
This means the name of the file being created.
$^
This means the entire list of dependencies.
$<
This means the first dependency in the list of dependencies.

Compilation commands often have one source file and several header files in their dependencies. The source files should be part of the compilation command, but the header files should not. This might result in a rule like the following:

dll_cpp: usedll.cpp dll.hpp
    c++ $< -o $@

which expands out to the same thing as

dll_cpp: usedll.cpp dll.hpp
    c++ usedll.cpp -o dll_cpp

Linking commands often have several compiled object and/or source files as their dependencies, all of which should be linked together. This might result in a rule like the following:

dll_c: dll.o usedll.c
    cc $(CFLAGS) $^ -o $@

which expands out to the same thing as

dll_c: dll.o usedll.c
    cc $(CFLAGS) dll.o usedll.c -o dll_c

3 Patterns and Functions

When projects expand to include many files, it is common that large numbers of them all use very similar rules in the Makefile. To avoid having to write out all the rules individually, Makefiles allow us to create a general pattern of rules. If a % appears in a file name to be created, it can match any part of a filename and can also be used in the dependencies to match the same string.

The following rule

workloads/%.o: workloads/%.c
    $(CC) -c $< -o $@

means to make any file workloads/something.o, the dependency is workloads/the same thing.c and the command is $(CC) -c $< -o $@. This can make workloads/backstep.o from workloads/backstep.c, and workloads/linked_list.o from workloads/linked_list.c, and so on for as many files as match that pattern.

These patterned rules typically work best when all the files they could create are used as the dependency of some other rule. To facilitate getting many files all in the dependency list, make has various functions that are often used when defining variables. Function invocations look like variable instantiation, but inside the parenthesis is the function name, a space, and then the function’s arguments separated by commas. The two functions I see the most often are wildcard and patsubst:

wildcard

This takes a single argument, a filename with *s in it, which is expanded similarly to how ls operates.

The function invocation $(wildcard workloads/*.c) becomes all of the .c files in the workloads/ folder, separated by spaces.

patsubst

This takes three arguments: a pattern to match, a pattern to replace it with, and a space-separated list of strings to make the replacements in.

The function invocation $(patsubst %.c,%.o,dll.c usedll.c) becomes dll.o usedll.o.

These are often used together to create a set of file names to create based on a set of source files that exist.

Consider the following parts of a single Makefile, in particular the one used for MP4.

CASES := $(patsubst %.c,%.so,$(wildcard workloads/*.c))

The snippet above causes the variable $(CASES) to expand to many filenames workloads/something.so, one for every workloads/something.c that exists on the disk.

workloads/%.so: workloads/%.o
    $(CC) -shared -fPIC $^ -o $@

The snippet above explains how to make one of those workloads/something.so files, with workloads/something.o as a dependency.

workloads/%.o: workloads/%.c
    $(CC) -c $< -o $@

The snippet above explains how to make one of those workloads/something.o files, with workloads/something.c as a dependency.

.PHONY: all test clean build

all: tester mytest.so $(CASES)

The snippet above says that all is a phony rule that makes the files tester and mytest.so, and also makes a workloads/something.so from each workloads/something.c. Coupled with the earlier rules, each .so file first makes a .o file.