
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)# 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)# Default flags for Cortex-M4Fset(CMAKE_C_FLAGS_INIT "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16")set(CMAKE_ASM_FLAGS_INIT "${CMAKE_C_FLAGS_INIT}")set(CMAKE_EXE_LINKER_FLAGS_INIT "-specs=nosys.specs -specs=nano.specs -Wl,--gc-sections")# 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 -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. 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> ${PROJECT_NAME}.binCOMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${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 ---FetchContent_Declare(freertosGIT_REPOSITORY https://github.com/FreeRTOS/FreeRTOS-Kernel.gitGIT_TAG V11.1.0SOURCE_SUBDIR .)FetchContent_MakeAvailable(freertos)# FreeRTOS Kernel is a header-only library with portable/ layer# We'll add the portable layer for GCC/ARM_CM4F manually:target_include_directories(${PROJECT_NAME}.elf PRIVATE${freertos_SOURCE_DIR}/include${freertos_SOURCE_DIR}/portable/GCC/ARM_CM4F)# --- CMSIS Core (Cortex-M) ---FetchContent_Declare(cmsisGIT_REPOSITORY https://github.com/ARM-software/CMSIS_5.gitGIT_TAG 5.9.0SOURCE_SUBDIR CMSIS/Core/Include)FetchContent_MakeAvailable(cmsis)target_include_directories(${PROJECT_NAME}.elf PRIVATE${cmsis_SOURCE_DIR})# --- STM32 HAL (device-specific) ---FetchContent_Declare(stm32halGIT_REPOSITORY https://github.com/STMicroelectronics/STM32CubeF4.gitGIT_TAG v1.28.0SOURCE_SUBDIR Drivers/STM32F4xx_HAL_Driver)FetchContent_MakeAvailable(stm32hal)target_include_directories(${PROJECT_NAME}.elf PRIVATE${stm32hal_SOURCE_DIR}/Inc${stm32hal_SOURCE_DIR}/Inc/Legacy)# Compile HAL sources into a static libraryadd_library(stm32hal STATIC${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_cortex.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_dma.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_flash.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_gpio.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_pwr.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_rcc.c${stm32hal_SOURCE_DIR}/Src/stm32f4xx_hal_uart.c# Add more as needed)target_compile_definitions(stm32hal PRIVATEUSE_HAL_DRIVERSTM32F407xx)target_link_libraries(${PROJECT_NAME}.elf PRIVATE stm32hal)
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=${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 = 192KCCM (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 : { *(.preinit_array) } >FLASH.init_array : { *(.init_array) } >FLASH.fini_array : { *(.fini_array) } >FLASH.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 192 KB 6.25%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 (INTERFACE, optional) ||│ ├── 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: Configure CMakerun: |cmake -B build \-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/arm-none-eabi-gcc.cmake \-DCMAKE_BUILD_TYPE=Release \-DFETCHCONTENT_FULLY_DISCONNECTED=ON \-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.FETCHCONTENT_FULLY_DISCONNECTED=ON — prevents network calls in CI after first cache population..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
Social Media




