The problem
The client's pulse induction detector drives a coil at 3.125 kHz and needs the 29.1 kHz carrier recovered as magnitude and phase every millisecond, fast enough to react inside the operator's swing. The previous firmware was a polled ADC loop with printf debugging, and the timing was already gone. The vendor HAL wrapped the DMA path in callbacks with latency nobody could predict, and dropped windows showed up as missed targets on a real scan.
What I did
I wrote the startup and linker by hand: vector table, .data copy, .bss zero, FPU enable, jump to main. No CubeMX. TIM6 ticks at 2 MHz and triggers ADC1 on PA0. The results land in a 4000 sample circular DMA buffer split into two halves. The half transfer interrupt hands the first half to the demodulator and the transfer complete interrupt hands the second, each exactly 1 ms of carrier, 58 full cycles at 29.1 kHz.
The demodulator subtracts the DC offset, multiplies each sample by a length 69 cosine and sine lookup table, accumulates I and Q, and emits magnitude and phase as fixed point integers on UART at 2 Mbaud. A heartbeat counts windows per second and a watchdog fires if the DMA stops for more than 100 ms. Every node in the analog chain was captured on a scope before any application code: gate drive, coil flyback at minus 22 V, op amp output, ADC input.
Decisions that shaped it
- Bare metal, because deterministic interrupt latency mattered more than convenience.
- Timer triggered ADC, not polled. The CPU only wakes inside the demod interrupt.
- Circular DMA with half and complete interrupts, so a missed half is caught by the watchdog rather than corrupting a window silently.
- Scope on every node before the app code.
Result
I and Q at the rate the front end runs, about 1000 updates a second over USB, with no vendor middleware.



