Skip to main content

CMake for Embedded

CMake's defaults encode one assumption so deeply that it is easy to miss: the machine running the build can also run what the build produces. That is what lets CMake test a compiler by compiling and linking a tiny program, what lets find_package look in /usr/lib, and what lets check_c_source_runs exist at all. Every one of those assumptions is false for a Cortex-M4 with 128 KB of RAM and no operating system.

The mental model: a toolchain file is a set of corrections applied before CMake forms any of its opinions. It is read first, before the compiler is probed, before project() runs, before anything is cached. Get it right and the rest of your CMakeLists.txt is ordinary CMake. Get it wrong and CMake fails during configure, before it has compiled a line of your code — which is exactly why the failure is so disorienting the first time.

This page is the embedded delta only. What is CMake?, CMakeLists Structure and Target Properties own CMake itself — targets, visibility, variables, generator expressions — and none of that changes because the target is a microcontroller.

Prerequisites

Cross-Compilation establishes the arm-none-eabi triple and the four target flags this page wires up. The Linker Script is the stm32f411re.ld referenced below, and C Libraries for Embedded explains --specs=nano.specs and --specs=nosys.specs.

The failure everyone hits first

Write the toolchain file the obvious way — name the system, name the compiler — and configure the project:

arm-none-eabi.cmake (incomplete — this version fails)
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_CXX_COMPILER arm-none-eabi-g++)
$ cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=arm-none-eabi.cmake
-- The C compiler identification is GNU 14.2.1
-- Detecting C compiler ABI info - failed
-- Check for working C compiler: .../arm-none-eabi-gcc - broken
CMake Error at /usr/share/cmake-4.2/Modules/CMakeTestCCompiler.cmake:67 (message):
The C compiler

".../bin/arm-none-eabi-gcc"

is not able to compile a simple test program.

It fails with the following output:
...
ld: libc.a(libc_a-exit.o): in function `exit':
exit.c:(.text.exit+0x28): undefined reference to `_exit'
ld: libc.a(libc_a-closer.o): in function `_close_r':
closer.c:(.text._close_r+0x18): undefined reference to `_close'
ld: libc.a(libc_a-lseekr.o): in function `_lseek_r':
lseekr.c:(.text._lseek_r+0x24): undefined reference to `_lseek'
ld: libc.a(libc_a-readr.o): undefined reference to `_read'
ld: libc.a(libc_a-writer.o): undefined reference to `_write'
ld: libc.a(libc_a-sbrkr.o): undefined reference to `_sbrk'
collect2: error: ld returned 1 exit status

Read the message carefully and it is telling the truth about the wrong thing. The compiler is not broken — it compiled testCCompiler.c fine. What failed is the link, because CMake's compiler check builds an executable, and linking an executable against newlib drags in exit, _sbrk and the stdio syscall stubs, none of which exist on a bare-metal target. There is no _start, no kernel, and no linker script telling ld where anything goes.

The instinct at this point is to start supplying the missing pieces to the test program — a linker script, --specs=nosys.specs, stub definitions. That works, and it is the wrong shape of fix: you end up maintaining a second, parallel link configuration whose only purpose is to satisfy a check.

The fix: CMAKE_TRY_COMPILE_TARGET_TYPE

set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

One line. It tells CMake that every internal try_compile — the compiler check, the ABI detection, every check_c_source_compiles and check_symbol_exists a dependency might run — should build a static library instead of an executable. A static library is compiled and archived; it is never linked. No _start, no _exit, no syscall stubs, nothing to resolve.

The tell that it worked is a single word in the configure output:

-- Detecting C compiler ABI info - done
-- Check for working C compiler: .../arm-none-eabi-gcc - skipped
-- Configuring done (3.1s)

skipped, not broken. CMake now knows better than to ask a question it cannot answer.

Two consequences worth understanding rather than just accepting:

  • Anything that needs to run a test program is now impossible, and always was. check_c_source_runs() and try_run() cannot work when the target is a different computer that is not attached. If a dependency's CMake code calls them, it was never going to cross-compile without patching — this setting makes that fact surface early instead of after a confusing link error.
  • check_c_source_compiles and check_symbol_exists still run, because compiling is all they need to produce an answer — but read that answer carefully. Under STATIC_LIBRARY these probes never link, so they can only tell you that a declaration was visible, not that an implementation exists. A check_function_exists-style probe is the sharp edge: a call to an undeclared-but-referenced function compiles happily into an object file and the check reports success, even when nothing in the link would ever provide the symbol. You then discover the truth as an undefined reference when your real executable links. Treat these as header probes, and confirm anything you actually depend on by linking the real target.

There is no other value to reach for. CMAKE_TRY_COMPILE_TARGET_TYPE accepts exactly two: EXECUTABLE, the default that fails here, and STATIC_LIBRARY. Anything else — OBJECT_LIBRARY is the plausible-looking guess — is not recognised, so CMake falls back to building an executable and you get the identical "is not able to compile a simple test program" failure, now with a setting in your toolchain file that looks like it should have prevented it.

A complete toolchain file

cmake/arm-none-eabi.cmake
# Toolchain file for bare-metal Arm Cortex-M with the Arm GNU Toolchain.
# Use: cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake

# "Generic" means: no operating system. This is what makes CMake stop
# assuming a host-like target, and it is what defines CMAKE_CROSSCOMPILING.
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)

# THE line. Without it the configure step fails; see above.
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)

# Let the toolchain be overridden for a pinned or containerised install.
set(TOOLCHAIN_PREFIX arm-none-eabi- CACHE STRING "cross toolchain prefix")

set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++)
set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy CACHE FILEPATH "")
set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size CACHE FILEPATH "")

# The four flags from Cross-Compilation, set once so compile and link agree.
# _INIT variables seed the per-language flags before any cache entry exists.
set(MCU_FLAGS "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
set(CMAKE_C_FLAGS_INIT "${MCU_FLAGS}")
set(CMAKE_CXX_FLAGS_INIT "${MCU_FLAGS}")
set(CMAKE_ASM_FLAGS_INIT "${MCU_FLAGS}")

# Never search the host filesystem for target libraries or headers.
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM BOTH) # host tools: openocd, python
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

# Firmware convention: the linked output is an ELF.
set(CMAKE_EXECUTABLE_SUFFIX ".elf")
set(CMAKE_EXECUTABLE_SUFFIX_C ".elf")
set(CMAKE_EXECUTABLE_SUFFIX_CXX ".elf")

The three parts that do real work, beyond CMAKE_TRY_COMPILE_TARGET_TYPE:

CMAKE_SYSTEM_NAME Generic is CMake's name for "a target with no operating system". Setting any CMAKE_SYSTEM_NAME in a toolchain file is what sets CMAKE_CROSSCOMPILING to true, and Generic additionally suppresses the platform modules that would otherwise assume a Linux- or Darwin-shaped world.

The _INIT flag variables rather than plain CMAKE_C_FLAGS. CMAKE_<LANG>_FLAGS_INIT seeds the cache entry the first time CMake configures and then leaves the user's cache value alone on subsequent runs. Assigning CMAKE_C_FLAGS directly in a toolchain file re-applies on every configure and duplicates the flags. This matters because these four flags are exactly the ones that must be identical everywhere — Cross-Compilation explains what happens when they drift.

CMAKE_FIND_ROOT_PATH_MODE_* stops find_library and find_path from finding your host's libz and offering it to a Cortex-M4. PROGRAM BOTH is deliberate and is the one exception: find_program(OPENOCD openocd) should find the host's OpenOCD, because that runs on your laptop.

The CMakeLists.txt

CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(blink LANGUAGES C ASM)

set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/stm32f411re.ld)

add_executable(blink
src/main.c
src/startup.c
)

target_compile_options(blink PRIVATE
-Wall -Wextra
-ffunction-sections -fdata-sections
$<$<CONFIG:Debug>:-Og -g3>
$<$<CONFIG:Release>:-Os -g>
)

target_link_options(blink PRIVATE
-T${LINKER_SCRIPT}
--specs=nano.specs # newlib-nano
--specs=nosys.specs # stub syscalls; supply your own to replace
-Wl,--gc-sections
-Wl,-Map=$<TARGET_FILE_DIR:blink>/blink.map,--cref
-Wl,--print-memory-usage
)

# Make an edit to the linker script actually trigger a relink. See the warning.
set_target_properties(blink PROPERTIES LINK_DEPENDS ${LINKER_SCRIPT})

# Report flash and RAM, and produce the .bin / .hex a programmer wants.
add_custom_command(TARGET blink POST_BUILD
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:blink>
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:blink> $<TARGET_FILE_DIR:blink>/blink.bin
COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:blink> $<TARGET_FILE_DIR:blink>/blink.hex
COMMENT "Size, .bin and .hex"
)

Nothing here is embedded-specific CMake — it is ordinary targets, generator expressions and custom commands. What is embedded-specific is which options you pass, and every one of them is explained on another page in this folder: the linker script, --gc-sections and the map file in Reading the Map File, the specs files in C Libraries for Embedded, and -Os versus -Og in Optimization for Size and Speed.

--print-memory-usage on the link line is the highest-value line in the file. Every build prints:

Memory region Used Size Region Size %age Used
FLASH: 4500 B 512 KB 0.86%
RAM: 1992 B 128 KB 1.52%

A flash target

find_program(OPENOCD_EXECUTABLE openocd)

if(OPENOCD_EXECUTABLE)
add_custom_target(flash
COMMAND ${OPENOCD_EXECUTABLE}
-f interface/stlink.cfg -f target/stm32f4x.cfg
-c "program $<TARGET_FILE:blink> verify reset exit"
DEPENDS blink
USES_TERMINAL
COMMENT "Flashing over ST-LINK"
)
endif()

cmake --build build --target flash. Three details make this behave: DEPENDS blink means flashing rebuilds first, so you cannot flash a stale image; USES_TERMINAL gives OpenOCD the console directly so its progress and errors are not swallowed by the generator; and guarding on find_program means a machine without OpenOCD still configures, it just has no flash target. Flashing and Programming covers what that OpenOCD command line is doing.

Presets, so nobody types the toolchain flag

CMakePresets.json
{
"version": 3,
"configurePresets": [
{
"name": "stm32f411re",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "${sourceDir}/cmake/arm-none-eabi.cmake",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
}
]
}

cmake --preset stm32f411re then cmake --build build/stm32f411re. This is worth adding on day one: the toolchain file is only correct if it is used, and a forgotten -DCMAKE_TOOLCHAIN_FILE produces a host build that compiles happily and is completely useless. CMake Presets covers the format.

What each piece is for

SettingWhere it goesWhat breaks without it
CMAKE_SYSTEM_NAME GenericToolchain fileCMAKE_CROSSCOMPILING stays false; host platform modules apply.
CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARYToolchain fileConfigure fails: "compiler is not able to compile a simple test program".
CMAKE_<LANG>_FLAGS_INITToolchain fileWrong multilib, or flags duplicated on every re-configure.
CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLYToolchain filefind_library offers you host x86 libraries.
-T<script> in target_link_optionsCMakeLists.txtDefault layout; the image does not match the chip's memory map.
LINK_DEPENDSCMakeLists.txtLinker-script edits silently do not relink. See the warning.
--gc-sections + -ffunction-sectionsBothTens of KB of unreferenced library code stays in the image.
objcopy post-buildCMakeLists.txtNo .bin/.hex for tools that will not take an ELF.
CMakePresets.jsonRepo rootSomeone configures without the toolchain file and builds for the host.
You edit the linker script, rebuild, and CMake links the old one anyway

This one is quiet, and it wastes the afternoon after the afternoon you spent getting the linker script right.

-T${LINKER_SCRIPT} inside target_link_options() is, to CMake, an opaque string on the link command line. CMake does not parse link flags looking for filenames, so it never learns that stm32f411re.ld is an input. The generated build system has no dependency edge from the ELF to the script.

Measured on the project above, without LINK_DEPENDS:

$ touch stm32f411re.ld
$ cmake --build build
[100%] Built target blink.elf # no link step ran

The linker never re-ran. You changed LENGTH on a RAM region, or moved a section, or fixed the very bug you were chasing — and then flashed the previous binary and concluded the change had no effect. The reasoning that follows from that false observation can burn hours, because every subsequent experiment is also invalidated by the same stale link.

The fix is one line, and the same touch afterwards proves it:

set_target_properties(blink PROPERTIES LINK_DEPENDS ${LINKER_SCRIPT})
$ touch stm32f411re.ld
$ cmake --build build
[ 50%] Linking C executable blink.elf
[100%] Built target blink.elf # it relinked

Two relatives of the same problem, both with the same shape — a real input the build system cannot see:

  • LINK_DEPENDS is per-target. A project with a bootloader and an application, each with its own script, needs it on both.
  • Generated headers and .ld fragments. If the linker script is produced by configure_file() or an add_custom_command, depend on the generated path, not the template.

The habit that catches all of them: when a change to a file appears to have no effect, check whether the build system knows the file exists before you doubt the change.

See also

References