STM32H7: DMA Double-Buffering Mistake That Looked Like SPI Drift
A week of false leads ended in one NVIC priority inversion and a lesson about completion interrupts I won't forget.

A week of false leads ended in one NVIC priority inversion and a lesson about completion interrupts I won't forget. The symptom looked like SPI drift — ADC samples arriving half a frame late, timestamps sliding by microseconds per second — but the root cause was DMA double-buffering configured correctly on paper and broken in the interrupt hierarchy.
Answer first
The SPI peripheral was fine. The DMA stream was fine. What failed was which interrupt ran first when both the SPI RX completion and the DMA half-transfer fired in the same microsecond window on an STM32H743.
Setup
Board: custom sensor front-end, STM32H743VIT6, AD7768-1 over SPI at 10 MHz, DMA1 Stream0 in circular double-buffer mode. Firmware: bare-metal, no RTOS, HAL 1.11.2 (I know — we were mid-migration to LL).
Goal: continuous 192 kSPS capture into two 4096-sample buffers, ping-ponging on HT (half-transfer) and TC (transfer-complete) interrupts.
// Simplified — the shape that looked correct
hdma_spi1_rx.Init.Mode = DMA_CIRCULAR;
hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_WORD;
NVIC priorities at bring-up:
- SPI1 global: priority 2
- DMA1 Stream0: priority 3
- SysTick: priority 0
What looked like SPI drift
The application consumed buffer_a / buffer_b based on flags set in DMA ISRs. Over tens of seconds, the consumer's index drifted relative to a reference 1 PPS GPIO tick. Classic symptoms we chased:
- Clock mismatch — measured SCK with a Rigol DS1054Z; 10.000 MHz ± ppm, clean.
- CPOL/CPHA — wrong mode gives garbage immediately, not slow drift. Ruled out fast using the approach in SPI bus debugging notes.
- Buffer overrun — logic analyzer on DRDY + CS showed no gaps; DMA kept up.
- Cache coherency — separate war story; we had MPU regions marked non-cacheable for DMA buffers by this point (see STM32H7 cache coherency with DMA).
The drift was one sample every ~8000 frames, which is exactly the kind of off-by-one that double-buffering hides until it doesn't.
Findings
Saleae capture at 24 MHz (same rig we use for I2C glitch taxonomy) showed SPI frames were periodic. The bug was in software bookkeeping.
Sequence that broke us:
- HT fires → ISR sets
active = 1, points consumer at buffer 0. - SPI RXNE/overrun path also runs (HAL enables SPI error IRQ "for safety").
- SPI ISR touches the same
activeflag and increments a frame counter before HT handler finishes. - On ~1/8192 events, TC and SPI fire in the same priority inversion window because SPI priority was higher (lower number) than DMA.
The HAL SPI IRQ handler was doing more work than we needed — clearing flags, updating internal state — and it was preempting the DMA HT handler mid-update. Two writers, one flag, no atomic protection.
Secondary issue: we were using HAL_SPI_Receive_DMA restart patterns that assumed TC always meant "safe to swap." With a preempting SPI handler, TC sometimes ran with a stale active index.
NVIC fix
Swapped priorities so DMA stream > SPI:
DMA1_Stream0_IRQn → priority 1, sub 0
SPI1_IRQn → priority 2, sub 0
Disabled SPI RX interrupt entirely — DMA owns the data path; SPI errors go to polling or a minimal error hook on OVR only.
Double-buffer discipline
Replaced flag toggling with index math tied to HT/TC only:
void DMA1_Stream0_IRQHandler(void) {
if (__HAL_DMA_GET_FLAG(&hdma, HT)) {
consumer_idx = 0; // first half ready
__HAL_DMA_CLEAR_FLAG(&hdma, HT);
}
if (__HAL_DMA_GET_FLAG(&hdma, TC)) {
consumer_idx = 1; // second half ready
__HAL_DMA_CLEAR_FLAG(&hdma, TC);
}
}
No shared mutable state outside that ISR except a single volatile uint8_t consumer_idx read by the main loop.
Oscilloscope confirmation
Used pulse-width trigger on DRDY (see oscilloscope trigger cheat sheet) to bracket 1000 consecutive frames. After the NVIC change, PPS alignment held for 24-hour soak with zero sample slip.
False leads we burned time on
Before NVIC, the team spent three days on plausible-but-wrong theories:
SPI clock drift from HSE trim. We re-trimmed HSE using USB SOF as reference — useful calibration, irrelevant to sample slip. The slip rate correlated with interrupt load, not temperature.
DMA FIFO threshold misconfiguration. H7 SPI can use FIFO packing; we tried threshold 1/4, 1/2, full. Changed latency, not drift.
Compiler optimization. -O2 vs -Os moved ISR timing enough to change slip rate — which should have been the clue that preemption was involved. We blamed "undefined behavior" until a -O0 build also slipped, just slower.
Buffer alignment. Already 32-byte aligned for cache; re-checked with MPU disabled — no change.
Document false leads in the ticket when you close it. The next engineer will repeat HSE trim otherwise.
HAL vs LL: what we kept
We didn't rewrite the whole SPI path. Minimal LL patch:
- Disable
SPI_IT_RXNEandSPI_IT_ERRafterHAL_SPI_Receive_DMAstarts - Route DMA HT/TC only
- Keep HAL for init and error recovery paths used once at boot
CubeMX regenerates still merge cleanly if changes live in USER CODE blocks — discipline matters.
Review checklist (copy to PR template)
- DMA ISR priority ≥ any ISR touching same buffer metadata
- SPI IRQ disabled or lower priority when DMA owns RX
- HT and TC both update consumer state (half-buffer ping-pong)
- No HAL SPI callback from DMA context
- 24 h soak test with scope PPS check logged
What I'd do next
- Move to MDMA or BDMA for this particular path — H7 has three DMA controllers; Stream0 on DMA1 shares the bus with other traffic. Not the bug here, but next optimization.
- Static analysis rule: any ISR that touches buffer ownership must be same or higher priority than all other ISRs touching that state. Lint this in code review.
- Drop HAL for the hot path — LL + explicit IRQ routing; HAL's SPI IRQ is a footgun on H7 when DMA is primary.
The lesson isn't "double buffering is hard." It's that completion interrupts compete, and on Cortex-M7 with multiple concurrent events, priority ordering is part of the data contract — not an afterthought.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

