The quest to the perfect Makefile

Posted on Wed 17 October 2018 in Programming

I have been using cmake for a while because I think writing good Makefile is a very tricky task. Unfortunately, cmake is not always available and writing a Makefile is the only solution. I decided to spend some time on writing a small Makefile structure that I could use for new projects and would share my journey to this quest with you.

The material of this article is also available on gitlab

Requirements

Here is what I expect from a well written Makefile:

  • Takes well care of parallel builds
  • Makes easy to add a new source files
  • Makes cross compilation easy
  • Has fancy and debug output
  • Rebuilds only and everything that is required on changes
  • Can enable debug or release compilation mode
  • Builds the unit tests when requested
  • Finds depedencies to other libraries automatically
  • Generates sources automatically

That may sound a lot but it should be the bare minimal to make it usable in complex projects.

The project

To illustrate the usage of this Makefile, I will implement an overengineered Fibonnaci numbers computation. I won't spend too much time on the details here but this will give us some source files to compile.

The base structure

Let's start writing the basic structure of our project with a entry point and some interfaces. The tree looks like this:

├── Makefile
└── src
    ├── FibonacciNumbersRecursed.cpp
    ├── FibonacciNumbersRecursed.hpp
    ├── IFibonacciNumbers.cpp
    ├── IFibonacciNumbers.hpp
    └── main.cpp

We have an entry point in main.cpp, an interface to compute fibonacci numbers and a first implementation that does nothing for now. Let's now write a basic Makefile.

The first Makefile

First we define some useful variables that can be reused later:

PROJECT             = fibonacci
BUILD_DIR           ?= build

The binary and the list of source files will also be saved in a variable:

APP_BIN             = $(BUILD_DIR)/$(PROJECT)
APP_SOURCES         = src/IFibonacciNumbers.cpp \
                      src/FibonacciNumbersRecursed.cpp \
                      src/main.cpp

Here we create a list of compiled .o files from the list of sources, that can be used as a prerequist list.

APP_OBJS            = $(patsubst %.cpp,$(BUILD_DIR)/%.o,$(APP_SOURCES))

Define here all the compiler flags:

COMMON_CFLAGS       = -Wall -Werror -Wextra
CFLAGS              += $(COMMON_CFLAGS)
CXXFLAGS            += $(COMMON_CFLAGS) -std=c++14

Now we have all the variables we need, we can start writing a default target that is by convention all. It will simply build the main binary:

all: $(APP_BIN)
.PHONY: all

Now how to link the binary:

$(APP_BIN): $(APP_OBJS)
    $(CXX) -o $@ $(APP_OBJS)

How each source will be built:

$(BUILD_DIR)/%.o: %.cpp
    mkdir -p $(dir $@)
    $(CXX) $(CXXFLAGS) -c $< -o $@

Finally a small clean target:

clean:
    rm -rf $(BUILD_DIR)
.PHONY: clean

First make commands

Let's give a first attempt to make to build our application:

$ make
mkdir -p build/src/
g++ -Wall -Werror -Wextra -std=c++14 -c src/IFibonacciNumbers.cpp -o build/src/IFibonacciNumbers.o
mkdir -p build/src/
g++ -Wall -Werror -Wextra -std=c++14 -c src/FibonacciNumbersRecursed.cpp -o build/src/FibonacciNumbersRecursed.o
mkdir -p build/src/
g++ -Wall -Werror -Wextra -std=c++14 -c src/main.cpp -o build/src/main.o
g++ -o build/fibonacci build/src/IFibonacciNumbers.o build/src/FibonacciNumbersRecursed.o build/src/main.o

$ ./build/fibonacci
Computing using implementation recursive
Fibonacci number of 3 is ... 3

The application is not implemented but at least it generates a working executable. Let's see what goals we have achieved until now.

Takes well care of parallel builds

This is important to build the project faster by making use of all CPUs. We can check if make behaves correctly with the -j option. After cleaning and rebuilding the project with different jobs, it always succeeds. We can also see that the sources files are built in parallel:

$ make
$ make clean
$ make -j2
$ make clean
$ make -j3
$ ...

Makes easy to add a new source file

When adding a new source file, the Makefile should have as least changes as possible. To demonstrate this, we simply add a new Fibonacci number implementation and update the source list:

 APP_BIN             = $(BUILD_DIR)/$(PROJECT)
 APP_SOURCES         = src/IFibonacciNumbers.cpp \
                       src/FibonacciNumbersRecursed.cpp \
+                      src/FibonacciNumbersDynamic.cpp \
                       src/main.cpp
 APP_OBJS            = $(patsubst %.cpp,$(BUILD_DIR)/%.o,$(APP_SOURCES))

The application is updated in order to be able to choose the implementation at runtime. We can now check that it works as expected:

$ make
# ...

$ ./build/fibonacci -t 0
Computing using implementation recursive
Fibonacci number of 3 is ... 3

$ ./build/fibonacci -t 1
Computing using implementation dynamic
Fibonacci number of 3 is ... 3

Some poeple would prefere having a function that automatically searches for all the source files with something like this:

SOURCES = $(shell find src -name '*.cpp')

However, we lose a bit of control on which source file we want to include in the build in case we support different platforms which don't use the same sources files. This is why I'm fine with adding a line in the Makefile when I add a new source file in the project for more control on what is being built.

Makes cross compilation easy

When using multiple hardware, it should be easy to build for another architecture with the minimal effort. In our case, cross compiling can be done by defining another build directory and compiler on the command line:

$ make BUILD_DIR=build_arm CXX=arm-linux-g++
# ...

Has fancy and debug output

We don't want make to print the executed commands that is very verbose, this can be done by adding the @ character at the beginning of the command. When something goes wrong, we need to see what is done. This should be enabled with an environment variable.

First we define an environement variable to enable the verbose mode:

ifneq ($(V),)
  SILENCE           =
else
  SILENCE           = @
endif

Update all the recipes consequently:

 $(APP_BIN): $(APP_OBJS)
-       $(CXX) -o $@ $(APP_OBJS)
+       $(SILENCE)$(CXX) -o $@ $(APP_OBJS)

 $(BUILD_DIR)/%.o: %.cpp
-       mkdir -p $(dir $@)
-       $(CXX) $(CXXFLAGS) -c $< -o $@
+       $(SILENCE)mkdir -p $(dir $@)
+       $(SILENCE)$(CXX) $(CXXFLAGS) -c $< -o $@

 clean:
-       rm -rf $(BUILD_DIR)
+       $(SILENCE)rm -rf $(BUILD_DIR)
 .PHONY: clean

Now we can enable the verbose mode by setting the V environment variable to any value:

make V=1
# ...

Unfortunately, in silent mode, we don't see anything:

$ make
$

Some feedback is always good in order to know what happens behind the hood. However, we can print something nicer than the build command and use for that some printf that can be reused:

SHOW_COMMAND        := @printf "%-15s%s\n"
SHOW_CXX            := $(SHOW_COMMAND) "[ $(CXX) ]"
SHOW_CLEAN          := $(SHOW_COMMAND) "[ CLEAN ]"

Update the recipe to print the status of the build:

 $(APP_BIN): $(APP_OBJS)
+       $(SHOW_CXX) $@
       $(SILENCE)$(CXX) -o $@ $(APP_OBJS)

 $(BUILD_DIR)/%.o: %.cpp
+       $(SHOW_CXX) $@
       $(SILENCE)mkdir -p $(dir $@)
       $(SILENCE)$(CXX) $(CXXFLAGS) -c $< -o $@

 clean:
+       $(SHOW_CLEAN) $(BUILD_DIR)
       $(SILENCE)rm -rf $(BUILD_DIR)
 .PHONY: clean

Now the bulid is more verbose but still very comfortable to watch:

$ make
[ g++ ]        build/src/IFibonacciNumbers.o
[ g++ ]        build/src/FibonacciNumbersRecursed.o
[ g++ ]        build/src/FibonacciNumbersDynamic.o
[ g++ ]        build/src/main.o
[ g++ ]        build/fibonacci

$ make clean
[ CLEAN ]      build

Nice!

Rebuilds only and everything that is required on changes

This is very important for big projects, we don't want to recompile everything if we changed only one source file. When using unit tests, we want to have a feedback aboout our changes as fast as possible, to achieve this we need to recompile only the files that changed.

As a first test, we will check what happens if we update a source file:

$ touch src/IFibonacciNumbers.cpp

$ make
[ g++ ]        build/src/IFibonacciNumbers.o
[ g++ ]        build/fibonacci

The file gets compiled and the binary linked again, this seems correct. However, having a nice depedency of headers is more difficult to maintain as demonstrated here:

$ touch src/IFibonacciNumbers.hpp

$ make
make: Nothing to be done for 'all'.

We would expect main.cpp and IFibonacciNumbers.cpp to be compiled again as those include the modified header. We could manually list the header depedency for each source file like this:

main.o: main.cpp IFibonacciNumbers.hpp
IFibonacciNumbers.o: IFibonacciNumbers.cpp IFibonacciNumbers.hpp
# ...

But this is a nightmare to maintain and is source of mistakes when the project evolve! Fortunately, the compiler is able to generate this list of depedencies automatically with the -MMD argument that will generate a file with the .d extension. First we add this flags:

-COMMON_CFLAGS       = -Wall -Werror -Wextra
+COMMON_CFLAGS       = -Wall -Werror -Wextra -MMD

Out of curiosity, let's have a look at the list of generated files:

$ make
# ...

$ find build/ | grep \\.d\$
build/src/main.d
build/src/FibonacciNumbersRecursed.d
build/src/FibonacciNumbersDynamic.d
build/src/IFibonacciNumbers.d

$ cat build/src/main.d
build/src/main.o: src/main.cpp src/IFibonacciNumbers.hpp

A rule is automatically generated with the list of headers, exactly what we need! We must now include those rules in our Makfile by defining a list of generated depedency files, that is the list of objects with the .d extension:

DEPS                = $(APP_OBJS:.o=.d)

And include them right after the default target:

all: $(APP_BIN)
.PHONY: all

-include $(DEPS)

Now verify that it works as expected when modifying different header:

$ touch src/IFibonacciNumbers.hpp
$ make
[ g++ ]        build/src/IFibonacciNumbers.o
[ g++ ]        build/src/FibonacciNumbersRecursed.o
[ g++ ]        build/src/FibonacciNumbersDynamic.o
[ g++ ]        build/src/main.o
[ g++ ]        build/fibonacci

$ touch src/FibonacciNumbersDynamic.hpp
$ make
[ g++ ]        build/src/IFibonacciNumbers.o
[ g++ ]        build/src/FibonacciNumbersDynamic.o
[ g++ ]        build/fibonacci

Much better!

Can enable debug or release compilation mode

We often need a debug version during the development but the application should be shipped in release mode. To switch from a mode to another, we will use an environement variable and change the flags consequently. We also change the build directory to not mix objects compiled with different flags:

COMMON_CFLAGS       = -Wall -Werror -Wextra -MMD

ifneq ($(DEBUG),)
  COMMON_CFLAGS     += -g
  BUILD_DIR         := $(BUILD_DIR)/debug
else
  COMMON_CFLAGS     += -DNDEBUG -O3
  BUILD_DIR         := $(BUILD_DIR)/release
endif

CFLAGS              += $(COMMON_CFLAGS)
CXXFLAGS            += $(COMMON_CFLAGS) -std=c++14

To verify that the flags are passed correctly we run make in verbose mode:

$ make V=1
g++ -Wall -Werror -Wextra -MMD -DNDEBUG -O3 -std=c++14 -c src/IFibonacciNumbers.cpp -o build/release/src/IFibonacciNumbers.o

$ make V=1 DEBUG=1
g++ -Wall -Werror -Wextra -MMD -g -std=c++14 -c src/IFibonacciNumbers.cpp -o build/debug/src/IFibonacciNumbers.o

Builds the unit tests when requested

During the development, it makes a lot of sense to build and execute the unit tests. For an end user, this is probably not required and should be skippable.

I like the CPPUTest test framework and will use it here. If you use another testing framework, the idea is the same, you would need to adapt the flags consequently. First we write some tests source files that contains an entry point and a test that fails:

.
└── tests
    ├── FibonacciNumbersRecursedTest.cpp
    └── main.cpp

Now we need to remove main.cpp from the applications sources list to not have two entry points when linking the tests:

 APP_SOURCES         = src/IFibonacciNumbers.cpp \
                       src/FibonacciNumbersRecursed.cpp \
-                      src/FibonacciNumbersDynamic.cpp \
-                      src/main.cpp
-APP_OBJS            = $(patsubst %.cpp,$(BUILD_DIR)/%.o,$(APP_SOURCES))
+                      src/FibonacciNumbersDynamic.cpp
+APP_MAIN            = src/main.cpp
+APP_OBJS            = $(patsubst %.cpp,$(BUILD_DIR)/%.o,$(APP_SOURCES) $(APP_MAIN))

We list the files required for the test the same manner as for the application. We recompile the application sources because CppUTest adds some flags in order to enable the memory leak detection:

TEST_BIN            = $(BUILD_DIR)/$(PROJECT)_tests
TEST_SOURCES        = $(APP_SOURCES) \
                      tests/FibonacciNumbersRecursedTest.cpp \
                      tests/main.cpp
TEST_OBJS           = $(patsubst %.cpp,$(BUILD_DIR)/tests/%.o,$(TEST_SOURCES))

Do not forget to add the tests source to the depedency list:

-DEPS                = $(APP_OBJS:.o=.d)
+DEPS                = $(APP_OBJS:.o=.d) \
+                      $(TEST_OBJS:.o=.d)

We need to add the flags required for CppUTest. We let the user defining a directory where cpputest is installed, otherwise we use pkg-config to detect the flags automatically.

ifneq ($(CPPUTEST_HOME),)
  CPPUTEST_FLAGS    := -I$(CPPUTEST_HOME)/include
  CPPUTEST_LDFLAGS  := -L$(CPPUTEST_HOME)/lib -lCppUTest -lCppUTestExt
else
  CPPUTEST_FLAGS    := $(shell pkg-config --cflags cpputest 2>/dev/null)
  CPPUTEST_LDFLAGS  := $(shell pkg-config --libs cpputest 2>/dev/null)
endif

Add a target to link the test application with the right flags:

 $(TEST_BIN): $(TEST_OBJS)
    $(SHOW_CXX) $@
    $(SILENCE)$(CXX) $(TEST_OBJS) $(CPPUTEST_LDFLAGS) -o $@

$(BUILD_DIR)/tests/%.o: %.cpp
    $(SHOW_CXX) $@
    $(SILENCE)mkdir -p $(dir $@)
    $(SILENCE)$(CXX) $(CXXFLAGS) $(CPPUTEST_FLAGS) -c $< -o $@

For convenience, add a phony target to build and run the tests. If the test application fails, it should be removed and the make command should fail. We want to keep the test application for debugging, in that case we can use the tests target with make, the run_tests target will run them automatically and can be used 99% of the time:

tests: $(TEST_BIN)
.PHONY: tests

run_tests: $(BUILD_DIR)/.tests_passed
.PHONY: run_tests

$(BUILD_DIR)/.tests_passed: $(TEST_BIN)
    $(SILENCE)./$< || rm $<
    $(SILENCE)touch $@

Now we can check that the tests are built and run correctly:

$ make tests
[ g++ ]        build/release/tests/src/IFibonacciNumbers.o
[ g++ ]        build/release/tests/src/FibonacciNumbersRecursed.o
[ g++ ]        build/release/tests/src/FibonacciNumbersDynamic.o
[ g++ ]        build/release/tests/tests/FibonacciNumbersRecursedTest.o
[ g++ ]        build/release/tests/tests/main.o
[ g++ ]        build/release/fibonacci_tests

$ make run_tests

tests/FibonacciNumbersRecursedTest.cpp:54: error: Failure in TEST(FibonacciRecursed, Fibo0Is0)
    CHECK(false) failed

.
Errors (1 failures, 1 tests, 1 ran, 1 checks, 0 ignored, 0 filtered out, 0 ms)

Now that we have a structure that works, we can implement our fibonacci numbers with TDD and check that it works as expected:

$ make run_tests
[ g++ ]        build/release/fibonacci_tests
.......
OK (7 tests, 7 ran, 7 checks, 0 ignored, 0 filtered out, 0 ms)

$ ./build/release/fibonacci -n 45 -t0
Computing using implementation recursive
Fibonacci number of 45 is ... 1134903170

$ ./build/release/fibonacci -n 45 -t1
Computing using implementation dynamic
Fibonacci number of 45 is ... 1134903170

Finds depedencies automatically

When using external libraries, compiler flags should be added. If it is not found, a clear error message should be printed to inform the user what is wrong.

The CppUTest depedency is a good point to start and we can improve the current implementation by building the tests automatically if CppUTest is found.

We will change a bit the Makefile to assume that CppUTest is found when the user gives a path:

ifneq ($(CPPUTEST_HOME),)
  HAS_CPPUTEST          = 1
  CPPUTEST_FLAGS        = -I$(CPPUTEST_HOME)/include
  CPPUTEST_LDFLAGS      = -L$(CPPUTEST_HOME)/lib -lCppUTest -lCppUTestExt

Otherwise we use pkg-config return code to check if the package was found on the system:

else
  HAS_CPPUTEST          = $(shell pkg-config cpputest && echo 1)
  ifeq ($(HAS_CPPUTEST),1)
    CPPUTEST_FLAGS      = $(shell pkg-config --cflags cpputest 2>/dev/null)
    CPPUTEST_LDFLAGS    = $(shell pkg-config --libs cpputest 2>/dev/null)
  endif
endif

We will change the defaut target to run the tests automatically if CppUTest is present:

DEFAULT_TARGET =  $(APP_BIN)

ifeq ($(HAS_CPPUTEST),1)
  DEFAULT_TARGET += run_tests
endif

all: $(DEFAULT_TARGET)
.PHONY: all

To avoid the user trying to build the test when CppUTest is not found, we will throw an error message instead:

$(BUILD_DIR)/tests/%.o: %.cpp
ifneq ($(HAS_CPPUTEST),1)
    $(error CppUTest not found, cannot build the tests)
endif
    $(SHOW_CXX) $@
    $(SILENCE)mkdir -p $(dir $@)
    $(SILENCE)$(CXX) $(CXXFLAGS) $(CPPUTEST_FLAGS) -c $< -o $@

Let's compile the application without CppUTest support:

$ make
[ g++ ]        build/release/src/IFibonacciNumbers.o
[ g++ ]        build/release/src/FibonacciNumbersRecursed.o
[ g++ ]        build/release/src/FibonacciNumbersDynamic.o
[ g++ ]        build/release/src/main.o
[ g++ ]        build/release/fibonacci
$ make run_tests
Makefile:110: *** CppUTest not found, cannot build the tests.  Stop.

Now the same test with a CPPUTEST_HOME defined:

$ export CPPUTEST_HOME=/path/to/cpputest/
$ make
[ g++ ]        build/release/src/IFibonacciNumbers.o
[ g++ ]        build/release/src/FibonacciNumbersRecursed.o
[ g++ ]        build/release/src/FibonacciNumbersDynamic.o
[ g++ ]        build/release/src/main.o
[ g++ ]        build/release/fibonacci
[ g++ ]        build/release/tests/src/IFibonacciNumbers.o
[ g++ ]        build/release/tests/src/FibonacciNumbersRecursed.o
[ g++ ]        build/release/tests/src/FibonacciNumbersDynamic.o
[ g++ ]        build/release/tests/tests/FibonacciNumbersRecursedTest.o
[ g++ ]        build/release/tests/tests/FibonacciNumbersDynamicTest.o
[ g++ ]        build/release/tests/tests/main.o
[ g++ ]        build/release/fibonacci_tests
.......
OK (7 tests, 7 ran, 7 checks, 0 ignored, 0 filtered out, 0 ms)

And with pkg-config method:

$ export PKG_CONFIG_PATH=/path/to/cpputest.pc/
$ make
[ g++ ]        build/release/src/IFibonacciNumbers.o
[ g++ ]        build/release/src/FibonacciNumbersRecursed.o
[ g++ ]        build/release/src/FibonacciNumbersDynamic.o
[ g++ ]        build/release/src/main.o
[ g++ ]        build/release/fibonacci
[ g++ ]        build/release/tests/src/IFibonacciNumbers.o
[ g++ ]        build/release/tests/src/FibonacciNumbersRecursed.o
[ g++ ]        build/release/tests/src/FibonacciNumbersDynamic.o
[ g++ ]        build/release/tests/tests/FibonacciNumbersRecursedTest.o
[ g++ ]        build/release/tests/tests/FibonacciNumbersDynamicTest.o
[ g++ ]        build/release/tests/tests/main.o
[ g++ ]        build/release/fibonacci_tests
.......
OK (7 tests, 7 ran, 7 checks, 0 ignored, 0 filtered out, 0 ms)

Depedencies with other libraries

This can be handled the same manner using pkg-config, here for example detecting if zlib is available:

HAS_ZLIB              = $(shell pkg-config zlib && echo 1)
ifeq ($(HAS_ZLIB),1)
  ZLIB_FLAGS          = $(shell pkg-config --cflags zlib 2>/dev/null)
  ZLIB_LDFLAGS        = $(shell pkg-config --libs zlib 2>/dev/null)
endif

In case the library is required, we can fail and write a clear error message:

ifeq ($(HAS_ZLIB),1)
  #...
else
  $(error Cannot build application, zlib support not found!)
endif

Or maybe the depedency is optional and new sources need to be added. It is possible to define a macro that can be used in the sources to detect if zlib is supported:

ifeq ($(HAS_ZLIB),1)
  ZLIB_FLAGS          = $(shell pkg-config --cflags zlib 2>/dev/null) -D_HAS_ZLIB_
  ZLIB_LDFLAGS        = $(shell pkg-config --libs zlib 2>/dev/null
  APP_SOURCES         += src/Sources.cpp \
                         src/Using.cpp \
                         src/Zlib.cpp
endif

zilb detection can be used as follow in a source file:

#ifdef _HAS_ZLIB_
#include <zlib.h>
#endif

// ...

void foo()
{
#ifdef _HAS_ZLIB_
  // Something with zlib
#endif
}

Just keep in mind that using macros decreases the readibility, it should be concentrated in one place like factory methods and not spread around the projects.

Generates other sources automatically

This can be required when you are using a source code generator such as protocol buffer, if you want to generate .pc files for pkg-config when building a library and many more. In this article, we will simply generate a version file that will contain the usual major, minor, bugfix version with also the git hash.

First we create a template called Version.hpp.in that looks like this:

#ifndef _FIBO_VERSION_HPP_
#define _FIBO_VERSION_HPP_

#include <string>

namespace fibo {

static const unsigned int VERSION_MAJOR   = %%VERSION_MAJOR%%;
static const unsigned int VERSION_MINOR   = %%VERSION_MINOR%%;
static const unsigned int VERSION_BUGFIX  = %%VERSION_BUGFIX%%;
static const std::string VERSION_GIT      = "%%VERSION_GIT%%";

}       // namespace
#endif  // _FIBO_VERSION_HPP_

The version variables will be defined inside the Makefile

# Version information
VERSION_MAJOR       = 1
VERSION_MINOR       = 2
VERSION_BUGFIX      = 3
VERSION_GIT         := $(shell git describe --tag --always --abbrev=5 --dirty)

Then with sed, we can replace the placeholders of the variables:

src/Version.hpp: src/Version.hpp.in Makefile $(APP_SOURCES)
    $(GEN_INFO) $@
    $(SILENCE)sed -e's/%%VERSION_MAJOR%%/$(VERSION_MAJOR)/g' \
      -e 's/%%VERSION_MINOR%%/$(VERSION_MINOR)/g' \
      -e 's/%%VERSION_BUGFIX%%/$(VERSION_BUGFIX)/g' \
      -e 's/%%VERSION_GIT%%/$(VERSION_GIT)/g' \
      $< > $@

Don't forget to add depedency to the Version.hpp file so it gets generated:

-$(APP_BIN): $(APP_OBJS)
+$(APP_BIN): src/Version.hpp $(APP_OBJS)

and remove the generated file in the clean target:

 clean:
        $(SHOW_CLEAN) $(BUILD_DIR)
-       $(SILENCE)rm -rf $(BUILD_DIR)
+       $(SILENCE)rm -rf $(BUILD_DIR) src/Version.hpp

Finally, we update the application to also display the version information with the -v argument and check if it works:

$ ./build/release/fibonacci -v
1.2.3 (3a5e3-dirty)

Conlusion

There is still room for improvements, maybe this would deserve another article as this one is getting slightly long... At least we have reach our goals and most of the usual tasks are covered to build a complex project with make. Stay tuned for further improvements!

As a reminder, the source is available on gitlab do not hesitate to take, share, improve, ...