Writing Modern CMake Files

Posted on 19 Feb 2018 in C++ • 6 min read


If you are a C++ developer, the word CMake shall not be a new word for you. The first time I know CMake was back in 2009 when I join the Hector team at TU Darmstadt to develop the elevation map from scratch. At that time, I learned the basic of CMake and then used it very minimal just to build my own component. Last year during the Self-Driving Car Engineer Nanodegree, I used and write CMake to build some C++ files. In November 2017, I encountered a challenge where I have to build software modules and libraries with CMake for multi-variant projects.

Supporting multi-variant projects mean that the platform libraries have to be compiled:

  • in different hardware architectures (e.g. x86, x64, armv7),
  • for different operating system (Linux, QNX, Integrity),
  • for different customer projects with different variants (e.g. project X model A, B, C and project Y model H, I, J)
  • for release and debug version

Note, CMake is a build system generator, not a build system.

My first effort

Directory structure

First of all, I have to make sure that the structure of the source code has the same layout. In my Linux OS, I define the root folder of the platform codes as platform and create the subfolders like this:

/platform
    library_a/
        CMakeLists.txt   
        doc/
        inc/
            lib_a.h
        src/
            lib_a.cpp
    library_b/
        CMakeLists.txt
        doc/
        inc/
            lib_b.h
            interface.h
        src/
            lib_b.cpp
            helper_b.cpp
    app_a/
        CMakeLists.txt   
        doc/
        src/
            main.cpp

Writing CMake

My first reference was the Mastering CMake book. Then, I read the official documentation and discussions in Stackoverflow. Reading the CMake introduction and the tutorials, I can create a CMake to build an application. The CMake content is written in the CMakeLists.txt.

An example to write a CMakeLists.txt (e.g. in AppA/) for an application written only in main.cpp without any dependency of library_a and library_b is:

cmake_minimum_required (VERSION 3.5)
project (AppA)
add_definitions(-std=c++11)
include_directories(/usr/local/include)
# Set a variable named sources
set(sources main.cpp)

# add the executable
add_executable (my_app ${sources})

I need include_directories(/usr/local/include) due to the standard library that I used like #include <string> in my main.cpp.

Next, I have such assumptions:

  • the main.cpp depends on both libraries (library_a, library_b),
  • library_a and library_b are built as shared libraries,
  • the library_a needs a third party library, for example it is called thirdlib.

Then, based on the tutorials I wrote such a top CMakeLists.txt file:

project(AppA)
cmake_minimum_required (VERSION 3.5)

# Add directories which contains CMakeLists.txt
add_subdirectory (
    ${CMAKE_CURRENT_SOURCE_DIR}/../library_a/ libliba
    ${CMAKE_CURRENT_SOURCE_DIR}/../library_b/ liblibb)

This top-level CMakeLists.txt is placed under app_a folder. The CMakeLists.txt under library_a/ can be written as:

add_definitions(-std=c++11 -g)

set(LIBSOURCES src/lib_a.cpp)

include_directories(/usr/local/include)

# include the internal header files
include_directories(inc)

# Path of the third library called thirdlib
link_directories(/usr/local/lib)

# create the shared library
add_library(libliba SHARED ${LIBSOURCES})
# Link the thirdlib to libliba
target_link_libraries (libliba  -lthirdlib)

So far the above CMake files can compile my code. However, after adding several libraries and delivering the library to the integration with another component in an integration server, I realized that the build process in different computers does not work. The reason is the include_directory syntax that searches for a particular path in a system. Moreover, a third party library can be installed in different system path which causes an error in the link_directories(/usr/local/lib) line.

The modern and effective approach

Daniel Pfeifer[1] and Mathieu Ropert[2] presentations give me a solution how to write CMake files effectively and efficiently to achieve a good modular design.

The main takeaway from their talks are:

  1. Use the latest CMake if possible or minimum the version 3.5.0.
  2. Keep the CMake code clean and clear.
  3. Avoid these commands: add_compiler_options, include_directories, link_directories, link_libraries.
  4. Do not set CMAKE_CXX_FLAGS. Setting the C++ compiler via -std=c++14 in CMAKE_CXX_FLAGS or add_definitions(-std=c++11) will brake in the future. Let CMake figures out itself. Instead, use the target_compile_features and the CMake features. In order to compile the library with C++11 we can use this below example that uses the variable templates feature[3]. The disadvantage of this approach is that we cannot recognize directly whether a library need to be compiled using C++11 or C++14.

    target_compile_features(liba PRIVATE cxx_variable_templates)
    
  5. Don’t use file(GLOB) in projects.

  6. Always explicitly declare properties PUBLIC, PRIVATE, or INTERFACE when using target_*.
  7. In order to build my own modular library I need to :

    • add add_subdirectory() of the library in your top-level CMakeLists.txt
    • add add_library() in the CMakeLists.txt in the library folder. Optionally, use a namespace for your library, e.g.: add_library(myLibrary::liba ALIAS liba)
    • use target_link_libraries() to express direct dependencies in your application folder.
      target_link_libraries(my_app
        PUBLIC myLibrary::liba
        PRIVATE myLibrary::libb
      )
      
  8. Keep internal properties PRIVATE.

  9. Use the find module find_package() for third-party libraries that are not built by CMake. If you can find the *Config.cmake file you don't have to create a Find*.cmake.
  10. Export your library’s interface, if you are a library author. One way is to use BUILD_INTERFACE and INSTALL_INTERFACE generator expressions[4] as filters. See more in Daniel's slide[5], on page 26.

Rico wrote about the similar modern CMake guide[6]. His article helps me to understand both talks from Daniel and Mathieu.

Directory Layout

Finally, I can restructure my folder layout to become like this:

/platform
    CMakeLists.txt
    library_a/
        build/
            project-x/
                CMakeLists.txt
            project-y/
                CMakeLists.txt
        cmake/
            FindLibthirdlib.cmake
        doc/
        inc/
            lib_a.h
        src/
            lib_a.cpp
        unittest/
            test.cpp
    library_b/
        build/
            project-x/
                CMakeLists.txt
            project-y/
                CMakeLists.txt
        doc/
        inc/
            lib_b.h
            interface.h
        src/
            lib_b.cpp
            helper_b.cpp
        unittest/
            test.cpp
    app_a/
        build/
            project-x/
                CMakeLists.txt
            project-y/
                CMakeLists.txt
        doc/
        src/
            main.cpp

Assuming that the third-party library thirdlib doesn't support CMake (ThirdlibConfig.cmake doesn't exist in the system), I need to write my own FindLibthirdlib.cmake under cmake folder.

The top-level CMakeLists.txt

In order to build the above application app_a for the project-x variant, we can pass an argument -DSW_PROJECT=project-x, for instance:

$mkdir /build && cd /build
/build$ cmake ../platform/ -DSW_PROJECT=project-x

Here, I can expand the above argument for the hardware variants by adding HW_ARCH=x64.

An example of the top-level CMake:

cmake_minimum_required (VERSION 3.5)

# Differentiate between Windows platform and others
if (MSVC)
    add_compile_options(/W3 /WX)
else()
    add_compile_options(-W -Wall)
endif()

# Use GNUInstallDirs to install libraries into the correct path on all platforms.
include(GNUInstallDirs)

# Include third party library, e.g. thirdlib
set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")

find_package(Libthirdlib REQUIRED)
if(LIBTHIRDLIB_LIBRARIES)
    message(STATUS "Top level cmake: libthirdlib found")
else()
    message(STATUS "Top level cmake: libthirdlib not found")
endif()
add_library(libthirdlib INTERFACE IMPORTED)
set_property(TARGET libthirdlib PROPERTY
    INTERFACE_INCLUDE_DIRECTORIES ${Libthirdlib_INCLUDE_DIR})

# dealing with the hardware variants
if(HW_ARCH)
    #if(${HW_ARCH} STREQUAL "x32")
    #...
    #elseif(${HW_ARCH} STREQUAL "armv7")
    #...
    #else()
    #...
    #endif()
endif()

# Add the other CMake files to be built
add_subdirectory(library_a/build/${SW_PROJECT})
add_subdirectory(library_b/build/${SW_PROJECT})
add_subdirectory(app_a/build/${SW_PROJECT})

CMakeLists.txt in the library folder

From the CMake wiki, CMake projects or libraries that are intended to be used by other projects should provide at a minimum a Config.cmake or a -config.cmake file. This file can then be used by the find_package() command in config-mode to provide information about include-directories, libraries and their dependencies, required compile-flags or locations of executables.

cmake_minimum_required (VERSION 3.5)

# Define library. Only source files here!
project(libliba VERSION 1.0 LANGUAGES CXX)
add_library(liba SHARED ../src/library_a.cpp)
add_library(myLibrary::liba ALIAS liba)

# Compile using C++11
target_compile_features(liba
    PRIVATE
        cxx_variable_templates
)

# Define headers for this library. PUBLIC headers are used for
# compiling the library, and will be added to consumers' build paths.
target_include_directories(liba PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../inc>
    $<INSTALL_INTERFACE:include>
    PRIVATE ../src
)

# Link the third-party library that we defined in the top-level file
target_link_libraries(liba PRIVATE
    thirdlib
)

# 'make install' to the correct locations (provided by GNUInstallDirs).
install(TARGETS liba EXPORT liba-config 
    ARCHIVE  DESTINATION ${CMAKE_INSTALL_LIBDIR}
    LIBRARY  DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME  DESTINATION ${CMAKE_INSTALL_BINDIR}
    INCLUDES DESTINATION inc # alternative use ${CMAKE_INSTALL_INCLUDEDIR} for ./include 
)

# This makes the project importable from the install directory
# where the *config.cmake files are placed
install(EXPORT liba-config DESTINATION DESTINATION lib/cmake/)

# This makes the project importable from the build directory
export(TARGETS liba FILE liba-config.cmake)

By running make, the file liba-config.cmake will be generated under the folder ./library_a. Both commands install(TARGETS ...) and install(EXPORT...) allow us to install the library and the *config.cmake file to the system folder. The command line make install executes both CMake commands.

CMakeLists.txt in the application folder

# Define an executable called my_app
add_executable(my_app
    ../src/main.cpp
)

# Define the libraries this project depends upon
target_link_libraries(my_app
    PUBLIC myLibrary::liba
    PRIVATE myLibrary::libb
)

Conclusion

Applying the effective modern CMake approach allow me to design a modular C++ software. I can write clean and simple CMake files that can be distributed to other teams around the world. The software modules and the libraries will not be break even I distribute them in the different integration servers. One aspect that is still need an improvement is the use of CMAKE_CXX_FEATURES, which is for me not so trivial how to map the features to the C++ compiler version.


References

  • [1] Daniel Pfeifer “Effective CMake".
  • [2] Mathieu Ropert “Using Modern CMake Patterns to Enforce a Good Modular Design".
  • [3] C++ Variable Templates.
  • [4] CMake Generator Expressions.
  • [5] Effective CMake Slides .
  • [6] The Ultimate Guide to Modern CMake by Rico.