HomeAbout UsContact Us

CMake for Embedded C: Cross-Compilation & Dependency Management

By Jithin Tom
July 16, 2026
3 min read
CMake for Embedded C: Cross-Compilation & Dependency Management

Table Of Contents

01
Toolchain File: The Cross-Compilation Contract
02
Project Structure and Minimal CMakeLists.txt
03
Dependency Management with FetchContent
04
Generator Expressions: Per-Config Flags Without Duplication
05
Linker Script Integration
06
ASCII Art: Build System Dependency Graph
07
CI Integration: Reproducible Builds
08
Summary
09
Related Reading
10
References
11
Frequently Asked Questions

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.


Toolchain File: The Cross-Compilation Contract

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.cmake
cmake_minimum_required(VERSION 3.20)
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
# Cross-compiler triplet
set(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 sysroot
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)
# Default flags for Cortex-M4F
set(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.


Project Structure and Minimal CMakeLists.txt

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.txt
cmake_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}.elf
src/main.c
src/startup_stm32f407xx.s
src/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_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.bin
COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.hex
COMMAND ${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.


Dependency Management with FetchContent

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.cmake
include(FetchContent)
# --- FreeRTOS ---
FetchContent_Declare(
freertos
GIT_REPOSITORY https://github.com/FreeRTOS/FreeRTOS-Kernel.git
GIT_TAG V11.1.0
SOURCE_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(
cmsis
GIT_REPOSITORY https://github.com/ARM-software/CMSIS_5.git
GIT_TAG 5.9.0
SOURCE_SUBDIR CMSIS/Core/Include
)
FetchContent_MakeAvailable(cmsis)
target_include_directories(${PROJECT_NAME}.elf PRIVATE
${cmsis_SOURCE_DIR}
)
# --- STM32 HAL (device-specific) ---
FetchContent_Declare(
stm32hal
GIT_REPOSITORY https://github.com/STMicroelectronics/STM32CubeF4.git
GIT_TAG v1.28.0
SOURCE_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 library
add_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 PRIVATE
USE_HAL_DRIVER
STM32F407xx
)
target_link_libraries(${PROJECT_NAME}.elf PRIVATE stm32hal)

Why FetchContent over git submodules?

  • Submodules pin a commit but don’t integrate with CMake’s target model. You still write target_include_directories by hand.
  • FetchContent makes the dependency a CMake target (or at least exposes its source path as a variable), so target_link_libraries and target_include_directories work naturally.
  • It runs at configure time — CI caches the downloaded source, so repeated builds are fast.

Pitfall: FetchContent re-downloads on every clean configure. Set FETCHCONTENT_FULLY_DISCONNECTED=ON in CI after the first run, or use a local mirror.


Generator Expressions: Per-Config Flags Without Duplication

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 options
target_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 options
target_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 config
target_compile_definitions(${PROJECT_NAME}.elf PRIVATE
$<$<CONFIG:Debug>:DEBUG=1>
$<$<CONFIG:Release>:NDEBUG=1>
USE_HAL_DRIVER
STM32F407xx
)

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.


Linker Script Integration

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 = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 192K
CCM (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 Used
FLASH: 48 KB 1 MB 4.69%
RAM: 12 KB 192 KB 6.25%
CCM: 0 B 64 KB 0.00%

ASCII Art: Build System Dependency Graph

+------------------------------------------------------------------------------+
| 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) |
+------------------------------------------------------------------------------+

CI Integration: Reproducible Builds

# .github/workflows/build.yml
name: Firmware Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
container: arm-none-eabi/gcc:13.2.1 # Pinned toolchain image
steps:
- uses: actions/checkout@v4
- name: Configure CMake
run: |
cmake -B build \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/arm-none-eabi-gcc.cmake \
-DCMAKE_BUILD_TYPE=Release \
-DFETCHCONTENT_FULLY_DISCONNECTED=ON \
-S .
- name: Build
run: cmake --build build -- -j$(nproc)
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-images
path: |
build/firmware.bin
build/firmware.hex
build/firmware.map
retention-days: 30

Key CI practices:

  • Pinned container image (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.
  • Artifact upload for .bin, .hex, .map — the map file is essential for post-mortem analysis.

Summary

CMake brings three concrete advantages to embedded C projects:

  1. Toolchain isolation — The toolchain file cleanly separates host and target environments, eliminating “works on my machine” bugs from host header leakage.
  2. Dependency modeling — FetchContent + 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.
  3. Configuration granularity — Generator expressions let you express per-build-type flags (optimization, debug info, assertions) in one 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.


  • Fixing I2C Clock Stretching Timeouts on STM32
  • JTAG and SWD Debugging Strategies for Embedded Systems
  • Bootloader Design for Embedded Systems

References

  1. CMake Documentation — “Cross Compiling With CMake” — https://cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html
  2. CMake Documentation — “FetchContent Module” — https://cmake.org/cmake/help/latest/module/FetchContent.html
  3. CMake Documentation — “Generator Expressions” — https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html
  4. ARM — “CMSIS_5 Repository” — https://github.com/ARM-software/CMSIS_5
  5. FreeRTOS — “FreeRTOS-Kernel Repository” — https://github.com/FreeRTOS/FreeRTOS-Kernel
  6. STMicroelectronics — “STM32CubeF4 Repository” — https://github.com/STMicroelectronics/STM32CubeF4

Frequently Asked Questions

Why use CMake over Makefiles for embedded C projects?

CMake provides native cross-compilation toolchain support, dependency management via FetchContent or find_package, generator expressions for per-configuration settings, and IDE integration (VS Code, CLion, Eclipse). Makefiles require manual toolchain variable management and lack built-in dependency fetching.

How does CMake handle cross-compilation for ARM Cortex-M targets?

CMake uses a toolchain file (e.g., arm-none-eabi-gcc.cmake) that sets CMAKE_SYSTEM_NAME=Generic, CMAKE_C_COMPILER=arm-none-eabi-gcc, and CMAKE_FIND_ROOT_PATH_MODE_*=ONLY. This isolates the build from host libraries and enables find_package to locate target-side dependencies.

What is the recommended way to manage third-party libraries (e.g., FreeRTOS, CMSIS) in CMake?

Use FetchContent to pull dependencies at configure time, then add_subdirectory to include them in the build. For system-installed SDKs, provide a config.cmake package or use find_package with HINTS pointing to the SDK root. Avoid copying source trees manually — it breaks reproducibility.

How do you configure different build types (Debug, Release, MinSizeRel) for embedded targets?

Set CMAKE_BUILD_TYPE at configure time (-DCMAKE_BUILD_TYPE=Release) for single-config generators (Make, Ninja). Use generator expressions like $<$<CONFIG:Debug>:-Og -g> in target_compile_options to apply per-config flags without hardcoding build type in CMakeLists.txt.

What is the minimal CMakeLists.txt structure for a Cortex-M project?

A minimal setup defines cmake_minimum_required(3.20), project() with LANGUAGES C ASM, includes a toolchain file, adds the executable with add_executable(firmware.elf src/main.c), sets target_compile_options for -mcpu=cortex-m4 -mthumb -mfloat-abi=hard, and adds linker script via target_link_options(-T linker.ld).

Tags

cmakeembedded-ccross-compilationbuild-systemsdependency-management

Share


Previous Article
Debugging Hard Faults on ARM Cortex-M: A Systematic Approach
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Static Analysis Tools for Embedded C: Cppcheck, MISRA, and Clang Static Analyzer
Static Analysis Tools for Embedded C: Cppcheck, MISRA, and Clang Static Analyzer
July 11, 2026
3 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media