平台:STM32F103ZET6 @ 72MHz · 工程名 usart_dma · USART1(PA9=TX / PA10=RX)@ 115200 前置知识:USART 中断接收 + 环形缓冲区 + 协议帧(模块 2) 实验日期:2026-10-04 · 两个实验全部验收通过
一、实验背景:CPU 当搬运工的辛酸
在学习 DMA 之前,我的串口架构是这样的:每来一个字节,硬件敲门(RXNE 中断)→ CPU 放下手头的活 → 进中断 → 从 DR 寄存器搬一个字节进缓冲区 → 返回。115200 波特率连续收数据 = 每秒 11520 次中断,CPU 的日程表被"搬字节"切碎。
发送侧更惨:HAL_UART_Transmit 是阻塞式的——发 420 字节,CPU 在函数里干等约 37ms,一步不能离开。
DMA(Direct Memory Access,直接内存访问)= 芯片里一个独立的数据搬运处理器。它挂在 AHB 总线(主干道)上,能自己访问内存和外设。CPU 是经理,DMA 是搬运工:经理填一张搬运单(源地址、目的地址、数量),搬运工自己搬,搬完敲门汇报。期间经理想干嘛干嘛。
二、DMA 原理要点
搬运单六要素
配置一次 DMA 传输 = 填这张单子:
| 字段 | 含义 | 串口发送的例子 |
|---|---|---|
| 源地址 | 从哪搬 | 内存里那个字符串数组 |
| 目的地址 | 搬到哪 | USART1 的 DR 寄存器(固定不变) |
| 数量 | 搬多少个 | 字符串长度 |
| 数据宽度 | 每件多大 | Byte(半字/字用于 16/32 位数据) |
| 方向 | P2M / M2P / M2M | M2P(内存→外设) |
| 增量 | 每搬一件地址走不走 | 内存地址 +1(数组递增),外设地址不动(永远是 DR) |
请求(Request):搬运的节奏控制器
DMA 不是一口气把数据砸进外设,而是盯着外设的"空了"信号——串口每发完一字节,TXE 标志变空 → 硬件向 DMA 发一个请求 → DMA 搬一字节进 DR。传输速率被外设节流(115200 的节奏),DMA 只是随叫随到。
F1 的通道映射是固定死的
DMA1 有 7 个通道、DMA2 有 5 个,每个通道同一时刻只能服务一个外设,对应关系查参考手册的映射表,不能自由组合:USART1_TX 固定走 DMA1 通道 4、USART1_RX 固定走 DMA1 通道 5。(F4 以后的 Stream/Request 才可以任意组合。)
六问速答
挂哪条总线:AHB(第一个住 AHB 楼层的外设,这是它能"直接访问内存"的结构原因);门控开关:RCC_AHBENR 的 DMA1EN;两种模式:Normal(搬一批停)和 Circular(循环,流式场景)。
三、实验 4.1:阻塞 vs DMA 当面对决
实验设计(含一次重要的设计修正)
同一个 420 字节的字符串,两种发法,用 CPU 计数器当裁判。
最初的设计是:阻塞发送后打印计数(=0),DMA 发送期间 CPU 数数(=几十万)。但我自己发现了这个设计的漏洞:阻塞版的"计数=0"其实是"压根没人数",不是"想数但数不动",没有说服力。
关键洞察:你恰恰没法在阻塞期间加计数——CPU 被关在 HAL_UART_Transmit 函数里出不来,一行计数代码都执行不了,"加不进计数"本身就是"阻塞"的定义。
所以要严谨地展示"CPU 被占死",得请一个不受阻塞影响的观察者——中断。现成的是 SysTick(每 1ms 中断一次,主循环卡死它照跑),用它计时,主循环计数器量工作量,两个维度一起打印:
char msg[512];
int n = 0;
for (int i = 0; i < 10; i++) // 填 10 行 ≈ 420 字节
n += sprintf(&msg[n], "DMA搬运演示第%d行-0123456789ABCDEFG\r\n", i);
while (1)
{
// ① 阻塞发送:SysTick 计时(中断观察者,主循环卡死它照跑)
uint32_t t0 = HAL_GetTick();
cpu_count = 0; // 想数数?看看数不数得起来
HAL_UART_Transmit(&huart1, (uint8_t *)msg, n, 100);
// 此刻 cpu_count 必然还是 0——CPU 在函数里出不来
printf(">>> 阻塞发送 %d 字节:耗时 %lu ms,CPU 计数 = %lu(想数也数不了)\r\n\r\n",
n, (unsigned long)(HAL_GetTick() - t0), (unsigned long)cpu_count);
HAL_Delay(1000);
// ② DMA 发送:CPU 扔下地址就走,期间数数
uint32_t t1 = HAL_GetTick();
cpu_count = 0;
HAL_UART_Transmit_DMA(&huart1, (uint8_t *)msg, n); // 立即返回!
while (huart1.gState != HAL_UART_STATE_READY) // DMA 还在搬
cpu_count++; // CPU 闲着数圈
printf(">>> DMA 发送 %d 字节:耗时 %lu ms,CPU 计数 = %lu(全程自由)\r\n\r\n",
n, (unsigned long)(HAL_GetTick() - t1), (unsigned long)cpu_count);
HAL_Delay(1000);
}
CubeMX 配置
USART1 → DMA Settings → Add → USART1_TX:Channel=DMA1_Channel4(自动)、Direction=Memory to Peripheral、Mode=Normal、Memory Increment ✅、Peripheral Increment ❌、Data Width=Byte。NVIC 勾 DMA1 channel 4 和 USART1 两个中断。
实测数据与结论
>>> 阻塞发送 420 字节:耗时 38 ms,CPU 计数 = 0(想数也数不了)
>>> DMA 发送 420 字节:耗时 38 ms,CPU 计数 = 101437(全程自由)
两行对比的完整解读:耗时几乎一样(都被 115200 波特率定死——DMA 买不回速度);差别全在 CPU 计数——DMA 买回的是 CPU 那 38ms 的自由时间。一个维度证"速度没变",一个维度证"占用天差地别"。
方法论沉淀
要证明"某段代码被卡死",不能用它自己计数(它数不了),要用不受它影响的观察者(中断/定时器)。以后调试"主循环是不是被某行代码拖死了",同一招:SysTick 打点前后对比。
四、实验 4.2:DMA 接收 + IDLE 空闲断帧
问题:DMA 只管埋头搬,怎么知道"一帧结束了"?
DMA 接收时字节悄无声息地进缓冲区,CPU 睡着了——谁来叫醒它?答案在 UART 硬件里:IDLE 空闲检测。
IDLE:硬件自带的"断句器"
串口有个天然规律:一帧数据一口气发完(字节间隔 < 1 字符时间),帧与帧之间有静默。UART 硬件的 IDLE 标志:收到数据后总线静默满一个字符时间(115200 下约 87µs),IDLE 置位——这是硬件替你数的"句号"。
AA 55 02 01 01 02 ──────静默 87µs────── 下一帧...
└───────────────┘ └── IDLE 中断! ──┘
DMA 逐字节默默搬
↑ CPU 只在这里醒一次
整个接收过程:6 个字节 = 0 次中断;帧结束 = 1 次 IDLE 中断——中断数量从"字节数"降到"帧数",这就是 DMA 高速接收的全部秘密。
不定长的魔法
IDLE 中断里 CPU 醒来后的第一问:这帧多长?DMA 的计数器就是答案:
本次到达字节数 Size = 缓冲区大小 − DMA 剩余计数(CNDTR)
发 6 字节算 6,发 60 字节算 60——不用事先约定长度,来多少算多少,这就是"不定长接收"。HAL 已把这整套打包成 HAL_UARTEx_ReceiveToIdle_DMA,事件回调直接给算好的 Size。
架构:一帧数据的完整旅程
PC 串口助手(HEX)
│ AA 55 02 01 01 02
▼
PA10(RX) ──► USART1 DR ──► [DMA1_CH5 硬件搬运] ──► rxDmaBuf[0..5]
│ 每字节:RXNE→硬件DMA请求→搬一字节→CNDTR减一(0中断)
├────────── 87µs ──────────┐
▼ ▼
静默满 → IDLE 置位 ──► IDLE 中断(每帧仅 1 次)
│ Size = 64 − CNDTR = 6
▼
HAL_UARTEx_RxEventCallback(huart, Size)
│
▼
frame_parse(rxDmaBuf, Size) ← 协议层,与模块2.3一字未改
│ 五状态机推进
▼
frame_exec:LED 点亮 + ACK 经 DMA 回发
│
▼
重新武装 ReceiveToIdle_DMA ──► 待命等下一帧
核心代码
// 启动(USER CODE 2):
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rxDmaBuf, RXBUF_SIZE);
// IDLE 事件回调(USER CODE 4):
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1 &&
huart->RxEventType == HAL_UART_RXEVENT_IDLE) {
frame_parse(rxDmaBuf, Size); // Size=本次字节数(不定长)
HAL_UARTEx_ReceiveToIdle_DMA(huart, rxDmaBuf, RXBUF_SIZE); // 重新武装
}
}
(frame_parse/frame_exec 是模块 2.3 的五状态机解析器原封不动搬过来,仅 ACK 回发从阻塞改成 HAL_UART_Transmit_DMA。)
验收:五连测试(串口助手 HEX 模式)
| 测试 | 发送 | 预期 | 结果 |
|---|---|---|---|
| 开灯 | AA 55 02 01 01 02 |
LED 亮 + 回传同帧 | ✅ |
| 关灯 | AA 55 02 01 00 03 |
LED 灭 + 回传同帧 | ✅ |
| 坏 CRC | AA 55 02 01 01 99 |
无动作无回传 | ✅ |
| 垃圾+好帧 | 12 34 56 AA 55 02 01 01 02 |
重新同步,灯亮+回帧 | ✅ |
| 双 AA | AA AA 55 02 01 01 02 |
第二个 AA 识别为真帧头 | ✅ |
最有价值的发现:协议层一行没改,五连全过——搬运层从"每字节中断"换装成"DMA 批量",解析器毫无感觉。分层解耦的活证据:换一层,其他层无感。
与模块 2 的账本对比(每帧 6 字节)
| 2.2/2.3(RXNE 每字节中断) | 4.2(DMA+IDLE) | |
|---|---|---|
| RX 中断次数 | 6 | 1 |
| CPU 搬字节数 | 6 | 0 |
| TX 回 ACK | 阻塞(CPU 等) | DMA(CPU 走人) |
| 满速连续流 | 11520 中断/秒 | 11520÷帧长 次/秒 |
五、踩坑实录:MX_DMA_Init 缺失(本次最值钱的教训)
现象
烧录后串口只打了一轮"阻塞发送…计数=0"+10 行数据,然后永久冻结。用调试器读 CPU 计数变量:单调增长到 840 万、从不归零——程序卡死在 DMA 等待循环里。
诊断链(gdb 符号级调试,全程远程)
| 步骤 | 读数 | 推理 |
|---|---|---|
p huart1.gState |
0x21 = BUSY_TX | 卡在 DMA 发送等待 ✓ |
p huart1.TxXferCount |
420 = 全量 | 一个字节都没发出去 |
| DMA1 所有寄存器 | 全 0 | DMA 没通电——配置写进没通电的寄存器=石沉大海 |
| NVIC ISER0 | 0(CH4 中断没使能) | dma.c 里的使能代码从没执行过 |
| NVIC ISER1 | bit5=1(USART1 使能了) | usart.c 的 MspInit 跑了 |
| 查 main.c 初始化序列 | 只有 GPIO→USART1 | 根本没有 MX_DMA_Init() 调用! |
根因
CubeMX 给已有工程加 DMA 后重新生成,偶尔不往 main.c 插 MX_DMA_Init() 调用——dma.c 里时钟使能、中断使能都生成得好好的,但没人调用它们,DMA 房间没通电。症状极具迷惑性:编译零错误、HAL_UART_Transmit_DMA 返回 OK、然后永远等不到完成。
修复
main.c 补两处(MX_DMA_Init 必须在 USART 之前——MspInit 要写 DMA 寄存器):
#include "dma.h"
...
MX_GPIO_Init();
MX_DMA_Init(); // DMA 时钟+中断使能
MX_USART1_UART_Init();
修复后复测:gState = 0x20(READY,正常循环)、cpu_count = 101437(DMA 期间 CPU 自由计数)——复活。
防复发
以后每次 CubeMX 重新生成,git diff 里专门盯一眼 MX_DMA_Init() 还在不在——被吃掉就补回来。
次级知识点
HAL 的 DMA 发送完成走两步:DMA 中断(数据搬完进 DR)→ USART TC 中断(最后一位移出移位寄存器)→ gState=READY。所以 USART1 的 NVIC 也必须勾,只勾 DMA 通道中断不够。
六、调试手法沉淀(本模块新增)
- gdb 符号级读句柄:
p/x huart1.gState、p/x huart1.TxXferCount——比猜寄存器位准得多 - NVIC ISER 读中断使能实况:ISER0/ISER1 对照 IRQ 号(DMA1_CH4=14、USART1=37)
- "没通电"的识别:外设寄存器全零 + 配置写入无效 = 时钟没开
- 证明卡死用独立观察者:SysTick/定时器打点,别让被卡死的代码自己数
七、一句话知识点清单
- DMA=独立搬运工:CPU 填单(源/目的/数量/宽度/方向/增量),DMA 搬完敲门
- DMA1/DMA2 挂 AHB,门控 RCC_AHBENR——第一个 AHB 楼层外设
- F1 通道映射固定:USART1_TX=CH4、RX=CH5,查表不可选
- 请求节流:速率被外设定死——DMA 买的是 CPU 自由时间,不是速度
- IDLE=硬件断句器:静默 1 字符时间置位,帧边界的物理本质
- 不定长 Size = 缓冲区大小 − CNDTR
HAL_UARTEx_ReceiveToIdle_DMA+RxEventCallback;Normal 模式 IDLE 后必须 re-arm- DMA TX 完成两步走:DMA 中断 + USART TC 中断——两个 NVIC 都要勾
MX_DMA_Init必须在用 DMA 的外设之前调用- CubeMX 偶发不插 MX_DMA_Init——每次生成 git diff 盯梢
- 分层解耦:搬运层升级,协议层免疫(2.3 解析器零改动上 DMA)
- 证明卡死要用不受影响的观察者(SysTick),不能让卡死者自己数
模块 4 实验记录 · 2026-10-04 · STM32F103ZET6 最小系统板 · 工程名 usart_dma