Embedded Systems · Formula Student

Battery
Management System

Firmware that watches all 144 cells of a Formula Student tractive battery, decides in milliseconds whether the pack is safe, and pulls the car's shutdown circuit open when it isn't. Built on a Teensy 4.1 under FreeRTOS for Virginia Motorsports.

Role
BMS firmware lead — architecture, driver, data acquisition, fault logic, charging, CAN
Team
Virginia Motorsports
Electrical subteam
Timeline
2025 – present
2026–27 vehicle
Target
Teensy 4.1 · Cortex-M7 @ 600 MHz · FreeRTOS
C++ FreeRTOS PlatformIO isoSPI CAN bus ADBMS6830 / 6822 FlexCAN_T4

A 500-volt battery that has to prove it is safe, continuously

The tractive battery on an electric Formula Student car stores enough energy to be lethal. The competition rules treat the BMS as a safety device rather than a convenience: it must measure every cell's voltage and the temperature of critical points in the pack whenever the tractive system is live or a charger is attached, and the moment anything reads out of bounds it must open the shutdown circuit and light an indicator that stays lit until a human resets it.

That framing drives the whole design. There is no "retry and hope" path and no state where a detected fault quietly goes away — a fault, once seen, is latched. The interesting engineering is in getting 144 trustworthy measurements across a high-voltage isolation boundary fast enough that the safety decision is made on fresh data.

144
cells monitored — 8 segments × 18 series
48
thermistors — 6 per segment
16 ms
pack snapshot period
10 ms
fault frame on the vehicle bus

Getting 144 measurements across the isolation boundary

Each segment has its own Analog Devices ADBMS6830B cell monitor sitting at pack potential, measuring its 18 cell taps and 6 NTC thermistors locally. The monitors are daisy-chained to each other over isoSPI — a two-wire differential link that carries the four SPI signals as pulse pairs through transformers. That transformer coupling is what puts a galvanic isolation boundary between the high-voltage stack and the control electronics, which the rules require at every module-to-module boundary.

On the low-voltage side an ADBMS6822 bridge translates isoSPI back into ordinary 4-wire SPI for the Teensy. The MCU never touches pack potential; it only ever talks to the bridge.

TRACTIVE BATTERY — HIGH VOLTAGE CONTROL SIDE — LOW VOLTAGE galvanic isolation boundary · required at every module boundary Segment 1 18 cells · 6 NTC ADBMS6830B cell monitor Segment 2 18 cells · 6 NTC ADBMS6830B cell monitor segments 3–7 ⋮ Segment 8 18 cells · 6 NTC ADBMS6830B cell monitor daisy chain isoSPI · 2-wire ADBMS6822 isoSPI ↔ SPI bridge SPI mode 3 Teensy 4.1 Cortex-M7 @ 600 MHz FreeRTOS · PEC-validates every read IMD isolation monitoring device fault line
Where the isolation boundary sits. Everything left of the dashed line floats at pack potential. The only thing crossing it is the transformer-coupled isoSPI pair — which is precisely why the rules accept it as a module-to-module isolation boundary. The IMD reports insulation failures on the low-voltage side as a separate digital input.

Trusting the data

A daisy chain of transformers through a noisy vehicle is not a friendly channel, so nothing is taken at face value. Every command the Teensy sends carries a PEC15 checksum and every data group that comes back carries a PEC10; a mismatch marks the snapshot invalid rather than letting a corrupted voltage reach the fault logic. A missing or stale measurement is itself a fault condition under the rules, so silence is treated as failure, not as "no news."

Nine tasks, three priorities, one shared picture of the pack

setup() initialises the peripherals, seeds the shared state, spawns the tasks and hands control to the FreeRTOS scheduler — it never returns, and loop() is unused. From there every task operates on the same few global structs: BatteryStatus holds the cell and thermistor arrays plus the pack summary, ChargingStatus holds the charger link, and BMSStatus wraps both with the active faults, the latched faults, and the current state.

Priorities are assigned by consequence, not by convenience. The tasks that can open the shutdown circuit or report a fault to the rest of the car run at the highest priority and preempt everything else; charger housekeeping runs at the lowest. A charger handshake can never delay a fault from reaching the shutdown circuit.

PRODUCERS SHARED STATE CONSUMERS BMSStatus state · active_faults latched_faults BatteryStatus 144 cell mV · 48 temps pack V · min/max · SOC ChargingStatus charger V / I readback heartbeat payload · flags globals declared in main.cpp, passed to each task as context data_aq_task MED (3) · every 16 ms read + PEC-check pack, build snapshot writes canChargerRxTask MED (3) · blocks on RX queue decodes charger frame 0x18FF50E5 writes charger_input_task LOW (1) detects charger connect writes fault_handler_task HIGH (5) · polls every 50 ms asserts SHUTDOWN_PIN on ERROR reads canTxFaultsTask HIGH (5) · every 10 ms BMS_Faults → 0x010 reads canTxSlowTask MED (3) · every 500 ms SOC → 0x020 · temps → 0x021 reads charging_heartbeat_task LOW (1) · 1 Hz BMS → charger control frame reads
Producers write, consumers read, priority decides who wins. The two paths highlighted in amber are the safety path: data_aq_task is the only writer of the pack picture, and fault_handler_task — at the highest priority — is the only task that can open the shutdown circuit.
ModuleResponsibility
shared/Header-only data model: pack limits, fault bits, state enum. Every other module depends on it and it depends on nothing.
data_aq/Drives the monitor stack over SPI, validates PEC, builds the pack snapshot, computes the summary and state of charge, evaluates faults.
fault_handler/Watches the BMS state and asserts the shutdown pin while it is ERROR.
charging/Charger connect/heartbeat state machine, pack limits in the control frame, and the conditions that stop a charge.
can_handler/FlexCAN_T4 on CAN1 at 500 kbit/s. RX interrupt enqueues frames; TX encoders publish faults, SOC and temperatures per the vehicle DBC.
gui/Human-readable telemetry over USB serial — the thing you actually use when the pack is on the bench.

The fault path: detect, latch, open, stay open

Every acquisition cycle ends by evaluating the fresh snapshot against the five conditions the rules enumerate. If any of them trips, the corresponding bit is set in active_faults and — this is the important part — OR'd into latched_faults, which nothing in the firmware ever clears. The state machine moves to ERROR and does not move back.

MONITORED CONDITIONS — CHECKED EVERY SNAPSHOT Cell voltage outside 3.00–4.23 V Cell temperature above limit Measurement missing or stale Comms / PEC checksum failure IMD isolation fault evaluate_faults() builds the active fault bitfield any bit set? no IDLE / CHARGING normal operation continues yes latched_faults |= active_faults state = ERROR — never cleared in firmware Shutdown circuit opens SHUTDOWN_PIN asserted → AIRs drop BMS indicator red, visible to the seated driver TSSI tractive-system status indicator manual reset only
There is no automatic way back. The dashed edge is the only route out of ERROR, and it is a human with a physical reset — the firmware has no path that clears a latched fault. That asymmetry is deliberate: a pack fault that clears itself is a pack fault you get to have twice.

A handshake that defaults to stopping

Charging runs over the same CAN bus using 29-bit extended frames. The BMS sends a control frame to the charger once a second carrying the pack's voltage and current limits; the charger answers with its actual output and its own fault bits. If the heartbeat stops, the charger stops — the protocol's failure mode is "no charge," which is the one you want.

The firmware stops a charge on any of three conditions:

Rules constraint

Cell balancing is not permitted while the shutdown circuit is open, which means balancing can never be a background activity — it has to be explicitly tied to the same state the safety path owns.

Where it stands

The architecture, the shared data model, the CAN layer and the charging state machine are in place, and the driver is up far enough to configure the monitor stack, run the wake sequence and read back configuration and device IDs over the real hardware. The current work is bringing up the full voltage and thermistor read path against a live segment and validating the PEC implementation end to end — the point where measurements stop coming from a test harness and start coming from actual cells.

Build and flash is PlatformIO (pio run -t upload) against a Teensy 4.1, with a serial GUI task for bench diagnostics.

Next project — ReefWatch → All projects