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:
| Priority | Typical Usage |
|---|---|
| Very High | Time-critical audio I2S, high-speed ADC |
| High | UART/SPI with tight timing requirements |
| Medium | Standard peripheral transfers |
| Low | Memory-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
- ARM DMA Configuration Guide
- STM32 DMA Application Note
- Related: Communication Protocols and Interfaces
- Related: CPU Performance Metrics
Last updated: August 29, 2026