DMA Controllers in Embedded Systems: Direct Memory Access Explained with Real-World Examples

Direct Memory Access (DMA) is one of the most powerful yet underutilized features in modern microcontrollers. When used correctly, DMA can dramatically reduce CPU overhead, improve system responsiveness, and enable designs that would otherwise be impossible with polling or interrupt-driven approaches.

This guide explains how DMA controllers work, when they provide real benefits, how to configure them properly, and the common mistakes that lead to data corruption and system crashes.


What is DMA and Why Does It Matter?

DMA (Direct Memory Access) allows peripherals to transfer data directly to or from memory without CPU intervention. Instead of the CPU reading from a peripheral register and writing to memory (or vice versa), the DMA controller handles the entire transfer autonomously.

Without DMA (CPU-Driven Transfer):

// CPU must handle every byte
void UART_IRQHandler(void) {
    if (UART->SR & UART_RXNE) {
        buffer[index++] = UART->DR;  // CPU reads and writes
    }
}

CPU cycles per byte: ~10-50 cycles (read peripheral, write memory, manage index, check conditions)

With DMA:

// DMA handles the entire buffer transfer
DMA_Configure(UART_RX_Channel, &UART->DR, buffer, 1024);
DMA_Start(UART_RX_Channel);

// CPU is interrupted only when complete
void DMA1_Channel5_IRQHandler(void) {
    // Process 1024 bytes at once
    process_uart_data(buffer, 1024);
}

CPU cycles per byte: ~0 (CPU is free for other tasks)


How DMA Controllers Work

Basic Architecture

┌─────────────────────────────────────────────────┐
│                   CPU Core                      │
└────────────────┬────────────────────────────────┘
                 │
    ┌────────────┴────────────┐
    │      System Bus         │
    └─┬──────┬──────┬─────┬──┘
      │      │      │     │
┌─────▼──┐ ┌─▼────┐ ┌────▼─────┐ ┌──▼───────┐
│  DMA   │ │ SRAM │ │  Flash   │ │Peripheral│
│Controlle│ │      │ │  Memory  │ │  Blocks  │
└────────┘ └──────┘ └──────────┘ └──────────┘

The DMA controller acts as a bus master that can:

  • Read from memory or peripheral registers
  • Write to memory or peripheral registers
  • Operate independently of the CPU
  • Generate interrupts when transfers complete

DMA Transfer Types

1. Peripheral-to-Memory (Most Common)

Transfer data from a peripheral (ADC, UART, SPI) to RAM.

Use cases:

  • ADC continuous sampling
  • UART/SPI receive buffers
  • I2S audio input
  • Sensor data logging

Example: ADC with DMA

// Configure ADC to trigger DMA on each conversion
ADC1->CR1 |= ADC_CR1_SCAN;     // Scan mode
ADC1->CR2 |= ADC_CR2_DMA;      // Enable DMA requests

// Configure DMA
DMA1_Channel1->CCR = 
    DMA_CCR_MINC |      // Increment memory address
    DMA_CCR_PSIZE_16 |  // Peripheral size: 16-bit
    DMA_CCR_MSIZE_16 |  // Memory size: 16-bit
    DMA_CCR_CIRC;       // Circular mode

DMA1_Channel1->CPAR = (uint32_t)&ADC1->DR;      // Source
DMA1_Channel1->CMAR = (uint32_t)adc_buffer;     // Destination
DMA1_Channel1->CNDTR = ADC_BUFFER_SIZE;         // Count

DMA1_Channel1->CCR |= DMA_CCR_EN;  // Start DMA
ADC1->CR2 |= ADC_CR2_ADON;         // Start ADC

2. Memory-to-Peripheral

Transfer data from RAM to a peripheral (DAC, UART, SPI).

Use cases:

  • Audio playback via DAC or I2S
  • UART/SPI transmit buffers
  • PWM waveform generation
  • Display updates

Example: UART Transmit with DMA

void uart_send_dma(const uint8_t *data, uint16_t length) {
    // Wait if previous transfer still active
    while (DMA1_Channel4->CNDTR != 0);
    
    DMA1_Channel4->CMAR = (uint32_t)data;
    DMA1_Channel4->CNDTR = length;
    DMA1_Channel4->CCR |= DMA_CCR_EN;
}

// In initialization:
DMA1_Channel4->CCR = 
    DMA_CCR_MINC |      // Increment memory pointer
    DMA_CCR_DIR |       // Memory-to-peripheral
    DMA_CCR_TCIE;       // Enable transfer complete interrupt

DMA1_Channel4->CPAR = (uint32_t)&USART1->DR;

3. Memory-to-Memory

Transfer data between memory regions.

Use cases:

  • Fast memory copies
  • Buffer initialization
  • Image processing
  • Data reformatting

Example: Fast Memory Copy

void dma_memcpy(void *dest, const void *src, size_t count) {
    DMA2_Channel1->CCR = 
        DMA_CCR_MINC |      // Increment memory source
        DMA_CCR_PINC |      // Increment memory destination
        DMA_CCR_MEM2MEM;    // Memory-to-memory mode
    
    DMA2_Channel1->CPAR = (uint32_t)src;   // Treat peripheral reg as source
    DMA2_Channel1->CMAR = (uint32_t)dest;
    DMA2_Channel1->CNDTR = count;
    
    DMA2_Channel1->CCR |= DMA_CCR_EN;
    
    // Wait for completion
    while (!(DMA2->ISR & DMA_ISR_TCIF1));
}

Note: DMA memory copy is typically only faster for large transfers (>256 bytes) due to setup overhead.


DMA Operating Modes

1. Normal Mode

Transfer runs once and stops when complete.

DMA_Channel->CNDTR = 100;  // Transfer 100 items
DMA_Channel->CCR |= DMA_CCR_EN;

// DMA stops automatically after 100 transfers

Use when:

  • Single buffer transfers
  • Event-driven data collection
  • Non-continuous communication

2. Circular Mode

DMA automatically restarts when the transfer completes.

DMA_Channel->CCR |= DMA_CCR_CIRC;  // Enable circular mode
DMA_Channel->CNDTR = BUFFER_SIZE;
DMA_Channel->CCR |= DMA_CCR_EN;

// DMA continuously fills buffer and wraps around

Use when:

  • Continuous ADC sampling
  • Audio streaming
  • Sensor data logging
  • Communication receive buffers

Double-Buffer Pattern:

#define HALF_BUFFER_SIZE 512
uint16_t adc_buffer[HALF_BUFFER_SIZE * 2];

void DMA1_Channel1_IRQHandler(void) {
    if (DMA1->ISR & DMA_ISR_HTIF1) {
        // Half-transfer complete: process first half
        process_data(&adc_buffer[0], HALF_BUFFER_SIZE);
        DMA1->IFCR = DMA_IFCR_CHTIF1;
    }
    
    if (DMA1->ISR & DMA_ISR_TCIF1) {
        // Transfer complete: process second half
        process_data(&adc_buffer[HALF_BUFFER_SIZE], HALF_BUFFER_SIZE);
        DMA1->IFCR = DMA_IFCR_CTCIF1;
    }
}

This pattern ensures continuous data capture with no gaps.


DMA Priority Levels

When multiple DMA channels request access simultaneously, priority determines the order.

// Priority levels (most MCUs)
DMA_CCR_PL_LOW        // Priority level: Low
DMA_CCR_PL_MEDIUM     // Priority level: Medium
DMA_CCR_PL_HIGH       // Priority level: High
DMA_CCR_PL_VERY_HIGH  // Priority level: Very high

Priority Guidelines:

PriorityTypical Usage
Very HighTime-critical audio I2S, high-speed ADC
HighUART/SPI with tight timing requirements
MediumStandard peripheral transfers
LowMemory-to-memory copies, background tasks

Example:

// High-priority ADC for control loop
DMA1_Channel1->CCR |= DMA_CCR_PL_VERY_HIGH;

// Lower-priority UART logging
DMA1_Channel4->CCR |= DMA_CCR_PL_MEDIUM;

Common DMA Pitfalls and How to Avoid Them

Pitfall 1: Cache Coherency Issues

Problem: On Cortex-M7 and other processors with data cache, DMA operates on physical memory while CPU sees cached data.

// WRONG: CPU may read stale cached data
uint8_t rx_buffer[1024];
DMA_Start_RX(rx_buffer, 1024);
wait_for_dma_complete();
uint8_t first_byte = rx_buffer[0];  // May be old cached value!

Solution:

// Option 1: Place DMA buffers in non-cacheable memory
__attribute__((section(".dma_buffer"))) uint8_t rx_buffer[1024];

// Option 2: Manually invalidate cache
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, 1024);
uint8_t first_byte = rx_buffer[0];

// Option 3: Use MPU to mark buffer as non-cacheable
MPU_Configure_Region(rx_buffer, 1024, MPU_NON_CACHEABLE);

Pitfall 2: Unaligned Buffers

Problem: Some DMA controllers require aligned addresses for optimal performance or correct operation.

// WRONG: Unaligned buffer may cause hard fault or poor performance
uint8_t data[100];
uint32_t *buffer = (uint32_t*)&data[1];  // Unaligned!
DMA_Transfer(buffer, 25);

Solution:

// Ensure proper alignment
__attribute__((aligned(4))) uint8_t data[100];

// Or use aligned memory allocation
uint32_t *buffer = aligned_alloc(4, 100);

Pitfall 3: Race Conditions with Circular Buffers

Problem: CPU reads buffer while DMA is writing to it.

// WRONG: Data might be corrupted mid-read
while (1) {
    if (dma_complete) {
        process_buffer(buffer, SIZE);  // DMA still writing!
        dma_complete = false;
    }
}

Solution: Use double-buffering or half-transfer interrupts (shown earlier).


Pitfall 4: Forgetting to Enable Peripheral DMA Requests

// WRONG: DMA configured but peripheral doesn't trigger it
DMA1_Channel1->CCR |= DMA_CCR_EN;
ADC1->CR2 |= ADC_CR2_ADON;  // ADC starts but doesn't trigger DMA

// RIGHT: Enable peripheral DMA request
ADC1->CR2 |= ADC_CR2_DMA | ADC_CR2_ADON;

Pitfall 5: Buffer Overwrite in Non-Circular Mode

// WRONG: No handling when buffer fills up
void UART_Init_DMA(void) {
    DMA->CNDTR = 1024;
    DMA->CCR |= DMA_CCR_EN;  // Stops after 1024 bytes, data lost after that
}

// RIGHT: Use circular mode or restart on completion
DMA->CCR |= DMA_CCR_CIRC;  // Continuously capture data

Performance Comparison: Polling vs Interrupt vs DMA

Let’s measure the performance of receiving 1000 bytes via UART at 115200 baud.

Method 1: Polling

void receive_polling(uint8_t *buffer, uint16_t size) {
    for (uint16_t i = 0; i < size; i++) {
        while (!(UART->SR & UART_RXNE));  // Wait for data
        buffer[i] = UART->DR;
    }
}

CPU utilization: ~100% (blocking)
Latency: Low
Throughput: Good
Power consumption: High


Method 2: Interrupt-Driven

volatile uint16_t rx_count = 0;
uint8_t rx_buffer[1000];

void UART_IRQHandler(void) {
    if (UART->SR & UART_RXNE) {
        rx_buffer[rx_count++] = UART->DR;
    }
}

CPU utilization: ~30-50% (interrupt overhead)
Latency: Medium (interrupt latency)
Throughput: Good
Power consumption: Medium


Method 3: DMA

void receive_dma(uint8_t *buffer, uint16_t size) {
    DMA->CMAR = (uint32_t)buffer;
    DMA->CNDTR = size;
    DMA->CCR |= DMA_CCR_EN;
    
    // CPU can do other work
    // Interrupt only when complete
}

CPU utilization: <5% (minimal overhead)
Latency: Low
Throughput: Excellent
Power consumption: Low


Real-World Example: Audio I2S with DMA

A practical example of DMA for audio streaming:

#define AUDIO_BUFFER_SIZE 512
int16_t audio_buffer[AUDIO_BUFFER_SIZE * 2];  // Double buffer

void audio_init_dma(void) {
    // Configure I2S peripheral
    SPI2->I2SCFGR = 
        SPI_I2SCFGR_I2SMOD |  // I2S mode
        SPI_I2SCFGR_I2SCFG_2; // Master receive
    
    // Configure DMA for circular double-buffered operation
    DMA1_Stream3->CR = 
        DMA_SxCR_CHSEL_0 |     // Channel 1
        DMA_SxCR_PL_1 |        // High priority
        DMA_SxCR_MSIZE_0 |     // Memory size: 16-bit
        DMA_SxCR_PSIZE_0 |     // Peripheral size: 16-bit
        DMA_SxCR_MINC |        // Increment memory
        DMA_SxCR_CIRC |        // Circular mode
        DMA_SxCR_HTIE |        // Half-transfer interrupt
        DMA_SxCR_TCIE;         // Transfer complete interrupt
    
    DMA1_Stream3->PAR = (uint32_t)&SPI2->DR;
    DMA1_Stream3->M0AR = (uint32_t)audio_buffer;
    DMA1_Stream3->NDTR = AUDIO_BUFFER_SIZE * 2;
    
    // Enable peripheral DMA and start
    SPI2->CR2 |= SPI_CR2_RXDMAEN;
    DMA1_Stream3->CR |= DMA_SxCR_EN;
    SPI2->I2SCFGR |= SPI_I2SCFGR_I2SE;
}

void DMA1_Stream3_IRQHandler(void) {
    if (DMA1->LISR & DMA_LISR_HTIF3) {
        // Process first half while DMA fills second half
        audio_process(&audio_buffer[0], AUDIO_BUFFER_SIZE);
        DMA1->LIFCR = DMA_LIFCR_CHTIF3;
    }
    
    if (DMA1->LISR & DMA_LISR_TCIF3) {
        // Process second half while DMA fills first half
        audio_process(&audio_buffer[AUDIO_BUFFER_SIZE], AUDIO_BUFFER_SIZE);
        DMA1->LIFCR = DMA_LIFCR_CTCIF3;
    }
}

This setup provides zero-gap audio streaming with minimal CPU overhead.


When NOT to Use DMA

DMA isn’t always the right choice:

1. Very Small Transfers (<16 bytes)

Setup overhead exceeds the benefit.

2. Infrequent Events

If data arrives once per second, interrupt handling is simpler.

3. Complex Data Processing During Transfer

If you need to inspect or modify each byte, CPU involvement is necessary anyway.

4. Resource Constraints

Limited DMA channels may be better used for higher-priority tasks.


Conclusion

DMA is a powerful tool for embedded developers that can:

  • Reduce CPU overhead by 50-95% for data transfers
  • Enable real-time performance impossible with CPU-based methods
  • Lower power consumption by allowing deeper sleep states
  • Improve system responsiveness by freeing CPU for critical tasks

Key takeaways:

  • Use DMA for bulk transfers from peripherals (ADC, UART, SPI, I2S)
  • Configure circular mode with double-buffering for continuous streams
  • Watch for cache coherency issues on Cortex-M7 and similar cores
  • Ensure buffers are properly aligned
  • Set appropriate priorities for time-critical channels

Master DMA configuration, and you’ll unlock significant performance gains in your embedded applications.


Additional Resources


Last updated: August 29, 2026