首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >DMA 原理从 0 到 1:为什么它能解放 CPU?一文讲透 DMA 的工作流程

DMA 原理从 0 到 1:为什么它能解放 CPU?一文讲透 DMA 的工作流程

原创
作者头像
用户11663509
发布2026-07-27 19:53:53
发布2026-07-27 19:53:53
460
举报

前言

很多刚接触 STM32、GD32、NXP、ESP32 或其他 MCU 的同学,第一次看到 DMA 时都会有点懵:

DMA 到底是什么? 为什么开启 DMA 后,ADC、串口、SPI 就能“不占 CPU”传数据? DMA 是不是一个更快的 memcpy? 为什么 DMA 还要配置源地址、目标地址、数据宽度、传输长度、自增模式? DMA 半传输中断、传输完成中断又是什么意思?

如果只背概念,DMA 很抽象。 但如果从一个实际问题出发,它就很好理解。

假设我们要连续采集 ADC,并把采样结果放进数组:

代码语言:javascript
复制
uint16_t adc_buf[1024];

不用 DMA 时,CPU 需要不断做这件事:

代码语言:javascript
复制
adc_buf[i++] = ADC_DR;

也就是 ADC 每转换一次,CPU 就要过来读一次寄存器、写一次内存。 如果采样频率很高,CPU 会被这种重复搬运拖住。

DMA 的作用就是:

让 CPU 先把搬运规则配置好,然后由 DMA 硬件自动把数据从外设搬到内存,或者从内存搬到外设。

一句话理解:

CPU 是指挥,DMA 是搬运工,外设和内存是货物的两端。


1. 没有 DMA 时,CPU 在做什么

先看一个不用 DMA 的 ADC 采样流程。

伪代码如下:

代码语言:javascript
复制
for (int i = 0; i < 1024; i++)
{
    Start_ADC_Convert();
    Wait_ADC_Complete();
    adc_buf[i] = Read_ADC_DataRegister();
}

CPU 做了很多重复工作:

  • 启动转换
  • 等待完成
  • 读取 ADC 数据寄存器
  • 写入内存数组
  • 更新数组下标
  • 判断是否采完

如果采样频率低,这样写没问题。 但如果 ADC 采样很快,或者系统同时还要跑电机控制、通信协议、UI、算法任务,CPU 就会被数据搬运占用太多时间。

串口接收也是类似问题。

不用 DMA 时,CPU 可能每收到一个字节就进一次中断:

代码语言:javascript
复制
void USART_IRQHandler(void)
{
    rx_buf[index++] = USART_DR;
}

如果串口数据量大,中断频率就会很高。 CPU 一直进中断,主程序自然就被打断得很碎。

先看没有 DMA 的情况,CPU 基本是在替外设搬数据。

对比着看,DMA 的价值就更容易理解了:它把“搬数据”这件事从 CPU 手里拿走。


2. DMA 到底是什么

DMA 的全称是 Direct Memory Access,直译是“直接内存访问”。

这个名字容易让人误会,好像 DMA 只是访问内存。 更准确地说,它是:

MCU 内部的一个硬件数据搬运控制器,可以在不让 CPU 逐个搬运数据的情况下,在外设和内存之间传输数据。

常见传输方向有三种:

方向

例子

外设到内存

ADC 采样到数组、UART 接收到缓冲区

内存到外设

UART 发送数组、SPI 刷屏、DAC 输出波形

内存到内存

拷贝一段 RAM 数据,部分 MCU 支持

DMA 不是凭空工作的。 它需要 CPU 先告诉它几件事:

  • 从哪里读数据
  • 写到哪里去
  • 每次搬几个字节
  • 总共搬多少次
  • 地址是否自动增加
  • 谁来触发传输
  • 搬完后要不要中断通知 CPU

这些就是我们在工程里配置 DMA 的本质。


3. DMA 在系统里的位置

先看系统结构图。

从图里可以看到,DMA 不是 CPU 的一条语句,也不是普通函数。 它是 MCU 内部一个独立的硬件模块,并且和 CPU 一样可以访问总线。

以 ADC 采样为例:

  1. ADC 转换完成后,数据出现在 ADC 数据寄存器里
  2. ADC 向 DMA 发出请求
  3. DMA 通过总线读取 ADC 数据寄存器
  4. DMA 再通过总线把数据写入 RAM 数组
  5. 搬运达到指定数量后,DMA 置位标志或触发中断
  6. CPU 在中断或回调里处理这块数据

所以 DMA 的核心不是“让数据消失在后台”,而是把原本 CPU 做的数据搬运交给硬件完成。


4. DMA 一次传输到底发生了什么

再看一次完整流程。

还是以 ADC → 内存 为例。

4.1 CPU 配置 DMA

CPU 先配置:

代码语言:javascript
复制
源地址:ADC 数据寄存器地址
目标地址:adc_buf 数组首地址
传输长度:1024
源数据宽度:16 bit
目标数据宽度:16 bit
源地址自增:不自增
目标地址自增:自增
模式:普通模式或循环模式

为什么 ADC 源地址不自增?

因为 ADC 数据永远从同一个寄存器读:

代码语言:javascript
复制
ADC_DR

为什么目标地址要自增?

因为每次采样值要依次写入数组:

代码语言:javascript
复制
adc_buf[0]
adc_buf[1]
adc_buf[2]
...

这就是 DMA 配置里“地址自增”的含义。

4.2 外设产生 DMA 请求

DMA 不一定自己主动搬。

对于 ADC、UART、SPI 这类外设,通常是外设事件触发 DMA:

  • ADC 转换完成,触发一次 DMA 请求
  • UART 收到一个字节,触发一次 DMA 请求
  • UART 发送寄存器空,触发一次 DMA 请求
  • SPI 接收缓冲非空,触发一次 DMA 请求
  • SPI 发送缓冲空,触发一次 DMA 请求

每来一次请求,DMA 就按配置搬一次数据。

4.3 DMA 搬数据并更新计数器

DMA 每搬一次,会做几件事:

代码语言:javascript
复制
读取源地址
写入目标地址
根据配置更新地址
传输计数器减 1
判断是否半传输或传输完成

比如 ADC 采样到数组:

代码语言:javascript
复制
第 1 次:ADC_DR → adc_buf[0]
第 2 次:ADC_DR → adc_buf[1]
第 3 次:ADC_DR → adc_buf[2]
...

CPU 不需要每次都介入。

4.4 半传输和传输完成

如果传输长度是 1024:

代码语言:javascript
复制
搬到 512 个:半传输 HT
搬到 1024 个:传输完成 TC

这两个事件非常重要。

在 ADC 连续采样里,我们经常用双半区处理:

代码语言:javascript
复制
DMA 正在写前半区时,CPU 处理后半区
DMA 正在写后半区时,CPU 处理前半区

这样可以实现连续采样,不容易丢数据。


5. DMA 的几个核心配置怎么理解

5.1 源地址和目标地址

DMA 首先要知道:

代码语言:javascript
复制
从哪里搬
搬到哪里

例如 ADC 到内存:

代码语言:javascript
复制
源地址:&ADCx->DR
目标地址:adc_buf

UART 发送:

代码语言:javascript
复制
源地址:tx_buf
目标地址:&USARTx->DR

UART 接收:

代码语言:javascript
复制
源地址:&USARTx->DR
目标地址:rx_buf

方向不同,源和目标就不同。

5.2 数据宽度

数据宽度决定每次搬多少。

常见有:

代码语言:javascript
复制
8 bit
16 bit
32 bit

例如:

  • UART 收发通常是 8 bit
  • ADC 结果通常用 16 bit
  • 某些 DAC、SPI、内存拷贝可能用 16 bit 或 32 bit

如果宽度配错,轻则数据错位,重则传输异常。

5.3 地址自增

地址自增是 DMA 最容易让初学者困惑的地方。

判断方法很简单:

如果每次都访问同一个寄存器,不自增。 如果每次要写入或读取数组下一个元素,自增。

ADC 到数组:

代码语言:javascript
复制
ADC_DR:不自增
adc_buf:自增

UART 发送数组:

代码语言:javascript
复制
tx_buf:自增
USART_DR:不自增

内存到内存:

代码语言:javascript
复制
src_buf:自增
dst_buf:自增

5.4 传输长度

传输长度不是字节数,而是“传输次数”。

如果配置:

代码语言:javascript
复制
数据宽度:16 bit
传输长度:1024

表示搬 1024 个半字,也就是 2048 字节。

如果配置:

代码语言:javascript
复制
数据宽度:8 bit
传输长度:1024

表示搬 1024 个字节。

这个概念很关键,尤其在调 ADC、SPI、I2S、LCD 时。

5.5 普通模式和循环模式

普通模式:

代码语言:javascript
复制
搬完指定长度后停止

适合:

  • UART 发送一包数据
  • SPI 发送一帧图片数据
  • 一次性内存拷贝

循环模式:

代码语言:javascript
复制
搬完指定长度后,从头继续搬

适合:

  • ADC 连续采样
  • I2S 音频采集
  • UART 环形接收
  • DAC 循环输出波形

循环模式一定要配合好缓冲区处理,否则 CPU 还没处理完,DMA 又把旧数据覆盖了。


6. 一个实际例子:ADC + DMA 连续采样

假设我们要采集 1024 个 ADC 数据。

缓冲区:

代码语言:javascript
复制
uint16_t adc_buf[1024];

启动:

代码语言:javascript
复制
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 1024);

当 DMA 写完前 512 个数据时,会进入半传输回调:

代码语言:javascript
复制
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    ProcessAdcData(&adc_buf[0], 512);
}

当 DMA 写完后 512 个数据时,会进入完成回调:

代码语言:javascript
复制
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    ProcessAdcData(&adc_buf[512], 512);
}

如果 DMA 是循环模式,流程就是:

代码语言:javascript
复制
DMA 写前半区 → CPU 处理前半区
DMA 写后半区 → CPU 处理后半区
DMA 再写前半区 → CPU 再处理前半区
...

这就是很多音频采集、波形采集、电流采样里常见的 ping-pong 思路。


7. 一个实际例子:UART + DMA 接收

串口接收如果一个字节进一次中断,数据量大时 CPU 压力会很明显。

DMA 接收可以这样理解:

代码语言:javascript
复制
UART 每收到 1 字节
  ↓
触发 DMA 请求
  ↓
DMA 从 UART 数据寄存器读出
  ↓
写入 rx_buf

常见启动方式:

代码语言:javascript
复制
uint8_t rx_buf[256];

HAL_UART_Receive_DMA(&huart1, rx_buf, sizeof(rx_buf));

如果接收的是不定长数据,通常会配合空闲中断:

代码语言:javascript
复制
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));

回调:

代码语言:javascript
复制
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1)
    {
        ParseFrame(rx_buf, Size);
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
    }
}

这样 CPU 不需要每个字节都进中断,而是在收到一段数据后统一处理。


8. DMA 不是完全不占 CPU

很多人会说“DMA 不占 CPU”,这句话不够严谨。

更准确的说法是:

DMA 不需要 CPU 逐个数据搬运,但 CPU 仍然需要配置 DMA、处理完成事件、处理错误和消费缓冲区数据。

CPU 仍然要做:

  • 初始化 DMA
  • 启动外设
  • 启动 DMA
  • 处理半传输中断
  • 处理传输完成中断
  • 处理错误中断
  • 处理缓冲区里的数据

DMA 省掉的是最重复、最机械的搬运动作。


9. DMA 会不会和 CPU 抢总线

会。

DMA 和 CPU 都要访问内存和外设寄存器,本质上都要用总线。 所以 DMA 并不是魔法,它只是把搬运工作从 CPU 指令流里拿出来,交给独立硬件模块执行。

在高性能 MCU 上,可能会有:

  • AHB 总线
  • AXI 总线
  • 多层总线矩阵
  • DMA 控制器
  • DMAMUX
  • Cache
  • TCM / SRAM 多区域

这些都会影响 DMA 行为。

一般入门阶段先记住:

DMA 可以减少 CPU 参与,但大量 DMA 传输仍然会占用总线带宽。

所以在高速采样、屏幕刷新、音频传输、网络收发等场景里,仍然要注意带宽和缓冲区设计。


10. DMA 常见错误

10.1 地址自增配置错

ADC 到数组时,如果目标地址没有自增,就会一直写 adc_buf[0]

UART 发送时,如果源地址没有自增,就会一直发送同一个字节。

10.2 数据宽度配置错

ADC 是 12 bit 结果,通常用 16 bit 存。 如果 DMA 配成 8 bit,数据就可能被截断或错位。

10.3 传输长度理解错

传输长度是次数,不一定是字节数。

例如 uint16_t adc_buf[1024],长度应该填 1024,不是 2048。

10.4 缓冲区被覆盖

循环模式下,如果 CPU 处理太慢,DMA 会重新写到旧位置。

解决办法:

  • 增大缓冲区
  • 使用半传输/完成双半区处理
  • 降低采样率
  • 提高处理效率
  • 只在回调里置标志,主循环里处理

10.5 Cache 一致性问题

在带 D-Cache 的 MCU 上,比如 STM32H7,DMA 和 CPU 看到的数据可能不一致。

典型现象:

  • DMA 明明接收了数据,CPU 读缓冲区还是旧值
  • CPU 改了发送缓冲区,DMA 发出去的是旧数据

这是 Cache 一致性问题,需要根据芯片和工程处理:

  • DMA 缓冲区放到非 Cache 区域
  • 接收后 invalidate cache
  • 发送前 clean cache
  • 注意缓冲区地址和长度对齐

这部分是进阶内容,但如果你用的是 H7、MPU、带 Cache 的 Cortex-M7,一定要留意。


11. 怎么判断一个场景该不该用 DMA

适合用 DMA 的场景:

  • 数据量大
  • 传输频繁
  • 数据格式规则
  • CPU 不想被频繁中断打断
  • 外设支持 DMA 请求
  • 可以用缓冲区批量处理

典型场景:

场景

是否适合 DMA

ADC 连续采样

很适合

UART 大量接收

很适合

UART 发送大包

适合

SPI 刷屏

很适合

I2S 音频

很适合

DAC 输出波形

很适合

偶尔发 1 个字节

没必要

很复杂的非规则协议解析

DMA 只负责接收,解析仍由 CPU 做

简单判断:

如果 CPU 在重复搬数据,考虑 DMA。 如果 CPU 在做判断、计算、协议解析,DMA 不能替你完成这些逻辑。


12. 总结

DMA 可以从三个层次理解。

第一层:

DMA 是硬件搬运工。

第二层:

CPU 配置搬运规则,DMA 根据外设请求在外设和内存之间搬数据。

第三层:

DMA 减少 CPU 的重复搬运和高频中断压力,但仍然会占用总线,也需要正确的缓冲区、中断和 Cache 处理。

如果你第一次学 DMA,不要一上来陷入寄存器细节。 先把这几个问题想清楚:

  • 数据从哪里来?
  • 数据要到哪里去?
  • 每次搬多宽?
  • 搬多少次?
  • 哪边地址要自增?
  • 是普通模式还是循环模式?
  • 搬到一半和搬完后 CPU 要做什么?

能回答这几个问题,DMA 就已经理解了一大半。

最后用一句话收尾:

DMA 的本质不是让 CPU 消失,而是让 CPU 从“搬数据”升级为“安排数据怎么搬”。

如果你在调 STM32、RS485、Modbus、RTT、波形采集或者数据可视化,上位机工具 多多盒子助手。 它把串口、J-Link RTT、Modbus、实时图表、CRC 校验、日志保存和固件升级这些常用能力放在了一起,适合做日常嵌入式调试。

目地址:

https://gitee.com/momingchuan/duo-duo-box

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
    • 1. 没有 DMA 时,CPU 在做什么
    • 2. DMA 到底是什么
    • 3. DMA 在系统里的位置
    • 4. DMA 一次传输到底发生了什么
      • 4.1 CPU 配置 DMA
      • 4.2 外设产生 DMA 请求
      • 4.3 DMA 搬数据并更新计数器
      • 4.4 半传输和传输完成
    • 5. DMA 的几个核心配置怎么理解
      • 5.1 源地址和目标地址
      • 5.2 数据宽度
      • 5.3 地址自增
      • 5.4 传输长度
      • 5.5 普通模式和循环模式
    • 6. 一个实际例子:ADC + DMA 连续采样
    • 7. 一个实际例子:UART + DMA 接收
    • 8. DMA 不是完全不占 CPU
    • 9. DMA 会不会和 CPU 抢总线
    • 10. DMA 常见错误
      • 10.1 地址自增配置错
      • 10.2 数据宽度配置错
      • 10.3 传输长度理解错
      • 10.4 缓冲区被覆盖
      • 10.5 Cache 一致性问题
    • 11. 怎么判断一个场景该不该用 DMA
    • 12. 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档