Bare metal ventilator controller on STM32F4. No OS, no HAL. Runs in Renode (hardware simulator). PID controls turbine speed to hit pressure targets. Fixed-point math, no FPU.
A peripheral is any hardware block on the chip that isn't the CPU — timers, UART, GPIO. The CPU talks to them through registers. They do their job independently once configured.
Every peripheral is dead until you turn on its clock (RCC — power saving, only turn on what you need). Then you wire pins to the right peripheral — GPIO alternate function config connects the physical pin to the internal peripheral. Then configure each peripheral.
Three init levels:
- Clocks — RCC enables for GPIO, TIM2, TIM3, USART2
- Pins — PA2 → USART2 TX (AF7), PA6 → TIM3 PWM (AF2). Mappings fixed by datasheet.
- Peripherals — all same level hardware-wise, but TIM2 started last (software concern — its ISR uses everything else):
- USART2: 115200 baud (16MHz / 139). Fast enough for logs within the 10ms tick, slow enough to be universal. Interrupt on RX byte → ring buffer.
- TIM3 (PWM): prescaler 15 → 1MHz tick, ARR 999 → 1kHz PWM, 1000 duty steps. Inaudible to motor, 0.1% resolution.
- TIM2 (tick): prescaler 15999 → 1kHz tick, ARR 9 → 10ms period (100Hz). ~25× oversampling of lung dynamics (RC τ=250ms).
Interrupts need two enables: one on the peripheral side, one on the CPU side (NVIC). Miss either and the handler never fires.
TIM2 fires → ISR clears the interrupt flag → calls state_machine_tick():
- Decrement state timer. If not expired: read pressure from sensor register (0x50000000), run PID, PID outputs duty percentage.
- If timer expired: transition to next state (INHALE→HOLD→EXHALE→INHALE). On EXHALE entry specifically — zero the PID integral, zero duty, reset derivative memory. Other transitions don't touch PID.
- Back in the ISR: take the duty percentage PID computed, write it to TIM3 (PWM hardware). TIM3 converts that to a real waveform on PA6 that drives the turbine motor.
That's the entire closed loop. Sensor → PID → PWM, 100 times a second.
After all peripherals are initialized and TIM2 is started (last, because it kicks off interrupts), main enters an infinite loop:
- Check if state changed (flag set by ISR) → if yes, log the transition over UART (state name, pressure, duty).
- Drain RX ring buffer — bytes arrive from UART interrupt into a ring buffer, main loop pulls them out and feeds them to the command parser (runtime PID tuning, timing changes).
- Flush TX ring buffer to UART hardware — ISR writes log lines into a ring buffer, main loop actually pushes bytes out over serial.
WFI— wait for interrupt. CPU sleeps until next TIM2 tick or UART byte arrives. Saves power, no busy-waiting.
INHALE (1s, target 20) → HOLD (0.5s, target 20) → EXHALE (1.5s, target 5). Repeat. ~20 breaths/min.
HOLD exists to let flow drop to zero so you see plateau pressure (pure elastic V/C) — clinically important.
On EXHALE entry: zero PID integral, zero duty, reset derivative memory. Other transitions don't touch PID state.
- P: proportional to error (target - pressure)
- I: sum of past errors — eliminates steady-state error. Summing not averaging — measures "how long off target"
- D: derivative on measurement (not error) to avoid setpoint kicks
- Anti-windup: back-calculation. When output saturates (clamped to 0-100%), the difference feeds back into the integral to pull it down. Prevents integral from growing unbounded during saturation.
Three separate processes, strict boundaries:
- ventway — firmware process. Runs on its own (emulated) CPU. Only talks through registers — reads pressure from
0x50000000, writes duty to TIM3. Knows nothing about lungs. - lung_model — patient process. Independent physics simulation. Receives flow, computes pressure (RC circuit). Knows nothing about firmware.
- sim — glue process. Connects the other two through the register interface. Reads duty from ventway, feeds it to lung_model as flow, takes pressure back, writes it to the sensor register.
Each process is a separate codebase, separate build, separate unit tests. They can only communicate through the register interface — no function calls, no shared memory, no imports. Process isolation enforces the boundary.
The closed loop: ventway writes duty → sim reads it → lung_model turns it into pressure → sim writes pressure to 0x50000000 → ventway reads it next tick. Same loop as real hardware: ventilator → turbine → air → patient → pressure sensor → ventilator.
Startup: Robot Framework (4th process) orchestrates:
- Start sim — sets up the register interface
- Start lung_model — connects to sim, ready to receive flow
- Start ventway — firmware boots, starts reading registers, control loop goes live
Once all three are up, Robot just watches UART output. Pure black-box: it only sees what the firmware prints over serial, same as a real operator. Tests check state transitions happen, pressure hits targets, duty is zero during exhale.