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.
The project layout
Section titled “The project layout”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-commitA complete Makefile
Section titled “A complete Makefile”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 - STM32F407 firmware, plain GNU makeTARGET := firmwareCONFIG ?= debugBUILD := build/make-$(CONFIG)
CROSS ?= arm-none-eabi-CC := $(CROSS)gccOBJCOPY := $(CROSS)objcopySIZE := $(CROSS)sizeGDB := $(CROSS)gdb
SRCS := $(sort $(wildcard src/*.c))OBJS := $(SRCS:%.c=$(BUILD)/%.o) $(BUILD)/version.oDEPS := $(OBJS:.o=.d)
MCU := -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16
ifeq ($(CONFIG),release) OPT := -Os -g -DNDEBUGelse ifeq ($(CONFIG),debug) OPT := -Og -g3else $(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 -MPLDFLAGS := $(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.?=(forCONFIGandCROSS) only assigns if the variable is not already set, somake CONFIG=releaseormake CROSS=/opt/arm/bin/arm-none-eabi-override them from the command line. $(sort $(wildcard src/*.c))finds the sources.sortmatters for reproducibility:wildcardreturns files in directory order, which differs between file systems, and link order affects the binary.$(SRCS:%.c=$(BUILD)/%.o)mapssrc/main.ctobuild/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 -MPmake the compiler write a.dfile 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.-MPadds dummy targets for headers so deleting a header does not break the build.- The pattern rule
$(BUILD)/%.o: %.ccompiles any source into the build tree.$<is the first prerequisite,$@the target,$(@D)the target’s directory, whichmkdir -pcreates. - The ELF depends on
link.ld, so editing the linker script relinks. %.binand%.hexare produced from the ELF withobjcopy. The raw binary is what bootloaders and many flashing tools want; Intel HEX carries addresses and a checksum per line..PHONYdeclares targets that are not files, so a file accidentally namedcleancannot stopmake cleanfrom running.flashanddebugare the targets you will type all day.debugexpects 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.
Version stamping
Section titled “Version stamping”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:
#ifndef VERSION_H#define VERSION_H
extern const char fw_version[]; /* generated at build time from git describe */
#endifThe 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.
Every flag explained
Section titled “Every flag explained”| Flag | What it does | Why firmware needs it |
|---|---|---|
-mcpu=cortex-m4 | Generate code for the M4 and tune for its pipeline | Enables the M4’s instructions (hardware divide, DSP); wrong CPU means UsageFaults or slow code |
-mthumb | Thumb instruction set | Cortex-M executes only Thumb; required |
-mfloat-abi=hard | Pass float arguments in FPU registers | Fastest; every object and library must use the same ABI or the link fails |
-mfpu=fpv4-sp-d16 | The M4F’s single-precision FPU | Without it, floats use software routines |
-ffunction-sections, -fdata-sections | One section per function and per variable | Lets the linker drop unused code and data |
-Wl,--gc-sections | Linker removes unreferenced sections | Often saves 20 to 50% of an image that uses part of a library |
-fno-builtin | Do not treat standard function names specially | Needed 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-usage | Write a .su file with each function’s stack frame size | Input for worst-case stack analysis, as in Memory: where everything lives |
-g3 | Debug info including macro definitions | gdb can expand GPIOD and RCC_AHB1ENR_GPIODEN; costs nothing on the chip |
-Og | Optimise without hurting debuggability | Development builds that still run at a realistic speed |
-Os / -O2 | Size or speed optimisation | Release builds; see Performance and code size optimization |
-Wl,-Map=file.map | Write the link map | Where every byte went, and why each library member was linked |
-Wl,--print-memory-usage | Print flash and RAM use per memory region at link time | Instant feedback on how full the chip is |
--specs=nano.specs | Link newlib-nano instead of full newlib | Much smaller printf, malloc and stdio |
--specs=nosys.specs | Link stub system calls (_write, _sbrk, …) that return errors | Links without an OS; you override the ones you need, as in The C standard library on a microcontroller |
-nostartfiles | Do 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=new | Rewrite source paths in debug info and __FILE__ | Reproducible builds and shorter assert strings |
-MMD -MP | Emit dependency files | Correct 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.
CMake for firmware
Section titled “CMake for firmware”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.
The toolchain file
Section titled “The toolchain file”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:
# 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 Genericmeans “no operating system”. Setting it also tells CMake it is cross-compiling (CMAKE_CROSSCOMPILINGbecomes true).CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARYis 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
_INITvariables 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 (-O3for Release is a poor default for firmware). CMAKE_FIND_ROOT_PATH_MODE_*stopfind_libraryandfind_packagefrom picking up the host’s x86 libraries.
CMakeLists.txt
Section titled “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:
# Run at build time: cmake -DOUT=<file> -DSRC_DIR=<dir> -P version.cmakeexecute_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.
CMakePresets.json
Section titled “CMakePresets.json”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:
{ "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:
cmake --preset releasecmake --build --preset releasecmake --preset host-tests && cmake --build --preset host-tests && ctest --preset host-testsarm-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.
Reproducible builds
Section titled “Reproducible builds”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-timewarns if anything does. (GCC also honours theSOURCE_DATE_EPOCHenvironment 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
wildcardresults change link order. Sort them, or list sources explicitly as the CMake file does. - Archive metadata.
arcan store timestamps and user IDs in static libraries. Deterministic mode (ar D, the default in most current distributions’ binutils) zeroes them. - Uncommitted changes. The
-dirtysuffix fromgit describemakes 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)) } > FLASHThe 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.
A pinned toolchain in Docker
Section titled “A pinned toolchain in Docker”“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.
# The one place the toolchain version is defined.FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractiveRUN 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 /workThe 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:
#!/bin/sh# Run a command inside the toolchain image with the repository mounted at /work.set -euexec 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”.
Continuous integration
Section titled “Continuous integration”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.
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 fiStep by step:
fetch-depth: 0checks out the full history with tags. The default shallow clone has no tags, sogit describewould 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-supprlets 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.
outputOnFailurein the test preset prints a failing test’s output into the CI log. - Firmware release build uses the
releasepreset, which setsFW_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:
sizeprintstext data bss dec hex filename; flash usage istext + data(the initial values of.datalive 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 withdata + 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.
Static analysis in more depth
Section titled “Static analysis in more depth”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-tidyfile. It needs to know how each file is compiled, which it reads fromcompile_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 toCMAKE_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.
Compile databases for your editor
Section titled “Compile databases for your editor”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.
Formatting and pre-commit hooks
Section titled “Formatting and pre-commit hooks”Arguments about brace placement are a waste of review time. Let a tool decide:
BasedOnStyle: LLVMIndentWidth: 4ColumnLimit: 100BreakBeforeBraces: LinuxAllowShortFunctionsOnASingleLine: NoneAllowShortIfStatementsOnASingleLine: NeverAlignConsecutiveMacros: ConsecutivePointerAlignment: RightSortIncludes: NeverSortIncludes: 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:
#!/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 0if ! clang-format --dry-run --Werror $files; then echo "pre-commit: run 'clang-format -i' on the files above, then add them" >&2 exit 1fiEnable 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.
Managing dependencies
Section titled “Managing dependencies”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(orsubmodules: recursivein CI), and submodule workflows confuse people. - Package managers and fetchers: CMake’s
FetchContentor CPM download a pinned version at configure time; Zephyr’swestmanages 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.
Common mistakes
Section titled “Common mistakes”- Missing header dependencies. Without
-MMD -MPand-include $(DEPS), editing a header does not rebuild its users. Generate dependency files; never rely onmake cleanto 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. Usegit describeand a build ID. - Shallow CI clones.
git describefinds no tags and stamps a bare hash. Usefetch-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=softand the rest withhardfails at link time with “uses VFP register arguments”. Build everything with the same-mcpu,-mfpuand-mfloat-abi. - “dubious ownership” inside Docker. git refuses a mounted repository owned by another user. Add
safe.directoryin the image or run the container as the calling user.
Exercises
Section titled “Exercises”- Create the project layout with your blinky sources, build it with the Makefile in both configurations (
makeandmake CONFIG=release), and comparearm-none-eabi-sizeoutput and the--print-memory-usagelines. Then touch a header included bymain.c, runmakeagain, and confirm from the output that only the files including that header were recompiled. - Build the same project with CMake:
cmake --preset debug && cmake --build --preset debug. Openbuild/debug/compile_commands.jsonand find the full compiler command formain.c; check that the MCU flags from the toolchain file and the warning flags fromproject_warningsare both present. Delete theCMAKE_TRY_COMPILE_TARGET_TYPEline, reconfigure from scratch, and read the error. - Tag your repository
v0.1.0, build, and printfw_versionat boot. Make one commit, rebuild, and check the string changes tov0.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 thatfirmware.elfis not relinked the second time. - Make your build reproducible: build the release preset in two separate clean clones in different directories, compare
sha256sumof the twofirmware.binfiles, and if they differ, find out why (cmp -lon the ELFs,readelf -p .comment,strings | grep /home). Add-Wl,--build-id, keep the note in flash, print it at boot, and match it againstarm-none-eabi-readelf -n. - 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 thereleaseandrelease-proto-bpresets, and make a pull request that deliberately exceeds the flash budget to see the annotation.
Check yourself
Section titled “Check yourself”Question 1:After editing a struct in sensor.h, the firmware behaves strangely until you run make clean. What is missing from the Makefile?
Without dependency files, make only knows that sensor.o depends on sensor.c. Files that include the changed header are not recompiled and disagree about the struct layout.
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?
CMake's compiler check links a test executable, which fails on bare metal without a linker script and system call stubs. Building a static library skips the link.
Question 3:Why does the version rule write to a temporary file and only replace version.c when the content differs?
The rule runs on every build. Rewriting the file unconditionally would update its timestamp each time and relink the firmware even when nothing changed.
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?
The default CI checkout fetches a single commit without tags, so git describe --always falls back to the abbreviated hash.
Question 5:The CI flash budget check computes text + data from arm-none-eabi-size. Why is data included?
.data occupies RAM at run time, but its initial values must be stored somewhere non-volatile. They live in flash at the section's load address and are copied by the startup code.
Key takeaways
Section titled “Key takeaways”- A real firmware Makefile has a per-configuration build directory, automatic header dependencies (
-MMD -MP),objcopyto.bin/.hex, andflash/debugtargets; sort wildcard results. - CMake for firmware needs a toolchain file with
CMAKE_SYSTEM_NAME GenericandCMAKE_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-sectionsremove dead code, and the map file and--print-memory-usagetell you where the bytes went. - Stamp versions from
git describeat 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.