Skip to content

fix(arm): refuse bounded waits before the tick, and name the boot stages - #42

Open
nehalkpatel wants to merge 3 commits into
mainfrom
refactor/boot-flow
Open

fix(arm): refuse bounded waits before the tick, and name the boot stages#42
nehalkpatel wants to merge 3 commits into
mainfrom
refactor/boot-flow

Conversation

@nehalkpatel

Copy link
Copy Markdown
Owner

Follow-on to #41. Adds docs/BOOT_FLOW.md, closes a latent hang, and corrects
an invariant #41 stated wrongly.

The bug

Millis() does not advance until InitSysTick() runs in Board::Init(), so
(Millis() - start) > timeout can never become true before that point.
I2CBus's flag wait and Usart::Receive both compared against that frozen
counter with no guard — a bus held low before the tick would spin forever,
with the timeout silently doing nothing.
Both now return kInvalidState.

Unreachable today: both are called only after Board::Init() has run. It
becomes reachable the moment anything talks to a device during bring-up, which
is exactly what documenting an off-chip bring-up stage invites — so the guard
and the document belong together.

The design

A peripheral's stage is decided by what can fail, not by what is convenient:

  • On-chip bring-up is register writes against hardware soldered into the
    die. Nothing can be absent, nothing can time out. It cannot fail, so it lives
    in a constructor — which is what makes an unconfigured peripheral unreachable.
  • Off-chip bring-up is I/O across a wire, where absence and silence are
    normal outcomes. It needs somewhere that can report one: Board::Init().

That structure already existed; the contract was what was missing.
board::Board now states it where a reader meets it, and docs/BOOT_FLOW.md
carries the verified reset sequence, the stage table, a decision tree for
placing a new peripheral, and worked examples for an I2C sensor, SPI flash, a
UART-attached radio and an ADC sensor.

The ADC is the case that shows the split is not "sensor or not": the ADC
peripheral is stage 1, the sensor wired to it is stage 2.

Correction to #41

mcu::Delay does work before the tick — delay.cpp falls back to a
DWT->CYCCNT spin — so #41's "bring-up must not call mcu::Delay" was wrong.
The real boundary is narrower and sharper: a stage 1 constructor may wait, but
may not talk
, because every real transfer needs a timeout.

Deliberately not done

Both written down so the next step is a decision rather than a drift:

  • A timebase valid from reset (Micros() on DWT->CYCCNT) would make
    timeouts work in every stage and dissolve this boundary entirely. Worth doing
    when a stage 1 peripheral needs a bounded wait — not before.
  • Explicit init levels (Zephyr's PRE_KERNEL_1 / POST_KERNEL) would
    replace the two-phase split. Trigger: two off-chip devices with an ordering
    constraint between them. Until then it is a framework guarding a state that
    has not occurred.

Known gaps, recorded not solved

  • common::Error carries no payload, so Board::Init() cannot name which
    device failed. This lands before stage 2 has real occupants.
  • There is nowhere to report a bring-up failure to — the console is a stage
    3 resource the application brings up.
  • An RTOS will want SysTick for itself, and will likely revisit this ordering.

Verification

Check Result
nucleo-f767zi-debug / -release green
host-debug / host-release 33/33
tools/format.sh --check clean
Doc links in BOOT_FLOW.md all resolve

Costs 236 bytes of .text on i2c_demo for the guards. No hardware boot —
the guarded paths are unreachable without a device on the bus, so this is
build-and-host evidence only.

🤖 Generated with Claude Code

nehalkpatel and others added 2 commits September 7, 2026 16:11
Millis() does not advance until InitSysTick() runs in Board::Init(), so
`(Millis() - start) > timeout` could never become true before that point.
I2CBus's flag wait and Usart::Receive both compared against that frozen
counter with no guard: a bus held low before the tick would have spun
forever, with the timeout silently doing nothing. Both now return
kInvalidState instead.

Unreachable today, since both are called only after Board::Init() has run.
It becomes reachable the moment anything talks to a device during bring-up,
which is exactly what documenting an off-chip bring-up stage invites.

docs/BOOT_FLOW.md is that documentation. The organising idea is that a
peripheral's stage is decided by what can fail, not by what is convenient:

  - On-chip bring-up is register writes against hardware soldered into the
    die. Nothing can be absent and nothing can time out, so it cannot fail,
    and it lives in a constructor -- which is what makes an unconfigured
    peripheral unreachable.
  - Off-chip bring-up is I/O across a wire, where absence and silence are
    normal outcomes. It needs somewhere that can report one: Board::Init().

That structure already existed; what was missing was the contract, which
board::Board now states where a reader meets it. The ADC is the case that
shows the split is not "sensor or not" -- the peripheral is stage 1, the
sensor wired to it is stage 2.

Also corrects an invariant from the previous commit. mcu::Delay *does* work
before the tick -- delay.cpp falls back to a DWT->CYCCNT spin -- so "bring-up
must not call mcu::Delay" was wrong. The real boundary is narrower: a stage 1
constructor may wait, but may not talk, because every real transfer needs a
timeout.

Two follow-ups are written down rather than taken. A timebase valid from
reset (Micros() on DWT->CYCCNT) would make timeouts work in every stage and
dissolve this boundary; it is worth doing when a stage 1 peripheral needs a
bounded wait. Explicit init levels in the style of Zephyr's PRE_KERNEL_1 /
POST_KERNEL would replace the two-phase split; the trigger is two off-chip
devices with an ordering constraint between them, and until then it would be
a framework guarding a state that has not occurred.

Known gaps recorded, not solved: common::Error has no payload, so
Board::Init() cannot name which device failed; there is nowhere to report a
bring-up failure to, since the console is a stage 3 resource; and an RTOS
will want SysTick for itself.

Costs 236 bytes of .text on i2c_demo for the guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three commits overtook it. UART and I2C were still listed as placeholders that
return an error, the architecture section said hardware boards were planned,
and the technology stack named host emulation as the only target.

The status table now distinguishes implemented from verified, which is a
distinction this project should be making: GPIO/EXTI/SysTick and USART3 have
been confirmed on the physical board, I2C has not. That matches the open task
in PROJECT_PLAN.md's Milestone 2 rather than quietly claiming more.

Also adds a Documentation section. docs/ has three files and the README linked
none of them except in passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two gaps BOOT_FLOW.md recorded now have issues. Point at them, so the
trigger for each is one click from the document that describes it -- the same
treatment 4c141fd gave the peripheral-init decisions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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