STM32 DMA到底解决了什么问题?从CPU搬运数据到自动传输完整解析

前言

学习 STM32 时,经常会看到下面这些函数:

HAL_UART_Receive_DMA();
HAL_UART_Transmit_DMA();
HAL_ADC_Start_DMA();
HAL_SPI_TransmitReceive_DMA();

很多初学者知道 DMA 可以“减轻 CPU 负担”,但对下面这些问题并不清楚:

  • DMA 到底替 CPU 做了什么?
  • 没有 DMA 时,CPU 是怎样搬运数据的?
  • DMA 是不是完全不需要 CPU?
  • DMA 和中断有什么区别?
  • 数据从外设搬到内存时,经过了哪些步骤?
  • Normal、Circular 和双缓冲模式有什么区别?
  • 为什么用了 DMA,串口还是会丢数据?
  • 为什么 Cortex-M7 使用 DMA 后,CPU 读到的还是旧数据?
  • DMA 什么时候值得使用,什么时候反而没有必要?

本文将从 CPU 搬运数据的问题开始,详细讲解 STM32 DMA 的工作原理、配置流程、典型应用和常见故障。


一、先说结论:DMA解决了什么问题

DMA 的英文全称是:

Direct Memory Access

中文通常称为:

直接存储器访问

DMA 解决的核心问题是:

外设和内存之间存在大量、重复的数据搬运工作,如果所有数据都由 CPU 一次一次读取和写入,会浪费大量 CPU 时间。

DMA 可以按照 CPU 预先配置好的规则,自动完成以下数据传输:

外设 → 内存
内存 → 外设
内存 → 内存

例如串口接收数据时:

USART数据寄存器 → 内存接收数组

没有 DMA 时,每收到一个字节,CPU 都要执行一次读取和保存。

使用 DMA 后,DMA 控制器可以自动将每个字节写入数组。CPU 不需要参与每一个字节的搬运,只需要在一批数据接收完成后进行处理。

一句话概括:

DMA不是帮助CPU计算数据,而是帮助CPU搬运数据。


二、为什么CPU搬运数据会成为问题

假设串口以 115200 bit/s 接收数据,数据格式为:

1位起始位
8位数据位
1位停止位

一个字节实际需要传输大约 10 bit。

每秒可以接收的字节数约为:

115200 ÷ 10 = 11520 Byte/s

如果使用逐字节中断接收,CPU 每秒可能进入一万多次串口中断。

每次中断都需要执行:

保存现场
进入中断服务函数
读取状态寄存器
读取数据寄存器
写入接收数组
更新数组下标
判断是否接收完成
恢复现场
退出中断

如果只有一个低速串口,CPU 可能还能承受。

但实际项目中可能同时存在:

  • 多路 UART;
  • ADC连续采样;
  • SPI高速通信;
  • CAN数据接收;
  • I²S音频采集;
  • 定时器输入捕获;
  • 网络数据处理;
  • FreeRTOS任务调度。

此时CPU如果一直忙于搬运数据,就没有足够时间执行真正的业务逻辑。


三、没有DMA时,CPU怎样读取外设数据

以UART接收一个字节为例。

1. 外设接收到数据

UART硬件接收到一个完整字节后,将数据放入接收数据寄存器:

UART_RX引脚
    ↓
串口接收移位寄存器
    ↓
USART接收数据寄存器

2. CPU读取数据寄存器

CPU需要执行类似操作:

data = USARTx->RDR;

3. CPU将数据写入内存

receive_buffer[index] = data;
index++;

数据实际经历的路径是:

USART数据寄存器
        ↓
      CPU寄存器
        ↓
      内存数组

CPU成为了外设和内存之间的“搬运工”。

如果数据量很大,CPU会反复执行:

读取外设
写入内存
读取外设
写入内存
读取外设
写入内存
……

这些操作本身没有复杂算法,只是不断复制数据。

DMA就是为了接管这类重复搬运工作。


四、DMA是怎样搬运数据的

使用DMA后,数据路径变成:

USART数据寄存器
        ↓
    DMA控制器
        ↓
      内存数组

CPU只需要提前告诉DMA:

  1. 数据从哪里来;
  2. 数据要搬到哪里;
  3. 一次搬多少数据;
  4. 每个数据有多宽;
  5. 地址是否自动增加;
  6. 什么时候开始;
  7. 完成后是否产生中断。

例如:

源地址:USART接收数据寄存器
目标地址:receive_buffer
传输数量:100字节
数据宽度:8位
源地址:固定
目标地址:每次加1
方向:外设到内存

配置完成后,CPU可以继续执行其他任务。

当UART收到数据时,会向DMA发出请求:

UART收到一个字节
       ↓
产生DMA请求
       ↓
DMA读取UART数据寄存器
       ↓
DMA写入内存数组
       ↓
剩余传输数量减1

当100个字节全部搬运完成后,DMA可以产生一次完成中断,通知CPU:

数据已经全部放到数组中,可以开始处理了。

五、DMA并不是完全不需要CPU

“DMA不需要CPU”是一种不准确的说法。

更准确的说法是:

DMA不需要CPU参与每一次数据搬运,但仍然需要CPU完成初始化、启动、状态处理和数据处理。

CPU仍然需要负责:

  • 配置DMA通道或Stream;
  • 设置源地址和目标地址;
  • 设置传输数量;
  • 设置数据宽度;
  • 设置传输模式;
  • 启动外设;
  • 响应DMA完成中断;
  • 处理接收到的数据;
  • 处理传输错误;
  • 必要时重新启动DMA。

因此,DMA降低的是:

CPU参与每个字节、每个半字或每个字搬运的频率

而不是让CPU完全退出系统。


六、轮询、中断和DMA有什么区别

外设数据传输通常有三种方式:

  • 轮询;
  • 中断;
  • DMA。

1. 轮询方式

CPU不断检查外设状态:

while ((USART1->ISR & USART_ISR_RXNE) == 0)
{
    /* 一直等待 */
}

data = USART1->RDR;

通信过程:

CPU检查状态
   ↓
没有数据
   ↓
继续检查
   ↓
有数据
   ↓
读取数据

优点

  • 实现简单;
  • 调试方便;
  • 执行流程直观;
  • 适合初始化阶段或少量数据。

缺点

  • CPU一直等待;
  • 不能及时处理其他任务;
  • 数据量大时效率很低;
  • 容易造成程序阻塞。

2. 中断方式

外设收到数据后通知CPU:

外设收到数据
    ↓
产生中断
    ↓
CPU进入中断服务函数
    ↓
读取并保存数据

优点

  • CPU不需要一直轮询;
  • 数据到达时才进行处理;
  • 适合低速、随机到达的数据;
  • 实时响应能力较好。

缺点

  • 每个数据可能触发一次中断;
  • 高频中断会占用大量CPU;
  • 中断服务函数需要尽量短;
  • 大数据量传输效率有限。

3. DMA方式

外设发出DMA请求后,由DMA完成数据搬运:

外设收到数据
    ↓
产生DMA请求
    ↓
DMA搬运数据
    ↓
整批完成后通知CPU

优点

  • CPU不参与逐个数据搬运;
  • 适合连续、大批量、高速数据;
  • 降低中断次数;
  • 提高系统并行处理能力;
  • 有利于保持固定采样节奏。

缺点

  • 配置比轮询复杂;
  • 需要管理缓冲区;
  • 存在通道映射和优先级问题;
  • 高速系统可能涉及Cache一致性;
  • DMA完成只代表数据搬完,不代表业务处理完成。

七、三种方式的直观对比

对比项目 轮询 中断 DMA
CPU是否等待
CPU是否逐个搬运数据
中断频率 通常没有 可能每个数据一次 通常每批数据一次
实现难度 较高
适合数据量 少量 少量或间歇数据 连续或大量数据
实时性 取决于轮询速度 较好 适合稳定连续传输
CPU占用 较低
缓冲区管理 简单 需要 非常重要

可以简单理解为:

轮询:CPU一直等着搬
中断:数据来了叫CPU搬
DMA:数据来了DMA自己搬,搬完再叫CPU

八、DMA内部有哪些核心参数

配置DMA时,需要理解下面几个参数。


1. 传输方向

常见方向包括:

外设到内存
内存到外设
内存到内存

外设到内存

例如:

ADC → adc_buffer
UART_RX → uart_rx_buffer
SPI_RX → spi_rx_buffer

内存到外设

例如:

uart_tx_buffer → UART_TX
frame_buffer → SPI
audio_buffer → I2S

内存到内存

例如:

source_buffer → destination_buffer

是否支持内存到内存模式,要看具体STM32系列和DMA控制器。


2. 外设地址

外设地址通常是固定的寄存器地址。

例如UART接收时:

USARTx->RDR

同一个UART数据寄存器不断接收新数据,因此外设地址通常不增加。

DMA配置中一般选择:

Peripheral Increment:Disable

3. 内存地址

内存地址通常是数组首地址:

uint8_t uart_rx_buffer[100];

DMA依次写入:

uart_rx_buffer[0]
uart_rx_buffer[1]
uart_rx_buffer[2]
……

因此通常需要启用:

Memory Increment:Enable

4. 数据宽度

DMA传输宽度可能包括:

Byte:8位
Half Word:16位
Word:32位

必须与外设数据寄存器和内存缓冲区类型匹配。

例如ADC结果为12位,通常保存在16位变量中:

uint16_t adc_buffer[16];

DMA可以配置为:

外设宽度:Half Word
内存宽度:Half Word

如果误配置成Byte,ADC结果可能被截断。


5. 传输数量

DMA需要知道要搬运多少个数据。

例如:

uint16_t adc_buffer[32];

启动:

HAL_ADC_Start_DMA(
    &hadc1,
    (uint32_t *)adc_buffer,
    32
);

这里的32通常表示32个传输单元,不一定表示32字节。

实际字节数由数据宽度决定。

如果每个传输单元为16位:

32个Half Word = 64字节

6. 地址自增

典型配置如下:

参数 外设地址 内存地址
是否自增 通常不增加 通常增加

例如UART接收:

源地址:USART_RDR,固定
目标地址:buffer[0]、buffer[1]、buffer[2]……,递增

7. DMA优先级

STM32可能同时有多个DMA请求:

  • ADC;
  • UART;
  • SPI;
  • I²S;
  • 定时器;
  • DAC。

当多个请求同时发生时,需要决定先服务谁。

常见优先级包括:

Low
Medium
High
Very High

优先级越高,DMA请求越容易先被处理。

但优先级不是越高越好。

如果所有DMA都配置成最高优先级,就失去了区分意义。


九、DMA是如何被外设触发的

DMA不是自己随时读取外设,而是由外设产生DMA请求。

以ADC为例:

ADC完成一次转换
       ↓
ADC数据寄存器产生新结果
       ↓
ADC发出DMA请求
       ↓
DMA读取ADC数据寄存器
       ↓
写入内存

以UART接收为例:

UART接收到一个字节
       ↓
接收数据寄存器非空
       ↓
UART发出DMA请求
       ↓
DMA读取RDR
       ↓
写入接收数组

以UART发送为例:

UART发送数据寄存器可以写入
       ↓
UART发出DMA请求
       ↓
DMA从内存读取一个字节
       ↓
写入UART发送寄存器

因此DMA和外设之间需要有硬件请求映射关系。


十、DMA通道、Stream和DMAMUX是什么

不同STM32系列的DMA结构不同。


1. 固定通道映射

部分较早STM32系列中,一个外设固定对应某个DMA通道。

例如:

USART1_RX → DMA1_Channel5
USART1_TX → DMA1_Channel4

如果选错通道,DMA不会正常工作。


2. Stream和Channel选择

部分STM32系列使用:

DMA控制器
   ↓
Stream
   ↓
Channel

一个Stream可以通过Channel选择连接不同外设请求。

配置时需要同时确认:

  • DMA1还是DMA2;
  • 使用哪个Stream;
  • 使用哪个Channel;
  • 是否与其他外设冲突。

3. DMAMUX

部分较新的STM32系列增加DMAMUX:

外设DMA请求
      ↓
    DMAMUX
      ↓
DMA Channel

DMAMUX可以更灵活地将外设请求映射到DMA通道。

但配置也更加复杂,需要确认:

  • Request编号;
  • DMA通道;
  • 同步触发;
  • Request Generator;
  • 是否存在请求冲突。

十一、Normal模式和Circular模式有什么区别

DMA最常见的两种模式是:

  • Normal;
  • Circular。

1. Normal模式

DMA按照设定数量完成一次传输后停止。

例如接收100字节:

接收第1字节
接收第2字节
……
接收第100字节
       ↓
DMA完成
       ↓
停止传输

如果需要继续接收,CPU需要重新启动DMA。

适合:

  • 固定长度数据包;
  • 单次发送;
  • 单次ADC采样;
  • 固定大小SPI传输。

2. Circular模式

DMA完成一轮后,自动从缓冲区开头重新开始。

例如:

buffer[0]
buffer[1]
……
buffer[15]
   ↓
buffer[0]
buffer[1]
……

适合:

  • ADC连续采样;
  • 音频数据流;
  • 循环波形输出;
  • 持续传感器采集;
  • 固定速率数据流。

Circular模式的关键问题是:

CPU处理数据的速度必须跟得上DMA覆盖数据的速度。

如果CPU还没有处理完前一轮数据,DMA就重新写入同一块内存,旧数据会被覆盖。


十二、什么是半传输中断

假设DMA循环缓冲区长度为100:

buffer[0] ~ buffer[99]

DMA传输到一半时:

buffer[0] ~ buffer[49]

可以触发半传输中断。

全部传输完成时:

buffer[50] ~ buffer[99]

可以触发全传输中断。

这样CPU可以分段处理:

DMA填充前半区
       ↓
半传输中断
       ↓
CPU处理前半区

DMA继续填充后半区
       ↓
全传输中断
       ↓
CPU处理后半区

这是一种典型的“乒乓处理”思想。

优点是:

  • DMA持续采集;
  • CPU分块处理;
  • 减少数据停顿;
  • 降低缓冲区被覆盖的风险。

十三、什么是双缓冲模式

双缓冲模式使用两个内存缓冲区:

Buffer A
Buffer B

工作过程:

DMA写Buffer A
CPU处理Buffer B

DMA写满Buffer A后切换到Buffer B
CPU开始处理Buffer A

两块缓冲区交替使用:

DMA:A → B → A → B
CPU:B → A → B → A

这类似生产线:

一个缓冲区正在生产数据
另一个缓冲区正在消费数据

双缓冲适合:

  • 音频采集;
  • 高速ADC;
  • 摄像头数据;
  • 高速SPI;
  • 网络数据流;
  • 连续DAC输出。

十四、DMA为什么能降低CPU占用

假设需要接收1000个字节。

中断逐字节接收

可能产生:

1000次接收中断

CPU需要执行1000次:

进入中断
读取数据
保存数据
退出中断

DMA接收

DMA自动搬运1000个字节,可能只产生:

1次完成中断

或者:

1次半传输中断
1次全传输中断

CPU中断次数从1000次下降到1~2次。

因此,DMA主要降低:

  • 中断进入和退出开销;
  • 数据寄存器读取开销;
  • 内存写入开销;
  • 数组索引更新开销;
  • CPU被频繁打断的次数。

十五、DMA是否一定比CPU快

不一定。

DMA的优势主要体现在:

降低CPU参与程度
提高并行处理能力
适合连续或批量传输

但DMA不一定在所有场景都更快。

例如只发送一个字节:

uint8_t data = 0x55;

如果使用DMA,需要:

配置地址
配置长度
清除标志
启动DMA
等待完成
处理回调

配置开销可能比CPU直接写寄存器还大。

因此:

少量、偶发数据:轮询或中断可能更简单
大量、连续数据:DMA优势明显

十六、STM32 UART轮询、IT和DMA代码对比

1. 轮询发送

uint8_t message[] = "Hello STM32\r\n";

HAL_UART_Transmit(
    &huart1,
    message,
    sizeof(message) - 1U,
    100U
);

CPU会等待发送完成。


2. 中断发送

uint8_t message[] = "Hello STM32\r\n";

HAL_UART_Transmit_IT(
    &huart1,
    message,
    sizeof(message) - 1U
);

CPU不用一直等待,但发送过程中通常需要多次UART中断。


3. DMA发送

uint8_t message[] = "Hello STM32\r\n";

HAL_UART_Transmit_DMA(
    &huart1,
    message,
    sizeof(message) - 1U
);

DMA负责将整个数组逐字节写入UART数据寄存器。

完成后会调用回调:

void HAL_UART_TxCpltCallback(
    UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        /* DMA发送完成 */
    }
}

十七、STM32 UART DMA接收固定长度数据

假设接收32字节:

#define UART_RX_LENGTH 32U

uint8_t uart_rx_buffer[UART_RX_LENGTH];

启动DMA:

if (HAL_UART_Receive_DMA(
        &huart1,
        uart_rx_buffer,
        UART_RX_LENGTH) != HAL_OK)
{
    Error_Handler();
}

接收完成后:

void HAL_UART_RxCpltCallback(
    UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1)
    {
        /*
         * uart_rx_buffer中已经有32字节数据。
         * 不建议在回调中执行复杂业务。
         */

        uart_rx_ready = 1U;

        /*
         * 如果DMA是Normal模式,
         * 需要重新启动下一次接收。
         */
        HAL_UART_Receive_DMA(
            &huart1,
            uart_rx_buffer,
            UART_RX_LENGTH);
    }
}

主循环中处理:

if (uart_rx_ready != 0U)
{
    uart_rx_ready = 0U;

    UART_ProcessData(
        uart_rx_buffer,
        UART_RX_LENGTH);
}

十八、不定长串口数据为什么不能只用固定长度DMA

实际串口协议的数据长度经常不固定。

例如:

第一帧:8字节
第二帧:21字节
第三帧:13字节

如果固定启动100字节DMA:

HAL_UART_Receive_DMA(
    &huart1,
    uart_rx_buffer,
    100);

只有接满100字节才会触发完成回调。

如果对方只发送21字节,DMA会一直等待剩余79字节。

这时通常需要结合:

DMA + UART空闲中断

也就是:

DMA负责搬运字节
UART IDLE负责判断一帧暂时结束

十九、DMA加空闲中断接收不定长数据

很多STM32 HAL库提供:

HAL_UARTEx_ReceiveToIdle_DMA();

示例:

#define UART_RX_BUFFER_SIZE 256U

uint8_t uart_rx_buffer[UART_RX_BUFFER_SIZE];

启动接收:

if (HAL_UARTEx_ReceiveToIdle_DMA(
        &huart1,
        uart_rx_buffer,
        UART_RX_BUFFER_SIZE) != HAL_OK)
{
    Error_Handler();
}

可以关闭半传输中断,避免不需要的回调:

__HAL_DMA_DISABLE_IT(
    huart1.hdmarx,
    DMA_IT_HT);

接收事件回调:

void HAL_UARTEx_RxEventCallback(
    UART_HandleTypeDef *huart,
    uint16_t size)
{
    if (huart->Instance == USART1)
    {
        /*
         * size表示本次接收到的数据长度。
         */

        UART_CopyToProcessBuffer(
            uart_rx_buffer,
            size);

        /*
         * Normal模式下重新启动。
         * 某些HAL实现和模式可能已经自动管理,
         * 需要结合具体芯片HAL确认。
         */
        HAL_UARTEx_ReceiveToIdle_DMA(
            &huart1,
            uart_rx_buffer,
            UART_RX_BUFFER_SIZE);

        __HAL_DMA_DISABLE_IT(
            huart1.hdmarx,
            DMA_IT_HT);
    }
}

这种方式非常适合:

  • Modbus RTU;
  • 自定义串口协议;
  • AT指令;
  • GPS数据;
  • 传感器报文;
  • 不定长调试命令。

二十、DMA接收不等于协议解析

DMA只负责:

把串口数据寄存器中的字节搬到内存数组

DMA不会自动完成:

  • 判断帧头;
  • 判断帧尾;
  • CRC校验;
  • Modbus地址判断;
  • 功能码解析;
  • 超时判断;
  • 数据粘包处理;
  • 数据拆包处理。

因此完整流程应该是:

UART接收数据
      ↓
DMA搬到内存
      ↓
空闲中断或长度条件判断帧结束
      ↓
协议解析
      ↓
CRC校验
      ↓
业务处理

不要把“DMA接收完成”误认为“协议帧一定完整”。


二十一、STM32 ADC使用DMA连续采集

ADC是DMA最典型的应用之一。

假设连续采集4个ADC通道:

#define ADC_CHANNEL_COUNT 4U

uint16_t adc_buffer[ADC_CHANNEL_COUNT];

启动ADC DMA:

if (HAL_ADC_Start_DMA(
        &hadc1,
        (uint32_t *)adc_buffer,
        ADC_CHANNEL_COUNT) != HAL_OK)
{
    Error_Handler();
}

如果ADC配置为:

扫描模式
连续转换
DMA循环模式

数据可能不断更新:

adc_buffer[0] → 通道1
adc_buffer[1] → 通道2
adc_buffer[2] → 通道3
adc_buffer[3] → 通道4

CPU只需要定期读取数组。


二十二、ADC DMA为什么比CPU逐次读取更合适

如果ADC采样率为100kHz,意味着每秒产生10万次转换结果。

如果每次转换都进入中断:

每秒10万次中断

CPU负担会非常大。

使用DMA后:

ADC每完成一次转换
       ↓
DMA自动写入数组
       ↓
一批数据完成后通知CPU

CPU可以一次处理几十、几百甚至几千个样本。

这对以下应用非常重要:

  • 电流采样;
  • 电压波形分析;
  • 音频采集;
  • 振动检测;
  • 电机控制;
  • FFT频谱计算;
  • 多通道传感器采集。

二十三、ADC DMA缓冲区示例

假设需要连续采集128个样本:

#define ADC_SAMPLE_COUNT 128U

uint16_t adc_samples[ADC_SAMPLE_COUNT];
volatile uint8_t adc_first_half_ready;
volatile uint8_t adc_second_half_ready;

启动:

HAL_ADC_Start_DMA(
    &hadc1,
    (uint32_t *)adc_samples,
    ADC_SAMPLE_COUNT);

半传输回调:

void HAL_ADC_ConvHalfCpltCallback(
    ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adc_first_half_ready = 1U;
    }
}

全传输回调:

void HAL_ADC_ConvCpltCallback(
    ADC_HandleTypeDef *hadc)
{
    if (hadc->Instance == ADC1)
    {
        adc_second_half_ready = 1U;
    }
}

主循环处理:

if (adc_first_half_ready != 0U)
{
    adc_first_half_ready = 0U;

    ADC_ProcessSamples(
        &adc_samples[0],
        ADC_SAMPLE_COUNT / 2U);
}

if (adc_second_half_ready != 0U)
{
    adc_second_half_ready = 0U;

    ADC_ProcessSamples(
        &adc_samples[ADC_SAMPLE_COUNT / 2U],
        ADC_SAMPLE_COUNT / 2U);
}

这样ADC和CPU可以并行工作。


二十四、SPI为什么经常使用DMA

SPI速度通常比较高。

例如SPI时钟为20MHz,发送1KB数据时,CPU逐字节写入会产生大量操作。

典型应用包括:

  • LCD刷新;
  • Flash读写;
  • 高速ADC;
  • 传感器批量读取;
  • 网络模块;
  • FPGA通信。

DMA发送:

HAL_SPI_Transmit_DMA(
    &hspi1,
    tx_buffer,
    tx_length);

DMA全双工传输:

HAL_SPI_TransmitReceive_DMA(
    &hspi1,
    tx_buffer,
    rx_buffer,
    data_length);

需要注意:

SPI DMA完成并不一定表示最后一个bit已经完全离开引脚。

某些场景中,DMA完成只表示最后一个数据已经写入SPI数据寄存器或FIFO。

如果要立即拉高片选,需要进一步等待SPI Busy标志清零。


二十五、DMA完成到底意味着什么

这是非常重要的工程问题。

DMA完成通常表示:

设定数量的数据已经由DMA完成读写

但不一定表示:

外设的物理传输已经完全结束

例如UART发送:

DMA完成:
最后一个字节已经写入UART发送寄存器。

UART TC完成:
最后一个字节的停止位已经真正从TX引脚发送完毕。

如果使用RS485,需要在发送完后切换DE方向:

DMA完成后立刻拉低DE

可能太早。

更可靠的流程是:

DMA把最后一个字节写入UART
        ↓
等待UART Transmission Complete
        ↓
确认停止位已经发送完成
        ↓
拉低RS485 DE
        ↓
切换到接收状态

二十六、DMA和中断是什么关系

DMA不是中断的替代品。

它们解决的是不同问题:

DMA:负责搬运数据
中断:负责通知事件

典型组合是:

DMA自动搬运1000个字节
        ↓
完成后产生一次DMA中断
        ↓
CPU处理这一批数据

因此实际系统通常是:

DMA + 中断

而不是:

DMA 或 中断二选一

二十七、为什么用了DMA仍然会丢数据

DMA可以降低CPU负担,但不能保证任何情况下都不丢数据。

常见原因包括:


1. 缓冲区太小

例如UART DMA缓冲区只有64字节,但CPU长时间没有处理。

新数据继续到来,缓冲区可能被覆盖。


2. CPU处理速度跟不上

DMA负责快速生产数据,CPU负责消费数据。

如果:

数据产生速度 > 数据处理速度

无论缓冲区多大,最终都会溢出。

这就是典型的生产者和消费者速度不匹配。


3. DMA没有及时重新启动

Normal模式传输完成后,如果CPU没有重新启动DMA,后续数据就无法接收。


4. 空闲中断处理不正确

可能出现:

  • 清除IDLE标志方式错误;
  • 数据长度计算错误;
  • 重启DMA太晚;
  • 回调中处理时间太长;
  • 数据复制与DMA写入冲突。

5. UART发生溢出错误

如果UART接收寄存器中的数据没有及时被读取,可能出现ORE错误。

DMA配置错误或被其他高优先级总线访问长期阻塞时,也可能造成问题。


6. DMA优先级不合理

高速外设DMA优先级太低,可能无法及时获得总线访问权。


7. 中断被长时间关闭

如果程序长时间关闭中断,DMA可能仍在搬数据,但完成事件和缓冲区切换无法及时处理。


8. 缓冲区被其他代码修改

例如DMA正在发送:

HAL_UART_Transmit_DMA(
    &huart1,
    tx_buffer,
    length);

但DMA还没结束,CPU就修改了:

tx_buffer[0] = new_data;

最终发送内容可能被改变。


二十八、DMA传输期间为什么不能随便修改缓冲区

DMA和CPU可能同时访问同一块内存。

例如:

DMA正在读取tx_buffer发送串口
CPU同时修改tx_buffer

可能发生:

前半部分是旧数据
后半部分是新数据

因此DMA发送期间,应保证缓冲区内容不被修改。

常见做法包括:

  • 使用独立发送缓冲区;
  • 发送前复制数据;
  • 使用发送忙标志;
  • 使用消息队列;
  • 使用双缓冲;
  • 完成回调后再释放缓冲区。

二十九、DMA缓冲区为什么经常使用volatile

中断回调和主程序之间共享的状态变量通常需要使用:

volatile

例如:

volatile uint8_t adc_data_ready;

因为这个变量可能在中断中被修改。

但DMA数据缓冲区是否必须声明为volatile,需要结合访问方式判断。

例如:

uint16_t adc_buffer[128];

仅仅因为DMA写这个数组,并不代表所有场景下都必须加volatile。

在有Cache的系统中,volatile也无法解决Cache一致性问题。

volatile的主要作用是告诉编译器:

该变量可能在程序正常控制流之外发生变化,
每次访问都应按照语言规则实际读取或写入。

它不能代替:

  • 内存屏障;
  • Cache维护;
  • DMA同步;
  • 临界区保护。

三十、Cortex-M7为什么会出现DMA Cache一致性问题

部分STM32 Cortex-M7芯片带有D-Cache。

CPU读取内存时,数据可能先被缓存到Cache中。

假设DMA接收数据:

DMA将新数据写入RAM

但CPU再次访问同一地址时,可能仍然读取Cache中的旧数据。

结果表现为:

  • DMA明明完成了,数组内容却没有更新;
  • 调试器看到的数据和程序读取的数据不同;
  • 偶尔正常、偶尔异常;
  • 关闭D-Cache后问题消失。

DMA接收时

DMA写内存后,CPU读取前通常需要使对应Cache行失效:

SCB_InvalidateDCache_by_Addr(
    (uint32_t *)rx_buffer,
    buffer_size);

DMA发送时

CPU修改内存后,DMA读取前通常需要将Cache中的数据清理到RAM:

SCB_CleanDCache_by_Addr(
    (uint32_t *)tx_buffer,
    buffer_size);

还需要注意:

  • 地址按Cache Line对齐;
  • 长度覆盖完整Cache Line;
  • 缓冲区不要与其他变量共享同一Cache Line;
  • 可将DMA缓冲区放入非缓存区域;
  • 可使用MPU设置内存属性。

不同STM32系列和内存区域的Cache行为不同,应结合具体芯片手册设计。


三十一、DMA为什么可能产生总线竞争

CPU和DMA都需要访问存储器和外设总线。

例如:

CPU读取Flash
CPU访问SRAM
DMA读取外设
DMA写入SRAM
另一个DMA读取SRAM

这些操作可能同时请求总线。

总线矩阵或仲裁器会决定访问顺序。

因此DMA并不是“完全免费”的。

大量DMA传输可能带来:

  • SRAM总线占用;
  • CPU访问内存延迟增加;
  • 多DMA之间竞争;
  • 外设FIFO溢出;
  • 实时任务抖动。

不过在绝大多数普通嵌入式项目中,合理使用DMA仍然比CPU逐字节搬运更高效。


三十二、DMA FIFO和Burst有什么作用

部分高性能STM32 DMA支持:

  • FIFO;
  • Burst传输。

1. FIFO

DMA可以先将数据暂存在内部FIFO,再成批访问总线。

作用包括:

  • 缓冲外设和内存速度差异;
  • 支持不同数据宽度;
  • 减少总线访问次数;
  • 提升突发传输效率。

2. Burst

DMA可以一次连续传输多个数据:

Single
INCR4
INCR8
INCR16

例如INCR4表示一次突发传输4个数据单元。

适合:

  • 高带宽数据;
  • 存储器批量传输;
  • 高速外设;
  • 音视频数据。

普通低速UART通常不需要复杂Burst配置。


三十三、DMA常见标志位

DMA通常包含以下事件:

传输完成 Transfer Complete
半传输 Half Transfer
传输错误 Transfer Error
FIFO错误 FIFO Error
直接模式错误 Direct Mode Error

程序不能只处理完成回调,也应该关注错误。

例如:

void HAL_DMA_ErrorCallback(
    DMA_HandleTypeDef *hdma)
{
    dma_error_flag = 1U;
}

对于可靠性要求高的系统,还需要记录:

  • 哪个DMA出错;
  • 错误代码;
  • 当前剩余数量;
  • 外设状态;
  • 是否需要复位DMA;
  • 是否需要复位外设。

三十四、DMA剩余传输数量有什么用

DMA内部通常会保存剩余传输数量。

HAL中可以读取:

uint32_t remaining;

remaining = __HAL_DMA_GET_COUNTER(
    huart1.hdmarx);

如果总缓冲区长度为256:

已接收数量 = 256 - 剩余数量

例如:

剩余数量 = 200
已接收数量 = 256 - 200 = 56字节

这个机制常用于:

  • UART空闲中断;
  • 环形缓冲区;
  • 当前DMA写指针计算;
  • 数据流位置判断。

三十五、DMA环形缓冲区如何理解

假设DMA循环接收数组为:

#define RX_BUFFER_SIZE 128U

uint8_t rx_buffer[RX_BUFFER_SIZE];

DMA不断循环写入:

0 → 1 → 2 → …… → 127 → 0 → 1……

CPU需要维护自己的读取位置:

DMA写指针
CPU读指针

当两者不相等时,说明有新数据。

基本逻辑:

DMA负责写入
CPU负责读取
读指针追赶写指针

如果写指针追上读指针,可能意味着缓冲区已满,旧数据被覆盖。


三十六、DMA与FreeRTOS如何配合

在FreeRTOS中,不建议在DMA完成回调里执行复杂处理。

更合理的结构是:

DMA完成中断
      ↓
释放信号量/发送任务通知
      ↓
数据处理任务被唤醒
      ↓
任务中处理数据

例如:

void HAL_UART_RxCpltCallback(
    UART_HandleTypeDef *huart)
{
    BaseType_t higher_priority_task_woken =
        pdFALSE;

    if (huart->Instance == USART1)
    {
        vTaskNotifyGiveFromISR(
            uart_task_handle,
            &higher_priority_task_woken);

        portYIELD_FROM_ISR(
            higher_priority_task_woken);
    }
}

任务中等待:

void UART_ProcessTask(void *argument)
{
    for (;;)
    {
        ulTaskNotifyTake(
            pdTRUE,
            portMAX_DELAY);

        UART_ProcessData(
            uart_rx_buffer,
            UART_RX_LENGTH);
    }
}

这样可以让中断保持简短。


三十七、DMA使用中最常见的配置错误

1. DMA时钟没有使能

例如:

__HAL_RCC_DMA1_CLK_ENABLE();

没有使能DMA时钟,DMA不会工作。


2. DMA通道或Stream选错

外设请求和DMA映射不匹配。

表现为:

外设正常工作
DMA完全没有数据
完成中断不触发

3. 传输方向错误

例如UART接收应为:

Peripheral to Memory

却配置为:

Memory to Peripheral

4. 数据宽度错误

ADC结果是16位,DMA却配置为8位,导致数据截断或数组错位。


5. 地址自增配置错误

UART数据寄存器地址如果错误地自增,DMA第一次传输后就会访问错误寄存器。

内存地址如果没有自增,所有数据都会覆盖在数组第一个元素中。


6. DMA长度与缓冲区不匹配

例如:

uint8_t buffer[64];

却启动:

HAL_UART_Receive_DMA(
    &huart1,
    buffer,
    128);

DMA会写越界,破坏其他变量甚至导致HardFault。


7. 没有正确关联DMA句柄

HAL库通常需要:

__HAL_LINKDMA(
    &huart1,
    hdmarx,
    hdma_usart1_rx);

如果句柄没有关联,HAL_UART_Receive_DMA可能无法正常管理DMA。


8. DMA中断没有配置

如果需要完成回调,需要:

  • 配置NVIC;
  • 设置优先级;
  • 使能DMA中断;
  • 在中断函数中调用HAL处理函数。

例如:

void DMA1_Channel5_IRQHandler(void)
{
    HAL_DMA_IRQHandler(
        &hdma_usart1_rx);
}

9. 中断函数名字错误

启动文件中的中断向量名称必须与代码一致。

如果名称写错,DMA硬件已经完成,但程序不会进入正确的处理函数。


10. DMA缓冲区生命周期错误

例如在函数栈中定义:

void UART_Send(void)
{
    uint8_t temp[32];

    HAL_UART_Transmit_DMA(
        &huart1,
        temp,
        sizeof(temp));
}

函数返回后,temp所在栈空间可能被其他函数复用,但DMA可能仍在读取。

DMA缓冲区应该在传输完成前保持有效。

可以使用:

  • 全局数组;
  • 静态数组;
  • 动态内存并延迟释放;
  • 专用缓冲池。

三十八、如何判断DMA是否真的在工作

可以按照下面的步骤排查。

第一步:检查外设本身是否正常

先用轮询方式验证:

HAL_UART_Receive();
HAL_ADC_PollForConversion();
HAL_SPI_Transmit();

如果轮询都不正常,先不要怀疑DMA。


第二步:检查DMA时钟

确认DMA控制器时钟已开启。


第三步:检查请求映射

确认:

外设
DMA控制器
Channel或Stream
Request编号

全部匹配。


第四步:检查DMA计数器

读取剩余数量:

__HAL_DMA_GET_COUNTER();

如果计数器完全不变化,说明DMA请求可能没有到达。


第五步:检查外设DMA使能位

外设中通常存在DMA发送或接收使能位。

例如UART需要使能:

DMAR
DMAT

HAL函数一般会自动配置,但手写寄存器时必须检查。


第六步:检查DMA中断标志

判断:

  • 传输完成标志是否置位;
  • 是否发生传输错误;
  • 是否发生FIFO错误;
  • 中断是否被NVIC屏蔽。

第七步:检查内存数组

观察:

  • 数据是否写入;
  • 是否只写入第一个元素;
  • 是否发生错位;
  • 是否被其他程序覆盖;
  • 数据宽度是否正确。

三十九、DMA适合哪些场景

DMA非常适合以下应用。

1. ADC连续采样

ADC → DMA → 数组 → 滤波/FFT

2. 串口高速收发

UART → DMA → 环形缓冲区

3. SPI大批量传输

帧缓冲区 → DMA → SPI → LCD

4. I²S音频

I²S → DMA → 双缓冲 → 音频处理

5. DAC连续输出

波形数组 → DMA → DAC

6. 定时器捕获

输入捕获寄存器 → DMA → 时间戳数组

7. 摄像头或高速接口

DCMI/外设 → DMA → 图像缓冲区

四十、哪些场景不一定需要DMA

DMA并不是所有传输的最佳方案。

以下场景可以优先考虑轮询或中断:

  • 只发送几个字节;
  • 数据发生频率很低;
  • 初始化时读取一次寄存器;
  • 调试串口偶尔打印;
  • 系统资源很少;
  • DMA通道已经被重要外设占用;
  • 传输长度不固定且协议逻辑非常简单;
  • 数据处理本身比搬运更耗时。

例如I²C读取一个2字节温度寄存器,使用阻塞方式可能更简单:

HAL_I2C_Mem_Read(
    &hi2c1,
    device_address,
    register_address,
    I2C_MEMADD_SIZE_8BIT,
    data,
    2,
    100);

不必为了“看起来高级”而强行使用DMA。


四十一、DMA的真正价值是什么

很多人认为DMA只是让数据传输更快。

实际上,DMA更重要的价值是:

1. 释放CPU时间

CPU可以处理:

  • 控制算法;
  • 通信协议;
  • UI;
  • 状态机;
  • 故障判断;
  • FreeRTOS任务。

2. 降低中断频率

从逐字节中断变成分块完成中断。

3. 实现外设和CPU并行工作

例如:

DMA采集下一批ADC数据
CPU处理上一批ADC数据

4. 保证连续数据流

适合:

  • 音频;
  • 波形采样;
  • 高速通信;
  • 连续显示刷新。

5. 提高系统实时性

CPU不需要被大量低价值搬运工作频繁打断。


四十二、一个完整的DMA系统设计思路

使用DMA前,建议回答以下问题:

数据从哪里来?
数据要到哪里去?
每次传多少?
数据宽度是多少?
传输是单次还是连续?
什么时候认为一帧结束?
缓冲区多大?
CPU处理速度能否跟上?
数据是否可能被覆盖?
完成后怎样通知任务?
是否需要半传输?
是否需要双缓冲?
是否存在Cache?
错误后如何恢复?

典型结构如下:

外设产生数据
      ↓
DMA写入缓冲区
      ↓
半传输/全传输/空闲事件
      ↓
中断中设置标志或通知任务
      ↓
任务处理数据
      ↓
完成协议解析或算法运算

四十三、面试中如何回答DMA解决了什么问题

如果面试官问:

STM32 DMA到底解决了什么问题?

可以这样回答:

DMA主要解决外设和内存之间大量重复数据搬运占用CPU的问题。

在没有DMA时,CPU需要通过轮询或中断,
逐个读取外设数据寄存器并写入内存,
或者从内存读取数据再写入外设。

使用DMA后,CPU只需要提前配置源地址、目标地址、
传输方向、数据宽度和传输数量,
DMA控制器就可以响应外设请求自动完成数据搬运。

传输完成后,DMA再通过中断通知CPU处理整批数据。

DMA可以明显减少逐字节中断和CPU搬运开销,
特别适合ADC连续采样、UART高速通信、
SPI批量传输、I2S音频和DAC波形输出。

但DMA并不是完全不需要CPU,
CPU仍然负责配置、启动、缓冲区管理、
完成回调、错误处理和业务数据处理。

四十四、总结

DMA的本质不是一个新的通信协议,而是一个硬件数据搬运控制器。

没有DMA时:

外设
  ↓
CPU读取
  ↓
CPU写入内存

使用DMA后:

外设
  ↓
DMA自动搬运
  ↓
内存

DMA主要解决以下问题:

CPU逐字节搬运数据占用时间;
高频中断导致系统负担过重;
连续高速数据难以稳定接收;
外设工作时CPU无法同时处理其他任务;
大量数据传输效率低。

DMA并不能自动解决:

协议解析;
帧头帧尾判断;
CRC校验;
缓冲区溢出;
CPU处理速度不足;
Cache一致性;
错误恢复;
业务逻辑。

轮询、中断和DMA可以简单总结为:

轮询:CPU一直等待并搬运数据;
中断:数据到达后通知CPU搬运;
DMA:硬件自动搬运,完成后再通知CPU。

最后用一句话总结:

STM32 DMA真正解决的不是“数据怎么计算”,而是“数据由谁搬运”的问题,它让CPU从重复的数据复制工作中释放出来,把时间用于更重要的控制、通信和算法处理。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐