Skip to content

Notes on CMake

Introduction

CMake is a build system generator for C/C++ projects. This means that CMake will not build your project itself, but instead generates the build system, which in turn builds your project.

Learning CMake can be frustrating. Unlike many other tools where you see immediate feedback from small changes, CMake requires you to regenerate the entire build system before you see any effect. Even for the simplest projects, the generated output is large and difficult to understand (by humans). So you need to learn CMake by trial-and-error, instead of try-and-inspect. Another thing that makes CMake hard to learn is that there are many ways to do something (because CMake is also a scripting language), and the 'correct' way to do something is not always obvious at first. As a consequence, there are a lot of CMake examples that sort of work, but in a fragile and nontransparent way.

Despite all of this, learning CMake is still worthwhile, because it is rapidly becoming the standard, replacing all the alternatives which are even harder to master.

Getting started

A minimalistic CMake file looks like this

cmake_minimum_required(VERSION 3.22)

project(project1)

add_executable(project1
    src/main.cpp
    src/functions.cpp
    etc.
)

You typically put this in a file called CMakeLists.txt at the root of your project.

To build your project, you typically first create a build folder, enter it, and run CMake:

mkdir build
cd build
cmake ..

By default CMake creates Makefiles, so the next step is to build your project by calling make

With the -G option you can choose other build systems. For example, on Windows you may want to generate Visual Studio project files. Or, you may want to choose Ninja, the faster and more modern alternative to make:

cmake .. -G Ninja
ninja

Instead of an executable, you can also build a library. In this case you have to replace add_executable by add_library(). In the above example this will generate libproject1.a

Working with variables:

You can define a variable with:

set(MY_VAR 42)

To reference the variable, you use it like ${MY_VAR}

For example, you can print it as a message with:

message(STATUS "The variable MY_VAR = ${MY_VAR}")

Note that CMake does not distinguish various variable types, everything is just a string.

You can also make a list of variables:

set(MY_VAR 42 43 boom roos True FALSE)

You can get an element out of the list and assign it to a new variable:

list(GET MY_VAR 2 MY_VAR2)
message(${MY_VAR2})

or use a for-loop to go over all of them:


foreach(MY_VAR2 IN LISTS MY_VAR)
    message(${MY_VAR2})
endforeach()

Note that if you want to learn the scripting aspects of CMake better, you can run cmake with the -P option, which turns off its build-system features.

Linking to a library

Say you have a library MyLib.so in a different project, and you want to use functions from this library in project1. You can tell CMake to link to this library with:

target_link_libraries(project1
    PUBLIC
        ${PROJECT_SOURCE_DIR}/../MyLib/build/MyLib.so
)

Usually a library will come with some header files, so you have to add them to the include path:

target_include_directories(project1
    PUBLIC
        ${PROJECT_SOURCE_DIR}/../MyLib/include
)

Note that the above way of adding a library is a good example of something that works in CMake, but not in the preferred way. In particular, if the library is a CMake project itself, you should add it with:

add_subdirectory(../MyLib MyLib_build)

target_link_libraries(project1
    PUBLIC
        MyLib
)

The first statement imports the CMakeLists.txt from MyLib into the current CMakeLists.txt, and the second statement links the library MyLib to project1. Note that if things are set up correctly in MyLib, there is no need to add something to the include path of project1.

Producing a library

Just as add_executable() creates an executable, add_library() creates a library, so start with adding something like this to the CMakeLists.txt file:

add_library(MyLib SHARED
    src/functions1.cpp
    src/functions2.cpp
    etc.
)

Next, you typically need to specify the include paths. You can do this with a statement that looks something like this:

target_include_directories(MyLib
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

Note that two different paths are included; the 'include' directory contains the header files which define the public interface of the library. These headers must be visible to the library itself, as well as the projects that use the library. On the other hand, the headers in the 'src' directory are only visible to the library itself. The interesting thing is that the consumer of the library does not have to worry about the include path; the other project just has to call target_link_libraries() and CMake takes care of the rest.

Note that besides PUBLIC and PRIVATE, you can also specify a feature as INTERFACE. This will make it available for consumers of the library, but invisible to the library itself.

Using Qt

Using Qt starts with this statement somewhere at the top of the CMakeLists.txt file:

find_package(Qt6 REQUIRED COMPONENTS Widgets)

Next, you have to add this to make uic and moc work:

set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)

And finally, you have to link to Qt by adding it to the list of libraries you use in target_link_libraries()

target_link_libraries(project1
    PUBLIC
        Qt6::Widgets
        (other libraries you use)
)

Using a toolchain file

cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=toolchain.cmake

In toolchain.cmake you specify your toolchain, for example:

# the name of the target operating system
set(CMAKE_SYSTEM_NAME Generic)

# which compilers to use:

set(CMAKE_ASM_COMPILER /path_to_toolchain/bin/arm-none-eabi-gcc)
set(CMAKE_C_COMPILER   /path_to_toolchain/bin/arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER /path_to_toolchain/bin/arm-none-eabi-g++)

set(CMAKE_C_COMPILER_WORKS TRUE)
set(CMAKE_CXX_COMPILER_WORKS TRUE)

Setting compiler and linker flags

With these statements you can specify compiler and linker flags:

target_compile_options(project1 PRIVATE MyOption)
target_link_options(project1 PRIVATE MyOption)

For example, you can enable the generation of a .map file with:

target_link_options(project1 PRIVATE -Wl,-Map=output.map)

Note that the options are given verbatim to the compiler, which is powerful, but also fragile if you have multiple compilers.

For specific tasks such as adding include paths or setting defines, it's better to use dedicated CMake functions. For example, to add a define, use target_compile_definitions():

target_compile_definitions(project1 PRIVATE MY_OPTION=42)

Options

option(ENABLE_MY_OPTION "This is a test option" ON)

if (ENABLE_MY_OPTION)
    target_compile_definitions(project1 PRIVATE MY_OPTION=42)
endif()

Note that the idea of options is that they can be presented to the user via a GUI, for example in an IDE.

Useful pre-defined variables

PROJECT_SOURCE_DIR
PROJECT_BINARY_DIR
CMAKE_CURRENT_SOURCE_DIR
CMAKE_CURRENT_LIST_DIR
CMAKE_CURRENT_BINARY_DIR
CMAKE_SOURCE_DIR
CMAKE_BINARY_DIR
CMAKE_CXX_COMPILER_ID
CMAKE_CXX_COMPILER
CMAKE_SYSTEM_NAME
CMAKE_BUILD_TYPE
CMAKE_INSTALL_PREFIX