STM32 DMA详解:让CPU解放双手
前面讲串口、定时器、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等) │
└──────────┘
三种搬运方向:
- 内存 → 外设(如SPI刷屏:显存→SPI->DR)
- 外设 → 内存(如ADC采集:ADC->DR→数组)
- 内存 → 内存(如数组拷贝,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数据错位/丢失
现象:收到的数据顺序乱了,或丢字节。
原因:
- 源/目标数据宽度配错(如ADC用Byte而不是Half Word)
- DMA优先级太低,被高速通道抢占
- 缓冲区大小不够,新数据覆盖了未处理的旧数据
解决:检查数据宽度配置;增大缓冲区;提高优先级。
坑2:DMA只工作一次就停
现象:第一次正常,之后不再传输。
原因:
- 模式设成了Normal而非Circular
- 传输完成后没有重新启动(Normal模式下需要手动重启)
解决:连续采集用Circular模式;单次传输在回调里重新HAL_ADC_Start_DMA。
坑3:DMA中断频繁,CPU反而更忙
现象:开了DMA中断,但CPU中断处理时间比不用DMA还长。
原因:中断触发太频繁。比如ADC每采一个点就中断一次,采样率1MHz就是1MHz中断,CPU扛不住。
解决:
- 不要每个数据都中断,用半传输+完成中断(采一批再处理)
- 降低采样率
- 中断回调里别做耗时操作(如printf)
坑4:内存→内存不能用
现象:STM32F1想用DMA1做内存到内存拷贝,配置后不工作。
原因:STM32F1的DMA1不支持内存到内存传输,只有DMA2支持(且仅大容量型号)。
解决:
- 换DMA2(如果芯片支持)
- 老实用
memcpy或CPU拷贝 - 换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入门第一课:从裸机到多任务的思维转变》如果这个系列对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。作者:嵌入式阿蔡
更多推荐
所有评论(0)