armhub.dev
Tools · 10 pages
Tools

Build systems

CMake, Make, Meson, west, scons — what to use for a new Cortex-M project, and how to glue the toolchain together.

2 min read · 417 wordsupdated · May 12, 2026

A new Cortex-M project today should use CMake. It's not the prettiest build system, but it's universally supported by IDEs, vendor SDKs, CI systems, and toolchains. Once you have it set up, you'll spend more time writing code than wrestling with builds — which is the actual goal.

The minimum viable CMake

cmake_minimum_required(VERSION 3.20)
 
# Toolchain — must come before project()
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_SIZE         arm-none-eabi-size)
set(CMAKE_C_COMPILER_WORKS 1)   # skip CMake's host test compile
set(CMAKE_CXX_COMPILER_WORKS 1)
 
# CPU flags
set(CPU_FLAGS "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard")
 
set(CMAKE_C_FLAGS_INIT   "${CPU_FLAGS} -ffunction-sections -fdata-sections -Wall -Wextra")
set(CMAKE_CXX_FLAGS_INIT "${CPU_FLAGS} -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -Wall -Wextra")
set(CMAKE_ASM_FLAGS_INIT "${CPU_FLAGS}")
set(CMAKE_EXE_LINKER_FLAGS_INIT "${CPU_FLAGS} -Wl,--gc-sections -Wl,-Map=output.map -nostartfiles --specs=nano.specs")
 
project(firmware C CXX ASM)
 
add_executable(firmware
  src/main.c
  src/startup.c
  drivers/uart.c
)
 
target_include_directories(firmware PRIVATE include)
target_link_options(firmware PRIVATE -T${CMAKE_SOURCE_DIR}/linker.ld)
 
# Generate .bin and .hex outputs + print size
add_custom_command(TARGET firmware POST_BUILD
  COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:firmware> firmware.bin
  COMMAND ${CMAKE_OBJCOPY} -O ihex   $<TARGET_FILE:firmware> firmware.hex
  COMMAND ${CMAKE_SIZE} $<TARGET_FILE:firmware>
)

Save as CMakeLists.txt. Build:

cmake -B build -G Ninja
cmake --build build

That's it. You'll get build/firmware (ELF), build/firmware.bin, build/firmware.hex, and a size summary printed at the end.

Using a toolchain file

For cleaner separation, factor the toolchain bits into a separate arm-gcc-toolchain.cmake:

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++)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY      arm-none-eabi-objcopy)
set(CMAKE_SIZE         arm-none-eabi-size)
 
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

Then invoke CMake with:

cmake -B build -G Ninja --toolchain arm-gcc-toolchain.cmake

The toolchain file is reusable across projects.

Configuration types

CMake's default is single-config. For multi-config (Debug/Release/MinSizeRel):

if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  set(CMAKE_BUILD_TYPE "MinSizeRel")
endif()
 
set(CMAKE_C_FLAGS_DEBUG_INIT   "-Og -g3 -DDEBUG")
set(CMAKE_C_FLAGS_RELEASE_INIT "-O3 -DNDEBUG")
set(CMAKE_C_FLAGS_MINSIZEREL_INIT "-Os -DNDEBUG")

Most embedded projects use MinSizeRel for production and Debug for development.

Vendor SDKs

STM32CubeMX → CMake

STMicro's CubeMX generates Makefiles or vendor IDE projects. To use CMake instead, project tooling has caught up: the STM32CubeIDE → CMake templates, or stm32-cmake, wrap the Cube HAL as importable CMake targets.

find_package(CMSIS COMPONENTS STM32F407VG REQUIRED)
find_package(HAL COMPONENTS STM32F407VG RCC GPIO UART REQUIRED)
 
target_link_libraries(firmware PRIVATE
  CMSIS::STM32::F407VG
  HAL::STM32::F4::RCC
  HAL::STM32::F4::GPIO
  HAL::STM32::F4::UART
)

Nordic nRF Connect SDK

Built on CMake + west (the Zephyr meta-tool). Use it as-is — they've done the work.

Raspberry Pi Pico SDK

CMake-native. Add their SDK as a submodule and include(pico_sdk_import.cmake). Excellent reference for a clean modern CMake setup.

NXP MCUXpresso SDK

Ships as either Eclipse projects or CMake-buildable. Pick the CMake variant and skip the Eclipse IDE.

Make — when it still makes sense

If you have a small, single-target project and don't need IDE integration, a plain Makefile is fine and faster to read:

TARGET = firmware
CPU = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
CFLAGS = $(CPU) -Os -ffunction-sections -fdata-sections -Wall
LDFLAGS = $(CPU) -Wl,--gc-sections -nostartfiles -T linker.ld --specs=nano.specs
 
SRCS = src/main.c src/startup.c
OBJS = $(SRCS:.c=.o)
 
$(TARGET).elf: $(OBJS)
	arm-none-eabi-gcc $(LDFLAGS) -o $@ $^
 
%.o: %.c
	arm-none-eabi-gcc $(CFLAGS) -c -o $@ $<
 
clean:
	rm -f $(OBJS) $(TARGET).elf

For anything beyond hobby scale, CMake wins because of:

  • IDE integration (CLion, VS Code with CMake Tools, Qt Creator).
  • Dependency management (vendor SDKs).
  • Cross-platform reproducibility (Windows + macOS + Linux + CI).

Meson — the contender

Meson is a younger build system with arguably nicer syntax than CMake. Cortex-M support exists (meson cross files) but the ecosystem is smaller. Excellent for pure-application projects; less excellent when you need to pull in vendor SDKs that ship CMake files.

west — Zephyr's meta-build

If you're on Zephyr, west is your build entry point. It wraps CMake under the hood:

west init -l my-app
west update
west build -b nucleo_h745zi_q my-app
west flash

You still configure your application with CMake + Kconfig, but west handles the workspace, the dependencies, and the per-board build directory structure.

CI for embedded

A typical CI job:

- run: |
    cmake -B build -G Ninja --toolchain arm-gcc.cmake
    cmake --build build
    arm-none-eabi-size build/firmware.elf > size.txt
    cat size.txt

Add static analysis (cppcheck, clang-tidy) and a "size budget" guard:

- name: Enforce flash budget
  run: |
    size=$(arm-none-eabi-size -d build/firmware.elf | awk 'NR==2 {print $1}')
    if [ $size -gt $((128*1024)) ]; then
      echo "Flash usage $size exceeds 128 KB budget"; exit 1
    fi

Hardware-in-the-loop (HIL) tests are an upgrade — connect a board to your CI runner, flash from CI, run integration tests. Worth doing for any product that's gone past prototype.

See also