
CMake has become the de facto standard build system for embedded C projects, displacing hand-written Makefiles and vendor-specific IDE project files. Its strength lies not in building code — any build system can invoke a compiler — but in modeling the build configuration as a directed acyclic graph of targets, each carrying compile options, include directories, link libraries, and transitive usage requirements. For embedded development, this target-oriented model maps directly to the problems we face daily: cross-compilation toolchains, vendor SDK integration, firmware image generation, and reproducible CI builds.
This article walks through a production-grade CMake setup for a Cortex-M4 project using the ARM GCC toolchain, FreeRTOS, and CMSIS. It covers toolchain files, FetchContent-based dependency management, generator expressions for build-type-specific flags, linker script integration, and post-build steps for binary/hex generation.
The toolchain file is the single source of truth for the target environment. It tells CMake: this is not the host system; do not search host paths for libraries or headers.
# cmake/toolchains/arm-none-eabi-gcc.cmakecmake_minimum_required(VERSION 3.20)set(CMAKE_SYSTEM_NAME Generic)set(CMAKE_SYSTEM_PROCESSOR arm)# Bypass compile/link test failures on bare-metal targetsset(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)# Cross-compiler tripletset(CMAKE_C_COMPILER arm-none-eabi-gcc)set(CMAKE_CXX_COMPILER arm-none-eabi-g++)set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)set(CMAKE_OBJCOPY arm-none-eabi-objcopy)set(CMAKE_OBJDUMP arm-none-eabi-objdump)set(CMAKE_SIZE arm-none-eabi-size)# Isolate find_* commands to the sysrootset(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)# Note: Architecture-specific flags (e.g., -mcpu=cortex-m4) and linker specs# should NOT go here. Keep the toolchain file generic and apply CPU flags# at the target level in your CMakeLists.txt via target_compile_options.# Optional: point to a sysroot if you have one# set(CMAKE_FIND_ROOT_PATH /opt/arm/gcc-arm-none-eabi/arm-none-eabi)
Invoke it at configure time:
cmake -B build -S . \-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/arm-none-eabi-gcc.cmake \-DCMAKE_BUILD_TYPE=Release
Key point: CMAKE_SYSTEM_NAME=Generic disables CMake’s platform detection logic. Without it, CMake tries to run uname or inspect /usr/include, which fails or pollutes the build with host headers. Setting CMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY is critical; it forces CMake’s compiler checks to compile test files into static libraries rather than fully linked executables. This prevents false compiler check failures caused by missing bare-metal runtime syscalls (such as _exit or _sbrk). The FIND_ROOT_PATH_MODE_* variables force find_package, find_library, and find_path to search only under CMAKE_FIND_ROOT_PATH (your SDK/sysroot), never the host filesystem.
firmware/├── CMakeLists.txt├── cmake/│ ├── toolchains/│ │ └── arm-none-eabi-gcc.cmake│ └── modules/│ └── GenerateFirmwareImages.cmake├── src/│ ├── main.c│ ├── startup_stm32f407xx.S│ └── system_stm32f4xx.c├── include/│ └── board.h├── linker/│ └── stm32f407vg_flash.ld├── deps/ # FetchContent populates here└── build/ # Out-of-tree build directory
# CMakeLists.txtcmake_minimum_required(VERSION 3.20)project(firmware LANGUAGES C ASM)# --- Toolchain is passed via -DCMAKE_TOOLCHAIN_FILE=... ---# No need to set compiler variables here.# --- Build type default ---if(NOT CMAKE_BUILD_TYPE)set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)endif()# --- Linker script ---set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/linker/stm32f407vg_flash.ld)# --- Main target ---add_executable(${PROJECT_NAME}.elfsrc/main.csrc/startup_stm32f407xx.Ssrc/system_stm32f4xx.c)target_include_directories(${PROJECT_NAME}.elf PRIVATE${CMAKE_SOURCE_DIR}/include)# Cortex-M4F flags via generator expressions (per-config)target_compile_options(${PROJECT_NAME}.elf PRIVATE$<$<CONFIG:Debug>:-Og -g3 -gdwarf-4>$<$<CONFIG:Release>:-O2 -g>$<$<CONFIG:MinSizeRel>:-Os -g>-Wall -Wextra -Wpedantic -Werror=implicit-function-declaration-ffunction-sections -fdata-sections-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16)target_link_options(${PROJECT_NAME}.elf PRIVATE-T${LINKER_SCRIPT}-Wl,--gc-sections-Wl,--print-memory-usage-specs=nosys.specs -specs=nano.specs)# --- Post-build: .bin, .hex, size report ---add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILDCOMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> $<TARGET_FILE_DIR:${PROJECT_NAME}.elf>/${PROJECT_NAME}.binCOMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${PROJECT_NAME}.elf> $<TARGET_FILE_DIR:${PROJECT_NAME}.elf>/${PROJECT_NAME}.hexCOMMAND ${CMAKE_SIZE} -B -d $<TARGET_FILE:${PROJECT_NAME}.elf>COMMENT "Generating firmware images and size report")
Why generator expressions for compile flags? Hardcoding -O2 in CMAKE_C_FLAGS locks you to Release optimization. With $<$<CONFIG:Debug>:-Og>, a single build tree supports cmake --build build --config Debug (multi-config generators like Ninja Multi-Config, VS) and CMAKE_BUILD_TYPE=Debug (single-config generators). The same CMakeLists.txt works for both.
Vendor SDKs (STM32Cube, NXP MCUXpresso) and middleware (FreeRTOS, FatFS, mbedTLS) are dependencies, not submodules you copy-paste. FetchContent downloads them at configure time and makes them available as CMake targets.
# cmake/dependencies.cmakeinclude(FetchContent)# --- FreeRTOS Config ---# FreeRTOS CMake requires a 'freertos_config' target pointing to the config directory.# Since FreeRTOSConfig.h resides in our include/ directory, we define it here.add_library(freertos_config INTERFACE)target_include_directories(freertos_config INTERFACE${CMAKE_SOURCE_DIR}/include)# --- FreeRTOS ---# Set the port before fetching so FreeRTOS configures the right portable layerset(FREERTOS_PORT GCC_ARM_CM4F CACHE STRING "")FetchContent_Declare(freertosGIT_REPOSITORY https://github.com/FreeRTOS/FreeRTOS-Kernel.gitGIT_TAG V11.1.0)FetchContent_MakeAvailable(freertos)# Create an alias target to match namespacing guidelinesadd_library(freertos::kernel ALIAS freertos_kernel)target_link_libraries(${PROJECT_NAME}.elf PRIVATE freertos::kernel)# --- CMSIS Core (Cortex-M) ---FetchContent_Declare(cmsisGIT_REPOSITORY https://github.com/ARM-software/CMSIS_5.gitGIT_TAG 5.9.0)FetchContent_GetProperties(cmsis)if(NOT cmsis_POPULATED)FetchContent_Populate(cmsis)endif()# CMSIS doesn't have a CMakeLists.txt, so we create an INTERFACE target# and alias it with a namespace for clean downstream consumption.add_library(cmsis_core INTERFACE)target_include_directories(cmsis_core INTERFACE${cmsis_SOURCE_DIR}/CMSIS/Core/Include)add_library(cmsis::core ALIAS cmsis_core)target_link_libraries(${PROJECT_NAME}.elf PRIVATE cmsis::core)# --- STM32 HAL (device-specific) ---FetchContent_Declare(stm32hal_srcGIT_REPOSITORY https://github.com/STMicroelectronics/STM32CubeF4.gitGIT_TAG v1.28.0)FetchContent_GetProperties(stm32hal_src)if(NOT stm32hal_src_POPULATED)FetchContent_Populate(stm32hal_src)endif()# Compile HAL sources into a static libraryadd_library(stm32hal STATIC${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_cortex.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_dma.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_flash.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_pwr.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_rcc.c${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_uart.c# Add more as needed)# STM32 HAL requires the HAL includes, CMSIS Core, and CMSIS Device-specific headers.target_include_directories(stm32hal PUBLIC${CMAKE_SOURCE_DIR}/include # stm32f4xx_hal_conf.h lives here${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Inc${stm32hal_src_SOURCE_DIR}/Drivers/STM32F4xx_HAL_Driver/Inc/Legacy${stm32hal_src_SOURCE_DIR}/Drivers/CMSIS/Device/ST/STM32F4xx/Include)# Link STM32 HAL against CMSIS Core so that consumers get both transitivelytarget_link_libraries(stm32hal PUBLIC cmsis::core)target_compile_definitions(stm32hal PUBLICUSE_HAL_DRIVERSTM32F407xx)add_library(stm32hal::hal ALIAS stm32hal)target_link_libraries(${PROJECT_NAME}.elf PRIVATE stm32hal::hal)
Why FetchContent over git submodules?
target_include_directories by hand.target_link_libraries and target_include_directories work naturally.Pitfall: FetchContent re-downloads on every clean configure. Set FETCHCONTENT_FULLY_DISCONNECTED=ON in CI after the first run, or use a local mirror.
Embedded builds need different flags for Debug vs Release vs MinSizeRel. Generator expressions ($<...>) evaluate at build time, not configure time, enabling a single CMakeLists.txt to drive all configurations.
# Compile optionstarget_compile_options(${PROJECT_NAME}.elf PRIVATE# Optimization per config$<$<CONFIG:Debug>:-Og -g3 -gdwarf-4>$<$<CONFIG:Release>:-O2 -g>$<$<CONFIG:MinSizeRel>:-Os -g># Warnings (always)-Wall -Wextra -Wpedantic-Werror=implicit-function-declaration-Werror=return-type-Wshadow-Wdouble-promotion# Embedded-specific-ffunction-sections -fdata-sections-fno-common-fshort-enums-fno-builtin# Architecture-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16)# Link optionstarget_link_options(${PROJECT_NAME}.elf PRIVATE-T${LINKER_SCRIPT}-Wl,--gc-sections-Wl,--print-memory-usage-Wl,-Map=$<TARGET_FILE_DIR:${PROJECT_NAME}.elf>/${PROJECT_NAME}.map-specs=nosys.specs -specs=nano.specs)# Defines per configtarget_compile_definitions(${PROJECT_NAME}.elf PRIVATE$<$<CONFIG:Debug>:DEBUG=1>$<$<CONFIG:Release>:NDEBUG=1>USE_HAL_DRIVERSTM32F407xx)
Anti-pattern to avoid: Setting CMAKE_C_FLAGS_DEBUG globally. It affects all targets, including third-party libraries that may not support -Og. Target-specific generator expressions keep flags scoped.
The linker script defines memory regions and section placement. CMake passes it via -T in target_link_options.
/* linker/stm32f407vg_flash.ld */MEMORY{FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024KRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128KCCM (rwx) : ORIGIN = 0x10000000, LENGTH = 64K}SECTIONS{.isr_vector :{. = ALIGN(4);KEEP(*(.isr_vector)). = ALIGN(4);} >FLASH.text :{. = ALIGN(4);*(.text*)*(.rodata*)*(.glue_7)*(.glue_7t). = ALIGN(4);} >FLASH.ARM.extab : { *(.ARM.extab*) } >FLASH.ARM.exidx : { *(.ARM.exidx*) } >FLASH.preinit_array : { KEEP(*(.preinit_array)) } >FLASH.init_array : { KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array)) } >FLASH.fini_array : { KEEP(*(SORT(.fini_array.*))) KEEP(*(.fini_array)) } >FLASH_sidata = LOADADDR(.data);.data :{. = ALIGN(4);_sdata = .;*(.data*). = ALIGN(4);_edata = .;} >RAM AT>FLASH.bss :{. = ALIGN(4);_sbss = .;*(.bss*)*(COMMON). = ALIGN(4);_ebss = .;} >RAM.heap :{. = ALIGN(8);_sheap = .;. = . + 16K;_eheap = .;} >RAM.stack :{. = ALIGN(8);_sstack = .;. = . + 16K;_estack = .;} >RAM}
CMake’s --print-memory-usage (via -Wl,--print-memory-usage) emits a summary at link time:
Memory region Used Size Region Size %age UsedFLASH: 48 KB 1 MB 4.69%RAM: 12 KB 128 KB 9.38%CCM: 0 B 64 KB 0.00%
+------------------------------------------------------------------------------+| CMake Target Dependency Graph: Embedded Firmware Project |+------------------------------------------------------------------------------+| firmware.elf (EXECUTABLE) || ||├── PRIVATE (compile-time only) ||│ ||│ ├── include/ [target_include_directories] ||│ ├── cmake/toolchains/arm-none-eabi-gcc.cmake [CMAKE_TOOLCHAIN_FILE] ||│ └── linker/stm32f407vg_flash.ld [target_link_options -T] ||│ ||├── COMPILE OPTIONS (generator expressions) ||│ ├── $<$<CONFIG:Debug>:-Og -g3> ||│ ├── $<$<CONFIG:Release>:-O2 -g> ||│ ├── $<$<CONFIG:MinSizeRel>:-Os -g> ||│ ├── -mcpu=cortex-m4 -mthumb -mfloat-abi=hard ||│ ├── -ffunction-sections -fdata-sections ||│ └── -Wall -Wextra -Werror=implicit-function-declaration ||│ ||├── LINK OPTIONS ||│ ├── -Wl,--gc-sections --print-memory-usage ||│ ├── -specs=nosys.specs -specs=nano.specs ||│ └── -T linker/stm32f407vg_flash.ld ||│ ||├── PUBLIC / INTERFACE (transitive usage requirements) ||│ ||│ ├── freertos::kernel (INTERFACE) ||│ │ ├── include/ -> FreeRTOS.h, task.h, queue.h ||│ │ ├── portable/GCC/ARM_CM4F -> port.c, portmacro.h ||│ │ └── SOURCE_SUBDIR via FetchContent (Git tag V11.1.0) ||│ ||│ ├── cmsis::core (INTERFACE) ||│ │ ├── CMSIS/Core/Include -> core_cm4.h, cmsis_gcc.h ||│ │ └── SOURCE_SUBDIR via FetchContent (Git tag 5.9.0) ||│ ||│ └── stm32hal::hal (STATIC) ||│ ├── Drivers/STM32F4xx_HAL_Driver/Inc ||│ └── Drivers/CMSIS/Device/ST/STM32F4xx/Include ||│ ||└── POST-BUILD COMMANDS (add_custom_command POST_BUILD) || ├── objcopy -O binary firmware.elf -> firmware.bin || ├── objcopy -O ihex firmware.elf -> firmware.hex || └── size -B -d firmware.elf -> memory report |+------------------------------------------------------------------------------+| BUILD FLOW |+------------------------------------------------------------------------------+|CMakeLists.txt || | || v ||cmake/toolchains/arm-none-eabi-gcc.cmake || (cross-compile config) || | || v ||FetchContent_Populate() --> freertos/ cmsis/ || (cloned at configure time) || | || v ||add_executable(firmware.elf src/*.c src/*.S) || | || +-- target_include_directories() || --> include/, deps/freertos/include, ... || | || +-- target_compile_options() || --> per-config flags via $<CONFIG:...> || | || +-- target_link_options() || --> -T linker.ld --gc-sections || | || v ||arm-none-eabi-gcc (compile) --> link || | || v ||POST_BUILD: objcopy -> .bin/.hex || size -> report || | || v ||firmware.bin + firmware.hex || + firmware.map (flash artifacts) |+------------------------------------------------------------------------------+
# .github/workflows/build.ymlname: Firmware Buildon: [push, pull_request]jobs:build:runs-on: ubuntu-latestcontainer: arm-none-eabi/gcc:13.2.1 # Pinned toolchain imagesteps:- uses: actions/checkout@v4- name: Cache FetchContentuses: actions/cache@v4with:path: build/_depskey: ${{ runner.os }}-cmake-deps-${{ hashFiles('**/CMakeLists.txt') }}- name: Configure CMakerun: |cmake -B build \-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/arm-none-eabi-gcc.cmake \-DCMAKE_BUILD_TYPE=Release \-S .- name: Buildrun: cmake --build build -- -j$(nproc)- name: Upload Artifactsuses: actions/upload-artifact@v4with:name: firmware-imagespath: |build/firmware.binbuild/firmware.hexbuild/firmware.mapretention-days: 30
Key CI practices:
arm-none-eabi/gcc:13.2.1) — guarantees the same compiler version everywhere.actions/cache) — caches the build/_deps directory, avoiding repeated network calls for FetchContent on subsequent runs..bin, .hex, .map — the map file is essential for post-mortem analysis.CMake brings three concrete advantages to embedded C projects:
target_link_libraries replaces manual include-path management with a declarative graph. Adding FreeRTOS or mbedTLS is a FetchContent_Declare call, not a directory copy.CMakeLists.txt without duplicating logic.The minimal setup shown here — toolchain file, FetchContent for FreeRTOS/CMSIS/HAL, generator expressions for flags, linker script integration, and post-build image generation — scales to projects with 50+ source files, multiple build configurations, and CI pipelines that ship signed firmware images. The same CMakeLists.txt builds on a developer’s laptop, in GitHub Actions, and in a Docker-based release pipeline.
Quick Links
Legal Stuff




