Picking up from where we left off: the project has moved a long way past “SVPWM signals passing by in a dashboard.” We’re now at milestone MS6 — sensorless field-oriented control running closed-loop on the actual STM32G474RE, not the virtual motor — and I pulled a real readout off the release build to see what Fable had actually put on the chip. Numbers below are all measured from that build, not estimates.
The resource budget
The headline is that there’s almost nothing on this chip. Flash sits at 26.7 KB out of 512 KB — 5.1%. That’s the entire Embassy async runtime, the HAL, the FOC controller, the flux observer, and the host protocol, all together. RAM tells a stranger story: 34.4 KB out of 128 KB used, 26.9%, but it’s wildly lopsided. A single [f32; 4096*2] capture buffer for the auto-tune profiler accounts for 32 KB of that — 25% of all static RAM — while literally everything else the firmware needs adds up to about 2.4 KB. Pull that one buffer out from behind a feature flag and the resident footprint drops to under 3 KB.
There’s no heap anywhere in this build. Allocation is fully static, it’s a single executor thread, and the panic handler is panic-halt — if something goes wrong, the MCU stops rather than doing anything undefined. That’s a deliberate trade for a motor controller: predictable failure over graceful degradation.
The platform underneath is a Cortex-M4F at 170 MHz (HSI16, ×85, /2 through the PLL), running embassy-stm32 0.6.0 in no_std/no-alloc mode, built with LTO, one codegen unit, and -O2. Flashing and debugging goes through probe-rs.
The control loop
The control rate is 20 kHz — a 50 microsecond window for the entire FOC ISR, every tick. PWM comes off TIM1 in center-aligned mode at that same 20 kHz carrier (ARR of 4250), capped at 0.85 max duty so there’s always dead time margin. Current sensing is three-shunt, injected off ADC1 running at 42.5 MHz, through 0.33 Ω shunts scaled to 0.504 V/A. Bus voltage comes in through a ×16 divider. All of it talks to the host over LPUART1 at 1 Mbaud, framed with COBS (Consistent Overhead Byte Stuffing — it escapes the frame-delimiter byte out of the payload so the receiver can always find message boundaries on a plain serial stream), as the mmc-proto link — the same channel I’ve been using to steer the whole project.
Motor parameters were pulled from an actual locked-rotor and rotating-fit characterization on the bench motor: 7 pole pairs, stator resistance 1.00 Ω (locked-rotor) reconciling to 0.97 Ω apparent in the rotating fit, flux linkage 0.894 mWb ± 0.04. Inductance is the shaky one — the rotating sweep put it at 0.05–0.15 mH, ill-conditioned because the I-f hang angle near π/2 hides it — so 0.10 mH is used as a robust center that only really sets the current-loop PI zero. Inertia (0.31 µN·m·s²) is flagged as the least-trusted number on the sheet, which is why the speed loop is tuned conservatively rather than aggressively: current loop bandwidth 1 kHz with Kp = L·ω, Ki = R·ω; speed loop around 40 rad/s electrical with small, deliberately soft gains and the i_q command clamped to ±0.8 A.
Modes and how it protects itself
There are five drive modes: 0 off (phases Hi-Z), 1 open-loop forced-frame voltage, 2 open-loop forced-frame current (I-f, live-adjustable), 3 sensorless closed loop (I-f start, blend into a flux+PLL observer, then the speed loop takes over), and 4 the locked-rotor R/L probe used for bench identification. Mode 3 is the one that matters — it’s the actual sensorless control path — and mode 4 is how the motor parameters above got measured in the first place.
Sitting underneath all of that is a fairly serious protection layer for something this small: overcurrent trips at 1.5 A across two consecutive samples, a VBUS window of 5–30 V, a hardware driver fault line (EN_FAULT, open-drain) from the STSPIN830, and a 2-second deadman timeout if the host link goes quiet. Boot calibration takes 8192 ticks (0.41 s). The state channel reports back over telemetry as one of OFF, RUN, FAULT_OC, FAULT_DRV, FAULT_VBUS, or one of the transient states (CAL, SL_RAMP, SL_BLEND), so the host always knows exactly what the firmware thinks it’s doing.
Profiling itself
The auto-tune profiler is the feature I like most, mostly because it’s the one that makes the 32 KB RAM cost make sense. It aligns the rotor (300 ms), excites the d-axis with a square wave (1.6 ms half-period, 32 ticks), and captures i_d and v_d at the full 20 kHz control rate into that burst buffer — 4096 sample pairs, no downsampling, no missed ticks. The host then fits R and L from the capture and writes new gains straight back over the same link: r, l, flux, cur_bw, speed_kp, speed_ki are all live-writable at runtime, range-checked on arrival, and take effect at the next clean drive start. That whole measure-fit-write-back loop happens without ever touching the firmware source — it’s why the resistance and flux numbers above have real confidence flags attached instead of being guesses out of a datasheet.
Watching it work
Telemetry streams 18 channels over the same COBS link, by default downsampled to 1 kHz (÷20 from the control rate), with a separate 20 kHz burst path reserved for the profiler capture. The live set covers the obvious control-loop signals — iq_ref, i_d, i_q, v_d, v_q, the three duty cycles, omega_m, theta_e, vbus, the three phase currents, and state — but it also streams theta_est, omega_est, and theta_err from the observer alongside their measured counterparts. That means every capture doubles as a live check of the sensorless estimator against something closer to ground truth, which has been the fastest way to tell whether the observer is actually tracking or just looking like it is.
Next up: getting into what it took to get mode 3 stable through the I-f-to-observer handoff, which was the part that actually ate the tokens.