
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.
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 + alignmentmpu_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-cacheablempu_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 | || +--------------+ || |+------------------------------------------------------------------+
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 = pongstatic 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 pongframe_ready = 1;}}void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {if (hadc->Instance == ADC1) {// Second half complete - process pong while DMA fills pingdma_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 regionSCB_InvalidateDCache_by_Addr((uint32_t*)process_buf, FRAME_SIZE * 2);// Process directly - no memcpyfor (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] || |+------------------------------------------------------------------+
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 regionif (write_pos > cpu_read_pos) {SCB_InvalidateDCache_by_Addr((uint32_t*)(uart_rx_ring + cpu_read_pos),write_pos - cpu_read_pos);// Process linear chunkparse_uart_data(uart_rx_ring + cpu_read_pos, write_pos - cpu_read_pos);} else {// Wrapped - two chunksSCB_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 || |+------------------------------------------------------------------+
| Operation | Cache Action | Function |
|---|---|---|
| DMA writes to memory (Periph->Mem) | Invalidate before DMA starts | |
| DMA reads from memory (Mem->Periph) | Clean before DMA starts | |
| CPU reads after DMA write | Invalidate after DMA complete | |
| CPU writes before DMA read | Clean 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 32static 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);}
| Approach | Pros | Cons |
|---|---|---|
| MPU non-cacheable region | Zero cache maintenance, deterministic timing | CPU accesses 2-3x slower, consumes MPU region (8 max) |
| Explicit cache maintenance | Full cache speed for CPU, flexible | Must 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.
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 updateatomic_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 readuint32_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.
Zero-copy DMA on Cortex-M eliminates the memcpy tax entirely. Three patterns cover most use cases:
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.
Quick Links
Legal Stuff




