浮云落笔

浮云落笔

首页
瞬间
反馈
浮云落笔

浮云落笔

首页 瞬间 反馈
  1. 首页
  2. stm32
  3. STM32 DMA 数据搬运实验记录:让 CPU 卸下搬运工的活

STM32 DMA 数据搬运实验记录:让 CPU 卸下搬运工的活

  • stm32
  • 发布于 2026-10-04
  • 15 次阅读
flor
flor

平台: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

目录
湘ICP备2025147565号-1
gongan beian 湘公网安备43102602000213号