Real embedded Rust firmware calling into a real, unmodified HAPI-composed
C++ stack — on real STM32F103C8 (Blue Pill) hardware, the same board and
chip focCompose's C++ work already hardware-verified. Unlike config_loader/
cutlass_layout/cuda_device_chain (all "does the composition mechanism
generalize past embedded" proofs), this is the reverse move: a real
embedded consumer, the audience a go-to-market plan for this technology
would actually target.
Built in three phases, each verified on real hardware before moving on (not "it compiled" — real OpenOCD register readback at every step, the same discipline every hardware round in this project uses):
src/main.rs, pure Rust (cortex-m-rt + stm32f1xx-hal), zero HAPI/C++.
Drives PC13 (onboard LED, active-low) low. Real OpenOCD readback:
GPIOC.CRH bits for PC13 = 0011 (push-pull output, matching
into_push_pull_output exactly), GPIOC.ODR bit 13 = 0. Purpose:
isolate "does Rust genuinely control this exact chip" from "does the
bridge work," before any FFI complexity enters.
Why not cxx or bindgen directly (real research, not assumed):
cxx(the standard safe Rust↔C++ bridge) addedno_stdsupport in 1.0.58, but it requires a global allocator (features=["alloc"]) — true heap-freecore-only support was explicitly deprioritized by its maintainer and never shipped (confirmed via dtolnay/cxx#933). In tension with this ecosystem's own no-dynamic-allocation stance.bindgencannot bind C++ templates beyond field access on non-specialized types — no template methods, no monomorphization, no concept ofhapi::Chain<>'s recursive inheritance fold (confirmed via its own docs, rust-bindgen/cpp.html). Anything it can't translate becomes an opaque byte blob.- The Rust embedded book's own guidance: "As C++ does not have a stable
ABI for the Rust compiler to target, it is recommended to use the
CABI when combining Rust with C or C++."
So cpp/shim.cpp does exactly that: composes a trivial Counter+
DoubleStep component via real, unmodified hapi::APIOf<> (the SAME
Chain<> inheritance-fold + template-template-parameter pattern used in
HAPI/examples/cuda_device_chain, cross-compiled here instead with the
real arm-none-eabi-g++ toolchain focCompose already uses — not a new
toolchain), storing state in a static (no heap), and exposes it through
two flat extern "C" functions. build.rs invokes that same real
compiler directly (not the cc crate — its target-detection heuristics
don't know thumbv7m-none-eabi) and links the result into the Rust
binary.
Real result: links completely clean. No symbol
conflicts, no vector-table clash (Rust's cortex-m-rt owns the reset
handler/vector table; the C++ shim contributes no startup code of its
own, since Ticker's default constructor is trivial — zero-init only,
confirmed via the final .bss placement, no runtime constructor call
needed). arm-none-eabi-nm on the final binary confirms both
hapi_ticker_inc/hapi_ticker_get at real flash addresses and
g_ticker in .bss RAM.
Zero-overhead, confirmed at -Os, same as every other target in this
project (AVR/ESP32/ARM/PTX): the compiled shim's hapi_ticker_inc() is
a single ldr/adds #2/str — DoubleStep→Counter's two-layer fold
collapses to one add, zero internal calls. But the FFI seam itself is
honestly NOT zero-overhead — confirmed both by research (real,
ABI-mandated, non-inlined calls; cross-language LTO exists but the
rustc book's own description of practical use is manual bitcode
extraction/relinking, no bare-metal precedent found) and empirically
here: arm-none-eabi-objdump on the final binary shows three real bl
instructions from Rust's main into hapi_ticker_inc and one into
hapi_ticker_get. HAPI's zero-overhead property holds inside the C++
side; it stops at the language boundary. Reported plainly, not glossed
over — the same discipline as cutlass_layout's reference::host::Gemm
finding.
Real flash+verify via the same ST-Link/OpenOCD flow as Phase A/
focCompose (device id 0x20036410 — the same physical board).
main.rs calls hapi_ticker_inc() three times (DoubleStep→2×
Counter::inc() per call, 3×2=6 — matching cuda_device_chain's
numbers exactly, for a direct side-by-side comparison across toolchains)
then drives PC13 low only if the C++ side's answer is exactly 6.
Real OpenOCD readback, post-flash:
0x20000004 (g_ticker.value, RAM): 0x00000006 <- the C++-composed value, live
0x40011004 (GPIOC.CRH): 0x44344444 <- PC13 = push-pull output
0x4001100c (GPIOC.ODR): 0x00000000 <- PC13 driven low (LED on)
The value genuinely crossed the FFI boundary correctly and drove real hardware state — not "the firmware didn't crash," a direct register-level confirmation of the actual computed result.
cpp/lcd_shim.cpp bridges to a real 16×2 HD44780 LCD over a PCF8574 I2C
backpack (I2C1, PB6/PB7) using the unmodified oneIO::display::I2cLcd
→ Hd44780 → oneBus::I2cGpio → hw::stm32::Stm32I2cCore stack — a
deeper composition than the Counter/DoubleStep shim, and one that
actually touches a peripheral. Two flat extern "C" entry points
(hapi_lcd_init, hapi_lcd_print); main.rs calls them once after the
ticker check and parks in loop {}. The glass reads Rust -> HAPI /
on STM32! — the full stack, driven from Rust, confirmed visually and by
register readback.
Clock: stm32f1xx-hal's rcc::Config::hsi().sysclk(72.MHz()) does not
error on an unreachable target — HSI/2 = 4 MHz caps the PLL at ×16, so it
silently clamps to 64 MHz (PLLSRC=0, PLLMUL=×16, register-confirmed).
hse(8.MHz()) for a literal 72 MHz was tried and reverted: the HAL's
HSE-ready wait is an un-timeout'd busy-loop with no fallback, run before
any LED/LCD code — a dead crystal hangs the whole firmware. 64 MHz is safe
for hd44780.h's delay calibration (a slower clock only over-delays). The
shim pins f1::Twi<100000, 32000000> so Stm32I2cCore computes
CCR/TRISE from the real 32 MHz APB1, not the 36 MHz default.
OpenOCD readback, post-flash (64 MHz):
0x20000008 (g_ticker.value): 0x00000006 <- ticker still correct
0x4001100c (GPIOC.ODR): 0x00000000 <- PC13 low, value crossed the FFI boundary
0x40021004 (RCC_CFGR): SWS=PLL, PLLSRC=HSI/2, PLLMUL=×16, PPRE1=/2
0x40005404 (I2C1_CR2 FREQ): 32
0x4000541c (I2C1_CCR): 160 <- 32MHz/(2·100kHz)
0x40005420 (I2C1_TRISE): 33
fault_count: 0 <- no I2C wedge (see below)
Breakpointing I2cGpio::flush() and dumping the shadow byte on each hit
shows the exact HD44780 4-bit power-on sequence (0x3/0x3/0x3 wake, 0x2
switch, function-set, …) clocking out to the PCF8574 — byte-identical to
OneIO/examples/i2cLcd's own C++ build.
Bug fixed to get here (OneChip/chips/stm32/stm32Twi.h, not the Rust
side): the STM32F1 I2C block locks up with BUSY stuck after sustained
back-to-back transfers, and every SR poll was an un-bounded while.
end_write() now waits for BUSY to clear after STOP (so the next
transfer starts on an idle bus — this alone stops the lockup); all polls
are bounded; a stall or BERR/ARLO/AF triggers recover() (SWRST +
re-init) and bumps a public fault_count. Compiled into this firmware
too; fault_count stays 0.
Bench note: the Blue Pill's 5V pin is only ~3.2 V when the board runs
off the ST-Link's 3.3 V — enough for the PCF8574 to ACK but not for the
HD44780 panel to develop contrast (blank glass, working I2C). Power the
Blue Pill over USB (5 V pin then ~5 V, LCD readable); keep the ST-Link for
SWD, grounds common.
cargo build --release
~/.platformio/packages/tool-openocd/bin/openocd \
-f interface/stlink.cfg -f target/stm32f1x.cfg \
-c "program target/thumbv7m-none-eabi/release/rust_stm32_bridge verify reset exit"build.rs defaults to PlatformIO's bundled arm-none-eabi-g++/-ar
(~/.platformio/packages/toolchain-gccarmnoneeabi/bin/) and this
repo's own ../../include for HAPI's headers — override with the
ARM_GXX/ARM_AR/HAPI_INCLUDE env vars if those paths don't apply
elsewhere.
rustup+rustup target add thumbv7m-none-eabi(official installer, no root needed).- A real
arm-none-eabi-g++/-ar(PlatformIO's bundled one works, or any other — seeARM_GXX/ARM_ARabove). - Real ST-Link + OpenOCD to flash (same setup
focCompose's hardware rounds already use).
Device #1 is a trivial counter; device #2 is a real I2C peripheral stack
but still only called at startup. Neither exercises focCompose's real
SPI-sensor / PWM-driver composition or a control loop — a natural next
step, deliberately sequenced after the mechanism itself. And per the FFI
finding above: don't expect this pattern to preserve zero-overhead across
a hot, high-frequency Rust↔C++ boundary (e.g. calling into a composed
FOC control loop every PWM cycle) without measuring the real call
overhead first — it's a real cost here, just a very small one for a
handful of calls at startup.