Skip to content

Part 2 · C Meets the Metal33 min read

DMA

Until now the CPU has moved every byte: polling TXE, reading DR in an interrupt, copying into a ring buffer. That works at 115200 baud and falls apart at 2 Mbaud, at 20 MHz SPI, or when an ADC is producing a million samples a second. A DMA controller (direct memory access) is a second bus master that copies data between peripherals and memory on its own, while the CPU does something useful or sleeps. In this lesson you will configure the STM32F407’s DMA streams by hand: transmit over USART2 with a completion interrupt, receive variable-length frames with a circular buffer and the idle-line interrupt, copy memory, and debug a transfer that does nothing.

The CPU and the DMA controller are both bus masters on the STM32’s AHB bus matrix. Either can issue reads and writes to any slave on the matrix (flash, SRAM, the peripheral bridges). A DMA transfer is just a loop executed in hardware:

repeat NDTR times:
wait for a request from the peripheral (or go immediately, memory-to-memory)
read one item from the source address
write it to the destination address
advance source and/or destination if increment is enabled
NDTR = NDTR - 1
raise half-transfer flag at NDTR = N/2, transfer-complete flag at 0

Compare that with an interrupt-per-byte UART receive. Each byte costs exception entry (12 cycles), the handler, exception exit, and the cache-less flash fetches of all of it: easily 50 to 100 cycles per byte. At 2 Mbaud that is 200,000 interrupts a second, a large fraction of a 16 MHz CPU. With DMA each byte costs a few bus cycles stolen from the CPU, and the CPU is interrupted once per buffer instead of once per byte.

Three reasons to use DMA:

  1. Offload: the CPU is free during the transfer.
  2. Determinism: a DMA request is serviced within a few bus cycles, regardless of what interrupt the CPU happens to be in. An ADC sampled by DMA never loses a sample because some other ISR ran long.
  3. Power: the CPU can sit in sleep mode (WFI) while the DMA fills a buffer, waking only when it is full.

DMA is not magic speed. A memory-to-memory DMA copy is often no faster than a good memcpy on a Cortex-M4, because both are limited by the same bus. The win is that the CPU is not the one waiting.

The F407 has two identical controllers:

ControllerBaseClock enable
DMA10x40026000RCC_AHB1ENR bit 21
DMA20x40026400RCC_AHB1ENR bit 22

Each controller has 8 streams (0 to 7). A stream is one independent transfer engine with its own configuration and counter. Each stream has 8 channels, which here means “which peripheral request line this stream listens to”. A fixed table in the reference manual (RM0090, the DMA1 and DMA2 request mapping tables) says which peripheral request is wired to which stream and channel. An excerpt:

Peripheral requestControllerStreamChannel
USART2_RXDMA154
USART2_TXDMA164
DAC1DMA157
ADC1DMA200
SPI1_RXDMA20 or 23
SPI1_TXDMA23 or 53

You cannot pick an arbitrary stream: USART2 TX only works on DMA1 Stream 6 with channel 4 selected. And a stream serves one request at a time, so USART2_RX and DAC1 both needing DMA1 Stream 5 is a real conflict you solve at design time. Always plan your stream allocation on paper for the whole product before writing the first driver. Confirm every entry against the table in your copy of the reference manual.

There is also an asymmetry in the bus wiring. DMA2’s peripheral port is connected to the bus matrix, so DMA2 can address memory on both its ports and is the only controller that supports memory-to-memory transfers. DMA1’s peripheral port reaches only the APB1 peripherals.

Each controller has four shared flag registers, then six registers per stream at offset 0x10 + 0x18 * stream:

OffsetRegisterPurpose
0x00LISRstatus flags for streams 0 to 3 (read-only)
0x04HISRstatus flags for streams 4 to 7 (read-only)
0x08LIFCRwrite 1 to clear flags for streams 0 to 3
0x0CHIFCRwrite 1 to clear flags for streams 4 to 7
0x10 + 0x18*xSxCRconfiguration and enable
0x14 + 0x18*xSxNDTRnumber of items left to transfer (max 65535)
0x18 + 0x18*xSxPARperipheral address
0x1C + 0x18*xSxM0ARmemory address 0
0x20 + 0x18*xSxM1ARmemory address 1 (double-buffer mode)
0x24 + 0x18*xSxFCRFIFO control

The flags come in a 6-bit group per stream. Within a group: bit 0 FEIF (FIFO error), bit 2 DMEIF (direct mode error), bit 3 TEIF (transfer error), bit 4 HTIF (half transfer), bit 5 TCIF (transfer complete). The groups sit at bit offsets 0, 6, 16 and 22 for streams 0, 1, 2, 3 in LISR, and the same offsets for streams 4, 5, 6, 7 in HISR. So Stream 6’s transfer-complete flag is HISR bit 21 (16 + 5), and Stream 5’s half-transfer flag is HISR bit 10 (6 + 4).

The SxCR fields (RM0090):

BitsFieldMeaning
0ENstream enable; hardware clears it at the end of a non-circular transfer
2TEIEtransfer error interrupt enable
3HTIEhalf-transfer interrupt enable
4TCIEtransfer-complete interrupt enable
7:6DIR00 peripheral-to-memory, 01 memory-to-peripheral, 10 memory-to-memory
8CIRCcircular mode: reload NDTR and restart at 0
9PINCincrement peripheral address
10MINCincrement memory address
12:11PSIZEperipheral item size: 00 byte, 01 half-word, 10 word
14:13MSIZEmemory item size, same encoding
17:16PLpriority: 00 low to 11 very high
18DBMdouble-buffer mode
19CTcurrent target (which of M0AR/M1AR is in use)
27:25CHSELchannel select, 0 to 7

NDTR counts items of the peripheral size, not bytes. A word transfer with NDTR = 10 moves 40 bytes.

Priority settles contention between streams of the same controller: PL first, then the lower stream number wins a tie. Give the stream with the tightest deadline (an audio or ADC stream that must never miss a sample) a higher PL than a UART log.

Direct mode vs FIFO: by default (SxFCR.DMDIS = 0) each item goes straight from source to destination, and PSIZE must equal MSIZE. With the FIFO enabled (DMDIS = 1) the stream has a 4-word buffer, can pack bytes into words, and can do bursts. Memory-to-memory transfers require the FIFO.

Add these to stm32f407_regs.h:

stm32f407_regs.h (additions)
typedef struct {
volatile uint32_t CR; /* 0x00 */
volatile uint32_t NDTR; /* 0x04 */
volatile uint32_t PAR; /* 0x08 */
volatile uint32_t M0AR; /* 0x0C */
volatile uint32_t M1AR; /* 0x10 */
volatile uint32_t FCR; /* 0x14 */
} DMA_Stream_TypeDef;
typedef struct {
volatile uint32_t LISR; /* 0x00 */
volatile uint32_t HISR; /* 0x04 */
volatile uint32_t LIFCR; /* 0x08 */
volatile uint32_t HIFCR; /* 0x0C */
} DMA_TypeDef;
#define DMA1 ((DMA_TypeDef *)0x40026000U)
#define DMA2 ((DMA_TypeDef *)0x40026400U)
#define DMA1_Stream5 ((DMA_Stream_TypeDef *)0x40026088U) /* 0x10 + 0x18*5 */
#define DMA1_Stream6 ((DMA_Stream_TypeDef *)0x400260A0U) /* 0x10 + 0x18*6 */
#define DMA2_Stream0 ((DMA_Stream_TypeDef *)0x40026410U) /* 0x10 + 0x18*0 */
#define RCC_AHB1ENR_DMA1EN (1u << 21)
#define RCC_AHB1ENR_DMA2EN (1u << 22)

The stream struct is 24 bytes, exactly the 0x18 stride, so an array DMA_Stream_TypeDef[8] starting at base + 0x10 would also work. Spelling out the three streams we use keeps the addresses visible.

Every stream setup follows the same order, and skipping a step is the root of most “DMA does nothing” bugs:

  1. Enable the controller clock in RCC_AHB1ENR.
  2. If the stream is enabled, clear EN and wait until EN reads 0. A stream cannot be reconfigured while it is running; writes to NDTR, PAR and M0AR are ignored.
  3. Clear all of the stream’s flags in LIFCR/HIFCR. A leftover TCIF from the last transfer would make you think the new one finished instantly, and some leftover flags prevent the stream from enabling at all.
  4. Write PAR, M0AR, NDTR, FCR.
  5. Write CR with channel, direction, sizes, increments, priority, interrupt enables, EN = 0.
  6. Enable the stream interrupt in the NVIC if you use one.
  7. Set EN.
  8. Enable the DMA request in the peripheral (for USART, CR3.DMAT or CR3.DMAR). Without this the stream waits forever.

To send a buffer over USART2:

  • Stream: DMA1 Stream 6, channel 4.
  • Direction: memory-to-peripheral (DIR = 01).
  • Peripheral address: &USART2->DR, fixed (PINC = 0).
  • Memory address: the buffer, incrementing (MINC = 1).
  • Sizes: byte to byte.
  • NDTR: number of bytes.

The USART raises a DMA request whenever TXE is set, which is immediately after enabling, so the first byte goes as soon as you set EN. When NDTR reaches 0, the stream sets TCIF6, clears its own EN, and (with TCIE) interrupts.

One subtlety: DMA transfer-complete means “the DMA has written the last byte into DR”, not “the last byte has left the pin”. Two more bytes are still in the USART (one in DR, one in the shift register). For freeing the buffer that is fine, because the DMA is done reading memory. For turning off an RS-485 driver or entering a low-power mode you must additionally wait for the USART’s own TC flag (SR bit 6). The reference manual also tells you to clear TC (by writing 0 to it) before starting the DMA, so that a later check of TC reflects this transfer.

Peripheral-to-memory: circular receive with idle line

Section titled “Peripheral-to-memory: circular receive with idle line”

Receiving is harder because you do not know how many bytes will arrive. If you program NDTR = 64 and the other side sends a 10-byte command, the transfer never completes and the command sits in your buffer unnoticed.

The standard solution has three parts:

  1. Run the RX stream (DMA1 Stream 5, channel 4) in circular mode over a buffer. It never stops; when it reaches the end it wraps to the start and reloads NDTR.
  2. Track where the DMA is: the write position is SIZE - NDTR. Keep your own read position. Bytes between your read position and the write position are new.
  3. Run that “consume new bytes” check whenever there might be new data: on the half-transfer interrupt, on the transfer-complete interrupt, and on the USART idle-line interrupt.

The idle-line interrupt is what makes variable-length frames work. The USART sets IDLE (SR bit 4) when the RX line has been high for one full character time after at least one byte was received, meaning “the sender paused”. With IDLEIE (CR1 bit 4) set, you get one interrupt at the end of every burst, however long it was. Clear IDLE by reading SR and then DR.

rx_buf (64 bytes, circular)
0 32 (HT) 64 (TC, wraps to 0)
|---------------|-----------------------|
[h e l l o \n]
^last ^pos = 64 - NDTR = 6
IDLE fires after '\n' -> consume bytes 0..5, last = 6

The half-transfer and complete interrupts guarantee a check at least every 32 bytes, even during a long burst with no pause. As long as you consume faster than half a buffer fills, nothing is overwritten. Size the buffer so that half of it covers your worst-case interrupt latency at the maximum baud rate, with margin.

The same two interrupts give you ping-pong (double) buffering for continuous streams such as audio or ADC data. While the DMA fills the second half of a circular buffer, the CPU processes the first half (signalled by HTIF); while the DMA fills the first half, the CPU processes the second (signalled by TCIF). The CPU always works on the half the DMA is not touching, as long as processing one half finishes before the DMA finishes the other.

The STM32F4 also has a hardware version: double-buffer mode (DBM = 1). The stream alternates between two separate buffers at M0AR and M1AR, toggling CT at each transfer complete. Software may rewrite the address register of the buffer that is not currently in use, which lets you swap in fresh buffers on the fly. It is circular mode with two independent buffers instead of two halves of one.

DMA2 can copy memory to memory. Set DIR = 10, put the source in PAR and the destination in M0AR, enable both increments, enable the FIFO, and set EN. There is no request to wait for; the stream runs flat out until NDTR reaches 0. Circular mode is not allowed in this direction.

Useful for: clearing or copying large buffers while the CPU does math, moving a received frame out of a DMA buffer, filling a display framebuffer. Not useful for: copying 16 bytes, where setting up the stream costs more than memcpy.

For ADC scans, DMA2 Stream 0 channel 0 moves each conversion result into an array in circular mode. That full configuration lives in ADC, DAC, and analog basics; it is the same recipe with DIR = 00, half-word sizes and MINC set.

The DMA controllers are bus masters on the AHB matrix. Some memory is not connected to them.

Other placement rules:

  • Alignment: the memory address must be aligned to MSIZE (half-word transfers need an even address, word transfers a multiple of 4). Use _Alignas(4) on byte buffers you might transfer as words.
  • Lifetime: never hand the DMA a pointer to a local variable in a function that returns before the transfer ends. The DMA will keep writing into whatever stack frame is there next. Use static buffers.
  • Flash as source: DMA2 can read flash, so a const table can be the source for memory-to-peripheral transfers (a waveform for the DAC, for example). DMA1’s memory port can read flash too.

The compiler does not know the DMA exists. Two consequences:

  1. When the DMA writes a buffer that the CPU then reads, the compiler might keep a stale copy in a register, or hoist reads out of a loop. Declare RX buffers volatile, or put a compiler barrier between “DMA is done” and “read the buffer”.
  2. When the CPU fills a TX buffer and then sets EN, the stores to the buffer are ordinary stores and the write to EN is a volatile store. C only orders volatile accesses relative to each other; the compiler may legally sink a plain store past the EN write. Put __asm volatile ("dmb" ::: "memory") between them: the "memory" clobber stops the compiler reordering, and DMB makes sure the core’s write buffer has drained before the DMA starts reading. The precise rules are in Concurrency, atomics, and memory ordering.

The Cortex-M4 in the F407 has no data cache, so the CPU and DMA always see the same memory. A Cortex-M7 (STM32F7, STM32H7) has a data cache, and then DMA buffers need explicit cache maintenance. Before starting a TX DMA, clean the buffer’s cache lines (write dirty data out to RAM, SCB_CleanDCache_by_Addr). After an RX DMA completes, invalidate the buffer’s lines (discard stale cached copies, SCB_InvalidateDCache_by_Addr) before reading. Buffers must be 32-byte aligned and sized in 32-byte multiples so maintenance does not clobber neighbouring variables. The alternative is an MPU region marking the DMA buffers non-cacheable. Code that works perfectly on an F4 and returns stale data on an H7 is almost always this.

The program below configures USART2 at 115200 baud for DMA in both directions, echoes every line you type, and on startup copies 1 KB with DMA2 and compares the time against memcpy using the DWT cycle counter. The ISR names must match the entries in the vector table from Startup code and the vector table: DMA1_Stream5_IRQHandler (IRQ 16), DMA1_Stream6_IRQHandler (IRQ 17), and USART2_IRQHandler (IRQ 38). It uses gpio_util.h and the RCC_ enable-bit macros added to the register header in SPI and I2C, and it does not use printf: with the USART owned by DMA, all output goes through snprintf into a buffer and then uart_dma_send. The NVIC is written through its raw addresses (ISER0 at 0xE000E100, IPR0 at 0xE000E400) so the example does not depend on how your header names those arrays.

main.c
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "stm32f407_regs.h"
#include "gpio_util.h"
/* ---- DMA stream bits ---- */
#define DMA_SxCR_EN (1u << 0)
#define DMA_SxCR_TEIE (1u << 2)
#define DMA_SxCR_HTIE (1u << 3)
#define DMA_SxCR_TCIE (1u << 4)
#define DMA_SxCR_DIR_P2M (0u << 6)
#define DMA_SxCR_DIR_M2P (1u << 6)
#define DMA_SxCR_DIR_M2M (2u << 6)
#define DMA_SxCR_CIRC (1u << 8)
#define DMA_SxCR_PINC (1u << 9)
#define DMA_SxCR_MINC (1u << 10)
#define DMA_SxCR_PSIZE_32 (2u << 11)
#define DMA_SxCR_MSIZE_32 (2u << 13)
#define DMA_SxCR_PL_HIGH (2u << 16)
#define DMA_SxCR_CHSEL(n) ((uint32_t)(n) << 25)
#define DMA_SxFCR_FTH_FULL (3u << 0)
#define DMA_SxFCR_DMDIS (1u << 2)
/* Flag bits inside one stream's 6-bit group */
#define DMA_FEIF (1u << 0)
#define DMA_DMEIF (1u << 2)
#define DMA_TEIF (1u << 3)
#define DMA_HTIF (1u << 4)
#define DMA_TCIF (1u << 5)
#define DMA_ALLF (DMA_FEIF | DMA_DMEIF | DMA_TEIF | DMA_HTIF | DMA_TCIF)
#define S0_SHIFT 0u /* stream 0 group in LISR */
#define S5_SHIFT 6u /* stream 5 group in HISR */
#define S6_SHIFT 16u /* stream 6 group in HISR */
/* ---- USART bits ---- */
#define USART_SR_IDLE (1u << 4)
#define USART_SR_TC (1u << 6)
#define USART_CR1_RE (1u << 2)
#define USART_CR1_TE (1u << 3)
#define USART_CR1_IDLEIE (1u << 4)
#define USART_CR1_UE (1u << 13)
#define USART_CR3_DMAR (1u << 6)
#define USART_CR3_DMAT (1u << 7)
/* ---- DWT cycle counter ---- */
#define DEMCR (*(volatile uint32_t *)0xE000EDFCu)
#define DWT_CTRL (*(volatile uint32_t *)0xE0001000u)
#define DWT_CYCCNT (*(volatile uint32_t *)0xE0001004u)
static void nvic_enable(uint32_t irq, uint32_t prio)
{
volatile uint8_t *ipr = (volatile uint8_t *)0xE000E400u;
volatile uint32_t *iser = (volatile uint32_t *)0xE000E100u;
ipr[irq] = (uint8_t)(prio << 4); /* 4 implemented bits */
iser[irq >> 5] = 1u << (irq & 31u);
}
/* ---- RX: DMA circular buffer -> SPSC queue for main ---- */
#define RX_DMA_SIZE 64u
#define RXQ_SIZE 256u /* power of two */
static volatile uint8_t rx_dma_buf[RX_DMA_SIZE];
static uint32_t rx_last; /* ISR-only, same priority */
static uint8_t rxq[RXQ_SIZE];
static volatile uint32_t rxq_head; /* written by ISRs */
static volatile uint32_t rxq_tail; /* written by main */
static volatile uint32_t rx_dropped;
static volatile uint32_t dma_errors;
static void rxq_put(uint8_t b)
{
uint32_t h = rxq_head;
if (h - rxq_tail >= RXQ_SIZE) {
rx_dropped++;
return;
}
rxq[h & (RXQ_SIZE - 1u)] = b;
__asm volatile ("" ::: "memory"); /* data before index */
rxq_head = h + 1u;
}
static bool rxq_get(uint8_t *b)
{
uint32_t t = rxq_tail;
if (t == rxq_head) {
return false;
}
__asm volatile ("" ::: "memory"); /* index before data */
*b = rxq[t & (RXQ_SIZE - 1u)];
rxq_tail = t + 1u;
return true;
}
static void rx_check(void)
{
uint32_t pos = RX_DMA_SIZE - DMA1_Stream5->NDTR;
if (pos == RX_DMA_SIZE) { /* NDTR read as 0 mid-reload */
pos = 0u;
}
while (rx_last != pos) {
rxq_put(rx_dma_buf[rx_last]);
rx_last = (rx_last + 1u) % RX_DMA_SIZE;
}
}
/* ---- TX: one DMA transfer at a time ---- */
#define TX_BUF_SIZE 128u
static uint8_t tx_buf[TX_BUF_SIZE];
static volatile bool tx_busy;
static bool uart_dma_send(const void *data, size_t len)
{
if (len == 0u || len > TX_BUF_SIZE || tx_busy) {
return false;
}
memcpy(tx_buf, data, len);
tx_busy = true;
DMA1->HIFCR = DMA_ALLF << S6_SHIFT; /* clear stale flags */
DMA1_Stream6->M0AR = (uint32_t)(uintptr_t)tx_buf;
DMA1_Stream6->NDTR = (uint32_t)len;
USART2->SR = ~USART_SR_TC; /* TC is cleared by writing 0 */
__asm volatile ("dmb" ::: "memory"); /* buffer visible before start */
DMA1_Stream6->CR |= DMA_SxCR_EN;
return true;
}
static bool uart_send_wait(const char *s)
{
for (uint32_t n = 2000000u; n != 0u; n--) {
if (uart_dma_send(s, strlen(s))) {
return true;
}
}
return false;
}
/* ---- Interrupt handlers ---- */
void DMA1_Stream5_IRQHandler(void)
{
uint32_t f = (DMA1->HISR >> S5_SHIFT) & DMA_ALLF;
DMA1->HIFCR = f << S5_SHIFT;
if ((f & DMA_TEIF) != 0u) {
dma_errors++;
}
if ((f & (DMA_HTIF | DMA_TCIF)) != 0u) {
rx_check();
}
}
void DMA1_Stream6_IRQHandler(void)
{
uint32_t f = (DMA1->HISR >> S6_SHIFT) & DMA_ALLF;
DMA1->HIFCR = f << S6_SHIFT;
if ((f & DMA_TEIF) != 0u) {
dma_errors++;
}
if ((f & (DMA_TCIF | DMA_TEIF)) != 0u) {
tx_busy = false;
}
}
void USART2_IRQHandler(void)
{
uint32_t sr = USART2->SR;
if ((sr & USART_SR_IDLE) != 0u) {
(void)USART2->DR; /* SR then DR clears IDLE */
rx_check();
}
}
/* ---- Setup ---- */
static void uart_dma_init(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN | RCC_AHB1ENR_DMA1EN;
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
(void)RCC->APB1ENR;
gpio_af(GPIOA, 2u, 7u); /* PA2 = TX */
gpio_af(GPIOA, 3u, 7u); /* PA3 = RX */
/* RX stream: DMA1 Stream 5, channel 4, circular */
DMA1_Stream5->CR = 0u;
while ((DMA1_Stream5->CR & DMA_SxCR_EN) != 0u) { }
DMA1->HIFCR = DMA_ALLF << S5_SHIFT;
DMA1_Stream5->PAR = (uint32_t)(uintptr_t)&USART2->DR;
DMA1_Stream5->M0AR = (uint32_t)(uintptr_t)rx_dma_buf;
DMA1_Stream5->NDTR = RX_DMA_SIZE;
DMA1_Stream5->FCR = 0u; /* direct mode */
DMA1_Stream5->CR = DMA_SxCR_CHSEL(4) | DMA_SxCR_DIR_P2M | DMA_SxCR_MINC
| DMA_SxCR_CIRC | DMA_SxCR_HTIE | DMA_SxCR_TCIE
| DMA_SxCR_TEIE;
DMA1_Stream5->CR |= DMA_SxCR_EN;
/* TX stream: DMA1 Stream 6, channel 4, one-shot; enabled per send */
DMA1_Stream6->CR = 0u;
while ((DMA1_Stream6->CR & DMA_SxCR_EN) != 0u) { }
DMA1->HIFCR = DMA_ALLF << S6_SHIFT;
DMA1_Stream6->PAR = (uint32_t)(uintptr_t)&USART2->DR;
DMA1_Stream6->FCR = 0u;
DMA1_Stream6->CR = DMA_SxCR_CHSEL(4) | DMA_SxCR_DIR_M2P | DMA_SxCR_MINC
| DMA_SxCR_TCIE | DMA_SxCR_TEIE;
USART2->BRR = (8u << 4) | 11u; /* 115200 baud at 16 MHz */
USART2->CR3 = USART_CR3_DMAR | USART_CR3_DMAT;
USART2->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_IDLEIE;
nvic_enable(16u, 5u); /* DMA1_Stream5 */
nvic_enable(17u, 5u); /* DMA1_Stream6 */
nvic_enable(38u, 5u); /* USART2 */
}
/* ---- Memory-to-memory on DMA2 Stream 0 ---- */
static bool dma_memcpy32(uint32_t *dst, const uint32_t *src, uint32_t words)
{
DMA_Stream_TypeDef *s = DMA2_Stream0;
if (words == 0u || words > 65535u) {
return false;
}
s->CR = 0u;
while ((s->CR & DMA_SxCR_EN) != 0u) { }
DMA2->LIFCR = DMA_ALLF << S0_SHIFT;
s->PAR = (uint32_t)(uintptr_t)src; /* source goes in PAR */
s->M0AR = (uint32_t)(uintptr_t)dst;
s->NDTR = words;
s->FCR = DMA_SxFCR_DMDIS | DMA_SxFCR_FTH_FULL;
s->CR = DMA_SxCR_DIR_M2M | DMA_SxCR_PINC | DMA_SxCR_MINC
| DMA_SxCR_PSIZE_32 | DMA_SxCR_MSIZE_32 | DMA_SxCR_PL_HIGH;
__asm volatile ("dmb" ::: "memory");
s->CR |= DMA_SxCR_EN;
for (uint32_t n = 1000000u; n != 0u; n--) {
uint32_t f = (DMA2->LISR >> S0_SHIFT) & DMA_ALLF;
if ((f & (DMA_TEIF | DMA_DMEIF)) != 0u) {
return false;
}
if ((f & DMA_TCIF) != 0u) {
DMA2->LIFCR = DMA_ALLF << S0_SHIFT;
__asm volatile ("" ::: "memory"); /* dst changed behind our back */
return true;
}
}
return false;
}
static uint32_t copy_src[256];
static uint32_t copy_dst[256];
int main(void)
{
char msg[96];
char line[64];
size_t n = 0;
RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;
DEMCR |= 1u << 24; /* TRCENA */
DWT_CYCCNT = 0u;
DWT_CTRL |= 1u; /* CYCCNTENA */
uart_dma_init();
for (uint32_t i = 0; i < 256u; i++) {
copy_src[i] = i * 0x01010101u;
}
uint32_t t0 = DWT_CYCCNT;
memcpy(copy_dst, copy_src, sizeof copy_src);
uint32_t t_cpu = DWT_CYCCNT - t0;
memset(copy_dst, 0, sizeof copy_dst);
t0 = DWT_CYCCNT;
bool ok = dma_memcpy32(copy_dst, copy_src, 256u);
uint32_t t_dma = DWT_CYCCNT - t0;
ok = ok && memcmp(copy_dst, copy_src, sizeof copy_src) == 0;
snprintf(msg, sizeof msg, "\r\nDMA demo. memcpy 1 KB: %lu cycles, DMA2: %lu cycles (%s)\r\n",
(unsigned long)t_cpu, (unsigned long)t_dma, ok ? "match" : "FAIL");
(void)uart_send_wait(msg);
for (;;) {
uint8_t c;
while (rxq_get(&c)) {
if (c == '\r' || c == '\n') {
if (n > 0u) {
line[n] = '\0';
snprintf(msg, sizeof msg, "echo: %s (dropped %lu, errors %lu)\r\n",
line, (unsigned long)rx_dropped, (unsigned long)dma_errors);
(void)uart_send_wait(msg);
n = 0u;
}
} else if (n < sizeof line - 1u) {
line[n++] = (char)c;
}
}
__asm volatile ("wfi"); /* sleep until the next interrupt */
}
}

Type hello and press Enter in your terminal:

DMA demo. memcpy 1 KB: 791 cycles, DMA2: 1393 cycles (match)
echo: hello (dropped 0, errors 0)
echo: led on (dropped 0, errors 0)

Your cycle counts will differ with toolchain and memory placement, but expect the DMA copy to be in the same range as memcpy or slower, not dramatically faster. That is the point made at the start: the DMA’s value is that the CPU could have been doing something else for those cycles.

The important lines:

  • The RX stream is started once and never stopped. rx_check derives the DMA’s position from NDTR and consumes everything between rx_last and that position. All three interrupts that call it share priority 5, so they cannot pre-empt each other, which is what makes the non-volatile rx_last safe.
  • The idle handler reads DR. At that moment RXNE is already clear because the DMA took the byte, so the read only completes the SR-then-DR sequence that clears IDLE. It does not steal data.
  • The TX path copies into a static buffer so the caller’s buffer (here a stack array) can be reused immediately. The tx_busy flag is cleared by the stream interrupt; uart_dma_send refuses to start while it is set, because writing NDTR on a running stream is ignored.
  • USART2->SR = ~USART_SR_TC clears TC without touching the other flags: TC and RXNE are cleared by writing 0, and the remaining bits are read-only, so writing 1s to them does nothing.
  • The main loop sleeps. With both directions on DMA, the CPU wakes only at the end of a burst, at half buffers, and after a transmission. This is the low-power structure developed in Power management.
  • dma_memcpy32 puts the source in PAR. In memory-to-memory mode the “peripheral” port is the source. Swap them and you copy the zeroed destination over your source data.
SituationBest choiceWhy
A few bytes, rarely, at bootpollingsimplest, no state
Low-rate input of unknown timing (a button, a 9600 baud GPS)interrupt per eventlow latency, small code
Interactive console at 115200interrupt or DMAeither works; DMA saves CPU and power
Burst transfers: SPI flash page, display frameDMACPU free during the burst; no gaps between bytes
Continuous sampling: ADC, audio, sensor streamsDMA circular with HT/TCdeterministic, no missed samples
Multi-Mbaud UARTDMA plus idle lineper-byte interrupts would saturate the CPU
Very short, latency-critical exchange (a 2-byte SPI command)pollingsetup cost of DMA exceeds the transfer

DMA costs code complexity, a stream that might be needed by something else, and debugging difficulty. Use it where the data rate or the power budget needs it.

When a DMA transfer does nothing, check in this order:

  1. Read NDTR. If it still equals the value you wrote, no request was ever serviced: the peripheral’s DMA enable (CR3.DMAT/DMAR) is off, the channel number is wrong, the stream is wrong for that peripheral, or EN never set. If NDTR has decreased but stopped, the transfer started and then stalled or errored.
  2. Read SxCR.EN back after setting it. If it reads 0 immediately, the stream refused to start: usually stale flags not cleared, or an invalid configuration (for example circular memory-to-memory, or mismatched sizes in direct mode).
  3. Check TEIF. A transfer error means the stream accessed an address it cannot reach: CCM RAM, an unmapped address, a peripheral on the wrong bus for this controller. The stream disables itself.
  4. Check FEIF and DMEIF. FIFO and direct-mode errors indicate underrun/overrun conditions or a size and alignment mismatch. FEIF in direct mode is often benign, which is why the example only treats TEIF and DMEIF as fatal.
  5. Peripheral clock and GPIO. The DMA can be perfect and the USART unclocked.

In gdb, p/x *DMA1_Stream5 and p/x DMA1->HISR show everything at once. Remember that halting the core does not halt the DMA: the stream keeps running while you look at it, so NDTR changes under you.

  • Reconfiguring a running stream. Writes to NDTR, PAR, M0AR are ignored while EN = 1. Clear EN and wait for it to read 0 first.
  • Not clearing flags before enabling. A stale TCIF looks like instant completion. Write all five flag bits for the stream to LIFCR/HIFCR before every start.
  • Forgetting the peripheral-side enable. The stream is armed but the USART never asks. Set CR3.DMAT/DMAR (or the SPI/ADC equivalent).
  • Wrong stream or channel. Nothing happens and there is no error flag. Check the request mapping table.
  • DMA buffer in CCM RAM or on the stack. TEIF from CCM, random corruption from a dead stack frame. Use static buffers in main SRAM.
  • NDTR in bytes when PSIZE is half-word or word. You transfer two or four times too much. NDTR counts peripheral-size items.
  • No barrier between filling a TX buffer and setting EN. At -O2 the last stores may land after the DMA started. Use a dmb with a "memory" clobber.
  • Treating DMA TC as “sent”. Two bytes are still in the USART. Wait for USART TC before switching off a transceiver.
  1. Run the example. Paste a 200-character line into the terminal in one go and confirm it echoes intact (line truncates at 63 characters, but dropped must stay 0). Then shrink RX_DMA_SIZE to 8 and repeat. Explain why it still works, and find the baud rate or ISR delay at which it would start losing bytes.
  2. Move rx_dma_buf into CCM RAM with __attribute__((section(".ccmram"))) (using the linker script section from Linker scripts). Observe dma_errors incrementing and read DMA1_Stream5->CR in gdb to confirm EN is 0. Restore it.
  3. Delete the dmb in uart_dma_send, compile at -O2, and look at the disassembly of the inlined function with arm-none-eabi-objdump -d -S. Identify whether any store into tx_buf (from the memcpy call or inlined code) could legally move after the str to CR. Explain what in the C standard permits it.
  4. Change the TX path to a ring buffer so uart_dma_send never refuses: queue data while a transfer is running, and in the transfer-complete interrupt start a new DMA transfer over the next contiguous chunk of the ring.
  5. Use the half-transfer and transfer-complete interrupts on a circular 2 x 32 byte RX buffer to compute a running checksum of every received byte without the SPSC queue, processing exactly one half per interrupt. Then argue the worst-case latency budget for your checksum code at 921600 baud.

Question 1:You program DMA1 Stream 6 with NDTR = 20 and set EN, but NDTR still reads 20 a second later and no flags are set. Which cause is most likely?

Question 2:A circular RX buffer is 128 bytes and NDTR reads 100. Your last processed index is 20. How many new bytes are waiting?

Question 3:Why does the USART idle-line interrupt matter for DMA reception?

Question 4:On a Cortex-M4 you fill tx_buf with plain stores and then write DMA1_Stream6->CR |= EN. At -O2 the first transmitted bytes are sometimes stale. What fixes it?

Question 5:You want to copy 4 KB from SRAM to SRAM with DMA on an STM32F407. Which is correct?

  • DMA is a second bus master that executes the copy loop in hardware; it buys offload, determinism and power, not necessarily raw speed.
  • On the STM32F4 each peripheral request is wired to a specific controller, stream and channel; plan stream allocation for the whole product up front.
  • The recipe: disable and wait for EN = 0, clear flags, set addresses and NDTR, write CR, enable, then enable the request in the peripheral.
  • For unknown-length input, run RX in circular mode and consume up to SIZE - NDTR on HT, TC and idle-line interrupts.
  • DMA cannot reach CCM RAM at 0x10000000; keep DMA buffers static, aligned, in main SRAM, and fence CPU writes before starting a transfer.
  • Debug from NDTR outward: unchanged means no request, stalled means a stall or error, TEIF means an unreachable address.

Next: ADC, DAC, and analog basics, where you sample real-world voltages and use DMA to stream them into memory at a fixed rate.