前面讲串口、定时器、ADC时都提到过DMA,这篇把它单独讲透。
DMA是STM32里"最容易被新手忽略,但用了就回不去"的功能。
理解DMA后,你的程序效率能提升好几倍,CPU终于能干点正事了。


一、为什么需要DMA?

1.1 没有DMA的痛苦

假设你要用SPI刷一块480×480的屏幕,每帧460KB数据。不用DMA的话:

/* 笨办法:CPU一个字节一个字节搬 */
for (uint32_t i = 0; i < 460800; i++) {
    SPI1->DR = frame_buf[i];           /* CPU写一个字节到SPI */
    while (!(SPI1->SR & (1 << 1)));    /* CPU等发送完成 */
}

这460800次循环,CPU全程占用,干不了别的。刷一帧要几十毫秒,期间任务全卡住。

1.2 有DMA的爽快

/* DMA办法:配置一次,自动搬 */
HAL_SPI_Transmit_DMA(&hspi1, frame_buf, 460800);
/* CPU干别的事去了,DMA在后台默默搬运 */

DMA(Direct Memory Access,直接内存访问)就是不经过CPU,直接在内存和外设之间搬数据的硬件模块。

1.3 对比

方式 CPU占用 速度 适用场景
CPU轮询搬运 100% 慢(每字节都要CPU干预) 少量数据
中断+CPU 中断时占用 不定长数据
DMA 接近0% 快(硬件并行) 大量数据

一句话:DMA是给CPU请了个"搬运工",脏活累活全丢给它,CPU专心干脑力活。


二、DMA工作原理

2.1 DMA能搬什么?

DMA搬运发生在三个地方之间:

   ┌──────────┐
   │  内存    │ ←──→ ┐
   │ (SRAM)   │      │
   └──────────┘      │
                     ↓
               ┌──────────┐
               │   DMA    │  ← 硬件搬运工
               └──────────┘
                     ↓
   ┌──────────┐      │
   │  外设    │ ←──→ ┘
   │(SPI/ADC/ │
   │ UART等)  │
   └──────────┘

三种搬运方向

  1. 内存 → 外设(如SPI刷屏:显存→SPI->DR)
  2. 外设 → 内存(如ADC采集:ADC->DR→数组)
  3. 内存 → 内存(如数组拷贝,STM32F1的DMA1不支持,DMA2才支持)

2.2 DMA怎么知道搬多少?

配置DMA时告诉它三个关键信息:

  • 源地址:数据从哪来(如显存地址、&SPI1->DR)
  • 目标地址:数据到哪去
  • 数据长度:搬多少个

DMA每搬一个数据,数据长度自动减1,减到0就触发"传输完成"中断。

2.3 STM32的DMA通道

STM32F1有2个DMA控制器,每个有7个通道:

DMA1(7个通道):
  CH1 → ADC1
  CH2 → SPI1_RX / USART3_TX
  CH3 → SPI1_TX / USART3_RX
  CH4 → SPI2_RX / USART1_TX
  CH5 → SPI2_TX / USART1_RX
  CH6 → USART2_RX
  CH7 → USART2_TX

DMA2(5个通道,仅大容量型号有):
  CH1-CH5 → 各种外设

重要:每个通道只能服务固定的外设!比如USART1_TX必须用DMA1_CH4,不能随便选。这是硬件决定的,查参考手册的"DMA请求映射表"。

STM32F4/G4/H7用户注意:F4及以上系列不用"通道"而是用"DMA Stream(数据流)+ 请求选择器"。区别是:F1的通道是固定映射的(USART1_TX只能用CH4),F4的数据流可以通过请求选择器灵活选择连接任意外设,不再受固定映射限制。但本文的代码和配置方法F1/F4通用,CubeMX会帮你处理差异。


三、DMA的三个核心配置

3.1 传输模式

模式 说明 适用
Normal(正常) 搬完指定长度后停止 一次性传输,如发一条串口消息
Circular(循环) 搬完后自动从头开始 连续采集,如ADC持续采样

坑点:ADC持续采集必须用Circular,否则采一次就停。很多人踩过——上一篇ADC文章里特别提过。

3.2 数据宽度

源和目标的数据宽度可以不同,DMA会自动对齐:

源宽度 目标宽度 行为
半字(16bit) 半字(16bit) 正常搬
字(32bit) 半字(16bit) 截断低16位
半字(16bit) 字(32bit) 高16位补0

常用配置

  • ADC(12位数据存16位寄存器)→ 源和目标都用Half Word(16bit)
  • UART(8位数据)→ 用Byte(8bit)
  • SPI刷屏(16位RGB565)→ 用Half Word(16bit)

3.3 优先级

多个DMA通道同时请求时,按优先级决定谁先搬:

优先级 说明
Very High 最高
High
Medium
Low 最低

实际项目中,高速外设(如SPI刷屏)设High,慢速外设(如UART)设Low。


四、DMA三大经典应用

4.1 ADC + DMA(连续多通道采集)

上一篇ADC文章详细讲过,这里精简回顾:

#define ADC_CH_NUM 3
uint16_t adc_dma_buf[ADC_CH_NUM];

/* CubeMX配置ADC1的DMA,Circular模式 */
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_CH_NUM);

/* 数组自动更新,随时可读 */
while (1) {
    printf("CH0:%d CH1:%d CH2:%d\r\n",
           adc_dma_buf[0], adc_dma_buf[1], adc_dma_buf[2]);
    HAL_Delay(500);
}

CPU零参与,DMA后台自动把3个通道的ADC结果搬到数组。

4.2 UART + DMA(高效收发不定长数据)

这是ESP8266 WiFi模块通信的核心技术。

发送(内存→UART)

uint8_t tx_buf[] = "AT+RST\r\n";

/* DMA方式发送,发完触发中断 */
HAL_UART_Transmit_DMA(&huart1, tx_buf, sizeof(tx_buf) - 1);

/* 发送完成回调 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    printf("发送完成\r\n");
}

接收(UART→内存)+ 空闲中断

接收不定长数据是难点。UART不知道对方发多少字节,用空闲中断(IDLE)检测一帧结束:

#define RX_BUF_SIZE 256
uint8_t rx_buf[RX_BUF_SIZE];

/* 启动DMA接收 */
HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);  /* 开启空闲中断 */

/* 在USART1_IRQHandler里处理空闲中断 */
void USART1_IRQHandler(void)
{
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);   /* 清标志 */

        /* 停止DMA,计算收到的长度 */
        HAL_UART_DMAStop(&huart1);
        uint16_t rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);

        /* 处理rx_buf前rx_len个字节 */
        process_at_response(rx_buf, rx_len);

        /* 重新启动接收 */
        HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    }
    HAL_UART_IRQHandler(&huart1);
}

原理:对方发完一帧后,UART总线空闲,触发IDLE中断。此时用"缓冲区大小 - DMA剩余计数"算出实际收了多少字节。这是接收AT指令响应的标准做法。

4.3 SPI + DMA(高速刷屏)

LVGL项目里60fps刷屏的核心:

LVGL(Light and Versatile Graphics Library)是一个开源的嵌入式GUI库,常用于在MCU上实现触摸屏界面。如果你还没用到LVGL,可以暂时跳过这段,重点理解SPI+DMA部分。

/* 显存:480×480×2字节 = 460800字节 */
uint16_t frame_buf[480 * 480];

void lcd_flush(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t *color)
{
    /* 设置LCD显示窗口 */
    lcd_set_window(x1, y1, x2, y2);

    /* DMA方式发送显存数据到SPI */
    HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)color, (x2-x1+1)*(y2-y1+1)*2);
}

/* DMA发送完成回调 */
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
    /* 通知LVGL可以画下一帧了 */
    lv_disp_flush_ready(&disp_drv);
}

为什么快:SPI外设和DMA都是硬件,数据从内存→SPI->DR→MOSI引脚全程不需要CPU。CPU在DMA搬运期间去算下一帧内容,实现并行。


五、双缓冲(Double Buffer)

连续采集场景下,DMA填满缓冲区A时,CPU还在读A会冲突。双缓冲解决这个:

DMA填A时 → CPU处理B
DMA填B时 → CPU处理A
交替进行,互不干扰

STM32F4+的双缓冲

**注意:以下代码仅适用于STM32F4/G4/H7系列。F1系列不支持硬件双缓冲,但可以用"半传输中断+完成中断"模拟(上一篇ADC文章里讲过这个方法)。F1用户可以跳过本节。

STM32F4/G4/H7的DMA支持硬件双缓冲(M0AR和M1AR两个目标地址):

M0AR = Memory 0 Address Register(内存0地址寄存器),M1AR = Memory 1 Address Register(内存1地址寄存器)。DMA可以在两个缓冲区之间自动切换。

uint16_t buf_a[1024], buf_b[1024];

/* CubeMX里DMA Stream配置双缓冲 */
/* 或代码里 */
HAL_DMAEx_MultiBufferStart_IT(&hdma_adc1,
    (uint32_t)&ADC1->DR,
    (uint32_t)buf_a,
    (uint32_t)buf_b,
    1024);

/* 半传输完成:A填了一半 */
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
    /* 可以安全处理buf_a */
}

/* 全传输完成:A填满,DMA切到buf_b */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    /* 处理buf_a,DMA正在填buf_b */
}

STM32F1不支持硬件双缓冲,但可以用"半传输中断+完成中断"模拟——上一篇ADC文章里讲过。


六、DMA + 中断回调完整流程

理解这个流程,DMA就彻底通了:

1. CPU配置DMA(源地址、目标地址、长度、模式)
        ↓
2. CPU启动DMA,去干别的事
        ↓
3. 外设产生请求(如ADC转换完成、UART收到字节)
        ↓
4. DMA自动搬运一个数据,长度计数-1
        ↓
5. 重复3-4,直到长度计数=0
        ↓
6. DMA触发"传输完成"中断
        ↓
7. CPU在中断回调里处理数据
        ↓
8. Circular模式下,DMA自动重新开始

关键点:步骤3-5全程不需要CPU,只有步骤7需要CPU处理结果。这就是"CPU解放双手"。


七、常见踩坑

坑1:DMA数据错位/丢失

现象:收到的数据顺序乱了,或丢字节。

原因

  1. 源/目标数据宽度配错(如ADC用Byte而不是Half Word)
  2. DMA优先级太低,被高速通道抢占
  3. 缓冲区大小不够,新数据覆盖了未处理的旧数据

解决:检查数据宽度配置;增大缓冲区;提高优先级。


坑2:DMA只工作一次就停

现象:第一次正常,之后不再传输。

原因

  1. 模式设成了Normal而非Circular
  2. 传输完成后没有重新启动(Normal模式下需要手动重启)

解决:连续采集用Circular模式;单次传输在回调里重新HAL_ADC_Start_DMA


坑3:DMA中断频繁,CPU反而更忙

现象:开了DMA中断,但CPU中断处理时间比不用DMA还长。

原因:中断触发太频繁。比如ADC每采一个点就中断一次,采样率1MHz就是1MHz中断,CPU扛不住。

解决

  1. 不要每个数据都中断,用半传输+完成中断(采一批再处理)
  2. 降低采样率
  3. 中断回调里别做耗时操作(如printf)

坑4:内存→内存不能用

现象:STM32F1想用DMA1做内存到内存拷贝,配置后不工作。

原因STM32F1的DMA1不支持内存到内存传输,只有DMA2支持(且仅大容量型号)。

解决

  1. 换DMA2(如果芯片支持)
  2. 老实用memcpy或CPU拷贝
  3. 换STM32F4/H7,所有DMA都支持内存到内存

坑5:DMA缓冲区必须对齐

现象:偶发数据错误,查很久找不到原因。

原因:DMA访问内存要求地址对齐。地址对齐是指数据在内存中的起始地址必须是数据宽度的整数倍——16位(2字节)数据必须存在偶数地址上,32位(4字节)数据必须存在4的倍数地址上。DMA硬件有这个要求,如果地址不对齐,DMA可能读到错位的数据或直接报错。

解决:用__ALIGN_BEGIN或确保数组定义时自然对齐:

/* 错误:可能未对齐 */
uint8_t buf[100];
uint16_t *p = (uint16_t *)buf;   /* buf地址可能是奇数 */

/* 正确:直接定义16位数组,必然2字节对齐 */
uint16_t buf[50];

八、性能对比实测

以SPI发送460800字节(一帧屏幕数据)为例:

方式 耗时 CPU占用 帧率上限
CPU轮询 ~30ms 100% 33fps
CPU中断 ~25ms 80% 40fps
DMA ~8ms <5% 120fps

这就是为什么LVGL项目里用SPI+DMA能稳定60fps——DMA把刷屏时间从30ms压到8ms,CPU有充足时间算下一帧。


九、DMA使用决策树

什么时候该用DMA?看这个决策:

数据量 < 100字节?
  ├─ 是 → CPU轮询就够了,别折腾DMA
  └─ 否
      │
      是否连续/周期性传输?
      ├─ 是 → 用DMA + Circular模式(ADC采集、音频流)
      └─ 否
          │
          数据量大且不频繁?
          ├─ 是 → 用DMA + Normal模式(刷屏、批量发送)
          └─ 否 → 中断方式即可

经验:UART收发AT指令、ADC多通道采集、SPI刷屏,这三大场景必用DMA。其他看数据量决定。


十、总结

要点 内容
DMA本质 硬件搬运工,不经过CPU直接搬数据
三种方向 内存↔外设、内存↔内存(F1不支持)
传输模式 Normal(单次)/ Circular(循环)
三大应用 ADC采集 / UART收发 / SPI刷屏
通道映射 F1固定映射,查手册;F4+可配置
双缓冲 DMA填A时CPU处理B,流水线并行

一句话总结:DMA的核心思想就是让硬件搬数据,CPU干脑力活。会用DMA,是从"新手"到"工程师"的分水岭。


STM32入门系列到这篇告一段落。下一篇开始新的系列——RTOS多任务开发。
下一篇预告:《FreeRTOS入门第一课:从裸机到多任务的思维转变》

如果这个系列对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。

作者:嵌入式阿蔡

Logo

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

更多推荐