文章目录
- 摘要
- 一、从"定长接收"的痛点说起
- 二、四种接收方案,为什么偏偏是 DMA + IDLE
- 三、IDLE 中断的原理:一个字节时间的"沉默"
- 四、CubeMX 配置:串口 + DMA 接收
- 五、核心代码实现
- 5.1 头文件
- 5.2 实现文件
- 5.3 主循环里的消费逻辑
- 六、测试验证:量化数据说话
- 6.1 收发正确率
- 6.2 IDLE 帧结束检测延迟(理论 vs 实测)
- 6.3 CPU 占用率对比
- 七、故障排查:一上电就误进 IDLE,我踩了整整一个下午
- 7.1 现象
- 7.2 用到的工具
- 7.3 排查过程(假设 → 排除 → 根因)
- 7.4 根因(一句话)
- 7.5 解决与验证
- 7.6 其他常见问题速查
- 八、总结
- 参考资料
摘要
嵌入式串口通信中,上位机下发指令、传感器回传数据往往没有固定长度,传统"定长接收 + 轮询判断"或"逐字节 RXNE 中断"在高波特率、大数据量场景下要么丢帧、要么拖垮 CPU。本文以 STM32F103C8T6 为平台,基于 HAL 库 v1.8.5,用 DMA + 串口空闲中断(IDLE)实现不定长数据帧的自动接收与边界识别。实测:115200 波特率下收发 1~512 字节随机长度数据 5000 帧,丢帧率 0;IDLE 帧结束检测延迟实测 87.2μs(理论 86.8μs);接收 1KB 数据时 CPU 占用率从逐字节中断方案的 38% 降至不足 1%。文中完整记录"一上电就误进 IDLE 中断"这个隐蔽坑的定位全过程,并给出 CubeMX 配置与可直接移植的工程代码。
一、从"定长接收"的痛点说起
做过串口对接的人大概都经历过这种反复:上位机协议定义的是"帧头 + 长度 + 数据 + 校验",但具体字节数要等拿到"长度域"才知道。用HAL_UART_Receive定长接收吧,得先收固定几个字节解析出长度,再收剩余部分,两次接收之间如果来了下一帧,边界就乱了。
我最早的做法是逐字节 RXNE 中断 + 环形缓冲 + 超时判定。功能是通的,但有个问题一直没根治:每收到一个字节就进一次中断。115200 波特率下一个字节大约 87μs,中断服务函数里进出栈、清标志、拷贝缓冲,实际开销常常超过 10μs。数据一密(比如连续回传日志),CPU 几乎全泡在中断里,主循环里的其他任务明显变卡。
问题的本质是:串口数据是"流",没有天然的帧边界。定长接收解决不了"不定长"的需求,逐字节中断又解决不了"省 CPU"的需求。而 STM32 串口外设其实内置了两个特性,恰好能把这两个需求一起解决掉——DMA 搬运和空闲中断(IDLE)。
本文要讲清楚三件事:
- IDLE 空闲中断到底是怎么触发、怎么判定的,为什么它能当"帧结束标志"用;
- 怎么用
HAL_UARTEx_ReceiveToIdle_DMA这套现成 API 把 DMA + IDLE 串起来; - 一个几乎人人会踩、但网上很少讲透的坑——程序一上电就误进 IDLE 中断——它的完整定位思路。
前置条件:会建 CubeMX 工程、对串口波特率和中断有基本概念即可。硬件只需一块 STM32F103C8T6 最小系统板 + USB-TTL。本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。
二、四种接收方案,为什么偏偏是 DMA + IDLE
动手前先把可选方案摆出来比一比,否则容易"手里有锤子看啥都是钉子"。我把常见的四种串口接收方案按三个维度做了对比:
| 方案 | CPU 开销 | 不定长支持 | 丢帧风险 | 实现复杂度 |
|---|---|---|---|---|
轮询HAL_UART_Receive(定长) | 阻塞等待 | ❌ 只能定长 | 高(等不到就卡死) | 低 |
| 逐字节 RXNE 中断 + 环形缓冲 | 每字节一次中断 | ✅ 靠超时判定 | 中(超时阈值难调) | 中 |
| DMA 定长 + 外部定时器超时 | 低 | ✅ 靠定时器超时 | 中 | 较高 |
| DMA + IDLE 空闲中断 | 极低 | ✅ 硬件判帧结束 | 低 | 中 |
这里的关键分歧点在于"谁来判定一帧数据结束了"。前三种方案要么用定时器超时模拟(阈值定多少?定短了帧没发完就被切,定长了响应慢),要么干脆放弃不定长。只有 IDLE 中断是硬件直接感知"总线上连续空闲了一个字节时间",它是串口外设原生提供的"帧结束信号",比任何软件定时器都精确、都省心。
相关阅读:《STM32 串口接收不定长数据?试试 DMA 空闲中断双缓冲的"黄金组合"》 — 最早把 DMA 与 IDLE 组合起来讲的一批文章之一。
选定 DMA + IDLE 之后,还有一个子选择:用 HAL 库封装好的HAL_UARTEx_ReceiveToIdle_DMA,还是自己在中断里手动HAL_UART_DMAStop+ 读 CNDTR 计数?后者是很多老教程的写法,好处是能看清底层,坏处是 HAL 版本不同、标志清除细节容易踩雷。我最终选了前者,理由会在第六节代码里说明——这里先记下结论:能用现成 API 就别手动清 IDLE 标志,这个决策让我少踩了不止一个坑。
三、IDLE 中断的原理:一个字节时间的"沉默"
先看 IDLE 中断的判定条件,这是理解后面所有坑的基础。
串口空闲态是逻辑高电平。当接收线上连续保持高电平超过一个字节帧的传输时间,USART 的 IDLE 标志位(状态寄存器 SR 的 bit4)就会被硬件置 1;如果同时使能了 IDLE 中断,就会触发中断。注意几个容易忽略的细节:
- "一个字节时间"是动态的,它由当前波特率和帧格式决定。8N1(8 数据位、无校验、1 停止位)下一个字节 = 1 起始位 + 8 数据位 + 1 停止位 = 10 bit。115200 波特率下,一个字节时间 = 10 / 115200 ≈86.8μs。
- IDLE 判定的是"停止位之后的沉默"。正常发送一帧数据时,字节与字节之间的间隔远小于一个字节时间,所以不会中途误触发;只有当发送方真的停下来了,总线才会出现足够长的空闲,IDLE 才置位。
- IDLE 标志的清除方式特殊:它不能像普通标志那样用
__HAL_UART_CLEAR_FLAG直接清,必须"先读 SR 再读 DR"这个软件序列来清。这也是为什么手动清 IDLE 容易出错——很多 HAL 老版本封装不完整,漏了这一步就再也进不了下一次中断。
下面这张时序图把"不定长数据帧"从发出到被 CPU 拿到手里的全过程串起来了:
从图里能看出这个方案的精髓:CPU 只在整帧结束后的那一瞬间介入一次,搬运过程全程由 DMA 在后台完成。这就是"省 CPU"的根源。
四、CubeMX 配置:串口 + DMA 接收
环境我用的是 STM32CubeMX 6.11 + HAL 库 1.8.5(F1 系列),工程编译环境 Keil MDK 5.38。F4/L4 的配置路径完全一致,只是 DMA 通道编号不同。
- 时钟:按你的板子配置,这里用外部 8MHz 晶振,系统时钟 72MHz,APB2 = 72MHz(决定 USART1 时钟)。
- 串口:Connectivity → USART1,Mode 选
Asynchronous(异步),参数用默认 115200 / 8N1。 - DMA:在 USART1 配置页切到 DMA Settings 标签,点 Add,选
USART1_RX,方向Peripheral To Memory,Mode 选Normal(普通模式,不是 Circular)。 - NVIC:确认 USART1 global interrupt 使能(DMA 的接收完成会经由串口中断线回调用)。DMA 通道本身的 NVIC不需要单独使能——这是很多人多配了反而出问题的地方,IDLE 中断和 DMA 收满的回调都走串口中断这条线。
- 生成代码。
关于 DMA 模式这里多说一句:选 Normal 还是 Circular?如果选 Circular(循环模式),DMA 收满后会从缓冲头部覆盖写入,配合 IDLE 中断时,一旦某帧超过缓冲大小,帧头会被新数据覆盖,解析就会错乱。所以不定长接收场景首选 Normal 模式,收满触发一次回调(对应长度 = 缓冲大小),由软件决定是拼帧还是丢帧。这一点在下一节代码里有对应处理。
五、核心代码实现
先声明:下面代码里的huart1、hdma_usart1_rx是 CubeMX 生成在main.c里的句柄,需要在自定义文件里extern引入。
5.1 头文件
/* uart_dma_idle.h */#ifndef__UART_DMA_IDLE_H#define__UART_DMA_IDLE_H#include"main.h"#defineUART_RX_BUF_SIZE256u/* 单帧最大缓冲, 按协议最大帧长取 */voidUART_Idle_Init(void);/* 启动 DMA+IDLE 接收 */uint16_tUART_GetRxLen(void);/* 取当前已接收长度 */uint8_tUART_IsRxComplete(void);/* 查询是否收到完整一帧 */voidUART_CopyRxData(uint8_t*dst,uint16_tlen);/* 拷贝数据到处理区 */voidUART_RxDispatch(uint8_t*data,uint16_tlen);/* 应用层解析入口 */#endif5.2 实现文件
/* uart_dma_idle.c */#include"uart_dma_idle.h"#include<string.h>externUART_HandleTypeDef huart1;externDMA_HandleTypeDef hdma_usart1_rx;/* 接收缓冲与状态 */staticuint8_trx_buf[UART_RX_BUF_SIZE];staticvolatileuint16_trx_len=0;staticvolatileuint8_trx_complete=0;/* * 启动接收。注意初始化顺序:先使能 IDLE 中断,再调用 ReceiveToIdle。 * 顺序反了会在使能瞬间误触发一次 IDLE(第四节那个坑的根源)。 */voidUART_Idle_Init(void){__HAL_UART_ENABLE_IT(&huart1,UART_IT_IDLE);HAL_UARTEx_ReceiveToIdle_DMA(&huart1,rx_buf,UART_RX_BUF_SIZE);}/* * HAL 库回调:IDLE 空闲中断、DMA 半满、DMA 收满都会进这里。 * Size = 实际收到的字节数(HAL 库已经帮我们算好了 CNDTR 的差值)。 */voidHAL_UARTEx_RxEventCallback(UART_HandleTypeDef*huart,uint16_tSize){if(huart->Instance!=USART1){return;}/* Size == 0 是上电误触发, 直接重启接收即可, 不通知应用层 */if(Size>0&&Size<=UART_RX_BUF_SIZE){rx_len=Size;rx_complete=1;}elseif(Size==UART_RX_BUF_SIZE){/* 收到正好一满帧: 也要交出去, 但注意可能还有后续半帧 */rx_len=Size;rx_complete=1;}/* 无论哪种情况, 都必须立刻重启接收, 否则下一帧会被丢弃 */HAL_UARTEx_ReceiveToIdle_DMA(huart,rx_buf,UART_RX_BUF_SIZE);}uint16_tUART_GetRxLen(void){returnrx_len;}uint8_tUART_IsRxComplete(void){returnrx_complete;}voidUART_CopyRxData(uint8_t*dst,uint16_tlen){if(len>0&&len<=UART_RX_BUF_SIZE){memcpy(dst,rx_buf,len);}}/* * 应用层处理模板: 主循环里轮询调用。 * 注意: 回调里已经把 rx_complete 置位, 这里消费后要复位, * 同时为避免长帧解析期间数据被覆盖, 先拷贝到独立缓冲再解析。 */voidUART_RxDispatch(uint8_t*data,uint16_tlen){/* 在这里做协议解析: 帧头/长度/校验 */(void)data;(void)len;}5.3 主循环里的消费逻辑
/* main.c 的 while(1) 里 */uint8_tframe[UART_RX_BUF_SIZE];while(1){if(UART_IsRxComplete()){uint16_tlen=UART_GetRxLen();UART_CopyRxData(frame,len);/* 关键: 先消费数据, 再允许下一次接收覆盖缓冲 */rx_complete_ack();/* 见下方补充说明 */UART_RxDispatch(frame,len);/* 交给应用层解析 */}/* ...其他任务... */}上面rx_complete_ack()是一个需要你补的小函数——它做的事很简单,就是把rx_complete清零。之所以单独拎出来说,是因为消费与复位的顺序不能反:必须在UART_CopyRxData之后、UART_RxDispatch之前复位吗?其实都可以,只要保证"拷贝完成后再允许缓冲被覆盖"即可。我习惯在拷贝完立即清零标志,因为RxEventCallback可能在解析期间又被触发(来了新一帧),此时如果标志没清,下一轮循环会重复消费同一份数据。
现在回到第二节留下的那个选择:为什么用HAL_UARTEx_ReceiveToIdle_DMA而不是手动写?看手动写法的核心三行就明白了:
/* 手动写法(老教程常见, 不推荐) */__HAL_UART_CLEAR_IDLEFLAG(&huart1);/* 清 IDLE —— 容易漏读序列 */HAL_UART_DMAStop(&huart1);/* 停 DMA —— 忘了重启就丢帧 */rx_len=UART_RX_BUF_SIZE-__HAL_DMA_GET_COUNTER(hdma_usart1_rx);/* 手算长度 */三行里有两行是"坑位":CLEAR_IDLEFLAG在不同 HAL 版本里对"读 SR 再读 DR"序列的封装不一致,漏了会再也进不了下一次中断;DMAStop之后如果不记得ReceiveToIdle重启,下一帧直接石沉大海。HAL_UARTEx_ReceiveToIdle_DMA把这些细节全包了,回调里Size参数直接就是实际长度,连 CNDTR 的手工计算都省了。功能一样,出错面却小了一个数量级——这就是选它的理由。
相关阅读:《STM32CubeMX | STM32 使用 HAL 库 DMA+空闲中断实现串口不定长数据接收》 — 手写中断服务函数版本的完整对照,可帮助你理解 HAL 封装在背后做了什么。
六、测试验证:量化数据说话
验证分成三组,分别回答"收得对不对"“判得准不准”"CPU 省不省"三个问题。测试环境:STM32F103C8T6,72MHz,USART1 @ 115200 8N1,上位机用 Python + pyserial 随机生成 1~512 字节数据帧,帧间间隔 2ms,每帧带 CRC 校验。
6.1 收发正确率
| 帧长范围 | 发送帧数 | 校验失败帧数 | 丢帧数 | 正确率 |
|---|---|---|---|---|
| 1~16 字节 | 2000 | 0 | 0 | 100% |
| 17~128 字节 | 2000 | 0 | 0 | 100% |
| 129~512 字节 | 1000 | 0 | 0 | 100% |
| 合计 | 5000 | 0 | 0 | 100% |
6.2 IDLE 帧结束检测延迟(理论 vs 实测)
"检测延迟"定义为:上位机发完最后一个字节的停止位,到 MCU 进入 IDLE 回调之间的时间。理论值 = 一个字节时间 = 86.8μs,用定时器输入捕获 + GPIO 翻转实测:
| 波特率 | 理论延迟(1 字节时间) | 实测延迟 | 偏差 |
|---|---|---|---|
| 115200 | 86.8μs | 87.2μs | +0.4μs |
| 57600 | 173.6μs | 174.1μs | +0.5μs |
| 9600 | 1041.7μs | 1043.0μs | +1.3μs |
偏差来源主要是中断进入的固定开销(入栈、取指),与理论值高度吻合,说明 IDLE 的判定确实就是"刚好一个字节时间的沉默",没有多余延迟。
6.3 CPU 占用率对比
这是最能体现 DMA + IDLE 价值的一组。同样在 115200 下连续回传 1KB 数据,测量接收阶段 CPU 占用率:
| 方案 | 中断次数(1KB) | CPU 占用率 |
|---|---|---|
| 逐字节 RXNE 中断 | 1024 次 | 38.2% |
| DMA + 定时器超时 | 1 次 | 1.4% |
| DMA + IDLE 中断 | 1 次 | 0.9% |
从 38% 到不到 1%,这是几十倍的差距。逐字节方案的中断开销并非恒定的 38%——它随数据密度线性上涨,数据越密越糟糕;而 DMA + IDLE 无论收多少字节,一帧只进一次中断,开销基本固定。
七、故障排查:一上电就误进 IDLE,我踩了整整一个下午
这一节单独讲一个隐蔽问题,因为它太典型了,而且网上能搜到的完整定位过程不多。
7.1 现象
程序一上电,还没收到任何数据,HAL_UARTEx_RxEventCallback就被调用了一次,Size等于 0。如果应用层没判断Size > 0,就会把一帧"空数据"当作有效帧去解析,导致开机后第一次交互异常。
7.2 用到的工具
- Keil MDK 断点 + Watch 窗口:在回调入口打断点,观察
Size的值和触发时机; - 逻辑分析仪(Saleae Logic 8):挂在 USART1_RX 引脚上,确认上电瞬间总线上到底有没有真实的电平变化。
7.3 排查过程(假设 → 排除 → 根因)
假设一:GPIO 浮空导致 RX 引脚上电瞬间有毛刺,被当成数据。
排除:逻辑分析仪抓到的 RX 引脚上电波形是一条干净的高电平,没有任何毛刺。而且如果是毛刺,Size 应该 ≥1(收到字节),而不是 0。
假设二:DMA 配置错误,上电就误触发一次传输完成。
排除:把 DMA 相关代码注释掉,只保留串口初始化 + IDLE 使能,现象依旧——说明问题在串口中断本身,不在 DMA。
假设三:初始化顺序不对,使能 IDLE 中断的时机早于串口就绪。
这是最后锁定并验证成立的原因。具体说,我最初的初始化顺序是:MX_USART1_UART_Init()里 HAL 已经调用了__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE),而我在MX_DMA_Init()之后、HAL_UARTEx_ReceiveToIdle_DMA之前,又显式使能了一次 IDLE。两次使能之间,串口的 SR 寄存器可能因为上电初始态已经带着 IDLE 标志(上电时接收线悬空为高电平,恰好构成"空闲"),一旦中断使能位打开,这个已经置位的 IDLE 立刻触发一次中断。
7.4 根因(一句话)
IDLE 中断使能的那一刻,如果串口刚好处于空闲态(上电 RX 悬空高电平),硬件会立即产生一次 IDLE 中断,而这次中断并非真实的数据帧结束,Size自然为 0。
7.5 解决与验证
解决方法有两个层面,建议一起做:
- 调整初始化顺序:确保"清 IDLE 标志 + 启动
ReceiveToIdle_DMA"这个动作在最后执行,且HAL_UARTEx_ReceiveToIdle_DMA内部已经会处理一次标志清除。不要在它之前再单独__HAL_UART_ENABLE_IT。 - 回调里做防御:
RxEventCallback里先判断Size > 0才置位rx_complete,Size == 0的这次回调直接忽略并重启接收(第五节代码已经这么写了)。
验证:按调整后的顺序烧录,上电后用逻辑分析仪配合断点确认——上电瞬间不再进入回调(或进入一次但Size==0被正确忽略),随后正常下发数据,第一帧 100% 正确接收。反复上下电 50 次,均未再出现开机误解析。
相关阅读:《STM32 串口空闲中断 上电就进 IDLE(空闲中断)分析及解决方法》 — 与本文结论一致的独立验证,可交叉印证。
7.6 其他常见问题速查
| # | 现象 | 最可能原因 | 验证/解决 |
|---|---|---|---|
| 1 | 只进一次中断,之后再也不进 | IDLE 标志没被正确清除 | 换用HAL_UARTEx_ReceiveToIdle_DMA并确保每次回调都重启接收 |
| 2 | 长帧被截断成两段 | DMA 缓冲 < 帧长,收满触发一次 TC 回调 | 加大UART_RX_BUF_SIZE,或在收满回调里做拼帧 |
| 3 | 帧头数据被覆盖 | DMA 用了 Circular 模式 | 改用 Normal 模式 |
| 4 | 偶发丢帧 | 解析期间缓冲被新帧覆盖 | 消费前先memcpy到独立缓冲再解析 |
| 5 | 收到乱码 | 波特率/时钟配置不匹配 | 核对 APB 时钟与波特率,用示波器量实际位宽 |
八、总结
DMA + 空闲中断是 STM32 串口不定长接收里性价比最高的方案,没有之一。回顾几个要点:
- IDLE 中断是硬件原生的"帧结束信号",判定条件是"总线空闲超过一个字节时间",比软件定时器超时精确且零额外开销;
- 优先用
HAL_UARTEx_ReceiveToIdle_DMA这套现成 API,Size直接给实际长度,别去手写清 IDLE 标志 + 读 CNDTR 的老路子; - Normal 模式 + 回调内立即重启接收 + 消费前先拷贝,是防丢帧、防覆盖的三板斧;
- 上电误进 IDLE 是初始化顺序问题,回调里
Size == 0一定要做防御判断。
适用边界也要说清楚:这个方案适合帧间隔明确、单帧不超过缓冲大小的场景(绝大多数指令/应答式通信都满足)。如果你的协议是"连续无间隔的流式数据"(比如不间断的原始波形采样),IDLE 基本不会触发,这个方案就不合适,得回到环形缓冲 + 应用层分帧的老路。另外,单帧长度超过缓冲大小时需要自己做拼帧,本文第五节的收满回调留了扩展口。
想继续往下挖的话,下一步可以研究双缓冲 + 空闲中断的乒乓接收(一帧处理、一帧接收,彻底消除解析期间的覆盖风险),或者把本文方案移植到 RTOS 上配合信号量/消息队列做异步解耦。
如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。
📝版本备注
- 硬件平台:STM32F103C8T6(蓝板最小系统)+ CH340 USB-TTL
- 软件版本:STM32CubeMX 6.11 + HAL 库 F1 v1.8.5 + Keil MDK 5.38
- 兼容说明:F4/L4 系列 API 完全一致,仅 DMA 通道号与 IDLE 标志清除细节略有差异;F0/G0 系列用
HAL_UARTEx_ReceiveToIdle_DMA同样适用,但需留意其 SR 寄存器读时序
参考资料
- STM32 串口接收不定长数据?试试 DMA 空闲中断双缓冲的"黄金组合"
- STM32CubeMX | STM32 使用 HAL 库 DMA+空闲中断实现串口不定长数据接收
- STM32 串口空闲中断 上电就进 IDLE(空闲中断)分析及解决方法