Skip to content

Part 3 · Firmware Systems32 min read

Build Systems, Toolchain Flags, and CI

A firmware project is only as good as its build. If the release image depends on which laptop built it, which compiler version happened to be installed, or whether someone remembered to run make clean, then every bug report starts with “which binary is this?”. This lesson builds the same STM32F407 project twice, once with a complete GNU Makefile and once with CMake, a toolchain file and presets, explains every compiler and linker flag in both, stamps a version from git into the image, makes the build reproducible, and wires it into a GitHub Actions pipeline that builds inside a pinned Docker toolchain, runs host unit tests and static analysis, archives the artifacts, and fails the pull request if the image grows past its flash budget.

Both build systems in this lesson work on this tree, which extends the layout from Organizing a real C codebase:

fw/
Makefile plain GNU make build
CMakeLists.txt CMake build
CMakePresets.json named configurations
cmake/
arm-none-eabi.cmake toolchain file
version.cmake build-time version stamping
link.ld linker script (from Linker scripts)
include/ public headers, including version.h
src/ startup.c main.c ringbuf.c ...
tests/ host unit tests (Unity)
third_party/unity/ vendored test framework
ci/
Dockerfile the pinned toolchain image
in-docker.sh run a command inside that image
.github/workflows/firmware.yml
.clang-format
.githooks/pre-commit

Hello, C: the compilation pipeline ended with a three-rule Makefile. A real firmware Makefile needs a build directory, automatic header dependencies, debug and release configurations, post-processing into .bin and .hex, and convenience targets for flashing and debugging.

Makefile
# Makefile - STM32F407 firmware, plain GNU make
TARGET := firmware
CONFIG ?= debug
BUILD := build/make-$(CONFIG)
CROSS ?= arm-none-eabi-
CC := $(CROSS)gcc
OBJCOPY := $(CROSS)objcopy
SIZE := $(CROSS)size
GDB := $(CROSS)gdb
SRCS := $(sort $(wildcard src/*.c))
OBJS := $(SRCS:%.c=$(BUILD)/%.o) $(BUILD)/version.o
DEPS := $(OBJS:.o=.d)
MCU := -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16
ifeq ($(CONFIG),release)
OPT := -Os -g -DNDEBUG
else ifeq ($(CONFIG),debug)
OPT := -Og -g3
else
$(error CONFIG must be debug or release)
endif
CFLAGS := $(MCU) $(OPT) -std=c11 -Wall -Wextra -Wpedantic -Wshadow \
-Wdouble-promotion -Wformat=2 -ffunction-sections -fdata-sections \
-fstack-usage -ffile-prefix-map=$(CURDIR)=. -Iinclude -MMD -MP
LDFLAGS := $(MCU) -T link.ld --specs=nano.specs --specs=nosys.specs \
-Wl,--gc-sections -Wl,-Map=$(BUILD)/$(TARGET).map -Wl,--print-memory-usage
GIT_VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
.PHONY: all clean flash debug size FORCE
all: $(BUILD)/$(TARGET).elf $(BUILD)/$(TARGET).bin $(BUILD)/$(TARGET).hex
$(BUILD)/$(TARGET).elf: $(OBJS) link.ld
$(CC) $(LDFLAGS) $(OBJS) -o $@
$(SIZE) $@
$(BUILD)/%.o: %.c
@mkdir -p $(@D)
$(CC) $(CFLAGS) -c $< -o $@
$(BUILD)/version.c: FORCE
@mkdir -p $(@D)
@printf '#include "version.h"\nconst char fw_version[] = "%s";\n' '$(GIT_VERSION)' > $@.tmp
@cmp -s $@.tmp $@ || mv $@.tmp $@
@rm -f $@.tmp
$(BUILD)/version.o: $(BUILD)/version.c
$(CC) $(CFLAGS) -c $< -o $@
%.bin: %.elf
$(OBJCOPY) -O binary $< $@
%.hex: %.elf
$(OBJCOPY) -O ihex $< $@
size: $(BUILD)/$(TARGET).elf
$(SIZE) -A $<
flash: $(BUILD)/$(TARGET).elf
openocd -f board/stm32f4discovery.cfg -c "program $< verify reset exit"
debug: $(BUILD)/$(TARGET).elf
$(GDB) -ex "target extended-remote :3333" -ex "monitor reset halt" -ex "load" $<
clean:
rm -rf build/make-*
FORCE:
-include $(DEPS)

Recipe lines must start with a real tab character, not spaces; “missing separator” is make’s way of telling you an editor converted them. The important parts, top to bottom:

  • Variables with := are expanded once, when defined. ?= (for CONFIG and CROSS) only assigns if the variable is not already set, so make CONFIG=release or make CROSS=/opt/arm/bin/arm-none-eabi- override them from the command line.
  • $(sort $(wildcard src/*.c)) finds the sources. sort matters for reproducibility: wildcard returns files in directory order, which differs between file systems, and link order affects the binary.
  • $(SRCS:%.c=$(BUILD)/%.o) maps src/main.c to build/make-debug/src/main.o. Each configuration gets its own build directory, so switching between debug and release never mixes objects compiled with different flags.
  • -MMD -MP make the compiler write a .d file next to each object listing every header it included. -include $(DEPS) reads them (the leading - ignores missing files on the first build). Without this, editing a header does not rebuild the files that use it, and you get the classic “struct layout changed but half the objects did not notice” bug. -MP adds dummy targets for headers so deleting a header does not break the build.
  • The pattern rule $(BUILD)/%.o: %.c compiles any source into the build tree. $< is the first prerequisite, $@ the target, $(@D) the target’s directory, which mkdir -p creates.
  • The ELF depends on link.ld, so editing the linker script relinks.
  • %.bin and %.hex are produced from the ELF with objcopy. The raw binary is what bootloaders and many flashing tools want; Intel HEX carries addresses and a checksum per line.
  • .PHONY declares targets that are not files, so a file accidentally named clean cannot stop make clean from running.
  • flash and debug are the targets you will type all day. debug expects OpenOCD to be running in another terminal, as in Debugging and tracing on real hardware.

Run it in parallel with make -j8 (or make -j$(nproc)). Parallel builds are safe only if every dependency is declared; a missing dependency shows up as a build that fails one time in twenty. The mkdir -p in every compile recipe is what makes the parallel build safe for directory creation.

The version.c rule runs on every build because it depends on FORCE, an empty target that is always out of date. It writes the output of git describe --tags --always --dirty into a temporary file and replaces version.c only if the content changed (cmp -s ... ||). That last trick matters: if the version is unchanged, version.c keeps its old timestamp, version.o is not recompiled, and the ELF is not relinked. A rule that rewrote the file every time would relink every build.

git describe produces strings like v1.4.0 on a tagged commit, v1.4.0-3-g9fceb02 three commits after the tag (the g prefix marks the abbreviated commit hash), and v1.4.0-3-g9fceb02-dirty if there are uncommitted changes. The -dirty suffix is the most valuable part: an image built from uncommitted code announces itself. The header is one line:

include/version.h
#ifndef VERSION_H
#define VERSION_H
extern const char fw_version[]; /* generated at build time from git describe */
#endif

The firmware prints fw_version at boot and returns it from a shell info command, and the bootloader’s image header from Bootloaders and firmware update carries a numeric version derived from the same tag. Never use __DATE__ and __TIME__ for this; see the section on reproducible builds below.

FlagWhat it doesWhy firmware needs it
-mcpu=cortex-m4Generate code for the M4 and tune for its pipelineEnables the M4’s instructions (hardware divide, DSP); wrong CPU means UsageFaults or slow code
-mthumbThumb instruction setCortex-M executes only Thumb; required
-mfloat-abi=hardPass float arguments in FPU registersFastest; every object and library must use the same ABI or the link fails
-mfpu=fpv4-sp-d16The M4F’s single-precision FPUWithout it, floats use software routines
-ffunction-sections, -fdata-sectionsOne section per function and per variableLets the linker drop unused code and data
-Wl,--gc-sectionsLinker removes unreferenced sectionsOften saves 20 to 50% of an image that uses part of a library
-fno-builtinDo not treat standard function names speciallyNeeded when you implement memcpy or printf yourself; it does not stop GCC from emitting calls to memcpy/memset for struct copies or recognised loops
-fstack-usageWrite a .su file with each function’s stack frame sizeInput for worst-case stack analysis, as in Memory: where everything lives
-g3Debug info including macro definitionsgdb can expand GPIOD and RCC_AHB1ENR_GPIODEN; costs nothing on the chip
-OgOptimise without hurting debuggabilityDevelopment builds that still run at a realistic speed
-Os / -O2Size or speed optimisationRelease builds; see Performance and code size optimization
-Wl,-Map=file.mapWrite the link mapWhere every byte went, and why each library member was linked
-Wl,--print-memory-usagePrint flash and RAM use per memory region at link timeInstant feedback on how full the chip is
--specs=nano.specsLink newlib-nano instead of full newlibMuch smaller printf, malloc and stdio
--specs=nosys.specsLink stub system calls (_write, _sbrk, …) that return errorsLinks without an OS; you override the ones you need, as in The C standard library on a microcontroller
-nostartfilesDo not link the C runtime start files (crt0.o, crti.o, crtn.o)When your own Reset_Handler does the startup; if you call __libc_init_array, you must then provide an empty _init (and _fini) yourself
-ffile-prefix-map=old=newRewrite source paths in debug info and __FILE__Reproducible builds and shorter assert strings
-MMD -MPEmit dependency filesCorrect incremental builds

The output of --print-memory-usage looks like this, and it is worth reading on every build:

Memory region Used Size Region Size %age Used
FLASH: 30872 B 1 MB 2.94%
RAM: 2496 B 128 KB 1.90%
CCMRAM: 0 GB 64 KB 0.00%

Debug versus release differ in more than -O. A typical split: debug builds use -Og -g3, keep asserts, enable verbose logging and debug-only shell commands, and freeze the watchdog at breakpoints (DBGMCU); release builds use -Os or -O2, define NDEBUG or set your own assert policy, compile out debug commands and semihosting, and enable readout protection at provisioning. Both keep -g: debug information lives only in the ELF, and you need it to decode crash addresses from the field. Archive the release ELF.

Make is explicit and universal, but large projects, IDE integration, and multiple boards push most teams to CMake. CMake does not build anything itself; it generates build files for a backend, and Ninja is the backend to use: fast, parallel by default, and precise about dependencies.

CMake assumes it is building for the machine it runs on. A toolchain file tells it otherwise, and it is the only place that should know about the cross compiler:

cmake/arm-none-eabi.cmake
# Cross-compiling for a bare-metal Cortex-M4F.
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
# Bare metal cannot link a test program without our linker script and startup
# code, so make CMake's compiler checks build a static library instead.
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_ASM_COMPILER arm-none-eabi-gcc)
set(CMAKE_OBJCOPY arm-none-eabi-objcopy CACHE FILEPATH "objcopy")
set(ARM_SIZE arm-none-eabi-size CACHE FILEPATH "size")
set(MCU_FLAGS "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16")
set(CMAKE_C_FLAGS_INIT "${MCU_FLAGS}")
set(CMAKE_ASM_FLAGS_INIT "${MCU_FLAGS}")
set(CMAKE_EXE_LINKER_FLAGS_INIT "${MCU_FLAGS} --specs=nano.specs --specs=nosys.specs")
set(CMAKE_C_FLAGS_DEBUG_INIT "-Og -g3")
set(CMAKE_C_FLAGS_RELEASE_INIT "-Os -g -DNDEBUG")
# Never search the host system for libraries, headers or packages.
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)
  • CMAKE_SYSTEM_NAME Generic means “no operating system”. Setting it also tells CMake it is cross-compiling (CMAKE_CROSSCOMPILING becomes true).
  • CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY is the line people forget. When CMake first configures, it compiles and links a tiny test program to check that the compiler works. For bare metal, that link fails (no linker script, no _start, undefined _exit), and CMake reports that your perfectly good compiler “is not able to compile a simple test program”. Building a static library skips the link step.
  • The _INIT variables seed CMake’s flag variables once, so the MCU flags apply to every compile, link, and CMake test, and the per-configuration defaults replace CMake’s own (-O3 for Release is a poor default for firmware).
  • CMAKE_FIND_ROOT_PATH_MODE_* stop find_library and find_package from picking up the host’s x86 libraries.
CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(firmware LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(BOARD "disco" CACHE STRING "Target board: disco or proto_b")
option(FW_WERROR "Treat warnings as errors" OFF)
# Warnings shared by firmware and host tests.
add_library(project_warnings INTERFACE)
target_compile_options(project_warnings INTERFACE
-Wall -Wextra -Wpedantic -Wshadow -Wdouble-promotion -Wformat=2
$<$<BOOL:${FW_WERROR}>:-Werror>)
if(CMAKE_CROSSCOMPILING)
# ---------------- firmware ----------------
add_custom_target(fw_version
COMMAND ${CMAKE_COMMAND}
-DOUT=${CMAKE_BINARY_DIR}/version.c
-DSRC_DIR=${CMAKE_SOURCE_DIR}
-P ${CMAKE_SOURCE_DIR}/cmake/version.cmake
BYPRODUCTS ${CMAKE_BINARY_DIR}/version.c
COMMENT "Stamping version from git describe"
VERBATIM)
add_executable(firmware
src/startup.c
src/main.c
src/ringbuf.c
${CMAKE_BINARY_DIR}/version.c)
add_dependencies(firmware fw_version)
set_target_properties(firmware PROPERTIES
SUFFIX ".elf"
LINK_DEPENDS ${CMAKE_SOURCE_DIR}/link.ld)
target_include_directories(firmware PRIVATE include)
target_compile_definitions(firmware PRIVATE BOARD_${BOARD}=1)
target_compile_options(firmware PRIVATE
-ffunction-sections -fdata-sections -fstack-usage
-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.)
target_link_options(firmware PRIVATE
-T${CMAKE_SOURCE_DIR}/link.ld
-Wl,--gc-sections
-Wl,-Map=$<TARGET_FILE_DIR:firmware>/firmware.map
-Wl,--print-memory-usage)
target_link_libraries(firmware PRIVATE project_warnings)
add_custom_command(TARGET firmware POST_BUILD
COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:firmware>
$<TARGET_FILE_DIR:firmware>/firmware.bin
COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:firmware>
$<TARGET_FILE_DIR:firmware>/firmware.hex
COMMAND ${ARM_SIZE} $<TARGET_FILE:firmware>
VERBATIM)
else()
# ---------------- host unit tests ----------------
enable_testing()
add_library(unity STATIC third_party/unity/src/unity.c)
target_include_directories(unity PUBLIC third_party/unity/src)
add_executable(test_ringbuf tests/test_ringbuf.c src/ringbuf.c)
target_include_directories(test_ringbuf PRIVATE include)
target_link_libraries(test_ringbuf PRIVATE unity project_warnings)
target_compile_options(test_ringbuf PRIVATE
-fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options(test_ringbuf PRIVATE -fsanitize=address,undefined)
add_test(NAME ringbuf COMMAND test_ringbuf)
endif()

One CMakeLists.txt describes both worlds. Configured with the toolchain file, CMAKE_CROSSCOMPILING is true and it builds the firmware. Configured without one, it builds the host unit tests from Testing and debugging C on the host with the host compiler and sanitizers. The same ringbuf.c is compiled for both, which is the point: the code you test is the code you ship.

The modern CMake style is target-based: options are attached to targets with target_compile_options, target_link_options, and target_include_directories rather than set globally. project_warnings is an INTERFACE library, a bundle of usage requirements with no code; linking it applies the warning flags to whatever uses it. The generator expression $<$<BOOL:${FW_WERROR}>:-Werror> adds -Werror only when the FW_WERROR option is on, which CI turns on and laptops leave off.

LINK_DEPENDS relinks when the linker script changes, which CMake would otherwise not notice. $<TARGET_FILE:firmware> and $<TARGET_FILE_DIR:firmware> are generator expressions that resolve to the ELF’s actual path at build time. POST_BUILD commands run after every link, producing the .bin, .hex, and a size report. Unity gets its own library without the project’s strict warnings, because third-party code is not yours to fix.

The version stamp runs as a custom target on every build. CMake’s execute_process at configure time would only capture the version when you re-run CMake, so a build after a new commit would carry a stale version. Instead, the custom target runs a small CMake script each time:

cmake/version.cmake
# Run at build time: cmake -DOUT=<file> -DSRC_DIR=<dir> -P version.cmake
execute_process(
COMMAND git describe --tags --always --dirty
WORKING_DIRECTORY ${SRC_DIR}
OUTPUT_VARIABLE GIT_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET)
if(NOT GIT_VERSION)
set(GIT_VERSION "unknown")
endif()
set(NEW "#include \"version.h\"\nconst char fw_version[] = \"${GIT_VERSION}\";\n")
set(OLD "")
if(EXISTS "${OUT}")
file(READ "${OUT}" OLD)
endif()
if(NOT NEW STREQUAL OLD)
file(WRITE "${OUT}" "${NEW}")
endif()

Like the Makefile rule, it writes the file only when the content changes. Declaring version.c as a BYPRODUCTS of the target tells Ninja that the file is generated and lets it skip recompiling when the timestamp did not move.

Without presets, everyone types a different cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=... -DCMAKE_BUILD_TYPE=... line, and CI types yet another. Presets put those configurations in a file under version control:

CMakePresets.json
{
"version": 3,
"cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 },
"configurePresets": [
{
"name": "arm-base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "${sourceDir}/cmake/arm-none-eabi.cmake",
"cacheVariables": { "BOARD": "disco" }
},
{
"name": "debug",
"displayName": "Firmware, debug (-Og)",
"inherits": "arm-base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
},
{
"name": "release",
"displayName": "Firmware, release (-Os)",
"inherits": "arm-base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "FW_WERROR": "ON" }
},
{
"name": "release-proto-b",
"displayName": "Firmware, release, prototype B board",
"inherits": "release",
"cacheVariables": { "BOARD": "proto_b" }
},
{
"name": "host-tests",
"displayName": "Host unit tests",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", "FW_WERROR": "ON" }
}
],
"buildPresets": [
{ "name": "debug", "configurePreset": "debug" },
{ "name": "release", "configurePreset": "release" },
{ "name": "release-proto-b", "configurePreset": "release-proto-b" },
{ "name": "host-tests", "configurePreset": "host-tests" }
],
"testPresets": [
{
"name": "host-tests",
"configurePreset": "host-tests",
"output": { "outputOnFailure": true }
}
]
}

Now everybody, including CI and VS Code’s CMake Tools extension, uses the same commands:

Terminal window
cmake --preset release
cmake --build --preset release
cmake --preset host-tests && cmake --build --preset host-tests && ctest --preset host-tests

arm-base is hidden: it only exists to be inherited. Each preset gets its own build directory, build/release, build/host-tests, and so on. release-proto-b shows how board variants work: it inherits everything from release and changes one cache variable, which CMakeLists.txt turns into a BOARD_proto_b=1 definition that your board support file switches on. Ten board and configuration combinations are ten short entries, not ten copies of a build script. Each developer can add personal presets in an untracked CMakeUserPresets.json.

A build is reproducible if the same source and the same toolchain produce a bit-for-bit identical binary, on any machine, at any time. It is what lets you answer “is the image on this device really built from tag v1.4.0?” by rebuilding and comparing hashes, and it is increasingly expected by customers and regulators. The usual sources of difference:

  • Toolchain version. Different GCC releases generate different code. Pin the toolchain, which the Docker image below does.
  • Timestamps. __DATE__ and __TIME__ change every build. Do not use them; -Wdate-time warns if anything does. (GCC also honours the SOURCE_DATE_EPOCH environment variable for them, if a library you cannot change uses them.)
  • Absolute paths. Debug information and every __FILE__ contain the build directory. -ffile-prefix-map=$(CURDIR)=. rewrites them to relative paths.
  • File order. Unsorted wildcard results change link order. Sort them, or list sources explicitly as the CMake file does.
  • Archive metadata. ar can store timestamps and user IDs in static libraries. Deterministic mode (ar D, the default in most current distributions’ binutils) zeroes them.
  • Uncommitted changes. The -dirty suffix from git describe makes these visible.

A build ID gives every image a content-derived fingerprint. -Wl,--build-id makes the linker compute a SHA-1 over the output and store it in a .note.gnu.build-id section. Because it is computed from content, it is itself reproducible. Keep it in flash so the firmware can report it:

.note.gnu.build-id :
{
PROVIDE(g_build_id = .);
KEEP(*(.note.gnu.build-id))
} > FLASH

The section is a standard ELF note: a 16-byte header (name size, descriptor size, type, and the name "GNU" padded to 4 bytes) followed by the 20-byte SHA-1, so the ID starts at g_build_id + 16. The firmware prints it at boot; arm-none-eabi-readelf -n firmware.elf prints the same value on the host, matching a crash report to the exact ELF in your archive.

Check reproducibility the direct way: build twice from clean checkouts in different directories and compare sha256sum build/release/firmware.bin. If the hashes differ, diffoscope or a byte comparison of the two ELFs points at the difference.

“Works on my machine” usually means “my machine has a different compiler”. Put the toolchain in a container image, defined in the repository, and use that image everywhere it matters: CI, release builds, and any developer who wants the exact release environment.

ci/Dockerfile
# The one place the toolchain version is defined.
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gcc-arm-none-eabi libnewlib-arm-none-eabi binutils-arm-none-eabi \
gcc libc6-dev cmake ninja-build make git python3 \
cppcheck clang-tidy clang-format ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# The workspace is mounted from the host; let git operate on it.
RUN git config --system --add safe.directory '*'
# Record the exact tool versions in the image build log.
RUN arm-none-eabi-gcc --version && cmake --version && cppcheck --version
WORKDIR /work

The image takes its toolchain from the Ubuntu 24.04 package archive, so every build in it uses the same major GCC release, newlib (including the nano variant), CMake, Ninja, the host compiler for unit tests, and the analysis tools. For stronger pinning, refer to the base image by digest (FROM ubuntu:24.04@sha256:...) so it cannot change under you, and for bit-exact reproducibility over years, install the Arm GNU Toolchain release tarball of a specific version and verify its published SHA-256 in the Dockerfile, since distribution packages receive updates. Whichever you choose, archive the image itself with each release: in five years you may need to rebuild that exact firmware to fix a field bug.

The safe.directory line avoids a confusing failure: git refuses to run in a repository owned by a different user (“detected dubious ownership”), which is exactly the situation when a host directory is mounted into a container. Without it, git describe fails and every image is stamped unknown.

A small wrapper keeps the CI steps readable:

ci/in-docker.sh
#!/bin/sh
# Run a command inside the toolchain image with the repository mounted at /work.
set -eu
exec docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/work -w /work \
-e HOME=/tmp \
fw-toolchain "$@"

--user runs the container as the calling user, so files created in build/ belong to you rather than root. HOME=/tmp gives tools a writable home directory, because that user does not exist inside the image. Commit the script with its executable bit set (git update-index --chmod=+x ci/in-docker.sh), or CI fails with “permission denied”.

CI runs on every push and pull request, so problems are found within minutes of being written instead of the week before release. A firmware pipeline should at least: build every configuration with warnings as errors, run the host unit tests, run static analysis, check size budgets, and archive the artifacts.

.github/workflows/firmware.yml
name: firmware
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-24.04
env:
FLASH_BUDGET: 131072 # bytes of flash this product may use
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history and tags, for git describe
submodules: recursive
- name: Build toolchain image
run: docker build -t fw-toolchain ci
- name: Static analysis
run: >
./ci/in-docker.sh cppcheck --enable=warning,style,performance,portability
--std=c11 --error-exitcode=1 --inline-suppr -I include src
- name: Host unit tests
run: |
./ci/in-docker.sh cmake --preset host-tests
./ci/in-docker.sh cmake --build --preset host-tests
./ci/in-docker.sh ctest --preset host-tests
- name: Firmware release build
run: |
./ci/in-docker.sh cmake --preset release
./ci/in-docker.sh cmake --build --preset release
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-${{ github.sha }}
path: |
build/release/firmware.elf
build/release/firmware.bin
build/release/firmware.hex
build/release/firmware.map
if-no-files-found: error
- name: Check flash budget
run: |
used=$(./ci/in-docker.sh arm-none-eabi-size build/release/firmware.elf \
| awk 'NR == 2 { print $1 + $2 }')
echo "flash used: $used of $FLASH_BUDGET bytes"
if [ "$used" -gt "$FLASH_BUDGET" ]; then
echo "::error::flash usage $used exceeds the budget of $FLASH_BUDGET bytes"
exit 1
fi

Step by step:

  • fetch-depth: 0 checks out the full history with tags. The default shallow clone has no tags, so git describe would print only a hash.
  • Build toolchain image builds the Dockerfile from the repository. Every later step runs inside it through in-docker.sh, so CI uses exactly the toolchain the repository defines. For speed, push the image to a container registry and pull it instead of rebuilding on every run, or use Docker layer caching.
  • Static analysis runs cppcheck over the sources and fails the job on any finding (--error-exitcode=1). --inline-suppr lets you silence a reviewed false positive with a comment at the line, which is better than disabling a check globally.
  • Host unit tests configure, build and run the Unity tests with AddressSanitizer and UndefinedBehaviorSanitizer, using the presets. outputOnFailure in the test preset prints a failing test’s output into the CI log.
  • Firmware release build uses the release preset, which sets FW_WERROR, so any warning fails the build.
  • Upload artifacts stores the ELF, binary, HEX and map file with the run, named after the commit. The ELF and map are as important as the binary: they are what you need to decode a crash address from a device running this build. It runs before the budget check so that a too-big build still leaves its map file for you to inspect.
  • Check flash budget: size prints text data bss dec hex filename; flash usage is text + data (the initial values of .data live in flash). If the image exceeds the budget, the step fails with an error annotation on the pull request. A budget turns “the image slowly grew until it did not fit” into “this pull request added 6 KB, please justify it”. Track RAM the same way with data + bss, and raise budgets deliberately in a reviewed commit.

Add more as the project grows: a matrix over the board presets, a job that runs the firmware in Renode against a test script (covered in Testing embedded software), a release job triggered by a version tag that signs the image with a key held in a KMS, as described in Firmware security.

The compiler’s warnings are the first analyser; the pipeline above adds more:

  • cppcheck finds bugs the compiler does not look for: out-of-bounds indexing with constant indices, null dereferences on some paths, resource leaks, uninitialised variables across branches. It needs no compile database and is cheap enough to run on every commit. Its MISRA addon checks a subset of MISRA C rules.
  • clang-tidy runs Clang’s static analyser and hundreds of checks (bugprone-*, cert-*, clang-analyzer-*), configured in a .clang-tidy file. It needs to know how each file is compiled, which it reads from compile_commands.json: clang-tidy -p build/host-tests src/ringbuf.c.
  • GCC’s -fanalyzer (GCC 10 and later, best on C) performs inter-procedural path analysis for double frees, use-after-free, null dereferences, and file-descriptor leaks. It is slow, so run it in a separate CI job or a nightly build, with a preset that adds it to CMAKE_C_FLAGS.
  • Commercial tools (PC-lint Plus, Coverity, Polyspace, Helix QAC) add deeper analysis and full MISRA C and CERT C checking with the reports that safety standards require, as discussed in Safety, standards, and coding guidelines.

CMAKE_EXPORT_COMPILE_COMMANDS ON makes CMake write compile_commands.json into the build directory: one entry per source file with the exact compiler command. clangd (the language server behind most editors’ C support), clang-tidy, and many other tools read it to get include paths, defines, and target flags right. Without it, your editor parses firmware code as if it were for your desktop: it cannot find stm32f4xx.h, thinks int arithmetic behaves like x86, and underlines half the file in red. Point the tool at the build directory (clangd’s --compile-commands-dir, or a symlink from the repository root). For Make-based projects, the bear tool wraps a build and records the commands: bear -- make.

Arguments about brace placement are a waste of review time. Let a tool decide:

.clang-format
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
BreakBeforeBraces: Linux
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: Never
AlignConsecutiveMacros: Consecutive
PointerAlignment: Right
SortIncludes: Never

SortIncludes: Never is deliberate: in firmware, include order sometimes matters (a CMSIS device header before the core header, a config header before the library that reads it), and silently reordering includes can break a build or change which macro wins.

Check formatting before code ever reaches CI with a git pre-commit hook, stored in the repository so everyone shares it:

.githooks/pre-commit
#!/bin/sh
# Refuse to commit C files that clang-format would change.
files=$(git diff --cached --name-only --diff-filter=ACM -- '*.c' '*.h')
[ -z "$files" ] && exit 0
if ! clang-format --dry-run --Werror $files; then
echo "pre-commit: run 'clang-format -i' on the files above, then add them" >&2
exit 1
fi

Enable it once per clone with git config core.hooksPath .githooks. The hook checks the working-tree copy of each staged file, which is good enough in practice. Hooks are a convenience, not enforcement: anyone can skip them with git commit --no-verify, so run the same clang-format --dry-run --Werror check in CI. The pre-commit framework (a Python tool) manages hooks like this across many languages if your team wants more than one.

Firmware depends on code you did not write: CMSIS headers, vendor HALs, an RTOS, a crypto library, a test framework. Three ways to bring it in:

  • Vendoring: copy the source into third_party/ and commit it. The build is self-contained and works offline forever, and you can patch it. The cost is that updating is a manual diff, and it is easy to lose track of which upstream version and which local patches you have. Record both in a README next to the code.
  • Git submodules: the repository records a specific commit of another repository. Exact pinning with small history, but every clone needs git submodule update --init --recursive (or submodules: recursive in CI), and submodule workflows confuse people.
  • Package managers and fetchers: CMake’s FetchContent or CPM download a pinned version at configure time; Zephyr’s west manages a whole manifest of repositories (the RTOS, HALs for every vendor, modules) at fixed revisions; PlatformIO and Conan offer package registries. Convenient and declarative, but the build depends on the network and the remote still existing, so mirror what you depend on.

Most teams vendor small, stable code (Unity, a CRC table generator), use submodules or west for large external projects they track, and always pin exact versions. “Latest” is not a version.

  • Missing header dependencies. Without -MMD -MP and -include $(DEPS), editing a header does not rebuild its users. Generate dependency files; never rely on make clean to fix stale builds.
  • Forgetting CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY. CMake’s compiler test fails to link and reports the cross compiler as broken. Put it in the toolchain file.
  • Stamping __DATE__ and __TIME__. Every build differs and nothing can be reproduced. Use git describe and a build ID.
  • Shallow CI clones. git describe finds no tags and stamps a bare hash. Use fetch-depth: 0.
  • Uploading only the .bin. Without the matching ELF and map, crash addresses from the field cannot be decoded. Archive all of them.
  • Mixing float ABIs. One library built with -mfloat-abi=soft and the rest with hard fails at link time with “uses VFP register arguments”. Build everything with the same -mcpu, -mfpu and -mfloat-abi.
  • “dubious ownership” inside Docker. git refuses a mounted repository owned by another user. Add safe.directory in the image or run the container as the calling user.
  1. Create the project layout with your blinky sources, build it with the Makefile in both configurations (make and make CONFIG=release), and compare arm-none-eabi-size output and the --print-memory-usage lines. Then touch a header included by main.c, run make again, and confirm from the output that only the files including that header were recompiled.
  2. Build the same project with CMake: cmake --preset debug && cmake --build --preset debug. Open build/debug/compile_commands.json and find the full compiler command for main.c; check that the MCU flags from the toolchain file and the warning flags from project_warnings are both present. Delete the CMAKE_TRY_COMPILE_TARGET_TYPE line, reconfigure from scratch, and read the error.
  3. Tag your repository v0.1.0, build, and print fw_version at boot. Make one commit, rebuild, and check the string changes to v0.1.0-1-g...; edit a file without committing and check for -dirty. Then run the build twice with no changes and confirm from the build output that firmware.elf is not relinked the second time.
  4. Make your build reproducible: build the release preset in two separate clean clones in different directories, compare sha256sum of the two firmware.bin files, and if they differ, find out why (cmp -l on the ELFs, readelf -p .comment, strings | grep /home). Add -Wl,--build-id, keep the note in flash, print it at boot, and match it against arm-none-eabi-readelf -n.
  5. Hard: put the Dockerfile, the wrapper script and the workflow in a GitHub repository and get the pipeline green. Then add a RAM budget check (data + bss), a second build job using a matrix over the release and release-proto-b presets, and make a pull request that deliberately exceeds the flash budget to see the annotation.

Question 1:After editing a struct in sensor.h, the firmware behaves strangely until you run make clean. What is missing from the Makefile?

Question 2:CMake reports that arm-none-eabi-gcc "is not able to compile a simple test program", with an undefined reference to _exit. The compiler is fine. What fixes it?

Question 3:Why does the version rule write to a temporary file and only replace version.c when the content differs?

Question 4:CI stamps every image with just a commit hash like 9fceb02 instead of v1.4.0-3-g9fceb02, although the tags exist. What is the most likely cause?

Question 5:The CI flash budget check computes text + data from arm-none-eabi-size. Why is data included?

  • A real firmware Makefile has a per-configuration build directory, automatic header dependencies (-MMD -MP), objcopy to .bin/.hex, and flash/debug targets; sort wildcard results.
  • CMake for firmware needs a toolchain file with CMAKE_SYSTEM_NAME Generic and CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY; presets make every configuration a one-line command shared by humans and CI.
  • Know every flag you pass: the four MCU flags must match across all objects, section flags plus --gc-sections remove dead code, and the map file and --print-memory-usage tell you where the bytes went.
  • Stamp versions from git describe at build time, never from __DATE__; a content-derived build ID ties a device’s image to its archived ELF.
  • Pin the toolchain in a container image and build releases in it; reproducible builds let you prove what is running in the field.
  • CI builds every configuration with warnings as errors, runs host tests and static analysis, archives the ELF, binary and map, and enforces size budgets.

Next: Testing embedded software, the first lesson of Part 4, where the host tests wired into this pipeline grow into a full strategy from unit tests to hardware-in-the-loop.