F429-HAL-DMA(2026/7/24)
目录
一、F103 vs F429 DMA 资源对比
二、最大的四个差异
1. 命名体系完全不同
2. 并发能力
3. FIFO
4. 传输宽度配置
三、对你学 DMA 的影响
四、Stream/Channel 选择规则
1:M2M:随意选
2:外设 DMA:必须查表
五、为什么 UART 一个句柄,DMA 要两个?
六、F103 vs F429 并发能力
七、实战踩坑:DMA 和 IT 抢 RxState
问题根源
错误写法(昨晚最初烧录的版本)
修复(加一行 AbortReceive)
为什么不直接删掉 HAL_UART_Receive_IT?
八、__HAL_LINKDMA:告诉串口谁帮你搬东西
1:huart1 内部的结构
2:不挂上去会怎样?
九、DMA 初始化字段逐个解释(第①步)
1. Instance — 选哪台运输车
2. Channel — 选哪条运输线
3. Direction — 搬运方向
4. PeriphInc — 外设地址要不要递增
5. MemInc — 内存地址要不要递增
6. PeriphDataAlignment — 外设端的搬运单位
7. MemDataAlignment — 内存端的搬运单位
8. Mode — 普通还是循环
9. Priority — 多台车抢总线时谁先走
10. FIFOMode — 要不要用 FIFO 缓冲
11.TX vs RX 初始化差异就两处
十、TX DMA 数据流(第②步)
十一、RX DMA 数据流(第③步)
第一段:DMA 默默搬数据(CPU 完全不知情)
第二段:总线空闲 → USART1_IRQHandler
第三段:main 感知并处理
涉及的中断和回调
NDTR 计算原理
对比 IT 接收 vs DMA 接收
"为什么句柄填的全是 uart"
十二、main.c demo:DMA 模式 vs IT 模式(第④步)
DMA 模式 main.c
IT 模式 main.c
切换四步
十三、IT vs DMA 场景选择(第⑤步)
判断标准
具体场景
一句话
一、F103 vs F429 DMA 资源对比
| STM32F103 | STM32F429 | |
|---|---|---|
| 控制器数量 | 2 个(DMA1、DMA2) | 2 个(DMA1、DMA2) |
| 组织方式 | Channel(通道) | Stream + Channel(两级) |
| DMA1 | 7 个 Channel | 8 个 Stream,每 Stream 可选 8 个 Channel |
| DMA2 | 5 个 Channel(仅大容量型号) | 8 个 Stream,每 Stream 可选 8 个 Channel |
| 总并发数 | 12 个(7+5) | 16 个 |
| 端口架构 | 单端口(同一时间只能一个 Channel 干活) | 双端口(多个 Stream 可以并发) |
| FIFO | ❌ 没有 | ✅ 有,可配置阈值(1/4、1/2、3/4、全满) |
| DMAMUX | ❌ 硬绑定 | ❌ 也是硬绑定,但多了 Stream 层级可选 |
| Memory-to-Memory | 仅 DMA2 | DMA1 和 DMA2 都支持 |
二、最大的四个差异
1. 命名体系完全不同
F103: DMA1_Channel1, DMA1_Channel2, ..., DMA2_Channel5 F429: DMA1_Stream0, DMA1_Stream1, ..., DMA2_Stream7 └── 每个 Stream 再选一个 Channel(0~7)
F103 的 Channel = 运输车 + 运输线合二为一。F429 是 Stream(车)+ Channel(选哪条线)分开。
2. 并发能力
F103:同一时刻 DMA1 只有一个 Channel 在传数据(单端口) DMA1_Channel1 在传 → Channel 2~7 都得排队 F429:多个 Stream 可以同时干活(双端口,带仲裁) DMA1_Stream0 传 USART1_TX DMA1_Stream3 传 SPI1_RX → 同时跑,互不干扰
3. FIFO
F103 没 FIFO,数据一来必须立刻搬到目的地。F429 有 FIFO 缓冲——比如串口波特率很高时,DMA 可以先把数据暂存 FIFO,然后一次性搬走,减少总线占用。
4. 传输宽度配置
F103:源宽度和目标宽度可以不相等,但没有突发模式 → 只能说"每次读 1 字节,写 2 字节" F429:支持 Burst 传输(一次突发传 4/8/16 拍) → 比如一次 Burst 搬 16 个字节,效率高很多
三、对你学 DMA 的影响
F103 的经验概念都能复用(DMA 就是搬运工,外设→内存、内存→外设、内存→内存),但 F429 的编程模型变了:
| 操作 | F103 | F429 |
|---|---|---|
| 选外设 | 直接选 Channel 号 | 先选 Stream,再往 Stream 里选 Channel |
| 启停 | DMA_Cmd(Channel, ENABLE) | HAL_DMA_Start_IT(&hdma, ...) |
| 中断标志 | DMA_GetITStatus(Channel) | __HAL_DMA_GET_FLAG(&hdma, DMA_FLAG_TCIFx) |
HAL 帮你包了一层,但底层概念你得知道——选错 Stream/Channel 组合是最常见的坑。
四、Stream/Channel 选择规则
1:M2M:随意选
// 内存到内存不绑外设,Stream 和 Channel 随便配 DMA_Handle.Instance = DMA1_Stream3; // 或用 DMA2_Stream0,都行 DMA_Handle.Init.Channel = DMA_CHANNEL_5; // 或 0~7 随便,没影响 DMA_Handle.Init.Direction = DMA_MEMORY_TO_MEMORY;
2:外设 DMA:必须查表
一旦Direction选了MEMORY_TO_PERIPH或PERIPH_TO_MEMORY,Stream + Channel 组合就是写死在芯片里的:
每个外设的 DMA 请求线连到了固定的 Stream 和 Channel → 软件改不了(F4 没 DMAMUX) → 选错了 DMA 就找不到这个外设,传不了
五、为什么 UART 一个句柄,DMA 要两个?
UART = 一个外设,收发都在同一个硬件模块里 → huart1 一个句柄就够,里面 Mode = TX_RX 同时管两路 DMA = 两台独立的运输车,各干各的 → hdma_usart1_tx = 车7号,专门往 USART 搬(发) → hdma_usart1_rx = 车5号,专门从 USART 搬(收) → 两辆车同时跑,互不影响
这就是为什么 DMA 每个 Stream 只能干一件事——想同时收发,就得占两个 Stream,两个句柄。
六、F103 vs F429 并发能力
F103(单端口): DMA1_Channel4 (USART1_TX) 在传 → Channel 5(USART1_RX) 只能排队 不能同时收发 F429(双端口): DMA2_Stream7 (USART1_TX) ─┐ ├─ 同时跑,互不干扰 DMA2_Stream5 (USART1_RX) ─┘
F429 比 F103 强的不只是主频,DMA 的并发能力也是质变。后面学 FreeRTOS 时优势更明显——多个任务各自用各自的 DMA Stream,谁都不用等谁。
七、实战踩坑:DMA 和 IT 抢 RxState
问题根源
Usart1_Init结尾默认调了HAL_UART_Receive_IT:
// Usart.c 第 66 行 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // → huart1.RxState = BUSY_RX
之后在 main.c 调HAL_UART_Receive_DMA时,HAL 内部检查状态不是READY,直接返回HAL_BUSY,DMA 静默失败,编译零报错。
错误写法(昨晚最初烧录的版本)
int main(void) { Usart1_Init(115200); // 内部调了 HAL_UART_Receive_IT // → RxState = BUSY_RX Usart1_DMA_Init(); HAL_UART_Receive_DMA(&huart1, dma_rx_buf, 128); // ↑ 静默失败!HAL 检查 RxState != READY → 直接 return HAL_BUSY // DMA 根本没启动,串口助手收不到任何数据 }修复(加一行 AbortReceive)
Usart1_Init(115200); Usart1_DMA_Init(); HAL_UART_AbortReceive(&huart1); // ← 停掉 IT,RxState 恢复 READY HAL_UART_Receive_DMA(&huart1, dma_rx_buf, 128); // ← 现在成功了
为什么不直接删掉 HAL_UART_Receive_IT?
保留它是为了随时切换 IT/DMA 模式:
Usart1_Init是公共入口,IT 接收默认备着用 DMA 时多写一行
AbortReceive停掉即可切回 IT 时删掉
AbortReceive那行就行,不用改Usart1_Init
八、__HAL_LINKDMA:告诉串口谁帮你搬东西
__HAL_LINKDMA不是函数,是个宏。内部就是简单的赋值:
__HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx); // 等价于: huart1.hdmatx = &hdma_usart1_tx; // 意思:串口1,你的 TX,由 DMA2_Stream7 这辆车负责 __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); // 等价于: huart1.hdmarx = &hdma_usart1_rx; // 意思:串口1,你的 RX,由 DMA2_Stream5 这辆车负责
1:huart1 内部的结构
初始化时只填了 Instance 和 Init 部分,其余字段是 HAL 内部在用:
huart1 ├── .Instance = USART1 ← 你填:哪个串口硬件 ├── .Init.BaudRate = 115200 ← 你填:波特率 ├── .Init.Mode = TX_RX ← 你填:收发模式 ├── .hdmatx = &hdma_usart1_tx ← LINK 挂上去的,HAL 内部找 DMA ├── .hdmarx = &hdma_usart1_rx ← 同上 ├── .RxState = READY/BUSY ← HAL 自管:接收状态(DMA 抢 RxState 的坑) ├── .TxState = READY/BUSY ← HAL 自管:发送状态 ├── .Lock = UNLOCK/LOCK ← HAL 自管:防重入锁 └── .ErrorCode = 错误码 ← HAL 自管
句柄 = 大管家结构体,跟这个外设有关的所有信息全打包在一起。
2:不挂上去会怎样?
之后HAL_UART_Transmit_DMA内部会从huart->hdmatx找 DMA 句柄来启动传输。如果没挂 → 空指针 → 跑飞。
九、DMA 初始化字段逐个解释(第①步)
hdma_usart1_tx.Instance = DMA2_Stream7; hdma_usart1_tx.Init.Channel = DMA_CHANNEL_4; hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode = DMA_NORMAL; hdma_usart1_tx.Init.Priority = DMA_PRIORITY_LOW; hdma_usart1_tx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
1. Instance — 选哪台运输车
.Instance = DMA2_Stream7;
F429 有 16 台车(DMA1 8 台 + DMA2 8 台),这里选了 DMA2 的第 7 号车。必须跟芯片手册的映射表一致——USART1_TX 固定绑在 DMA2_Stream7。
跟串口类比:huart1.Instance = USART1选哪个串口。
2. Channel — 选哪条运输线
.Init.Channel = DMA_CHANNEL_4;
一辆车可以跑 8 条线(Channel 0~7),每条线连着一个外设。Channel 4 连的是 USART1_TX。
DMA2_Stream7 这台车 ├─ Channel 0 → 某外设 ├─ Channel 1 → 某外设 ├─ Channel 4 → USART1_TX ← 我们选这条 └─ ...
3. Direction — 搬运方向
.Direction = DMA_MEMORY_TO_PERIPH; // 内存 → 外设(发送)
| 值 | 方向 | 场景 |
|---|---|---|
DMA_MEMORY_TO_PERIPH | 内存 → 外设 | TX:buf 里的数据搬到串口 DR 寄存器 |
DMA_PERIPH_TO_MEMORY | 外设 → 内存 | RX:串口 DR 的数据搬到 dma_rx_buf |
DMA_MEMORY_TO_MEMORY | 内存 → 内存 | 野火那个 M2M 例程 |
4. PeriphInc — 外设地址要不要递增
.PeriphInc = DMA_PINC_DISABLE; // 不递增
TX 时"外设"就是 USART1 的数据寄存器 DR。这个寄存器的地址是固定的(0x40011004),你不会往 DR+1、DR+2 发数据。所以 DISABLE——每次搬都往同一个地址写。
对比 M2M 模式:源地址在内存里,需要递增才能把整个数组搬完 → ENABLE。
5. MemInc — 内存地址要不要递增
.MemInc = DMA_MINC_ENABLE; // 递增
"内存"就是你的发送缓冲区。你要把buf[0], buf[1], buf[2]...依次发出去,所以每搬一个字节,地址要 +1。
第 1 次: 搬 buf[0] → USART_DR 第 2 次: 搬 buf[1] → USART_DR ← 地址自动 +1 第 3 次: 搬 buf[2] → USART_DR ← 地址自动 +1 ...
6. PeriphDataAlignment — 外设端的搬运单位
.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 1 字节
串口一次只能收发 1 字节(8 位),所以设 BYTE。如果是 ADC(12 位结果存在 16 位寄存器里),就要用 HALFWORD。
7. MemDataAlignment — 内存端的搬运单位
.MemDataAlignment = DMA_MDATAALIGN_BYTE; // 1 字节
两边对齐——外设 1 字节,内存也 1 字节。内存到内存且是 uint32_t 数组时(野火那个),两端都设 WORD。
8. Mode — 普通还是循环
.Mode = DMA_NORMAL; // 普通模式
| 模式 | 行为 |
|---|---|
DMA_NORMAL | 搬完指定数量就停。比如你叫它搬 15 字节,搬完它就下班了 |
DMA_CIRCULAR | 搬完自动从头再来。适合 ADC 连续采样这种场景,永远不停 |
9. Priority — 多台车抢总线时谁先走
.Priority = DMA_PRIORITY_LOW; // TX 优先级低
当多个 DMA Stream 同时要干活时,优先级高的先拿到总线。发送慢点没事,接收偏不能丢——所以 TX 用 LOW,RX 用 HIGH。
10. FIFOMode — 要不要用 FIFO 缓冲
.FIFOMode = DMA_FIFOMODE_DISABLE; // 不用
F429 有 FIFO(F103 没有),但串口逐字节传输用不到。关了就行,数据直接从源到目的地。
11.TX vs RX 初始化差异就两处
| 字段 | TX | RX | 原因 |
|---|---|---|---|
Direction | MEMORY_TO_PERIPH | PERIPH_TO_MEMORY | 方向反 |
Priority | LOW | HIGH | 收不能丢 |
十、TX DMA 数据流(第②步)
你调Usart_SendString_DMA((uint8_t*)"Hello", 5),整条路径:
main 调用 Usart_SendString_DMA(str, 5) │ ▼ HAL_UART_Transmit_DMA(&huart1, "Hello", 5) │ ├─ ① 查 huart1.TxState 是否 READY → OK,置 BUSY_TX ├─ ② 从 huart1.hdmatx 取出 DMA 句柄 → hdma_usart1_tx ├─ ③ 配 DMA:源="Hello" 目标=&USART1→DR 次数=5 ├─ ④ 使能 DMA2_Stream7,挂上 USART1_TX 请求线 └─ ⑤ 立刻返回 ← main 不用等!
DMA 自己在后台搬:
第1次: str[0]='H' → DMA → USART_DR → PA9 发出,计数器-1 第2次: str[1]='e' → DMA → USART_DR → PA9 发出,计数器-1 第3次: str[2]='l' → DMA → USART_DR → PA9 发出,计数器-1 第4次: str[3]='l' → DMA → USART_DR → PA9 发出,计数器-1 第5次: str[4]='o' → DMA → USART_DR → PA9 发出,计数器=0 → 完成!
完成后:
DMA2_Stream7 硬件 → TCIF 置 1 → DMA2_Stream7_IRQHandler │ ▼ HAL_DMA_IRQHandler(&hdma_usart1_tx) │ 判断是 TC 中断 → 清标志 → 回调 ▼ HAL_UART_TxCpltCallback(&huart1) ← 你的回调 │ printf("DMA TX done\n"); ▼ huart1.TxState = READY ← 释放,可以发下一包了对比阻塞发送 vs DMA 发送:
Usart_SendString("Hello"): Usart_SendString_DMA(str, 5): main发出'H'→等 main说"帮我发"→立刻干别的 main发出'e'→等 │ main发出'l'→等 同时 DMA 在后台发 main发出'l'→等 H→e→l→l→o 发完了回调通知你 main发出'o'→恢复 ← 零等待 五连等,全程卡住 CPU 时间省下来了TX 流程里只有一个中断(DMA2_Stream7_IRQHandler)+一个回调(TxCpltCallback)。搬运过程 CPU 不参与,只收一次完成通知。
十一、RX DMA 数据流(第③步)
以串口助手发AT\n(3 字节)为例,分三段。
第一段:DMA 默默搬数据(CPU 完全不知情)
字节 'A' 到达 PA10: USART_DR ← 0x41 → 硬件 RXNE=1 DMA2_Stream5 看到 RXNE → 自动读 DR → 把 'A' 搬进 dma_rx_buf[0] DMA 内部:地址 +1 → 指向 buf[1],计数器 -1(127 剩余) RXNE 自动清零,CPU 全程旁观 字节 'T' 到达: DMA → dma_rx_buf[1],地址 +1,计数器 -1(126 剩余) 字节 '\n' 到达: DMA → dma_rx_buf[2],地址 +1,计数器 -1(125 剩余)
此时dma_rx_buf = [A][T][\n][?]...,main 还在while(1)傻跑,一无所知。
第二段:总线空闲 → USART1_IRQHandler
发完 '\n' 后 TX 不再发数据 → RX 引脚回到高电平 持续 > 1 字节时间(~87us)→ 硬件 IDLE 标志置 1 USART1_IRQHandler 触发 ← USART1 的中断,不是 DMA 中断 │ ├─ HAL_UART_IRQHandler(&huart1) │ 扫 SR 寄存器所有标志: │ RXNE=0(DMA 已清)→ 跳过 │ IDLE=1 → HAL 故意不处理(F4 无回调)→ 跳过 │ 其他错误标志 → HAL 顺便处理 │ 返回 │ └─ 你手搓的 IDLE 处理: if (IDLE 标志 != RESET) → YES! { ① __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 清硬件标志 ② uint16_t ndtr = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 读 DMA 剩余计数器,ndtr = 125(128-3) ③ if (ndtr < DMA_RX_BUF_SIZE) // 125 < 128 → TRUE → DMA 模式 { dma_rx_len = 128 - 125 = 3; // 算出实际收到 3 字节 if (dma_rx_len > 0) // 3 > 0 → 真的收到了 dma_rx_flag = 1; // 按门铃! ④ HAL_UART_DMAStop(&huart1); // 停 DMA ⑤ HAL_UART_Receive_DMA(..., 128); // 重启,指向 buf[0] ⑥ __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 重启后可能立即再触发 IDLE,清掉 } }第三段:main 感知并处理
if (dma_rx_flag == 1) // 门铃响了 { LED_B(0); // 蓝灯亮:处理中 printf("DMA rx %d bytes: ", dma_rx_len); Usart_SendString_DMA(dma_rx_buf, dma_rx_len); // DMA 回显 printf("\n"); dma_rx_flag = 0; // 关掉门铃 LED_B(1); // 蓝灯灭 }涉及的中断和回调
场景 中断 回调 ───────────────────── ───────────────────────── ────────────────── DMA 搬数据中 不触发任何中断 无 DMA 自己默默搬 帧结束(空闲) USART1_IRQHandler 无回调 └─ IDLE 部分你手搓 直接置 flag 通知 main DMA 收满 64/128 字节 DMA2_Stream5_IRQHandler 你未实现(预留) └─ HAL_DMA_IRQHandler
小数据帧只走 USART1_IRQHandler(IDLE),不经过 DMA2_Stream5_IRQHandler。
NDTR 计算原理
初始:NDTR = 128(缓冲区总大小) 收 'A':NDTR → 127 收 'T':NDTR → 126 收 '\n':NDTR → 125 实际收到 = 128 - 125 = 3 字节
NDTR 在哪?DMA 硬件寄存器里,
__HAL_DMA_GET_COUNTER每次实时读取,不存在"读到旧值"的问题ndtr 变量在哪?局部变量,栈上。每次进 ISR 重新创建,退出时销毁。跟全局缓冲区
dma_rx_buf/flag/len不同,它不需要清零
对比 IT 接收 vs DMA 接收
| IT 接收 | DMA 接收 | |
|---|---|---|
| 每字节进几次中断 | 1 次 RXNE → CPU 亲自存 | 0 次→ DMA 自动搬 |
| 帧结束谁通知 | USART1 IDLE(同一套手搓) | USART1 IDLE(同一套) |
| 帧长怎么算 | usart1_rx_len(一字节一字节数) | 128 - NDTR(DMA 计数器算) |
| 缓冲区 | usart1_rx_buf[50] | dma_rx_buf[128] |
| CPU 每字节耗时 | ~3us 中断处理 | 0us |
| 适合场景 | 短帧,调试 | 大批量,高速,省 CPU |
"为什么句柄填的全是 uart"
HAL_UART_DMAStop(&huart1); // 主体是串口,内部从 huart1.hdmarx 找 DMA HAL_UART_Receive_DMA(&huart1, ...); // 同上,内部自动操作 DMA __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 这个跟 DMA 无关,清 USART 自己的标志
主体从来都是串口,DMA 只是挂在上面的外挂。__HAL_LINKDMA把 DMA 句柄写进 huart1 之后,UART 函数内部就能自动找到对应的 DMA。用 UART 层面的函数能保证 UART 状态机和 DMA 两边状态一起更新。
十二、main.c demo:DMA 模式 vs IT 模式(第④步)
同一套收发逻辑,换底层搬运工。
DMA 模式 main.c
Usart1_Init(115200); Usart1_DMA_Init(); HAL_UART_AbortReceive(&huart1); // 停掉 IT HAL_UART_Receive_DMA(&huart1, dma_rx_buf, 128); // 启动 DMA 收 __HAL_UART_CLEAR_IDLEFLAG(&huart1); Usart_SendString_DMA(...); // DMA 发 while (1) { if (dma_rx_flag == 1) { // 检测 DMA flag Usart_SendString_DMA(dma_rx_buf, dma_rx_len); // DMA 回显 dma_rx_flag = 0; } }IT 模式 main.c
Usart1_Init(115200); // 不用 DMA_Init // HAL_UART_Receive_IT 在 Usart1_Init 内部自动启动了 while (1) { if (usart1_rx_flag == 1) { // 检测 IT flag HAL_UART_Transmit(&huart1, usart1_rx_buf, usart1_rx_len, 1000); // 阻塞回显 usart1_rx_flag = 0; usart1_rx_len = 0; } }切换四步
IT → DMA DMA → IT ─────── ─────── ① Usart1_DMA_Init() ① 不调 Usart1_DMA_Init ② HAL_UART_AbortReceive() ② HAL_UART_Receive_IT(&huart1, &rx_byte, 1) ③ HAL_UART_Receive_DMA(...,128) ③ 不调 AbortReceive ④ 轮询 dma_rx_flag/len ④ 轮询 usart1_rx_flag/len
ISR 是同一个USART1_IRQHandler,内部自动通过 NDTR 判断走 DMA 分支还是 IT 分支,不用改。
十三、IT vs DMA 场景选择(第⑤步)
判断标准
数据量大不大? → 大(> 几十字节)→ DMA → 小(几个字节)→ IT 够了 CPU 有没有更要紧的事? → 有(RTOS、PID、通信并发)→ DMA → 没有(裸机傻等)→ IT 也行
具体场景
| 场景 | 推荐 | 原因 |
|---|---|---|
| 调试打印、命令行交互 | IT | 每次就几个字,中断存一下毫秒级 |
AT 指令(发AT\r\n回OK\r\n) | IT | 帧很短,DMA 浪费 128 字节缓冲区 |
| GPS 模块每秒吐长串 NMEA | DMA | 每帧几百字节,DMA 省心 |
| Modbus RTU 从机 | DMA | 帧长不定可能到 256 字节,CPU 同时要干别的 |
| 串口传文件、OTA 固件升级 | DMA | 几十 K 数据,IT 中断会炸 |
| 跑 FreeRTOS 时串口通信 | DMA | CPU 忙着调度,不能老被串口中断打断 |
一句话
短帧 + 裸机 + 没别的事 → IT,写起来简单 长帧 / 高波特率 / 跑 RTOS / CPU 忙 → DMA
