In this article, we take a deep dive into CMake and see how we can use it to compile two simple projects: one to generate an executable and another one to generate a static library. We will pick up these examples from our previous article and see how we can modify them to incorporate a CMake build script, and why this approach is superior to our previously developed Makefiles (as we have native cross-platform compilation support).
By the end of this article, you will know everything you need to get started with CMake and use it in your own CFD projects. While CMake offers many more features than what is discussed in this article, most of the time, you’ll just need the features we go through in this article. If you understand the configuration and build steps as well, you have a very powerful tool at your disposal to start automating your build process.
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-2
- Download: Hello-World-Part-2
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
What is CMake?
So then, the time has finally come to look at CMake in earnest. In the previous article, we looked at reasons why we want to use a build system in the first place. We then followed that up and looked at Make, a somewhat historic build system (or rather, framework), which offers excellent educational value for learning how the build process works but offers zero support for cross-platform compilation. Well, you can achieve cross-platform compilation with Make, but generally, you have to provide the compilation instructions yourself for each compiler, which is tedious.
I teased at the end of the article, that CMake offers native support for cross-platform compilation and that is one of the biggest advantages we get when choosing CMake over Make. Well, if that was all that CMake offered, it probably wouldn’t be the best choice out there, given the myriad of build systems out there that offer cross-platform compilation as well, which we reviewed in the previous article as well.
No, CMake should be (nay, must) be used by any serious CFD practitioner for one simple reason: CMake is the most popular build system out there, and chances are that your favourite library that you want to use already uses CMake. By using CMake in your own project, you can integrate other libraries with ease (and we will see later how we can make this process really painless!). Of course, if you use CMake and are familiar with it, then building someone else’s library will become simple as well, as the steps involved are always the same.
The biggest advantage is that you write a single file with build instructions, and CMake uses these to generate native build scripts on your machine. These could be Makefiles on UNIX, MSBuild on Windows, and Xcode files on macOS. You can also choose cross-platform build tools, such as Ninja, which simplifies the build process even further. Do you want to change between the release and debug build? No problem, change one command line flag, and CMake will generate the corresponding build file again with the correct compiler flags for each compiler.
But that is not all. While CMake is really good at abstracting away the build process from its users, building software is actually only a fraction of what CMake can do. As I hinted in my previous article as well, there are many other aspects of the build process CMake can do for you, such as generating header files automatically, copying files around during installation, running automated tests, packaging your project and wrapping this in an installer. It is really powerful, and chances are you will not reach its limits quickly.
Before we jump into looking at some code, I just want to mention documentation. We spent the last series looking exclusively at how to write (useful) documentation (which probably no one has or will read; writing documentation is not everyone’s favourite topic …). CMake is a prime example for how to not write documentation. I don’t know what it is, but every time you need to look up how to use a feature in CMake, I have no idea what it tries to tell me.
I don’t seem to be the only one, looking at the cpp reddit, there is one particular thread on CMake, where users seem to vent their frustrations. One particular comment states: “Oh, and the documentation: It’s extensive but never tells me what I need to know.” which pretty much summarises the issue in one sentence. It’s useless; it’s wasted time on the side of the developers and wasted time for you to go through. It’s the reason it took me a long time to pick up CMake, I only did because everyone else is using it.
If you want to get a decent description of CMake and have some money to spare, I can only recommend Craig;s Scott book titled Professional CMake. It is the resource I most commonly use for any CMake related questions and probably contains more than you ever need to know about CMake. But with that recommendation out of the way, let’s look at two simple projects to get started with CMake.
The first steps with CMake
In this section, I want to look at two examples, which are essentially just reworked examples from the previous article. We will first look at the hello world example again as the project is relatively simple and straight forward, so we can concentrate more on the CMake specifics (how to write the CMake file and which commands to execute to get the executable).
Afterwards, we will modify the complex number example slightly so that we can compile it into a static library. Then, we create the corresponding CMake file to create the static library, and we will look at how to install this library. There are some issues which we can’t just yet address, for that, we need to learn more about how to handle dependencies, which requires some more background information which we’ll address in a later article (at which point, we will resolve the issue).
For now, let us jump into the first example and see how we can compile the simple hello world example using CMake.
Repurposing our Hello World example using CMake
In this section, we look at the hello world example we have developed in the previous article. I’ll quickly go through it again so we all have a shared understanding of its structure and content, but feel free to look at the previous article for more information.
Project structure
The project structure is shown below. We can see our hello.cpp
source file we had in the previous article as well, and we have replaced the Makefile
here with the CMakeLists.txt
file. This is the file name we need to give in order for CMake to recognise it as build instructions. In this simplified example, we will only have a single CMakeLists.txt
file but larger projects usually have several files, separating the build process into multiple steps (we will see that in the next example as well, which arguably is more realistic).
root
├── build/
├── CMakeLists.txt
└── hello.cpp
Finally, we have an additional folder called build
/. CMake supports (and exclusively uses) out-of-source builds. This means that, by default, any object files, executables, or libraries that are being built will not be mixed with the source files. All of these build artefacts are collected in a dedicated folder, and the default convention is to use a folder called build/
. If you looked closely in the previous example, we used in-source builds with Makefiles, i.e. compiled object files appeared next to the source files in the same folder, polluting the folder structure.
Out-of-source builds are the norm in software engineering, and there are no good reasons to use in-source builds anymore. Why did we do that with Makefiles? Well, that is the default. You can change it, but as always with Make, you have to write the instructions yourself. CMake makes that easy for us, and so we create a build/
folder and use that to collect all output files.
As a reminder, the hello.cpp
files is just a plain hello world file and shown below:
#include <iostream>
int main() {
std::cout << "Hello cfd.university" << std::endl;
return 0;
}
Let’s look at the CMakeLists.txt
file next.
Our first shot at CMake
For such a simple example, our CMakeLists.txt
file contains only 4 instructions. The file below contains a few comments to understand what is going on, have a look through and we’ll discuss afterwards what they are doing in detail.
# Select the minimum cmake version required. If in doubt, use the same version you are using
# To find out your cmake version, run 'cmake --version' in the console
cmake_minimum_required(VERSION 3.10)
# Set the project name/description and project settings like the C++ standard required
project(helloWorld
LANGUAGES CXX
VERSION 1.0.0
DESCRIPTION "A simple introduction to CMake"
)
# Create the executable with an empty list of source files
# The executable's name will be the same as the project name given above
add_executable(${CMAKE_PROJECT_NAME} "")
# after the executable has been defined, add all source files, here only one
target_sources(${CMAKE_PROJECT_NAME}
PRIVATE hello.cpp
)
First of all, pretty much all commands in CMake feel like function calls, where a few parameters can be specified. To identify which parameters are set, CMake uses a key-value type of evaluation, and it is common to use capital letters for the keys. Most parameters are optional, and so CMake needs to have a mechanism to identify which parameters are set.
The first command that should be in your CMake file is the cmake_minimum_required()
command, and should contain the lowest version of CMake that can be used to compile your project. This allows CMake to exit gracefully when its current version is lower than what is specified in this file. As the comments in the CMake file suggest, use your CMake version initially, which you can find out by running cmake --version
on the command line.
But say you want to support earlier versions as well. In that case, you would have to go through each command and check when it was introduced in CMake. Take the last command, for example, looking up the target_sources()
command in the glorious CMake documentation reveals that it was introduced in version 3.1. Further down the list, we see that FILE_SETS
were introduced in version 3.23 (which we will use in the second example, so cmake_minimum_required()
must change here).
A version of 3.10 is conservative here, it is unlikely that you will find systems with that version still installed (it was released in November 2018), so this should not make any issues. In any case, you should never go below a Version of 3.0. Everything from version 3.0 is considered to be Modern CMake, where the syntax has significantly changed (and improved) compared to version 2.x and earlier.
The next command we have is the project()
command, which takes a few arguments (and there are more possible key-value pairs we could set). The first argument is the name of the project, followed by basic information such as the language used in the project (here, C++, i.e. CXX
), the version of the project (1.0.0), and a short description of what this project does.
You will find that the convention is with CMake to break a command up into several lines, as we do with the project() command. Only the first argument is left on the first line. You don’t have to follow this convention, but you will see that I (mostly) follow that.
Everything up to now is just setting up CMake and providing some basic meta description about the project, but we want to compile something. In this case, we want to compile source files into an executable, for which we have the add_executable()
command on line 14. The first argument is the name of the executable, and the second argument is a list of source files required to compile the executable.
For the name, we use ${CMAKE_PROJECT_NAME}
. In CMake, we can define variables, just like in any other programming language, and the convention is to use capital letters here as well. The variable CMAKE_PROJECT_NAME
is set by CMake automatically after we have given a project name in the project()
command, which we did on line 6. In order to use the variable, we need to put it between ${}
to let CMake know this is a variable and not a string.
The list of files used for this executable is set to an empty list. For this simple example, we could have just given the single source file that we have, but for anything slightly larger than a Hello World example project, you wouldn’t do that, so let’s not start bad practices just because we can. Instead, it is much more common to add the source files through the target_sources()
command, which we see on line 17.
The first argument for target_sources()
is the name of the target (executable) for which we want to add sources. This is because we can define any number of executables (and later, libraries) that we want to build, and these may have different source files on which they depend. By specifying the target as the first argument, CMake is able to differentiate between different targets.
The next argument is PRIVATE
followed by the source files that should be added, here simply the hello.cpp
file. The PRIVATE
keyword can be somewhat confusing to understand, but it is actually pretty useful. In a nutshell, we have both PRIVATE
and PUBLIC
as an option here, where PRIVATE
is what you’ll use most of the time. When other projects use our executable, they will also be able to use any PUBLIC
files. Typically, we use that for header files, and we will see that in the next example project, so let’s defer that discussion until then.
This is all. If you remove the comments and put every command on a single line, you are left with 4 lines, of which 2 are required by CMake just to set the project up. The instructions for compiling the code are just two lines. This supports building debug or release builds on any platform. It’s not bad for such minimal effort. The way that we can change the build behaviour is set on the command line when we invoke CMake, which we will look at in the next section.
How to use CMake on the command line
The next step involves building the executable. If your project does not already contain a build/
folder, then you want to create that first. The command for UNIX (Linux, macOS), as well as PowerShell, is
mkdir build
You want to change into the directory so that you are within the build
folder before executing CMake. If you have done so, the simplest form to run CMake is to use the following command:
cmake ..
This will do a number of things. First, it tells CMake to look in the parent directory (..
) for the CMakeLists.txt
file, which will then be processed. At this stage, we are only configuring the project, not actually building anything. For example, CMake will figure out which operating system we are on, what compilers to use (and check that they are available), and what build script to generate. As a result, this step is also known as the configure step. If I run the above command on my Windows machine, I get the following output:
-- Building for: Visual Studio 17 2022
-- Selecting Windows SDK version 10.0.22621.0 to target Windows 10.0.22631.
-- The CXX compiler identification is MSVC 19.37.32825.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Tools/MSVC/14.37.32822/bin/Hostx64/x64/cl.exe - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (1.3s)
-- Generating done (0.0s)
-- Build files have been written to: C:/Users/tom/code/buildSystems/Hello-World-Part-2/build
Running the same command on Ubuntu results in:
-- The CXX compiler identification is GNU 11.4.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/tome/code/buildSystems/Hello-World-Part-2/build
We have quite a few customisation options available, and to get a list of all command line arguments we can supply, you can run CMake with the help flag, i.e.
cmake -h
This will print quite an abundance of information. The most interesting ones are probably the generators listed at the very end of the help section. These are used to build the actual source code so we can instruct CMake on which build scripts we want to generate. You don’t really need to care about this, as CMake will choose the most appropriate one for your system. The following shows the generators that are available for CMake on Windows:
Generators
The following generators are available on this platform (* marks default):
* Visual Studio 17 2022 = Generates Visual Studio 2022 project files.
Use -A option to specify architecture.
Visual Studio 16 2019 = Generates Visual Studio 2019 project files.
Use -A option to specify architecture.
Visual Studio 15 2017 [arch] = Generates Visual Studio 2017 project files.
Optional [arch] can be "Win64" or "ARM".
Visual Studio 14 2015 [arch] = Generates Visual Studio 2015 project files.
Optional [arch] can be "Win64" or "ARM".
Visual Studio 12 2013 [arch] = Generates Visual Studio 2013 project files.
Optional [arch] can be "Win64" or "ARM".
Visual Studio 11 2012 [arch] = Deprecated. Generates Visual Studio 2012
project files. Optional [arch] can be
"Win64" or "ARM".
Visual Studio 9 2008 [arch] = Deprecated. Generates Visual Studio 2008
project files. Optional [arch] can be
"Win64" or "IA64".
Borland Makefiles = Generates Borland makefiles.
NMake Makefiles = Generates NMake makefiles.
NMake Makefiles JOM = Generates JOM makefiles.
MSYS Makefiles = Generates MSYS makefiles.
MinGW Makefiles = Generates a make file for use with
mingw32-make.
Green Hills MULTI = Generates Green Hills MULTI files
(experimental, work-in-progress).
Unix Makefiles = Generates standard UNIX makefiles.
Ninja = Generates build.ninja files.
Ninja Multi-Config = Generates build-<Config>.ninja files.
Watcom WMake = Generates Watcom WMake makefiles.
CodeBlocks - MinGW Makefiles = Generates CodeBlocks project files
(deprecated).
CodeBlocks - NMake Makefiles = Generates CodeBlocks project files
(deprecated).
CodeBlocks - NMake Makefiles JOM
= Generates CodeBlocks project files
(deprecated).
CodeBlocks - Ninja = Generates CodeBlocks project files
(deprecated).
CodeBlocks - Unix Makefiles = Generates CodeBlocks project files
(deprecated).
CodeLite - MinGW Makefiles = Generates CodeLite project files
(deprecated).
CodeLite - NMake Makefiles = Generates CodeLite project files
(deprecated).
CodeLite - Ninja = Generates CodeLite project files
(deprecated).
CodeLite - Unix Makefiles = Generates CodeLite project files
(deprecated).
Eclipse CDT4 - NMake Makefiles
= Generates Eclipse CDT 4.0 project files
(deprecated).
Eclipse CDT4 - MinGW Makefiles
= Generates Eclipse CDT 4.0 project files
(deprecated).
Eclipse CDT4 - Ninja = Generates Eclipse CDT 4.0 project files
(deprecated).
Eclipse CDT4 - Unix Makefiles= Generates Eclipse CDT 4.0 project files
(deprecated).
Kate - MinGW Makefiles = Generates Kate project files (deprecated).
Kate - NMake Makefiles = Generates Kate project files (deprecated).
Kate - Ninja = Generates Kate project files (deprecated).
Kate - Ninja Multi-Config = Generates Kate project files (deprecated).
Kate - Unix Makefiles = Generates Kate project files (deprecated).
Sublime Text 2 - MinGW Makefiles
= Generates Sublime Text 2 project files
(deprecated).
Sublime Text 2 - NMake Makefiles
= Generates Sublime Text 2 project files
(deprecated).
Sublime Text 2 - Ninja = Generates Sublime Text 2 project files
(deprecated).
Sublime Text 2 - Unix Makefiles
= Generates Sublime Text 2 project files
(deprecated).
And, on Ubuntu, we get the following generators:
Generators
The following generators are available on this platform (* marks default):
Green Hills MULTI = Generates Green Hills MULTI files
(experimental, work-in-progress).
* Unix Makefiles = Generates standard UNIX makefiles.
Ninja = Generates build.ninja files.
Ninja Multi-Config = Generates build-<Config>.ninja files.
Watcom WMake = Generates Watcom WMake makefiles.
CodeBlocks - Ninja = Generates CodeBlocks project files.
CodeBlocks - Unix Makefiles = Generates CodeBlocks project files.
CodeLite - Ninja = Generates CodeLite project files.
CodeLite - Unix Makefiles = Generates CodeLite project files.
Eclipse CDT4 - Ninja = Generates Eclipse CDT 4.0 project files.
Eclipse CDT4 - Unix Makefiles= Generates Eclipse CDT 4.0 project files.
Kate - Ninja = Generates Kate project files.
Kate - Unix Makefiles = Generates Kate project files.
Sublime Text 2 - Ninja = Generates Sublime Text 2 project files.
Sublime Text 2 - Unix Makefiles
= Generates Sublime Text 2 project files.
Quite a few less, though most Windows generators are marked as deprecated. You can see the default generator that will be used if none is specified during configuration by the asterisk (*). On Windows, we use Visual Studio 17 2022 and on Ubuntu Unix Makefiles. Ninja is a good choice if you want to use the same generator across different platforms. To change the generator, we need to tell CMake that we want to change it using the -G
flag (as can be deduced from the help section).
cmake -G Ninja ..
If you run the command after you have already configured the project once, then you will get the following error:
CMake Error: Error: generator : Ninja
Does not match the generator used previously: Unix Makefiles
Either remove the CMakeCache.txt file and CMakeFiles directory or choose a different binary directory.
Once a generator has been selected, it can’t be changed, as the entire project has been configured to be used with this generator. So as the message suggests, we need to first remove the build/CMakeCache.txt
variable, after which we can run the command again. This time it will succeed.
We do have a few more options that we can set during configuration. One thing you probably always want to set is the build type, i.e. do you want to get a debug or release build? We use the debug build during development to catch any warnings and errors the compiler can generate for us, as well as put debugging symbols into our executable so that we can step through it with a debugger should we wish to do so. The release build will strip all of that out and apply common optimisations to enhance the performance of the executable.
To pass in additional variables, we use the -D
flag, followed by the variable name we want to set. The variable steering of the build type is called CMAKE_BUILD_TYPE
and we can set this to either Debug
or Release
. So, if we wanted to specify that we want to use a Debug
build and use Ninja as our generator, then we could type:
cmake -DCMAKE_BUILD_TYPE=Debug -G Ninja ..
If you want to use any generator that uses spaces between the names, put them in quotation marks, e.g., -G "Unix Makefiles"
. After this step has finished, we get the corresponding file to build our project, i.e. ninja.build
if we used Ninja, Makefile
if we used Unix Makefiles
, and helloWorld.(sln|vcxproj|vcxproj.filters)
if we used Visual Studio as the generator.
You can now look up the specific commands required to build your project for these build scripts (e.g. make all as we have seen in the previous article when discussing Makefiles), but CMake wants to abstract the build stage away from you as well so that you only ever need to memorise a single command, which is:
cmake --build .
This will now build the project and generate the executable for you (in the current folder, indicated by the dot (.
)). This will typically be located within the build/
folder itself, but some generators will place them in subdirectories. For example, using Visual Studio on Windows as the generator, you will see a new folder called either Debug/ or Release/, which will depend on the configuration you have set. If you want to influence build type during the build step, you can specify that as well:
cmake --build . --config Debug
This begs the question, why do you have to specify both during the configuration and during the build step that you want a debug build? Well, you only have to do it during either of these steps, but it will depend on the build script you use at which point you have to specify it. I have used the convention of specifying it during both steps, to make sure that I get the correct build type regardless.
If you have a very large project, you probably also want to enable parallel support and build your source files on different cores. This can be achieved by specifying the -j N
flag during the build stage, where N
is the number of cores you want to use during compilation.
cmake --build . --config Debug -j 4
Be sensible, if you have only 1 file, don’t try to compile it with 4 cores, this can lead, in the worst case, to compilation issues.
If you have understood everything up until now, you have mastered CMake in its basic form. Sure, it has quite a lot more features, but at its core, what you have just seen is how you most commonly use CMake on a regular basis. Let’s graduate from this simple example and move on to our complex number class example.
Working with our complex number library
Thus far, we have gotten to grips with the basics of CMake, how to write a simple CMakeLists.txt
file, and then how to configure and build your project. I want to build on that knowledge and show you what else we can do when it comes to compiling and distributing libraries.
Project structure
The project is now slightly different to what we saw in the previous article. First of all, I have removed the tests, as linking against external libraries is a topic we need to discuss in a separate article. I have also separated the complex number class now into a header and source file, which is located within the src/
folder. We have a CMakeLists.txt
file in the root folder, but also in the src/
folder, and we will see why this is advantages and how we can combine the two. There is, again, a build folder, which we use to dump all of our compiled files into.
root
├── build/
├── src/
│ ├── CMakeLists.txt
│ ├── complexNumbers.hpp
│ └── complexNumbers.cpp
└── CMakeLists.txt
For completeness, the complex number header file (class definition), is given below:
// complexNumbers.hpp
#pragma once
#include <iostream>
#include <cmath>
#include <stdexcept>
#include <limits>
class ComplexNumber {
public:
ComplexNumber(double real, double imaginary);
~ComplexNumber();
double Re() const;
double Im() const;
void setRe(double real);
void setIm(double imaginary);
void conjugate();
double magnitude() const;
ComplexNumber operator+(const ComplexNumber &other);
ComplexNumber operator-(const ComplexNumber &other);
ComplexNumber operator*(const ComplexNumber &other);
ComplexNumber operator/(const ComplexNumber &other);
friend std::ostream &operator<<(std::ostream &os, const ComplexNumber &c);
private:
void isNan(double real, double imaginary) const;
private:
double _real;
double _imaginary;
};
The source file is given as
// complexNumbers.cpp
#include "src/complexNumbers.hpp"
ComplexNumber::ComplexNumber(double real, double imaginary) : _real(real), _imaginary(imaginary) {
isNan(real, imaginary);
}
ComplexNumber::~ComplexNumber() = default;
double ComplexNumber::Re() const { return _real; }
double ComplexNumber::Im() const { return _imaginary; }
void ComplexNumber::setRe(double real) { _real = real; }
void ComplexNumber::setIm(double imaginary) { _imaginary = imaginary; }
void ComplexNumber::conjugate() { _imaginary *= -1.0; }
double ComplexNumber::magnitude() const { return std::sqrt(std::pow(_real, 2) + std::pow(_imaginary, 2)); }
ComplexNumber ComplexNumber::operator+(const ComplexNumber &other) {
isNan(_real, _imaginary);
isNan(other._real, other._imaginary);
ComplexNumber c(0, 0);
c._real = _real + other._real;
c._imaginary = _imaginary + other._imaginary;
return c;
}
ComplexNumber ComplexNumber::operator-(const ComplexNumber &other) {
isNan(_real, _imaginary);
isNan(other._real, other._imaginary);
ComplexNumber c(0, 0);
c._real = _real - other._real;
c._imaginary = _imaginary - other._imaginary;
return c;
}
ComplexNumber ComplexNumber::operator*(const ComplexNumber &other) {
isNan(_real, _imaginary);
isNan(other._real, other._imaginary);
ComplexNumber c(0, 0);
c._real = _real * other._real - _imaginary * other._imaginary;
c._imaginary = _real * other._imaginary + _imaginary * other._real;
return c;
}
ComplexNumber ComplexNumber::operator/(const ComplexNumber &other) {
isNan(_real, _imaginary);
isNan(other._real, other._imaginary);
double denominator = other._real * other._real + other._imaginary * other._imaginary;
if (std::abs(denominator) < std::numeric_limits<double>::epsilon())
throw std::runtime_error("Complex number division by zero");
ComplexNumber c(0, 0);
c._real = (_real * other._real + _imaginary * other._imaginary) / denominator;
c._imaginary = (_imaginary * other._real - _real * other._imaginary) / denominator;
return c;
}
std::ostream &operator<<(std::ostream &os, const ComplexNumber &c) {
os << "(" << c._real << ", " << c._imaginary << ")";
return os;
}
void ComplexNumber::isNan(double real, double imaginary) const {
if (std::isnan(real) || std::isnan(imaginary))
throw std::runtime_error("Complex number is NaN");
}
We won’t go over the code again, but there is a detailed write-up on this class when we first looked at this example during our software testing series. Feel free to have a look if you want to understand the complex number class in more detail.
A CMake file for libraries
Let’s focus instead on the more interesting bit: the CMakeLists.txt
file, which is located in the root folder. This is given below. Have a look through it, and we’ll discuss it afterwards.
# Select the minimum cmake version required. If in doubt, use the same version you are using
# To find out your cmake version, run 'cmake --version' in the console
cmake_minimum_required(VERSION 3.23)
# Set the project name/description and project settings like the C++ standard required
project(
complexNumbers
LANGUAGES CXX
VERSION 1.0.0
DESCRIPTION "A simple C++ library for complex numbers"
)
# Create a static library for the complex number class
add_library(${CMAKE_PROJECT_NAME} STATIC)
# include the root directory of the project in the include path
target_include_directories(${CMAKE_PROJECT_NAME}
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
)
# go through the src/ subdirectory and add any files to the executable
add_subdirectory(src)
# during installation, install the headers that we specified with FILE_SET
install(TARGETS ${CMAKE_PROJECT_NAME}
FILE_SET HEADERS
)
Lines 1-11 should look familiar again, where only the minimum required CMake version has changed, as we are going to make use of some newer options, as alluded to above when discussing the target_sources()
command. On line 14, we now say that we want to build a library, not an executable, by using the add_library()
command. The name is again the same as the first argument we provided to the project()
function, and we state that we want to build a STATIC
library. The other option we can specify here is SHARED
, if we wanted to build a shared/dynamic library.
Let’s look at line 17. target_include_directories()
allows us to pass specific include paths to the compiler that we want it to know about. This is the same as passing the -I
flag on UNIX or /I
on Windows to the compiler, followed by a path that the compiler will use to look for header files. In this case, we include the ${CMAKE_CURRENT_SOURCE_DIR} directory, which is a variable CMake provides us that points to the current directory where the CMakeLists.txt
file is located. In other words, we tell the compiler to include the root director, e.g. -I.
The first argument to target_include_directories()
is again the target for which we want the directory to be included, in this case, we only have a single target but we still need to specify it. The PUBLIC
keyword becomes important now, as libraries are typically built so that other projects can make use of it. In this case, using the PUBLIC keyword simply means that other projects using this library will also know about this include directory.
The reason we have to specify this include directory in the first place is the complexNumbers.cpp
file, where we have an include at the top of #include "src/complexNumbers.hpp"
. By default, the include statements should be relative to the current directory, so the include should be #include “complexNumbers.hpp”. However, if you look at projects like OpenFOAM, where several header files have the same name in different directories, it becomes difficult to track which file is being included.
As a result, I always made it a rule for my own project to always include header files relative to the root directory, which means that I have to include the root directory as a search path for the compiler to find all required header files.
Return back to the CMakeLists.txt
file, on line 22, we have a call to add_subdirectory()
. This allows us to leave the current CMakeLists.txt
file, and go into the specified subdirectory. In this case, we have specified the src/
folder, so CMake will go into this folder and look for another CMakeLists.txt
file, which we have provided. The content of this file is given below.
target_sources(${CMAKE_PROJECT_NAME}
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/complexNumbers.cpp
PUBLIC FILE_SET HEADERS
BASE_DIRS ${PROJECT_SOURCE_DIR}
FILES ${CMAKE_CURRENT_SOURCE_DIR}/complexNumbers.hpp
)
We have already seen the target_sources()
command before, but we are now using a few more options here. First, we are adding the source file to the target ${CMAKE_PROJECT_NAME}
, i.e. the complex number library that we defined on line 14 in the root-level CMakeLists.txt
file. This file is marked PRIVATE
, as we only want the library itself to know about this file. Also, notice that we specify the path of the file using the ${CMAKE_CURRENT_SOURCE_DIR}
variable again, which will now point to the src/
directory, as this is the location of the current CMakeLists.txt
file is stored in.
In CMake version 3.23, we got a new function called FILE_SET HEADERS
. We are adding the header file as a PUBLIC
source to our target, where we also specify the BASE_DIR
(the root parent directory, i.e. ${PROJECT_SOURCE_DIR}
), as well as the header file itself using the FILES
command.
Using this option will allow us to copy the header file later along with our library once we install the library into a specific location (which we will see in just a second).
Notice how we have now split the root level CMakeLists.txt
file to contain global parameters (i.e. defining the project, setting up the library that should be compiled, etc.) while we have delegated the responsibility of adding source files to the library to each folder containing the actual source (and header) files. This is a pretty common arrangement and helps to declutter your CMake files. You will see that they quickly grow in size, and being able to avoid putting all files to the executable in the top-level CMake file enhanced readability quite a lot, especially for large projects.
If we return to our root-level CMake file, we see on lines 25-27 the following commands:
install(TARGETS ${CMAKE_PROJECT_NAME}
FILE_SET HEADERS
)
This provides CMake with instructions on what to do if we want to install a target. We probably all have an idea of what installation means. In CMake terms, it means taking whatever is in the build/ folder, be it an executable or a library, and copying that into an installation folder. I typically have a folder called C:\libraries on Windows and /home/tom/libraries/
on UNIX, where I store my external dependencies/libraries. So, I could install the complex number library into these directories and get it under C:\libraries\libs\complexNumber.lib
on Windows, for example.
Since we marked the header files as PUBLIC
during the target_sources()
command, they will also get copied into the include/
directory. Again, on Windows, the header file will now be located at C:\libraries\include\src\complexNumbers.hpp
. Once I make CMake aware of the C:\libraries
folder in another project, I will be able to use the complex number library in that project (though we have first to discuss how to make it available, which will be a dedicated article later in this series).
Now that we have an idea for how to define a library, let’s look at the corresponding configuration and build steps next.
Building, linking, and installing the library
Configuring and compiling the library is exactly the same as what we saw before. First, we run the configuration step, this time in release mode, just to show as well how this works:
cmake -DCMAKE_BUILD_TYPE=Release -G Ninja ..
Next, we build the project using the same commands and again in release mode:
cmake --build . --config Release
Within our build/
folder, we should now see the complexNumbers.lib
file. If you used a different generator, it may be in a subdirectory like Release/
. Now we want to install this target, and again, CMake provides a unified syntax for this as well, which is shown below:
cmake --install . --prefix "C:\libraries" --config Release
This command checks for any executables/libraries that were supposed to be built with CMake in the current directory (indicated but by the dot (.
)). If they are available, we can specify that we want to install the release version of this software into a given location, indicated by the --prefix
location.
Running the above command will produce the following output:
-- Installing: C:\libraries/lib/complexNumbers.lib
-- Installing: C:\libraries/include/src/complexNumbers.hpp
You can see that both the library and header files were indeed copied over successfully, and you can verify that by looking at your --prefix
folder. Both these files should now be located at the locations shown above. However, we can’t yet use this library. If you tried to create a project and include this library now, then you would not be able to find this newly created library. For that, we first have to provide some configuration files, which require some more advanced CMake syntax. We’ll tackle both of these issues in the next two articles.
Summary
If you made it to the end and you feel comfortable with the material discussed, you know the fundamentals of CMake, and it doesn’t get more complicated than what we have looked at unless you want it to be. What we will cover in the next few articles are more advanced topics that you don’t necessarily need (apart from working with dependencies/libraries), but if you have already picked up CMake, then you may as well make use of the other powerful features it offers at no additional cost.