Skip to content

First silicon: arm_cm7 backend and STM32F767ZI Nucleo board - #39

Merged
nehalkpatel merged 12 commits into
mainfrom
feat/arm-cm7-nucleo-f767zi
Sep 4, 2026
Merged

First silicon: arm_cm7 backend and STM32F767ZI Nucleo board#39
nehalkpatel merged 12 commits into
mainfrom
feat/arm-cm7-nucleo-f767zi

Conversation

@nehalkpatel

Copy link
Copy Markdown
Owner

Adds the arm_cm7 MCU backend and the STM32F767ZI Nucleo board, taking the
project's central claim — a typed HAL portable across host emulation and real
silicon — from asserted to demonstrated. Before this, every interface below
board::Board had exactly one implementation, and no ARM MCU code had ever
existed in the repository's history.

blinky and uart_echo now run on a physical F767ZI from unmodified
application source
: git diff main..HEAD -- src/apps/ is empty except for
one line per app adding .bin/.hex generation.

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/.bss init, __libc_init_array constructing the
    namespace-scope board, SysTick, mcu::Delay, GpioPin on MODER/BSRR/IDR,
    the SYSCFG→NVIC chain, and a std::function invoked from an ISR without
    allocating.
  • uart_echo — greeting and per-character echo at 115200 over the ST-LINK
    virtual COM port.

Not yet verified

  • i2c_demo on the board. Its NACK path needs no device attached, so it is
    testable as-is: an unanswered address should return kOperationFailed inside
    the 25 ms transfer timeout and the demo should retry rather than hang.
  • The ARM CI job, which has never executed — there is no Docker in the dev
    container. This PR is what proves it.

What is here

arm_cm7: SystemInit (FPU + VTOR), a 1 kHz SysTick backing mcu::Delay,
GpioPin, EXTI dispatch, Usart, and I2CBus. The Nucleo board adds a pin
map, 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.a and libgcc.a. The
first word of blinky.bin is 0x20080000, the top of the real 512 K.

Findings worth reviewing

CMSIS macros collide with the project's interface names. stm32f767xx.h
defines I2C1, USART3 and hundreds of other bare identifiers as object-like
macros, so a header that includes it rewrites matching names in every header
after it — board::Board::I2C1() became a syntax error. The rule now: no
public header in arm_cm7/ names a vendor type. Ports are mcu::GpioPort,
instances are mcu::UsartId/mcu::I2CId, and cmsis.hpp is .cpp-only.

operator delete is unavoidable; operator new is the real signal. A
polymorphic class's vtable references its deleting destructor regardless of
whether anything allocates — which is why _sbrk is not optional here.
tools/verify-firmware.sh originally grepped for both and failed on its own
first run; it now checks operator new, the symbol that means a
std::function handler outgrew libstdc++'s 16-byte SBO. Confirmed absent.

newlib-nano ships __malloc_lock as bx lr. Correct for a
single-threaded program, wrong here: uart_echo's receive handler builds a
std::vector, so it allocates in interrupt context. An interrupt arriving
while main was inside malloc would re-enter the allocator and corrupt its
arena, faulting somewhere unrelated much later. syscalls.cpp overrides both
to mask interrupts, counting nesting and restoring the previous PRIMASK.

Usart::Send had to be made atomic against its own receive handler. An
echoing handler calls Send from interrupt context; preempting a Send
already in progress interleaves the two byte streams, and the outer one's next
TDR write clears TC so the inner one's completion wait outlasts its byte.
It now masks just that USART's receive interrupt — bytes arriving meanwhile
still set RXNE and 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:

  • clang-tidy could not parse cross builds at all — clang 18 defines
    __cpp_concepts as 201907L while libstdc++-13's <expected> guards on
    >= 202002L. Gated on host builds; the real fix is a newer clang pin.
  • The "Available: host" in both not-implemented messages was hand-written and
    already wrong. It is derived from the directories that exist now.
  • -fno-exceptions was claimed in CLAUDE.md and set nowhere. Now real, and
    cross-build-only — the host entry point is an exception boundary by design.
  • The sys target, linked by name and documented nowhere, is now
    platform_entry with its contract written down.
  • fix(emulator): _dispatch raised on an unroutable message, the serve
    loop's bare except Exception swallowed it, and the thread died — so a
    forgotten registry entry appeared as a hung test rather than an error. It now
    replies Unhandled, which Transact folds into the error channel. Four new
    tests, all failing without the fix.

Deferred, with issues

Both are anchored from the code rather than living only in the tracker.

Verification

cmake --workflow --preset=host-debug            # 32/32
cmake --workflow --preset=nucleo-f767zi-debug
cmake --workflow --preset=nucleo-f767zi-release
tools/verify-firmware.sh build/nucleo-f767zi    # 6 images

verify-firmware.sh checks what decides whether the chip boots, without
hardware: vector table at 0x08000000, entry point is Reset_Handler with the
Thumb bit, .init_array non-empty so static constructors run, no undefined
symbols, 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

nehalkpatel and others added 12 commits September 4, 2026 05:10
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>
@nehalkpatel
nehalkpatel merged commit 4c141fd into main Sep 4, 2026
2 checks passed
@nehalkpatel
nehalkpatel deleted the feat/arm-cm7-nucleo-f767zi branch September 4, 2026 15:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant