The end of a semester is here and, as I grade our student's semestral works, I get to use Makefiles and CMakeLists of dubious quality[1]. After seeing the same errors repeated over and over again, I decided to write a short tutorial towards writing simple Makefiles and CMakeLists. This is the Make tutorial, the CMake one can be found here.

Through these tutorials, I'll use a very simple example from one of our labs. It is start of an implementation of growing array (ala std::vector), consisting of 5 files:

  • main.cpp
  • vector.hpp
  • vector.cpp
  • array.hpp
  • array.cpp

Their exact contents do not matter[2], but main.cpp includes vector.hpp, vector.cpp includes array.hpp and both vector.cpp and array.cpp include their respective headers, vector.hpp and array.hpp.

It is important to note that these tutorials are not meant to build a bottom-up understanding of either of the two, but rather provide a person with an easy-to-modify template that they can use for themselves and quickly get back to the interesting part -- their code.

Make

Make is a general purpose task automation tool, but it's most common use is in building things from source. It even has some built-in rules, e.g. for building .o files from .cpp files, but relying on these is often considered bad form.

Makefiles are a set of targets, where each target can have many dependencies, and each target has a set of commands that will satisfy this target. The structure of a single target is:

target: dependency1 dependency2 dependency3 ...
    command1
    command2
    ...

Note that commands are preceded by a tab, not spaces!

An example Makefile that is fairly common in online examples looks something like this:

CXXFLAGS += -Wall -Wextra -std=c++14

SOURCES = array.cpp vector.cpp main.cpp
OBJECTS = $(SOURCES:.cpp=.o)

.PHONY: clean all

all: vector-test

%.o: %.cpp
    $(CXX) $(CXXFLAGS) $^ -o $@ -c

vector-test: $(OBJECTS)
    $(CXX) $(CXXFLAGS) $^ -o $@


clean:
    rm -f *.o vector-test

This Makefile lets the user call

  • make to compile the vector-test binary from specified sources
  • make clean to remove all build artifacts (object files and the final binary)

I will go over how it works later, as this Makefile has a very important problem: it does not track dependencies between implementation files and header files, so if vector.hpp changes, it will not recompile any object files. We can fix this in two ways, the most straightforward of which is tracking the dependencies manually, by adding these targets to our Makefile:

array.o: array.hpp
vector.o: vector.hpp array.hpp
main.o: vector.hpp

This works but obviously does not scale well. What we can do instead, is to fill in the dependencies automatically, with compiler's help. The compiler has to resolve all dependencies of an object file during compilation (as it has to include each header it depends on) anyway, so all we need is to get the information into a Make-friendly format.

Luckily both GCC and Clang can already output dependencies in Make format, so all we need is to change our Makefile somewhat and add these two lines:

CXXFLAGS += -MMD -MP
-include $(OBJECTS:.o=.d)

-MMD tells the compiler to output a Makefile snippet for each compiled .cpp file, and save it into a .d file with the same name. As an example, for main.cpp it will output this snippet:

main.o: main.cpp vector.hpp

-MP then tells the compiler to also output a non-dependent target for each header file it encounters. This prevents Make errors if header files are renamed/deleted/moved, and it attempts to use the old dependency information, as it thinks it can create the missing header. The output for main.cpp will now look like this:

main.o: main.cpp vector.hpp

vector.hpp:

Finally, include $(OBJECTS:%.o=%.d) tells Make to include all .d files created by compiling object files, and the - prefix tells it to ignore error during inclusions -- this prevents errors when compiling the project from scratch when corresponding .d files have not yet been created.

We should also extend the clean target to remove the generated .d files.

The final Makefile should look something like this:

CXXFLAGS += -Wall -Wextra -std=c++14 -MMD -MP

SOURCES = array.cpp vector.cpp main.cpp
OBJECTS = $(SOURCES:.cpp=.o)

.PHONY: clean all
.DEFAULT_GOAL := all

all: vector-test

%.o: %.cpp
    $(CXX) $(CXXFLAGS) $< -o $@ -c

vector-test: $(OBJECTS)
    $(CXX) $(CXXFLAGS) $^ -o $@

clean:
    rm -f *.o vector-test *.d
    
-include $(OBJECTS:.o=.d)

This Makefile provides basic functionality, that is

  • Functional header dependency tracking -> if a header changes, all dependent files will be recompiled
    • And only the dependent ones
  • make clean removes build artifacts
    • And it works even if a file named clean is present in the folder
  • Default target, so make will build the desired binary
  • An all target, so make all will build all binaries
  • The files will be compiled with reasonable warnings and C++ standard enabled

How does it work?

The first line appends extra flags -Wall -Wextra -std=c++14 -MMD -MP to variable CXXFLAGS. The reason the flags are appended is that it allows users of the Makefile to add to the flags easily. E.g. CXXFLAGS=-Weverything make all would mean that CXXFLAGS would expand into -Weverything -Wall -Wextra -std=c++14 -MMD -MP inside the makefile.

The third line defines variable SOURCES as a list of three files, main.cpp, vector.cpp and array.cpp. Fourth line the defines a variable OBJECTS as a list of files create from SOURCES by replacing the .cpp suffix of each file with .o suffix.

Next up we use a special target called .PHONY to denote that certain targets are not files, but rather names for a set of tasks. This means that they will be run even if a file with this name already exists. Next, we modify what happens when make is invoked without a target, by setting special variable .DEFAULT_GOAL. By convention, plain make invocation is expected to build everything, so we explicitly set it to all.[3]

Next up are 4 targets, all, a wildcard target, vector-test and clean. By convention, all makefiles should provide a target named all, that builds all binaries in the makefile. Similarly, all makefiles should provide a target named clean, that returns the directory into the original state (i.e. deletes build artifacts and generated files).

Target vector-test describes how to build the desired binary. It depends on all of the object files and is created by invoking $(CXX) $(CXXFLAGS) $^ -o $@ on the command line. $^ is an implicit variable containing all dependencies, $@ is another implicit variable containing target's name. Desugared a bit, the command becomes $(CXX) $(CXXFLAGS) $(OBJECTS) -o vector-test. This is another convention used in makefiles, binaries have the same name as the target that generates them (or targets have the same name as binaries that they generate).

The last undescribed build rule is a wildcard rule %.o: %.cpp. This rule applies to every file ending in .o (or to all object files). It says that each object file depends on an implementation file of the same name, and is generated by invoking $(CXX) $(CXXFLAGS) $< -o $@ -c. This command uses another implicit variable, $<, containing the name of the first dependency.[4]

Closing words

I think that both Make and CMake are terrible. Make is horrible because it does not handle spaces in paths, contains some very strong assumptions about running on Linux (and maybe other POSIX systems) and there are many incompatible dialects (GNU Make, BSD Make, NMake, the other NMake, etc.). The syntax isn't anything to write home about either.

CMake then has absolutely horrendous syntax, contains a large amount of backward compatibility cruft and lot of design decisions in it are absolutely mindboggling -- across my contributions to OSS projects I've encountered enough crazy things that they need to be in their own post.

Still, I am strongly in favour of using CMake, if just for supporting various IDEs well and being able to deal with Windows properly.


  1. Last time we didn't teach either, but we will probably add a short introduction to both to the next semester. ↩︎

  2. In case you do care, here is a link to one of the zips that contains them. ↩︎

  3. If .DEFAULT_GOAL is not set, then the first non-special target is built when make is called without a goal. .DEFAULT_GOAL also cannot contain more than 1 target. ↩︎

  4. This prevents compiling object files from multiple inputs if the object file has a dependency. ↩︎