HomeAbout UsContact Us

Zero-Copy DMA Patterns on ARM Cortex-M

By Jithin Tom
Published in Embedded C/C++
July 19, 2026
3 min read
Zero-Copy DMA Patterns on ARM Cortex-M

Table Of Contents

01
Peripheral-to-Memory Direct Placement
02
Double-Buffered Ping-Pong
03
Circular DMA with Pointer Chasing
04
Cache Coherency Checklist for Cortex-M7
05
MPU vs Cache Maintenance Trade-offs
06
Synchronization Primitives
07
Summary
08
Related Reading
09
References
10
Frequently Asked Questions

Zero-copy DMA is not a luxury optimization — it is the difference between meeting hard real-time deadlines and missing them on Cortex-M. Every memcpy between a peripheral buffer and your application buffer burns cycles, pollutes cache, and introduces non-deterministic latency. On a Cortex-M4 at 168 MHz, a single 4 KB memcpy costs ~25 µs. On a Cortex-M7 with cache, it costs more due to write-back traffic. Multiply by 1000 transfers per second and you have 25 ms of pure copy overhead per second — 2.5% of CPU time doing nothing useful.

This article covers three zero-copy patterns that eliminate intermediate copies entirely: peripheral-to-memory direct placement, double-buffered ping-pong, and circular DMA with pointer chasing. Each pattern includes cache coherency handling for Cortex-M7, MPU configuration for non-cacheable regions, and synchronization primitives that work correctly with DMA interrupts.

Peripheral-to-Memory Direct Placement

The simplest zero-copy pattern: configure DMA to write directly into your application’s working buffer. No intermediate staging buffer, no memcpy.

#define ADC_BUFFER_SIZE 2048
#define ADC_BUFFER_ALIGN 4096 // ARMv7-M MPU requires alignment to region size (4KB)
__attribute__((aligned(ADC_BUFFER_ALIGN)))
static uint16_t adc_buffer[ADC_BUFFER_SIZE];
static void adc_dma_init(void) {
__HAL_RCC_DMA2_CLK_ENABLE();
hdma_adc.Instance = DMA2_Stream0;
hdma_adc.Init.Channel = DMA_CHANNEL_0;
hdma_adc.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_adc.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_adc.Init.MemInc = DMA_MINC_ENABLE;
hdma_adc.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma_adc.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_adc.Init.Mode = DMA_CIRCULAR;
hdma_adc.Init.Priority = DMA_PRIORITY_HIGH;
hdma_adc.Init.FIFOMode = DMA_FIFOMODE_ENABLE;
hdma_adc.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_HALFFULL;
hdma_adc.Init.MemBurst = DMA_MBURST_SINGLE;
hdma_adc.Init.PeriphBurst = DMA_PBURST_SINGLE;
HAL_DMA_Init(&hdma_adc);
__HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc);
HAL_NVIC_SetPriority(DMA2_Stream0_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(DMA2_Stream0_IRQn);
}

On Cortex-M7, the buffer must be 32-byte aligned and you must invalidate the cache region before DMA starts writing:

static void adc_start_zero_copy(void) {
SCB_InvalidateDCache_by_Addr((uint32_t*)adc_buffer, sizeof(adc_buffer));
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);
}

The ADC conversion complete callback processes data directly from adc_buffer — no copy needed. The buffer lives in your application’s address space from the first byte.

Critical: On Cortex-M7, if the buffer is in cacheable memory (default SRAM), you must invalidate before DMA writes and clean before DMA reads. The alternative: place the buffer in a non-cacheable MPU region.

static void mpu_config_non_cacheable_buffer(void) {
MPU_Region_InitTypeDef mpu_region = {0};
mpu_region.Enable = MPU_REGION_ENABLE;
mpu_region.Number = MPU_REGION_NUMBER0;
mpu_region.BaseAddress = (uint32_t)adc_buffer;
mpu_region.Size = MPU_REGION_SIZE_4KB; // Must exactly match buffer size (4KB)
mpu_region.SubRegionDisable = 0x00;
mpu_region.TypeExtField = MPU_TEX_LEVEL0;
mpu_region.AccessPermission = MPU_REGION_FULL_ACCESS;
mpu_region.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE;
mpu_region.IsShareable = MPU_ACCESS_SHAREABLE;
mpu_region.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; // Key: non-cacheable
mpu_region.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
HAL_MPU_ConfigRegion(&mpu_region);
HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

With non-cacheable MPU region, you skip all cache maintenance. The trade-off: CPU reads/writes to this region bypass L1 cache, costing ~2-3 cycles per access vs 1 cycle cached. For DMA buffers written once and read once, this is optimal.

+------------------------------------------------------------------+
| ZERO-COPY DMA DATA FLOW |
+------------------------------------------------------------------+
| |
| PERIPHERAL (ADC/UART/SPI) |
| | |
| v DMA REQUEST |
| +-----------+ DMA TRANSFER +------------------------+ |
| | DMA CTRL | -------------------> | APPLICATION BUFFER | |
| +-----------+ (NO CPU COPY) | (32-byte aligned) | |
| +------------------------+ |
| | |
| CACHE INVALIDATE (M7 only) |
| | |
| v |
| +------------------+ |
| | CPU PROCESSES | |
| | DATA DIRECTLY | |
| +------------------+ |
| |
+------------------------------------------------------------------+

Double-Buffered Ping-Pong

For frame-based processing (audio blocks, sensor frames, protocol packets), double buffering lets you process one frame while DMA fills the next. The CPU never touches the active DMA buffer.

#define FRAME_SIZE 1024
#define FRAME_ALIGN 32
// STM32 HAL expects a single contiguous buffer for half/full complete callbacks
__attribute__((aligned(FRAME_ALIGN)))
static uint16_t dma_buffer[FRAME_SIZE * 2];
static volatile uint8_t active_half = 0; // 0 = first half, 1 = second half
static volatile uint8_t frame_ready = 0;
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
if (hadc->Instance == ADC1) {
// First half complete - process first half while DMA fills second half
active_half = 0;
frame_ready = 1;
}
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
if (hadc->Instance == ADC1) {
// Second half complete - process second half while DMA fills first half
active_half = 1;
frame_ready = 1;
}
}
static void process_frame_zero_copy(void) {
uint16_t* process_buf = (active_half == 0) ? &dma_buffer[0] : &dma_buffer[FRAME_SIZE];
// Cache maintenance if not using MPU non-cacheable region
SCB_InvalidateDCache_by_Addr((uint32_t*)process_buf, FRAME_SIZE * sizeof(uint16_t));
// Process directly - no memcpy
for (uint32_t i = 0; i < FRAME_SIZE; i++) {
// DSP algorithm here - FIR, FFT, decimation, etc.
process_sample(process_buf[i]);
}
frame_ready = 0;
}

The key synchronization: The active_half variable is explicitly set in each callback to indicate which half of the buffer is ready for processing. The half-complete callback signals the first half is ready, while the full-complete callback signals the second half is ready. This ensures you process the correct chunk while the DMA safely writes to the other half.

+------------------------------------------------------------------+
| DOUBLE BUFFER TIMELINE |
+------------------------------------------------------------------+
| |
| TIME --------------------------------------------------------> |
| |
| DMA: [===== FILL FIRST HALF ====][==== FILL SECOND HALF ====] |
| | | |
| v v |
| CPU: [==== PROCESS 2ND HALF ====][==== PROCESS 1ST HALF ====] |
| ^ ^ |
| | | |
| HALF CPLT FULL CPLT |
| (1ST READY) (2ND READY)|
| |
| BUFFER OWNERSHIP: |
| 1ST HALF: DMA owns [0...HALF_CPLT) CPU owns [HALF_CPLT...END] |
| 2ND HALF: CPU owns [0...HALF_CPLT) DMA owns [HALF_CPLT...END] |
| |
+------------------------------------------------------------------+

Circular DMA with Pointer Chasing

For continuous streams (UART RX, SPI slave, I2S audio), circular DMA with a write pointer is optimal. The CPU chases the DMA write pointer, processing data up to the current position.

#define RING_SIZE 8192
#define RING_ALIGN 32
__attribute__((aligned(RING_ALIGN)))
static uint8_t uart_rx_ring[RING_SIZE];
static volatile uint32_t cpu_read_pos = 0;
void uart_rx_circular_init(void) {
__HAL_RCC_DMA1_CLK_ENABLE();
hdma_uart_rx.Instance = DMA1_Stream1;
hdma_uart_rx.Init.Channel = DMA_CHANNEL_4;
hdma_uart_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_uart_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_uart_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_uart_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_uart_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_uart_rx.Init.Mode = DMA_CIRCULAR;
hdma_uart_rx.Init.Priority = DMA_PRIORITY_HIGH;
hdma_uart_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
HAL_DMA_Init(&hdma_uart_rx);
__HAL_LINKDMA(&huart3, hdma_rx, hdma_uart_rx);
__HAL_DMA_ENABLE_IT(&hdma_uart_rx, DMA_IT_HT | DMA_IT_TC);
HAL_NVIC_SetPriority(DMA1_Stream1_IRQn, 6, 0);
HAL_NVIC_EnableIRQ(DMA1_Stream1_IRQn);
HAL_UART_Receive_DMA(&huart3, uart_rx_ring, RING_SIZE);
}
static uint32_t get_dma_write_pos(void) {
return RING_SIZE - __HAL_DMA_GET_COUNTER(&hdma_uart_rx);
}
static void process_uart_stream(void) {
uint32_t write_pos = get_dma_write_pos();
if (write_pos == cpu_read_pos) {
return; // No new data
}
// Cache invalidate only the new region (using safe alignment wrapper)
if (write_pos > cpu_read_pos) {
cache_invalidate_range(
(void*)(uart_rx_ring + cpu_read_pos),
write_pos - cpu_read_pos
);
// Process linear chunk
parse_uart_data(uart_rx_ring + cpu_read_pos, write_pos - cpu_read_pos);
} else {
// Wrapped - two chunks
cache_invalidate_range(
(void*)(uart_rx_ring + cpu_read_pos),
RING_SIZE - cpu_read_pos
);
parse_uart_data(uart_rx_ring + cpu_read_pos, RING_SIZE - cpu_read_pos);
cache_invalidate_range(
(void*)uart_rx_ring,
write_pos
);
parse_uart_data(uart_rx_ring, write_pos);
}
cpu_read_pos = write_pos;
}

The DMA counter (NDTR register) counts remaining transfers. Subtracting from buffer size gives the write position. Cache invalidation targets only the newly-written region, not the entire ring. Note that we use a custom cache_invalidate_range function here (defined in the next section) rather than bare SCB_InvalidateDCache_by_Addr because UART write positions are typically unaligned, which would cause an alignment fault on Cortex-M7.

+------------------------------------------------------------------+
| CIRCULAR DMA POINTER CHASE |
+------------------------------------------------------------------+
| |
| RING BUFFER (8 KB) |
| |
| +----+----+----+----+----+----+----+----+----+----+----+----+ |
| | D | A | T | A | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
| +----+----+----+----+----+----+----+----+----+----+----+----+ |
| ^ ^ |
| | | |
| cpu_read_pos write_pos |
| (CPU processed) (DMA head) |
| |
| NEW DATA = (write_pos - cpu_read_pos) mod RING_SIZE |
| |
| CPU processes from cpu_read_pos up to write_pos, |
| then updates cpu_read_pos = write_pos |
| |
+------------------------------------------------------------------+

Cache Coherency Checklist for Cortex-M7

OperationCache ActionFunction
DMA writes to memory (Periph->Mem)Invalidate before DMA startsSCB_InvalidateDCache_by_Addr()
DMA reads from memory (Mem->Periph)Clean before DMA startsSCB_CleanDCache_by_Addr()
CPU reads after DMA writeInvalidate after DMA completeSCB_InvalidateDCache_by_Addr()
CPU writes before DMA readClean after CPU writeSCB_CleanDCache_by_Addr()

Alignment requirement: All SCB_*DCache_by_Addr calls require 32-byte aligned address and size multiple of 32 bytes. Use __attribute__((aligned(32))) on buffers and round sizes up.

#define CACHE_LINE_SIZE 32
static void cache_invalidate_range(void* addr, uint32_t len) {
uint32_t start = (uint32_t)addr & ~(CACHE_LINE_SIZE - 1);
uint32_t end = ((uint32_t)addr + len + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1);
SCB_InvalidateDCache_by_Addr((uint32_t*)start, end - start);
}
static void cache_clean_range(void* addr, uint32_t len) {
uint32_t start = (uint32_t)addr & ~(CACHE_LINE_SIZE - 1);
uint32_t end = ((uint32_t)addr + len + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1);
SCB_CleanDCache_by_Addr((uint32_t*)start, end - start);
}

MPU vs Cache Maintenance Trade-offs

ApproachProsCons
MPU non-cacheable regionZero cache maintenance, deterministic timingCPU accesses 2-3x slower, consumes MPU region (8 max)
Explicit cache maintenanceFull cache speed for CPU, flexibleEasy to get wrong → data corruption, adds code complexity

Recommendation: Use MPU non-cacheable for high-frequency DMA buffers (ADC, UART, SPI > 100 kHz). Use explicit cache maintenance for infrequent large transfers (SD card, flash, display) where CPU cache speed matters.

Synchronization Primitives

Never use bare volatile for DMA-CPU synchronization. Use C11 atomics with memory ordering:

#include <stdatomic.h>
static _Atomic uint32_t dma_write_index = 0;
static _Atomic uint32_t cpu_read_index = 0;
void dma_complete_callback(void) {
// Release: ensure all DMA writes visible before index update
atomic_store_explicit(&dma_write_index, new_write_pos, memory_order_release);
}
void cpu_process_loop(void) {
// Acquire: ensure we see all DMA writes after index read
uint32_t write_pos = atomic_load_explicit(&dma_write_index, memory_order_acquire);
if (write_pos != cpu_read_index) {
process_data(cpu_read_index, write_pos);
atomic_store_explicit(&cpu_read_index, write_pos, memory_order_release);
}
}

On Cortex-M (ARMv7-M), memory_order_release compiles to DMB + STR, memory_order_acquire compiles to LDR + DMB. The DMB (Data Memory Barrier) ensures ordering of memory accesses without the heavier completion semantics of DSB. Correct and portable.

Summary

Zero-copy DMA on Cortex-M eliminates the memcpy tax entirely. Three patterns cover most use cases:

  1. Direct placement — simplest, best for single-shot or continuous processing where CPU keeps up
  2. Double buffering — best for frame-based DSP (audio, vision, sensor fusion) with predictable frame sizes
  3. Circular with pointer chase — best for variable-rate streams (UART, SPI slave, CAN) where processing latency varies

The Cortex-M7 cache is the main complexity. Either configure MPU non-cacheable regions for DMA buffers (simplest, safest) or apply disciplined cache maintenance with 32-byte alignment. Use C11 atomics for synchronization — not volatile.

Measure your memcpy overhead before and after. On a typical Cortex-M4 application with 50 KB/s UART + 200 KB/s ADC + 1 MB/s SPI, zero-copy saves 15-30% CPU cycles. That margin is often the difference between adding a new feature and hitting the thermal limit.

  • Fixing I2C Clock Stretching Timeouts on STM32
  • DMA Programming in Embedded C for High-Throughput Data Transfer
  • ARM Cortex-M NVIC Interrupt Latency Optimization

References

  1. ARM Cortex-M7 Processor Technical Reference Manual, “Cache Maintenance Operations” (ARM DDI 0489)
  2. STM32F7 Series Reference Manual, “DMA Controller (DMA)” (RM0410)
  3. ARM Application Note 321, “ARM Cortex-M Programming Guide to Memory Barrier Instructions” (ARM DAI 0321)
  4. “Real-Time Embedded Systems with ARM Cortex-M” — Jonathan Valvano, ISBN 978-1477508992
  5. “Embedded Systems: Real-Time Operating Systems for ARM Cortex-M” — Jonathan Valvano, ISBN 978-1466468863

Frequently Asked Questions

What is zero-copy DMA and why does it matter on Cortex-M?

Zero-copy DMA transfers data directly between peripherals and memory without intermediate CPU copies, eliminating memcpy overhead. On Cortex-M, this reduces latency by 30-50% for high-throughput streams like ADC, UART, or SPI, although cache coherency must be explicitly managed on Cortex-M7.

How does cache coherency affect zero-copy DMA on Cortex-M7?

Cortex-M7 has L1 data cache. DMA bypasses cache, so software must clean (write-back) cache lines before DMA reads and invalidate cache lines after DMA writes. Use SCB_CleanDCache_by_Addr and SCB_InvalidateDCache_by_Addr with 32-byte aligned buffers.

When should I use double buffering vs circular DMA for zero-copy?

Double buffering suits fixed-size frames (e.g., audio blocks, sensor frames) where you process one buffer while DMA fills the other. Circular DMA suits continuous streams with variable processing latency — the CPU chases the write pointer.

What alignment constraints apply to zero-copy DMA buffers?

Buffers must be 32-byte aligned for cache line operations on Cortex-M7. For Cortex-M4/M3 without cache, 4-byte alignment suffices. Always place DMA buffers in non-cacheable regions (MPU) or use __attribute__((aligned(32))) with explicit cache maintenance.

How do I prevent data corruption when CPU and DMA access the same buffer?

Use memory barriers (DSB/ISB) and cache maintenance around DMA triggers. For double buffering, use a volatile buffer index flag updated with __atomic_store_n/__atomic_load_n. Never access the active DMA buffer until the transfer complete interrupt confirms ownership transfer.

Tags

embedded-cdmacortex-mzero-copymemory-managementstm32

Share


Previous Article
Reducing ARM Cortex-M Interrupt Latency with Tail-Chaining
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Link-Time Optimization (LTO) for Embedded Firmware Size Reduction
Link-Time Optimization (LTO) for Embedded Firmware Size Reduction
August 08, 2026
2 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media