1. 为什么不定长接收一直是串口开发的“老大难”
做嵌入式开发的兄弟应该都有过这种体验:产品联调的时候,上位机发过来一包数据,长度要么是 7 个字节,要么是 31 个字节,甚至有可能是 256 个字节。你要让板子把数据完整收下来,还不能卡死主循环。这个需求听起来简单,实际上坑不少。STM32 的 HAL 库虽然封装得很完善,但一碰到“不定长帧接收”这个场景,默认的HAL_UART_Receive阻塞方式和HAL_UART_Receive_IT中断方式都不太够用。
阻塞方式会卡在while循环里,等不到数据就出不来,主循环全部停摆。中断方式虽然不阻塞主流程,但每收一个字节进一次中断,高频数据下 CPU 占用率高,而且还要自己拼帧。真正好用的方案其实就两条路:一条是 DMA 加串口 IDLE 空闲中断,一条是 DMA 加超时管理。这篇文章就围绕这两种实战方法展开,把各自的原理、配置、代码实现、坑点一次性说透。不管你是刚接触 STM32HAL 库的初学者,还是被串口不定长问题折腾过的老手,本文的内容都可以直接抄作业。
1.1 串口接收的本质与 HAL 库默认接收方式
先理清一个基础认知。串口通信本质上是逐字节传输,底层硬件(USART/UART 外设)每收到一个字节就放进接收数据寄存器,同时置位 RXNE(接收数据非空)标志位。CPU 有几种办法拿走这个数据:轮询读取(阻塞)、中断读取(每字节进一次 ISR)、DMA 搬运(外设直接送到内存,CPU 不管)。
HAL 库默认提供的三种接收接口,对应关系是这样的:
| HAL 接口 | 底层机制 | 适用场景 |
|---|---|---|
HAL_UART_Receive | 轮询 + 阻塞等待 | 数据量小、时间不敏感 |
HAL_UART_Receive_IT | 逐字节 RXNE 中断 | 按字节解析的简单协议 |
HAL_UART_Receive_DMA | DMA 搬运到内存 | 大数据块、高速接收 |
这里最有意思的是第三种。HAL_UART_Receive_DMA一调用,DMA 控制器就把 RX 引脚上收到的数据源源不断搬运到指定的缓冲区,收满指定长度后触发HAL_UART_RxCpltCallback回调。但问题来了:你要把缓冲区设多大?设 100 字节,上位机如果只发 15 字节呢?回调函数永远不触发,数据就躺在缓冲区里“装死”。
所以 DMA 本身不解决“帧结束”判定问题,它只解决“数据怎么高效搬进内存”的问题。要判断一帧数据什么时候结束,还需要额外机制。这就像快递员把包裹放到你门口了(DMA 搬运),但没人告诉你包裹齐不齐,你得自己判断“是不是全部到货了”。IDLE 中断和超时管理,就是两种判断“包裹到齐了”的方案。
1.2 四种常见接收方案的选型比较
在深入写代码之前,我们先横向对比一下嵌入式中常用的四种不定长接收方案,这样你对选型会更有底。
第一种是“逐字节中断接收”。每个字节触发一次 RXNE 中断,在中断里把数据放进数组,同时启动一个软件定时器,比如每收到一个字节就重置超时计数。当超时计数值超过阈值,就认为一帧结束了。这种方案最灵活,但是高波特率下 CPU 占用很严重,比如 115200 波特率、每秒收到约 11520 字节,意味着每 87 微秒就要进一次中断,主循环基本被掏空。
第二种是“空闲中断 + 普通接收”。串口外设检测到总线上出现空闲(即收到数据后停止接收),就触发 IDLE 中断。但数据还是没有 DMA 帮忙,仍然是 CPU 逐字节搬,只是省去了“判断帧结束”的代码。
第三种是“DMA + 空闲中断”,这也是本文要详解的第一种方案。DMA 负责把数据搬到内存,IDLE 中断负责告知“一帧收完了”,CPU 全程不参与数据搬运,只在帧结束时处理一次数据。这种方案在高波特率、大数据量场景下优势明显,是量产项目中最常见的组合。
第四种是“DMA + 超时管理”,本文的第二种方案。DMA 持续接收,但帧结束不是靠硬件 IDLE 信号,而是靠软件定时检查:如果距离最后一次收到数据的时间超过了阈值,就判定当前帧接收完毕。这个方案适合那些 IDLE 中断行为比较“诡异”的芯片,或者你想让代码在不同 MCU 之间移植性更强的情况。
这四种方案没有绝对优劣,但如果你用的是 STM32 标准系列,强烈建议优先掌握第三种。HAL 库对 IDLE 中断的支持并不像对 RXNE 那样封装得“傻瓜化”,需要我们自己动手在中断回调里做一点处理,所以下面详细展开。
1.3 为什么要用 DMA,以及 DMA+IDLE 方案的核心价值
先说 DMA(Direct Memory Access,直接存储器访问)。它就像一个专职搬运工,外设收到数据,它直接帮你搬到内存缓冲区,完全不需要 CPU 插手。在你处理主循环逻辑、跑 LCD 刷新、算 PID 的时候,串口数据已经悄悄收进内存了。等到一帧数据全部到齐,通知你一声,你再去缓冲区统一处理。这就是 DMA 的核心价值:把 CPU 从繁重的搬运劳动中解放出来。
DMA+IDLE 组合更是绝配。DMA 帮你解决“数据搬运”,IDLE 中断帮你解决“帧结束时机”,两者结合,你得到的是一套近乎完美的“后台自动接收”方案。上位机每发完一帧数据,总线就会进入空闲状态。什么时候总线空闲了?就是最后一个字节接收完之后,持续一个字节时间没有新数据。STM32 的 USART 外设专门为这个状态准备了一个标志位——IDLE。只要检测到这个标志位,就说明当前这一帧数据已经完整到达,可以马上处理了。
这个方案的效率有多高?假设你从机每秒收到 100 帧不同长度的数据,如果用逐字节中断接收,每秒要进几千次中断;如果用 DMA+IDLE,每秒只进 100 次左右的 IDLE 中断加若干次 DMA 传输完成中断,CPU 占用率天差地别。
2. IDLE中断:最正统的“帧结束”判定方案
2.1 IDLE中断到底是什么
很多人会用 IDLE 中断,但未必清楚它背后的硬件行为。IDLE 全称 Idle Line Detection,就是空闲线路检测。它的触发条件是:USART 接收线(RX)上经历了整整一个字节时间的空闲。这里“一个字节时间”在起始位和停止位的配置下,大概是 10 个位时间(8 数据位 + 1 起始位 + 1 停止位),也就是在波特率为 115200 时约 86.8 微秒。
需要特别注意的是,IDLE 中断不是“一进入空闲就立即触发”,而是要等一个完整字节时间的空闲之后才触发。这在高速连续帧和数据流场景下可能会造成一点点延迟,但对绝大多数帧-应答式的工业通信协议来说,完全不是问题。
更关键的是 IDLE 标志位的清除方式。ST 官方的参考手册上写得很清楚:要顺序读取 USART 的状态寄存器(SR)和读取数据寄存器(DR)才能清除 IDLE 标志位。在 HAL 库里,这里有一个常见的坑:__HAL_UART_CLEAR_IDLEFLAG这个宏在部分系列芯片中的实现就是“先读 SR 再读 DR”,但在另一些芯片系列(比如 F4)中是用软件写 0 的方式清除的。如果你用的是 HAL 库,建议直接调用对应宏,不要自己去操作寄存器,否则容易踩到芯片差异的坑。
2.2 DMA接收的配置细节与参数解析
用 CubeMX 配置时,第一步是打开 USART,波特率根据你的协议要求设置。这里要重点说一下 DMA 的设置:
DMA Mode 选择
Normal。这个很关键。Normal 模式表示 DMA 接收完设定的长度后自动停止,需要重新调用HAL_UART_Receive_DMA才能继续。如果选择Circular(循环模式),DMA 会一直循环往缓冲区写数据,当缓冲区写满后自动回卷到起始地址。两种模式都能用,但 Normal 模式逻辑更清晰,处理完一帧后重新启动,不会出现数据覆盖的问题。Data Width 设置为
Byte。串口数据是 8 位的,DMA 搬运宽度也设为 Byte,保证一一对应。缓冲区大小(Buffer Size)怎么设置?这是很多人容易犯迷糊的地方。DMA 接收缓冲区的大小,决定了你单次能接收的最大帧长度。如果你设置的缓冲区是 256 字节,那么 DMA 搬运满 256 字节后会触发
HAL_UART_RxCpltCallback。如果这时候一帧还没收完(上位机发的是 300 字节的长帧),数据就会继续往缓冲区外面写,导致内存越界,这是非常致命的。所以缓冲区大小要根据你项目里的最大帧长度来定,通常加上一定余量。
我在实际项目中,一般把缓冲区设为最大协议帧长度的 1.5 倍到 2 倍。比如协议规定最长帧是 128 字节,我就开 256 字节的缓冲区。这样既不会浪费太多内存,也能覆盖异常情况下的超长数据。
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE];在 CubeMX 的 NVIC 设置中,要同时打开 USART 全局中断和 DMA 中断。USART 全局中断是必须的,因为 IDLE 中断是挂在 USART 中断线上的;DMA 的中断则用于处理 DMA 传输完成的情况(比如缓冲区满了)。
到这里,硬件的底层配置就完成了。接下来最关键的一步,是在代码里处理 IDLE 中断。
2.3 完整代码实现与关键行解读
先说 CubeMX 生成代码后的常规操作。HAL 库的HAL_UART_IRQHandler会处理 USART 的所有中断标志位,并调用对应的回调函数。但 HAL 库默认没有把 IDLE 中断单独封装成一个__Weak回调函数,所以我们需要在主循环启动 DMA 接收,然后在中断处理中自己判断 IDLE 标志位。
第一步,启动 DMA 接收。一般在初始化完成后或者主循环开始前调用一次:
HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);这行代码的意思很明确:让 DMA 把串口 1 收到的数据搬运到rx_buffer,一共搬运RX_BUFFER_SIZE个字节。搬运完成后会触发 DMA 传输完成中断。
第二步,修改中断处理。找到stm32f1xx_it.c(不同芯片系列文件名略有不同)中的USART1_IRQHandler函数,改成这样:
void USART1_IRQHandler(void) { // HAL库统一中断处理,处理接收、发送、错误等标志 HAL_UART_IRQHandler(&huart1); // 自定义IDLE中断处理 if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { // 清除IDLE标志位,注意顺序 __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 处理接收到的数据 UART_IDLE_Handler(&huart1); } }第三步,实现UART_IDLE_Handler,这是整个方案的核心:
static uint16_t rx_len = 0; // 当前接收长度 static uint8_t rx_complete_flag = 0; // 帧接收完成标志 void UART_IDLE_Handler(UART_HandleTypeDef *huart) { uint16_t temp_len = 0; if (huart == &huart1) { // 关键:DMA当前剩余计数器的值,用总长度减去它,得到已经接收的字节数 temp_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (temp_len > 0) { rx_len = temp_len; rx_complete_flag = 1; // 在这里可以给协议处理函数发信号,或者在主循环轮询标志位 } // 重新启动DMA接收,准备接收下一帧 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } }先别急着复制粘贴,这里有几个细节值得好好琢磨。
第一个细节是__HAL_DMA_GET_COUNTER(&hdma_usart1_rx)。这个宏读取 DMA 内部寄存器中的剩余字节数。DMA 刚开始搬运时,计数器的值是RX_BUFFER_SIZE,每搬一个字节减 1。当 IDLE 中断触发时,计数器的值就代表“还有多少字节没搬”,用总长度减去当前值,得到的就是“已经收到了多少字节”。这个计算很巧妙,也是整个方案里最容易出错的点。
另外一个容易埋雷的地方是:__HAL_DMA_GET_COUNTER的参数必须是hdma_usart1_rx,也就是 USART1 的接收 DMA 通道句柄。这个名字是 CubeMX 根据外设和 DMA 配置自动生成的,如果你用的不是 USART1,名称会不一样,需要根据实际工程修改。
第三个细节是重新启动 DMA 接收的时机。我在中断里直接调用了HAL_UART_Receive_DMA。这里有个小问题:如果上一帧数据还没来得及处理,DMA 又开始往同一个缓冲区写数据,就会把上一帧的数据覆盖掉。所以比较稳妥的做法是:在rx_complete_flag置 1 之后,不要立即重启 DMA,而是在主循环处理完数据之后再重新启动。
不过我在实际项目中的做法略有不同:我用了一个双缓冲区。第一个缓冲区在收数据,第二个缓冲区在让主循环解析,两个缓冲区交替使用。DMA 重启直接做,不等待,因为即使下一帧数据来得很快,它也只会覆盖当前缓冲区,不会干扰主循环正在解析的另一块数据区。这个技巧在高速通信场景下特别有用。
第四步,在主循环里轮询rx_complete_flag,解析数据:
while (1) { if (rx_complete_flag) { rx_complete_flag = 0; // 处理 rx_buffer 中的 rx_len 字节数据 Process_Protocol(rx_buffer, rx_len); } // 其他任务 }这套代码整体跑起来,实测在 115200 波特率下接收 30 字节的帧,CPU 占用几乎可以忽略不计,处理 1000 帧耗时也很快。把 IDLE 中断和 DMA 配合好之后,你基本感知不到串口在后台工作,这才是嵌入式软件开发中“用硬件解决实时性问题”的典型思路。
注意一个细节:
UART_FLAG_IDLE和__HAL_UART_CLEAR_IDLEFLAG这两个宏在 F1/F4/H7 系列上行为一致,但在部分低端系列(比如 G0 系列)上 IDLE 标志位处理方式有差异。如果你换了新系列的芯片,建议先查一下参考手册里面 IDLE 位清零的方式,再复现这套代码。
3. 超时管理:不依赖IDLE的稳定备选方案
3.1 超时管理的设计思路
IDLE 中断很香,但有个前提:你的 MCU 支持这个中断,而且你手动改动了它默认的中断处理逻辑。对于某些没有 IDLE 中断的芯片,或者你不想改动 HAL 库中断处理函数的场景,“超时管理”就是另一个很好的备选方案。
超时管理的思路特别朴素:DMA 一直处于接收状态,每收到一个字节,就更新一个“最后接收时间戳”。主循环(或者定时器中断)定期检查当前时间与“最后接收时间戳”的差值。如果这个差值超过了预设的阈值,就认为一帧数据已经结束了,可以取出缓冲区里的数据去解析。
来打个比方:你在公司前台等快递,快递员每放一个包裹,你就抬头看一眼表。过了一段时间你没发现新包裹,心想“估计这一批送完了”,于是去把包裹统一搬回来。超时管理就是这个逻辑,它靠“时间沉默期”来判定结束。
这个方案的优势很明显:代码逻辑直白、移植性强,不依赖芯片特有的 IDLE 中断。缺点是实时性稍微差一点。假设你设置了 5ms 超时,那么从最后一个字节进入到主循环真正取走数据,最坏情况下有 5ms 延迟。如果协议对响应时间要求不苛刻,这个延迟完全在可接受范围内。
3.2 基于时间戳的主循环超时检测
下面这个是超时管理方案中最简单、也最实用的版本:主循环加时间戳判断。
初始化部分和 IDLE 中断版本一样,直接启动 DMA 接收:
HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);在 DMA 接收完成回调中记录时间戳。注意,DMA 回调函数是每收满RX_BUFFER_SIZE字节才触发一次的,但我们希望收到任意字节都能更新时间戳,怎么办?这就需要配置成 DMA 半满中断吗?不一定。这里有一个更优雅的做法:利用 DMA 的循环模式和 HAL 库的回调机制来模拟逐包时间戳。
我的做法是:把 DMA 模式设置为Circular(循环模式),缓冲区大小仍然设为RX_BUFFER_SIZE。然后利用 USART 的任意字节中断特性?不对,如果要任意字节都触发中断,那还是逐个字节中断的老路,没有意义。
实际上,用纯超时管理方案时,最常用的时间戳更新时机有两个:一个是 DMA 传输完成回调(每收满缓冲区触发一次),另一个是串口错误回调(比如溢出错误,但这不是常态)。如果使用 Circular 模式,缓冲区连满了也不回调,那时间戳就不更新了,超时判断就会失效。
所以,回到最简单的方案:如果你用Circular模式,主循环里读取 DMA 当前计数器的值,如果发现计数器的值发生了变化,说明收到了新数据,就更新时间戳。这完全在主循环里完成,不需要中断参与,非常适合“协议简单、数据量不极端”的场景。
代码长这样:
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; uint16_t last_len = 0; uint32_t last_rx_time = 0; uint16_t rx_len = 0; int rx_complete_flag = 0; // 在初始化时启动DMA循环接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); while (1) { // 获取当前DMA剩余计数,计算已接收长度 uint16_t current_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (current_len != last_len) { // 有新数据进来,更新时间戳 last_len = current_len; last_rx_time = HAL_GetTick(); } else if (current_len > 0) { // 没有新数据,判断是否超时 if ((HAL_GetTick() - last_rx_time) > RX_TIMEOUT_MS) { rx_len = current_len; rx_complete_flag = 1; // 处理完数据后,必须重置last_len和计数器 last_len = 0; // 这里要重启DMA? Circular模式下不需要,但需要把current_len归零 // 办法是暂停DMA再重新启动,或者用DMA的半满中断配合 } } if (rx_complete_flag) { rx_complete_flag = 0; Process_Protocol(rx_buffer, rx_len); // 清标志,为下一帧准备 HAL_UART_DMAStop(&huart1); memset(rx_buffer, 0, RX_BUFFER_SIZE); HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); last_len = 0; } }这里你会发现有个别扭的地方:我用的是 Circular 模式,但处理完一帧后为什么不直接接着等下一帧,却要HAL_UART_DMAStop再重启?
这是为了把 DMA 的当前计数器值归零,同时保证current_len从 0 开始重新计算。否则你处理完一帧后,DMA 计数器继续往后走,新一帧的长度计算会把旧数据也算进去,长度就混乱了。
如果你嫌这样的思路过于绕,那我还是推荐用 Normal 模式加 IDLE 中断,或者干脆用下面的“定时器超时”版本。超时管理方案在实现上不要追求花哨,简单可靠才是王道。
3.3 中断里做超时管理的进阶用法
如果主循环太忙,轮询current_len != last_len的间隔不稳定,超时判断精度会受影响。更可靠的做法是把超时检测放到定时器中断里做,保证检测周期恒定。
我的做法是:开启一个 1ms 的定时器中断(用硬件定时器,比如 TIM2),在中断里做超时判断。DMA 收到新数据时,通过HAL_UART_RxCpltCallback(每收满缓冲区触发)或者自定义的“任意数据到达”机制来更新时间戳,中断里检查时间差。
但这里有个不好的消息:HAL 库默认没有提供“串口任意字节到达”的回调接口,HAL_UART_RxCpltCallback只在 DMA 接收完成(缓冲区满)后触发。如果你希望“每收到任意字节都能更新时间戳”,光靠 HAL 库默认机制是不行的。
实战中,我处理这个问题有一个取巧的办法:给 USART 开启HAL_UART_Receive_IT接收一个字节,在这个字节的中断回调里更新时间戳、重新调用HAL_UART_Receive_IT继续接收下一个字节;同时再开一路 DMA 把数据搬进缓冲区。这样既不用每字节都手动搬数据(DMA 负责搬),又能在每字节到达时轻量更新一下时间戳(一个字节中断的开销远小于逐字节手工搬数据)。
具体做法:
// 初始化时,先启动DMA接收,再启动单字节IT接收 HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); HAL_UART_Receive_IT(&huart1, &temp_byte, 1); void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { // 说明临时接收的1个字节到达 if (temp_byte == 0xAA) // 这里随意,一般我们把timestamp放这里 { } // 更新时间戳 last_rx_time = HAL_GetTick(); last_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 继续接收下一个字节 HAL_UART_Receive_IT(&huart1, &temp_byte, 1); } }等等,这样是不是反而把事情搞复杂了?每字节中断一次,不是又回到老路上了吗?
其实不是。这里单字节 IT 的作用不是搬运数据,只是纯粹用来更新时间戳,因为 DMA 才是真正搬运数据的渠道。每字节进一次中断,但中断里只是last_rx_time = HAL_GetTick(),这个开销很小。再加上 USART 空闲时不会进中断,低频率下 CPU 开销几乎为零;高频率下,中断频率最多也就是每字节一次——和逐字节搬数据相比,少了很多数组写入、标志判断的操作,还是省了不少 CPU。
那还有没有更省事的?有。很多项目里,协议本身是有固定帧头或固定帧尾的。比如协议规定帧头是0xAA 0x55,那么你可以在 DMA 接收回调里检查缓冲区最后一个字节是不是预期的帧尾,是就认为一帧结束。这种方法在工业控制里经常配合超时管理一起用,叫“帧尾判定法”。但它对协议格式有要求,不是所有场景都适用。
超时管理中,超时阈值的设置也很讲究。我的经验是:阈值至少是“发送一个完整字节所需时间”的 3 到 5 倍,同时小于协议帧与帧之间的最小间隔时间。波特率 9600 时,一个字节约 1.04ms,超时阈值可以取 10ms 左右;波特率 115200 时,一个字节约 86.8μs,超时阈值建议取 2 到 5ms。如果上位机发数据的时候存在分包行为(比如用 TCP 转串口模块,网络数据包可能把逻辑上的一帧拆成多片到达),阈值就要适当加大,不然会把完整帧剁成好几截。
4. 两种方案的对比与选型建议
4.1 关键差异对照表
写到这里,把两种方案放一起对比,优缺点就非常清楚了。我整理了一张表,方便你直接参考:
| 对比维度 | IDLE 中断方案 | 超时管理方案 |
|---|---|---|
| 帧结束判定方式 | 硬件检测到总线空闲触发中断 | 软件检测到字节间时间间隔超阈值 |
| 实时性 | 高,空闲后立即触发 | 一般,有超时时间延迟 |
| CPU 占用 | 极低,仅在帧结束时进中断 | 低到中等,取决于检测方式 |
| 代码实现复杂度 | 中等,需修改中断函数 | 低,主循环轮询即可 |
| 芯片依赖 | 依赖 USART 外设具备 IDLE 中断 | 无特殊依赖,任何带 DMA 的 MCU 可用 |
| 典型应用 | 高速数据采集、实时通信 | 传感器数据上报、低频命令控制 |
从表里可以看出来,如果芯片支持并且你不排斥改中断函数,IDLE 中断是性能和实时性的首选。超时管理方案的优势在于可移植性强,代码规则简单,不容易出“玄学问题”。
4.2 什么场景用哪种方案
我在实际项目里的选型经验是这样的。如果你的产品使用的是 STM32F1/F4/H7 这类主流系列,协议是标准的帧头+数据+校验+帧尾格式,上位机下发频率不高(几十赫兹以内),优先用 IDLE 中断方案。它一收完就通知你,响应快,不会丢数据。
如果你的设备是电池供电、主控是一个小资源芯片(比如 STM32G0 系列的部分型号,或者国产替代型号),或者你写代码时间紧迫、没有时间细调中断交互逻辑,超时管理方案更稳妥。它不需要深入处理那些莫名其妙的标志位清除和中断优先级问题,先把功能跑通再说。
还有一种混合用法,我见过的老工程师经常这么干:用 IDLE 中断快速识别帧结束,同时保留超时检测作为“兜底”。比如因为干扰或者 DMA 配置错误导致 IDLE 信号丢失,超时机制还能在 20ms 后强制认为一帧结束,避免数据永远卡在缓冲区里。这种双保险的做法,在工业设备上非常实用。
4.3 实际项目中我踩过的坑
这里说几个我真实踩过、后来排查了很久的坑,给各位提个醒。
第一个坑:清除 IDLE 标志位的顺序问题。我第一次写 IDLE 接收代码时,参考的是某篇老帖子,里面直接写USART1->SR; USART1->DR;来清除标志。后来换了 HAL 库,我用的是__HAL_UART_CLEAR_IDLEFLAG(&huart1),结果在 F4 系列上跑没问题,到了某国产替代芯片上就频繁触发多次 IDLE 中断。查了寄存器手册才发现,不同系列对 IDLE 位清零的方式不一样,有些是写 0,有些是顺序读。所以移植代码时,一定先看参考手册,再用寄存器级方式清零,不要偷懒直接套宏。
第二个坑:缓冲区大小设置过小导致溢出。有个项目最开始图省事把RX_BUFFER_SIZE设为 64,结果上位机偶尔会发一条 80 字节的日志帧。DMA 搬运满 64 个字节后触发了完成回调,但剩下的数据继续往缓冲区后面写,直接把后面的数组结构体全踩烂了。最后表现出来的现象非常诡异:串口数据时好时坏,程序偶发崩溃。排查了很久才发现是缓冲区越界。从那以后,我所有通信缓冲区的分配原则都是“宁多勿少”,且处理完一帧后立即memset。
第三个坑:DMA 重新启动的时机。IDLE 中断里直接调用HAL_UART_Receive_DMA重新启动,如果此时串口还有遗留数据,可能导致新一帧的首个字节丢失。我在批量生产的某个设备上就遇到过:上位机和设备通信时,连续发送两条命令间隔只有几个毫秒,设备经常漏掉第二条命令的第一个字节。我的解决办法是:收到一帧后,先把 DMA 停掉,清空缓冲区,再重新启动 DMA 接收。顺序不能反,先后顺序错了容易导致接收错位。
第四个坑:握手协议期间的状态复位。如果你的设备支持 AT 指令、命令响应这类交互,那么上位机发送一帧后,你的设备要回复响应,此时如果 DMA 正在接收状态,回复过程中又有新指令进来,会导致协议状态机混乱。建议在协议状态机切换时,显式调用HAL_UART_DMAStop暂停接收,处理好状态后再恢复,避免 DMA 一直咚咚咚地往缓冲区写。
这些坑在 STM32 社区里面讨论过很多次,但每次项目新人都要重新踩一遍。希望这篇文章能让你一次避开。
5. 扩展思路:IDLE中断与超时管理的融合实践
5.1 融合方案的原理与实现
前面把两种方案分开讲,但真正的量产项目里,我更喜欢把两者混合起来用。原因很简单:IDLE 中断在绝大多数情况下都能准时触发,但上位机如果用的是 USB 转串口芯片,发送数据时经常会把一帧拆分成多个 TCP/IP 分片,每个分片之间间隔几毫秒,总线会短暂空闲,IDLE 中断会提前触发。这时你取出来的数据其实只是半帧,后续分片还在路上。
怎么解决?方案就是在 IDLE 中断中不清除超时判断,而是额外增加一个短超时等待。具体做法是:IDLE 中断触发后,先不直接通知主循环“一帧完成”,而是记录当前时间和当前 DMA 计数。然后启动一个 5ms 的延迟确认定时器。在这 5ms 内如果没有新的 IDLE 中断触发,就认为整帧真的收完了;如果又有新数据到达(新的 IDLE 中断触发),就重新计时。
void UART_IDLE_Handler(UART_HandleTypeDef *huart) { // 更新接收长度,但不置完成标志 last_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 开启5ms延迟确认 start_frame_confirm_timer(5); } // 5ms到期后调用 void Frame_Confirm_Timeout(void) { // 检查期间是否更新了last_len,如果没更新,说明帧真正结束了 if (pending_len == last_len) { rx_len = last_len; rx_complete_flag = 1; } // 如果数据又来了,重新检查 }这个融合方案,既利用了 IDLE 中断的高实时性,又给多次分片预留了重新组合的时间窗口。代价是会增加一点处理延迟,但对绝大多数协议来说无所谓。
5.2 缓冲区管理与多帧处理技巧
再分享一个缓冲区管理经验。很多人在中断回调里直接把数据处理掉,但我建议只是置标志位,数据解析放到主循环。中断函数要短小精悍,这是嵌入式开发的常识,但在串口 DMA 场景下还有另一个原因:DMA 和主循环是同时操作同一块内存的。如果主循环正在解析数据,DMA 又在往缓冲区写新数据,就存在数据竞争。
最安全的实践就是双缓冲区交替使用。DMA 往 A 缓冲区写,主循环解析 B 缓冲区;解析完了,交换角色。这个做法不需要加锁,也不会因为中断优先级导致数据错乱,是目前我见过最稳妥的串口 DMA 多帧处理方案。
5.3 从调试到量产的检查清单
最后把从调试到量产过程中的关键检查点列一下,这些点每个我都付出过代价:
DMA 和串口中断在 NVIC 中的优先级要配好。建议串口中断优先级高于 DMA 中断,否则 DMA 接收到缓冲区满中断、串口 IDLE 中断同时到达时,优先级处理不当会导致事件丢失。
处理完一帧数据后,一定要重新启动 DMA 接收。忘了重启是最常见的低级错误。
如果使用超时管理,别忘了
HAL_GetTick()的精度。STM32F1 系列默认是 1ms 一个 tick,够用;如果追求更高精度,可以用硬件定时器微秒计数,避免在毫秒级误判。量产前一定要做大数据量压测,模拟上位机以最高速率连续发送不同长度的帧 24 小时以上。很多通信问题都是压测后才会暴露出来的,比如缓冲区溢出、超时阈值设置不当、DMA 计数器计算错误等等。
尽量把通信协议加上帧头、帧尾和校验。DMA 接收方案虽然方便,但它只负责物理层搬运数据,协议层的数据完整性还需要你自己保证。比如常见的做法是帧头固定为两个字节的特定值,校验用 CRC16,帧尾再固定一个字节。这样即使出现接收错位,协议层也能通过校验丢弃坏帧。
我个人在实际项目中的体会是,串口 DMA 不定长接收这套东西,看起来只是十几行代码的事,但牵扯到的硬件细节(IDLE 标志位、DMA 计数器、中断优先级)和软件设计(缓冲区管理、协议状态机)是整个嵌入式开发里比较综合的一块。把这套逻辑吃透了,你对 STM32 的整个中断系统和 DMA 工作机制的理解都会上一个台阶。如果你以前是用中断逐字节接收的老办法,不妨下个项目试着切换到 DMA 方案,跑起来之后你会明显感觉到主循环压力小了一大截,系统的实时响应能力也完全不一样了。