How to Modern CMake

Modern CMake is all about targets and properties. The whole point is to avoid using CMake plain variables and apply the same best practices we apply to our source code. Using the targets and properties approach comes packed with additional machinery that makes identifying errors and exporting projects way easier.

An additional argument to stop using variables in CMake is that plain variables are just replaced as strings which are error prone. For example, if we commit a typo, the variable is just translated into a an empty string, which might be quite difficult to debug. Working with this new CMake approach is really easy.

First we define our targets. There are to options:

add_executable
add_library

I will use as example a new project I’m working on: a C++ chip8 interpreter. The project structure looks like this:

build
cmake
depends
docs
include
src/
    main.cpp
    other source files
test/
    test source files
CMakelists.txt
README.md

We basically have 4 targets in this project. The main executable, the rest of the sources (which I refer to as the core library), the test executable, and the test case sources.

This division allows us to easily tune the properties of each target appropriately. For example, we will use different compile flags for test and the core files.

In the cmake folder we will store the additional required files:

cmake/
    chip8_core_lib.cmake
    chip8_test_lib.cmake
    scripts/
        functions and macros common among the project

Our project cmake files should be prefixed with the project name, which will act as a namespace and will reduce the risk of collision with other projects’ scripts.

chip8_core_lib.cmake will define the core lib target. We will use an object library, which will compile the files but not link them.

add_library(CHIP8_CORE_LIB
    OBJECT
        src/Interpreter.cpp
        src/io/Speaker.cpp
        src/io/Keypad.cpp
        src/io/KeyMap.cpp
        src/io/display/PixelArray.cpp
        src/io/display/Renderer.cpp
        src/details/memory.cpp
        src/details/audio.cpp
        src/registers/DataRegister.cpp
        src/registers/IRegister.cpp
)

target_include_directories(CHIP8_CORE_LIB 
    PUBLIC 
        $<INSTALL_INTERFACE:${CMAKE_INSTALL_PREFIX}/include>
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    PRIVATE 
        ${CMAKE_CURRENT_SOURCE_DIR}/src
    )

target_compile_features(CHIP8_CORE_LIB PRIVATE cxx_std_17)

And our test object library:

add_library(CHIP8_TESTS_LIB
    OBJECT
        test/rom_test.cpp
        test/timer_test.cpp
        test/speaker_test.cpp
        test/keypad_test.cpp
        test/display_test.cpp
        test/register_test.cpp
        test/random_test.cpp
)

target_include_directories(CHIP8_TESTS_LIB PRIVATE include)

target_compile_features(CHIP8_TESTS_LIB PRIVATE cxx_std_17)

Our main CMake file can make use of these two object libraries to build the executable targets:

cmake_minimum_required(VERSION 3.1 FATAL_ERROR)
project(chip8_cpp VERSION 1.0.0)

list(APPEND CMAKE_MODULE_PATH 
    "${PROJECT_SOURCE_DIR}/cmake"
    "${PROJECT_SOURCE_DIR}/cmake/modules"
    "${PROJECT_SOURCE_DIR}/cmake/scripts"    
)

set(MAIN_EXECUTABLE ${PROJECT_NAME}_interpreter)

include(chip8_core_lib)
add_executable(${MAIN_EXECUTABLE} src/main.cpp  
    $<TARGET_OBJECTS:CHIP8_CORE_LIB>
)

if(BUILD_TESTS)
    enable_testing()

    set(TESTS_EXECUTABLE ${PROJECT_NAME}_tests)

    include(chip8_tests_lib)
    add_executable(${TESTS_EXECUTABLE} test/test_main.cpp 
        $<TARGET_OBJECTS:CHIP8_CORE_LIB> 
        $<TARGET_OBJECTS:CHIP8_TESTS_LIB>
    )
endif()

This structure allows as to have full control over the targets. Let’s say we want to add clang-tidy and set different flags for the different targets. We will be using this for all targets, so lets create a script:

function(target_add_clang_tidy TARGET_NAME_ARG CLANG_TIDY_CHECKS)

    if(${CLANG_TIDY})
        find_program(
            CLANG_TIDY_PATH
            NAMES "clang-tidy"
            DOC "Path to clang-tidy executable"
            )

        if(NOT CLANG_TIDY_PATH)
            message(FATAL_ERROR "Clang-tidy not found.")
        endif()
        
        execute_process (
            COMMAND ${CLANG_TIDY_PATH} --version
            OUTPUT_VARIABLE CLANG_TIDY_VERSION
        )

        # if clang-tidy version > 8.0.0
        if(${CLANG_TIDY_VERSION} MATCHES "([1-9]+[0-9]|[8-9])\.([0-9]+)\.([0-9]+)")
            message(STATUS "Clang-tidy found: ${CLANG_TIDY_PATH}")
            set(CLANG_TIDY_PATH_AND_OPTIONS "${CLANG_TIDY_PATH}" "${CLANG_TIDY_CHECKS}")

            set_target_properties(
                ${TARGET_NAME_ARG} PROPERTIES
                CXX_CLANG_TIDY "${CLANG_TIDY_PATH_AND_OPTIONS}"
                )
        else()
            message(FATAL_ERROR 
                "Minimum clang-tidy version is 8.0.0\n
                Current version:\n ${CLANG_TIDY_VERSION}\n
                Please upgrade."
            )
        endif()
    endif()

endfunction(target_add_clang_tidy)

Now we can set clang tidy flags for our core libs, and different ones for the test files:

include(chip8_clang_tidy)
string(CONCAT CHIP8_CORE_CLANG_TIDY_CHECKS "-checks=*,"
    # Disabled checks must be marked with a slash prefix
    "-fuchsia-*" "," 
    "-google-readability-namespace-comments" ","
    "-google-readability-todo" ","
    "-llvm-namespace-comment" ","
    "-hicpp-uppercase-literal-suffix" ","
    "-readability-uppercase-literal-suffix"
    )
target_add_clang_tidy(CHIP8_CORE_LIB ${CHIP8_CORE_CLANG_TIDY_CHECKS})
include(chip8_clang_tidy)
string(CONCAT CHIP8_TESTS_LIB_CLANG_TIDY_CHECKS "-checks=*,"
    # Disabled checks must be marked with a slash prefix
    "-fuchsia-*" ","
    "-google-readability-namespace-comments" ","
    "-google-readability-todo" ","
    "-llvm-namespace-comment" ","
    "-hicpp-uppercase-literal-suffix" ","
    "-readability-uppercase-literal-suffix" ","
    "-cppcoreguidelines-special-member-functions" ","
    "-hicpp-special-member-functions" ","
    "-cert-err58-cpp" ","
    "-cppcoreguidelines-owning-memory" ","
    "-cppcoreguidelines-avoid-goto" ","
    "-hicpp-avoid-goto" ","
    "-cppcoreguidelines-avoid-magic-numbers" ","
    "-readability-magic-numbers" ","
    "-cppcoreguidelines-pro-type-vararg" ","
    "-hicpp-vararg"
    )
target_add_clang_tidy(CHIP8_TESTS_LIB ${CHIP8_TESTS_LIB_CLANG_TIDY_CHECKS})

That’s just an example of what we can easily do! If you are struggling grasping all this concepts, do check the magnificent talk from Daniel Pfeifer:

Leave a Reply

Close Menu