More Modern CMake

Introduction

Overview

Teaching: 10 min
Exercises: 0 min
Questions
  • What is the difference between a build system and a build system generator?

Objectives
  • Learn about build systems and build system generators.

  • Understand why CMake is used.

  • Newer CMake is better.

Building code is hard. You need long commands to build each part of your code; and you need do to this on many parts of your code.

So people came up with Build Systems; these had ways to set up dependencies (such as file A needs to be built to build file B), and ways to store the commands used to build each file or type of file. These are language independent (mostly), allowing you to setup builds of almost anything; you can use make to build LaTeX documents if you wish. Some common build systems include make (the classic pervasive one), ninja (a newer one from Google designed in the age of build system generators), invoke (a Python one), and rake (Ruby make, nice syntax for Ruby users).

However, this is:

Enter Build System Generators (hereby labeled BSGs for brevity). These understand the concepts of your programming language build; they usually support common compilers, languages, libraries, and output formats. These usually write a build system (or IDE) file and then let that do the actual build. The most popular BSG is CMake, which stands for Cross-platform Make. But as we’ve just shown, it is not really in the same category as make. Other BSGs include Autotools (old, inflexible), Bazel (by Google), SCons (older Python system), Meson (young Python system, very opinionated), and a few others. But CMake has unparalleled support by IDEs, libraries, and compilers. It also scales very well, with small projects able to pick it up easily (modern CMake, anyway), and massive projects like the CERN experiments being able to use it for thousands of modules.

Note that both CMake and Make are custom languages rather than being built in an existing language, like rake and SCons, etc. While it is nice to consolidate languages, the requirement that you have an external language installed and configured was too high for any of these to catch on for general use.

To recap, you should use CMake if:

(More) Modern CMake

CMake has really changed dramatically since it was introduced around 2000. And, by the time of 2.8, it was available in lots of Linux Distribution package managers. However, this means there often are really old versions of CMake “available by default” in your environment. Please, please upgrade and design for newer CMake. No one likes writing or debugging build systems. Using a newer version can cut your build system code in less than half, reduce bugs, integrate better with external dependents, and more. Installing CMake can be as little as one line, and doesn’t require sudo access. See more info here.

Somehow, this is difficult to understand, so I’ll shout it to make it clearer. Writing Modern CMake reduces your chances of build problems. The tools CMake provides are better than the ones you will try to write yourself. CMake works with more compilers, in more situations, than you do. So if you try to add a flag, you’ll likely get it wrong for some compilers or OSs, but if you can use a tool CMake gives you instead, then that’s CMake’s business to get right and not yours.

It’s not a big deal to install a newer CMake. The issues people open for “this is broken” far outnumber the issues people open because you required a CMake newer than they want to run (I think I’ve only seen one of those, and they came around when they saw the feature they would have to give up).

Example of Modern CMake

Bad 2.8 style CMake: Adding a C++11 flag manually. This is compiler specific, is different for CUDA, and locks in a set version, rather than a minimum version.

If you require CMake 3.1+, you can set CXX_STANDARD, but only on a final target. Or you can manually list compile_features for individual C++11 and C++14 features, and, and all targets using yours will get at least that level set on them.

If you require CMake 3.8+, you can just use compile_features to set a minimium standard level, like cxx_std_11, instead of manually listing a bunch of features. This was used for C++17 and later C++20 and C__23, exclusively.

Selecting a minimum in 2023:

What minimum CMake should you run locally, and what minimum should you support for people using your code? Since you are reading this, you should be able to get a release in the last few versions of CMake; do that, it will make your development easier. For support, there are two ways to pick minimums: based on features added (which is what a developer cares about), or on common pre-installed CMakes (which is what a user cares about).

Never select a minimum version older than the oldest compiler version you support. CMake should always be at least as new as your compiler.

What minimum to choose - OS support:

What minimum to choose - Features:

Other sources

There are some other places to find good information on the web. Here are some of them:

Key Points

  • Build systems describe exactly how to build targets.

  • Build system generators describe general relationships.

  • Modern CMake is simpler and reduces the chance of build problems.


Building with CMake

Overview

Teaching: 10 min
Exercises: 10 min
Questions
  • How do I build a project?

Objectives
  • Have a reference for installing CMake.

  • Learn how to build an existing project.

  • Customize the build.

  • Learn how to do some basic debugging.

Installing CMake

It’s usually only one line or maybe two to install a recent version of CMake almost anywhere; see CMake Instructions.

Building with CMake

Before writing CMake, let’s make sure you know how to run it to make things. This is true for almost all CMake projects, which is almost everything.

Try it out

Let’s get a project and try to build it. For fun, let’s build CLI11:

git clone https://github.com/CLIUtils/CLI11.git
cd CLI11

Now, from the newly downloaded directory, let’s try the modern CMake (3.14) build procedure:

cmake -S . -B build
cmake --build build
cmake --build build -t test

This will make a build directory (-B) if it does not exist, with the source directory defined as -S. CMake will configure and generate makefiles by default, as well as set all options to their default settings and cache them into a file called CMakeCache.txt, which will sit in the build directory. You can call the build directory anything you want; by convention it should have the word build in it to be ignored by most package’s .gitignore files.

You can then invoke your build system (line 2). Regardless of whether you used make (the default), ninja, or even an IDE-based system, you can build with a uniform command. You can add -j 2 to build on two cores, or -v to verbosely show commands used to build.

Finally, you can even run your tests from here, by passing the “test” target to the underlying build system. -t (--target before CMake 3.15) lets you select a target. There’s also a cmake <dir> --install command in CMake 3.15+ that does the install - without invoking the underlying build system!

Warning about in-source builds

Never do an “in-source” build - that is, run cmake . from the source directory. It will pollute your source directory with build outputs, CMake configuration files, and will disable out-of-source builds. A few packages do not allow the source directory to even sit inside the build directory; if that is the case, you need to change the relative path .. accordingly.

Just to clarify, you can point CMake at either the source directory from the build directory, or at an existing build directory from anywhere.

Other syntax choices

The classic, battle hardened method should be shown for completeness:

mkdir build
cd build
cmake ..
make
make test

This has several downsides. If the directory already exists, you have to add -p, but that doesn’t work on Windows. You can’t as easily change between build directories, because you are in it. It’s more lines, and if you forget to change to the build directory, and you use cmake . instead of cmake .., then you can pollute your source directory.

Picking a compiler

Selecting a compiler must be done on the first run in an empty directory. It’s not CMake syntax per se, but you might not be familiar with it. To pick Clang:

CC=clang CXX=clang++ cmake -S . -B build

That sets the environment variables in bash for CC and CXX, and CMake will respect those variables. This sets it just for that one line, but that’s the only time you’ll need those; afterwards CMake continues to use the paths it deduces from those values.

Picking a generator

You can build with a variety of tools; make is usually the default. To see all the tools CMake knows about on your system, run

cmake --help

And you can pick a tool with -G"My Tool" (quotes only needed if spaces are in the tool name). You should pick a tool on your first CMake call in a directory, just like the compiler. Feel free to have several build directories, like build and build-xcode. You can set the environment variable CMAKE_GENERATOR to control the default generator (CMake 3.15+). Note that makefiles will only run in parallel if you explicitly pass a number of threads, such as make -j2, while Ninja will automatically run in parallel. You can directly pass a parallelization option such as -j 2 to the cmake --build . command in recent versions of CMake as well.

Setting options

You set options in CMake with -D. You can see a list of options with -L, or a list with human-readable help with -LH.

Verbose and partial builds

Again, not really CMake, but if you are using a command line build tool like make, you can get verbose builds:

cmake --build build -v

If you are using make directly, you can write VERBOSE=1 make or even make VERBOSE=1, and make will also do the right thing, though writing a variable after a command is a feature of make and not the command line in general.

You can also build just a part of a build by specifying a target, such as the name of a library or executable you’ve defined in CMake, and make will just build that target. That’s the --target (-t in CMake 3.15+) option.

Options

CMake has support for cached options. A Variable in CMake can be marked as “cached”, which means it will be written to the cache (a file called CMakeCache.txt in the build directory) when it is encountered. You can preset (or change) the value of a cached option on the command line with -D. When CMake looks for a cached variable, it will use the existing value and will not overwrite it.

Standard options

These are common CMake options to most packages:

Try it out

In the CLI11 repository you cloned:

  • Check to see what options are available
  • Change a value; maybe set CMAKE_CXX_STANDARD to 14 or turn off testing.
  • Configure with CMAKE_INSTALL_PREFIX=install, then install it into that local directory. Make sure it shows up there!

Debugging your CMake files

We’ve already mentioned verbose output for the build, but you can also see verbose CMake configure output too. The --trace option will print every line of CMake that is run. Since this is very verbose, CMake 3.7 added --trace-source="filename", which will print out every executed line of just the file you are interested in when it runs. If you select the name of the file you are interested in debugging (usually with a parent directory if you are debugging a CMakeLists.txt, since all of those have the same name), you can just see the lines that run in that file. Very useful!

Try it out

Run the following from the source directory:

cmake build --trace-source="CMakeLists.txt"

Answer this

Question: Does cmake build build anything?

Answer

No, the “build” here is the directory. This will configure (create build system files). To build, you would add --build before the directory, or use your build tool, such as make.

More reading

Key Points

  • Build a project.

  • Use out-of-source builds.

  • Build options and customization.

  • Debug a CMakeLists easily.


Your first CMakeLists.txt file

Overview

Teaching: 10 min
Exercises: 10 min
Questions
  • How little can I get away with in my CMakeLists?

Objectives
  • Understand the deep implications of cmake_minimum_version

  • Know how to set up a project

  • Know how to make at least one target

Writing a CMakeLists

The following file is fine for the following examples:

/* simple.c or simple.cpp */
#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

This file can be compiled with C or C++.

Starting off

This is the simplest possible CMakeLists.txt:

cmake_minimum_required(VERSION 3.15)

project(MyProject)

add_executable(myexample simple.cpp)

Let’s look at the three lines:

  1. The cmake_minimum_required command sets the policies so that the build is exactly like it would be on the listed version of CMake - in other words, CMake “dumbs itself down” to the version you request for any features that could produce a different build. This makes CMake almost perfectly backwards compatible.
  2. You need to be working on a project, and it needs at least a name. CMake assumes a CXX (that’s C++) and C mixed project if you don’t give any LANGUAGES.
  3. You need at least one library or executable to do anything interesting. The “thing” you make here is called a “target”, and the executable/library has the same name, by default, and it has to be unique in the project. You use add_executable for programs, and add_library for libraries.

Those commands have a few extra arguments that you can give:

cmake_minimum_required(VERSION 3.15...3.25)

project(MyProject
  VERSION
    1.0
  DESCRIPTION
    "Very nice project"
  LANGUAGES
    CXX
)

add_executable(myexample simple.cpp)
  1. You can specify a range of versions - this will cause the policies to be set to the highest supported value in that range. As a general rule, set the highest version you’ve tested with here.
  2. Projects can have versions, descriptions, and languages.
  3. Whitespace doesn’t matter. Be clear/pretty, or use cmake-format.

More reading

Key Points

  • The cmake_minimum_version setting has deep implications

  • You need a project line.

  • You should prepare one or more targets to do anything interesting.


Working with Targets

Overview

Teaching: 10 min
Exercises: 15 min
Questions
  • How do targets work?

Objectives
  • Know how to set up targets

  • Understand linking and INTERFACE properties

  • Make INTERFACE targets

Targets

Now you know how to compile a single file using three lines of CMake. But what happens if you have more than one file with dependencies? You need to be able to tell CMake about the structure of your project, and it will help you build it. To do so, you will need targets.

You’ve already seen a target:

add_executable(myexample simple.cpp)

This creates an “executable” target with the name myexample. Target names must be unique (and there is a way to set the executable name to something other than the target name if you really want to).

Targets are much like “objects” in other languages; they have properties (member variables) that hold information. The SOURCES property, for example, will have simple.cpp in it.

Another type of target is a library:

add_library(mylibrary simplelib.cpp)

You can add the keywords STATIC, SHARED, or MODULE if you know what kind of library you want to make; the default is sort-of an “auto” library that is user selectable with BUILD_SHARED_LIBS.

You can make non-built libraries too. More on that later, once we see what we can do with targets.

Linking

Once you have several targets, you can describe the relationship between them with target_link_libraries and a keyword; one of PUBLIC, PRIVATE, and INTERFACE. Don’t forget this keyword when making a library! CMake goes into an old compatibility mode for this target that generally breaks things.

Question

You have a library, my_lib, made from my_lib.hpp and my_lib.cpp. It requires at least C++14 to compile. If you then add my_exe, and it needs my_lib, should that force my_exe to compile with C++14 or better?

Answer

This depends on the header. If the header contains C++14, this is a PUBLIC requirement - both the library and it’s users need it. However, if the header is valid in all versions of C++, and only the implementations inside my_lib.cpp require C++14, then this is a PRIVATE requirement

  • users don’t need to be forced into C++14 mode.

Maybe you do require users have C++14, but your library can compile with any version of C++. This would be an INTERFACE requirement.

Example of Public and Private inheritance

Figure 1: Example of PUBLIC, PRIVATE, and INTERFACE. myprogram will build the three libraries it sees through mylibrary; the private library will not affect it.

There are two collections of properties on every target that can be filled with values; the “private” properties control what happens when you build that target, and the “interface” properties tell targets linked to this one what to do when building. The PUBLIC keyword fills both property fields at the same time.

Example 1: Include directories

When you run target_include_directories(TargetA PRIVATE mydir), then the INCLUDE_DIRECTORIES property of TargetA has mydir appended. If you use the keyword INTERFACE instead, then INTERFACE_INCLUDE_DIRECTORIES is appended to, instead. If you use PUBLIC, then both properties are appended to at the same time.

Example 2: C++ standard

There is a C++ standard property - CXX_STANDARD. You can set this property, and like many properties in CMake, it gets it’s default value from a CMAKE_CXX_STANDARD variable if it is set, but there is no INTERFACE version - you cannot force a CXX_STANDARD via a target. What would you do if you had a C++11 interface target and a C++14 interface target and linked to both?

By the way, there is a way to handle this - you can specify the minimum compile features you need to compile a target; the cxx_std_11 and similar meta-features are perfect for this - your target will compile with at least the highest level specified, unless CXX_STANDARD is set (and that’s a nice, clear error if you set CXX_STANDARD too low). target_compile_features can fill COMPILE_FEATURES and INTERFACE_COMPILE_FEATURES, just like directories in example 1.

Things you can set on targets

See more commands here.

Other types of targets

You might be really excited by targets and are already planning out how you can describe your programs in terms of targets. That’s great! However, you’ll quickly run into two more situations where the target language is useful, but you need some extra flexibility over what we’ve covered.

First, you might have a library that conceptually should be a target, but doesn’t actually have any built components - a “header-only” library. These are called interface libraries in CMake and you would write:

add_library(some_header_only_lib INTERFACE)

Notice you didn’t need to add any source files. Now you can set INTERFACE properties on this only (since there is no built component).

The second situation is if you have a pre-built library that you want to use. This is called an imported library in CMake, and uses the keyword IMPORTED. Imported libraries can also be INTERFACE libraries, they can be built and modified using the same syntax as other libraries (starting in CMake 3.11), and they can have :: in their name. (ALIAS libraries, which simply rename some other library, are also allowed to have ::). Most of the time you will get imported libraries from other places, and will not be making your own.

INTERFACE IMPORTED

What about INTERFACE IMPORTED? The difference comes down to two things:

  1. IMPORTED targets are not exportable. If you save your targets, you can’t save IMPORTED ones - they need to be recreated (or found again).
  2. IMPORTED header include directories will always be marked as SYSTEM.

Therefore, an IMPORTED target should represent something that is not directly part of your package.

More reading

Key Points

  • Libraries and executables are targets.

  • Targets have lots of useful properties.

  • Targets can be linked to other target.

  • You can control what parts of a target get inherited when linking.

  • You can make INTERFACE targets instead of making variables.


Variables explained

Overview

Teaching: 10 min
Exercises: 10 min
Questions
  • How do variables work?

Objectives
  • Learn about local variables.

  • Understand that cached variables persist across runs.

  • Know how to glob, and why you might not do it.

Variables

For this exercise, we will just directly run a CMake Script, instead of running CMakeLists.txt. The command to do so is:

# Assuming you have a file called example.cmake:
cmake -P example.cmake

This way, we don’t have so many little builds sitting around.

Local variables

Let’s start with a local variable.

# local.cmake
set(MY_VARIABLE "I am a variable")
message(STATUS "${MY_VARIABLE}")

Here we see the set command, which sets a variable, and the message command, which prints out a string. We are printing a STATUS message - there are other types (many other types in CMake 3.15+).

More about variables

Try the following:

  • Remove the quotes in set. What happens?
  • Remove the quotes in message. What happens? Why?
  • Try setting a cached variable using -DMY_VARIABLE=something before the -P. Which variable is shown?

Cached variables

Now, let’s look at cached variables; a key ingredient in all CMake builds. In a build, cached variables are set in the command line or in a graphical tool (such as ccmake, cmake-gui), and then written to a file called CMakeCache.txt. When you rerun, the cache is read in before starting, so that CMake “remembers” what you ran it with. For our example, we will use CMake in script mode, and that will not write out a cache, which makes it easier to play with. Feel free to look back at the example you built in the last lesson and investigate the CMakeCache.txt file in your build directory there. Things like the compiler location, as discovered or set on the first run, are cached.

Here’s what a cached variable looks like:

# cache.cmake
set(MY_CACHE_VAR "I am a cached variable" CACHE STRING "Description")
message(STATUS "${MY_CACHE_VAR}")

We have to include the variable type here, which we didn’t have to do before (but we could have) - it helps graphical CMake tools show the correct options. The main difference is the CACHE keyword and the description. If you were to run cmake -L or cmake -LH, you would see all the cached variables and descriptions.

The normal set command only sets the cached variable if it is not already set - this allows you to override cached variables with -D. Try:

cmake -DMY_CACHE_VAR="command line" -P cache.cmake

You can use FORCE to set a cached variable even if it already set; this should not be very common. Since cached variables are global, sometimes they get used as a makeshift global variable - the keyword INTERNAL is identical to STRING FORCE, and hides the variable from listings/GUIs.

Since bool cached variables are so common for builds, there is a shortcut syntax for making one using option:

option(MY_OPTION "On or off" OFF)

Other variables

You can get environment variables with $ENV{name}. You can check to see if an environment variable is defined with if(DEFINED ENV{name}) (notice the missing $).

Properties are a form of variable that is attached to a target; you can use get_property and set_property, or [get_target_properties][] and set_target_properties (stylistic preference) to access and set these. You can see a list of all properties by CMake version; there is no way to get this programmatically.

Handy tip:

Use include(CMakePrintHelpers) to add the useful commands cmake_print_properties and cmake_print_variables to save yourself some typing when debugging variables and properties.

Target properties and variables

You have seen targets; they have properties attached that control their behavior. Many of these properties, such as CXX_EXTENSIONS, have a matching variable that starts with CMAKE_, such as CMAKE_CXX_EXTENSIONS, that will be used to initialize them. So you can using set property on each target by setting a variable before making the targets.

Globbing

There are several commands that help with strings, files, [lists][], and the like. Let’s take a quick look at one of the most interesting: glob.

file(GLOB OUTPUT_VAR *.cxx)

This will make a list of all files that match the pattern and put it into OUTPUT_VAR. You can also use GLOB_RECURSE, which will recurse subdirectories. There are several useful options, which you can look at in the documentation, but one is particularly important: CONFIGURE_DEPENDS (CMake 3.12+).

When you rerun the build step (not the configure step), then unless you setCONFIGURE_DEPENDS, your build tool will not check to see if you have added any new files that now pass the glob. This is the reason poorly written CMake projects often have issues when you are trying to add files; some people are in the habit of rerunning cmake before every build because of this. You shouldn’t ever have to manually reconfigure; the build tool will rerun CMake as needed with this one exception. If you add CONFIGURE_DEPENDS, then most build tools will actually start checking glob too. The classic rule of CMake was “never glob”; the new rule is “never glob, but if you have to, add CONFIGURE_DEPENDS”.

More reading

Key Points

  • Local variables work in this directory or below.

  • Cached variables are stored between runs.

  • You can access environment variables, properties, and more.

  • You can glob to collect files from disk, but it might not always be a good idea.


Project Structure

Overview

Teaching: 10 min
Exercises: 10 min
Questions
  • What should my project look like?

Objectives
  • Know some best practices for project structure

For this section, we will be looking at the project in code/03-structure.

code/03-structure/
├── CMakeLists.txt
├── README.md
├── apps
│   ├── CMakeLists.txt
│   └── app.cpp
├── cmake
│   └── FindSomeLib.cmake
├── docs
│   ├── CMakeLists.txt
│   └── mainpage.md
├── include
│   └── modern
│       └── lib.hpp
├── src
│   ├── CMakeLists.txt
│   └── lib.cpp
└── tests
    ├── CMakeLists.txt
    └── testlib.cpp

First, take a look at the main CMakeLists.txt file. This is an example of a nice project file in CMake 3.14, so enjoy it for a minute. Now let’s look at specifics!

Click to see CMakeLists.txt
# Works with 3.15 and tested through 3.21
cmake_minimum_required(VERSION 3.15...3.25)

# Project name and a few useful settings. Other commands can pick up the results
project(
  ModernCMakeExample
  VERSION 0.1
  DESCRIPTION "An example project with CMake"
  LANGUAGES CXX)

# Only do these if this is the main project, and not if it is included through
# add_subdirectory
if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)

  # Optionally set things like CMAKE_CXX_STANDARD,
  # CMAKE_POSITION_INDEPENDENT_CODE here

  # Let's ensure -std=c++xx instead of -std=g++xx
  set(CMAKE_CXX_EXTENSIONS OFF)

  # Let's nicely support folders in IDE's
  set_property(GLOBAL PROPERTY USE_FOLDERS ON)

  # Testing only available if this is the main app. Note this needs to be done
  # in the main CMakeLists since it calls enable_testing, which must be in the
  # main CMakeLists.
  include(CTest)

  # Docs only available if this is the main app
  find_package(Doxygen)
  if(Doxygen_FOUND)
    add_subdirectory(docs)
  else()
    message(STATUS "Doxygen not found, not building docs")
  endif()
endif()

# FetchContent added in CMake 3.11, downloads during the configure step
# FetchContent_MakeAvailable was not added until CMake 3.14
include(FetchContent)

# Accumulator library This is header only, so could be replaced with git
# submodules or FetchContent
find_package(Boost REQUIRED)
# Adds Boost::boost / Boost::headers (newer FindBoost / BoostConfig 3.15 name)

# Formatting library, adds fmt::fmt Always use the full git hash, not the tag,
# safer and faster to recompile
FetchContent_Declare(
  fmtlib
  GIT_REPOSITORY https://github.com/fmtlib/fmt.git
  GIT_TAG 8.0.1)
FetchContent_MakeAvailable(fmtlib)

# The compiled library code is here
add_subdirectory(src)

# The executable code is here
add_subdirectory(apps)

# Testing only available if this is the main app
if(BUILD_TESTING)
  add_subdirectory(tests)
endif()

Protect project code

The parts of the project that only make sense if we are building this as the main project are protected; this allows the project to be included in a larger master project with add_subdirectory.

Testing handled in the main CMakeLists

We have to do a little setup for testing in the main CMakeLists, because you can’t run enable_testing from a subdirectory (and thereby include(CTest)). Also, notice that BUILD_TESTING does not turn on testing unless this is the main project.

Finding packages

We find packages in our main CMakeLists, then use them in subdirectories. We could have also put them in a file that was included, such as cmake/find_pakages.cmake. If your CMake is new enough, you can even add a subdirectory with the find packages commands, but you have to set IMPORTED_GLOBAL on the targets you want to make available if you do that. For small to mid-size projects, the first option is most common, and large projects use the second option (currently).

All the find packages here provide imported targets. If you do not have an imported target, make one! Never use the raw variables past the lines immediately following the find_package command. There are several easy mistakes to make if you do not make imported targets, including forgetting to add SYSTEM, and the search order is better (especially before CMake 3.12).

In this project, I use the new FetchContent (3.11/3.14) to download several dependencies; although normally I prefer git submodules in /extern.

Source

Now follow the add_subdirectory command to see the src folder, where a library is created.

Click to see src/CMakeLists.txt
# Note that headers are optional, and do not affect add_library, but they will
# not show up in IDEs unless they are listed in add_library.

# Optionally glob, but only for CMake 3.12 or later: file(GLOB HEADER_LIST
# CONFIGURE_DEPENDS "${ModernCMakeExample_SOURCE_DIR}/include/modern/*.hpp")
set(HEADER_LIST "${ModernCMakeExample_SOURCE_DIR}/include/modern/lib.hpp")

# Make an automatic library - will be static or dynamic based on user setting
add_library(modern_library lib.cpp ${HEADER_LIST})

# We need this directory, and users of our library will need it too
target_include_directories(modern_library PUBLIC ../include)

# This depends on (header only) boost
target_link_libraries(modern_library PRIVATE Boost::boost)

# All users of this library will need at least C++11
target_compile_features(modern_library PUBLIC cxx_std_11)

# IDEs should put the headers in a nice place
source_group(
  TREE "${PROJECT_SOURCE_DIR}/include"
  PREFIX "Header Files"
  FILES ${HEADER_LIST})

The headers are listed along with the sources in the add_library command. This would have been another way to do it in CMake 3.11+:

add_library(modern_library)
target_sources(modern_library
  PRIVATE
    lib.cpp
  PUBLIC
    ${HEADER_LIST}
)

Notice that we have to use target_include_directories; just adding a header to the sources does not tell CMake what the correct include directory for it should be.

We also set up the target_link_libraries with the appropriate targets.

App

Now take a look at apps/CMakeLists.txt. This one is pretty simple, since all the leg work for using our library was done on the library target, as it should be.

Click to see apps/CMakeLists.txt
add_executable(app app.cpp)
target_compile_features(app PRIVATE cxx_std_17)

target_link_libraries(app PRIVATE modern_library fmt::fmt)

Docs and Tests

Feel free to look at docs and tests for their CMakeLists.txt.

Click to see docs/CMakeLists.txt
set(DOXYGEN_EXTRACT_ALL YES)
set(DOXYGEN_BUILTIN_STL_SUPPORT YES)

doxygen_add_docs(docs modern/lib.hpp "${CMAKE_CURRENT_SOURCE_DIR}/mainpage.md"
                 WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}/include")

Click to see tests/CMakeLists.txt
# Testing library
FetchContent_Declare(
  catch2
  GIT_REPOSITORY https://github.com/catchorg/Catch2.git
  GIT_TAG v2.13.10)
FetchContent_MakeAvailable(catch2)
# Adds Catch2::Catch2

# Tests need to be added as executables first
add_executable(testlib testlib.cpp)

# I'm using C++17 in the test
target_compile_features(testlib PRIVATE cxx_std_17)

# Should be linked to the main library, as well as the Catch2 testing library
target_link_libraries(testlib PRIVATE modern_library Catch2::Catch2)

# If you register a test, then ctest and make test will run it. You can also run
# examples and check the output, as well.
add_test(NAME testlibtest COMMAND testlib) # Command can be a target

More reading

Key Points

  • Projects should be well organised.

  • Subproject CMakeLists are used for tests and more.


Common Problems and Solutions

Overview

Teaching: 5 min
Exercises: 0 min
Questions
  • What could go possibly wrong?

Objectives
  • Identify some common mistakes

  • Avoid making common mistakes

Now let’s take a look at some common problems with CMake code and with builds.

1: Low minimum CMake version

cmake_minimum_required(VERSION 3.0 FATAL_ERROR)

Okay, I had to put this one in. But in some cases, just increasing this number fixes problems. 3.0 or less, for example, has a tendency to do the wrong thing when linking on macOS.

Solution: Either set a high minimum version or use the version range feature and CMake 3.12 or better. The lowest version you should ever choose is 3.4 even for an ultra-conservative project; several common issues were fixed by that version.

2: Building inplace

CMake should never be used to build in-place; but it’s easy to accidentally do so. And once it happens, you have to manually clean the directory before you can do an out-of-source build again. Because of this, while you can run cmake . from the build directory after the initial run, it’s best to avoid this form just in case you forget and run it from the source directory. Also, you can add the following check to your CMakeLists.txt:

### Require out-of-source builds
file(TO_CMAKE_PATH "${PROJECT_BINARY_DIR}/CMakeLists.txt" LOC_PATH)
if(EXISTS "${LOC_PATH}")
    message(FATAL_ERROR "You cannot build in a source directory (or any directory with "
                        "CMakeLists.txt file). Please make a build subdirectory. Feel free to "
                        "remove CMakeCache.txt and CMakeFiles.")
endif()

One or two generated files cannot be avoided, but if you put this near the top, you can avoid most of the generated files as well as immediately notifying the user (possibly you) that you’ve made a mistake.

3: Picking a compiler

CMake may pick the wrong compiler on systems with multiple compilers. You can use the environment variables CC and CXX when you first configure, or CMake variables CMAKE_CXX_COMPILER, etc. - but you need to pick the compiler on the first run; you can’t just reconfigure to get a new compiler.

4: Spaces in paths

CMake’s list and argument system is very crude (it is a macro language); you can use it to your advantage, but it can cause issues. (This is also why there is no “splat” operator in CMake, like f(*args) in Python.) If you have multiple items, that’s a list (distinct arguments):

set(VAR a b v)

The value of VAR is a list with three elements, or the string "a;b;c" (the two things are exactly the same). So, if you do:

set(MY_DIR "/path/with spaces/")
target_include_directories(target PRIVATE ${MY_DIR})

that is identical to:

target_include_directories(target PRIVATE /path/with spaces/)

which is two separate arguments, which is not at all what you wanted. The solution is to surround the original call with quotes:

set(MY_DIR "/path/with spaces/")
target_include_directories(target PRIVATE "${MY_DIR}")

Now you will correctly set a single include directory with spaces in it.

Key Points

  • Setting a CMake version too low.

  • Avoid building inplace.

  • How to select a compiler.

  • How to work with spaces in paths.


Debugging

Overview

Teaching: 10 min
Exercises: 10 min
Questions
  • How do I debug everything?

Objectives
  • Know how to find problems in CMake

  • Know how to set up builds for debugging

Debugging is easy with CMake. We’ll cover two forms of debugging: debugging your CMake code, and debugging your C++ code.

CMake debugging

First, let’s look at ways to debug a CMakeLists or other CMake file.

Printing variables

The time honored method of print statements looks like this in CMake:

message(STATUS "MY_VARIABLE=${MY_VARIABLE}")

However, a built in module makes this even easier:

include(CMakePrintHelpers)
cmake_print_variables(MY_VARIABLE)

If you want to print out a property, this is much, much nicer! Instead of getting the properties one by one of of each target (or other item with properties, such as SOURCES, DIRECTORIES, TESTS, or CACHE_ENTRIES - global properties seem to be missing for some reason), you can simply list them and get them printed directly:

cmake_print_properties(
    TARGETS my_target
    PROPERTIES POSITION_INDEPENDENT_CODE
)

Warning

You can’t actually access SOURCES, since it conflictes with the SOURCES keyword in the function.

Tracing a run

Have you wanted to watch exactly what happens in your CMake file, and when? The --trace-source="filename" feature is fantastic. Every line run in the file that you give will be echoed to the screen when it is run, letting you follow exactly what is happening. There are related options as well, but they tend to bury you in output.

Watching a build

Let’s try this out. Let’s go to the code/04-debug folder and configure with trace mode on:

cmake -S . -B build --trace-source=CMakeLists.txt

Try adding --trace-expand too. What is the difference? How about replacing --trace-source=CMakeLists.txt with --trace?

Find call information

CMake scripts can search for dependent libraries, executables and more. More details on this will be shown in the next section.

For now, let’s watch where CMake searches for find_... locations in our current example! You can print extra find call information during the cmake run to standard error by adding --debug-find (CMake 3.17+).

Alternatively, CMAKE_FIND_DEBUG_MODE can be set around sections of your CMakeLists.txt to limit debug printing to a specific region.

C++ debugging

To run a C++ debugger, you need to set several flags in your build. CMake does this for you with “build types”. You can run CMake with CMAKE_BUILD_TYPE=Debug for full debugging, or RelWithDebInfo for a release build with some extra debug info. You can also use Release for an optimized release build, or MinSizeRel for a minimum size release (which I’ve never used).

Debug example

C++ code (click to expand)
#include "simple_lib.h"

#include <math.h>

/// Factorial function. Note that int has a maximum of 12!, and long 20!
int factorial(int a) {
    return 0 == a ? 1 : a * factorial(a - 1);
}

/// Approximate the sin function.
///
/// Uses the formula:
///   x - x³/3! + x⁵/5! - ···
///
double my_sin(double x) {
    int i;
    double sign;
    double value = 0;

    // Code has a bug
    for(i=1; i<12; i+=2) {
        sign = (i % 2 ? -1 : 1);
        value += sign * pow(x,i) / factorial(i);
    }

    return value;
}

Let’s try it. Go to code/04-debug, and build in debug mode. Our program has a bug. Let’s try it out in a debugger.

cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug
gdb build-debug/simple_example

Now, since we think there’s a problem in my_sin, let’s set a breakpoint in my_sin. Note that I’m providing the gdb commands on the left, and lldb commands on the right.

# GDB                # LLDB
break my_sin         breakpoint set --name my_sin
r                    r

Now, let’s watch what happens to the sign variable. Set a watchpoint:

# GDB                # LLDB
watch sign           watchpoint set variable sign
c                    c

Keep running continue (c). Do you find the problem in the code?

Aside: Linking to math

You may find that the example provided does not work unless it’s linked with the math library “m”, which looks like -lm when linking with gcc (llvm does not seem to need to link to it). Let’s look for the “m” library:

# Does -lm work? (notice this is find_library, not find_package)
find_library(MATH_LIBRARY m)

If it is found, this saves the location of the m library in a variable that we gave it the name of, in our case, MATH_LIBRARY. We can add the path (not a target) using the same target_link_libraries command. It is very unfortunate that this command happens to accept both targets and raw paths and linker flags, but it’s a historical leftover.

# If there is a -lm, let's use it
if(MATH_LIBRARY)
    target_link_libraries(simple_lib PUBLIC ${MATH_LIBRARY})
endif()

Note that CMake defaults to an “empty” build type, which is neither optimized nor debug. You can fix this manually, or always specify a build type.

Adopting a convention from Linux, all build types append compiler flags from the environment variables CFLAGS, CXXFLAGS, CUDAFLAGS, and LDFLAGS (full list). This feature is often used by package management software, in conjunction with the already mentioned CC, CXX, CUDACXX, and CUDAHOSTCXX environment variables. Otherwise, you can set the release and debug flags separately.

Common needs

There are several common utilities that CMake can integrate with to help you with your builds. Here are just a few:

You can set these when building if you want.

Key Points

  • There are several methods for debugging your CMake code.

  • CMake can help you debug and profile your source code.


Finding Packages

Overview

Teaching: 10 min
Exercises: 0 min
Questions
  • How do I search for packages?

Objectives
  • Understand FindPackage.cmake

  • Understand PackageConfig.cmake

You can search for packages in CMake in two ways; both of them, however, use the same interface. Here’s what it would look like:

find_package(MyPackage 1.2)

This will look for a file in the CMAKE_MODULE_PATH that is named FindMyPackage.cmake. If it does not find one, it will look for a file named MyPackageConfig.cmake in several places, including MyPackage_DIR if that variable exists. You can only perform one of these searches with MODULE or CONFIG, respectively.

You can add COMPONENTS in some cases, if the package supports it, and you can also add QUIET to hide extra text, or REQUIRED to cause a missing package to fail the configure step.

Aside: Environment Hints

Hinting the installation of software package that is installed outside of a system paths works can also be done with environment variables. In CMake 3.12+, individual packages locations can be hinted by setting their installation root path in <PackageName>_ROOT.

export HDF5_ROOT=$HOME/software/hdf5-1.12.0

Similarly, the variable CMAKE_PREFIX_PATH can be used to hint a list of installation root paths at once:

export CMAKE_PREFIX_PATH=$HOME/software/hdf5-1.12.0:$HOME/software/boost-1.74.0:$CMAKE_PREFIX_PATH

FindPackage

The older method for finding packages is the FindPackage.cmake method (MODULE). This is a CMake or user supplied search script that knows how to look for a package. While there are some conventions here, and some tools to help, there are not many hard-and-fast requirements. A package should at least set the variable Package_FOUND. There are 100 or so find packages included in CMake, refer to the documentation for each.

PackageConfig

The “better” way to do things is to have an installed package provide its own details to CMake; these are “CONFIG” files and come with many packages. These files can be simpler and smarter, since they don’t have to search for the package and query the options, but rather can be generated with the correct paths and options for a particular install. ROOT is an example of a package that is now providing a CONFIG file; another one that is just beginning to is Boost; while CMake includes a FindBoost, it has to be updated with each new Boost release, whereas BoostConfig.cmake can be included with each Boost release (first version in 1.70). One issue with some packages (TBB, for example) is that they may provide optional CONFIG files, and your packager may not have activated them.

To be clear: If you are a package author, never supply a Find<package>.cmake, but instead always supply a <package>Config.cmake with all your builds. If you are depending on another package, try to look for a Config first, and if that is not available, or often not available, then write a find package for it for your use.

Key Points

  • A FindPackage.cmake file can factor out package discovery for a package you don’t own.

  • A PackageConfig.cmake helps others find your package.


ROOT

Overview

Teaching: 10 min
Exercises: 10 min
Questions
  • How do I use ROOT?

Objectives
  • Use ROOT a couple of different ways

ROOT is a data analysis framework.

Let’s try a couple of ROOT examples; one with the classic variable/global configure and one with the newer target method. You will need a ROOT install or a ROOT docker container to run these examples. You can use rootproject/root:latest to test this, which is an official Ubuntu based build. Conda-Forge ROOT + CMake would work too, if you like Conda. (ROOT has tags for lots of other base images, too).

For these examples, you should be using a recent version of ROOT - especially for targets, which is still being worked on. The CONFIG files were added in 6.10, and targets received a lot of work in 6.14+. 6.16 has pretty decent targets.

Example 1: UseROOT

Change to the code/05a-root directory. Run:

cmake -S . -B build
cd build
cmake --build .
root -b -q -x ../CheckLoad.C
cmake_minimum_required(VERSION 3.15...3.25)

project(RootUseFileExample LANGUAGES CXX)

# 6.16 fixes a bug in ROOT_EXE_LINKER_FLAGS, especially on macOS
find_package(ROOT 6.16 CONFIG REQUIRED)

include("${ROOT_USE_FILE}")

include_directories("${CMAKE_CURRENT_SOURCE_DIR}")

add_library(DictExample SHARED DictExample.cxx DictExample.h G__DictExample.cxx)

root_generate_dictionary(G__DictExample DictExample.h LINKDEF DictLinkDef.h)

target_link_libraries(DictExample PUBLIC ${ROOT_LIBRARIES})

Example 2: Targets

Change to the code/05b-root directory. Run the same command above.

cmake_minimum_required(VERSION 3.15...3.25)

project(RootTargetExample LANGUAGES CXX)

# 6.16 fixes a bug in ROOT_EXE_LINKER_FLAGS, expecially on macOS
find_package(ROOT 6.16 CONFIG REQUIRED)

# Get the generate dictionary command from ROOT
if(ROOT_VERSION VERSION_LESS 6.20)
  include("${ROOT_DIR}/modules/RootNewMacros.cmake")
else()
  include("${ROOT_DIR}/RootMacros.cmake")
endif()

# Make the dictionary, produces G__DictExample.cxx
root_generate_dictionary(G__DictExample DictExample.h LINKDEF DictLinkDef.h)

add_library(DictExample SHARED DictExample.cxx DictExample.h G__DictExample.cxx)
target_include_directories(DictExample PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")

# Normally you need to link to lots of ROOT:: targets, but we aren't using much
# here.
target_link_libraries(DictExample PUBLIC ROOT::Core)

Key Points

  • ROOT has a CONFIG package you can use to integrate with CMake.


Functions in CMake

Overview

Teaching: 10 min
Exercises: 0 min
Questions
  • How do I write my own CMake commands?

Objectives
  • Know how to make a macro or a function in CMake.

Let’s take a look at making a CMake macro or function. The only difference is in scope; a macro does not make a new scope, while a function does.

function(EXAMPLE_FUNCTION AN_ARGUMENT)
    set(${AN_ARGUMENT}_LOCAL "I'm in the local scope")
    set(${AN_ARGUMENT}_PARENT "I'm in the parent scope" PARENT_SCOPE)
endfunction()

example_function() # Error
example_function(ONE)
example_function(TWO THREE) # Not error

message(STATUS "${ONE_LOCAL}") # What does this print?
message(STATUS "${ONE_PARENT}") # What does this print?

We see the basics of functions above. You can specify required positional arguments after the name; all other arguments are set in ARGN; ARGV holds all arguments, even the listed positional ones. Since you name variables with strings, you can set variables using names. This is enough to recreate any of the CMake commands. But there’s one more thing…

Parsing arguments

You’ll have noticed that there are conventions to calling CMake commands; most commands have all-caps keywords that take 0, 1, or an unlimited number of arguments. This handling is standardized in the cmake_parse_arguments command. Here’s how it works:

include(CMakePrintHelpers)

function(COMPLEX required_arg_1)
  cmake_parse_arguments(
    PARSE_ARGV 1 COMPLEX_PREFIX "SINGLE;ANOTHER" "ONE_VALUE;ALSO_ONE_VALUE"
    "MULTI_VALUES;ANOTHER_MULTI_VALUES")
  message(STATUS "ARGV=${ARGV}")
  message(STATUS "ARGN=${ARGN}")
  message(STATUS "required_arg_1=${required_arg_1}")
  message(STATUS "COMPLEX_PREFIX_SINGLE=${COMPLEX_PREFIX_SINGLE}")
  message(STATUS "COMPLEX_PREFIX_ANOTHER=${COMPLEX_PREFIX_ANOTHER}")
  message(STATUS "COMPLEX_PREFIX_ONE_VALUE=${COMPLEX_PREFIX_ONE_VALUE}")
  message(
    STATUS "COMPLEX_PREFIX_ALSO_ONE_VALUE=${COMPLEX_PREFIX_ALSO_ONE_VALUE}")
  message(STATUS "COMPLEX_PREFIX_MULTI_VALUES=${COMPLEX_PREFIX_MULTI_VALUES}")
  message(
    STATUS
      "COMPLEX_PREFIX_ANOTHER_MULTI_VALUES=${COMPLEX_PREFIX_ANOTHER_MULTI_VALUES}"
  )
  message(
    STATUS
      "COMPLEX_PREFIX_UNPARSED_ARGUMENTS=${COMPLEX_PREFIX_UNPARSED_ARGUMENTS}")
endfunction()

complex(
  something
  SINGLE
  ONE_VALUE
  value
  MULTI_VALUES
  some
  other
  values
  ANOTHER_MULTI_VALUES
  even
  more
  values)

Note: if you use a macro, then a scope is not created and the signature above will not work - remove the PARSE_ARGV keyword and the number of required arguments from the beginning, and add “${ARGN}”) to the end.

The first argument after the PARSE_ARGV keyword and number of required arguments is a prefix that will be attached to the results. The next three arguments are lists, one with single keywords (no arguments), one with keywords that take one argument each, and one with keywords that take any number of arguments. The final argument is ${ARGN} or ${ARGV}, without quotes (it will be expanded here). If you are in a function and not a macro, you can use PARSE_ARGV <N> at the start of the call, where N is the number of positional arguments to expect. This method allows semicolons in the arguments.

Inside the function, you’ll find:

-- ARGV=something;SINGLE;ONE_VALUE;value;MULTI_VALUES;some;other;values;ANOTHER_MULTI_VALUES;even;more;values
-- ARGN=SINGLE;ONE_VALUE;value;MULTI_VALUES;some;other;values;ANOTHER_MULTI_VALUES;even;more;values
-- required_arg_1=something
-- COMPLEX_PREFIX_SINGLE=TRUE
-- COMPLEX_PREFIX_ANOTHER=FALSE
-- COMPLEX_PREFIX_ONE_VALUE=value
-- COMPLEX_PREFIX_ALSO_ONE_VALUE=
-- COMPLEX_PREFIX_MULTI_VALUES=some;other;values
-- COMPLEX_PREFIX_ANOTHER_MULTI_VALUES=even;more;values
-- COMPLEX_PREFIX_UNPARSED_ARGUMENTS=

The semicolons here are an explicit CMake list; you can use other methods to make this simpler at the cost of more lines of code.

More reading

Key Points

  • Functions and macros allow factorization.

  • CMake has an argument parsing function to help with making functions.