串口接收为什么会丢数据:从轮询到循环缓冲的改造思路
串口接收看似只是“收到字节再读出来”,实际工程里会遇到短命令、连续数据流、主循环忙碌、协议解析耗时不稳定等情况。接收路径设计不好,就容易出现丢字节、乱序、短包延迟和偶发错包。
串口接收是嵌入式系统的基本输入路径。本文记录一种从简单轮询接收到 DMA circular + IDLE + HT/TC + FIFO 的演进思路,重点是把硬件接收、DMA 搬运、中断触发、软件缓冲和用户层解析拆开,让每一层职责清楚、边界可测。
阶段1:轮询寄存器
【快递站类比】
最开始的快递站很原始。老板每隔几分钟跑到门口看一眼,有快递就自己拿进来,没有快递就回去继续干别的活。
这个办法在快递很少时能用。一旦老板在里面算账、接电话,门口来了好几车快递没人接,快递员等不住就走了,包裹也就丢了。
【技术解耦与实现】
对应到串口接收,轮询就是主循环反复检查接收标志,发现有数据后读取数据寄存器。它的优点是直观,适合 bring-up、低速调试和最小可用验证。
问题在于 CPU 的主循环不可能长期只服务串口。只要轮询间隔变长,外设接收寄存器就可能来不及被读取,后续字节进入时触发溢出。轮询阶段可以用回环测试做最小验证:PC 持续发送随机字节,设备原样回显,PC 端逐字节比较。如果低波特率回环测试都不稳定,先检查串口配置、引脚复用和基础读写路径。
阶段2:字节中断
【快递站类比】
后来快递站在门口装了门铃。快递员到了不用等老板巡逻,按一下门铃,老板出来签收。
这比老板反复跑门口省力很多。但到了双11,门铃一直响,老板每隔几秒就被叫出去一次,里面的分拣、记账、发货全都做不动。
【技术解耦与实现】
字节中断把“等待数据”的成本降下来。串口收到一个字节后触发中断,CPU 在中断服务函数里读出数据寄存器。
它的限制来自中断频率。波特率升高后,每个字节都触发一次中断,CPU 会被大量小中断压住。中断里只应该做最小动作:快速取走字节、放入轻量缓冲、退出。协议解析、打印、大块复制都不应该压到这个路径里。
这一阶段继续用回环测试压测,但要逐步提高波特率,并让主循环同时做轻量任务。通过标准不能靠肉眼看到有回显,必须看 PC 端校验:长度一致、内容一致、顺序一致、设备不中断运行。
阶段3:普通 DMA
【快递站类比】
接着,快递站在门口放了一个大箩筐。快递员不用按门铃,也不用等老板,直接把包裹放进箩筐。
箩筐满了,老板再一次性搬进去。这样老板不用每个包裹都跑一趟。但老板把箩筐搬进屋、倒空、再放回门口的这段时间,新来的快递又没地方放。
【技术解耦与实现】
普通 DMA 让外设把接收到的字节直接搬到内存。CPU 不再为每个字节进入中断,只需要等一块 DMA buffer 写满后再处理。
这个阶段的结构通常是一块固定 RX buffer:启动一次外设到内存的 DMA 接收,DMA 写满后触发完成中断,CPU 处理这段内存,然后重新启动下一次 DMA。它解决了“每字节中断”的问题,但带来了重启空档。DMA 完成后,如果 CPU 处理和重新启动接收不够快,中间进入的字节仍然可能丢失。
回环测试要覆盖随机长度数据块,尤其是短包、长包和接近 buffer 大小的数据块。这里常见问题有两个:只处理满 buffer,短包一直等不到处理;DMA 完成后重启太慢,连续输入时出现空档。
阶段4:双缓冲区
【快递站类比】
快递站又放了两个箩筐。A 箩筐满了,老板搬 A;快递员马上改往 B 箩筐里放包裹。B 满了,再切回 A。
这解决了“搬箩筐时门口没地方放”的问题。但如果快递员只放了两个包裹就走了,箩筐永远装不满,老板可能一直等着“满了再搬”,屋里的人也迟迟拿不到这两个包裹。
【技术解耦与实现】
双缓冲区把接收和处理拆开:DMA 当前写 buffer A,A 满后切到 buffer B,CPU 处理 A;B 满后再切回 A。这个模型对固定长度数据块很清楚,可以减少普通 DMA 重启期间的接收空档。
它仍然没有解决变长数据。串口经常是字节流,数据不一定刚好填满 buffer。一段命令发完后,如果 buffer 没满,系统也需要及时处理这段数据。另一方面,如果上层处理长期慢于输入速度,两块 buffer 最终都会被占住,后续数据仍会被覆盖或丢弃。
这一阶段的回环测试要覆盖两类输入:刚好填满 buffer 的连续块,用来验证 A/B 切换;随机长度短包,用来验证未填满 buffer 能否被及时提交。出现整块丢失、A/B 顺序颠倒或短包长时间不回显,都说明双缓冲状态管理还有问题。
阶段5:空闲中断(IDLE)
【快递站类比】
快递站又装了一个红外线感应器。快递员往箩筐里放完东西,走了,门口安静下来,感应器就通知老板。
这样不管箩筐有没有满,只要快递员这一波已经放完,老板就能马上把已有包裹搬进去,不用傻等箩筐装满。
【技术解耦与实现】
IDLE 中断用于识别串口线上出现空闲。对变长数据来说,它提供了一个很重要的处理时机:这段输入暂时结束了,可以把 DMA 工作区里已经收到的数据搬走。
它不负责解析协议,也不判断这一段是否完整业务帧。它只解决一个底层问题:DMA buffer 里有一段新增字节,现在可以提交给后面的软件缓冲。实现上,驱动通过 DMA 当前写入位置和上次已处理位置,计算新增范围,再把这段数据搬到后续队列。
实现细节: 常见 HAL 写法会用 DMA 剩余计数换算写指针,例如
buffer_size - dma_remaining_count。这个代码只是在定位写位置,核心设计仍然是“IDLE 触发搬运,后续缓冲承接数据”。
IDLE 的限制也很明确:它依赖“空闲”。如果上位机连续发送,没有足够长的停顿,IDLE 就不会及时触发。持续流输入需要新的搬运节点。
阶段6:循环 DMA + HT/TC:兼顾连续流和短包
【快递站类比】
最后,快递站把两个箩筐换成了一条循环传送带。快递员不停往传送带上放包裹,传送带走到一半,系统提醒老板搬一段;走到末尾,再提醒老板搬一段。
如果快递员放一会儿就走,红外线感应器也会提醒老板来搬。这样一来,快递断断续续也能及时处理,快递连续不断也能按传送带位置分段搬走,传送带不需要停下来换箩筐。
【技术解耦与实现】
循环 DMA 让 DMA 写到缓冲区末尾后自动回到开头继续写。它省掉了显式 A/B 双缓冲状态,也减少了普通 DMA 反复重启带来的空档。
仅靠 IDLE 还不够,因为持续流没有空闲。因此循环 DMA 通常配合两个固定节点:半传输中断 HT 和传输完成中断 TC。HT 表示 DMA 写到缓冲区一半,TC 表示写到末尾。IDLE、HT、TC 都进入同一个搬运逻辑:计算从上次位置到当前位置之间新增了哪些字节,把它们交给后面的软件层。
实现细节: 在 HAL 工程里,通常会启用
DMA_IT_HT和DMA_IT_TC,再让 DMA IRQ 和 USART IDLE IRQ 走同一个“计算新增范围并搬运”的函数。函数名不重要,职责必须统一。
HT/TC 等价于把一段循环内存切成稳定观察点。物理上只有一块环形 DMA buffer,逻辑上获得了类似乒乓缓冲的效果:前半段写满搬一次,后半段写满再搬一次。配合 IDLE 后,短包、变长包和连续流都能触发搬运。
这一阶段最需要连续随机流压测。PC 端不要一帧一停,要持续发送随机数据,让串口线上尽量少出现空闲。通过标准是回显序列完全一致,正常容量内丢弃计数不增长,超过设计能力时必须能看到明确的溢出统计。
阶段7:软件 FIFO:解耦接收和解析
【快递站类比】
老板把传送带上的包裹搬进来后,没有马上逐个拆包处理。他把包裹先放进后面的大仓库。
业务部门有空了再去仓库慢慢分拣。哪怕业务部门临时去开会,前面快递站也可以先把包裹囤住,不会因为后面处理慢就让门口丢件。
【技术解耦与实现】
FIFO 的职责是把实时接收和业务处理解耦。DMA buffer 是硬件接收工作区,容量小,且持续被 DMA 写;FIFO 是软件队列,保存已经脱离 DMA 工作区、等待上层消费的字节流。
这条边界很重要:
- DMA buffer 负责临时接住硬件输入。
- 中断处理负责按 IDLE、HT、TC 节点把新增字节搬走。
- FIFO 负责保存有序字节流,并吸收短时间突发。
- 用户层负责协议解析、校验、组包、丢帧恢复等语义。
驱动层不应该解析协议,不应该替用户清理业务缓存,也不应该假装 FIFO 永远够用。FIFO 满时必须累计丢弃字节数和队列溢出次数。静默覆盖是灾难:上层只会看到偶发错包、乱序或解析失败,真正的压力来源消失了。溢出计数把“用户消费速度跟不上”暴露出来,这是防御性编程的一部分。
回环测试在这一阶段要验证保序和可观测性。PC 端持续发送随机流,设备从 FIFO 取一小段再回显。只要容量没有越界,回显就应该长度一致、内容一致、顺序一致。容量越界时,可以丢,但必须有溢出计数,测试报告也要明确标记为压力超过设计范围。
阶段8:直接读取循环缓冲:省内存但边界更紧
【快递站类比】
如果快递站租金很贵,老板可能想把后面的大仓库拆掉。这个时候不能只拆仓库,还要把门口的循环传送带加长,让它本身能临时堆更多包裹。
这样一来,传送带既负责接快递,也兼任临时仓库。业务部门不再去仓库拿包裹,直接从传送带上拿。省掉了“老板把包裹从传送带搬到仓库”这一步。
这样省地方,但要求业务部门动作很快,还要时刻知道自己拿到哪里了。一旦拿慢了,传送带转一圈,新的包裹就会盖掉旧包裹,之前没拿走的东西就丢了。
【技术解耦与实现】
走到循环 DMA 后,还可以取消独立软件 RX FIFO,但前提是把循环 DMA buffer 配得足够大。循环 DMA buffer 本身就是环形内存:DMA 写指针持续前进,走到末尾再回到开头。只要驱动再维护一个软件读指针,就可以把这段 DMA buffer 直接封装成 FIFO。
这个设计少一次从 DMA buffer 到外部 FIFO 的搬运,也省掉一块独立 FIFO RAM,适合资源很紧、接收处理足够快、接口可以统一封装的工程。代价是 DMA buffer 同时承担两个职责:硬件接收工作区和用户可读队列。读写边界必须清楚。
最低要求包括:
- DMA buffer 要按最坏消费延迟加长,不能沿用很小的临时接收缓冲。
- 可读长度必须由 DMA 写位置和软件读位置计算。
- 读取函数必须处理回绕,不能假设数据连续。
- 写位置追上读位置时,必须判定覆盖风险。
- 溢出统计必须保留,不能把覆盖伪装成随机错字节。
外部 FIFO 和加长 DMA buffer 后直接读取没有绝对高低。外部 FIFO 的职责边界更清楚,可观测性更好;DMA 直读更省搬运和 RAM,但读写指针、覆盖判断、缓冲区长度估算和压力测试都要更严谨。
发送侧延伸:TX FIFO
【快递站类比】
收快递解决后,发快递也会遇到类似问题。业务部门都想把包裹交给前台寄出,但门口只有一辆车,一次只能发一批。
更稳的做法是把待发包裹先排进出库队列。车空了就装下一批,装完再发下一批。业务部门只负责交货,前台负责按顺序发出。
【技术解耦与实现】
发送侧通常会有 busy 标志,并在发送完成回调里清除。简单回显可以直接判断空闲后启动 DMA 发送,但多个模块同时打印或发送协议帧时,调用方会被迫关心“现在能不能发”。
TX FIFO 可以把发送入口统一起来:外部模块只写入待发送数据,串口发送驱动在空闲时从 TX FIFO 取一段启动 DMA,发送完成中断清 busy 并继续取下一段。这样发送侧也形成同样边界:用户只负责投递,驱动负责按串口单通道特性串行发出。
回环测试可以同时验证 RX FIFO 和 TX FIFO。PC 端看到的仍然应该是原始随机流,顺序不能乱,内容不能变,两个 DMA 通道的状态不能互相破坏。
回环测试与可观测性
每一层优化都应该接受同一个外部判据:PC 端持续发送随机数据,设备原样回显,PC 端负责校验。这样测试目标不会随着实现变化被稀释。
推荐测试分成几组:
- 固定长度短包:验证基础收发和中断入口。
- 随机长度包:覆盖变长数据和 DMA 批量搬运。
- 连续随机流:减少空闲时间,验证 HT/TC 能否补上单纯 IDLE 的缺口。
- 逐步提高波特率:观察系统从稳定到接近极限的过程。
- 加入主循环负载:验证用户处理变慢时 FIFO 是否能吸收短时间突发。
通过标准要明确:
- PC 端发送字节数和回显字节数一致。
- 回显内容和发送内容逐字节一致。
- 字节顺序不乱,不重复,不跳号。
- 设备不中断运行,发送完成回调能持续推进。
- 正常压力下队列溢出计数不增长。
- 超过设计容量时,溢出计数增长,测试报告明确标记为压力越界。
随机流压测会打破“刚好按包发送”的理想条件。真实系统里,上位机可能连续发送,也可能短包密集出现,还可能在任意位置停顿。随机长度、随机内容、逐步提高波特率,能更早暴露边界错误。
适用边界
DMA circular + IDLE + HT/TC + FIFO 适合做通用串口接收底座,特别适合变长数据、突发输入和上层处理时间不稳定的场景。
它仍有容量上限:
- FIFO 满时仍然会丢数据,工程需要统计并暴露这个事件。
- 驱动层只搬运字节,不替协议层判断帧边界和恢复策略。
- DMA 缓冲区太小会增加 HT/TC 中断频率;如果取消独立软件 FIFO,DMA 缓冲区还要承担仓库职责,长度必须按最坏消费延迟估算。
- 用户层长期消费不足时,任何缓冲都会被填满,最终必须靠流控、限速或协议重试解决。
这套设计包含 DMA 搬运,也包含清楚的接收链路分层:硬件用 DMA 快速落内存,中断用 IDLE/HT/TC 找搬运时机,FIFO 保存已经脱离 DMA 工作区的数据,用户层按自己的协议节奏处理。
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。