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 32
__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_8KB; // Must cover full buffer + alignment
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 MAINTENANCE v |
| | (Cortex-M7 only) +--------------+ |
| +--------------------------------> | CPU PROCESSES | |
| | 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
__attribute__((aligned(FRAME_ALIGN)))
static uint16_t ping_buffer[FRAME_SIZE];
__attribute__((aligned(FRAME_ALIGN)))
static uint16_t pong_buffer[FRAME_SIZE];
static volatile uint8_t dma_active_buffer = 0; // 0 = ping, 1 = pong
static volatile uint8_t frame_ready = 0;
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
if (hadc->Instance == ADC1) {
// First half complete - process ping while DMA fills pong
frame_ready = 1;
}
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
if (hadc->Instance == ADC1) {
// Second half complete - process pong while DMA fills ping
dma_active_buffer ^= 1;
frame_ready = 1;
}
}
static void process_frame_zero_copy(void) {
uint16_t* process_buf = (dma_active_buffer == 0) ? pong_buffer : ping_buffer;
// Cache maintenance if not using MPU non-cacheable region
SCB_InvalidateDCache_by_Addr((uint32_t*)process_buf, FRAME_SIZE * 2);
// 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: dma_active_buffer flips in the complete callback, not the half callback. The half callback signals the previous buffer is ready. This avoids races where DMA overwrites the buffer you’re processing.

+------------------------------------------------------------------+
| DOUBLE BUFFER TIMELINE |
+------------------------------------------------------------------+
| |
| TIME ---> |
| |
| DMA: [===== PING FILLING =====][===== PONG FILLING =====] |
| | | |
| v v |
| CPU: [==== PROCESS PONG ====][==== PROCESS PING ====] |
| ^ ^ |
| | | |
| HALF CPLT FULL CPLT |
| (PONG READY) (PING READY) |
| |
| BUFFER OWNERSHIP: |
| PING: DMA owns [0...HALF_CPLT) CPU owns [HALF_CPLT...END] |
| PONG: 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
if (write_pos > cpu_read_pos) {
SCB_InvalidateDCache_by_Addr(
(uint32_t*)(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
SCB_InvalidateDCache_by_Addr(
(uint32_t*)(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);
SCB_InvalidateDCache_by_Addr(
(uint32_t*)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.

+------------------------------------------------------------------+
| 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 starts
DMA reads from memory (Mem->Periph)Clean before DMA starts
CPU reads after DMA writeInvalidate after DMA complete
CPU writes before DMA readClean after CPU write

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, flexibleMust remember to get it 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, memory_order_release compiles to DSB + STR, memory_order_acquire compiles to LDR + DSB + ISB. 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 0646)
  2. STM32F7 Series Reference Manual, “DMA Controller (DMA)” (RM0410)
  3. ARM Application Note 321, “Cache and TCM for Cortex-M7” (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 and cache coherency issues. On Cortex-M, this reduces latency by 30-50% for high-throughput streams like ADC, UART, or SPI.

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

Weak Symbols and Function Aliasing in Embedded C
Weak Symbols and Function Aliasing in Embedded C
July 20, 2026
3 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media