ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32 串口空闲中断 + DMA 接收不定长数据:从原理到抗粘包完整实现(HAL 库)

2026/8/19 11:23:57 拓冰建站 浏览量
STM32 串口空闲中断 + DMA 接收不定长数据:从原理到抗粘包完整实现(HAL 库) 文章目录一、为什么逐字节接收会拖垮系统二、空闲中断是怎么判断一帧结束的2.1 硬件层面的机制2.2 三种接收方案怎么选三、整体工作流程四、CubeMX 配置五、核心代码实现5.1 全局变量5.2 启动接收5.3 中断服务函数5.4 主循环里的消费六、实测验证6.1 功能测试6.2 性能对比6.3 理论 vs 实测七、粘包与半包DMAIDLE 也绕不开的问题7.1 两种模式的选择7.2 用帧头 长度字段自定帧界八、故障排查8.1 上电就进 IDLE 中断没发数据也进8.2 接收长度不对时多时少8.3 一帧数据被处理两次8.4 高速连续发送时数据错乱8.5 长时间运行后 DMA 卡死不再接收九、总结参考资料摘要串口是嵌入式设备间最常用的通信接口但上位机、传感器下发的数据帧长度往往不固定靠轮询或逐字节 RXNE 中断接收不仅 CPU 开销大还容易丢字节。本文基于 STM32F407ZGT6 与 HAL 库用「DMA 搬运 空闲中断IDLE收尾」的组合实现不定长数据的零拷贝接收并给出粘包/半包的处理方案。实测在 115200 波特率下连续收发 20 万字节丢包率 0%DMA 中断触发次数相比逐字节 RXNE 降低约 96%CPU 占用从 31% 降到不足 3%。提供完整的 CubeMX 配置步骤、中断处理代码与可运行工程。一、为什么逐字节接收会拖垮系统先交代一下我遇到的实际问题。我做的一个 Modbus 网关下位机串口要同时接 4 路 RS485每路波特率 115200。最初为了省事直接用HAL_UART_Receive阻塞接收 RXNE 中断逐字节往环形缓冲区里塞。结果设备跑起来之后主循环里负责解析的线程经常卡顿示波器上看串口还偶尔丢字节。问题的根源很简单每收到一个字节就要进一次中断、压一次栈、搬运一次数据。115200 波特率下一个字节大约 87μs 就能到齐4 路同时来数据时中断几乎连成一片。CPU 大部分时间都在中断和环形缓冲区的读写里打转留给业务逻辑的时间所剩无几。这时候就需要换一种思路数据搬运交给 DMA帧结束的判断交给硬件。前者把逐字节的中断开销降为零后者让 CPU 不用靠超时猜数据有没有收完。二、空闲中断是怎么判断一帧结束的2.1 硬件层面的机制STM32 串口有一个常被忽略却非常好用的功能——空闲中断IDLE Interrupt。它的触发条件是串口总线上从有数据变成持续空闲超过一个完整数据帧的时间硬件就自动把USART_SR寄存器里的IDLE位置 1。可以这样理解发数据的一方说一句话、喘口气你就知道这句话说完了。这个喘口气的间隔就是 IDLE 用来判断帧边界的依据。对 STM32F407 来说关键寄存器位是寄存器位作用USART_CR1IDLEIE空闲中断使能置 1 打开USART_SRIDLE空闲状态标志硬件置位需软件清除这里有个必须记住的细节IDLE 位不能用__HAL_UART_CLEAR_FLAG这类通用接口清除只能通过先读 SR、再读 DR这个固定序列来清。HAL 库已经封装成了宏__HAL_UART_CLEAR_IDLEFLAG()直接调它就行底层就是读两次寄存器。2.2 三种接收方案怎么选动手之前我列了一张对比表这也是我最终选型的关键依据方案中断次数CPU 占用能否处理不定长实现复杂度阻塞接收HAL_UART_Receive低高死等需预知长度最简单RXNE 逐字节中断每字节 1 次高可以简单超时解析法每帧 1~2 次中可以依赖定时器中等DMA IDLE每帧 1 次低天然支持中等逐字节中断在数据量大时开销不可接受超时解析法要靠定时器判断多久没数据算一帧超时阈值一旦设置不当就容易把一帧拆成两半或两帧并成一帧。DMA IDLE 是硬件天然支持不定长帧的方案——DMA 负责搬IDLE 负责告诉你搬完了这一帧就是这么长。所以我选它。相关阅读《STM32使用HAL库DMA空闲中断实现串口不定长数据接收》 — 最小化的寄存器级实现可对照理解 IDLE 标志位的清法三、整体工作流程整个接收流程可以用下面这张时序图概括内存缓冲区USART 外设DMA 控制器CPU内存缓冲区USART 外设DMA 控制器CPU启动接收 (HAL_UART_Receive_DMA)监听数据字节到达逐字节搬运写入内存 (CNDTR 递减)总线空闲超过 1 字节时间触发 IDLE 中断读 CNDTR 计算已收字节数拷贝/处理这一帧重新启动下一次接收关键点在第 6 步已接收字节数 缓冲区总长度 − 当前 CNDTR 剩余值。因为 DMA 的CNDTR寄存器每搬一个字节就减 1读它就知道还剩多少没搬反推就知道搬了多少。这一步用宏__HAL_DMA_GET_COUNTER()拿到。四、CubeMX 配置工程基于 STM32F407ZGT6下面是我实际配置的参数。如果你的板子是 F103、F0 或 G0 系列思路完全一致只是寄存器和引脚不同。系统时钟HSE 8MHz 晶振PLL 倍频到 168MHzF407 主频。USART1 配置模式Asynchronous异步波特率115200数据位 8停止位 1校验位 None引脚PA9TX/ PA10RXDMA 配置NVIC Settings 页签下给 USART1_RX 添加一个 DMA 通道方向 Peripheral → Memory模式这里先选Normal后面会讲为什么中断勾选 USART1 的全局中断在 NVIC Settings 里 enable。生成代码后DMA 的初始化已经由 CubeMX 生成好了我们只需要补接收逻辑。五、核心代码实现5.1 全局变量接收缓冲区必须定义成全局变量因为中断和主循环都要访问它/* USER CODE BEGIN PV */#defineUART_RX_BUF_LEN256/* 单帧最大长度按需调大 */uint8_tuart_rx_buf[UART_RX_BUF_LEN];/* DMA 接收缓冲区 */volatileuint16_tuart_rx_len0;/* 本帧实际接收长度 */volatileuint8_tuart_rx_ready0;/* 一帧接收完成标志 *//* USER CODE END PV */uart_rx_len和uart_rx_ready加volatile是必须的中断里改、主循环里读编译器优化可能把它们缓存到寄存器里导致主循环永远看不到变化。这个坑我早年踩过标志位不加volatile-O2优化下主循环死循环读同一个值调了整整一晚上。5.2 启动接收在main()里初始化完成之后、进入主循环之前启动第一次 DMA 接收/* USER CODE BEGIN 2 */__HAL_UART_ENABLE_IT(huart1,UART_IT_IDLE);/* 打开空闲中断 */HAL_UART_Receive_DMA(huart1,uart_rx_buf,UART_RX_BUF_LEN);/* 启动 DMA 接收 *//* USER CODE END 2 */注意这两行的顺序必须先把串口、DMA 都初始化完最后再开 IDLE 中断。我一开始图省事把__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)写在了串口初始化之前结果板子一上电、什么都没发就疯狂进 IDLE 中断。排查了半小时才发现——IDLE 中断在总线上电后本身就是空闲状态串口还没准备好就使能它等于直接触发。把这一行挪到所有初始化之后问题消失。5.3 中断服务函数这是整个方案的核心写在stm32f4xx_it.c的USART1_IRQHandler里voidUSART1_IRQHandler(void){/* USER CODE BEGIN USART1_IRQn 0 */if(__HAL_UART_GET_FLAG(huart1,UART_FLAG_IDLE)!RESET){/* 1. 先清 IDLE 标志再停 DMA顺序不能反 */__HAL_UART_CLEAR_IDLEFLAG(huart1);/* 2. 停掉本次 DMA锁定已接收的数据 */HAL_UART_DMAStop(huart1);/* 3. 用 CNDTR 反推实际接收长度 */uart_rx_lenUART_RX_BUF_LEN-__HAL_DMA_GET_COUNTER(huart1.hdmarx);/* 4. 标记一帧就绪交给主循环解析 */uart_rx_ready1;/* 5. 立刻重启 DMA准备收下一帧 */HAL_UART_Receive_DMA(huart1,uart_rx_buf,UART_RX_BUF_LEN);}/* USER CODE END USART1_IRQn 0 */HAL_UART_IRQHandler(huart1);/* USER CODE BEGIN USART1_IRQn 1 *//* USER CODE END USART1_IRQn 1 */}有三处容易写错的地方逐条说明一是先清标志、后停 DMA的顺序。IDLE 标志如果不清HAL_UART_DMAStop内部的一些状态判断可能再次触发中断导致重复进入。我在一次高压测试里就是顺序写反了单帧数据被处理了两次。二是HAL_UART_DMAStop的必要性。它不只是停搬运还会把 DMA 相关的内部状态复位让下一步HAL_UART_Receive_DMA能干净地重新启动。跳过它直接重启CNDTR不会正确复位长度计算就会漂移。三是别忘了尾部调用HAL_UART_IRQHandler。它负责处理 DMA 完成中断等其他串口相关事件。漏掉它长时间运行时 DMA 半传输完成等中断会累积成问题。5.4 主循环里的消费/* USER CODE BEGIN WHILE */while(1){if(uart_rx_ready){uart_rx_ready0;/* 这里对 uart_rx_buf 的前 uart_rx_len 字节做业务解析 */process_frame(uart_rx_buf,uart_rx_len);}/* USER CODE END WHILE *//* USER CODE BEGIN 3 */}/* USER CODE END 3 */主循环和中断通过uart_rx_ready这个标志位握手。中断只负责收完一帧、置标志业务解析放在主循环里做避免在中断上下文里干重活——中断里解析长帧会阻塞其他中断的响应这是实时系统里的大忌。六、实测验证6.1 功能测试用上位机串口助手以不同长度发送数据帧3 字节、16 字节、128 字节、255 字节各发 1000 次单片机收到后原样回传逐字节比对帧长发送次数成功回传丢帧率数据一致性3 字节100010000%100%16 字节100010000%100%128 字节100010000%100%255 字节100010000%100%6.2 性能对比关键的是 CPU 占用和中断次数的对比。用同一个测试程序在 RXNE 逐字节中断和 DMAIDLE 两种方案下连续收发 20 万字节用DWT计数器统计 CPU 占用指标RXNE 逐字节中断DMA IDLE提升幅度中断触发次数200000 次~8000 次降低 96%CPU 占用率31.2%2.8%降低 28.4 个百分点丢包率0.3%0%完全消除中断次数从 20 万次降到约 8000 次是因为 DMA 模式下每帧只触发一次 IDLE 中断而逐字节模式每字节一次。CPU 占用从 31% 掉到 3% 以下主循环终于能顺畅跑业务逻辑了。6.3 理论 vs 实测这里补一组手册理论值 vs 实测值的对照帮助理解 IDLE 的检测精度条件理论空闲检测时间实测帧间隔延迟说明115200 波特率8N187μs1 字节时间~90μs硬件检测精确接近理论帧间间隔 1ms应正常分帧正常分帧间隔远大于 87μs帧间间隔 50μs 87μs无法分帧粘包间隔小于检测阈值两帧被并成一帧最后一行是重点IDLE 判断帧边界的下限就是一个字节的传输时间。如果发送方两帧之间停顿小于 87μsSTM32 就区分不开数据会粘成一帧。这是硬件的物理限制不是代码能解决的——所以协议设计上要么保证帧间隔足够大要么在数据里加帧头长度字段自己分帧见第七节。七、粘包与半包DMAIDLE 也绕不开的问题DMA IDLE 解决了不定长和CPU 开销两个问题但没有解决粘包和半包。只要通信双方是连续流式传输这两个问题就客观存在与用什么接收方案无关。7.1 两种模式的选择DMA 有 Normal 和 Circular 两种模式选错模式会直接造成数据错乱维度Normal 模式Circular 模式缓冲区写满后停止搬运多余数据丢失回到起点覆盖最旧数据适用场景单帧处理帧长可控持续流式接收需配合环形缓冲区风险长帧超缓冲时丢尾部处理不及时时旧数据被覆盖相关阅读《STM32 UART DMA 与空闲中断接收》 — 详细讨论了 NORMAL/CIRCULAR 两种模式下数据覆盖与回调触发的边界情况我这里选Normal 模式 每次中断后立即重启。因为我的网关每帧最大 256 字节缓冲开 256 足够且 IDLE 中断里立即重启 DMA不存在缓冲区写满不处理的情况。如果你的应用是高速、连续、大流量的数据流应该考虑 Circular 模式 双缓冲DMA 半满/全满中断但这套方案复杂度要高一个量级。7.2 用帧头 长度字段自定帧界IDLE 的 87μs 下限在高速连续传输时不够可靠工程上更稳妥的做法是协议层自带帧界定。我给网关定了一个简单协议帧头 0xAA 0x55 | 长度(2字节) | 命令(1字节) | 数据(N字节) | 校验(1字节)#defineFRAME_HEAD_H0xAA#defineFRAME_HEAD_L0x55#defineFRAME_HDR_LEN5/* 2帧头 2长度 1命令 */staticuint8_tframe_buf[UART_RX_BUF_LEN];staticuint16_tframe_idx0;staticuint16_texpect_len0;/* 把 DMA 收上来的字节喂进一个解析状态机 */voidfeed_parser(uint8_t*data,uint16_tlen){for(uint16_ti0;ilen;i){uint8_tchdata[i];if(frame_idx0chFRAME_HEAD_H){frame_buf[frame_idx]ch;continue;}if(frame_idx1chFRAME_HEAD_L){frame_buf[frame_idx]ch;continue;}if(frame_idx2){frame_buf[frame_idx]ch;continue;}if(frame_idx3){frame_buf[frame_idx]ch;expect_lenframe_buf[2]|(ch8);/* 小端长度 */continue;}if(frame_idxexpect_lenFRAME_HDR_LEN){frame_buf[frame_idx]ch;continue;}/* 收满一帧校验 分发 */if(check_crc(frame_buf,expect_lenFRAME_HDR_LEN))dispatch_frame(frame_buf);frame_idx0;/* 复位状态机 */}}状态机逐字节喂入不管 DMA 一次收上来的是一整帧、半帧还是两帧半都能正确切分。帧头匹配失败会直接丢弃当前状态重新找头天然抗干扰。这一层协议兜底之后IDLE 中断就只是告诉 DMA 停一下、算个长度帧界定的正确性不再依赖 87μs 这个物理阈值。八、故障排查汇总几个我实际踩过、以及论坛里最常见的问题8.1 上电就进 IDLE 中断没发数据也进现象板子复位后串口中断函数被频繁调用uart_rx_ready一直为 1。排查用调试器看USART_SR的 IDLE 位发现上电即为 1。根因IDLE 中断使能过早串口上电后总线本身处于空闲状态一使能就触发。方案把__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE)挪到所有串口/DMA 初始化完成之后。验证复位后不发数据中断函数不再进入。相关阅读《STM32串口空闲中断 上电就进IDLE分析及解决方法》 — 同款问题的完整排查记录8.2 接收长度不对时多时少现象uart_rx_len计算出来的值比实际发送长度偏大或偏小。排查检查HAL_UART_DMAStop是否在计算长度前被调用。根因没停 DMA 就读 CNDTR或顺序反了CNDTR 还在变化。方案严格按清 IDLE → 停 DMA → 读 CNDTR → 算长度的顺序。验证固定长度帧回读长度值稳定。8.3 一帧数据被处理两次现象主循环里uart_rx_ready置位期间同一帧数据触发了两次解析。排查看中断是否重复进入。根因IDLE 标志没清干净或HAL_UART_DMAStop后又有事件触发中断。方案确保 IDLE 标志用__HAL_UART_CLEAR_IDLEFLAG清除且顺序正确。验证加计数器每发一帧计数应只 1。8.4 高速连续发送时数据错乱现象低速正常高速如帧间隔 100μs时数据串帧、错乱。排查确认是否为粘包/半包。根因两帧间隔小于 IDLE 检测阈值1 字节时间物理上无法分帧。方案协议层加帧头 长度字段 校验用状态机分帧不依赖 IDLE 精度。验证连续高速发帧解析出的帧数与发送数一致。8.5 长时间运行后 DMA 卡死不再接收现象跑几个小时到几天后串口突然收不到数据复位后恢复。排查检查是否漏调了HAL_UART_IRQHandler或 DMA 错误中断未处理。根因DMA 半满/错误中断未清累积导致状态机卡死或HAL_UART_DMAStop后未正确重启。方案中断尾部补上HAL_UART_IRQHandler(huart1)并在HAL_UART_ErrorCallback里做恢复处理。验证挂机压测 72 小时收发不中断。九、总结核心要点回顾DMA 负责搬运、IDLE 中断负责判断帧结束两者配合实现不定长数据的零 CPU 逐字节开销接收。已收长度 缓冲区长度 −__HAL_DMA_GET_COUNTER()的当前值这是整个方案的计算核心。中断处理必须遵循清 IDLE → 停 DMA → 算长度 → 重启 DMA的固定顺序顺序错一点都会出问题。IDLE 分帧有 1 字节时间的物理下限高速连续传输必须靠协议层帧头长度校验兜底。适用边界这套方案适合帧长可控、帧率适中的场景——例如传感器数据上报、Modbus/自定义协议网关、上位机指令交互。如果你的数据是高速、连续、不可预知长度的流比如透传转发建议改用 Circular DMA 双缓冲方案。已知局限单帧长度受缓冲区大小限制超长帧在 Normal 模式下会丢尾部IDLE 的 87μs 检测阈值决定了纯靠它分帧在高速场景下不可靠。扩展方向掌握本文之后可以进一步研究 DMA 的半满/全满中断双缓冲机制、RS485 的收发方向自动切换配合USART的 TC 标志以及 FreeRTOS 下如何用信号量把帧就绪事件同步给解析任务。本文完整工程代码可在 CSDN 下载频道 获取VIP 免费。参考资料STM32使用HAL库DMA空闲中断实现串口不定长数据接收STM32 UART DMA 与空闲中断接收NORMAL/CIRCULAR 模式讨论STM32串口空闲中断 上电就进IDLE分析及解决方法版本备注硬件平台STM32F407ZGT6正点原子探索者 CH340 USB 转串口软件版本STM32CubeMX 6.9.0 STM32F4 HAL 库 1.27.1 Keil MDK 5.37兼容说明本文代码适用于 STM32F4 全系F1/F0/G0 系列寄存器与中断配置略有差异思路完全通用移植时注意__HAL_DMA_GET_COUNTER参数与 IDLE 标志清法即可