Embedded CMake

“The year is 2021 B.C. C build systems are entirely occupied by CMake. Well not entirely! One small build system used by embedded developers still holds out against the invaders..”

Ok, this might not be entirely true anymore. Although embedded developers are categorically a decade late to every party we finally see some big players like Espressif or Zephyr move towards CMake as build system of choice. As of late I’ve even spotted a PR for adding CMake support to the FreeRTOS kernel which has been notoriously known for promoting a copy/paste workflow for including it into your own project.

Thankfully these times are over. Drop your makefiles, delete your custom python scripts and stop fiddling with your broken proprietary IDEs because we’re going to write some CMake.

To provide a working example I’ve used a NUCLEO-F746ZG demo board (yes, I have a demo board problem) to toggle the 3 LEDs on the board. We’re going to compile the example with two different toolchains, GCC and Clang. To follow the example along feel free to take a look at the corresponding git repository.

  1. Initial commit
  2. CMakeLists.txt
  3. GCC toolchain file
  4. Creating hex, bin and lst files
  5. Clang toolchain file

Initial commit

For the initial commit I’ve created a STM32CubeIDE project based on a default initialized NUCLEO-F746ZG board. The important part of the pinout are the 3 LEDs.

STM32CubeMX default initialized NUCLEO-F746ZG

In order to make the LEDs toggle I’ve added the following three invocations to HAL_GPIO_TogglePin to the infinite loop inside main. Make sure to place to code between the /* USER CODE */ comments, otherwise it might get deleted when re-running STM32CubeMX. Once that’s done we can move on to CMake.

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
	HAL_GPIO_TogglePin(LD1_GPIO_Port, LD1_Pin);
	HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
	HAL_GPIO_TogglePin(LD3_GPIO_Port, LD3_Pin);
	HAL_Delay(500);
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }

CMakeLists.txt

For some reason CMake project files are always named CMakeLists.txt so we create one in the projects root directory. In case you’ve never seen CMake code before please brace yourself for two things:

  1. CMake is ugly af
  2. CMake is total anarchy

As C/C++ developer #1 isn’t even worth a shrug but #2 poses a serious problem. Much like C++ there are almost two dialects of CMake, an old and a new modern one. But in contrast to the C++ community the modern style has not really fully caught on. The amount of old and now considered bad style CMake out there is staggering.

So what exactly is modern CMake?

Modern CMake relies on the notion of targets. A target might be an executable or a library of sorts. The crucial idea is that all the build settings you typically set for a C/C++ project (e.g. preprocessor definitions, include paths, sources, compiler flags, …) are set on a per-target basis.

Before we dive deeper let’s take a look at the tiny 16 line CMakeLists.txt I added with this commit.

cmake_minimum_required(VERSION 3.19)

project(
  EmbeddedCMake
  VERSION 0.1
  LANGUAGES ASM C)

file(GLOB_RECURSE SRC Core/*.c Core/*.s Drivers/*.c)
add_executable(EmbeddedCMake ${SRC})

target_compile_definitions(EmbeddedCMake PRIVATE $<$<CONFIG:DEBUG>:DEBUG>
                                                 USE_HAL_DRIVER STM32F746xx)

target_include_directories(
  EmbeddedCMake PRIVATE Core/Inc Drivers/CMSIS/Device/ST/STM32F7xx/Include
                        Drivers/CMSIS/Include Drivers/STM32F7xx_HAL_Driver/Inc)

The very first line of a projects top-level CMakeLists.txt should always set the minimum requires version. CMake needs us to be explicit about it in order to determine which features we’re allowed to use. There is no particular reason I’ve picked 3.19 but since it came out April 2021 I’d consider it a reasonable choice.

cmake_minimum_required(VERSION 3.19)

After we have picked a minimum CMake version we should use the project command to give our project a name, and optionally a version and a list of languages our project contains. Specifying a project version at this point has the benefits that it sets CMake’s internals PROJECT_VERSION variables. Those variables can then be used to create preprocessor definitions to be used inside the firmware or filenames for compiled binaries.

project(
  EmbeddedCMake
  VERSION 0.1
  LANGUAGES ASM C)

At this point we’re done setting up our project in our top-level CMakeLists.txt. Please note that it’s perfectly fine to have multiple CMake files for bigger projects but those don’t need to use the cmake_minimum_required or project command again. CMake files which don’t introduce new targets aren’t by convention required to be named CMakeLists.txt. In fact they can be named anything although they typically end in .cmake.

Now it’s finally time to specify a target for our project. Since we’d like to compile our firmware into some usable binary we use the add_executable command and pass it all the sources generated by STM32CubeMX. This can be done by using the file command together with GLOB_RECURSE. Don’t get into the habit of overusing GLOB_RECURSE. I only use it here because I neither know nor care which source files the STM32CubeMX code generator produces, I simply want them compiled. For source files which are solely created by yourself I’d recommend to add each individual file by hand. This might initially be a little more work but it’s also less error prone.

file(GLOB_RECURSE SRC Core/*.c Core/*.s Drivers/*.c)
add_executable(EmbeddedCMake ${SRC})

STM32CubeMX projects typically require a couple of preprocessor definitions. We use the target_compile_definitions command to add those to our target. This is also the first time we use one of the commands specifically tailored to work on targets (hence the target_* prefix). The weird expression in angle brackets is called a generator expressions. Those expressions are something like inline-ifs (or the ternary operator in C) and only add the right-hand-side argument to the command if evaluated to true. In this case the preprocessor definition DEBUG only gets added if the project is compiled in a Debug configuration. More on that later.

target_compile_definitions(EmbeddedCMake PRIVATE $<$<CONFIG:DEBUG>:DEBUG>
                                                 USE_HAL_DRIVER STM32F746xx)

Just like the source files we also need to add the header include paths to our target. This can be done by using the target_include_directories command.

target_include_directories(
  EmbeddedCMake PRIVATE Core/Inc Drivers/CMSIS/Device/ST/STM32F7xx/Include
                        Drivers/CMSIS/Include Drivers/STM32F7xx_HAL_Driver/Inc)

The attentive reader will have noticed that the target_* commands not only take a target and some command related arguments but also one of the scope specifiers PUBLIC, PRIVATE or INTERFACE. Those scope specifier are used to set the visibility of the command arguments as follows:

  • PUBLIC
    The arguments are visible to the current target and to external dependents.
  • PRIVATE
    The arguments are only visible to the current target.
  • INTERFACE
    The arguments are only visible to external dependents. This might sound weird at first glance but is actually used when creating header-only libraries without any source files. These kind of libraries are therefor also called INTERFACE libraries.

Since this project has no external dependencies and is completely built from source all our scope specifiers are PRIVATE. We do not need PUBLIC headers since this isn’t a library and no one is going to link against our target.

Please note that choosing the right specifier might require a bit of experience and isn’t always as straight forward. One could easily miss that some preprocessor definition is not only used inside a library’s source files but also in it’s headers. If those headers are only included as PRIVATE potential users of this library will face a compiler error in the best case and unwanted runtime behavior in the worst.

GCC toolchain file

Would this be a x86 project (I assume x86 is the architecture you’re currently sitting in front of) then we’d be done at this point. Actually you can go ahead and try it if you want to, no harm done. A typical CMake invocation looks something like this cmake -Bbuild. This creates an “out of source” build in a subdirectory of our projects root called build.

CMake even happily obliges and creates the build files for us. But that’s it as far as cooperation goes. The problem is that we’d like CMake to cross-compile to our specific target but right now it defaults to a compiler suited for your host platform. I believe that’s GCC on Linux and MSVC on Windows but don’t quote me on that.

To swap our host toolchain to one which can actually cross-compile to our target platform (an ARM Cortex-M7) we’re going to write a toolchain file. A toolchain file is essentially a bunch of CMake variable assignments which globally set things like the used C compiler and its initial compiler flags. To keep things simpler at first we’re going to start of with the GCC toolchain file.

When I say GCC I actually mean the version which targets 32-bit ARM Cortex-A, Cortex-M and Cortex-R processors. GCC follows a cross-compiler naming convention somewhere along the lines of arch-vendor-(os-)abi. So the GCC version we’re looking for is called arm-none-eabi-gcc.

For this commit I’ve created a toolchain file called arm_none_eabi_gcc.cmake inside a cmake subfolder. With 31 lines its even a bit longer than our actual CMakeLists.txt.

# Do not try to compile a full blown executable as this would depend on standard
# C and syscalls
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_SYSTEM_NAME Generic)

# Find arm-none-eabi
find_program(C_COMPILER arm-none-eabi-gcc)
find_program(CXX_COMPILER arm-none-eabi-g++)
find_program(AR arm-none-eabi-ar)
find_program(OBJCOPY arm-none-eabi-objcopy)
find_program(OBJDUMP arm-none-eabi-objdump)
find_program(SIZE arm-none-eabi-size)

set(CMAKE_ASM_COMPILER ${C_COMPILER})
set(CMAKE_C_COMPILER ${C_COMPILER})
set(CMAKE_CXX_COMPILER ${CXX_COMPILER})
set(CMAKE_AR ${AR})
set(CMAKE_OBJCOPY ${OBJCOPY})
set(CMAKE_OBJDUMP ${OBJDUMP})
set(CMAKE_SIZE ${SIZE})

# Architecture flags
include(${CMAKE_CURRENT_LIST_DIR}/arm_arch.cmake)

set(CMAKE_ASM_FLAGS "${ARCH}")
set(CMAKE_C_FLAGS "${ARCH}")
set(CMAKE_CXX_FLAGS "${ARCH}")
set(CMAKE_C_FLAGS_DEBUG "-Og -g")
set(CMAKE_CXX_FLAGS_DEBUG "-Og -g")
set(CMAKE_C_FLAGS_RELEASE "-DNDEBUG -Os -g")
set(CMAKE_CXX_FLAGS_RELEASE "-DNDEBUG -Os -g")

The first lines take care of two things. First we need to tell CMake to treat this toolchain as a cross-compiler one and therefor disable all compiler checks involving system calls. Setting the variable CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY makes sure that CMake only verifies things that do not rely on any runtime. I’d also recommend to set the variable CMAKE_SYSTEM_NAME to indicate that we no longer build for the host system but that’s not strictly necessary.

# Do not try to compile a full blown executable as this would depend on standard
# C and syscalls
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_SYSTEM_NAME Generic)

Afterwards we tell CMake to look for compilers and tools of the GCC toolchain by using the find_program command. Please check the CMake documentation to determine which paths are searched. There are optional arguments to give CMake some hints in case the defaults didn’t work for you. Once CMake found the tools we’re looking for we set a bunch of global variables to the path of those programs.

# Find arm-none-eabi
find_program(C_COMPILER arm-none-eabi-gcc)
find_program(CXX_COMPILER arm-none-eabi-g++)
find_program(AR arm-none-eabi-ar)
find_program(OBJCOPY arm-none-eabi-objcopy)
find_program(OBJDUMP arm-none-eabi-objdump)
find_program(SIZE arm-none-eabi-size)

set(CMAKE_ASM_COMPILER ${C_COMPILER})
set(CMAKE_C_COMPILER ${C_COMPILER})
set(CMAKE_CXX_COMPILER ${CXX_COMPILER})
set(CMAKE_AR ${AR})
set(CMAKE_OBJCOPY ${OBJCOPY})
set(CMAKE_OBJDUMP ${OBJDUMP})
set(CMAKE_SIZE ${SIZE})

CMake supports a couple of default build types (Debug, Release, RelWithDebInfo and MinSizeRel). I’ve found that neither really suits my personal needs which is why I set my preferred settings for Debug and Release builds here. The variable ARCH sets the architecture flags and is defined inside another file in the cmake subfolder. I’ve pulled it out of the toolchain file because we’re going to need it again when compiling with Clang (DRY).

# Architecture flags
include(${CMAKE_CURRENT_LIST_DIR}/arch.cmake)

set(CMAKE_ASM_FLAGS "${ARCH}")
set(CMAKE_C_FLAGS "${ARCH}")
set(CMAKE_CXX_FLAGS "${ARCH}")
set(CMAKE_C_FLAGS_DEBUG "-Og -g")
set(CMAKE_CXX_FLAGS_DEBUG "-Og -g")
set(CMAKE_C_FLAGS_RELEASE "-DNDEBUG -Os -g")
set(CMAKE_CXX_FLAGS_RELEASE "-DNDEBUG -Os -g")

Phew! That’s it. The toolchain file is done and ready to be used. There is just one last change in our CMakeLists.txt before we can go ahead and use it. Embedded systems typically require custom link options. At the very least this includes a path to the used linker script. To add those options we’re going to use the target_link_libraries command. We’re also going to pass a so called spec file which is a GCC specific solution to hide a lot of nasty linker stuff from us. Using such a spec file we’re free of the burden of worrying about things like linking order of libgcc, libm and the like. The nano.specs is basically a “please make this build as small as possible” configuration.

target_link_libraries(
  EmbeddedCMake
  PRIVATE --specs=nano.specs -Wl,--gc-sections,-Map=${PROJECT_NAME}.map
          -T${CMAKE_CURRENT_SOURCE_DIR}/STM32F746ZGTX_FLASH.ld)

Now we can go ahead and pass our toolchain file to CMake on the command line

cmake -Bbuild -DCMAKE_TOOLCHAIN_FILE=./cmake/arm_none_eabi_gcc.cmake -DCMAKE_BUILD_TYPE=Debug

and then build our target with make -C build EmbeddedCMake.

If everything worked correctly you’ll see some stdout similar to this

...
[ 88%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_tim_ex.c.obj
[ 92%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_uart.c.obj
[ 96%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_uart_ex.c.obj
[100%] Linking C executable EmbeddedCMake
[100%] Built target EmbeddedCMake

Creating hex, bin and lst files

Having a working executable is cool and all but embedded developers usually require .bin or .hex files to efficiently get the firmware onto their chips. Furthermore we embedded folks like to keep an eye on our FLASH and RAM usage by invoking the size tool and sometimes we even open assembly listings. Wouldn’t it be nice if CMake could take care of all that as well?

Turns out it can. There is an add_custom_command …erm… command which allows us to use tools like objcopy or objdump post build. I’ve added a custom command at the end of our CMakeLists.txt. It uses the CMake variables we defined in our toolchain file to invoke the right tool for the job. Those commands are invoked POST_BUILD which means after we created our executable. The weird copy I create as very first command is just to add an .elf file extension to the otherwise extension-less executable. In my experience some debugging tools aren’t very happy with extension-less .elf files.

add_custom_command(
  TARGET EmbeddedCMake
  POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E copy ${PROJECT_NAME} ${PROJECT_NAME}.elf
  COMMAND ${CMAKE_OBJCOPY} -O ihex ${PROJECT_NAME}
          ${PROJECT_NAME}_${PROJECT_VERSION}.hex
  COMMAND ${CMAKE_OBJCOPY} -O binary ${PROJECT_NAME}
          ${PROJECT_NAME}_${PROJECT_VERSION}.bin
  COMMAND ${CMAKE_OBJDUMP} --source --all-headers --demangle --line-numbers
          --wide ${PROJECT_NAME} > ${PROJECT_NAME}.lst
  COMMAND ${CMAKE_SIZE} --format=berkeley ${PROJECT_NAME} DEPENDS
          ${PROJECT_NAME}
  COMMENT "Generate .hex, .bin and .lst from .elf file")

If we repeat the build steps of running cmake and make like before we will now get informed that the .hex, .bin and .lst files got generated for us and we even get presented a nice size output on stdout

...
[ 84%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_tim.c.obj
[ 88%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_tim_ex.c.obj
[ 92%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_uart.c.obj
[ 96%] Building C object CMakeFiles/EmbeddedCMake.dir/Drivers/STM32F7xx_HAL_Driver/Src/stm32f7xx_hal_uart_ex.c.obj
[100%] Linking C executable EmbeddedCMake
Generate .hex, .bin and .lst from .elf file
   text    data     bss     dec     hex filename
  19312      20    1700   21032    5228 EmbeddedCMake

Clang toolchain file

Ok, I’ve got to confess that I’ve saved the ugliest bit for last. In contrast to GCC Clang is natively a cross-compiler. This means that the Clang installation used for your host is also perfectly suited to compile for 32bit ARM Cortex-M. Right about now would probably be a good time to read the official docs on cross-compiling. It’s only a couple of paragraphs long and reads as if this would be a breeze. And in all fairness it really is. Compiling source to object files is straight forward. It’s the linking part that really isn’t.

The problem is that Clang doesn’t come with precompiled libraries like GCC does. So for example if you’d like to use Clang’s compiler-rt on a 32bit ARM Cortex-M you’d have to compile it yourself. This is no easy task and there certainly is a path of less resistance which is to reuse the libraries from GCC. Sadly this has it’s own shortcomings. Clang as a linker driver has no in-depth knowledge about GCC’s internals. So once you resort to handling linking yourself you need to know quite a lot about low level stuff crt0, crtbegin and crtend, linking order of object files and libraries and so on.

Thankfully there’s even a third option. Although not very well supported by CMake we can use Clang to compile our source files and then swap to GCC as linker driver to link the produced object files. Before we take a look at how that’s done let’s recap all options

  1. Clang only
    Hard, requires to build libraries (e.g. compiler-rt) from source
  2. Compile with Clang, link GCC libraries with Clang
    Easier, still requires detailed knowledge about linking process (object files, libs and order)
  3. Compile with Clang, link GCC libraries with GCC
    Easiest, linking mostly stays black box, enters some dark corners of CMake though

Before we go back to CMake we need to resolve a little copy/paste bug inside the STM32CubeF7 library. Apparently all the assembly startup files contain a duplicated definition of DMA2_Stream4_IRQHandler. GCC doesn’t complain about that but Clang will and throws the following error

error: invalid reassignment of non-absolute variable 'DMA2_Stream4_IRQHandler'

Alright, back to CMake. For this commit I’ve created a toolchain file called arm_clang.cmake inside the cmake subfolder. At 75 lines, it is more than twice as long as its GCC counterpart so we’re only going to break it down into snippets.

The first couple of lines will look familiar. We set CMAKE_TRY_COMPILE_TARGET_TYPE and CMAKE_SYSTEM_NAME before looking up a couple of programs. Since we’re going to use GCC for linking (and a couple of other things) we’ll need arm-none-eabi-gcc and arm-none-eabi-g++ as well.

# Do not try to compile a full blown executable as this would depend on standard
# C and syscalls
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_SYSTEM_NAME Generic)

# Find arm-none-eabi
find_program(ARM_NONE_EABI_C_COMPILER arm-none-eabi-gcc)
find_program(ARM_NONE_EABI_CXX_COMPILER arm-none-eabi-g++)

# Find clang
find_program(C_COMPILER clang)
find_program(CXX_COMPILER clang++)
find_program(AR llvm-ar)
find_program(OBJCOPY llvm-objcopy)
find_program(OBJDUMP llvm-objdump)
find_program(SIZE llvm-size)

set(CMAKE_ASM_COMPILER ${C_COMPILER})
set(CMAKE_C_COMPILER ${C_COMPILER})
set(CMAKE_CXX_COMPILER ${CXX_COMPILER})
set(CMAKE_AR ${AR})
set(CMAKE_OBJCOPY ${OBJCOPY})
set(CMAKE_OBJDUMP ${OBJDUMP})
set(CMAKE_SIZE ${SIZE})

CMake has a set of CMAKE_<LANG>_COMPILER_TARGET variables specially for setting the target on cross-compilers. We’ll set those to match our GCC toolchain. The rest of this snippet is identical to before so we can move on.

# Clang target triple
set(COMPILER_TARGET arm-none-eabi)
set(CMAKE_ASM_COMPILER_TARGET ${COMPILER_TARGET})
set(CMAKE_C_COMPILER_TARGET ${COMPILER_TARGET})
set(CMAKE_CXX_COMPILER_TARGET ${COMPILER_TARGET})

# Architecture flags
include(${CMAKE_CURRENT_LIST_DIR}/arm_arch.cmake)

set(CMAKE_ASM_FLAGS "${ARCH}")
set(CMAKE_C_FLAGS "${ARCH}")
set(CMAKE_CXX_FLAGS "${ARCH}")
set(CMAKE_C_FLAGS_DEBUG "-Os -g")
set(CMAKE_CXX_FLAGS_DEBUG "-Os -g")
set(CMAKE_C_FLAGS_RELEASE "-DNDEBUG -Os -g")
set(CMAKE_CXX_FLAGS_RELEASE "-DNDEBUG -Os -g")

Now’ll use the GCC toolchain for the first time. We’ll ask GCC for its sysroot directory that is used during compilation by passing the -print-sysroot option. CMake has a CMAKE_SYSROOT variable which we can set to the output GCC gives us. I also had to strip trailing whitespaces for this to work, a task CMake happily takes care of.

# Set CMAKE_SYSROOT from arm-none-eabi-gcc -print-sysroot output
separate_arguments(ARCH_LIST NATIVE_COMMAND ${ARCH})
execute_process(
  COMMAND ${ARM_NONE_EABI_C_COMPILER} ${ARCH_LIST} -print-sysroot
  OUTPUT_VARIABLE CMAKE_SYSROOT
  OUTPUT_STRIP_TRAILING_WHITESPACE)

Now things get a little more tricky. From my experience the –sysroot option alone does not take care of adding all the necessary include paths from libc and libstdc++. There is a way to ask GCC for its default include paths but the output is ugly and cluttered. Here is an example of the output on my machine

ignoring nonexistent directory "/usr/arm-none-eabi/usr/local/include"
ignoring duplicate directory "/usr/arm-none-eabi/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/arm-none-eabi/11.2.0/../../../../arm-none-eabi/include/c++/11.2.0
/usr/lib/gcc/arm-none-eabi/11.2.0/../../../../arm-none-eabi/include/c++/11.2.0/arm-none-eabi/thumb/v7e-m+fp/hard
/usr/lib/gcc/arm-none-eabi/11.2.0/../../../../arm-none-eabi/include/c++/11.2.0/backward
/usr/lib/gcc/arm-none-eabi/11.2.0/include
/usr/lib/gcc/arm-none-eabi/11.2.0/include-fixed
/usr/lib/gcc/arm-none-eabi/11.2.0/../../../../arm-none-eabi/include
End of search list.

To extract the includes anyhow we will use the CMake string command and some regex magic which looks for a version number in the path. Since the execute_process command which invokes GCC will be blocking we tell CMake to error out after a TIMEOUT of 1 second. This is also the reason why we have to use ERROR_VARIABLE as output instead of RESULT_VARIABLE. Admittedly not very straight forward.

Once we got the output we’re looking for we foreach over it and only include the current directory if it contains a version number.

# Get list of include paths (https://stackoverflow.com/a/59068162/5840652)
execute_process(
  COMMAND ${ARM_NONE_EABI_C_COMPILER} ${ARCH_LIST} -Wp,-v -x c++ - -fsyntax-only
  TIMEOUT 1
  ERROR_VARIABLE ARM_NONE_EABI_INC_DIRS
  OUTPUT_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE)

# Add include paths
include_directories(${CMAKE_SYSROOT}/include)
separate_arguments(ARM_NONE_EABI_INC_DIRS_LIST NATIVE_COMMAND
                   ${ARM_NONE_EABI_INC_DIRS})
foreach(DIR ${ARM_NONE_EABI_INC_DIRS_LIST})
  string(REGEX MATCH "([0-9]+)\\.([0-9]+)\\.([0-9]+)" CONTAINS_VERSION ${DIR})
  if(NOT ${CONTAINS_VERSION} STREQUAL "")
    include_directories(${DIR})
  endif()
endforeach()

The final step relies on some obscure and badly documented corners of CMake. Apparently one can write the link command completely by hand by setting the corresponding CMAKE_<LANG>_LINK_EXECUTABLE variables. There is practically no existing official documentation available. Upon googling CMAKE_C_LINK_EXECUTABLE and CMAKE_CXX_LINK_EXECUTABLE I found the two files CMakeCInformation.cmake and CMakeCXXInformation.cmake in the CMake source code.

Those contain the following definitions

if(NOT CMAKE_C_LINK_EXECUTABLE)
  set(CMAKE_C_LINK_EXECUTABLE
    "<CMAKE_C_COMPILER> <FLAGS> <CMAKE_C_LINK_FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET> <LINK_LIBRARIES>")
endif()

if(NOT CMAKE_CXX_LINK_EXECUTABLE)
  set(CMAKE_CXX_LINK_EXECUTABLE
    "<CMAKE_CXX_COMPILER> <FLAGS> <CMAKE_CXX_LINK_FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET> <LINK_LIBRARIES>")
endif()

Looks like a small DSL used to describe the linking process? Turns out we can easily swap the original CMAKE_C_COMPILER and CMAKE_CXX_COMPILER (which still point to Clang) with GCC.

# Replace Clang with GCC as linker driver
set(CMAKE_C_LINK_EXECUTABLE
    "${ARM_NONE_EABI_C_COMPILER} <FLAGS> <CMAKE_C_LINK_FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET> <LINK_LIBRARIES>"
)
set(CMAKE_CXX_LINK_EXECUTABLE
    "${ARM_NONE_EABI_CXX_COMPILER} <FLAGS> <CMAKE_CXX_LINK_FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET> <LINK_LIBRARIES>"
)

And voilà, we’re done! You can now try the new toolchain file by invoking cmake and make again

cmake -Bbuild -DCMAKE_TOOLCHAIN_FILE=./cmake/arm_clang.cmake -DCMAKE_BUILD_TYPE=Debug
make -C build