Like it!

Join us on Facebook!

Like it!

Introduction to modern CMake for beginners

A look at one of the most popular build systems for C and C++.

CMake is a collection of open-source and cross-platform tools used to build and distribute software. In recent years it has become a de-facto standard for C and C++ applications, so the time has come for a lightweight introductory article on the subject. In the following paragraphs we will understand what CMake is exactly, its underlying philosophy and how to use it to build a demo application from scratch. Mind you, this won't be the definitive CMake bible. Rather, just a practical, ongoing introduction to the tool for humble enthusiasts like me.

What is CMake exactly

CMake is known as a meta build system. It doesn't actually build your source code: instead, it generates native project files for the target platform. For example, CMake on Windows will produce a solution for Visual Studio; CMake on Linux will produce a Makefile; CMake on macOS will produce a project for XCode and so on. That's what the word meta stands for: CMake builds build systems.

A project based on CMake always contains the CMakeLists.txt file. This special text file describes how the project is structured, the list of source files to compile, what CMake should generate out of it and so on. CMake will read the instructions in it and will produce the desired output. This is done by the so-called generators, CMake components responsible for creating the build system files.

Another nice CMake feature is the so-called out-of-source build. Any file required for the final build, executables included, will be stored in a separated build directory (usually called build/). This prevents cluttering up the source directory and makes it easy to start over again: just remove the build directory and you are done.

A toy project to work with

For this introduction I will be using a dummy C++ project made up of few source files:

myApp/
    src/
        engine.hpp
        engine.cpp
        utils.hpp
        utils.cpp
        main.cpp

To make things more interesting, later on I will spice up the project with an external dependency and some parameters to pass at build stage to perform conditional compilation. But first let's add a CMakeLists.txt file and write something meaningful in it.

Understanding the CMakeLists.txt file

A modern CMake's CMakeLists.txt is a collection of targets and properties. A target is a job of the building process or, in other words, a desired outcome. In our example, we want to build the source code into a binary executable: that's a target. Targets have properties: for example the source files required to compile the executable, the compiler options, the dependencies and so on. In CMake you define targets and then add the necessary properties to them.

Let's start off by creating the CMakeLists.txt file in the project directory, outside the src/ directory. The folder will look like this:

myApp/
    src/
        engine.hpp
        engine.cpp
        utils.hpp
        utils.cpp
        main.cpp
    CMakeLists.txt

Then open the CMakeLists.txt file with the editor of your choice and start editing it.

Define the CMake version

A CMakeLists.txt file always starts with the cmake_minimum_required() command, which defines the CMake version required for the current project. This must be the first thing inside a CMakeLists.txt file and it looks like this:

cmake_minimum_required(VERSION <version-number>)

where <version-number> is the desired CMake version you want to work with. Modern CMake starts from version 3.0.0 onwards: the general rule is to use a version of CMake that came out after your compiler, since it needs to know compiler flags, etc, for that version. Generating the project with a CMake older than the required version will result in an error message.

Set the project name

The second instruction a CMakeLists.txt file must contain is the project name, defined by the project() command. This command may take multiple options such as the version number, the description, the homepage URL and much more. The full list is available in the documentation page. A pretty useful one is the programming language the project is written in, specified with the LANGUAGES flag. So in our example:

project(myApp
    VERSION 1.0
    DESCRIPTION "A brief CMake experiment"
    LANGUAGES CXX)

where CXX stands for C++.

Define the executable target

We are about to add our first CMake target: the executable. Defined by the add_executable() command, it tells CMake to create an executable from a list of source files. Suppose we want to call it myApp, the command would look like this:

add_executable(myApp
    src/engine.hpp
    src/engine.cpp
    src/utils.hpp
    src/utils.cpp
    src/main.cpp)

CMake is smart enough to construct the filename according to the target platform conventions: myApp.exe on Windows, myApp on macOS and Linux and so on.

Set some target properties

As said earlier, targets have properties. They are set by a bunch of commands that start with the target_ suffix. These commands also require you to define the scope: how properties should propagate when you include the project into other CMake-based parent projects. Since we are working on a binary executable (not a library), nobody will include it anywhere so we can stick to the default scope called PRIVATE. In the future I will probably write another article about CMake libraries to fully cover this topic.

Set the C++ standard in use

Let's assume our dummy project is written in modern C++20: we have to instruct the compiler to act accordingly. For example, on Linux it would mean to pass the -stdc++=20 flag to GCC. CMake takes care of it by setting a property on the myApp target with the target_compile_features() command, as follows:

target_compile_features(myApp PRIVATE cxx_std_20)

The full list of available C++ compiler features is available here.

Set some hardcoded preprocessor flags

Let's also assume that our project wants some additional preprocessor flags defined in advance, something like USE_NEW_AUDIO_ENGINE. It's a matter of setting another target property with the target_compile_definitions() command:

target_compile_definitions(myApp PRIVATE USE_NEW_AUDIO_ENGINE)

The command will take care of adding the -D part for you if required, so no need for it.

Set compiler options

Enabling compiler warnings is considered a good practice. On GCC I usually go with the common triplet -Wall -Wextra -Wpedantic. Such compiler flags are set in CMake with the target_compile_options() command:

target_compile_options(myApp PRIVATE -Wall -Wextra -Wpedantic)

However, this is not 100% portable. In Microsoft's Visual Studio for example, compiler flags are passed in a completely different way. The current setup works fine on Linux, but it's still not cross-platform: we will see how to improve it in a few paragraphs.

Running CMake to build the project

At this point the project is kind of ready to be built. The easiest way to do it is by invoking the cmake executable from the command line: this is what I will do in the rest of this article. A graphical wizard is also available, which is usually the preferred way on Windows: the official documentation covers it in depth. On Unix ccmake is also available: same thing for Windows but with a text-based interface.

As said earlier, CMake supports out-of-source builds, so the first thing to do is to create a directory — sometimes called build but the name is up to you — and go in there. The project folder will now look like this:

myApp/
    build/
    src/
        engine.hpp
        engine.cpp
        utils.hpp
        utils.cpp
        main.cpp
    CMakeLists.txt

From inside the new folder invoke the CMake as follows:

cmake ..

This will instruct CMake to read the CMakeLists.txt file from above, process it and churn out the result in the build/ directory. Once completed, you will find your generated project files in there. For example, assuming I'm running CMake on Linux, my build/ directory will contain a Makefile ready to be run.

What we have seen so fare is a barebone yet working CMake configuration. We can do better, though: keep reading for additional improvements.

Add multiplatform support: Linux, Windows and macOS

CMake gives you the ability to detect which platform you are working on and act accordingly. This is done by inspecting CMAKE_SYSTEM_NAME, one of the many variables that CMake defines internally. CMake also supports conditionals, that is the usual if-else combination. With this tools in place, the task is pretty easy. For example, assuming we want to fix the portability issue we had before with the compiler options:

if (CMAKE_SYSTEM_NAME STREQUAL "Windows")
    target_compile_options(myApp PRIVATE /W4)
elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux")
    target_compile_options(myApp PRIVATE -Wall -Wextra -Wpedantic)
elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin")
    # other macOS-specific flags for Clang
endif()

Notice how STREQUAL is the CMake way for comparing strings. A list of possible CMAKE_SYSTEM_NAME values is available here. You can also check for additional information such as the operating system version, the processor name and so on: full list here.

Passing command line variables to CMake

In our current configuration we have a hardcoded preprocessor definition: USE_NEW_AUDIO_ENGINE. Why not giving users the ability to enable it optionally while invoking CMake? You can do it by adding the option() command anywhere in the CMakeLists.txt file. The syntax is the following:

option(<variable> "<help_text>" [value])

The optional [value] can be ON or OFF. If omitted, OFF is used. This is how it would look like in our dummy project:

option(USE_NEW_AUDIO_ENGINE "Enable new experimental audio engine" OFF)

To use it, just run CMake as follows:

cmake -DUSE_NEW_AUDIO_ENGINE=ON ..

or:

cmake -DUSE_NEW_AUDIO_ENGINE=OFF ..

This is also how internal CMake variables and other options are passed from the cmake executable. More generally:

cmake [options and flags here] <path to CMakeLists.txt>

Debug versus release builds

Sometimes you want to build an executable with debugging information and optimizations turned off for testing purposes. Some other times an optimized build ready for release is just fine. CMake supports the following build types:

  • Debug — debugging information, no optimization;
  • Release — no debugging information and full optimization;
  • RelWithDebInfo — same as Release, but with debugging information;
  • MinSizeRel — a special Release build optimized for size.

How build types are handled depends on the generator that is being used. Some are multi-configuration generators (e.g. Visual Studio Generators): they will include all configurations at once and you can select them from your IDE.

Some are single-configuration generators instead (e.g. Makefile Generators): they generate one output file (e.g. one Makefile) per build type. So you have to tell CMake to generate a specific configuration by passing it the CMAKE_BUILD_TYPE variable. For example:

cmake -DCMAKE_BUILD_TYPE=Debug ..

In such case it's useful to have multiple build directories, one for each configuration: build/debug/, build/release/ and so on.

Dependency management

Real world programs often depend on external libraries but C++ still lacks of a good package manager. Luckily, CMake can help in multiple ways. What follows is a brief overview of the commands available in CMake for dependency management, pretending that our project depends on SDL (a cross-platform development library).

1: the find_library() command

The idea here is to instruct CMake to search the system for the required library and then link it to the executable if found. The search is performed by the find_library() command: it takes the name of the library to look for and a variable that will be filled with the library path, if found. For example:

find_library(LIBRARY_SDL sdl)

You then check the correctness of LIBRARY_SDL and then pass it to target_link_libraries(). This command is used to specify the libraries or flags to use when linking the final executable. Something like this:

if (LIBRARY_SDL)
    target_link_libraries(myApp PRIVATE ${LIBRARY_SDL})
else()
    # throw an error or enable compilation without the library
endif()

Notice the use of the ${...} syntax to grab the variable content and use it as a command parameter.

2: the find_package() command

The find_package() command is like find_library() on steroids. With this command you are using special CMake modules that help in finding various well-known libraries and packages. Such modules are provided by the library authors or CMake itself (and you can also write your own). You can see the list of available modules on your machine by running cmake --help-module-list. Modules that start with the Find suffix are used by the find_package() command for its job. For example, the CMake version we are targeting is shipped with the FindSDL module, so it's just a matter of invoking it as follows:

find_package(SDL)

Where SDL is a variable defined by the FindSDL module. If the library is found, the module will define some additional variables to be used in your CMake script as shown in the previous method. If you can, always prefer this method over find_library().

3: the ExternalProject module

The two previous commands assume the library is already available and compiled somewhere in your system. The ExternalProject module follows a different approach: it downloads, builds and prepares the library for use in your CMake project. ExternalProject can also interface with popular version control systems such as Git, Mercurial and so on. By default it assumes the dependency to be a CMake project but you can easily pass custom build instructions if necessary.

Using this module is about calling the ExternalProject_Add(<name> [<option>...]) command, something like this:

include(ExternalProject) # Needs to be included first
ExternalProject_Add(sdl
    GIT_REPOSITORY https://github.com/SDL-mirror/SDL.git
)

This will download the SDL source code from the GitHub repository, run CMake on it — SDL is a CMake project — and then build it into a library ready to be linked. By default the artifacts will be stored in the build directory.

One thing to keep in mind: the download step is performed when you build the project (e.g. when invoking make on Linux), so CMake is not aware of the library presence while it generates the project (e.g. when you invoke the cmake command). The consequence is that you can't obtain the right path and flags for your library with commands like find_library() or find_package(). One way is to assume that the dependency is already in place because it will be downloaded and built sooner or later, so you just pass its full path to target_link_libraries() instead of a variable as we did before.

4: the FetchContent module (CMake 3.14+)

This module is based on the previous one. The difference here is that FetchContent downloads the source code in advance while generating the project. This lets CMake know that the dependency exists and to treat it as a child project. A typical usage looks like this:

include(FetchContent) # Needs to be included first
FetchContent_Declare(sdl
  GIT_REPOSITORY https://github.com/SDL-mirror/SDL.git
)
FetchContent_MakeAvailable(sdl)

In words: you first declare what you want to download with FetchContent_Declare, then you include the dependency with FetchContent_MakeAvailable to set up your project for the required library. The dependency will be automatically configured and compiled while building the final executable, before the linkage.

The FetchContent module assumes CMake-based dependencies. If so, including it in your project is as easy as seen above. Otherwise you need to explicitly tell CMake how to compile it, for example with the add_custom_target() command. More on this in future episodes.

As you can see, there are multiple ways to deal with external dependencies in CMake. Choosing the right one really depends on your taste and your project requisites. CMake can also interface with external package managers such as Vcpkg, Conan and existing git submodules directly.

Read more

In this article I've just scratched the surface of the huge CMake universe, while there are tons of interesting features that deserve a mention: macros and functions to write reusable CMake blocks; variables and lists, useful for storing and manipulating data; generator expressions to write complex generator-specific properties; continuous integration test support with ctest. Stay tuned for more!

Sources

CLion manual — Quick CMake tutorial
saoe.net — Using CMake with External Projects
CGold: The Hitchhiker’s Guide to the CMake
An Introduction to Modern CMake
Mirko Kiefer’s blog — CMake by Example
Pablo Arias — It's Time To Do CMake Right
Kuba Sejdak — Modern CMake is like inheritance
Preshing on Programming — How to Build a CMake-Based Project
Jason Turner — C++ Weekly - Ep 78 - Intro to CMake
Jason Turner — C++ Weekly - Ep 208 - The Ultimate CMake / C++ Quick Start
How To Write Platform Checks
CMake manual — Using Dependencies Guide
foonathan::​blog() — Tutorial: Easy dependency management for C++ with CMake and Git
Using external libraries that CMake doesn't yet have modules for
StackOverflow — Package vs Library
StackOverflow — CMake target_include_directories meaning of scope
StackOverflow — How to build an external library downloaded with CMake FetchContent?

comments
linhr on September 15, 2020 at 16:21
good jop!