case 11 · Co-op · 2026

Timberwolf VCU — STM32H755 EV firmware in Rust

Vehicle Control Unit and Battery Management System firmware for an Ontario EV pickup truck, built during a co-op term. Async Rust on embassy-stm32, targeting a dual-core STM32H755ZI — supplier ECUs (inverter, OBC, AC thermal unit, ABS, IMD) over a single 250 kbps CAN bus.

Rust embassy-stm32 STM32H755ZI CAN bus BMS safety-critical
MCU
STM32H755ZI — Cortex-M7 + M4 (running on M7)
Board
NUCLEO-H755ZI-Q (bench); production VCU PCB TBD
Framework
embassy-stm32 · async · no_std
CAN
FDCAN1 · 250 kbps · FD-capable PHYs
Supplier ECUs
Inverter · OBC · AC 2-in-1 TMS · ABS · IMD
Pack
168s LFP cells · 48 temperature sensors · MC33771C TPL chain
Vehicle
Timberwolf EV pickup truck · Ontario, Canada

Context

I wrote all of this during a co-op term — the full VCU and BMS firmware from scratch, solo, over about a month. The startup pivoted before the vehicle reached full testing, so the firmware never ran on the production hardware, but the architecture is complete and the codebase compiles clean. The platform choice — async Rust on embassy-stm32 rather than C with an RTOS — was my call, made early and defended on the grounds that the type system would enforce inter-task ownership rules that are normally left to code review.

What I built

The firmware manages the full ignition-to-drive sequence: detect ignition, sequence the HV contactors through precharge, confirm the bus is up, then hand control to the vehicle FSM which arbitrates driver inputs against pack state. I wrote every layer — BMS, fault manager, HV sequencing, pedal safety, CAN ifaces, thermal management, the vehicle FSM — and set four architectural rules up front that every module had to follow.

Signal freshness. Every value shared between async tasks carries a timestamp. A consumer that reads a value past its deadline gets the type's declared fail-safe instead — zero torque, parking brake engaged, pack fault active. This meant a crashed producer or a stale CAN frame degraded automatically to the safe state without any explicit timeout logic in each consumer.

Centralised fault management. Every detector in the system — HV interlock monitor, pedal plausibility check, cell voltage, isolation resistance, wheel-speed sanity — fires an edge observation into a single fault manager. The fault manager is the only place that owns debounce timers, latch state, and priority. It publishes one action signal (None / Derate / Limp / SafeShutdown); the vehicle FSM reads nothing else. This made the overall safety behaviour testable as a unit rather than emergent from 15 modules each doing their own thing.

ADC ownership. Three ADC instances, each owned by exactly one task — the pedal task (1 ms, safety-critical), the pack current task (shunt + Hall fusion), and a single owner for the slower pack-voltage and LV measurements. No shared ADC locks; each task samples at the rate it needs without coordinating with anything else.

Calibration gate. Every behavioral constant — cell over/under-voltage thresholds, contactor timeouts, pedal deadbands, fault debounce periods — lives in one file, tagged with which engineer must confirm it before the value is valid. A build script rejects any release build that still has an unconfirmed placeholder or a bare numeric literal in a comparison outside that file. Annoying to set up, but it caught hardcoded values that were supposed to be temporary every time.

Module breakdown

The binary spawns around 20 async tasks from main(), grouped by subsystem. Each task owns its hardware exclusively — no shared peripheral locks.

app / ev_state_machine — master vehicle FSM (Off → Standby → Precharge → Ready → Drive / Charge → Fault). Runs at 20 ms. Reads the consolidated fault action, pedal state, gear, ignition, parking brake, and precharge state — all through the stamped freshness layer. Any fatal fault collapses it to Fault in one tick; the only exit is ignition-off with faults cleared.

bms / bms_manager — BMS coordinator. Consumes cell snapshots from the TPL task, fused pack current, and bus voltage. Computes pack-level min/max V and T, runs coulomb-counting SoC estimation, derives discharge and charge current limits from V/T margins, and publishes a PackStatus every 20 ms.

bms / tpl — SPI driver for the MC33771C / MC33664 TPL daisy chain. Reads per-cell voltages (168 cells) and temperatures (48 NTCs) and publishes a CellSnapshot at 10 ms.

bms / pack_current — owns ADC2. Reads the shunt and Hall current sensors every 10 ms, scales each with its own calibration constants, checks plausibility between them, and publishes a fused current. Disagreement raises a BmsPackOverCurrent fault edge.

bms / soc — coulomb-counter SoC estimator. Accumulates charge in mA·ms to avoid per-tick truncation at low currents; converts to percent against the confirmed pack capacity.

bms / bms_faults — BMS fault detector. Called by bms_manager every tick. Edge-reports cell OV/UV and over/under-temperature to the fault bus; holds no local state.

bms / balancing — passive balancing scheduler. Reads cell voltages and commands the TPL to bleed high cells.

hv / contactor — per-contactor FSMs for main-positive, main-negative, and precharge. Each tracks its own Open / Closing / Closed / Opening / Welded state against the feedback pin. Close-timeout de-energises the coil immediately. Unexpected open (feedback drops while commanded closed) raises a dedicated per-contactor fault code. Weld detection also fires a per-contactor code so diagnostics know which unit failed.

hv / precharge — precharge sequence FSM. Closes the negative contactor, then waits for the bus capacitance to reach ≥95% of pack voltage before closing main-positive and opening the precharge contactor. Three independent phase-specific timeouts replace the original single flat timeout.

hv / hvil — HV interlock loop monitor on an EXTI input. Debounces the signal, reports HvilOpen on falling edge and clears it on rising edge.

hv / isolation — IMD consumer (CAN-delivered). Two-tier hysteresis: critical low (SafeShutdown) and warning (Derate), each with its own recovery threshold so near-threshold resistance doesn't chatter the fault bus.

driver_inputs / pedal — owns ADC1. Samples two accelerator channels and a brake channel at 1 ms. Cross-checks the two accelerator readings for plausibility; latches PedalImplausible if they disagree for more than the debounce window. Recovery requires both channels to return to released for a hold period. Applies per-channel calibration spans and a deadband before publishing.

driver_inputs / gear — shift-by-wire with four momentary buttons (P/R/N/D). Debounces each button, enforces interlocks (reverse blocked above 3 kph, park blocked above 3 kph, must hold brake to shift from stop), and publishes a GearStatus.

driver_inputs / ignition and parking_brake — debounced GPIO readers; each publishes a stamped value with a fail-safe (Off / Engaged) used by the FSM on stale reads.

fault / fault_manager — sole owner of all fault state. Receives FaultObservation edges from every detector, runs per-code debounce timers, manages latching, computes the highest-priority active action, persists to NVM, and publishes a single ACTIVE_ACTION stamped signal. Nothing else in the system makes safety decisions.

fault / fault_codes — canonical fault taxonomy: 35 codes across BMS, HV, driver inputs, CAN, inverter, OBC, thermal, and system categories. Each code has static metadata (severity, action, debounce, latch) defined in a const fn so the compiler can verify completeness.

safety / safety_manager — monitors the impact switch and brake fluid level; reports ImpactDetected and BrakeFluidLow.

safety / watchdog — kicks the IWDG on a fixed period. A hung task that starves this one resets the MCU; the reset is logged to NVM as a WatchdogReset fault on the next boot.

can / can_manager — FDCAN1 driver at 250 kbps. Receives and decodes frames from all supplier ECUs (inverter, OBC, ABS, IMD, TMS) and publishes them as typed signals. Also transmits VCU heartbeat and torque request frames.

mcu_iface / inverter_iface — consumes vehicle state, pack limits, and the torque arbitration output; encodes and sends the torque request frame to the inverter. Monitors the inverter status frame and raises InverterFault / MotorOverTemperature as needed.

charger / obc_iface — OBC charge FSM (Idle → Plugged → Negotiating → Charging → Stopping). Decodes the OBC status frame; a stale frame is treated as unplugged. Monitors OBC temperature and raises ObcFault on over-temp.

abs_iface / wheel_speed — decodes ABS wheel-speed frames, checks front/rear plausibility, and raises WheelSpeedImplausible on excessive delta. Times out if the ABS module goes silent.

thermal / tms_iface — decodes the AC 2-in-1 TMS feedback frame (500 ms, 29-bit extended CAN ID, J1939 PDU2). Maps TMS fault levels to VCU fault codes: Level 1 mandates SafeShutdown per the protocol spec, Level 2 latches Limp, Level 3 is informational.

thermal / thermal_manager — republishes TMS data in the internal ThermalStatus shape consumed by the BMS and fault manager.

lv / lv_monitor — reads LV battery voltage from ADC3 (sub-sampled at 100 ms), raises LvLow with hysteresis.

platform / stamped — the cross-cutting freshness primitive. Stamped<T> wraps a value with a timestamp and sequence number. read_or_failsafe returns the type's declared fail-safe if the signal is absent or past its deadline.

platform / adc — single owner task for ADC3; round-robins pack voltage and LV voltage at 10 ms, sub-sampling LV at 100 ms.

cal / tables — every behavioral constant in the firmware. Provenance-tagged with the engineer who must confirm it. Release builds rejected by cal_gate.sh if any placeholder remains or if a bare numeric literal appears in a comparison outside this file.

nvm / store — fault log and persistent config over internal flash. Written by the fault manager on latch edges; read on boot to restore prior fault state.

diagnostics / uds — UDS layer stub; will expose fault codes over CAN to a standard OBD tool.

torque_arbitration / arbiter — combines driver demand, pack discharge limit, and derate level into a final torque request. Regen blending and slip limiting pending.

HV sequencing

The precharge and contactor logic was the most safety-sensitive part. Each contactor (main-positive, main-negative, precharge) has its own FSM that tracks commanded state against measured feedback. A close that times out drops the drive coil immediately rather than leaving it energised. A contactor that opens unexpectedly while commanded closed raises a dedicated fault code — not a generic timeout — so diagnostics can identify which unit failed. Weld detection works the same way: one fault code per contactor.

Precharge uses three independent timeouts — one for the negative contactor to close, one for the bus capacitance to charge to ≥95% of pack voltage, one for the main-positive contactor to close. A single flat timeout was the original design; splitting it made each phase observable and independently calibrated.

Design process

A few pages from my notebook, captured before writing the code.

Handwritten sketch of async task spawn order — safety and fault spine first, vehicle FSM last
Task spawn priority order — I sketched this before writing main.rs. Safety-critical tasks (fault manager, watchdog, HVIL) spawn first; the vehicle FSM spawns last, after every upstream producer is already running.
Handwritten clock tree notes with gap numbering and decision to decouple HSE from bring-up
Clock tree reasoning — the production VCU's HSE crystal hadn't been confirmed by electrical, so I decoupled it: unblock full-speed bring-up on HSI now, track the HSE accuracy question as gap #10 to resolve before a production flash.
Handwritten fault guard logic sketch with four OR'd conditions and STATE → FAULT annotation
Early fault arbitration sketch — working out the conditions that should force a hard fault before writing the fatal() guard and the consolidated action pipeline.
Handwritten HV contactor state diagram with safety-review annotation
HV contactor state diagram — with a note to get the datasheet confirmed and a reminder that any change in this slice goes through safety review.

What was hard

Choosing embassy-stm32 over a C RTOS was the most consequential early decision. The async executor and ownership rules meant the compiler caught a class of concurrency bug — two tasks trying to own the same ADC instance, mutable state crossing task boundaries without synchronisation — that in C would only show up under load or on specific hardware. The downside was that embassy's API was still moving, and some patterns I designed against had to be revised as the crate evolved.

The fault architecture took the most iteration. The first version let subsystems act on their own faults directly. That broke down immediately when two faults conflicted: both the HVIL monitor and the precharge timeout wanted to open contactors simultaneously, and they didn't agree on the order. Moving all of that into a single arbitrating task made the behavior deterministic, but it required a clear contract for how detectors communicate observations versus how the manager communicates actions.

What's next

  • MC33771C cell monitor driver — implement the real SPI protocol with the correct CRC polynomial, address auto-discovery, and per-cell plausibility checks; currently runs on a fixed dummy snapshot
  • CAN frame typing — generate typed encode/decode for all supplier ECUs from a contract spec; current frame handling is hand-stubbed
  • Production PCB bring-up — first flash on real hardware once the PCB layout is finalised and the clock tree is confirmed by electrical
  • Torque arbitration — derate curves from pack limits, regen blending with the friction brake, slip detection from ABS wheel-speed deltas
  • UDS diagnostics — readable fault codes over CAN using a standard OBD tool