Skip to content
Merged
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
43 changes: 40 additions & 3 deletions docs/PROJECT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ is the open part.
hardware board exists: optional accessors
(`std::expected<mcu::I2CController&, Error>`) vs. capability mix-ins vs.
compile-time board traits. Deferred from Milestone 2 deliberately — with
one board there was nothing to design against. Entangled with #37: an
accessor that can report "this peripheral did not come up" is what the
factory-based fixes there need.
one board there was nothing to design against. No longer entangled with
#37: peripherals now configure themselves at construction, so there is no
"did not come up" state for an accessor to report.
- [ ] Additional example application exercising more complex behavior
- [ ] Cross-board validation: blinky runs on both boards unmodified

Expand Down Expand Up @@ -129,6 +129,43 @@ is the open part.

## Decision Log

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

- `mcu::GpioPin` and `mcu::I2CBus` now configure the hardware as they are
constructed. `configured_`/`initialized_` and the eight `kInvalidState`
guards they gated are gone, and `NucleoF767ZiBoard`'s member list is now a
description of the board rather than a set of promises `Init()` has to keep.
`Init()` is left with `InitSysTick()`, which needs the NVIC and so genuinely
cannot run before `main()`.
- #37 assumed a constructor could not touch registers, because the board is a
namespace-scope object. That turned out not to 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 (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 in the tree
stated that ordering backwards and were corrected.
- Rejected the alternatives #37 listed. A factory with a private constructor
(options 1/2) was the right answer *given* the no-registers-in-constructors
rule, but once that rule proved unnecessary it was machinery guarding a state
that no longer exists — and it forced `std::optional` members, which model a
hardware absence that cannot happen on a fixed board. Option 3's friend
declaration enforces who constructs, not who configures. Option 4's readiness
gate became vacuous: declaring the member *is* the bring-up.
- Three invariants now 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 also suits it:
only the application knows its `UartConfig`.
- I2C bus speed became a constructor argument with a derived `TIMINGR` table
(#38), deliberately undefaulted so a board has to name the rate its wiring
can carry.
- `arm_cm7` gained 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 the contract — that a pin cannot be constructed without
a direction, and a bus without a speed. It proves the types, not the register
writes; those still need the board in hand.

### 2026-09-04: First hardware backend (arm_cm7 + F767ZI Nucleo)

- Chose CMSIS device headers over the Cube HAL. The 30k lines of register
Expand Down
23 changes: 13 additions & 10 deletions src/libs/board/host/host_board.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ auto HostBoard::Init() -> std::expected<void, common::Error> {
}
zmq_transport_ = std::move(transport_result.value());

user_led_1_ = std::make_unique<mcu::HostPin>("LED 1", *zmq_transport_);
user_led_2_ = std::make_unique<mcu::HostPin>("LED 2", *zmq_transport_);
user_button_1_ = std::make_unique<mcu::HostPin>("Button 1", *zmq_transport_);
user_led_1_ = std::make_unique<mcu::HostPin>("LED 1", *zmq_transport_,
mcu::PinDirection::kOutput);
user_led_2_ = std::make_unique<mcu::HostPin>("LED 2", *zmq_transport_,
mcu::PinDirection::kOutput);
user_button_1_ = std::make_unique<mcu::HostPin>("Button 1", *zmq_transport_,
mcu::PinDirection::kInput);
uart_1_ = std::make_unique<mcu::HostUart>("UART 1", *zmq_transport_);
i2c_1_ = std::make_unique<mcu::HostI2CController>("I2C 1", *zmq_transport_);

Expand All @@ -33,13 +36,13 @@ auto HostBoard::Init() -> std::expected<void, common::Error> {
std::ref(*uart_1_), std::ref(*i2c_1_),
};

return user_led_1_->Configure(mcu::PinDirection::kOutput)
.and_then([this]() {
return user_led_2_->Configure(mcu::PinDirection::kOutput);
})
.and_then([this]() {
return user_button_1_->Configure(mcu::PinDirection::kInput);
});
// No Configure() chain: the pins were given their direction above. What is
// left here is what genuinely cannot happen at construction -- the transport
// has to connect first, and that can fail, which a constructor could not
// report. That is why this board builds its peripherals in Init() while
// NucleoF767ZiBoard holds them as members: an emulated peripheral depends on
// a socket, a real one only on registers that are always there.
return {};
}
auto HostBoard::UserLed1() -> mcu::OutputPin& { return *user_led_1_; }
auto HostBoard::UserLed2() -> mcu::OutputPin& { return *user_led_2_; }
Expand Down
23 changes: 8 additions & 15 deletions src/libs/board/stm32f767zi_nucleo/nucleo_board.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,15 @@
namespace board {

auto NucleoF767ZiBoard::Init() -> std::expected<void, common::Error> {
// The core is already configured: SystemInit ran from Reset_Handler, before
// .data was copied. What is left is everything that needs a working C++
// runtime -- starting with the tick that mcu::Delay is built on.
// The core is already configured: SystemInit ran from Reset_Handler, and the
// pins and the I2C bus configured themselves as this object was constructed.
// What is left is everything that cannot happen before main() -- which is
// just the tick that mcu::Delay is built on, since it needs the NVIC.
//
// The USART is absent on purpose: mcu::Uart::Init is the application's to
// call, because only the application knows the UartConfig it wants.
mcu::InitSysTick();

// The button is externally pulled down on this board (UM1974), so it needs
// no internal pull: it reads low at rest and high while pressed.
return user_led_1_.Configure(mcu::PinDirection::kOutput)
.and_then(
[this] { return user_led_2_.Configure(mcu::PinDirection::kOutput); })
.and_then([this] {
return user_button_1_.Configure(mcu::PinDirection::kInput);
})
// I2C has no Init() in the portable interface -- unlike Uart, which the
// application configures itself -- so the board brings the bus up here.
.and_then([this] { return i2c_1_.Init(); });
return {};
}

auto NucleoF767ZiBoard::UserLed1() -> mcu::OutputPin& { return user_led_1_; }
Expand Down
38 changes: 32 additions & 6 deletions src/libs/board/stm32f767zi_nucleo/nucleo_board.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,23 @@ namespace board {
/// namespace-scope object in main.cpp, so its constructor runs from
/// __libc_init_array before main().
///
/// Constructing a GpioPin touches no registers, which is what makes that safe
/// -- the pins record where they are, and Init() is what configures them.
/// Constructing a peripheral configures it, which is what makes this list a
/// description of the hardware rather than a set of promises Init() has to
/// keep. Reset_Handler copies .data, zeroes .bss and calls SystemInit before
/// __libc_init_array, and RCC is live out of reset, so a peripheral constructor
/// can bring its own clock up. Three invariants hold that up, and a new
/// peripheral has to satisfy all three before it can be a member here:
///
/// 1. Bring-up must not fail. A constructor cannot report an error. If a
/// 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.
/// 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
/// besides crtbegin's is what that looks like in the map file.
class NucleoF767ZiBoard final : public Board {
public:
[[nodiscard]] auto Init() -> std::expected<void, common::Error> override;
Expand All @@ -34,10 +49,16 @@ class NucleoF767ZiBoard final : public Board {
[[nodiscard]] auto Uart1() -> mcu::Uart& override;

private:
mcu::GpioPin user_led_1_{pin_map::kUserLed1.port, pin_map::kUserLed1.pin};
mcu::GpioPin user_led_2_{pin_map::kUserLed2.port, pin_map::kUserLed2.pin};
mcu::GpioPin user_led_1_{pin_map::kUserLed1.port, pin_map::kUserLed1.pin,
mcu::PinDirection::kOutput};
mcu::GpioPin user_led_2_{pin_map::kUserLed2.port, pin_map::kUserLed2.pin,
mcu::PinDirection::kOutput};

// The button is externally pulled down on this board (UM1974), so it needs
// no internal pull: it reads low at rest and high while pressed.
mcu::GpioPin user_button_1_{pin_map::kUserButton1.port,
pin_map::kUserButton1.pin};
pin_map::kUserButton1.pin,
mcu::PinDirection::kInput};

mcu::Usart uart_1_{mcu::UsartId::kUsart3,
{
Expand All @@ -55,7 +76,12 @@ class NucleoF767ZiBoard final : public Board {
.sda_port = pin_map::kI2C1Sda.port,
.sda_pin = pin_map::kI2C1Sda.pin,
.alternate_function = pin_map::kI2C1AlternateFunction,
}};
},
// The driver brings the bus up on the pins' internal
// pull-ups, which it notes are weak (~40k) and good for
// 100 kHz over short wiring. Going faster is a decision
// for whoever adds external resistors.
mcu::I2CSpeed::kStandard100kHz};
};

} // namespace board
16 changes: 16 additions & 0 deletions src/libs/mcu/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@ if(NOT IS_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/${EMBEDDED_CPP_MCU}")
"Available: ${available}. See CLAUDE.md.")
endif()
add_subdirectory(${EMBEDDED_CPP_MCU})

# The hardware backend has no tests of its own: cross builds run no CTest and
# have no googletest. But the arm_cm7 public headers deliberately name no vendor
# type, so the contract they encode compiles -- and can be asserted -- on the
# host, whichever backend is selected. add_host_unit_test comes from the host
# backend's CMakeLists, and CMake functions are global once defined.
#
# include(GoogleTest) again here rather than relying on the host backend's: it
# sets the discovery script path as a directory-scoped variable, so without it
# gtest_discover_tests silently registers a _NOT_BUILT placeholder instead of
# the tests.
if(BUILD_TESTING AND EMBEDDED_CPP_MCU STREQUAL "host")
include(GoogleTest)
add_host_unit_test(test_peripheral_contract
arm_cm7/test_peripheral_contract.cpp mcu)
endif()
6 changes: 4 additions & 2 deletions src/libs/mcu/arm_cm7/cortex_m7.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ namespace mcu {

/// @brief Bring the core to a state where compiled C++ can run correctly.
///
/// Called from Reset_Handler (as SystemInit) before .data is usable and before
/// static constructors run, so it must touch nothing but core registers.
/// Called from Reset_Handler (as SystemInit) after .data is copied and .bss
/// zeroed, but before static constructors run. It touches nothing but core
/// registers: peripheral clocks are each driver's own business, and this
/// project never leaves the reset clock configuration (HSI, 16 MHz) at all.
///
/// Enables the FPU and points the vector table at flash. The FPU matters even
/// for code with no floating point in sight: the toolchain compiles with
Expand Down
29 changes: 11 additions & 18 deletions src/libs/mcu/arm_cm7/gpio_pin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@

namespace mcu {

auto GpioPin::Configure(PinDirection direction)
-> std::expected<void, common::Error> {
GpioPin::GpioPin(GpioPort port, std::uint32_t pin, PinDirection direction)
: port_(port), pin_(pin), direction_(direction) {
ApplyDirection(direction);
}

auto GpioPin::ApplyDirection(PinDirection direction) -> void {
// ConfigurePin enables the port clock first, which matters: before it is
// running every register here reads as zero and ignores writes, silently.
ConfigurePin(port_, pin_,
Expand All @@ -26,14 +30,15 @@ auto GpioPin::Configure(PinDirection direction)
});

direction_ = direction;
configured_ = true;
}

auto GpioPin::Configure(PinDirection direction)
-> std::expected<void, common::Error> {
ApplyDirection(direction);
return {};
}

auto GpioPin::Get() -> std::expected<PinState, common::Error> {
if (!configured_) {
return std::unexpected(common::Error::kInvalidState);
}
// IDR, not ODR, for both directions: it reports what the pad is actually at,
// so a shorted or externally driven output reads as what it really is rather
// than as what it was told to be.
Expand All @@ -42,9 +47,6 @@ auto GpioPin::Get() -> std::expected<PinState, common::Error> {
}

auto GpioPin::SetHigh() -> std::expected<void, common::Error> {
if (!configured_) {
return std::unexpected(common::Error::kInvalidState);
}
if (direction_ != PinDirection::kOutput) {
return std::unexpected(common::Error::kInvalidOperation);
}
Expand All @@ -56,9 +58,6 @@ auto GpioPin::SetHigh() -> std::expected<void, common::Error> {
}

auto GpioPin::SetLow() -> std::expected<void, common::Error> {
if (!configured_) {
return std::unexpected(common::Error::kInvalidState);
}
if (direction_ != PinDirection::kOutput) {
return std::unexpected(common::Error::kInvalidOperation);
}
Expand All @@ -67,9 +66,6 @@ auto GpioPin::SetLow() -> std::expected<void, common::Error> {
}

auto GpioPin::Toggle() -> std::expected<void, common::Error> {
if (!configured_) {
return std::unexpected(common::Error::kInvalidState);
}
if (direction_ != PinDirection::kOutput) {
return std::unexpected(common::Error::kInvalidOperation);
}
Expand All @@ -86,9 +82,6 @@ auto GpioPin::Toggle() -> std::expected<void, common::Error> {
auto GpioPin::SetInterruptHandler(std::function<void()> handler,
PinTransition transition)
-> std::expected<void, common::Error> {
if (!configured_) {
return std::unexpected(common::Error::kInvalidState);
}
if (direction_ != PinDirection::kInput) {
return std::unexpected(common::Error::kInvalidOperation);
}
Expand Down
30 changes: 20 additions & 10 deletions src/libs/mcu/arm_cm7/gpio_pin.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ namespace mcu {

/// @brief One STM32 GPIO pin.
///
/// Construction only records where the pin is; it touches no registers, so a
/// board can hold pins as members and have them constructed before the clock
/// tree is up. Configure() is what makes the pin real, and every operation
/// before it returns kInvalidState rather than writing into a dead register
/// block.
/// Constructing a pin configures it: the constructor enables the port clock and
/// programs the pin, so there is no window in which an unconfigured GpioPin
/// exists and no operation has to ask whether there is one.
///
/// The split is deliberate; that nothing enforces the second half of it is
/// not. Every peripheral in this backend has the same shape. See issue #37.
/// That is safe even though a board holds its pins as namespace-scope members.
/// Reset_Handler copies .data, zeroes .bss and calls SystemInit before
/// __libc_init_array, RCC is live out of reset, and ConfigurePin enables its
/// own port clock before touching anything else. See the invariants on
/// board::NucleoF767ZiBoard before adding a peripheral that needs more.
///
/// The direction can still be changed at run time -- that is what makes this a
/// BidirectionalPin -- so operations that require a particular direction still
/// check for it. That check is about what the pin is right now, not about
/// whether anyone remembered to set it up.
class GpioPin final : public BidirectionalPin {
public:
GpioPin(GpioPort port, std::uint32_t pin) : port_(port), pin_(pin) {}
GpioPin(GpioPort port, std::uint32_t pin, PinDirection direction);

[[nodiscard]] auto Configure(PinDirection direction)
-> std::expected<void, common::Error> override;
Expand All @@ -37,12 +43,16 @@ class GpioPin final : public BidirectionalPin {
-> std::expected<void, common::Error> override;

private:
/// Program the pin for a direction. Shared by the constructor and Configure,
/// which is why it returns void rather than the expected Configure owes its
/// caller: there is nothing here that can fail.
auto ApplyDirection(PinDirection direction) -> void;

[[nodiscard]] auto Mask() const -> std::uint32_t { return 1U << pin_; }

GpioPort port_;
std::uint32_t pin_;
PinDirection direction_ = PinDirection::kInput;
bool configured_ = false;
PinDirection direction_;
};

} // namespace mcu
Loading
Loading