Cache-Coherent DMA on a Cortex-M7 Power Analyser
A three-phase power analyser uses an STM32H7 (Cortex-M7) with the D-cache enabled, 32-byte cache lines, write-back write-allocate policy. Two 16-bit ADC channels are sampled at 48 kS/s each through DMA1 into a circular buffer of 512 samples per channel (2 KB total) placed in AXI SRAM at 0x2400_0000. DMA raises a half-transfer interrupt and a transfer-complete interrupt; the processing task converts the freshly completed half (256 samples per channel, about 5.33 ms of data) into RMS and harmonic values.
After the D-cache was enabled to speed up the FFT, RMS readings show random spikes of 3–8 % that last exactly one processing block, in roughly 1 block out of 500. Disabling the D-cache makes the spikes disappear. The buffer is declared inside a struct as uint8_t status; uint16_t adc_buf[1024]; and the struct starts at a 32-byte-aligned address. A colleague proposes adding __DSB() and __ISB() at the top of the DMA interrupt handler and rebuilding.
1. Diagnose what is happening, identifying every distinct failure mode present in this design.
Read the worked answer
There are two independent bugs, and fixing one will not fix the other - which is exactly why the symptom rate is low and the failure looks random.
Bug 1 - stale reads (DMA writes bypass the cache). DMA is a bus master that writes to AXI SRAM directly. The CPU's D-cache may already hold those lines from the previous block, so when the processing task reads the buffer it gets cached copies of the previous block. A whole 32-byte line is stale together, which is why the artefact lasts exactly one block and shows up as an abrupt RMS spike rather than as a smooth error. The rate (1 in 500) matches the probability that the affected lines happen to be resident at the moment of the read - normally the buffer was evicted by the FFT working set.
Bug 2 - unaligned, over-broad invalidation. adc_buf is preceded by a uint8_t status, so the array starts at offset 2 rather than at a 32-byte boundary. Cache maintenance operates on whole lines. Invalidating "the buffer" by address therefore touches the line that also contains status and whatever else shares that line. Invalidating a line that holds dirty CPU-written data discards those writes: the next read of status returns stale memory content, not the value the CPU wrote. That is a second, distinct corruption path with a different signature.
Why __DSB()/__ISB() is not a fix. These are instruction-ordering barriers. DSB guarantees that explicit memory accesses issued before it have completed; it does not touch cache contents and does not evict or invalidate anything. It cannot make the CPU see DMA-written data, and it cannot undo a bad invalidate. At best it makes an already-correct sequence correctly ordered.
2. Propose two correct architectures, choose one, and justify the choice for this product.
Read the worked answer
Option 1 - non-cacheable DMA region via the MPU. Configure the 2 KB buffer window as Normal, non-cacheable, shareable (or Device) memory. The CPU then always goes to SRAM, so DMA and CPU see the same bytes with no maintenance code and no ordering subtleties.
- Pros: impossible to get wrong; zero maintenance cost; no alignment requirement; robust to future code that forgets an invalidate.
- Cons: every ADC buffer read and every write to that window is uncached. At 2 KB per block and 8 blocks per 42 ms, the CPU cost is small - a few thousand extra cycles per 5.33 ms block, well under 1 % of a 480 MHz core. The window also stops benefiting from the cache for the pre-FFT copy.
Option 2 - aligned buffer plus explicit cache maintenance. Enforce 32-byte alignment with __attribute__((aligned(32))) or a linker section, remove the uint8_t status predecessor or move it to a separate cache line, size the buffer to a multiple of 32 bytes, and:
before the CPU reads a completed half:
SCB_InvalidateDCache_by_Addr(half, len)- invalidate only, because the CPU has nothing dirty in an input buffer;before DMA reads a buffer the CPU produced (the DAC/output direction, if added later):
SCB_CleanDCache_by_Addr(...)- clean, never invalidate, or CPU data is lost.Pros: full cache performance for the FFT; explicit and auditable.
Cons: every maintenance call is a chance to get the direction or the alignment wrong, and the failure mode is silent.
Recommendation: Option 1, with Option 2's alignment discipline applied anyway. The buffer is small, the CPU time saved by caching it is negligible, and the product must remain correct after firmware maintenance by engineers who did not write the original code. Non-cacheable memory makes the ADC path obviously correct. Keep the FFT working set cacheable - that is where the performance actually is. Apply the alignment rules to all DMA descriptors regardless, so that the choice can be revisited without a redesign.
3. If the team insists on keeping the buffer cacheable, write the exact sequence required and describe how you would prove it works.
Read the worked answer
Required sequence per half-buffer:
- In the DMA half-transfer/complete ISR: record which half completed, then give a semaphore. Do not invalidate here - the processing task must invalidate immediately before it reads, so that nothing re-populates the cache in between.
- In the task, before reading half n:
SCB_InvalidateDCache_by_Addr((uint32_t*)half_n, bytes_n). The address must be 32-byte aligned and the length a multiple of 32 bytes, otherwise the first and last lines overlap data owned by other variables. - Read and process the half. Do not write into the buffer the DMA is currently filling.
- If the CPU ever writes to a region that DMA will read, call
SCB_CleanDCache_by_Addr()before starting the DMA - cleaning, not invalidating. Invalidate-before-DMA-read silently destroys the CPU's write-back data. __DSB()after a clean, before triggering the DMA, is the one place a barrier is genuinely needed: it ensures the clean has completed before the DMA engine is started.
Proof:
- Verify
((uint32_t)adc_buf % 32) == 0andsizeof(adc_buf) % 32 == 0at compile time with_Static_assert, and put the buffer in its own named section so a future struct edit cannot silently misalign it. - Fill the buffer with a known ramp pattern from a DAC or a test generator and run for hours at maximum ADC rate with the FFT at full load. Any stale line shows up as a repeated block; any lost write shows up as a
status/counter regression. - Add a runtime check that fails fast if the DMA ISR fires while the task is mid-invalidation of the same half (a flag is enough) - that condition means the maintenance points are in the wrong place.
- Benchmark the maintenance cost: invalidating 1 KB (32 lines) takes well under a microsecond on the M7, which is trivial against the 5.33 ms block budget. If you cannot show that number, you cannot defend the extra complexity over the non-cacheable option.