Build System

There are several build system options one can choose from, but CMake clearly stands out in the cross-platform category, and is widely supported by library developers. It may not be the simplest/easiest build system to use, however its recent evolution (i.e. ‘modern’ features) definitely helps. CMake does also provide a few functionalities that could make it look like a legit package manager, but it really is not, and should not be used as such. The excellent book from Craig Scott on the subject, Professional CMake, A Practical Guide, is a very useful reference.

In this post, we go through the build system of Sempervirens. It is likely good enough to start with, but will definitely evolve some more. Here is what the project tree looks like:

Sempervirens/
.. CMakeLists.txt  <-- Top-level CMakeLists.txt file
.. cmake/          <-- Project modules
.. dependencies/   
.. include/        <-- Project headers to be shared with application
.. packaging/      <-- Config and version cmake template files
.. src/            <-- Project header and implementation files
.. tests/          <-- Unit tests

Let’s start with the top level CMakeLists.txt file:

#////////////////////////////////////////////////////////////////////////////
# Preamble
cmake_minimum_required(VERSION 3.19 FATAL_ERROR)
list(APPEND CMAKE_MESSAGE_CONTEXT Sempervirens)
message(STATUS "Configuring Sempervirens ...")
project(Sempervirens
        VERSION 1.0.0
        LANGUAGES C CXX)

#////////////////////////////////////////////////////////////////////////////
# Project wide setup
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
set(CMAKE_CXX_STANDARD_EXTENSIONS False)

include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
include(GenerateExportHeader)
include(CMakeParseArguments)
list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake")
include(HelperFunctions)

enable_testing()

if(NOT WIN32 AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) 
    set(CMAKE_INSTALL_PREFIX "/opt/dawnofgiantsstudios.com/${CMAKE_PROJECT_NAME}")
endif()

#////////////////////////////////////////////////////////////////////////////
# Dependencies
add_subdirectory(dependencies)

#////////////////////////////////////////////////////////////////////////////
# Build main targets
add_subdirectory(src)

#////////////////////////////////////////////////////////////////////////////
# Tests and packaging
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
    add_subdirectory(tests)
    add_subdirectory(packaging)
endif()

The preamble section is fairly standard: we declare the minimum version of CMake we need (definitely at least 3.1 to get most of the new features), and define the project. The second line adds a message context that will be displayed during the configure stage of CMake: it is just a prefix to all messages generated from the current file, but it helps following what is going on when the project becomes more significant in size and the configure stage gets more involved.

Next comes a section where we define all the project-wide settings. This usually includes the C++ standard settings, enables testing (using CTest) for the project, and that is also where we include any modules we want to use functionalities from. The most important might be the GNUInstallDirs module that will make sure, in particular, that the installation binary and library directories are set to standard locations on different platforms. One default we want to override on Linux distributions is the installation prefix path. By default, the latter is not compliant with the Filesystem Hierarchy Standard (FHS) for add-on packages. For add-ons, it is recommended to install in /opt/name_of_your_organization. CMake also allows users to provide their own modules. In this case, it is good practice to include them in a dedicated cmake directory at the top level of the tree, and add the corresponding path to CMAKE_MODULE_PATH. This is what we do for our HelperFunctions.cmake module.

Then come sections dealing with the dependencies of the project, the targets to build, and finally testing and packaging. For these stages, it is recommended to delegate the configuration to sub-directories. This makes the top level CMakeLists.txt file clearer, and is a good practice of separation of concerns for easier development and maintenance. Let’s start with the dependencies, and look at the CMakeLists.txt file in the dependencies sub-directory.

find_package(Threads)
set_target_properties(Threads::Threads PROPERTIES IMPORTED_GLOBAL TRUE)
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
    find_package(X11 REQUIRED)
    set_target_properties(X11::X11 PROPERTIES IMPORTED_GLOBAL TRUE)
endif()

This is a fairly minimum set of dependencies for now, and both are found using the find_package command. By default, imported targets have only directory scope visibility. Because we import the targets from the dependencies sub-directory and will link against them in another directory, we need to promote them to global visibility. The main target is built from the src sub-directory:

add_library(${CMAKE_PROJECT_NAME} STATIC)
add_library(${CMAKE_PROJECT_NAME}::${CMAKE_PROJECT_NAME} ALIAS ${CMAKE_PROJECT_NAME})

set_target_properties(${CMAKE_PROJECT_NAME}
    PROPERTIES
    SOVERSION ${CMAKE_PROJECT_VERSION_MAJOR}
    VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH})    

target_compile_definitions(${CMAKE_PROJECT_NAME}
    PUBLIC
    $<$<PLATFORM_ID:Linux>:PLATFORM_UNIX=1>
    $<$<CONFIG:Debug>:BUILD_UNITTESTING=1 BUILD_LOGGING=1>)

target_include_directories(${CMAKE_PROJECT_NAME}
    PUBLIC
    $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
    PUBLIC
    $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/src>
    INTERFACE
    $<INSTALL_INTERFACE:include/${CMAKE_PROJECT_NAME}>)

target_precompile_headers(${CMAKE_PROJECT_NAME}
    PRIVATE
    ../include/Sempervirens_pch.hpp)

target_compile_options(${CMAKE_PROJECT_NAME}
    PRIVATE
    -Wall -O2)

add_subdirectory(Logging)
add_subdirectory(UnitTesting)
add_subdirectory(MemoryAlloc)
add_subdirectory(Timer)
add_subdirectory(EventSystem)
add_subdirectory(Window)
add_subdirectory(Keyboard)
add_subdirectory(Mouse)
add_subdirectory(Controller)
add_subdirectory(Application)

target_link_libraries(${CMAKE_PROJECT_NAME}
    PRIVATE
    sempervirensversion
    Threads::Threads)

if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
    target_link_libraries(${CMAKE_PROJECT_NAME}
    PRIVATE
    X11::X11)    
endif()

Sempervirens is currently built as a static library. By convention when a target is created, one should also provide an alias accounting for the export namespace of the target. This assures that the same naming convention (i.e. namespace::target) can be used whether the target is installed or directly exported in the build tree of the project. The version of the library is set as a target property. Here we also use the naming convention for shared libraries (with the soversion), in case we decide to switch later on. Then we declare some compile definitions, options, the include directories and precompiled headers.

A few generator expressions (i.e. $<…>) are used in this file: these are expressions that can only get evaluated during the generation phase (i.e. after the configuration and before the build phases). They make your code more robust (e.g. valid for both single and multiple configuration generators) and concise, by avoiding the use of conditional expressions for example.

After that we declare all the sources we want to be included in the library, and link the target to its dependencies, which are all private dependencies for now (i.e. build requirements only). It is a matter of preference to have all sources at the same level in the project tree, or organized in specific sub-directories like we do here. The later CMake versions allow users to add sources to a target from different directories, which makes this organization possible. Here is an example where we add sources for the Logging sub-directory:

list(APPEND CMAKE_MESSAGE_CONTEXT logging)
message(STATUS "Configuring Logging ...")
target_sources(${CMAKE_PROJECT_NAME}
    PRIVATE
    ${CMAKE_CURRENT_LIST_DIR}/Logger.cpp
    ${CMAKE_CURRENT_LIST_DIR}/ConsoleLogger.cpp)

Here it seems that we need to specify the path to the sub-directory containing the sources because the target was declared in the parent directory. There might be a policy that can be changed to deal with that automatically.

The final sections of our top-level CMakeLists.txt file are dealing with testing and packaging. All our unit tests are located in specific sub-directories of the tests directory. We use a helper function to create the unit tests in each sub-directory when we build the project in Debug mode:

if(${CMAKE_BUILD_TYPE} STREQUAL "Debug")
    create_unittest(DIR Logging DEP ${CMAKE_PROJECT_NAME})
    create_unittest(DIR UnitTesting DEP ${CMAKE_PROJECT_NAME})
    create_unittest(DIR MemoryAlloc DEP ${CMAKE_PROJECT_NAME})
    create_unittest(DIR Window DEP ${CMAKE_PROJECT_NAME})  
endif()

This function is defined in our HelperFunctions.cmake module located in the cmake directory of the project:

cmake_minimum_required(VERSION 3.19 FATAL_ERROR)

function(create_unittest)
    # Note: targets reuse the main project precompiled header and so must use the exact same compilation flags for it to be valid
    set(prefix       ARG)
    set(noValues     "")
    set(singleValues DIR)
    set(multiValues  DEP)
    cmake_parse_arguments(${prefix} "${noValues}" "${singleValues}" "${multiValues}" ${ARGN})
    set(DIR ${${prefix}_DIR})
    set(DEP ${${prefix}_DEP})

    string(TOLOWER ${DIR} dir)
    set(TGT "test_${dir}")
    add_executable(${TGT})
    target_sources(${TGT} PRIVATE ${DIR}/${DIR}Test.cpp)
    target_precompile_headers(${TGT} REUSE_FROM ${CMAKE_PROJECT_NAME})
    foreach(dep IN ITEMS ${DEP})
        target_link_libraries(${TGT} PRIVATE ${dep})
    endforeach()
    target_compile_options(${TGT}
        PRIVATE
        -Wall -O2)
    add_test(NAME test-${dir} COMMAND ${TGT})
endfunction()

Finally, we are left with packaging. This was definitely the most complex part of the CMake build setup to understand for us. Here we tried to follow the conventions explained in Craig Scott’s book, mentioned at the beginning of the post:

#//////////////////////////////////////////////////////////////////////////
# Generate version implementation (for runtime reference)
configure_file(SempervirensVersion.cpp.in ${PROJECT_BINARY_DIR}/SempervirensVersion.cpp @ONLY)
add_library(sempervirensversion OBJECT)
target_sources(sempervirensversion
    PRIVATE
    ${PROJECT_BINARY_DIR}/SempervirensVersion.cpp)
target_include_directories(sempervirensversion
    PRIVATE
    $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
    INTERFACE
    $<INSTALL_INTERFACE:include/${CMAKE_PROJECT_NAME}>)


#///////////////////////////////////////////////////////////////////////////
# Install the target
if(NOT ${CMAKE_SYSTEM_NAME} STREQUAL "Apple")
    set(CMAKE_INSTALL_RPATH $ORIGIN) # Deals with RPATH precedence on Linux
endif()

install(TARGETS ${PROJECT_NAME} sempervirensversion
        EXPORT ${CMAKE_PROJECT_NAME}Targets
        ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
                COMPONENT ${CMAKE_PROJECT_NAME}_Development
        LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
                COMPONENT ${CMAKE_PROJECT_NAME}_Runtime
                NAMELINK_COMPONENT ${CMAKE_PROJECT_NAME}_Development
        RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
                COMPONENT ${CMAKE_PROJECT_NAME}_Runtime
        INCLUDES DESTINATION include)

install(FILES ${PROJECT_SOURCE_DIR}/include/Sempervirens.hpp
        DESTINATION include/${CMAKE_PROJECT_NAME})
install(FILES ${PROJECT_SOURCE_DIR}/include/Sempervirens_pch.hpp
        DESTINATION include/${CMAKE_PROJECT_NAME})
install(FILES ${PROJECT_SOURCE_DIR}/include/SempervirensVersion.hpp
        DESTINATION include/${CMAKE_PROJECT_NAME})    
install(DIRECTORY ${PROJECT_SOURCE_DIR}/src/
        DESTINATION include/${CMAKE_PROJECT_NAME}
        FILES_MATCHING 
        PATTERN "*.hpp"
        PATTERN "*.inl")

install(EXPORT ${CMAKE_PROJECT_NAME}Targets
        FILE ${CMAKE_PROJECT_NAME}Targets.cmake
        NAMESPACE ${CMAKE_PROJECT_NAME}::
        DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${CMAKE_PROJECT_NAME})


#////////////////////////////////////////////////////////////////////////////
# Package the target
write_basic_package_version_file(${PROJECT_BINARY_DIR}/${CMAKE_PROJECT_NAME}ConfigVersion.cmake
        VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}
        COMPATIBILITY SameMajorVersion)

configure_package_config_file(${CMAKE_CURRENT_SOURCE_DIR}/${CMAKE_PROJECT_NAME}Config.cmake.in
        "${PROJECT_BINARY_DIR}/${CMAKE_PROJECT_NAME}Config.cmake"
        INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/${CMAKE_PROJECT_NAME})

install(FILES
        "${PROJECT_BINARY_DIR}/${CMAKE_PROJECT_NAME}Config.cmake"
        "${PROJECT_BINARY_DIR}/${CMAKE_PROJECT_NAME}ConfigVersion.cmake"
        DESTINATION
        ${CMAKE_INSTALL_LIBDIR}/cmake/${CMAKE_PROJECT_NAME})


#////////////////////////////////////////////////////////////////////////////
# Export the target directly from the build tree
export(EXPORT ${CMAKE_PROJECT_NAME}Targets
       FILE "${PROJECT_BINARY_DIR}/cmake/${CMAKE_PROJECT_NAME}Targets.cmake"
       NAMESPACE ${CMAKE_PROJECT_NAME}::)

In the first section, we automatically generate the implementation file for a project version header we included in the include directory of the project. The goal is for the user of the library to be able to query the library version from their application. This is totally optional, but a nice feature to have.

Next comes the installation of the library. In this section all specified paths are relative, as we are using the GNUInstallDirs package that defines CMAKE_INSTALL_PREFIX in particular. This has the great advantage to make the defined target relocatable. All headers and cmake config files are installed to the proper locations, and the target is automatically exported via the SempervirensTargets.cmake file. This file will include all the details about the target, in particular all its transitive dependencies. This is the file that the SempervirensConfig.cmake file (i.e. the file that the include_package(Sempervirens) command is looking for) will include after finding all the dependencies on the target platform. It is good practice that if a config file is provided for the project, a config version file that declares the version compatibility of the target (i.e. what will be checked against when using a version specifier in the find_package command) is also provided. The final section provides an export of the target directly from the build tree.

We are using a set of three helper scripts to use the Sempervirens build system: one to configure/generate the project, one to build it, and one to install it:

# Configure script
#!/bin/sh
config=$1
platform=$2
generator=''
if [ $platform = "Linux" ]
then
    generator="Unix Makefiles"
elif [ $platform = "apple" ]
then
    generator="Xcode"
else
    echo "unrecognized platform"
    return
fi

builddir="build-Sempervirens-"$config'-'$platform
rm -rf ../$builddir
mkdir ../$builddir && cd ../$builddir
cmake ../Sempervirens -G "$generator" --log-context -DCMAKE_BUILD_TYPE="$config" 

# Build script
#!/bin/sh
config=$1
platform=$2
if [ $platform != "Linux" ]
then
    if [ $platform != "apple" ]
    then
    echo "unrecognized platform"
    return
    fi
fi

builddir="build-Sempervirens-"$config'-'$platform
cd ../$builddir
cmake --build . --config $config --parallel 8

# Install script
#!/bin/sh
config=$1
platform=$2
if [ $platform != "Linux" ]
then
    if [ $platform != "apple" ]
    then
    echo "unrecognized platform"
    return
    fi
fi

installdir=$3

builddir="build-Sempervirens-"$config'-'$platform
cd ../$builddir
cmake --install . --prefix $installdir

After installation, whether an installation prefix was specified or not, the install tree will look something like this:

.. include/
  .. Sempervirens/
     .. Sempervirens.hpp
     .. Sempervirens_pch.hpp
     .. SempervirensVersion.hpp
     .. Sub-directories/*.hpp, *.inl
.. lib/
  .. cmake/
     .. Sempervirens/
        .. SempervirensConfig.cmake
        .. SempervirensConfigVersion.cmake
        .. SempervirensTargets.cmake  
        .. SempervirensTargets-debug.cmake
  .. libSempervirens.a