Make is a build automation tool originally created by Stuart Feldman at Bell Labs in 1976. It should really say something to you that a build tool created almost 50 years ago (at the time of writing this post, anyway) is still among the most widely-used tools for building large-scale C and C++ programs (this tends to be the case, though Make doesn't care what language your project uses). Projects such as the Linux kernel, the GNU Compiler Collection (gcc), Git, and the Python programming language use Make. Needless to say, knowing how to use Make is a valuable skill.
The only problem with this is Make is notoriously difficult to learn. There's no scarcity of criticism surrounding its complexity; many guides and tutorials only further confuse the eager learner. I was one such eager learner, and I failed to learn Make several times. But lately, I feel like I've finally got the hang of it, and I'd like to pass along what I've learned to you so perhaps you won't endure the same frustrations I did.
Note: This guide assumes you are using GNU Make. GNU Make was created by Richard Stallman and Roland McGrath in 1987 as part of the GNU Project. It's the standard implementation of Make these days and adds extensions over the original Make, many of which we'll learn about in this post.
A Gentle Introduction
Make is a build automation tool that is commonly used to build software projects. It reads a file called a "Makefile" that specifies the rules for building the project, and then automatically builds the project by executing the necessary commands.
Here's a simple example of a Makefile:
program: main.o utils.o
gcc main.o utils.o -o program
main.o: main.c
gcc -c main.c
utils.o: utils.c
gcc -c utils.c
This Makefile specifies that the program
executable should be built from the main.o
and utils.o
object files. It also specifies how to build each of these object files from their respective source files (main.c
and utils.c
). To execute it, you'd run make
in the directory where the Makefile resides.
The syntax of a Makefile is based on rules that define how to build a target (usually a file or an executable) from its dependencies (usually other files or object files). Each rule consists of a target, its dependencies, and the commands needed to build the target from its dependencies.
Let's take a closer look at the syntax used in Makefiles.
Makefile Syntax 101
As mentioned in the (hopefully) gentle introduction, a Makefile rule consists of a target, its dependencies, and the commands needed to build the target from said dependencies. Here's the typical structure of a Makefile rule:
target: dependency1 dependency2
command1
command2
In this rule, target
is the name of the file or executable that we want to build, and dependency1
and dependency2
are the files or object files that target
depends on. The commands command1
and command2
are the shell commands that are executed to build target
from its dependencies.
Note that the commands in a rule must be indented with a tab character (not spaces), or execution will fail (with the mildly cryptic Makefile:<line number>: *** missing separator. Stop
). If you're frustrated by this weird, seemingly arbitrary restriction, you're not the first. To quote the seminal UNIX-HATERS Handbook:
"The problem with Dennis’ Makefile is that when he added the comment line, he inadvertently inserted a space before the tab character at the beginning of line 2. The tab character is a very important part of the syntax of Makefiles. All command lines (the lines beginning with cc in our example) must start with tabs. After he made his change, line 2 didn’t, hence the error."
"So what?"" you ask, "What’s wrong with that?"
"There is nothing wrong with it, by itself. It’s just that when you consider how other programming tools work in Unix, using tabs as part of the syntax is like one of those pungee stick traps in The Green Berets: the poor kid from Kansas is walking point in front of John Wayne and doesn’t see the trip wire. After all, there are no trip wires to watch out for in Kansas corn fields. WHAM!"
So...yeah, gotta watch out for that.
Also, you can have multiple commands in a rule, each on a separate line.
Back to the earlier example of a Makefile:
program: main.o utils.o
gcc main.o utils.o -o program
main.o: main.c
gcc -c main.c
utils.o: utils.c utils.h
gcc -c utils.c
In this rule, program
is the name of the executable that we want to build, and it depends on main.o
and utils.o
. The first command in the rule uses gcc
to link the two object files together into the program
executable. The other two rules specify how to build the main.o
and utils.o
object files from their respective source files.
The experienced developer at this point may notice we're repeating file names quite a bit here. One simple typo could bring your entire build to a screeching halt. Fortunately, Make allows us leverage variables and macros in Makefiles to make them more flexible.
Variables and Macros
As aforementioned, Makefiles support the use of variables and macros, which can make them more flexible and easier to maintain. Here's an example of how to define a variable in a Makefile:
CC = gcc
In this example, CC
is a variable that is assigned the value gcc
. We can then use this variable in a rule like this:
program: main.o utils.o
$(CC) main.o utils.o -o program
This rule uses the $(CC)
macro to expand to the value gcc
. This makes the Makefile more flexible, because we can easily change the value of CC
to use a different compiler.
We can also define variables with more complex values. For example:
CFLAGS = -Wall -Werror -O2
Best Practices on = versus :=
By the way, in Make there are two main ways to set variables: with =
and with :=
. The difference between them is when they are evaluated. =
-assigned variables are evaluated when they are used, whereas :=
-assigned variables are evaluated when they are defined (i.e. immediately).
Here's an example to demonstrate the difference:
FOO := $(BAR)
BAR := hello
all:
@echo $(FOO)
In this example, FOO
is set using :=
, which means it is evaluated immediately. At the time it is evaluated, BAR
has not yet been set, so FOO
is set to an empty string. Therefore, the output of the echo
command will be an empty string.
If we swap the two lines that set FOO
and BAR
:
BAR := hello
FOO := $(BAR)
all:
@echo $(FOO)
Now BAR
is set before FOO
, so FOO
will be set to hello
and the output of the echo command will be hello
.
As a best practice, it is generally recommended to use :=
for variables that don't depend on other variables, and =
for variables that do.
We'll talk about that @
before the echo
command in just a few. But first, let's step up the complexity a tad and look at the more advanced features of Make.
Optional Assignment
In Make, ?=
is a conditional variable assignment operator. It assigns the value to the variable if the variable is not already set, but if the variable is already set, then it keeps the existing value and does not override it.
The syntax for this is:
VARIABLE ?= value
For example:
SOME_VAR ?= default_value
target:
@echo "SOME_VAR is $(SOME_VAR)"
Invoking make target
here would yield SOME_VAR is default_value
. However, if we set SOME_VAR
before invoking make
e.g SOME_VAR=custom_value make target
, make
will instead output SOME_VAR is custom_value
.
Pattern-matching Rules
Pattern rules are used to define how to build a target from a set of source files that match a particular pattern. Here's an example:
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
In this rule, the %.o
pattern matches any object file, and the %.c
pattern matches any C source file. The $<
and $@
variables are used to refer to the first dependency and the target, respectively. This rule specifies how to build any object file from its corresponding C source file.
Personally, I was a little confused by this at first, so here's a working example:
project/
├── Makefile
├── src/
│ ├── file1.c
│ ├── file2.c
│ └── file3.c
CC := gcc
CFLAGS := -Wall -Wextra -pedantic -std=c17
TARGET := program
%.o: src/%.c
$(CC) $(CFLAGS) -c $< -o $@
$(TARGET): file1.o file2.o file3.o
ar rcs $@ $^
To build TARGET
, we rely on three dependencies: file1.o
, file2.o
, and file3.o
. These don't exist yet, so Make will look for a matching rule that tells it how to build those dependencies. It finds the matching rule in %.o
, which relies on any files with a .c
extension inside of the src
directory. Since we do indeed have .c
files in src
, Make knows it can start at this %.o
rule.
Make will invoke the %.o
rule for each src/%.c
file. The output of running make
here will be:
gcc -Wall -Wextra -pedantic -std=c17 -c src/file1.c -o file1.o
gcc -Wall -Wextra -pedantic -std=c17 -c src/file2.c -o file2.o
gcc -Wall -Wextra -pedantic -std=c17 -c src/file3.c -o file3.o
ar rcs program file1.o file2.o file3.o
In a subsequent section, we'll talk about Make built-ins, which will enable us to avoid writing out every object file in the dependencies for the TARGET
rule.
Phony
Phony targets were especially confusing to me when I first learned Make. I don't know why, but pretty much every guide or tutorial I've read makes this subject so much more confusing than it needs to be.
Phony targets are just used to define targets that are not associated with files, but rather with actions that need to be performed. Here's an example:
.PHONY: clean
clean:
rm -f *.o program
In this rule, clean
is a phony target that specifies how to remove all object files and the program
executable. Note that we use the .PHONY
directive to tell make that clean
is not a file, but rather a phony target. If we didn't do this — and we happened to have a file in the root directory named clean
— running make clean
would yield make: 'clean' is up to date
because the file named clean
exists (i.e. Make thinks it has been "built" already).
By the way, you can specify many phony targets in a single line, like:
.PHONY: clean test whatever
Conditional Directives
Makefiles support conditional directives that allow us to specify different rules depending on the value of a variable or the existence of a file. You'll probably see this used most often for versioning and cross-platform compatibility support (where building for a different platform means using different source files).
Here's a simple, straight-forward example:
ifdef DEBUG
CFLAGS = -g -Wall
else
CFLAGS = -O2 -Wall
endif
In this example, we use the ifdef
directive to check if the DEBUG
variable is defined. If it is defined, we set the CFLAGS
variable to include debugging symbols (-g
). Otherwise, we set it to optimize the code (-O2
). To set the DEBUG
variable, we could have explicitly defined it inside the Makefile, or we could have exported it in the shell's environment.
At this point, it'd be a good idea to look at other Make built-ins...
Make Built-ins
Make has many built-in functions that can be used to manipulate strings, perform arithmetic, etc. Let's look at a few of the most common and useful ones. For a full accounting of these functions, one can learn about all of them in the GNU Make documentation.
wildcard
The wildcard
function can be used to search for files that match a certain pattern. For example:
SOURCES := $(wildcard *.c)
This will set SOURCES
to a space-separated list of all files in the current directory that end in .c
.
patsubst
The patsubst
function can be used to perform pattern substitution on a string. For example:
SOURCES := $(wildcard *.c)
OBJECTS := $(patsubst %.c, %.o, $(SOURCES))
This will set OBJECTS
to a space-separated list of all files in SOURCES
, but with the .c
extension replaced with .o
. Earlier I hinted could leverage wildcard
and patsubst
to make our working example more efficient. Here's the updated example:
project/
├── Makefile
├── src/
│ ├── file1.c
│ ├── file2.c
│ └── file3.c
CC := gcc
CFLAGS := -Wall -Wextra -pedantic -std=c17
TARGET := program
SOURCES := $(wildcard src/*.c) # Grab all .c files in src/
OBJECTS := $(patsubst %.c, %.o, $(SOURCES)) # Make a list of all .c filenames from src/ but with .o
%.o: src/%.c # Run this rule for each source file
$(CC) $(CFLAGS) -c $< -o $@
$(TARGET): $(OBJECTS) # TARGET depends on all of the object files - one for each .c file in src/
ar rcs $@ $^
.PHONY: clean # Our phony rule for cleaning up the build artifacts
clean:
rm program src/*.o
foreach
The foreach
function can be used to iterate over a list of values and perform an action on each one. For example:
DIRECTORIES := src include lib
make-dirs:
$(foreach dir, $(DIRECTORIES), mkdir -p $(dir);)
This will create the directories src
, include
, and lib
if they do not already exist. The breakdown here is:
target:
$(foreach arg, args-list, command $(arg);)
ifeq
The ifeq
function can be used to conditionally execute a block of Makefile code. For example:
ifeq ($(CC), gcc) # Are we using gcc?
CFLAGS += -std=c99
endif
This will add the -std=c99
flag to the CFLAGS
variable if the CC
variable is set to gcc
. Pretty straight-forward.
Running External Commands from Make
In an earlier example, we saw usage of the echo
command inside of a rule. It's often useful to run external commands as part of a build process. We can do this for many simple commands by simply referencing the command. You'll typically want to prefix the command with @
so Make knows to execute the command, and not print the command itself to stdout:
all:
@echo "beginning build..."
But what if you need output from some external command inside of the Makefile? For example, let's suppose we want to include the current date and time in the output of a build. We can use the $(shell)
function to execute the date command and capture its output:
BUILD_DATE := $(shell date)
all:
@echo "Build completed on $(BUILD_DATE)"
Here, the $(shell)
function is used to execute the date
command and assign its output to the BUILD_DATE
variable. The variable can then be used in a rule to include the date and time in the output.
Again, the @
symbol before the echo command will prevent the command itself from being printed to the terminal. This is useful for keeping the output of your Makefile clean and concise. If you have a lot of commands being run, you may not want to clutter the output with the commands themselves.
Bending the Rules
Let's look at some cool things we can do with rules.
The $(MAKE) Directive
Sometimes it's necessary for one rule in a Makefile to call another rule. This can be done using the $(MAKE)
directive, which tells Make to invoke itself recursively with the specified rule. For example, let's say we have two rules, build
and deploy
, and we want the deploy
rule to invoke the build
rule before executing. Here's how we would do that:
build:
./do_build
deploy:
$(MAKE) build
./do_deploy
Here, the deploy rule calls the $(MAKE)
directive with the build rule as its argument. Make will then recursively invoke itself with the build
rule, and once that is complete it will continue with the deploy
rule.
Private Rules?
Now suppose we have some setup logic we need to perform before running several different targets:
build:
# build commands
test:
# test commands
release:
# packaging commands
setup_files:
# commands to setup files needed by build, test, and release
Do we really need users running setup_files
from the command-line via make setup_files
? What if we want to have this rule because it's shared — and it's convenient to only write it once — but we don't necessarily want others to be able to invoke it through make
?
Well, unfortunately, Make does not have private rules. However, there's a trick you can use to approximate the same behavior:
build: --setup_files
# build commands
test: --setup_files
# test commands
release: --setup_files
# packaging commands
# private rule
--setup_files:
# commands to setup files needed by build, test, and release
By prefixing the "private" rule with --
, we take advantage of the fact that command-line flags are passed with two hyphens — thus, invoking make --setup_files
results in make: unrecognized option '--setup_files'
. Not too shabby.
Passing Arguments to Rules
It can be very useful to pass arguments from one rule to another rule in a Makefile. This can be done using inline variable declarations and the $(var)
syntax to expand them. In the following example, the rule foo
accepts an argument arg
, which is set inline to the string hello world
by the rule bar
.
foo:
@echo "Foo argument: $(arg)"
bar:
@$(MAKE) foo arg="hello world"
To pass arguments to a rule from outside of the Makefile, simply reference a variable that you've set via the command-line:
BIN_NAME ?= program # BIN_NAME defaults to "program"
all:
gcc main.c -o $(BIN_NAME)
Invoking make
would produce a binary called program
, while invoking BIN_NAME=app make
would produce a binary named app
.
Using Multiple Makefiles
In larger projects, it is common to split the Makefile into multiple files for better organization. This can be done using the include
directive.
Let's assume we have the following directory structure:
project/
├── Makefile
├── src/
│ ├── file1.c
│ ├── file2.c
│ └── file3.c
└── include/
├── header1.h
├── header2.h
└── header3.h
Now suppose we'd like to split the Makefile into multiple smaller Makefiles and include them in our main Makefile.
For example, we can create a sources.mk
file that specifies the source files and a headers.mk
file that specifies the header files. Then, we can include these files in the main Makefile using the include
directive like so
# Makefile
# Include the sources and headers Makefiles
include sources.mk
include headers.mk
# Compiler and linker flags
CC := gcc
CFLAGS := -Wall -Wextra -pedantic -std=c17
LDFLAGS := -L.
# Target executable
TARGET := program
# Object files
OBJS := $(SRCS:.c=.o)
# Rule to build the executable
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lutil
# Rules to build object files from source files
%.o: %.c $(HEADERS)
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
rm -f $(OBJS) $(TARGET)
# sources.mk
# Source files
SRCS := src/file1.c src/file2.c src/file3.c
# headers.mk
# Header files
HEADERS := include/header1.h include/header2.h include/header3.h
In this example, the sources.mk
file specifies the source files, and the headers.mk
file specifies the header files. These files are then included in the main Makefile using the include
directive. The main Makefile defines the compiler and linker flags, the target executable, and the object files. It also includes the rules to build the executable and the object files from the source files.
Using the include
directive allows you to split your Makefile into smaller, more manageable pieces, which can make it easier to maintain and understand your build system.
Note that include
more specifically tells Make to suspend reading the current Makefile and read one or more other Makefiles before continuing. This means if we were to place actual rules in sources.mk
or headers.mk
, they will be executed if matched. Keep this in mind when composing Makefiles together.
Conclusion
This has been a distillation of things that took me several tries and many projects to grasp. Ultimately, the best way to learn any tool is to get your hands dirty, so I recommend applying the knowledge in this guide by using Make in your next project. Remember, while Make is most often used for C projects, it's language agnostic. You could even leverage Make to automate something that has nothing to do with code (sort of analogous to how various government agencies use Git)!
And if you really want to step up to the current industry standard, I recommend looking at CMake, a build system generator that is often used to generate Makefiles.