Skip to content
Open
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
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ Application (apps/) → Board (libs/board/) → MCU (libs/mcu/) → Platfo
```

- **apps/**: Example applications (blinky, uart_echo, i2c_demo)
- **libs/mcu/**: Hardware abstractions (Pin, UART, I2C, Delay) with host emulation
- **libs/board/**: Board-specific implementations (host today; hardware boards planned)
- **libs/mcu/**: Hardware abstractions (Pin, UART, I2C, Delay), with a host
emulation backend and a Cortex-M7 one
- **libs/board/**: Board-specific implementations (the host emulator and the
STM32F767ZI Nucleo)
- **py/host-emulator/**: Python hardware simulator for desktop testing

## Build Commands
Expand Down Expand Up @@ -103,7 +105,7 @@ cd py/host-emulator && uv run host-emulator
| Compilers | Clang 18 (host), ARM GCC (embedded) |
| Testing | Google Test, pytest |
| IPC | ZeroMQ + JSON |
| Targets | Host emulation (hardware targets planned) |
| Targets | Host emulation, STM32F767ZI Nucleo (Cortex-M7) |

## Code Quality

Expand All @@ -123,10 +125,19 @@ cd py/host-emulator && uv run host-emulator
| Docker/DevContainer | ✅ Working |
| CI/CD | ✅ Working |
| ARM cross-compile (Cortex-M7) | ✅ Working |
| STM32F767ZI Nucleo: GPIO, EXTI, SysTick | ✅ Working |
| STM32F767ZI Nucleo: UART, I2C | 🚧 Placeholders that return an error |
| STM32F767ZI Nucleo: GPIO, EXTI, SysTick | ✅ Verified on hardware |
| STM32F767ZI Nucleo: UART (USART3 on the ST-LINK VCP) | ✅ Verified on hardware |
| STM32F767ZI Nucleo: I2C | ✅ Implemented, not yet verified on hardware |
| Other boards (STM32F3, nRF52) | 📋 Planned |

## Documentation

- [docs/BOOT_FLOW.md](docs/BOOT_FLOW.md) — how a board comes up, and which
stage a new peripheral belongs to
- [docs/HARDWARE.md](docs/HARDWARE.md) — flashing, debugging and the pin map
- [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md) — milestones, and a decision log
recording why things are the way they are

## Resources

- [Correct-by-Construction](https://youtu.be/nLSm3Haxz0I)
Expand Down
137 changes: 137 additions & 0 deletions docs/BOOT_FLOW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Boot Flow

How an application on this project gets from reset to running, and — the part
that matters when you add hardware — **which stage your new peripheral belongs
to and why**.

The short version: a peripheral's stage is decided by *what can fail*, not by
what is convenient.

## The sequence

Verified against the code rather than assumed; the ordering here is the sort of
thing that is easy to state backwards, and this repo has done so twice.

| # | Where | What happens |
|---|---|---|
| 1 | [`startup.s`](../src/libs/board/stm32f767zi_nucleo/startup.s) | `sp = _estack`, copy `.data` from flash, zero `.bss` |
| 2 | `bl SystemInit` → [`cortex_m7.cpp`](../src/libs/mcu/arm_cm7/cortex_m7.cpp) | Enable the FPU (`CPACR`), point `VTOR` at flash. **Core registers only.** |
| 3 | `bl __libc_init_array` | Static constructors run. `g_board` is built, and with it every on-chip peripheral. |
| 4 | `bl main` → [`main.cpp`](../src/libs/board/stm32f767zi_nucleo/main.cpp) | `app::AppMain(g_board)` → `RunApp<App>` |
| 5 | `Board::Init()` | `InitSysTick()`, then off-chip bring-up |
| 6 | `App::Init()` then `App::Run()` | Application. `Uart::Init` lives here. `Run()` never returns. |

Two consequences that are easy to get wrong:

- **`SystemInit` runs *after* `.data` and `.bss` are ready**, not before. It is
restricted to core registers because that is all it needs, not because memory
is unusable.
- **No code in this project configures clocks.** The F767 runs on HSI at 16 MHz
out of reset and stays there. `RCC` is live from reset, and each driver
enables its own peripheral clock as its first action — which is what makes
step 3 able to touch registers at all.

## The stages

| Stage | Runs at | Nature | Can fail? | Timeouts work? |
|---|---|---|---|---|
| 0 — Core | step 2 | Core registers | No | No |
| 1 — On-chip | step 3 | Register writes, no dependencies | **No** | **No** |
| 2 — Off-chip | step 5 | I/O across a wire | **Yes, routinely** | **Yes** |
| 3 — Runtime | step 6 | Application, interrupts | Yes | Yes |

### Why "can it fail" is the dividing line

Bringing up an on-chip peripheral is a fixed sequence of register writes against
hardware that is soldered into the die. There is nothing to be absent, nothing
to time out, nothing to report. That is what lets it happen in a constructor —
and a constructor is worth having, because it means an unconfigured peripheral
cannot be reached. See the 2026-09-06 decision-log entry in
[`PROJECT_PLAN.md`](PROJECT_PLAN.md).

Bringing up an off-chip device is I/O. The device may be unpopulated, wrongly
strapped, held in reset, or simply slow. Failure is a *normal outcome*, so it
needs somewhere that can report one — which a constructor is not.

### The timebase is the hard boundary

`mcu::Millis()` does not advance until `InitSysTick()` runs in stage 2. Before
that:

- **`mcu::Delay` works.** It checks `SysTickRunning()` and falls back to a
`DWT->CYCCNT` busy-wait ([`delay.cpp`](../src/libs/mcu/arm_cm7/delay.cpp)). An
early delay is imprecise and burns cycles, but it is not a hang.
- **Bounded waits do not.** `(Millis() - start) > timeout` can never become true
against a frozen counter, so a naive timeout loop spins forever. The drivers
therefore *refuse* one this early — `I2CBus`'s flag wait and
`Usart::Receive` with a non-zero timeout both return `kInvalidState` when the
tick is not running, rather than hanging.

So a stage-1 constructor may wait, but it may not *talk to anything*, because
every real transfer needs a timeout. That is the whole reason off-chip bring-up
is stage 2.

## Where does my peripheral go?

```
Is it inside the MCU?
├─ Yes → Stage 1. A board member; its constructor configures it.
│ GPIO, I2C/SPI/UART controllers, ADC, timers.
│ Must not fail. Must not do I/O.
└─ No, it is across a wire → Stage 2. Brought up in Board::Init().
Sensors, flash chips, radios, anything with its own part number.
May fail, and should say so.
```

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

### Worked examples

| Device | Stage 1 (constructor) | Stage 2 (`Board::Init()`) |
|---|---|---|
| I2C temp/humidity sensor | `I2CBus` on its pins | Probe the device, read its ID, configure sampling |
| SPI NOR flash | `SpiBus` + a chip-select `GpioPin` | Read the JEDEC ID, verify capacity |
| Bluetooth module on UART | `Usart` on its pins, reset-line `GpioPin` | Release reset, wait, exchange a command, check the reply |
| ADC-based sensor | The ADC peripheral | Reference/calibration; the reading itself is stage 3 |

`mcu::Usart` is the exception in the table above and worth understanding: it is
on-chip but still two-phase, because only the *application* knows the
`UartConfig` it wants, and `Init()` validates that config and can fail. Being
on-chip makes a constructor *possible*, not mandatory.

## Known gaps

Named because they are load-bearing for what comes next, not because they block
anything today.

- **`Board::Init()` cannot say which device failed** (#43). It returns
`std::expected<void, common::Error>`, and `common::Error` carries no payload.
With one off-chip device that is tolerable; with four it is not. This needs
solving before stage 2 has real occupants.
- **There is nowhere to report a failure to.** `main.cpp` spins with the error
in a register for a debugger to find, because the console is a stage-3
resource brought up by the application. A board that fails bring-up cannot
currently tell anyone.
- **An RTOS will want the tick.** FreeRTOS drives `SysTick` itself, and
conventionally starts some drivers after the kernel does. Stage 3 is where
that lands, and it will likely revisit step 5's ordering.
- **Stage 1 has no ordering mechanism between peripherals.** It is declaration
order in the board class, which is well defined but implicit. Fine for
independent peripherals; a problem the first time one depends on another.

## What would change this design

Deliberately written down so the next step is a decision rather than a drift.

- **A timebase valid from reset** (#44) — building `Micros()` on `DWT->CYCCNT`, which
runs from reset and needs no interrupt — would make timeouts work in every
stage and remove the hard boundary above. Worth doing when a stage-1
peripheral genuinely needs a bounded wait. It is a timeout source, not an
uptime clock: the counter wraps in ~268 s at 16 MHz.
- **Explicit init levels** (Zephyr's `PRE_KERNEL_1` / `POST_KERNEL`, or a
registry with priorities) would replace the two-phase split. The trigger is
concrete: **two off-chip devices with an ordering constraint between them.**
Until then it is ceremony, and this project would rather show the seam than
hide it behind a framework.
34 changes: 34 additions & 0 deletions docs/PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,40 @@ is the open part.

## Decision Log

### 2026-09-07: Boot flow named in two stages, split on fallibility

- Wrote `docs/BOOT_FLOW.md`. The stage a peripheral belongs to is decided by
**what can fail**, not by what is convenient: on-chip bring-up is register
writes against hardware soldered into the die, so it cannot fail and lives in
a constructor; off-chip bring-up is I/O across a wire, where absence and
silence are normal outcomes, so it needs somewhere that can report one --
`Board::Init()`.
- The structure this describes already existed; what was missing was the
contract. `board::Board` now states it where a reader meets it.
- Corrected a claim made in the 2026-09-06 entry above. `mcu::Delay` *does*
work before the tick -- it falls back to a `DWT->CYCCNT` spin -- so
"bring-up must not call mcu::Delay" was wrong. The real boundary is narrower
and sharper: `Millis()` is frozen until `InitSysTick()`, so a *bounded* wait
can never end. Since every real bus transfer needs a timeout, stage 1 may
wait but may not talk.
- That was a live latent hang, not just a doc error. `I2CBus`'s flag wait and
`Usart::Receive` compared against a frozen `Millis()` with no guard, so a
stuck bus before the tick would have spun forever. Both now refuse with
`kInvalidState`. Unreachable today -- both are called only after
`Board::Init()` -- and reachable the moment anything talks to a device during
bring-up, which is exactly what stage 2 invites.
- Rejected, for now, an init-level registry in the style of Zephyr's
`PRE_KERNEL_1`/`POST_KERNEL`. With one board and no off-chip devices it would
be a framework guarding a state that has not occurred. Its trigger is written
down instead: two off-chip devices with an ordering constraint between them.
- Deferred a reset-valid timebase (`Micros()` on `DWT->CYCCNT`, #44), which would
make timeouts work in every stage and dissolve the boundary entirely. Worth
doing when a stage-1 peripheral needs a bounded wait, and not before.
- Known gaps recorded rather than solved: `common::Error` has no payload, so
`Board::Init()` cannot name which device failed (#43); 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.

### 2026-09-06: Peripheral configuration moved into the constructor (#37, #38)

- `mcu::GpioPin` and `mcu::I2CBus` now configure the hardware as they are
Expand Down
15 changes: 15 additions & 0 deletions src/libs/board/board.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,24 @@

namespace board {

/// @brief Everything an application is given of the hardware it runs on.
///
/// A board comes up in two phases, and which one a peripheral belongs to is
/// settled by whether it can fail, not by what is convenient:
///
/// - **Construction** brings up what is inside the MCU. Register writes with
/// no dependencies, so they cannot fail and need no timebase.
/// - **Init()** brings up what is on the other side of a wire, and is
/// fallible for that reason: a device can be absent, wrongly strapped, or
/// simply not answer. It also starts the system tick, which is what makes
/// bounded waits -- and so any real bus transfer -- legal from here on.
///
/// docs/BOOT_FLOW.md has the whole sequence and the rules for each stage.
struct Board {
virtual ~Board() = default;

/// Bring up everything off-chip and start the timebase the drivers need.
/// Must have returned successfully before any accessor below is used.
[[nodiscard]] virtual auto Init() -> std::expected<void, common::Error> = 0;
[[nodiscard]] virtual auto UserLed1() -> mcu::OutputPin& = 0;
[[nodiscard]] virtual auto UserLed2() -> mcu::OutputPin& = 0;
Expand Down
7 changes: 5 additions & 2 deletions src/libs/board/stm32f767zi_nucleo/nucleo_board.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ namespace board {
/// peripheral has to poll a status bit that can time out, it needs a
/// separate call -- see mcu::Usart, which stays two-phase for this reason
/// as well as because only the application knows its UartConfig.
/// 2. Bring-up must not call mcu::Delay. InitSysTick() runs from Init(),
/// after main(), so a constructor that waited on the tick would hang.
/// 2. Bring-up must not talk to anything across a wire. mcu::Delay is fine
/// -- before the tick it falls back to a cycle-counter spin -- but a
/// *timeout* is not: Millis() is frozen until Init() starts the tick, so
/// a bounded wait cannot end and the drivers refuse one this early. That
/// makes off-chip bring-up Init()'s job. See docs/BOOT_FLOW.md.
/// 3. This board must stay the only object with a dynamic initializer.
/// Construction order within it is declaration order and well defined;
/// order across translation units is not. `.init_array` holding one entry
Expand Down
8 changes: 8 additions & 0 deletions src/libs/mcu/arm_cm7/i2c.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ auto EnablePeripheralClock(I2CId id) -> void {
/// missing device, which is worth telling apart from a wedged bus.
[[nodiscard]] auto WaitForFlag(I2C_TypeDef* registers, std::uint32_t flag)
-> std::expected<void, common::Error> {
// Millis() does not advance until the board starts the tick, so the timeout
// below could never fire and a bus held low would spin forever. Refuse
// instead: a transfer this early is a boot-order mistake, not a bus fault.
// See docs/BOOT_FLOW.md -- talking to a device is stage 2 work.
if (!SysTickRunning()) {
return std::unexpected(common::Error::kInvalidState);
}

const std::uint32_t start = Millis();
while ((registers->ISR & flag) == 0U) {
if ((registers->ISR & I2C_ISR_NACKF) != 0U) {
Expand Down
6 changes: 6 additions & 0 deletions src/libs/mcu/arm_cm7/usart.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ auto Usart::Receive(std::span<std::byte> buffer, std::uint32_t timeout_ms)
if (buffer.empty()) {
return 0U;
}
// A bounded wait needs a running tick: Millis() is frozen until the board
// starts it, so the timeout below would never fire. Waiting forever is still
// allowed, since that asks for no timeout in the first place.
if (timeout_ms != 0 && !SysTickRunning()) {
return std::unexpected(common::Error::kInvalidState);
}

auto* registers = Registers(id_);
const std::uint32_t start = Millis();
Expand Down
Loading