Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,72 @@ jobs:
path: |
build/host/ccov/**/*
if-no-files-found: ignore

arm-cross-build:
name: ARM Cross-Build (Nucleo F767ZI)
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

# The same image, and the same GHA cache the host job populates, so the
# apt/toolchain layers are a cache hit rather than a second build.
- name: Set up Buildx
uses: docker/setup-buildx-action@v3

- name: Build dev image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: embedded-cpp-docker:latest
cache-from: type=gha
cache-to: type=gha,mode=max

# Both configurations, deliberately. Release is the one that matters
# here: -Os -flto with --gc-sections is where a missing KEEP on the
# vector table or a garbage-collected weak interrupt handler shows up,
# and neither is visible in a Debug build.
- name: Cross-build (Debug)
run: >
docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm
--user "$(id -u):$(id -g)" embedded-cpp-dev
cmake --workflow --preset nucleo-f767zi-debug

- name: Cross-build (Release)
run: >
docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm
--user "$(id -u):$(id -g)" embedded-cpp-dev
cmake --workflow --preset nucleo-f767zi-release

# Everything about a firmware image that can be checked without a board:
# the vector table's address, the entry point and its Thumb bit, a
# non-empty .init_array, no undefined symbols, and no allocation on the
# interrupt-handler paths. Same script a developer runs locally.
- name: Verify firmware images
run: >
docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm
--user "$(id -u):$(id -g)" embedded-cpp-dev
tools/verify-firmware.sh build/nucleo-f767zi

# Recorded, not gated. A size budget is worth having once there are
# enough data points to know what normal looks like; picking a threshold
# now would only be a number to argue with.
- name: Firmware size report
run: >
docker compose -f docker-compose.yml -f docker-compose.ci.yml run --rm
--user "$(id -u):$(id -g)" embedded-cpp-dev
sh -c 'arm-none-eabi-size -A build/nucleo-f767zi/bin/*/*.elf'

- name: Upload firmware
if: always()
uses: actions/upload-artifact@v4
with:
name: firmware-nucleo-f767zi
path: |
build/nucleo-f767zi/bin/**/*.elf
build/nucleo-f767zi/bin/**/*.bin
build/nucleo-f767zi/bin/**/*.hex
build/nucleo-f767zi/**/*.map
if-no-files-found: ignore
28 changes: 24 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ cmake --build build/host --target format-check
# Python type-check (not covered by format.sh - types are not formatting)
cd py/host-emulator && uv run mypy

# Cross-compile for ARM - not yet functional. Toolchain files and configure
# presets exist, but only the `host` MCU/board implementations do; configuring
# an ARM preset stops with a message saying the backend is not implemented.
# Host build and emulation come first; hardware follows.
# Cross-compile for the STM32F767ZI Nucleo (Cortex-M7). Configure + build only:
# firmware has no tests that run on the build machine, so there is no test step
# and no workflow preset runs ctest. CI verifies the image with readelf/nm.
cmake --workflow --preset=nucleo-f767zi-debug
cmake --workflow --preset=nucleo-f767zi-release

# Other ARM presets are toolchain-only: no arm_cm4 backend or stm32f3_discovery
# board exists yet, so configuring stops with a message naming what does.
cmake --preset=stm32f3_discovery

# Docker alternative
Expand All @@ -59,6 +63,22 @@ Application (apps/) → Board (libs/board/) → MCU (libs/mcu/) → Platfo

**Host emulation**: C++ apps communicate with Python hardware emulator via ZeroMQ/JSON IPC. This enables desktop development and integration testing without hardware.

**Platform backend contract**. `EMBEDDED_CPP_MCU` and `EMBEDDED_CPP_BOARD` each
select a sibling directory (`src/libs/mcu/<mcu>`, `src/libs/board/<board>`); an
unknown name stops the configure with the list of directories that do exist.
A board backend must define a target named **`platform_entry`** — an OBJECT
library providing `main()` and calling `app::AppMain(board::Board&)`. The apps
link it by name (`src/apps/*/CMakeLists.txt`) and know nothing else about the
platform. OBJECT rather than a static archive because pulling `main()` (or a
vector table) out of an archive depends on link-order symbol resolution and
breaks under `--gc-sections`/LTO.

Cross builds differ from the host build in three ways worth knowing: they add
`-fno-exceptions -fno-threadsafe-statics` (the host entry point is an exception
boundary by design, since cppzmq throws), they skip cppzmq/JSON/googletest and
the Python emulator, and they run without clang-tidy — clang 18 cannot parse
libstdc++-13's `<expected>`, so the fix is a newer host clang, not a workaround.

## Key Constraints

- **No exceptions** - RTTI disabled; use `std::expected<T, common::Error>` for all fallible operations
Expand Down
88 changes: 86 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,20 @@ set(EMBEDDED_CPP_MCU "host" CACHE STRING
set_property(CACHE EMBEDDED_CPP_MCU PROPERTY STRINGS host arm_cm4 arm_cm7)
set(EMBEDDED_CPP_BOARD "host" CACHE STRING
"Board implementation to build (selects src/libs/board/<value>)")
set_property(CACHE EMBEDDED_CPP_BOARD PROPERTY STRINGS host stm32f3_discovery)
set_property(CACHE EMBEDDED_CPP_BOARD PROPERTY STRINGS
host stm32f3_discovery stm32f767zi_nucleo)

# A hardware MCU backend with the host board would pull in host_board, and with
# it cppzmq and nlohmann-json -- which the dependency section below deliberately
# does not fetch when cross-compiling. Catch the mismatch here, where the cause
# is obvious, rather than as a missing-target error hundreds of lines later.
if(NOT EMBEDDED_CPP_MCU STREQUAL "host" AND EMBEDDED_CPP_BOARD STREQUAL "host")
message(FATAL_ERROR
"EMBEDDED_CPP_MCU='${EMBEDDED_CPP_MCU}' needs a matching hardware board, "
"but EMBEDDED_CPP_BOARD is still 'host'. The host board is built on "
"ZeroMQ/JSON emulation and has no hardware equivalent. Select a board "
"with -DEMBEDDED_CPP_BOARD=<board>, or use a preset that sets both.")
endif()

# One bin/ directory per build tree (with per-config subdirectories under the
# multi-config generator). A build-layout decision, so it lives here rather
Expand Down Expand Up @@ -63,6 +76,25 @@ target_include_directories(project_options INTERFACE
$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/src>)
target_compile_options(project_options INTERFACE
$<$<COMPILE_LANGUAGE:CXX>:-fno-rtti>)

# The no-exceptions policy is real on hardware and cannot be project-wide: the
# host entry point (src/libs/board/host/main.cpp) is an exception boundary by
# design, because cppzmq and nlohmann-json throw. On a cross build nothing
# links either, so -fno-exceptions costs nothing and buys the absence of
# unwind tables and landing pads. std::expected degrades accordingly --
# .value() on an error aborts instead of throwing bad_expected_access, which
# is the better failure mode on a target with no way to service an unwind.
#
# -fno-threadsafe-statics drops the __cxa_guard_acquire/release pair around
# every function-local static. Correct on a single-core target with no RTOS.
# Revisit if FreeRTOS lands (docs/PROJECT_PLAN.md Milestone 4): with two tasks
# racing through the same function-local static, this flag turns a handled
# case into a real data race.
if(CMAKE_CROSSCOMPILING)
target_compile_options(project_options INTERFACE
$<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions;-fno-threadsafe-statics>)
endif()

target_link_libraries(project_options INTERFACE project_warnings)

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -101,6 +133,18 @@ FetchContent_Declare(
GIT_TAG 0ca0fe433eb70cea0d5761079c0c5b47b736565b # v3.11.2
)

FetchContent_Declare(
cmsis_core
GIT_REPOSITORY https://github.com/STMicroelectronics/cmsis_core.git
GIT_TAG 455a49f764c3378bcbc7d611deacae58cb027cd9 # v5.9.0
)

FetchContent_Declare(
cmsis_device_f7
GIT_REPOSITORY https://github.com/STMicroelectronics/cmsis_device_f7.git
GIT_TAG 3ba5bdaf3584a6a907f854e553ee8e5b88f7677c # v1.2.9
)

FetchContent_MakeAvailable(CmakeScripts)
list(PREPEND CMAKE_MODULE_PATH "${cmakescripts_SOURCE_DIR}")

Expand All @@ -117,6 +161,25 @@ if(EMBEDDED_CPP_MCU STREQUAL "host")
endif()
endif()

# CMSIS: the Cortex-M core headers and ST's register definitions for the F7.
# Headers only -- the peripheral drivers in src/libs/mcu/arm_cm7 are written
# against these registers directly rather than against the Cube HAL. The
# register definitions are mechanical transcriptions of RM0410 and there is
# nothing to learn from retyping them; the clock ordering and bit layouts that
# the HAL would hide are exactly what this project is for.
if(EMBEDDED_CPP_MCU MATCHES "^arm_")
FetchContent_MakeAvailable(cmsis_core cmsis_device_f7)

# Neither repository ships a usable CMakeLists.txt, so wrap them here.
add_library(cmsis_f767 INTERFACE)
# SYSTEM is not optional: the vendor headers use anonymous structs and unions
# that project_warnings' -Wpedantic -Werror rejects.
target_include_directories(cmsis_f767 SYSTEM INTERFACE
"${cmsis_core_SOURCE_DIR}/Include"
"${cmsis_device_f7_SOURCE_DIR}/Include")
target_compile_definitions(cmsis_f767 INTERFACE STM32F767xx)
endif()

# ---------------------------------------------------------------------------
# Tooling: clang-tidy, coverage, formatting
# ---------------------------------------------------------------------------
Expand All @@ -139,11 +202,32 @@ endif()
# developer should get the same guard rails.
include("${PROJECT_SOURCE_DIR}/cmake/format.cmake")

# implemented_backends(): used by the mcu and board layers to report which
# platform implementations exist when an unknown one is requested.
include("${PROJECT_SOURCE_DIR}/cmake/backends.cmake")

# add_platform_artifacts(): flashable .bin/.hex beside an application's ELF on
# cross builds; a no-op on the host.
include("${PROJECT_SOURCE_DIR}/cmake/platform_artifacts.cmake")

# ---------------------------------------------------------------------------
# Targets
# ---------------------------------------------------------------------------

clang_tidy("-header-filter=${PROJECT_SOURCE_DIR}/src/.*")
# clang-tidy runs on host builds only. On a cross build it fails before seeing
# any project code: clang 18 defines __cpp_concepts as 201907L, while
# libstdc++-13's <expected> guards its contents on __cpp_concepts >= 202002L,
# so every translation unit reports "no template named 'expected' in namespace
# 'std'" -- and src/.clang-tidy sets WarningsAsErrors: "*".
#
# This is a clang limitation, not a cross-compilation one. The real fix is to
# raise the host clang pin (cmake/toolchain/host-clang.cmake) to 19 or newer,
# which defines __cpp_concepts correctly; the dev image currently ships only
# clang-18. Until then the analysis runs against the host build, which compiles
# the same portable headers, and cross builds keep clang-format only.
if(NOT CMAKE_CROSSCOMPILING)
clang_tidy("-header-filter=${PROJECT_SOURCE_DIR}/src/.*")
endif()
add_subdirectory(src)
reset_clang_tidy()

Expand Down
51 changes: 51 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@
"cacheVariables": {
"EMBEDDED_CPP_BOARD": "stm32f3_discovery"
}
},
{
"name": "nucleo-f767zi",
"displayName": "STM32F767ZI Nucleo",
"description": "Cross-compile for the STM32F767ZI Nucleo (Cortex-M7)",
"inherits": [
"arm-cm7"
],
"cacheVariables": {
"EMBEDDED_CPP_BOARD": "stm32f767zi_nucleo"
}
}
],
"buildPresets": [
Expand Down Expand Up @@ -101,6 +112,18 @@
"description": "Host build (RelWithDebInfo) using Ninja",
"inherits": "host",
"configuration": "RelWithDebInfo"
},
{
"name": "nucleo-f767zi-debug",
"displayName": "STM32F767ZI Nucleo (Debug)",
"configurePreset": "nucleo-f767zi",
"configuration": "Debug"
},
{
"name": "nucleo-f767zi-release",
"displayName": "STM32F767ZI Nucleo (Release)",
"configurePreset": "nucleo-f767zi",
"configuration": "Release"
}
],
"testPresets": [
Expand Down Expand Up @@ -203,6 +226,34 @@
"name": "host-relwithdebinfo"
}
]
},
{
"name": "nucleo-f767zi-debug",
"displayName": "STM32F767ZI Nucleo Debug (configure + build)",
"steps": [
{
"type": "configure",
"name": "nucleo-f767zi"
},
{
"type": "build",
"name": "nucleo-f767zi-debug"
}
]
},
{
"name": "nucleo-f767zi-release",
"displayName": "STM32F767ZI Nucleo Release (configure + build)",
"steps": [
{
"type": "configure",
"name": "nucleo-f767zi"
},
{
"type": "build",
"name": "nucleo-f767zi-release"
}
]
}
]
}
13 changes: 12 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,20 @@ RUN apt-get update && apt-get --no-install-recommends -y full-upgrade && apt-get
# Additional tools
libzmq3-dev \
unzip \
# ARM GCC toolchain
# ARM GCC toolchain. Pinned by the base image at 13.2.rel1, which compiles
# every portable header and app source at -std=c++23. libstdc++ 13 has no
# <print>, but the only std::println calls live in host-only translation
# units. Staying on the distro package keeps a contributor's local
# toolchain byte-identical to CI's.
gcc-arm-none-eabi \
binutils-arm-none-eabi \
# Flashing and on-chip debugging. Note these are for use from the host OS
# in the usual devcontainer setup: reaching an ST-LINK from inside the
# container needs USB passthrough that is awkward on Linux and effectively
# unavailable on macOS/Windows. The F767ZI's USB mass-storage interface
# needs no tooling at all -- copy the .bin to the NODE_F767ZI volume.
openocd \
stlink-tools \
gdb \
gdb-multiarch \
neovim \
Expand Down
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,18 @@ Application (apps/) → Board (libs/board/) → MCU (libs/mcu/) → Platfo
cmake --workflow --preset=host-debug
cmake --workflow --preset=host-release

# ARM targets - not yet functional (see Implementation Status below).
# Toolchain files and configure presets are in place, but configuring stops
# with a clear message until the MCU layer lands in src/libs/mcu/arm_cm4/
# (and arm_cm7/ for Cortex-M7 parts).
# STM32F767ZI Nucleo (Cortex-M7). Configure and build only: firmware has no
# tests that run on the build machine, so nothing here runs ctest. See
# docs/HARDWARE.md for flashing, debugging and the pin map.
cmake --workflow --preset=nucleo-f767zi-debug
cmake --workflow --preset=nucleo-f767zi-release

# Structural checks on the linked image -- vector table address, entry point,
# static-constructor array, undefined symbols. No hardware needed; CI runs it.
tools/verify-firmware.sh build/nucleo-f767zi

# Other ARM presets are toolchain-only: no arm_cm4 backend or F3 Discovery
# board exists yet, so configuring stops with a message naming what does.
cmake --preset=stm32f3_discovery
```

Expand Down Expand Up @@ -114,8 +122,10 @@ cd py/host-emulator && uv run host-emulator
| Python integration tests | ✅ Working |
| Docker/DevContainer | ✅ Working |
| CI/CD | ✅ Working |
| ARM cross-compile toolchain | 🚧 Toolchain/presets only |
| Hardware boards (STM32, nRF52) | 📋 Planned |
| ARM cross-compile (Cortex-M7) | ✅ Working |
| STM32F767ZI Nucleo: GPIO, EXTI, SysTick | ✅ Working |
| STM32F767ZI Nucleo: UART, I2C | 🚧 Placeholders that return an error |
| Other boards (STM32F3, nRF52) | 📋 Planned |

## Resources

Expand Down
25 changes: 25 additions & 0 deletions cmake/backends.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Platform-backend discovery.
#
# The MCU and board layers each select an implementation by directory name
# (EMBEDDED_CPP_MCU / EMBEDDED_CPP_BOARD). When the requested name has no
# directory, the error message should say what *is* available -- and say it
# correctly. A hand-written list drifts: it named only "host" long after
# stm32f3_discovery became a visible preset.

# Set <out_var> to a comma-separated, sorted list of the implementation
# directories under <dir>. A directory counts as an implementation only if it
# has a CMakeLists.txt, so a stray or half-deleted directory is not advertised
# as a working backend.
function(implemented_backends out_var dir)
file(GLOB entries LIST_DIRECTORIES true "${dir}/*")
set(found "")
foreach(entry IN LISTS entries)
if(IS_DIRECTORY "${entry}" AND EXISTS "${entry}/CMakeLists.txt")
cmake_path(GET entry FILENAME name)
list(APPEND found "${name}")
endif()
endforeach()
list(SORT found)
list(JOIN found ", " joined)
set(${out_var} "${joined}" PARENT_SCOPE)
endfunction()
Loading
Loading