First silicon: arm_cm7 backend and STM32F767ZI Nucleo board - #39
Merged
Conversation
Every seam below board::Board has had exactly one implementation, so the contracts a backend must satisfy were never written down and the parts that are host-specific were never separated from the parts that are not. Each of these bites on the first cross build, none of them are about ARM code: - clang-tidy ran on cross builds and could not parse them at all. clang 18 defines __cpp_concepts as 201907L; libstdc++-13's <expected> guards its contents on >= 202002L, so every translation unit reports "no template named 'expected'" and src/.clang-tidy makes warnings errors. Gate it on the host build. The fix is a newer host clang, noted where it applies. - The "Available: host" in both not-implemented messages was hand-written and already wrong: stm32f3_discovery has been a visible preset for a while. Derive the list from the directories that exist. - A cross MCU backend with EMBEDDED_CPP_BOARD still 'host' pulled in host_board, and with it the cppzmq the dependency section deliberately does not fetch when cross-compiling. Fail where the cause is legible. - -fno-exceptions is claimed in CLAUDE.md and was set nowhere. It cannot be project-wide -- the host entry point is an exception boundary by design, because cppzmq throws -- so scope it, with -fno-threadsafe-statics, to cross builds. - The apps linked a target named literally `sys`, defined only in the host board directory and documented nowhere. Rename it platform_entry and write the contract down: a board backend provides main() under that name. Also: nosys.specs so a board links before it has written its syscalls (_sbrk is not optional even with no allocation -- a vtable references its deleting destructor, which references operator delete), a .elf suffix, nucleo-f767zi presets, and the flashing tools in the dev image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_dispatch raises UnhandledMessageError for an object type that is not in the routing table, and for a name that is not among its candidates. Both escaped the serve loop, were swallowed by its `except Exception`, and took the emulator thread with them. The device, blocked in Transport::Receive on a PAIR socket, then waited out its timeout with nothing coming: a forgotten registry entry showed up as a hung test, never as an error. An unroutable message is a protocol error, not a fatal one. Log it and reply Unhandled, which Transact() folds into the error channel as common::Error::kUnhandled -- the device gets a real error at the call that caused it. The reply carries the union of the fields the three response structs deserialize, so it decodes cleanly whichever one the caller expects. Only requests are answered. A Response is the tail of an exchange the emulator itself started; replying would leave an extra frame on the socket and desynchronize every exchange after it. All four new tests fail without the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the arm_cm7 MCU backend and the Nucleo board, and takes all three applications from "does not configure" to a linked firmware image verified in both Debug and Release. No hardware behaviour yet: the peripherals are placeholders that return kInvalidOperation, so every application links from this commit and the bring-up can proceed one peripheral at a time rather than all at once. Vendor code is CMSIS headers only -- ST's register definitions for the F7 plus the Cortex-M core headers, both Apache-2.0, fetched and pinned. The drivers are written against registers directly. The Cube HAL would hide exactly the parts worth understanding (RCC enable ordering, the MODER/OTYPER/AFR layout, the EXTI/SYSCFG/NVIC chain) behind a gigabyte of middleware, while the 30k lines of register #defines it would save teach nothing that transcribing them by hand would not also risk getting wrong. startup.s is recovered from fa430af (ST, BSD-3) with two changes: its `.fpu softvfp` contradicted the command line's -mfpu=fpv5-sp-d16 and would have produced ABI-mismatch diagnostics, and `bl main` is now followed by an infinite loop rather than `bx lr`. The 110-entry vector table is why it is kept rather than rewritten. The linker script is written, not recovered. The Ac6 one in history carries a no-redistribution notice, sizes RAM at 320K (F746 numbers; the F767ZI has 512K), and discards every section of libc.a, libm.a and libgcc.a. The first word of blinky.bin is now 0x20080000 -- the top of the real 512K. Two things the first cross build found: - logger.cpp is written against <print>, which libstdc++ 13 lacks. Only host_transport links it, so it is now built for the host only; the Logger interface and NullLogger are header-only and stay available. - CMAKE_EXECUTABLE_SUFFIX is reset by CMake's platform initialization after the toolchain file runs. The per-language variables survive. tools/verify-firmware.sh checks what decides whether the chip boots -- vector table at 0x08000000, entry point is Reset_Handler with the Thumb bit, .init_array non-empty so static constructors run, no undefined symbols -- all without hardware, which is what makes it CI's job later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the pin placeholders with mcu::GpioPin against MODER/BSRR/IDR, and implements SetInterruptHandler through EXTI. blinky.cpp is unchanged and now has a complete path on hardware: LED1 toggling on the SysTick-backed delay, and LED2 lit from an interrupt handler on the button's rising edge. Two things came out of making it compile. CMSIS defines I2C1, USART3 and several hundred other bare identifiers as object-like macros, so any header that includes stm32f767xx.h rewrites matching names in every header that follows it -- board::Board::I2C1() became a syntax error the moment gpio_pin.hpp reached nucleo_board.hpp. The fix is that no public header in the backend names a vendor type: pins identify their port with mcu::GpioPort, an enum whose values are the port index (which is also the RCC_AHB1ENR bit and the SYSCFG_EXTICR value), and cmsis.hpp is included only from .cpp files. gpio_registers.hpp carries the one function whose signature needs a vendor type and says why it must not be included from a header. The interrupt-path allocation check caught its own pattern being wrong. It looked for operator delete alongside operator new, but a polymorphic class's vtable references its deleting destructor unconditionally, so every one of these objects has an undefined operator delete and always will -- which is also why _sbrk is not optional here. operator new is the symbol that means a std::function handler outgrew the small-buffer optimization, and there is none: blinky's [this] capture is 4 bytes against a 16-byte buffer, as assumed. The check now tests the claim it was meant to. Also guards mcu::Delay against being called before the board starts the tick, where waiting on Millis() would never return; it falls back to the cycle counter, so an early delay is imprecise rather than a hang. Real GPIO and EXTI cost 1744 B of flash over the placeholders (Release blinky: 2572 -> 4316 B), which is the measured price of the pin interface's virtual-inheritance diamond and its thunks. Hardware verification is still pending -- this is verified by cross-build, image layout, and symbol checks only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a second CI job building both configurations for the Nucleo and running tools/verify-firmware.sh on the result. Release is the one that earns its place: -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. The job reuses the image and GHA cache the host job populates, so the toolchain layers are a cache hit. Firmware sizes are recorded as a step and the .elf/.bin/.hex/.map uploaded as artifacts. No size budget: a threshold picked before there are data points is only a number to argue with. docs/HARDWARE.md covers the pin map, flashing, and debugging, and says plainly to flash from the host OS rather than the devcontainer -- ST-LINK USB passthrough into a container is awkward on Linux and unavailable on macOS/Windows, and the mass-storage path needs no tooling at all. README and PROJECT_PLAN said ARM was "not yet functional" and "toolchain and presets only". Both now say what is true, including which peripherals are still placeholders, and Milestone 2's remaining tasks are the ones that need the board in hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without --reset, st-flash writes the image, verifies it, reports "Go to Thumb mode" and leaves the core halted. The write succeeds in every visible way and the board does nothing, so a correct image is indistinguishable from a broken one -- which is the worst possible failure mode for the first thing anyone does with this board. Found the way these things usually are: blinky was flashed, verified, and sat there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LD1 toggles and B1 latches LD2 from the EXTI handler, running unmodified blinky.cpp -- the same source the host emulator runs, with zero changes under src/apps/. That closes Milestone 2's hardware task and makes the layered design's portability claim a demonstrated one. The button in particular exercises what static checks could not: the SYSCFG/NVIC routing, clearing EXTI->PR before dispatch, and a stored std::function actually invoked from interrupt context -- confirming at runtime the small-buffer assumption that tools/verify-firmware.sh only checks in the symbol table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
25 ON-transitions in 10 s. Each ON is a full ON/OFF/ON cycle -- two toggles -- so the cycle is 400 ms and each Delay(200ms) is 200 ms, within the ~4% resolution of counting by eye. That confirms the SysTick divisor and the 16 MHz HSI assumption both systick.cpp and delay.cpp hardcode. HARDWARE.md now states the expected count and why it is 25 rather than 50: blinks and toggles differ by a factor of two, and reading it the wrong way makes a correct board look like it is running at half speed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements mcu::Usart against the F7's USART registers and wires Uart1() to USART3 on PD8/PD9 AF7, which the Nucleo routes to the ST-LINK virtual COM port. Blocking Send and Receive poll ISR; SetRxHandler goes through the RXNE interrupt into the same handler-table pattern exti.cpp uses. UnimplementedUart is deleted. Alternate-function mode is not expressible in mcu::PinDirection, and should not be -- it is a peripheral-driver concern, not an application one. Pin configuration now goes through ConfigurePin(), which writes OSPEEDR, PUPDR, OTYPER and AFR before MODER, so a pin never spends a cycle driving through whichever peripheral AF0 happens to be. GpioPin::Configure delegates to it rather than keeping a second MODER write. nosys.specs is gone; the board provides its own syscalls. _write reaches the console USART with CRLF expansion, and _sbrk is bounded by the linker script's __heap_limit, so exhausting the heap returns ENOMEM instead of handing out addresses the stack is about to grow into. The one non-obvious piece: newlib-nano ships __malloc_lock and __malloc_unlock as `bx lr`. That is correct for a single-threaded program and wrong here, because uart_echo's receive handler builds a std::vector -- it allocates in interrupt context. A USART interrupt arriving while the main context was inside malloc would re-enter the allocator and corrupt its arena, surfacing as a fault somewhere unrelated much later. Both are now overridden to mask interrupts, counting nesting and restoring the previous PRIMASK rather than assuming it was clear. Two details worth recording. Word length counts the parity bit, so 8 data bits with parity needs a 9-bit word; Init rejects 9-plus-parity, which the peripheral cannot express. Send waits for TC, not just TXE, so returning and immediately reconfiguring cannot truncate the last character. USART3 is a CMSIS macro, so nothing in usart.hpp names a vendor type -- the instance is selected by mcu::UsartId. Same rule that gpio_port.hpp records. Hardware verification pending: builds and image checks only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A handler that echoes calls Send from interrupt context -- which is exactly what uart_echo does. If that preempts a Send already in progress, the two byte streams interleave on the wire, and the outer Send's next TDR write clears TC, so the inner one's completion wait can outlast the byte it was waiting for. Typing during the greeting is enough to hit it. Masks this USART's receive interrupt for the duration rather than all interrupts: other peripherals keep interrupting, and a byte arriving meanwhile still sets RXNE, so it is delivered when the flag is restored rather than lost. Called from the handler itself it is a no-op, which is correct -- an interrupt cannot preempt itself. Also documents two things about the terminal that look like faults and are not: local echo is off by default, so everything on screen came from the board and one "hello" for a typed "hello" is a working echo; and the greeting usually appears twice, because st-flash --reset boots the board into the ST-LINK's USB buffer and opening the port resets it again. uart_echo is verified on hardware: greeting and per-character echo at 115200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements mcu::I2CBus against the F7's v2 I2C peripheral and wires I2C1() to PB8/PB9 AF4 (Arduino D15/D14). unimplemented_peripherals.hpp is deleted: every board::Board accessor now returns real hardware. Timing is TIMINGR's five fields, not the old CCR divisor. The constant for 100 kHz from a 16 MHz I2CCLK is ST's, but the derivation is in the comment so it can be checked rather than trusted: PRESC 3 gives a 250 ns tick, SCLL 0x13 and SCLH 0x0F give 5.00 and 4.00 us, and 9 us plus rise and fall is a 10 us period. It is invalidated by raising the core clock, which is said where the constant is. Transfers use autoend, so NBYTES is programmed up front and the hardware issues STOP itself. Waits are bounded by a 25 ms timeout, because a bus held low by a stuck device never sets any completion flag -- and a NACK is reported as kOperationFailed distinctly from a timeout, since an unanswered address means a missing device rather than a wedged bus. Pins are open drain: I2C is wire-AND, and a push-pull driver would fight anything else pulling low. The board brings the bus up in Init(), unlike Uart which the application configures itself, because mcu::I2CController has no Init() in the portable interface. Hardware verification pending. Without a device on the bus the NACK path is still a real test: SendData should return kOperationFailed inside the timeout and i2c_demo should retry rather than hang. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both decisions were reachable only from an issue tracker, which is not where anyone is standing when they matter. The code now points at them from the places that would otherwise look like arbitrary choices: the two-phase construction in gpio_pin.hpp, usart.hpp and i2c.hpp, and the hardcoded TIMINGR constant. Writing #37 up surfaced that the unenforced Init() is not an I2C problem. GpioPin::Configure(), Usart::Init() and I2CBus::Init() are the same shape -- a second phase guarded by a bool and a runtime kInvalidState. The split itself is necessary, because the board is a namespace-scope object whose members are constructed before SystemInit brings clocks up; what is wrong is that the type permits the intermediate state. Also records in PROJECT_PLAN that Milestone 3's board::Board narrowing is entangled with #37: an accessor that can say "this peripheral did not come up" is exactly what the factory-based fixes there need. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the
arm_cm7MCU backend and the STM32F767ZI Nucleo board, taking theproject's central claim — a typed HAL portable across host emulation and real
silicon — from asserted to demonstrated. Before this, every interface below
board::Boardhad exactly one implementation, and no ARM MCU code had everexisted in the repository's history.
blinkyanduart_echonow run on a physical F767ZI from unmodifiedapplication source:
git diff main..HEAD -- src/apps/is empty except forone line per app adding
.bin/.hexgeneration.Verified on hardware
blinky— LD1 toggles at a measured 200 ms (25 ON-transitions in 10 s;each ON is a full cycle, so two 200 ms toggles), and pressing B1 latches LD2
from an EXTI handler. That single image confirms vector table placement,
SystemInit,.data/.bssinit,__libc_init_arrayconstructing thenamespace-scope board, SysTick,
mcu::Delay,GpioPinon MODER/BSRR/IDR,the SYSCFG→NVIC chain, and a
std::functioninvoked from an ISR withoutallocating.
uart_echo— greeting and per-character echo at 115200 over the ST-LINKvirtual COM port.
Not yet verified
i2c_demoon the board. Its NACK path needs no device attached, so it istestable as-is: an unanswered address should return
kOperationFailedinsidethe 25 ms transfer timeout and the demo should retry rather than hang.
container. This PR is what proves it.
What is here
arm_cm7:SystemInit(FPU + VTOR), a 1 kHz SysTick backingmcu::Delay,GpioPin, EXTI dispatch,Usart, andI2CBus. The Nucleo board adds a pinmap, a hand-written linker script, ST's startup assembly, and newlib syscalls.
Vendor code is CMSIS headers only — ST's register definitions and the
Cortex-M core headers, both Apache-2.0, fetched and pinned. Drivers are written
against registers directly. The Cube HAL would hide the parts worth
understanding (RCC enable ordering, MODER/OTYPER/AFR, the EXTI/SYSCFG/NVIC
chain) behind a gigabyte of middleware, while the 30k lines of register
#defines it saves teach nothing.The linker script is written rather than recovered. The Ac6 one in git history
forbids redistribution, sizes RAM at 320 K (F746 numbers — the F767ZI has
512 K), and discards every section of
libc.a,libm.aandlibgcc.a. Thefirst word of
blinky.binis0x20080000, the top of the real 512 K.Findings worth reviewing
CMSIS macros collide with the project's interface names.
stm32f767xx.hdefines
I2C1,USART3and hundreds of other bare identifiers as object-likemacros, so a header that includes it rewrites matching names in every header
after it —
board::Board::I2C1()became a syntax error. The rule now: nopublic header in
arm_cm7/names a vendor type. Ports aremcu::GpioPort,instances are
mcu::UsartId/mcu::I2CId, andcmsis.hppis.cpp-only.operator deleteis unavoidable;operator newis the real signal. Apolymorphic class's vtable references its deleting destructor regardless of
whether anything allocates — which is why
_sbrkis not optional here.tools/verify-firmware.shoriginally grepped for both and failed on its ownfirst run; it now checks
operator new, the symbol that means astd::functionhandler outgrew libstdc++'s 16-byte SBO. Confirmed absent.newlib-nano ships
__malloc_lockasbx lr. Correct for asingle-threaded program, wrong here:
uart_echo's receive handler builds astd::vector, so it allocates in interrupt context. An interrupt arrivingwhile
mainwas insidemallocwould re-enter the allocator and corrupt itsarena, faulting somewhere unrelated much later.
syscalls.cppoverrides bothto mask interrupts, counting nesting and restoring the previous PRIMASK.
Usart::Sendhad to be made atomic against its own receive handler. Anechoing handler calls
Sendfrom interrupt context; preempting aSendalready in progress interleaves the two byte streams, and the outer one's next
TDRwrite clearsTCso the inner one's completion wait outlasts its byte.It now masks just that USART's receive interrupt — bytes arriving meanwhile
still set
RXNEand are delivered when it is restored.Also in here
Part A is build-contract work that had to land first, most of it not
ARM-specific:
__cpp_conceptsas201907Lwhile libstdc++-13's<expected>guards on>= 202002L. Gated on host builds; the real fix is a newer clang pin."Available: host"in both not-implemented messages was hand-written andalready wrong. It is derived from the directories that exist now.
-fno-exceptionswas claimed inCLAUDE.mdand set nowhere. Now real, andcross-build-only — the host entry point is an exception boundary by design.
systarget, linked by name and documented nowhere, is nowplatform_entrywith its contract written down.fix(emulator):_dispatchraised on an unroutable message, the serveloop's bare
except Exceptionswallowed it, and the thread died — so aforgotten registry entry appeared as a hung test rather than an error. It now
replies
Unhandled, whichTransactfolds into the error channel. Four newtests, all failing without the fix.
Deferred, with issues
GpioPin::Configure(),Usart::Init()andI2CBus::Init()areeach an unenforced second phase guarded by a
booland a runtimekInvalidState. The two-phase split is necessary (the board is anamespace-scope object constructed before clocks are up); that the type
permits the intermediate state is not.
Both are anchored from the code rather than living only in the tracker.
Verification
verify-firmware.shchecks what decides whether the chip boots, withouthardware: vector table at
0x08000000, entry point isReset_Handlerwith theThumb bit,
.init_arraynon-empty so static constructors run, no undefinedsymbols, and no allocation on the interrupt paths. The new CI job runs it.
Release sizes: blinky 7564 B flash / 2980 B RAM, uart_echo 8296 / 2988,
i2c_demo 8384 / 2980.
🤖 Generated with Claude Code