Skip to content

refactor(arm): configure peripherals in their constructors - #41

Merged
nehalkpatel merged 1 commit into
mainfrom
refactor/configure-in-constructor
Sep 6, 2026
Merged

refactor(arm): configure peripherals in their constructors#41
nehalkpatel merged 1 commit into
mainfrom
refactor/configure-in-constructor

Conversation

@nehalkpatel

Copy link
Copy Markdown
Owner

Closes #37. Closes #38.

The premise in #37 does not hold

#37 assumed a peripheral constructor could not touch registers, because the
board is a namespace-scope object built before the clock tree is up. Checking
the actual startup path:

  • startup.s runs copy .data → zero .bss → bl SystemInit → bl __libc_init_array → bl main. Static constructors run after .data is live and SystemInit
    has returned.
  • SystemInit is ours and touches only CPACR and VTOR. No code in this
    project configures clocks at all
    — the F767 runs on HSI at 16 MHz out of
    reset, which kTiming100kHzAt16MHz and kSystemCoreClockHz already assume.
  • RCC is live from reset, and each driver enables its own peripheral clock
    first: ConfigurePin calls EnablePortClock, which sets AHB1ENR and reads
    it back so the enable lands.

So there is no barrier to be after. Two comments in the tree stated that
ordering backwards and are corrected here.

What that buys

GpioPin and I2CBus configure their hardware as they are constructed.
configured_/initialized_ and the eight kInvalidState guards they gated are
gone. NucleoF767ZiBoard's member list is now a description of the board rather
than a set of promises Init() has to keep, and Init() is left with
InitSysTick() — which needs the NVIC and so genuinely cannot run before
main().

This makes every fix #37 proposed unnecessary rather than merely unchosen. A
factory with a private constructor is the right answer given the no-registers
rule, but once that rule goes it guards a state that cannot occur — and it
forces std::optional members, which model a hardware absence that cannot
happen on a soldered-down board. A friend declaration constrains who
constructs, not who configures. A readiness gate is vacuous when declaring the
member is the bring-up.

Invariants this now rests on

Documented on NucleoF767ZiBoard, where someone adding a peripheral will meet
them:

  1. Bring-up must not fail. A constructor cannot report an error.
  2. Bring-up must not call mcu::Delay. InitSysTick() runs from Init(),
    after main().
  3. This board must stay the only object with a dynamic initializer. Order
    within it is declaration order; order across translation units is not.

mcu::Usart violates the first and stays two-phase — which suits it, since only
the application knows its UartConfig and Init() validates it and can fail.

Also in here

  • I2C bus speed should be chosen by the board, not hardcoded in the driver #38: bus speed is a constructor argument with a derived TIMINGR table,
    deliberately undefaulted so a board must name the rate its wiring can carry.
    Both values are checked against the I2C-bus rev.03 limits rather than copied
    from ST's spreadsheet. The old comment credited a table in AN4235 that does
    not exist — that document has four tables, none of them per-frequency, and it
    covers F0/F3 rather than F7.
  • arm_cm7's first test. Its public headers name no vendor type, so
    test_peripheral_contract.cpp compiles on the host whichever backend is
    selected, and asserts that a pin cannot be built without a direction nor a bus
    without a speed. It links no arm_cm7 library and no CMSIS. It proves the
    types, not the register writes.
  • Host symmetry. HostPin takes its direction at construction. HostBoard
    still builds peripherals in Init(), which is a real difference and not an
    oversight: an emulated peripheral depends on a socket that can fail, a real
    one only on registers that are always there.

Verification

Check Result
nucleo-f767zi-debug / -release green
host-debug / host-release 33/33 (was 32 — the new contract test)
host_emulator_test passed
tools/format.sh --check, uv run mypy clean
board.hpp, src/apps/ unchanged

Flash drops 100–152 bytes per image and .bss by 4. .init_array still holds
exactly crtbegin.o's entry plus the board — invariant 3 intact.

The contract test was checked negatively: reintroducing a two-argument GpioPin
constructor fails the host build on the static_assert.

Not verified

  • No hardware boot. Blinky's period, button→LD2, an I2C round trip and
    uart_echo on /dev/ttyACM0 all still need a physical board.
  • kFast400kHz is unexercised. The arithmetic clears the fast-mode minima
    with margin, but nothing in the tree selects it and it has never been on a
    scope. Reasonable to drop it until something needs it.

🤖 Generated with Claude Code

GpioPin and I2CBus now bring their hardware up as they are constructed.
configured_/initialized_ and the eight kInvalidState guards they gated are
gone, NucleoF767ZiBoard's member list is a description of the board rather
than a set of promises Init() has to keep, and Init() is left with
InitSysTick(), which needs the NVIC and so genuinely cannot run earlier.

#37 assumed a constructor could not touch registers, because the board is a
namespace-scope object built before the clock tree is up. That does not hold.
Reset_Handler copies .data, zeroes .bss and calls SystemInit *before*
__libc_init_array; SystemInit only enables the FPU and sets VTOR; no code here
configures clocks at all, since the F767 runs on HSI at 16 MHz out of reset,
which every timing constant already assumes; and RCC is live from reset, with
each driver enabling its own peripheral clock first. Two comments stated that
ordering backwards and are corrected here.

That makes the fixes #37 proposed unnecessary. A factory with a private
constructor is the right answer *given* the no-registers rule, but once the
rule goes it guards a state that cannot occur -- and it forces std::optional
members, which model a hardware absence that cannot happen on a fixed board.
A friend declaration constrains who constructs, not who configures. A readiness
gate is vacuous when declaring the member is the bring-up.

Three invariants hold this up, documented on NucleoF767ZiBoard: bring-up must
not fail, must not call mcu::Delay (SysTick is not up yet), and this board must
stay the only object with a dynamic initializer. mcu::Usart violates the first
and stays two-phase, which suits it -- only the application knows its
UartConfig, and Init() validates it and can fail.

Bus speed becomes a constructor argument with a derived TIMINGR table,
deliberately undefaulted so a board must name the rate its wiring can carry.
Both values are checked against the I2C-bus rev.03 limits rather than copied
from ST's spreadsheet; the previous comment credited a table in AN4235 that
does not exist, and that document covers F0/F3 rather than F7.

arm_cm7 gains its first test. Its public headers name no vendor type, so
test_peripheral_contract.cpp compiles on the host whichever backend is selected
and asserts that a pin cannot be built without a direction, nor a bus without a
speed. It proves the types, not the register writes.

HostPin takes its direction at construction to match. HostBoard still builds
its peripherals in Init(), which is a real difference and not an oversight: an
emulated peripheral depends on a socket that can fail, a real one only on
registers that are always there.

board::Board and both applications are untouched. Flash drops 100-152 bytes per
image and .bss by 4; .init_array still holds exactly crtbegin's entry plus the
board.

Not yet verified on hardware: blinky, the button, an I2C round trip and
uart_echo on /dev/ttyACM0 all still need a physical boot. kFast400kHz is
likewise unexercised -- nothing selects it today.

Closes #37
Closes #38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nehalkpatel
nehalkpatel merged commit 5870413 into main Sep 6, 2026
2 checks passed
@nehalkpatel
nehalkpatel deleted the refactor/configure-in-constructor branch September 6, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant