The Definitive Guide to Writing a Makefile

If you ever come across C/C++ code on a Unix or Unix-like system, you’ll most likely stumble upon a makefile. As you quickly realize how confusing writing your own can be, you’ll begin cursing at online tutorials for explaining things differently from one another…

Let’s face it, Make isn’t everyone’s cup of tea and writing a makefile nowadays can often come off as a black art. But learning how to read and write makefiles is essential for every C/C++ programmer because of their ubiquitous nature on Linux distributions and their simplicity and convenience for building smaller programs.

Fortunately for you, I’ve had my fair share of headaches while dealing with makefiles and eventually figured out the “right way” to write one, mainly thanks to this article. I will describe below what I believe to be the correct approach to writing a portable makefile. Before reading any further, I highly advise you not to use Make if you’re starting a project from scratch. Take a look at the modern alternatives first, namely the popular and cross-platform CMake that can generate a makefile for your project, or my favorite, the simple and straight-forward Tup. Other good alternatives include SCons, Redo and Ninja.

If you’re still set on using Make to build your program, read on!

Motivation

Make is a build dependency manager that finds out what commands need to be executed in what order to turn a software project from a collection of source files, header files, object files, libraries, etc. into a correct up-to-date version of the program. The purpose of Make is to simplify the process of rebuilding a binary executable from its source code, and to ensure that only source code that has been modified gets recompiled.

Say goodbye to cavalry at the office. Courtesy of xkcd.

As a result, the use of Make significantly reduces the time required to recompile a program, especially for very large projects.

We’re now going to pretend to be game developers writing a game in C++11, which we creatively name “BallGame”. Let’s also pretend that we know what we’re doing and use the OpenGL Mathematics (GLM) and SDL2 libraries in our game. With that said, the project directory might look something like that:

.
├── Makefile
├── src
│   ├── ball.cpp
│   ├── ball.hpp
│   ├── game.cpp
│   ├── game.hpp
│   ├── main.cpp
│   ├── paddle.cpp
│   └── paddle.hpp
├── lib
│   └── libSDL2.a
└── include
    ├── GLM
    │   └── *.hpp
    └── SDL2
        └── *.h
  • The src directory contains all the source files (.cpp) and headers files (.hpp).
  • The lib directory contains the archive files (.a)—also known as libraries—to be statically linked into the final binary.
  • The include directory contains the libraries’ header files that will be included in our source code.

While SDL2 comes with both an archive file libSDL2.a as well as the needed header files found in SDL2, GLM is a header only mathematics library (used for graphics software, if you must know) which only provides header files found in GLM.

The project needs to be compiled! Let’s write a makefile to do it for us.

Basic Syntax

Before we delve into makefiles, it’s important to understand the basic syntax.

Comments and variables

# Hi, I'm a comment!
BINARY  = BallGame  # Dynamically bound assignment to BINARY variable.
BINARY := BallGame  # Statically bound assignment to BINARY variable.
                    # To use/expand the variable, we write $(BINARY).

This StackOverflow answer perfectly illustrates the difference between dynamically bound (value is expanded when the variable is used) and statically bound (value expanded when the variable is declared) assignments in makefiles. In our case, both types of assignments have the same effect.

Comments and variables are optional but give more clarity to the code and help with maintenance later on.

Rules

In addition to comments and variables, makefiles consist of rules. Each rule is made up of three building blocks: targets, dependencies and commands.

target: dependencies
    command
    command
    ...
  • A target is the name of a generated file such as an object file (.o) or executable. However, targets can also be the name of an action to carry out—such as install—which copies generated executable files into some appropriate locations for convenient access. The target name install is not predefined, but a very well-established convention. Same applies to the commonly used target clean. Typing make install will execute the commands associated with the target install.

  • A dependency can be a source file, header file, object file or executable. When the contents of a dependency change, the commands get executed and the corresponding target gets recompiled.

  • A command is an action that Make carries out. Usually a command serves to create a file with the same name as the target if any of the dependencies change. Commands are executed by $(SHELL) which expands to “/bin/sh” by default, meaning that any valid Bourne shell (sh) command can be executed.

Make creates a dependency graph out of all targets, which it topologically sorts to build the dependencies in a valid and efficient order.

Topologically sorted graph where 11 depends on 7, 9 depends on 8 and 2 depends on 5. Image by Derrick Coetzee.

One last thing to take into consideration is indentation. Each command is preceded by a tab. In makefiles, it’s necessary to use tabs, not spaces! Otherwise you’ll be seeing this friendly error a lot:

Makefile:19: *** missing separator.  Stop.

Forgetting to write a tab or typing 4 (or 8) spaces instead of a tab is a very common mistake. Be careful especially if you use a text editor like Vim that’s set up to automatically convert tabs into spaces. With Vim, I just tend to press “u” in normal mode as soon as the file opens to undo the conversion. Not the most practical solution, but it’ll do.

Writing The Makefile

Now that we’ve gone through the basics, it’s time to write our makefile.

Step 1: Source code, object files and binary

Let’s start by assigning some values to variables.

BINARY  := BallGame
SRCS    := $(wildcard src/*.cpp) # ball.cpp game.cpp main.cpp paddle.cpp
OBJS    := $(SRCS:.cpp=.o)       # ball.o game.o main.o paddle.o
  • BallGame is simply the name of the binary to generate.

  • A wildcard function allows certain characters to be given a special meaning; the asterisk (*) for example is given the meaning “all”. The variable in the second assignment will therefore expand to a space-delimited list of all source files found in the src/ directory.

  • The third assignment expands the SRCS variable and replaces all occurrences of “.cpp” with “.o”, resulting in a space-delimited list of all the corresponding object files.

Next, we assign the flag -g:

DEBUG := -g

-g is a GCC flag that enables the use of extra debugging information to make debugging work better in GDB. However, it can make other debuggers crash or refuse to read the program. We’re adding the flag to a variable so that we can use it in multiple places and remove it whenever we use a different compiler or don’t want to attach extra debugging information to the binary file. We only have one flag but could always add more debugging flags.

Step 2: Where are my libraries?

We need to provide the compiler with a path to the library header files included in the source code as well as a path to the libraries (archive files) to be linked to the binary. As previously stated, library header files and archive files can be found inside the include and lib directories respectively.

INCLUDE     := -Iinclude
LIB         := -Llib -lSDL2

CPPFLAGS    += $(INCLUDE)
CXXFLAGS    += $(DEBUG) -Wall -std=c++11
LDFLAGS     += $(LIB)
  • -I is used to specify the path of the directory containing library header files. -Iinclude informs the compiler that the library header files can be found in the include directory.

  • -L is used to specify the path of the directory containing the libraries. -Llib informs the compiler that archive files can be found in the lib directory.

  • -l is the name of the library you want to link to the binary, written without the beginning “lib” and the ending “.a” extension. For the archive file libSDL2.a, we pass -lSDL2.

One remark about variables: you may define some variables in the makefile such as BINARY, but there also exists predefined variables found in the large makefile database. For example, the following variables have special significance:

  • CFLAGS holds flags that will be passed to the C compiler (e.g. version of the C language, C compiler warning settings, or other options specific to the C compiler).
  • CPPFLAGS holds flags that will be passed to the C/C++ preprocessor (e.g. add paths to the header file search mechanism).
  • CXXFLAGS holds flags that will be passed to the C++ compiler (e.g. version of the C++ language, C++ compiler warning settings, or other options specific to the C++ compiler).
  • LDFLAGS holds flags that will be passed to the linker (e.g. libraries against which to link and additional search paths to be used for locating and resolving library dependencies).

The -Wall option flag enables all compiler warning messages. The -std=c++11 option flag indicates to the compiler the C++ version, in this case C++11.

Step 3: Rules, rules & more rules!

Let’s start with this rule:

.PHONY: all clean

.PHONY indicates that the targets all and clean are phony targets. Specifying a target as a phony target saves Make from having to look for a file of that name.

all: $(BINARY)

If no command line arguments are passed to make, it builds the first target that appears in the makefile which is traditionally a symbolic phony target named all. The name all is not predefined, it’s just used by convention to inform that Make will build all that is needed for a complete build. By default, I want Make to build everything which is why all: $(BINARY) is always the first rule.

If command line arguments are passed, the specified target will be built. For example, running make clean will execute commands to clean the project directory.

$(BINARY): $(OBJS)
    $(LINK.cc) $(OBJS) -o $(BINARY) $(LDFLAGS)

This is where all the magic happens. Perhaps one of the most useful and confusing feature of Make is its ability to automatically deduce intermediate targets.

You see, Make is smart enough to deduce that a file ending with the extension “.o” depends on a file with the same basename and the extension “.cpp”. As a result, we do not need to create a rule with object files as targets and explicitly listing corresponding source files as dependencies.

Simply listing all the object files as dependencies to the target binary “BallName” is sufficient for Make to build the object files using the system’s default C compiler or C++ compiler.

Furthermore, it knows how to create the object files from the source files. Let’s see what’s inside “LINK.cc”:

$ make -p | grep "LINK.cc ="
LINK.cc = $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH)

In our case, if we expand the variables we get

LINK.cc = g++ -g -Wall -std=c++11 -Iinclude -Llib -lSDL2.

Now, it would be great if the makefile can clean up after itself.

clean:
    @- $(RM) $(BINARY)
    @- $(RM) $(OBJS)
    @- $(RM) src/.depend

This rule is responsible for removing all the generated files. A quick explanation of the symbols used:

  • Make always prints out every command as it is executed and @ simply suppresses that.
  • - tells Make to keep going if the command fails for some reason.
  • $(RM) simply expands to rm -f.

On make clean, the binary, object files and dependency file (.depend) are removed. Pretty much everything that isn’t source code is removed. Convenient, isn’t it?

Step 4: Header file dependencies

Now consider this scenario: you edit a source file (.cpp) and expect it to get recompiled and linked. It works. Then you edit a header file (.h) and expect all source files which include it to get recompiled, but instead you get:

make: Nothing to be done for 'all'.

What happened? It turns out that Make doesn’t track header file dependencies. Honestly, I find it odd that a build system doesn’t have trivial solution to track header file dependencies.

Make, you had one job... Image by Meme Guy.

There are several methods for dealing with header file dependencies. One way is to use the tool makedepend but we will be using another method that doesn’t require installing an additional tool. Makefiles cannot generate header file dependencies but the GNU compiler certainly can. Our problem simply becomes a matter of informing the makefile of those dependencies.

.depend: $(SRCS)
    @- $(RM) .depend
    @- $(CXX) $(CPPFLAGS) $(CXXFLAGS) -MM $^ | sed -r 's|^([^ ])|src/\1|' > .depend;

A lot is happening here. The target .depend is a hidden file (due to the dot) that contains all the header file dependencies. If any of the source files is modified, the file gets reconstructed. The first time the makefile runs, it will not find a .depend file and will proceed to create it. If the file exists, it will check if any of the source files (.cpp) listed in $(SRCS) have been modified, will proceed to remove the old .depend and will create a new one reflecting the changes.

The sed command sed -r 's|^([^ ])|src/\1|' basically prefixes “src/” to each object file name because we want object files to be in the “src/” directory.

Once the header file dependencies have been generated, we have to make sure to inform the makefile by including the dependencies:

-include .depend

The include directive tells Make to suspend reading the current makefile and to read the contents of .depend before proceeding. And what does .depend contain? You guessed it, the header file dependencies. Here is an example of a .depend file generated by the makefile of a pong game:

src/ball.o: src/ball.cpp src/ball.hpp src/pong.hpp src/paddle.hpp
src/main.o: src/main.cpp src/pong.hpp
src/paddle.o: src/paddle.cpp src/paddle.hpp src/pong.hpp src/ball.hpp
src/pong.o: src/pong.cpp src/pong.hpp src/ball.hpp src/paddle.hpp \
  src/utilities.hpp
src/utilities.o: src/utilities.cpp src/utilities.hpp

Notice the header files listed along with the source files? Now every time the contents of a header file change, the corresponding object files will get recompiled.

Once the makefile finishes executing, the executable BallGame will have been compiled. To run it, simply type ./BallGame.

Note: Tup deals with header file dependencies more elegantly.

Final Makefile

Putting it all together, we get:

BINARY      := BallGame
SRCS        := $(wildcard src/*.cpp)
OBJS        := $(SRCS:.cpp=.o)

DEBUG       := -g

INCLUDE     := -Iinclude
LIB         := -Llib -lSDL2

CPPFLAGS    += $(INCLUDE)
CXXFLAGS    += $(DEBUG) -Wall -std=c++11
LDFLAGS     += $(LIB)

.PHONY: all clean

all: $(BINARY)

$(BINARY): $(OBJS)
    $(LINK.cc) $(OBJS) -o $(BINARY) $(LDFLAGS)

.depend: $(SRCS)
    @- $(RM) .depend
    @- $(CXX) $(CPPFLAGS) $(CXXFLAGS) -MM $^ | sed -r 's|^([^ ])|src/\1|' > .depend;

-include .depend

clean:
    @- $(RM) $(BINARY)
    @- $(RM) $(OBJS)
    @- $(RM) .depend

That’s it! Now you can go ahead and adapt this makefile to your own project.

Final Words

This proposed process for writing a makefile isn’t perfect. I still haven’t figured out how to have object files in a separate obj directory and I’m sure there’s a way to avoid using sed. For two real-world examples where I use makefiles, see my Tetris and Pong clones. With that said, it’s time for me to use something else than makefiles. For bigger projects, I’d better use CMake which I have yet to try. For smaller ones, I’ll stick with Tup. If you have any remarks or suggestions, let me know by leaving a comment below.


Related Posts