“Wondering child, so lost, so helpless,
Yearning for my guidance.”

— The Phantom of the Opera

Prologue

As a Visual Studio user from the age of Visual C++ 6.0, I’ve always been using .sln (or as old as .dsw) to manage my C++ projects. It works pretty well with Visual Studio, but is not portable for other toolchains. When I started to program on Linux, I have to use other tools like Make and CMake.🤔

Unlike Make, which is the low-level build tool, CMake is a high-level cross-platform build system generator. Take a look at some popular projects on GitHub, you will find that most of them are using CMake to manage their projects. And all that that implies — CMake is the de-facto standard for building C++ code.😲

Besides CMake, you may also notice a file called conanfile.txt. It is yet another modern C++ package manager called Conan. I haven’t get used to it yet, so it is not our topic today.

Although it’s been a while since I first came across CMake, I still find my CMakeLists.txt extremely messy. Then one day, I found spdlog, whose CMakeLists.txt is laconic and straightforward. All of a sudden, the vision is clear, and now I’m writing this article to share with you the best practices of managing a modern C++ project with CMake.😎


Preparation

CMake is a project management tool, it only generate the build system. So you still need to install the underlying toolchain, e.g. Make on Linux or MSBuild (the one used by Visual Studio) on Windows.

Install CMake

Not much to say, just download the installer from CMake official website, and follow the instructions to install it. For convenience, I’ll do it on my Windows machine. Don’t worry if you are a Linux user, the cmake command is available everywhere. If you also use Windows installer, make sure you check the option to add CMake to the system PATH. Otherwise, you have to add it manually. After installation, run the following command to check if CMake is installed correctly.

1
cmake --version

About CMakeLists.txt

This is the most important file in your CMake project. One CMakeLists.txt takes care of the build process of a directory. If you have a large project, you may have multiple CMakeLists.txt files in different directories. CMakeLists.txt is written in some kind of script language instead of a bunch of configuration, therefore it is super flexible and powerful.

Workspace

Well, I bet you do not write code with notepad. Modern IDEs like Visual Studio and CLion have built-in support for CMake, making it easier to interact with CMake. However, CMake itself is not limited to any IDE, so I recommend you use a code editor with fewer integration to go through the original CMake experience at first. Yes, I’m talking about Visual Studio Code. However, we’re not primitives. CMake Tools and Test Explorer UI are good extensions to make this process more enjoyable.

Attached Repository

All projects of this article is available at Lord-Turmoil/CMakeDemo. You can build each project separately, or all together. See README.md for more information.


Getting Started with CMake

All projects in this chapter are placed under chapter_1.

In this chapter, I’ll show you the very basic set up with CMake, which should be sufficient for small projects, and large projects that do not require much configuration.

Your First CMake Project

Project of this section is under chapter_1/demo_11.

So, let’s start with a single file project to see how CMake works. First, prepare two files in your workspace.

1
2
3
.
|-- main.cpp
`-- CMakeLists.txt

In main.cpp, write whatever you like. Or you can copy this to save some time.

1
2
3
4
5
6
#include <iostream>
int main()
{
std::cout << "Welcome to the world of CMake!" << std::endl;
return 0;
}

Now, it comes to CMakeLists.txt. For this simplest project, three lines are enough.

1
2
3
cmake_minimum_required(VERSION 3.16)
project(demo_11)
add_executable(demo_11 main.cpp)

If you use Visual Studio Code, you can get the documentation when you hover on the commands. But for the first time, I’ll list them below.

  • cmake_minimum_required(): Require a minimum version of CMake.
  • project(): Sets the name of the project, and stores it in the variable PROJECT_NAME. When called from the top-level CMakeLists.txt also stores the project name in the variable CMAKE_PROJECT_NAME.
  • add_executable(): Add an executable to the project using the specified source files.

Now, you can see that CMake commands are quite intuitive. However, you may find some new terms like “variable”, which I’ll cover later.

Build with CMake

Now, how to build our project? If you use the CMake extension, you can easily find the “Build” button and run it with a few clicks. Command line build is also simple. When you build your project for the first time, or after you modify the CMakeLists.txt, you need to (re)generate the build system.

The complete command to do this is as follows. Basically cmake needs to know the source path and build path.

1
2
3
cmake [options] <path-to-source>
cmake [options] <path-to-existing-build>
cmake [options] -S <path-to-source> -B <path-to-build>

Personally, I prefer the following command. You just run it under the project directory.

1
2
3
cmake -Bbuild
# or
cmake -B build

Or, you can specify source path instead. It does the same thing, but takes extra commands.

1
2
3
mkdir build
cd build
cmake ..

Then, to actually build our project, we can run the following command under project directory.

1
cmake --build build

After this, you can find the output executable file somewhere under build directory (usually build/Debug).

Since CMake itself only manages our project, it invokes related tools to build our project. For example, it may use Make on Linux and MSBuild on Windows. And if you are a curious reader, you may already found this by looking into the build directory. Therefore, some folks may directly use the underlying toolchain instead of cmake to build their project.

A fun tip for you. If you want to speed up the build speed, you can specify compile flags after build. On Linux, CMake uses Make, so you can specify -j [N] to utilize your many cores. Note that Make uses infinite jobs if N is missing.

1
cmake --build build -- -j

For MSBuild, you need to use /m[:N]. Also, you can let it choose automatically without N.

1
cmake --build build -- /m

Classic Project Structure

Project of this section is placed under chapter_1/demo_12.

I suppose no one write projects with only one file. So it’s time to consider a proper way to organize our source files and header files. A classic project structure is shown below. We put header files into include and source files into src.

1
2
3
4
5
6
7
8
demo_12
|-- include
| `-- demo_12
| `-- app.h
|-- src
| |-- app.cpp
| `-- main.cpp
`-- CMakeLists.txt

When your project includes many libraries, it is likely that some header files happen to have the same name. In this case, we add an extra directory under include (here is demo_12). In this case, we will include our header file as demo_12/app.h so as to tell header files from different projects.

Now, how should we write the CMakeLists.txt? Only one more line of script. Note that we only need to add source files, header files will be included into source file when the compilation happens. However, you can include header files in it.

1
2
3
4
cmake_minimum_required(VERSION 3.16)
project(demo_12)
add_executable(demo_12 src/main.cpp src/app.cpp)
target_include_directories(demo_12 PRIVATE include)

After we call add_executable(), we add a target called demo_12. Then we use target_include_directories to set the include directory of this target. Here, after the target name, we have a PRIVATE keyword. It means that the include directory (include) is private to this target, and won’t be inherited by targets that depend on it.

Here our target is an executable, which apparently cannot be referenced, so it doesn’t matter whether it is PRIVATE or PUBLIC. It is usually a consideration for library project and you’ll see it later.

Because we’ve set the target include directory, we can use absolute include path for #include. Although both "" and <> are OK here, we usually use the former as it is a custom header.

1
2
3
#include "demo_12/app.h"

// ...

Tada! You project looks organized now!😆

It’s Getting Larger

Everything looks great, until there are too many source files. Adding them one by one is really tiring. So in this case, you can use the following command to automatically find source files in your project.

1
2
file(GLOB_RECURSE SRC_LIST CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/src/*.cpp")
add_executable(demo_12 ${SRC_LIST})

Here, SRC_LIST is a custom variable, which stores all .cpp files under ${PROJECT_SOURCE_DIR}, which is a built-in variable as its literal meaning.


Use CMake Like a Pro

All projects in this chapter are placed under chapter_2.

In the last chapter, we learned the basic use of CMake. And now, let’s take a step further to become “professional”.

Your First CMake Library

Project of this section is under chapter_2/demo_21.

Not all projects are executables. Sometimes a library is a better choice to reuse our code. Or we just divide our logic into several libraries. So in section, I’ll talk about the basic structure of a library project. More specifically, static library.

In this project, our final target is an executable, but we depends on a library called app. The app library has both public headers and private ones. In this case, a good project structure is as follows.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
demo_21
|-- CMakeLists.txt
|-- lib
| |-- CMakeLists.txt
| `-- app
| |-- CMakeLists.txt
| |-- include/app
| | |-- detail
| | | `-- app_public.h
| | `-- app.h
| `-- src
| |-- app_private.h
| |-- app_private.cpp
| `-- app_public.cpp
`-- src
`-- main.cpp

Since all our dependencies are moved into the library, the top-level include directory is omitted. The lib/app directory is a complete CMake library project, which has its own include and src.

Usually, for a library, we will have an all-in-one header, so that client can get access to all functions with one include. For example, this is our app.h.

1
2
3
#pragma once

#include "app/detail/app_public.h"

Then in our main.cpp, we can simply write one line of #include. Here we use <> because we can take library as external code.

1
2
3
#include <app/app.h>

// ...

Usually, it is OK to make all header files public. However, if you do care about privacy, you can place private headers in src directory along with source files.

First, let’s see the CMakeLists.txt under lib/app. This time, we use add_library to make it a library instead of an executable. Also, we use the built-in ${PROJECT_NAME} to avoid writing app everywhere. Then, for the target_include_directories(), we set it more carefully by separating public and private headers.

1
2
3
4
5
6
7
8
9
10
11
12
cmake_minimum_required(VERSION 3.16)

project(app)

file(GLOB_RECURSE SRC_LIST CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/src/*.cpp")
add_library(${PROJECT_NAME} ${SRC_LIST})

target_include_directories(
${PROJECT_NAME}
PUBLIC ${CMAKE_CURRENT_LIST_DIR}/include
PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src
)

Since library can be referenced by other projects, we can use ${CMAKE_CURRENT_LIST_DIR} to make it always points to the path in our library. Or you can use ${CMAKE_CURRENT_SOURCE_DIR}.

Then, in lib/CMakeLists.txt, we simply use add_subdirectory to include our library.

1
add_subdirectory(app)

This is something new. By executing add_subdirectory, we go into that directory and run CMakeLists.txt there. This way, we can build large projects recursively. When there are more libraries, you can put them all here, so that we can add them all by executing add_subdirectory(lib).

Finally, our top-level CMakeLists.txt, we first add lib, and finally link app to our executable. The PRIVATE here has the same meaning as that of target_include_directories().

1
2
3
4
5
6
7
8
9
10
11
12
cmake_minimum_required(VERSION 3.16)

project(demo_21)

# Dependencies
add_subdirectory(lib)

# Main executable
add_executable(${PROJECT_NAME} src/main.cpp)

# Link the app library
target_link_libraries(${PROJECT_NAME} PRIVATE app)

This is how you build a project with library dependency.

No Source File?

Project of this section is placed under chapter_2/demo_22.

Sometimes, a library may only contain a bunch of header files, which is known as header-only libarary. In this case, there is no source file to be added to the target. Therefore, CMake provides a special target called INTERFACE. Now, we convert our library app in the last section into a header-only library.

1
2
3
4
5
6
7
8
9
10
11
12
demo_22
|-- CMakeLists.txt
|-- lib
| |-- CMakeLists.txt
| `-- app_ho
| |-- CMakeLists.txt
| `-- include/app
| |-- detail
| | `-- app_public.h
| `-- app.h
`-- src
`-- main.cpp

Except the changes in source code, only app_ho/CMakeLists.txt is slightly different. Now, instead of listing source files in add_library(), we use INTERFACE to indicate that it is header-only. Then, we also need to specify INTERFACE in target_include_directories().

1
2
3
4
5
6
7
8
9
cmake_minimum_required(VERSION 3.16)

project(app_ho)

add_library(${PROJECT_NAME} INTERFACE)

target_include_directories(
${PROJECT_NAME} INTERFACE ${CMAKE_CURRENT_LIST_DIR}/include
)

That’s it. You can reference this library in the same way.

Unit Test with CMake

Project of this section is placed under chapter_2/demo_23.

As a final step, I’d like to talk about unit testing with CMake. You may already heard about unit testing somewhere else. It is especially useful for library projects. To add unit test to your project, first you need to add enable_testing() in your top-level CMakeLists.txt.

enable_testing() must be placed top-level before you add the test directory. Otherwise, it will be ignored.

Usually, we place our unit tests under tests. And the complete project structure looks like this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fawefdemo_23
|-- CMakeLists.txt
|-- lib
| |-- CMakeLists.txt
| `-- add
| |-- CMakeLists.txt
| `-- include/add
| |-- detail
| | `-- add.h
| `-- add.h
|-- src
| `-- main.cpp
`-- tests
|-- CMakeLists.txt
`-- add_test.cpp

The content of tests/CMakeLists.txt is as follows. The unit test is in fact an executable, and we add it as a test. You can add more as you wish.

1
2
3
add_executable(add_test add_test.cpp)
target_link_libraries(add_test PRIVATE add)
add_test(NAME add_test COMMAND add_test)

Whether a test passes is indicated by its return value. Here, our test contains one assertion. If the assertion fails, it will return non-zero value, thus the unit test will fail.

1
2
3
4
5
6
7
#include "add/add.h"
#include <cassert>
int main()
{
assert(add(22, 44) == 66);
return 0;
}

Unit test is backed by ctest. You can run them simply with the following command.

1
2
3
4
5
6
7
8
> ctest --test-dir build
Test project F:/Development/Trivial/CMakeDemo/build
Start 1: add_test
1/1 Test #1: add_test ......................... Passed 0.01 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.02 sec

If you have build type specified, you may need to pass configuration argument to ctest, for example:

1
ctest --test-dir build -C Debug

Or if you have Test Explorer UI extension installed, you can view test cases in the sidebar of Visual Studio Code.

Now, I believe you are capable of managing a nice CMake project.😉


CMake Best Practices

Project of this chapter is under chapter_3.

Overview

First of all, it is a library project with dependencies, a demo and a set of unit tests, which makes it a comprehensive example. As you’ve already learned the essential of CMake in the previous chapters, I’ll focus on more advanced topics here, showing below.

  • Automatically version your project.
  • Using options to allow more flexible build.
  • Managing compiler flags.
  • Simplifying your CMakeLists.txt with loop.

Auto Versioning

It is a bad habit to copy code in your project, as it causes duplication, making it hard to maintain in the long run. One example is the project version. Usually, you will have a version in your code, but you have to write the same version again in the project configuration file, e.g. CMakeLists.txt. Fortunately, CMake is able to read files, so that you can automatically extract version from your code.

First, you can set the version of your project using MACROS in one of your header files. Here, I put it in c3/include/version.h.

1
2
3
#define C3_VERSION_MAJOR 1
#define C3_VERSION_MINOR 0
#define C3_VERSION_PATCH 0

Then, in your CMakeLists.txt, define a function called c3_extract_version to extract version from our header file via regex.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function(c3_extract_version)
file(READ "${CMAKE_CURRENT_LIST_DIR}/c3/include/version.h" file_contents)

string(REGEX MATCH "C3_VERSION_MAJOR ([0-9]+)" _ "${file_contents}")
if(NOT CMAKE_MATCH_COUNT EQUAL 1)
message(FATAL_ERROR "Could not extract major version number")
endif()
set(major_version ${CMAKE_MATCH_1})

string(REGEX MATCH "C3_VERSION_MINOR ([0-9]+)" _ "${file_contents}")
if(NOT CMAKE_MATCH_COUNT EQUAL 1)
message(FATAL_ERROR "Could not extract minor version number")
endif()
set(minor_version ${CMAKE_MATCH_1})

string(REGEX MATCH "C3_VERSION_PATCH ([0-9]+)" _ "${file_contents}")
if(NOT CMAKE_MATCH_COUNT EQUAL 1)
message(FATAL_ERROR "Could not extract patch version number")
endif()
set(patch_version ${CMAKE_MATCH_1})

set(C3_VERSION_MAJOR ${major_version} PARENT_SCOPE)
set(C3_VERSION_MINOR ${minor_version} PARENT_SCOPE)
set(C3_VERSION_PATCH ${patch_version} PARENT_SCOPE)
set(C3_VERSION "${major_version}.${minor_version}.${patch_version}" PARENT_SCOPE)
endfunction()

This function is quite long, and may fill up your main CMakeLists.txt. So, usually we will create a cmake directory and store such functions to utils.cmake. Then, we just need to include it before we use it. Note that if sub-directories will inherit the inclusion.

1
include(cmake/utils.cmake)

Options

Not all targets are needed when we build a project. For example, we may only build tests under development. Or, you may want to disable assertion for some builds. In this case, you can use option() command.

1
2
option(C3_BUILD_TEST "Build unit tests" ON)
option(C3_ENABLE_ASSERTION "Enable assertion" OFF)

To demonstrate assertion, I placed a 💣 in the source code. If you compile and run it with assertion enabled, you will see 💥.

We can override the choice in command line using -D.

1
cmake -Bbuild -DC3_BUILD_TEST=OFF -DC3_ENABLE_ASSERTION=ON

Then, the options can be directly used in if() command.

1
2
3
4
5
6
7
8
9
10
11
12
if(C3_ENABLE_ASSERTION)
target_compile_definitions(${TARGET_NAME} PUBLIC C3_ENABLE_ASSERT)
else()
message(WARNING "Assertion disabled for ${PROJECT_NAME}")
endif()

if(C3_BUILD_TEST)
enable_testing()
add_subdirectory(tests)
else()
message(STATUS "Skipping ${PROJECT_FULLNAME} unit tests")
endif()

Compiler Flags

To force yourself a better programming habit, you can enable more warnings for your project. Likewise, you can also turn off annoying warnings from 3rd-party dependencies. To achieve this, you can write some utility functions.

target_compile_options() will add compiler flags to the given target. It is always recommended to specify target to avoid unwanted pollution. Here I have to complain about MSVC, it has incompatible flags.☹️

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Turn on warnings on the given target
function(c3_enable_warnings target_name)
get_target_property(type ${target_name} TYPE)
if (NOT ${type} STREQUAL "INTERFACE_LIBRARY")
if(MSVC)
target_compile_options(${target_name} PRIVATE /W4)
else()
target_compile_options(${target_name} PRIVATE -Wall -Wextra -Werror)
endif()
endif()
endfunction()

# Turn off warnings on the given target
function(c3_disable_warnings target_name)
get_target_property(type ${target_name} TYPE)
if (NOT ${type} STREQUAL "INTERFACE_LIBRARY")
if(MSVC)
target_compile_options(${target_name} PRIVATE /W0)
else()
target_compile_options(${target_name} PRIVATE -w)
endif()
endif()
endfunction()

Since header file will be compiled with the source files that include it, a header-only library will not invoke compilation. Therefore, we cannot set compile options for interface library.

Final Touch

Now, your CMake project should look professional already.😁However, there is still some tips I’d like to share with you.

C/C++ standard

You can specify desired language standard by setting the built-in variable.

1
2
set(CMAKE_CXX_STANDARD 17) # for C++
set(CMAKE_C_STANDARD 17) # for C

Am I the Master?

When we develop a project, usually a library, we build it as the master project. That is to say, its CMakeLists.txt is at the top-level. However, after it is done, it will be referenced by another project, making it a sub-project. For this, we can write a simple function to detect whether it is built as a master project or not.

1
2
3
4
5
6
7
8
9
10
# a helper variable to check if the project is built as a master project
if(NOT DEFINED C3_MASTER_PROJECT)
if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR)
set(C3_MASTER_PROJECT ON)
message(STATUS "${PROJECT_NAME} is built as the master project")
else()
set(C3_MASTER_PROJECT OFF)
message(STATUS "${PROJECT_NAME} is built as a sub-project")
endif()
endif()

Then, we can optionally enable strict warnings when built as the master project.

1
2
3
4
# enable extra warnings if build as master project
if(C3_MASTER_PROJECT)
c3_enable_warnings(${TARGET_NAME})
endif()

How’s it going?

Can not help from wondering what CMake is doing? You can use message() command for logging.


Epilogue

Finally, it comes to an end, but there is still much to learn. For modern C++ projects, the most important part missing here is package management. CMake has install() and find_package() commands, and there are other tools like Conan and vcpkg for this purpose.

Anyway, this article is only a beginning of your CMake journey. Good luck, and have fun! ᓚᘏᗢ