Skip to content

Latest commit

 

History

History

rust_stm32_bridge

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

rust_stm32_bridge

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):

Phase A — Rust owns the chip

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.

Phase B — the C++ shim, compiled and linked

Why not cxx or bindgen directly (real research, not assumed):

  • cxx (the standard safe Rust↔C++ bridge) added no_std support in 1.0.58, but it requires a global allocator (features=["alloc"]) — true heap-free core-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.
  • bindgen cannot bind C++ templates beyond field access on non-specialized types — no template methods, no monomorphization, no concept of hapi::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 C ABI 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.

Phase C — flashed and verified on real hardware

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.

Device #2 — a real I2C LCD through the same bridge

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.

Build & flash

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.

Requirements

  • 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 — see ARM_GXX/ARM_AR above).
  • Real ST-Link + OpenOCD to flash (same setup focCompose's hardware rounds already use).

What this doesn't prove, and why that's honest

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.