In this article, we take a look at automating the software build stage using build and systems. We look at what options are available to us and why CMake, in particular, stands out as the most suitable choice for build automation. It enjoys the largest user base among all build systems, and chances are that if you use CFD libraries in your projects, you will come across CMake eventually.
But before jumping straight into CMake, we take a deeper look at Make, which at its core is an automation tool which works particularily well for developing and compiling software. CMake produces Makefiles which can then be processed by Make, and so having a working understand of Make will give you better chances troubleshooting issues later that will arise during compilation using CMake.
Bu the end of this article, you will have gained sufficient knowledge on Makefiles to write your own ones that are generic enough that you can drop them into your project and get the compilation going with minimal changes to the Makefiles. You will also gain a balanced view of what the strengths are of Make and where it has lost ground to other, more cross-platform friendly solutions by looking at how you have to write different Makefiles for UNIX and Windows.
Download Resources
All developed code and resources in this article are available for download. If you are encountering issues running any of the scripts, please refer to the instructions for running scripts downloaded from this website.
- Download: Build-Systems-Part-1
- Download: Hello World
In this series
- Part 1: How to use Makefiles to automate CFD solver compilations
- Part 2: Introduction to CMake for CFD practitioners
- Part 3: Advanced CMake features to power up your CFD development
In this article
Introduction to Build Systems
If you have been an astute reader on this website, and followed all series in order, or, you have heard me rambling on in my weekly newsletter, then it will come as no surprise to you that we are finally looking at build systems. Building software is such a fundamental step in developing any code that it is too important a topic to overlook.
Granted, if all you ever do is write a single source code application, you can just type out the compilation right into your terminal. You don’t need an entire build system behind you (though I will make my case for why I think you should still learn one over this series). Or, you may have stumbled across a different part of the internet and wondered, why am I here? I am using Python/Matlab/Julia/etc., it’s all interpreted, I don’t need a build system (while laughing at your screen). Oh yes, you do, read on, and I’ll tell you why.
Build systems are, at their core, automation powerhouses; they take an input and transform it into a desired output. The bread and butter of every build system is compilation, they take a set of source files, transform them into object files, and then link all of them together into an executable, or static/dynamic library.
But build systems do so much more than just compiling and linking your files; they can prepare the build by copying files into the right location (think test data you need during unit testing), they can automatically download and build dependencies/libraries your project depend on, they can execute external scripts (python/bash/etc.) to perform any number of tasks that are important and bespoke to your project, they can create files dynamically required for compilation and we can even create an installer so that users can easily install our software!
If you are using a compiled language such as C++, then knowing a build system will become vital. Even if you don’t plan to use it in your own project, you will need to have an awareness of the build systems that are available so that you can build external dependencies/libraries which you require within your own project (e.g. integrate the CGNS library to support mesh reading in your solver).
We have already looked at build systems a while back (and of course, you are an astute reader after all, so I don’t need to tell you!). In that article, we developed a sense of what the most important build systems are, and we even wrote some instructions to compile our linear algebra solver library. So I won’t go through all of them again in detail. Instead, let me give you a high-level overview of the most popular build systems; if you want to see how they look in code, then click through to the article linked at the beginning of this paragraph.
- Make: Make is probably the oldest build system around. You specify variables (a list of source files or compiler flags, for example), and then put these together to build up a compilation command. These can be used in so-called targets, which define different actions you can perform, such as compiling your code, creating files, cleaning up build files, and even running your executable.
- MSBuild: Microsoft doesn’t like to take someone else’s code and use it on their pristine platform (you can’t litter that with free and open-source software) and so of course they have developed their own build software that works with only Windows machines. It looks horrible (think XML galore), but it gets the job done. You wouldn’t write it by hand (though I have done that) but rather let a generator create it for you (which we will look at below).
- Autotools: A collection of tools that create Makefiles (for Make) based on some user-specific configurations. In that respect, it doesn’t build any software but rather spits out instructions that Make can understand to build your software. It claims to be cross-platform (it is not), and I hate it in every fibre of my being. In this day and age, you should not use it, but you will still find lots of CFD projects that do, so I need to mention it.
- Ninja: Very similar to Make but much newer. You define targets that you can execute that mainly build your source files into an executable or library. By design, it is much more limited than Make to favour speed over complexity/generality. The designers are clear about their mission: We don’t care about readability; we care about compilation speed. Though, I find it not that difficult to read, especially if you know Make.
- CMake: Similar to Autotools, CMake is a meta-build system that takes inputs such as the source files and required targets (should we build an executable or a static/dynamic library?) and then provides us with the required build files such as Make or Ninja (or MSBuild, if you are so inclined). Why is it exponentially better than Autotools, I hear you cry? Unlike Autotools, it works (that’s always a bonus in my books!), and it is the most used build system, so knowing it means you will be able to compile pretty much any other C++ project.
- Meson: Meson will always hold a special place in my (software engineering) heart as one of the great build systems that should have been but never was. Well, it is actually used by some people, but it has never grabbed the attention that it possibly deserved. It is essentially a CMake clone, let’s face it, and it addressed all of the issues CMake had at the time Meson was created. Since then, CMake has seen the issues themselves (and the threat of Meson grabbing their users?) and has changed significantly as a result. The fact that Meson fixed CMake is reason enough to fall in love with the project (and the lead developer, especially after exchanging a few fan emails back and forth!). Here is a picture and he is entering into my hall of fame of software engineers together with Kent Beck (and proof that software engineers should be part-time models):
- Bazel: Developed by Google, and driven by the same mantra as Microsoft (don’t use someone else’s code), Bazel was developed to build software. Period. It uses some advanced features to really make the build as fast as possible, and it doesn’t depend on build tools like Make or Ninja to do the actual build, i.e. Bazel will compile your source files for you (and not delegate that task like CMake). Every time I look at Bazel, I have to say that I am intrigued. I’ll probably give it a closer look in the near future, but it is unlikely to replace my CMake toolchain.
- SCons: Another build system that, full disclaimer, I have never used but always pops up when looking through a list of frequently used build systems for C++. It has around 18k downloads per month, so it must be in active use (though these numbers can be deceiving as most of these downloads are likely from automated software tests in the cloud). Either way, judging by its syntax, it feels like a weird combination of Make and Python, which just doesn’t look right. Let’s put it this way: I have included it on this list for completeness, not to give my endorsement (I haven’t seen any serious CFD project using SCons).
- Waf: Finally, there is Waf. I have used it extensively, and I am still not sure what to think of it. It is either absolute genius or insanity; it sits right at the border, and I guess it depends on your views that will decide which side it is on. There are some pretty neat features (that you will find in other build systems as well), but it feels more like writing Python code than working with a build system. Perhaps this is why I like it (as a converted Python lover), but its documentation is bad, so I probably never really got to the point of it. Still, it is a great tool, and if you are looking for a kick of madness for your project, why not choose Waf?
With a rundown of the most used build systems (for C++-based projects, there are loads more for mobile and web), what I want to do in the rest of this article is to go through Make in much greater detail. I really like Make from an educational perspective because you need to understand how compilation works to get your Makefiles to work. Once you are able to write Makefiles, you will have the skills required to debug any compilation issues with other build software, and so for that fact alone, it is worth spending some time on it.
There is, of course, a reason why we don’t use Make anymore for serious development (I should be careful here; most academic CFD solvers still use Make; even OpenFOAM is essentially using Make for compilation, making it so difficult to port it to Windows). By the end of this article, you will see the value of understanding Make for your own learning process, but you will also see the limitations, especially when it comes to cross-platform support. Without further ado, let’s jump straight into Make.
What are Makefiles (and why should I care)?
The year is 2014. I just left my last research assistant position and had some time to kill, with Wi-Fi access and a lot of free time. Randy LeVeque was giving away his lecture on high-performance scientific computing for free on Coursera (unfortunately, it seems to have since been removed), and towards the end of the lectures, Makefiles were introduced. I had used them before, but only from other people’s projects, and never really bothered to look up the syntax, but in that lecture, I learned what I needed and fell in love with Make. Let’s see if I can convey the same passion here; let’s make Randy proud.
Let’s start by how we have dealt with the build stage thus far. If we look back at our complex number example, that we developed as part of our software testing series, we can see that we have bespoke build scripts for Windows (runTests.ps1
) and UNIX (runTests.sh
). If we look at the UNIX build script for the moment, we have the following file:
#!/bin/bash
# clean up before building
rm -rf build
mkdir -p build
# compile source files into object files
g++ -std=c++20 -I. -I ~/libs/include/ -c -g ./tests/unit/testComplexNumbers.cpp -o ./build/testComplexNumbers.o
# create test executable
g++ -std=c++20 ./build/testComplexNumbers.o -o ./build/testComplexNumbers -L ~/libs/lib -lgtest
# run tests
./build/testComplexNumbers
This works great, it encodes all steps that we need, so why bother learning a new tool such as Make to replace our shell scripts? Well, the answer is complexity. As our software grows, so will our bash script. It is easy to see that if we add 1000 files to our project, we now need to add 1000 lines to our bash script to compile each file individually. That is a lot of copy and pasting (error-prone, especially if we decide to change the compiler and thus compiler flags) and in general, the shell script will grow linearly with the size of the project.
Furthermore, we remove the build folder each time before we compile the code. This is fine for a single source file, but will already be painful after having 10 files or so. If you have 100 files, you will go mad. You don’t want to compile everything from scratch, but only those files that change. Clearly we want to have a solution that only recompiles files that have been modified.
What if I want to only build the executable, but I don’t want to run it (like we do on line 14)? I could remove the line and then add it back later if I change my mind again, or I could have different versions of this shell script. Both options are bad and will most likely lead to unexpected behaviour later.
So, if we spend just a bit more time trying to make these shell scripts more general, then we customise our build process to only execute the parts we are interested in (e.g. removing the build folder, compiling the code, running the executable). Additionally, we would like to simply add additional source files and leave the rest of the script untouched. This is where Make comes in and provides us with the tools we need.
Make is not necessarily a tool just dedicated to programming (although it is most commonly used here); at its core, it defines targets that you want to achieve (e.g. creating an executable), and then it lists all dependencies to create that target (e.g. you need compiled source files before you can link them into a single executable). These dependencies may need to be targets themselves; for example, to create compiled source files, you need a list of source files. In this way, you can specify how to transform source files into object files and object files into an executable.
Writing our first Makefile
The above paragraph sounds overly convoluted, so let’s start with the simplest form, a hello world project. Our C++ source code for this is just
// hello.cpp
#include <iostream>
int main() {
std::cout << "Hello cfd.university" << std::endl;
return 0;
}
The corresponding Make file, in its simplest form, would be
hello: hello.o
g++ hello.o -o hello
hello.o: hello.cpp
g++ -c hello.cpp -o hello.o
Here, we define the target hello
on line 1, i.e. the executable that we want to create, which requires a single object file called hello.o
. If that file is not available, Make will look for a rule to create this object file. We have provided that on line 4, where, in order to create hello.o (the target now), we can see that we need to first hello.cpp
, which is available in our project. One important detail is that the instructions to for each target (here lines 2 and 5) need to be indented by the tab key, not with spaces. Otherwise Make may not work correctly.
So first, Make will execute line 5 to compile the source file into an object file, and then, after the object file is available, we can execute line 2 to create an executable from the object file. It may seem a bit convoluted at first, but if you understand the example above, then there is really not much else to it. Everything else in Make is just syntactic sugar.
If you have both the hellp.cpp
and Makefile in one directory, open a terminal and type
make hello
This will look for a target called hello
, which is specified on line 1. Incidentally, if Make is not installed, you’ll need to do that first. In this case, I am working on UNIX (for simplicity, as you will see later) and you can obtain Make through your package manager (e.g. sudo apt install -y make
on Ubuntu and brew install make
on macOS). If you are on Windows, hold on for just a second; we’ll get into the messy bits of writing Makefiles for Windows in just a second (they are mostly similar, but there are differences).
Adding additional targets
At the moment, we have specified only a single target for the user to use, which is hello
. The second target was used by Make to build all dependencies in order to complete the hello
target. From our bash script that we saw above, we see that there are additional things we may want to do, like creating a directory or deleting files. We can instruct Make to do that as well. However, if we think about a target that deletes files, for example, we don’t have any dependencies and for that Make has so-called phony targets. Let’s add targets for creating a build folder, deleting object files, and running the executable:
hello: hello.o
g++ build/hello.o -o build/hello
hello.o: hello.cpp
g++ -c hello.cpp -o build/hello.o
.PHONY: init
init:
mkdir -p build
.PHONY: clean
clean:
rm -rf build/
.PHONY: run
run: hello
./build/hello
We see from the last three targets that phony targets are first declared as such (see lines 7, 11, and 15), and then we just proceed to define the targets without any dependencies on lines 8, 12, and 16. The first of these phony targets on lines 7-9 allows us to create a build folder. So if we typed make init
into our console, we would create a folder called build
in the current directory, and since we want to store all of our build artifacts here (object files and executable), we have to change line 2 and 5 as well, to say that we want to to store these files in the build folder now.
Then we have the other phony targets, clean
on lines 11-13, which removes the build folder containing all build artefacts, while run
on line 15 allows us to run the executable itself. From a readability point of view, this Makefile is (hopefully) quiet easy to read. From a software engineering perspective, though, it is suboptimal. Say we want to change the name of the build folder to bin (another common choice), then we would have to make changes in 6 different places. That’s not great.
Adding variables to avoid copy-and-paste
This is where variables come in. We can define any variables at the top and then use them in the rest of the Makefile, Make will then substitute them where they are used in the file. Let’s create a variable called BUILD_DIR
and use that in all targets. The convention typically is to use capitalised letters with underscores for variable names, though it is not required. The updated Makefile now reads
BUILD_DIR = build
hello: hello.o
g++ $(BUILD_DIR)/hello.o -o $(BUILD_DIR)/hello
hello.o: hello.cpp
g++ -c hello.cpp -o $(BUILD_DIR)/hello.o
.PHONY: init
init:
mkdir -p $(BUILD_DIR)
.PHONY: clean
clean:
rm -rf $(BUILD_DIR)/
.PHONY: run
run: hello
./$(BUILD_DIR)/hello
Ok, this looks already slightly better, but there are still a few things we may want to change. For example, a common choice is to replace the compiler with a variable so that it can be later changed if required (both within the Makefile or while calling make
). And while we are at it, let’s also add some compiler and linker flags:
# specify the build directory
BUILD_DIR = build
# specify the default compiler
CXX = g++
# compiler flags
CXXFLAGS = -c
# linker flags
LXXFLAGS =
hello: hello.o
$(CXX) $(BUILD_DIR)/hello.o $(LXXFLAGS) -o $(BUILD_DIR)/hello
hello.o: hello.cpp
$(CXX) $(CXXFLAGS) hello.cpp -o $(BUILD_DIR)/hello.o
.PHONY: init
init:
mkdir -p $(BUILD_DIR)
.PHONY: clean
clean:
rm -rf $(BUILD_DIR)/
.PHONY: run
run: hello
./$(BUILD_DIR)/hello
If we look now at the targets on lines 13 (hello
) and 16 (hello.o
), we see that we have now replaced the compiler and its compilation and linking flags with variables. At the moment, we don’t have any linking flags specified on line 11, but if we want to add some later (for example, link against libraries), we can insert them on line 11 and don’t have to worry about changing the target later. The same goes for the compiler flags.
One nice thing of having variables defined is that we can change them when we invoke Make itself. Say, for example, we want to use the clang
compiler instead of g++
, we don’t have to change the Makefile, but instead can simply pass an additional command to our make
command as
make hello CXX=clang++
Make will now substitute clang++
for g++
and if you execute the command above, you can verify that, indeed clang++
was used:
clang++ -c hello.cpp -o build/hello.o
clang++ build/hello.o -o build/hello
There is one issue, though, that still requires some attention, in my view. And that is the targets to build and compile the object files and final executable. At the moment, we have hard-coded that the executable hello
depends on hello.o
, which in turn depends on hello.cpp
. As long as our project consists of only a single source file, this works great, but once we add a new file, Make won’t have a clue how to work with it. So, we want to write generic compilation and linking targets that work for different files of the same file type.
Adding generic targets for specific file types
A common generic target is to say that in order to build any object file, you will need a corresponding source file. We can achieve that relatively quickly on line 16 in the previous example by replacing this target, i.e.
hello.o: hello.cpp
$(CXX) $(CXXFLAGS) hello.cpp -o $(BUILD_DIR)/hello.o
With the following generic version:
%.o: %.cpp
$(CXX) $(CXXFLAGS) $< -o $(BUILD_DIR)/$@
Here, we say that any object file (denoted by the special character %
) can be compiled from a corresponding source file. So, if we added a file called cfdSolver.cpp
to our project, this could be compiled into cfdSolver.o
with this rule, and we wouldn’t have to change anything about this target.
Since we are working with generic targets, we need to now refer to the target and its dependencies differently, i.e. we can no longer hard-code the source file like hellp.cpp
and the output, i.e. hello.o
. To do that, we use $<
which represents the dependency (in our example, hello.cpp), while $@ represents the target (which, again, in our case, there is only one, i.e. hello.o
). We could have used that with the previous build rule as well, i.e.
hello.o: hello.cpp
$(CXX) $(CXXFLAGS) $< -o $(BUILD_DIR)/$@
would have been valid code as well. Here it is more verbose, i.e. we can see the target and its dependency written out clearly, and should bring home the point that $< essentially just refers to what is on the right-hand side of the colon of the rule and $@
to what is to the left-hand side of the colon.
We still haven’t touched the linking stage, though, and here, we need to think a bit more. Currently, we have
hello: hello.o
$(CXX) $(BUILD_DIR)/hello.o $(LXXFLAGS) -o $(BUILD_DIR)/hello
In this case, it is a bit different to the generic target we specified before. What we did in the previous example, is to construct any object file from a given source file. Each object file has exactly one dependency (i.e. the source file that is to be compiled), and if we wanted to get properly nerdy, we could say that there is a one-to-one relationship.
For the executable, though, we have a one-to-many relationship, i.e. we are building a single executable, but that may depend on any number of object files. So, we can’t use the same generic target we used before. Instead, we have to go back one step and first define all source files as a variable. Then, we can create a variable that will hold all object file names, which we can then pass to the hello
target. Again, this sounds convoluted, so let’s put this into code and see how it works:
# source files
SRC = hello.cpp
# object files
OBJ = $(subst .cpp,.o,$(SRC))
hello: $(OBJ)
$(CXX) $(BUILD_DIR)/hello.o $(LXXFLAGS) -o $(BUILD_DIR)/hello
Line 2 here specifies all source files, of which we have exactly one, and then the trick is to create all object files on line 5 using a Make function called subst
, i.e. it will substitute a specific combination of characters (first argument) with a given set of characters (second argument) within a specific string (third argument). So, in this case, hello.cpp
will be transformed into hello.o
.
If we had more than one source file, we would simply add this to the SRC
variable, either as a list with spaces separating file names or using backslashes and new lines, i.e.
# specify files on a single line
SRC_SINGLE_LINE = file1.cpp file2.cpp file3.cpp
# specifying files on new lines
SRC_NEW_LINE = \
file1.cpp \
file2.cpp \
file3.cpp
# or, use a combination of the two
SRC_HYBRID = file1.cpp file2.cpp \
file3.cpp
If you feel particularly lazy and say you don’ want to constantly add new source files to your Makefile, this is possible as well using the wildcard feature. So, instead of writing out all source files directly, let Make discover them dynamically:
# source files
SRC = $(wildcard *.cpp)
It will look in the current directory for any files (indicated by the *
) that end with .cpp
, i.e. our source files. It will only work for files that are in the current directory, though we can replace the wildcard
function with a more powerful find
function that can discover files in subdirectories as well (though this is not a Make function but instead provided by your shell). If you are using a bash
shell (default on most Linux distributions but not macOS), you can replace this line by
# source files
SRC = $(shell find . -type f -regex ".*\.cpp")
Here, we are telling Make to use the shell
, i.e. in my case my bash
shell on Ubuntu, and within the shell, Make should execute the find
command with the given parameters. We are searching the current directory (indicated by the dot after find
) but replace this with a source directory as well if that is our project structure (e.g. find src/ ...
). Then, we say that we want to find only files (type -f
) and use a regular expression (regex) syntax to find only files ending in .cpp
. If you are new to regex, this is a great place to start.
I should mention, though, that the usage of wildcards (or file globbing, as it is generally referred to in other build systems), is generally discouraged. There is a good write-up on embedded artistry, but in essence, file globbing (using wildcards) is great when it works, but it can introduce subtle problems that may result in compilation issues that are difficult to debug. Using wildcards (or the find
command) is also slower than explicitly stating all files, which can make a difference for large projects. But to show that this is possible, we use it for now (and ignore it later).
If you have made all of the modifications discussed above, then you should have a fairly generic Makefile that should look like the following:
# specify the build directory
BUILD_DIR = build
# specify the default compiler
CXX = g++
# compiler flags
CXXFLAGS = -c
# linker flags
LXXFLAGS =
# source files
SRC = $(wildcard *.cpp)
# or, use the following if you have source files in subdirectories
# SRC = $(shell find . -type f -regex ".*\.cpp")
# object files
OBJ = $(subst .cpp,.o,$(SRC))
hello: $(OBJ)
$(CXX) $(BUILD_DIR)/hello.o $(LXXFLAGS) -o $(BUILD_DIR)/hello
%.o: %.cpp
$(CXX) $(CXXFLAGS) $< -o $(BUILD_DIR)/$@
.PHONY: init
init:
mkdir -p $(BUILD_DIR)
.PHONY: clean
clean:
rm -rf $(BUILD_DIR)/
.PHONY: run
run: hello
./$(BUILD_DIR)/hello
Chaining targets and providing a default target
Thus far, we have only looked at the creation of the Makefile, but we haven’t really discussed how to use this now more complex (and generic) file. So let’s do that now.
Say we want to start with a clean project. We would want to invoke the clean
target first and then initialise the project (create the build folder) with the init
target. Of course, we could do that in two steps, i.e.
make clean
make init
but Make can chain targets together and we can just provide them in the order we want them to be executed. If we want to clean, initialise, compile, and run our project, we can do that by simply typing
make clean init hello run
This will print the following to the console:
rm -rf build/
mkdir -p build
g++ -c hello.cpp -o build/hello.o
g++ build/hello.o -o build/hello
./build/hello
Hello cfd.university
On line 6, we see the output of our source file so things have worked. But now we need to remember the 4 targets and also the order, which may become cumbersome. Make provides us with a default target called all
. If we simply type make
into our console, it will then look for a target called all
in our Makefile and execute that for us. We can use this to our advantage here to create a new target called all
that will execute all of the other targets for us.
all: clean init hello run
For completeness, here is the full Makefile:
# specify the build directory
BUILD_DIR = build
# specify the default compiler
CXX = g++
# compiler flags
CXXFLAGS = -c
# linker flags
LXXFLAGS =
# source files
SRC = $(wildcard *.cpp)
# or, use the following if you have source files in subdirectories
# SRC = $(shell find . -type f -regex ".*\.cpp")
# object files
OBJ = $(subst .cpp,.o,$(SRC))
# default target
all: clean init hello run
hello: $(OBJ)
$(CXX) $(BUILD_DIR)/hello.o $(LXXFLAGS) -o $(BUILD_DIR)/hello
%.o: %.cpp
$(CXX) $(CXXFLAGS) $< -o $(BUILD_DIR)/$@
.PHONY: init
init:
mkdir -p $(BUILD_DIR)
.PHONY: clean
clean:
rm -rf $(BUILD_DIR)/
.PHONY: run
run: hello
./$(BUILD_DIR)/hello
Try it out (if you are on a UNIX distribution with a bash
shell, for all other users, just hold your horses, we will get to you in a second). If you now type
make
you will get the same output as before, i.e.
rm -rf build/
mkdir -p build
g++ -c hello.cpp -o build/hello.o
g++ build/hello.o -o build/hello
./build/hello
Hello cfd.university
You may be stuck on the multiple-dependency situation in the all
target, but keep in mind that we can have a one-to-many dependency relation for our target (as we saw for the hello target as well, though we didn’t write out all object files (and in this case, there was just a single object file to begin with)). So in order to complete the all
target, we simply have to go through the dependencies, and Make will look for how to fulfil these dependencies in order.
What other magic can Make do for me?
One nice feature we can use in Make is a parallel compilation of source files. If we invoke Make with the -j N
command line argument, where N
is the number of processors we want to use, then Make will use the specified number of processors and compile all source files in parallel. For completeness, our command could be (using 4 processors):
make -j 4
make all -j 4
If you try to do that, you’ll most likely get an error message, as you need to have at least as many source files as you have processors. So for a small project, you probably don’t need it. But once you add more and more files, or you compile open-source projects with thousands of files, compilation in parallel is a very convenient feature.
Another nice feature is that you can use pretty much any shell scripting that you want in your Makefile. For example, if you want to use a certain library, why not have that downloaded, extracted, and compiled as part of your project initialisation step? Here is a phony target called build_dependencies
which will download gtest
and compile it for us. This will require wget
, tar
, and cmake
to be available on your machine:
.PHONY: build_dependencies
build_dependencies:
mkdir -p thirdParty
wget -nc -P thirdParty/ https://github.com/google/googletest/archive/refs/tags/release-1.11.0.tar.gz -O thirdParty/gtest-1.11.0.tar.gz || true
tar -zxvf thirdParty/gtest-1.11.0.tar.gz -C thirdParty
mkdir -p thirdParty/googletest-release-1.11.0/build
cmake -S thirdParty/googletest-release-1.11.0/ -B thirdParty/googletest-release-1.11.0/build -DCMAKE_BUILD_TYPE=Release
cmake --build thirdParty/googletest-release-1.11.0/build --config Release
So no more manual building of dependencies, inject the instruction in here. Or, if you have downloaded the corresponding shell script for downloading and installing gtest, then you could also simply execute that file as part of the build_dependencies
target.
You see, Makefiles are very versatile and you can inject a lot of custom-made scripts and code, which can help you to automate not jsut the compilation of your project, but think about other tasks like building the dependencies as seen above, copying files around that you need to executing tests, pushing and pulling changes to a remote git repository (like GitHub), running tests, or simply brew a cup of coffee.
Creating a Makefile for a realistic project: Revisiting the complex number example
When we looked at how to write code using the test-driven development method and, subsequently, how to get started with gtest (Google’s unit testing framework), we developed a simple example project to add, subtract, multiply and divide complex numbers. It serves as a good example as it is quick to code and easy to verify, yet it is doing something of use (this could be a building block for writing a tool to do the Fast Fourier Transformation (FFT), for example).
We will pick use this example codebase here as well, so that we can concentrate on writing the Makefile, while we don’t have to develop the codebase from scratch again. Let’s review some of the basics of the project, before we jump into writing the Makefile itself. This time, both for UNIX and Windows users.
If you want to follow along, you can download the code at the top of this article.
Project structure
The project structure will now slightly change to what we saw in the above-linked articles, as we are going to add two Makefiles to the project.
root
├── src
│ └── complexNumber.hpp
├── tests
│ └── unit
│ └── testComplexNumbers.cpp
├── runTests.ps1
├── runTests.sh
├── Makefile.bash
└── Makefile.win
We have added the Makefile.bash
and Makefile.win
on lines 9 and 10, and will see how to populate them in the next sections. Suffice it to say that the src/complexNumber.hpp file contains the implementation of the complex number class that allows us to create and use our complex numbers, while the tests/unit/testComplexNumbers.cpp files implement some unit tests to check that we have implemented the complex number class correctly. Let’s now switch to creating the Makefiles for both UNIX and Windows.
Writing a Makefile for UNIX (ah, the joy …)
First, let’s review the build script that we have available, i.e. the runTests.sh
file. This is given below:
#!/bin/bash
# clean up before building
rm -rf build
mkdir -p build
# compile source files into object files
g++ -std=c++20 -I. -I ~/libs/include/ -c -g ./tests/unit/testComplexNumbers.cpp -o ./build/testComplexNumbers.o
# create test executable
g++ -std=c++20 ./build/testComplexNumbers.o -o ./build/testComplexNumbers -L ~/libs/lib -lgtest
# run tests
./build/testComplexNumbers
We compile the unit test file on line 8 and then link that against the gtest
library on line 11 to get our final executable (which we then execute on line 14). If you need more information, you can look at the original write-up on this bash script. The corresponding Makefile is shown below. Hopefully, most of that will look familiar, but there are a few additional novelties here that we will discuss below.
### name of application
APP_NAME = testComplexNumbers
### Specifying the compiler
CXX = g++
### Specifying compiler flags
CXXFLAGS = -c -std=c++20 -I. -I ~/libs/include/
### Specifying linker flags
LXXFLAGS = -L ~/libs/lib -lgtest
### Specifying all source files
SRC = tests/unit/testComplexNumbers.cpp
### Create list of object files based on source files
OBJ = $(subst .cpp,.o,$(SRC))
# Create default target
all: clean init debug run
# Create release build
release: CXXFLAGS += -Ofast -DNDEBUG
release: init $(APP_NAME)
# Create debug build
debug: CXXFLAGS += -g -O0 -Wall -Wextra -Wpedantic
debug: init $(APP_NAME)
# Create test executable
$(APP_NAME): $(OBJ)
$(CXX) $^ $(LXXFLAGS) -o build/$@
%.o: %.cpp
$(CXX) $(CXXFLAGS) $< -o $@
.PHONY: init
init:
mkdir -p build
.PHONY: clean
clean:
rm -rf $(OBJ) build/$(APP_NAME)
.PHONY: run
run:
./build/$(APP_NAME)
First, we specify a variable for the application name. This is fairly standard and while we probably don’t plan usually on changing this, it is a good practice as we can see immediately what the executable is called (i.e. it serves as documentation here). Also, notice that we are good software engineers now and specify our source files explicitly (no more file globbing).
Lines 22-28 introduce two additional targets, the debug
and release
target. We typically want to perform the build in debug mode for testing (i.e. during development) but then switch to a release build for when we actually want to use the code in production (e.g. run CFD simulations). The biggest difference here is that the compiler flags will change. In debug mode, we want to turn on warnings and avoid optimisation, while in release mode, we want the compiler to optimise our executable for speed.
We do this on lines 23 and 27, respectively, where we are adding additional compiler flags to the existing CXXFLAGS
variable, which was defined on line 8 (and which only contains some global compiler flags, such as the C++ standard and default file locations to include).
We do provide the default all
target as well, which will build and run an executable in debug mode. Technically, we don’t need to delete the build folder here and then recreate it, but I just shows again that we can chain targets together.
If you want to run this Makefile now, we have to include an additional flag to Make as it is expecting a file called Makefile. In our case, we gave it a file ending of .bash
, so Make won’t be able to find this file for us. We do that by specifying the file with the -f
flag, and so we could build the executable in debug mode using the all
target as
make -f Makefile.bash
make -f Makefile.bash all
make -f Makefile.bash init debug
All of these targets will build the debug executable, and the first two commands will also run the executable. If we do that, we should see the following output:
rm -rf tests/unit/testComplexNumbers.o build/testComplexNumbers
mkdir -p build
g++ -c -std=c++20 -I. -I ~/libs/include/ -g -O0 -Wall -Wextra -Wpedantic tests/unit/testComplexNumbers.cpp -o tests/unit/testComplexNumbers.o
g++ tests/unit/testComplexNumbers.o -L ~/libs/lib -lgtest -o build/testComplexNumbers
./build/testComplexNumbers
[==========] Running 8 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 8 tests from ComplexNumberTest
[ RUN ] ComplexNumberTest.TestComplexMagnitude
[ OK ] ComplexNumberTest.TestComplexMagnitude (0 ms)
[ RUN ] ComplexNumberTest.TestComplexConjugate
[ OK ] ComplexNumberTest.TestComplexConjugate (0 ms)
[ RUN ] ComplexNumberTest.TestComplexNumberWithNaN
[ OK ] ComplexNumberTest.TestComplexNumberWithNaN (1 ms)
[ RUN ] ComplexNumberTest.TestComplexAddition
[ OK ] ComplexNumberTest.TestComplexAddition (0 ms)
[ RUN ] ComplexNumberTest.TestComplexSubtraction
[ OK ] ComplexNumberTest.TestComplexSubtraction (0 ms)
[ RUN ] ComplexNumberTest.TestComplexMultiplication
[ OK ] ComplexNumberTest.TestComplexMultiplication (0 ms)
[ RUN ] ComplexNumberTest.TestComplexDivision
[ OK ] ComplexNumberTest.TestComplexDivision (0 ms)
[ RUN ] ComplexNumberTest.TestComplexDivisionWithNaN
[ OK ] ComplexNumberTest.TestComplexDivisionWithNaN (0 ms)
[----------] 8 tests from ComplexNumberTest (1 ms total)
[----------] Global test environment tear-down
[==========] 8 tests from 1 test suite ran. (1 ms total)
[ PASSED ] 8 tests.
If you want to have some fun (I may have a strange definition of fun), try to create a release build and run it, i.e.
make -f Makefile.bash clean init release all
On my machine, I am getting an error (which is expected). i.e. not all tests finish successfully:
rm -rf tests/unit/testComplexNumbers.o build/testComplexNumbers
mkdir -p build
g++ -c -std=c++20 -I. -I ~/libs/include/ -Ofast -DNDEBUG tests/unit/testComplexNumbers.cpp -o tests/unit/testComplexNumbers.o
g++ tests/unit/testComplexNumbers.o -L ~/libs/lib -lgtest -o build/testComplexNumbers
./build/testComplexNumbers
[==========] Running 8 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 8 tests from ComplexNumberTest
[ RUN ] ComplexNumberTest.TestComplexMagnitude
[ OK ] ComplexNumberTest.TestComplexMagnitude (0 ms)
[ RUN ] ComplexNumberTest.TestComplexConjugate
[ OK ] ComplexNumberTest.TestComplexConjugate (0 ms)
[ RUN ] ComplexNumberTest.TestComplexNumberWithNaN
tests/unit/testComplexNumbers.cpp:38: Failure
Expected: ComplexNumber a(1.2, std::numeric_limits<double>::quiet_NaN()) throws an exception of type std::runtime_error.
Actual: it throws nothing.
[ FAILED ] ComplexNumberTest.TestComplexNumberWithNaN (0 ms)
[ RUN ] ComplexNumberTest.TestComplexAddition
[ OK ] ComplexNumberTest.TestComplexAddition (0 ms)
[ RUN ] ComplexNumberTest.TestComplexSubtraction
[ OK ] ComplexNumberTest.TestComplexSubtraction (0 ms)
[ RUN ] ComplexNumberTest.TestComplexMultiplication
[ OK ] ComplexNumberTest.TestComplexMultiplication (0 ms)
[ RUN ] ComplexNumberTest.TestComplexDivision
[ OK ] ComplexNumberTest.TestComplexDivision (0 ms)
[ RUN ] ComplexNumberTest.TestComplexDivisionWithNaN
[ OK ] ComplexNumberTest.TestComplexDivisionWithNaN (1 ms)
[----------] 8 tests from ComplexNumberTest (1 ms total)
[----------] Global test environment tear-down
[==========] 8 tests from 1 test suite ran. (1 ms total)
[ PASSED ] 7 tests.
[ FAILED ] 1 test, listed below:
[ FAILED ] ComplexNumberTest.TestComplexNumberWithNaN
1 FAILED TEST
make: *** [Makefile.bash:47: run] Error 1
Can you guess why? In release mode (turning on optimisation), the compiler is allowed to remove or ignore code, which can then lead to undefined behaviours. In this case, the exception is not thrown as expected, but that isn’t really an issue, as the exception would only be thrown if an issue was encountered, at which point, the code has left the happy path of code execution and will likely terminate anyway.
Writing a Makefile for freaking Windows (oh no …)
My quest for showing how to do things cross-platform has not always provided me with a joyful experience. And so when I tried to port my Makefile onto Windows, I wasn’t let down by Microsoft. Would you believe it, you can use Make on Windows, but the Make that we looked above, which is developed by GNU and free and open-source is not good enough for Windows. You can’t just give users free and open-source software but make it ever so slightly different while still being able to call it Make.
Well, at least here, Microsoft is being honest, and they call their version NMake, which stands for New Make. Back in the 1980s, Microsoft wrote their own version of Make, but without much care. As a result, the more complicated the build task got, the more luck was involved in getting the build to complete successfully. Believe it or not, even people at Microsoft got so upset that the decision was taken to reimplement Make from the ground up, without looking at the source code but rather only at the specifications. The result was NMake.
To be fair, Microsoft is not the only entity that is butchering a perfectly fine tool (i.e. Make); many others have joined them to create their own version, inspired by Make (or using it to some extent and simply providing a wrapper around it). There is Sun DevPro Make, BSD Make, dmake, gmake, pmake, bmake, fmake, qmake, wmake (OpenFOAM), Remake, Glenn Fowler’s nmake (not the same nmake as Microsoft), Jom (a derivative on nmake), snakemake, Mk, and Kati (a version from Google which translates directly to ninja).
Looking at this list, this situation is reminiscent of the different Markdown flavours that are out there, which constantly causes frustration when things are not rendered as they are supposed to, even when using the same flavour of Markdown. Anyway, I digress.
Let’s then have a look at Microsoft’s attempt at software engineering. True to their slogan “Think different” (or was it Apple?), everything needs to be reimplemented from scratch. In the original complex number example, the following served as our PowerShell build script:
# clean up before building
Remove-Item .\build -Force -Recurse
New-Item -Name "build" -ItemType "directory"
# compile source files into object files
cl.exe /nologo /EHsc /std:c++20 /Zi /I. /I"C:\libraries\include\" /c .\tests\unit\testComplexNumbers.cpp /Fo".\build\testComplexNumbers.obj"
# create test executable
cl.exe /nologo /EHsc /std:c++20 /Zi .\build\testComplexNumbers.obj /Fe".\build\testComplexNumbers.exe" /link /MACHINE:x64 /LIBPATH:"C:\libraries\lib" gtest.lib
# run tests
.\build\testComplexNumbers.exe
We want to transform this into an NMake file. Let’s look at the entire beauty first and then discuss it below. Do you remember playing the game Spot the Differences as a child? You had two seemingly identical images and had to find the 5 differences between them. If you are up for it, see if you can find the differences between the UNIX Makefile provided above and the version given below. I’ll walk you through the solution afterwards.
### name of application
APP_NAME = testComplexNumbers
### Specifying the compiler
CXX = cl
### Specifying compiler flags
CXXFLAGS = /c /nologo /EHsc /std:c++20 /I. /I C:\\libraries\\include
### Specifying linker flags
LXXFLAGS = /link /MACHINE:x64 /LIBPATH:"C:\libraries\lib" gtest.lib
### Specifying all source files
SRC = tests\\unit\\testComplexNumbers.cpp
### Create list of object files based on source files
OBJ = $(subst .cpp,.obj,$(SRC))
# Create default target
all: clean init debug run
# Create release build
release: init $(APP_NAME)
# Create debug build
debug: init $(APP_NAME)
$(APP_NAME): $(OBJ)
$(CXX) /nologo $? /Fe:build/$(APP_NAME).exe $(LXXFLAGS)
{tests\\unit\\}.cpp{tests\\unit\\}.obj:
$(CXX) $(CXXFLAGS) $? /Fo:$@
.PHONY: init
init:
@if not exist build mkdir build
.PHONY: clean
clean:
del /q $(OBJ) build\\$(APP_NAME).exe
.PHONY: run
run:
.\\build\\$(APP_NAME).exe
There are the obvious differences, from a different compiler to different compilation and linker flags, and the paths have backward slashes, and since the backward slash itself is typically used for special characters like \n
for a new line, we have to escape the backward slash with another backward slash. Think different at its best!
But let’s look at the more subtle differences: the generic target to build object files from source files on line 31 is different. The order is also reversed. Now, we are saying we first need a *.cpp
file to build an *.obj
file. You’ll notice that I have given a path here, as I could not for the life of me figure out how to make this generic and applicable to any potential subdirectory. There probably is a way, but I could not find it.
Line 36 also looks different, and we now have an if
statement, saying that if the build
folder does not exist, then execute the command mkdir build
.
All of these are minor differences, and apart from the loss of generic targets to build object files from source files, there is one major issue that I wasn’t able to overcome (and one for which there likely isn’t a (clean) solution). Have a look at the debug and release targets on lines 23 and 26, respectively. Then, have a look at the compiler flags defined in line 8. Do you notice that we are not overwriting or appending additional flags to the compiler flags variable? That is because variables in NMake (unlike Make) are global and constant. You can’t change them.
So, if we wanted to achieve the same behaviour as with UNIX/GNU Make, then we would be out of luck; this just isn’t possible. Believe me, I have tried various ways, and in all of them, there is a flaw that prevents you from doing what I achieved in the Makefile for UNIX shown above. If you were to write out the rules to build each individual source file for different targets (e.g. release or debug), then this isn’t really an issue. If you have a meta build system that collects all source files and then spits out the corresponding Makefile, then you get around this issue.
We’ll pick this up again in the next section, but suffice it to say that there are limitations to using NMake (and Make, for that matter, as well!). But just for completeness, let’s also look at the execution of NMake.
If you are the type of person who enjoys watching Ads on YouTube, then you should use the following command to invoke NMake
nmake target
But if you want to use an adblocker, you should always use
nmake /NOLOGO target
This is exactly the same as for standard Make. You can chain targets together and specify a different Makefile with the -f
flag. In our case, our Makefile is called Makefile.win
, so we need to provide this flag. We can either run the all
target or specify the targets explicitly. Remember, not specifying any targets will, by default, execute the all
target. The following commands all achieve the same output
nmake /NOLOGO -f Makefile.win
nmake /NOLOGO -f Makefile.win all
nmake /NOLOGO -f Makefile.win clean init debug run
Interestingly, building in release or debug mode, i.e.
nmake /NOLOGO -f Makefile.win clean init debug run
nmake /NOLOGO -f Makefile.win clean init release run
both result in the tests executing correctly and we don’t get an error thrown at us. Surprising? No, remember, we weren’t able to set specific compiler flags for debug
or release
builds, so there isn’t any compiler optimisation done, and as a result, no code gets thrown out or ignored. For completeness, the following gets printed to the console if we run in either debug or release mode:
del /q tests\\unit\\testComplexNumbers.obj build\\testComplexNumbers.exe
cl /c /nologo /EHsc /std:c++20 /I. /I C:\\libraries\\include tests\\unit\\testComplexNumbers.cpp /Fo:tests\\unit\\testComplexNumbers.obj
testComplexNumbers.cpp
cl /nologo tests\\unit\\testComplexNumbers.obj /Fe:build/testComplexNumbers.exe /link /MACHINE:x64 /LIBPATH:"C:\libraries\lib" gtest.lib
.\\build\\testComplexNumbers.exe
[==========] Running 8 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 8 tests from ComplexNumberTest
[ RUN ] ComplexNumberTest.TestComplexMagnitude
[ OK ] ComplexNumberTest.TestComplexMagnitude (0 ms)
[ RUN ] ComplexNumberTest.TestComplexConjugate
[ OK ] ComplexNumberTest.TestComplexConjugate (0 ms)
[ RUN ] ComplexNumberTest.TestComplexNumberWithNaN
[ OK ] ComplexNumberTest.TestComplexNumberWithNaN (0 ms)
[ RUN ] ComplexNumberTest.TestComplexAddition
[ OK ] ComplexNumberTest.TestComplexAddition (0 ms)
[ RUN ] ComplexNumberTest.TestComplexSubtraction
[ OK ] ComplexNumberTest.TestComplexSubtraction (0 ms)
[ RUN ] ComplexNumberTest.TestComplexMultiplication
[ OK ] ComplexNumberTest.TestComplexMultiplication (0 ms)
[ RUN ] ComplexNumberTest.TestComplexDivision
[ OK ] ComplexNumberTest.TestComplexDivision (0 ms)
[ RUN ] ComplexNumberTest.TestComplexDivisionWithNaN
[ OK ] ComplexNumberTest.TestComplexDivisionWithNaN (0 ms)
[----------] 8 tests from ComplexNumberTest (4 ms total)
[----------] Global test environment tear-down
[==========] 8 tests from 1 test suite ran. (6 ms total)
[ PASSED ] 8 tests.
Even adding the /O2 /DNDEBUG
compiler flags doesn’t change the output, and all tests still complete satisfactory. This goes to show that there are subtle differences between compilers and why some compilers might produce a build that work while others don’t. If you want to support cross-platform development and usage, you’ll need to develop and test on all of the supported platforms.
So, there are some issues with NMake, but to be fair, certain issues are shared between all derivatives of Make. This has led to the decline of Makefiles in general (although they are still in heavy usage, just in a different form), which we will look at next.
The death of Makefiles
When Make was introduced, it was a bit of magic. At its core, it was a simple automation tool, but one written and dedicated to automating the software build cycle. As a result, there was a lot of uptake among scientists, engineers, and software developers. These days, engineers and software engineers have moved on, while scientists are still busy pushing the boundaries of human knowledge with their research based on tools that have long been outdated.
I mentioned above that Makefiles are still in heavy use, but just not in the form discussed above, i.e. people are not writing their own Makefile anymore, unless they have a small project and just want to have a quick and dirty working solution (though even then, I’d argue CMake is still a better solution and likely one with fewer lines of code).
In the remaining articles of this series, we will be taking a deep dive into CMake, which is considered to be a meta-build system. This means CMake itself does not get involved in compiling any code. It is a tool to produce some form of build instruction that is specific to the platform you are developing on. If you write a CMake file and then execute it on Windows, you get an MSBuild file, which CMake can then use to build your code. On UNIX, you actually get a Makefile by default.
So CMake and other meta-build systems produce build instructions in the form of Makefiles or other similar alternatives (e.g. MSBuild, Ninja, Xcode, etc.). If you generate Makefiles automatically, you don’t have to care about generic build targets or release and debug builds. If the targets change and you add more source files, simply add a new rule to the Makefile upon creation, or write a Makefile that always compiles on debug or release mode. Only once the setting is changed within CMake should the compiler flags change as well.
So the demise of Makefiles is largely attributed to the success of other meta build platforms, and a general drive towards cross-platform development. Choosing Make as your build tool for your project most likely means that you are looking yourself into using a specific platform and you really should change to something more generic like CMake, but other alternatives are available that’ll do the job just as well.
Summary
So then, in this opening article, we looked at a high-level overview of build systems and why they are important, and then we took a somewhat deeper dive into Makefiles themselves. At this point, you have seen pretty much all there is to writing your own Makefiles and should you come across one in the wild, hopefully, this knowledge will help you understand what these Makefiles are trying to achieve. Remember that most Makefiles these days are automatically generated, so they may appear more cryptic and convoluted than necessary.
We also looked at why Makefiles have declined in popularity, mainly due to a lack of support for cross-platform development (where other tools have succeeded), but I would still argue knowing how to write a Makefile for at least one platform has enormous educational value as you have to put in the compiler flags yourself, and realise that the order of compiler and linker flags matter. If you feel comfortable writing Makefiles, you’ll be able to graduate to the next build system (in our case, CMake), and troubleshooting compiler issues will then become exponentially easier.