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.
- 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.
main.rs. Safety-critical tasks (fault manager, watchdog, HVIL) spawn first; the vehicle FSM spawns last, after every upstream producer is already running.
fatal() guard and the consolidated action pipeline.
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