CMake Part 2 – Release and Debug builds

In my previous blog post CMake Part – The Dark Arts I discussed how to configure CMake to cross-compile to target hardware such as our STM32F407 Discovery board.

We looked at the minimum requirements to configure the CMake build generator for a cross-compilation project using a project definition file (CMakeLists.txt), a toolchain definition file (toolchain-STM32F407.cmake). The CMake commands used to generate and build the project are:

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

In the real world, projects are never as simple as this minimal example, and we try to reflect this in our training. To support the different phases and objectives of a Software Development Lifecycle a project will need to differentiate between developing code, testing (in its various forms) and releasing a version for end-use. We usually do this using build configurations.

Outputs from each type of build configuration are usually different. For example, a developer’s build typically includes metadata used by a debugger which is not required for a released version of the project. Therefore, we need to configure our build process to cater for these different output requirements.

Both Visual Studio and Xcode  support multiple build configurations, and CMake can generate appropriate build configuration files for these systems.

On the other hand, the Unix/Linux/GNU Make system does not support build configurations. When using CMake to generate different build requirements using make files we take this into account by placing different build configurations in different output directories for each type of build we want to support.

Configuring Debug and Release Builds

CMake refers to different build configurations as a Build Type.  Suggested build types are values such as Debug and Release, but CMake allows any type that is supported by the build tool. The build type specification is case insensitive, so we prefer to be consistent and use all upper case types despite the fact that the CMake documentation refers to capitalised types.

Our underlying build system for training is Make, so we need to create separate output folders for each type of build we require. Unfortunately, this means we have to run two very similar cmake commands to generate different configurations:

cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=DEBUG \
      -DCMAKE_TOOLCHAIN_FILE=toolchain-STM32F407.cmake

cmake -S . -B build/release -DCMAKE_BUILD_TYPE=RELEASE \
      -DCMAKE_TOOLCHAIN_FILE=toolchain-STM32F407.cmake

We also have two separate commands, one for each build type:

cmake --build build/debug
cmake --build build/release

Aside: as a traditional Unix/Linux developer used to typing make I find these long and complex commands irksome and I know I’m not alone in this as it is a common source of criticism of CMake.

At this point, using a shell script, or scripts, to encapsulate the underlying cmake commands to simplify build the system would be advisable. There is an example shell script build.sh in the accompanying GitHub project https://github.com/feabhas/cmake-blog-2.

For developer’s working with build tools supporting multiple build configurations (like Xcode and Visual Studio), the build type is not passed on the generate command line (using -CCMAKE_BUILD_TYPE=…) but on the cmake build command with the –config option. For example:

cmake -S . -B build
cmake --build build --config Debug

Note: when using Make builds, the –config option is silently ignored, and when using multi-configuration build tools like Visual Studio, the setting for CMAKE_BUILD_TYPE is also silently ignored. A source of confusion and criticism when first starting to use CMake.

To support multiple build configurations for our training projects we just need to refactor the project and toolchain configuration files to be aware of build types. To do this, we make use of CMake generator expressions, so we need a short digression to discuss this feature of CMake.

CMake Generator Expressions

A generator expression is used to query aspects of the build as the build files are generated giving us a  dynamic view of the build generation process.

A static view of the build generation process is provided by command line definitions and variables defined in the configuration files, which are saved to the build cache file CMakeCache.txt in the build target directory. Note that variables should not change value once the build file generation process begins as this can cause discrepancies in the generated files.

Generator expressions are specified using  $< expression > where the expression can take many different forms, whereas variable values are specified using ${ name }. Variables, once set, can be used at any point in the CMake files, whereas generator expressions query the current build generation environment and are only valid in specific contexts.

The use of the generator expression $<TARGET_FILE:Application> resolves to the path to the output file in the build rule for our main application (Application is the target name). This expression is only valid after both the target and the target suffix have been defined:

add_executable(Application src/main.cpp)
set_target_properties(Application PROPERTIES
    SUFFIX .elf
)

For our project this is the absolute path to build/debug/Application.elf.

A generator expression is defined using a $< : > syntax with the entry after the colon defining the value of the expression. The first part before the colon takes different forms such as:

  • a conditional test such as $<CONFIG:DEBUG> is true if this is a debug build type  defined by the command line option -DCMAKE_BUILD_TYPE=DEBUG
  • a target-dependent query such as $<TARGET_FILE: name >
  • a string manipulation expression
  • a variable query

The generator expressions manual page describes the complete range of generator expressions.

Toolchain Configuration

Refactoring our project toolchain file (toolchain-STM32F407.cmake) requires identifying compilation options only applicable to debug builds:

add_compile_definitions(
  STM32F407xx
  USE_FULL_ASSERT
  $<$<CONFIG:DEBUG>:OS_USE_TRACE_SEMIHOSTING_STDOUT>
  $<$<CONFIG:DEBUG>:OS_USE_SEMIHOSTING>
)

In this example $<CONFIG:DEBUG> is true for a debug build type and similarly $<CONFIG:RELEASE> (not used in the example) is true for a release build. Note that the generator expression is all uppercase regardless of the actual value defined for CMAKE_BUILD_TYPE. The CMake documentation often refers to DCMAKE_BUILD_TYPE=Debug but the generator expression is always $<CONFIG:DEBUG>.

In our example we have added compiler definitions entries to support using host debugging via a serial port for a debug project.

For our training project we will need to use different runtime support configurations for the debug runtime (rdimon.specs) and a bare metal release (nosys.specs):

add_link_options(
  ${ARM_OPTIONS}
  $<$<CONFIG:DEBUG>:--specs=rdimon.specs>
  $<$<CONFIG:RELEASE>:--specs=nosys.specs>
  $<$<CONFIG:DEBUG>:-u_printf_float>
  $<$<CONFIG:DEBUG>:-u_scanf_float>
  -nostartfiles
  LINKER:--gc-sections
  LINKER:--build-id
)

As an alternative to using $<CONFIG:RELEASE> we could have tested for the absence of debug mode using the more complex syntax:

$<$<NOT:$<CONFIG:DEBUG>>:--specs=nosys.specs>

Here we use an inner generator expression to control the inclusion of an enclosing generator expression.

Build Customisation

With the toolchain correctly configured we will update the project configuration (CMakeLists.txt) to refactor the compiler optimisations and symbol definitions for each build type:

add_compile_options(
  -Wall
  -Wextra
  -Wconversion
  -Wsign-conversion
  $<$<CONFIG:DEBUG>:-g3>
  $<$<CONFIG:DEBUG>:-Og>
  $<$<CONFIG:RELEASE>:-O3>
)

add_compile_definitions(
  $<$<CONFIG:DEBUG>:DEBUG>
)

Note: we need to define the compiler DEBUG symbol ourselves – it doesn’t happen automatically when we select the debug build type. The build type variable CMAKE_BUILD_TYPE is a CMake variable and not a linker or compiler defined symbol. The familiar syntax of using -D on the command line to define CMake variables can be confusing when first using CMake as these are not definitions for the underlying compiler.

As an alternative approach for the build type definition we could have simply inserted the $<CONFIG> generator expression as a compiler pre-processor definition:

add_compile_definitions(
  $<CONFIG>
)

This approach would add the pre-processor build type value as a compiler definition. However in this approach the value used would keep the original letter case so that using the CMake approach of -DCMAKE_BUILD_TYPE=Debug would define a compiler variable called Debug which would not match the expected upper case definition (DEBUG).

Our example project does not need any linker options specific to the build type for our example project as these were handled in the toolchain file.

Post Build Tools

Often when creating a target, such as our executable program, there are additional actions required after a successful build.

In our cross compiler project, we want to use the objcopy command to generate the hex file used by some flash memory programmers.

We use add_custom_command() function calls to run actions after a successful build of a target. CMake automatically generates a variable (CMAKE_OBJCOPY) for the path of the objcopy program when the C or C++ compiler is specified in  the toolchain configuration file (in our case it will be arm-none-eabi-objcopy) . We should use this  variable preference to the raw command name:

add_custom_command(
  TARGET Application
  POST_BUILD
  COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:Application> 
          ${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_NAME:Application>.hex
)

The use of POST_BUILD command line should be self-explanatory: CMAKE_OBJCOPY is set to the path of the the objcopy command (implicitly defined in toolchain-STM32F407.cmake) and CMAKE_CURRENT_BINARY_DIR is the path to the build folder (-B on the command line).

In building the objcopy command line we need to use generator expressions to get the path to the target application ELF file ($<TARGET_FILE:Application>) and the base filename defined by $<TARGET_NAME:Application> because these are specific to that target.

Conditional Tests

One minor complication to using CMAKE_OBJCOPY in the previous section is that the objcopy command may not be part of the toolchain we are using, in which case CMake sets CMAKE_OBJCOPY to the value CMAKE_OBJCOPY-NOTFOUND.

We should test a command path variable to make sure the command exists:

if (EXISTS ${CMAKE_OBJCOPY})
  add_custom_command(
  TARGET Application
  POST_BUILD
  COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:Application>
          ${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_NAME:Application>.hex
)
else()
  message(STATUS "'objcopy' not found: cannot generate .hex file")
endif()

Note the use of parentheses on the else() and endif() functions – everything is a function in CMake. The else() part is optional, but we have used it to output a message during the build file generation phase, but this won’t be displayed in the actual build.

The first (optional) parameter to message() is a type indicator: in our case a STATUS message is output prefixed with . In contrast, a FATAL message will display the message and stop the build generation at that point. Other message types are described in the CMake manual.

It is worth reinforcing the idea that CMake uses whitespace separated arguments to functions so the COMMAND arguments can be given across multiple lines without using a line continuation character (such as \  in shell or Python scripts).

As an aside, you should be aware that CMake does not warn when an undefined variable is used, it simply substitutes nothing. This can be problematic, so we advise using the command line option –warn-uninitialized, which will display a warning message but won’t stop the build. So make sure you check the output from the build generation steps carefully in case you’ve mistyped a variable name.

cmake -S . -B build --warn-uninitialized -DCMAKE_TOOLCHAIN_FILE=toolchain-STM32F407.cmake

There is one downside to adding this warning and that is when CMake generates the build files and the output directory already contains generated files CMake does not usethe toolchain file if the toolchain file has not been recently modified. In this situation the CMAKE_TOOLCHAIN_FILE is effectively unused and a warning is issued. To suppress this warning, which implies something is wrong when it isn’t, you can simply read the variable in a message:

MESSAGE(STATUS "Using toolchain file: ${CMAKE_TOOLCHAIN_FILE}")

Custom Commands

While the CMake toolchain includes a few commonly used commands like objcopy and ar there are often additional project or environment specific commands you need to run post (or pre) build. While you can add these to the CMakeList.txt file, we think the toolchain file is the right place to configure the custom command paths.

In our cross compilation toolchain file (toolchain-STM32F407.cmake) we added logic to locate additional Arm commands not recognised by CMake:

find_program(CROSS_GCC_PATH "arm-none-eabi-gcc")
if (NOT CROSS_GCC_PATH)
  message(FATAL_ERROR "Cannot find ARM GCC compiler: arm-none-eabi-gcc")
endif()
get_filename_component(TOOLCHAIN ${CROSS_GCC_PATH} PATH)

set(CMAKE_C_COMPILER ${TOOLCHAIN}/arm-none-eabi-gcc)
set(CMAKE_Cxx_COMPILER ${TOOLCHAIN}/arm-none-eabi-g++)
set(TOOLCHAIN_as ${TOOLCHAIN}/arm-none-eabi-as CACHE STRING "arm-none-eabi-as")
set(TOOLCHAIN_LD ${TOOLCHAIN}/arm-none-eabi-ld CACHE STRING "arm-none-eabi-ld")
set(TOOLCHAIN_SIZE ${TOOLCHAIN}/arm-none-eabi-size CACHE STRING "arm-none-eabi-size")

The find_program function searches the host filesystem for the path to a given program which it stores in the variable name given as the first parameter. If the program isn’t found, the variable is set to <name>-NOTFOUND, in our case CROSS_GCC_PATH-NOTFOUND. We can check that the ARM compiler has been found by testing  CROSS_GCC_PATH:variable values ending with -NOTFOUND evaluate to false.

Our search is complicated because we haven’t put the Arm toolchain in the standard Linux folders (such as /usr/bin), so we have to extract the directory path part of the arm-none-eabi-gcc command so we can get the toolchain directory location with get_filename_component.

We have prefixed our custom variables defining the paths to the toolchain commands with TOOLCHAIN- to differentiate them from the standard CMake commands.

We need to store these variables where the main project can reference them, so we add them to the cache file using CACHE STRING followed by a variable description. Each CMake definition file is a separate processing environment, and variables not added to the cache will be discarded after build file processing is finished.

If you are interested, the variable cache is stored the file CMakeCache.txt in the build folder. An entry for the arm-none-eabi-as commnd looks like:

//arm-none-eabi-as
TOOLCHAIN_as:STRING=/opt/gcc-arm-none-eabi-10-2020-q4-major/bin/arm-none-eabi-as

Note that we don’t use strings for the variable values but use what Perl calls bare words which are values without the quotes (so long as we don’t have whitespace characters in the value). We have chosen to set the variable descriptions as strings because they usually contain spaces: in our case, as we have just used the program name as the description, these too could have been bare words.

Running Post Build Custom Commands

In the project file (CMakeLists.txt) we don’t assume the custom toolchain commands exist because we may be supplying a different toolchain on the command line. As with objcopy we verify we can find the required post build commands:

if (EXISTS "${TOOLCHAIN_SIZE}")
  add_custom_command(
    TARGET Application
    POST_BUILD
    COMMAND ${TOOLCHAIN_SIZE} --format=berkeley $<TARGET_FILE:Application>
            >${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_NAME:Application>.bsz
  )
  add_custom_command(
    TARGET Application
    POST_BUILD
    COMMAND ${TOOLCHAIN_SIZE} --format=sysv -x $<TARGET_FILE:Application>
            >${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_NAME:Application>.ssz
    )

else()

    message(STATUS "'size' not found: cannot generate .[bs]sz files")

endif()

There is nothing in this code that we haven’t seen before.

Summary

Real-world projects are always more complex than the simple examples used in most tutorials. In this post, we’ve looked at how CMake can be configured to generate two separate makefile build configurations using the same project and toolchain definition. This ability to add build configuration types to the GNU Make system is a good reason to use CMake in conjunction with the make command.

We recommend that you use the –warn-uninitialized when running CMake to generate the build files check the output from the build generation as this will help identify mistyped variable names.

A prototype project containing the code shown in this blog can be found in the GitHub project https://github.com/feabhas/cmake-blog-2.

In the next blog, we’ll look at multiple source and header files for a project and discuss how to organise a more extensive project into subsystems and libraries.

Postscript – A Simple Build Script

The GitHub project supporting for this blog contains a minimal shell script (build.sh) for building debug and release projects under Linux.

Linux Build Script (bash)

#!/bin/bash
set -o errexit
set -o nounset
USAGE="Usage: (basename $0) [-v | --verbose] [ reset | clean | debug | release ]"

CMAKE=cmake
BUILD=./build
TYPE=DEBUG
BUILD_DIR=$BUILD/debug
CLEAN=
RESET=
VERBOSE=

for arg; do
  case "$arg" in
    --help|-h)    echo $USAGE; exit 0;;
    -v|--verbose) VERBOSE='VERBOSE=1' ;;
    debug)        TYPE=DEBUG; BUILD_DIR=$BUILD/debug ;;
    release)      TYPE=RELEASE; BUILD_DIR=$BUILD/release ;;
    clean)        CLEAN=1 ;;
    reset)        RESET=1 ;;
    *)            echo -e "unknown option $arg\n$USAGE" >&2; exit 1 ;;
  esac
done

[[ -n $RESET && -d $BUILD_DIR ]] && rm -rf $BUILD_DIR

$CMAKE -S . -B $BUILD_DIR --warn-uninitialized -DCMAKE_BUILD_TYPE=$TYPE -DCMAKE_TOOLCHAIN_FILE=toolchain-STM32F407.cmake

[[ -n $CLEAN ]] && $CMAKE --build $BUILD_DIR --target clean

$CMAKE --build $BUILD_DIR -- $VERBOSE

Windows Build Script

Developers working on Windows who install CMake will find that the default build generation targets the Microsoft Build Tools for Visual Studio compilers. Configuring CMake on Windows to cross compile using the Arm Embedded Toolchain is not straightforward and will be the subject of a later blog post and will include a suitable example build script.



                         
Martin Bond
Latest posts by Martin Bond (see all)
Dislike (1)
+ posts

An independent IT trainer Martin has over 40 years academic and commercial experience in open systems software engineering. He has worked with a range of technologies from real time process controllers, through compilers, to large scale parallel processing systems; and across multiple sectors including industrial systems, semi-conductor manufacturing, telecomms, banking, MoD, and government.

About Martin Bond

An independent IT trainer Martin has over 40 years academic and commercial experience in open systems software engineering. He has worked with a range of technologies from real time process controllers, through compilers, to large scale parallel processing systems; and across multiple sectors including industrial systems, semi-conductor manufacturing, telecomms, banking, MoD, and government.
This entry was posted in ARM, Build-systems, C/C++ Programming, Cortex, General, Toolchain and tagged , . Bookmark the permalink.

5 Responses to CMake Part 2 – Release and Debug builds

  1. Wonderful blog post. Thanks for all your knowledge!

    Like (2)
    Dislike (0)
  2. Adam K says:

    Great introduction, you have presented the essence of the problem of building projects for embedded systems, which a lot of beginner embedded programmers are not aware of. They are used to tools provided by vendors where you build and upload something to the board in one click.

    However, I have some observations about CMake toolchain file that I would like to share with you.
    I would advise against putting the compiler and linker options in the toolchain file. The problem is that "add_compile_definitions", "add_compile_options" and "add_link_options" propagates these options to all targets.
    I would focus on a simple toolchain file that is designed to provide tools for compilation.
    That's all there is to it:
    set(CMAKE_SYSTEM_NAME Generic)
    set(CMAKE_SYSTEM_PROCESSOR arm)

    # Set the GNU ARM TOOLCHAIN installation bin directory
    set(TC_PATH "/opt/gcc-arm-none-eabi-10-2020-q4-major/bin")

    #Set up the CMake variable - prefix for all tools inside toolchain
    set(TC_PREFIX arm-none-eabi-)

    # Set up the compiler, assembler, linker
    set(CMAKE_C_COMPILER "${TC_PATH}/${TC_PREFIX}gcc.exe")
    set(CMAKE_CXX_COMPILER "${TC_PATH}/${TC_PREFIX}g++.exe")
    set(CMAKE_ASM_COMPILER "${TC_PATH}/${TC_PREFIX}ar.exe")
    set(CMAKE_LINKER "${TC_PATH}/${TC_PREFIX}ld.exe")

    # Perform compiler test with static library
    set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

    set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
    set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
    set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
    set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

    Configuration and options on the other hand are set per project, per target.
    You might have one CMake project that provides multiple targets at once: firmware for a target board, unit tests, perhaps images for other microprocessors, simulation environments. These targets are driven by the needs and problems you run into during software development.
    The same problems apply to setting options for DEBUG and RELEASE.
    With the complexity of a project where you are including code written by others, overriding global settings can produce unexpected results.

    I would like to see more CMake based embedded projects in the world.
    Your work is great, thank you.

    Like (2)
    Dislike (0)
  3. Martin Bond says:

    Thanks Adam K for the feedback, you've made a very good point.
    My view when we configured our training project was that the toolchain file should contain the common compiler/linker requirements specific to the embedded target device so that we could have a common CMakeLists.txt file for both embedded and hosted training courses.
    We currently only use an STM32F407xx board with a Cortex-M4 processor so placed all the target hardware options and definitions in the toolchain file. However we placed C/C++ versions, warning levels and code optimisation at the project or target level as this requirement will between exercises used on different training courses.
    I don't think there is a best approach that applies to all projects, but understanding the strengths and weaknesses of different approaches to defining the build files helps us make an informed choice. It's a shame that this sort of experiential advice is not provided on the official CMake web site.
    I'm definitely taking your view on board as we are discussing future training requirements which will probably involve different target hardware and updates to our build system.

    Like (1)
    Dislike (0)
  4. David Bakin says:

    I'm surprised that "TOOLCHAIN_as" has the "as" tool in lowercase, where for example "TOOLCHAIN_LD" doesn't. This inconsistency is part of CMake?

    Like (0)
    Dislike (0)
  5. Martin Bond says:

    I think this is an inconsistency in CMake. The CMakeCache.txt has TOOLCHAIN_LD and TOOLCHAIN_SIZE but TOOLCHAIN_as. I can't find any documentation that lists the toolchain variables. I will have seen one of these TOOLCHAIN variables used in a blog/article on CMake when I was starting to create our build setup, and checked what else was available by looking in the cache file.

    Like (0)
    Dislike (0)

Leave a Reply