公司动态

串口FIFO缓存详解:从硬件缓冲到软件队列的防丢数据方案

📅 2026/9/1 18:31:53
串口FIFO缓存详解:从硬件缓冲到软件队列的防丢数据方案
实际开发串口通信时最常见的故障不是硬件接错而是数据批次到达后程序来不及收。串口FIFO缓存正是解决这个矛盾的关键结构。很多开发者第一次遇到丢数据时习惯性地把缓冲区改大却发现数据照样丢真正的问题往往出在对FIFO工作方式、触发时机和中断处理路径的理解上。这篇文章围绕串口FIFO缓存从硬件缓冲到软件环形队列理清完整链路并给出可落地的防丢数据方案。先说明一个基本结论串口丢数据通常不是某一个原因造成的而是“接收路径上的某个环节超过处理能力”造成的。如果不先定位瓶颈直接加大缓冲区可能只是把问题往后推甚至会掩盖真正需要修改的配置。下面从串口接收的完整链路开始分析。1. 串口丢数据问题到底出在哪个环节1.1 串口接收路径上的关键节点从外设引脚到应用程序拿到数据一条典型的串口接收链路包括以下节点UART 外设的接收移位寄存器。UART 外设内部的接收数据寄存器 RDR。UART 硬件 FIFO如果该 MCU 支持。DMA 控制器如果使用 DMA 接收。串口中断服务函数或 DMA 中断服务函数。用户程序中的软件缓冲区环形队列或线性数组。任何一个节点处理不及时数据就可能覆盖或丢失。尤其要注意第三步和第六步的区别硬件 FIFO 是外设内部缓冲区软件缓冲区是开发者自己在内存中维护的缓冲结构。两者都叫 FIFO但职责不同。1.2 丢数据的典型场景常见丢数据场景有以下几种高频连续数据设备以 1ms 甚至更短周期持续发送 100 字节以上的数据包CPU 在一个字节一个字节地处理中断来不及收完下一个字节。中断阻塞系统中存在关闭中断或长时间占用 CPU 的临界区串口接收中断得不到及时响应UART 硬件接收寄存器被新数据覆盖。缓冲区溢出软件缓冲区设计得过小业务代码读取速度低于接收速度新数据覆盖旧数据。DMA 配置不当DMA 接收完成中断没有及时复位计数器或空闲中断处理时长度计算错误导致一帧数据错位。1.3 为什么“单独加大缓冲区”不一定能解决问题许多人遇到丢数据后第一反应是把环形缓冲区从 256 字节改成 4096 字节。如果丢数据发生在“业务代码读取得慢”的环节加大缓冲区确实可以缓解。但如果丢数据发生在“中断响应不及时”的环节加大软件缓冲区没有意义因为数据可能根本没有进入缓冲区就被硬件覆盖了。这也是必须引入 FIFO 缓存并正确配置它的原因硬件 FIFO 可以降低中断频率给 CPU 更多响应时间软件 FIFO 可以解耦中断接收与业务处理的速度差。两者配合才能形成一条“不容易丢数据”的接收路径。2. 先搞懂串口 FIFO 的两层含义硬件 FIFO 与软件 FIFO2.1 硬件 FIFOUART 外设内部的缓冲硬件 FIFO 是 UART 外设内部的一小段 RAM用于暂存接收或发送的数据。在没有硬件 FIFO 的单片机上每收到一个字节硬件会触发一次接收中断CPU 必须在下一个字节到达前把数据读走。如果波特率是 115200一位数据约 8.68 微秒一字节约 86.8 微秒如果波特率提高到 921600一字节只有约 10.85 微秒。一旦中断响应超过这个时间数据就会丢。硬件 FIFO 的作用是允许数据先暂存在外设里等累积到一定数量再触发中断。例如一个 16 字节深度的接收 FIFO可以配置为接收 8 字节触发中断这样中断频率降低到原来的 1/8CPU 只需要每 8 字节处理一次。对于高速接收场景这个缓冲能力非常关键。需要说明的是不同芯片的 UART 硬件 FIFO 深度并不相同。常见深度有 8 字节、16 字节、32 字节也有芯片不提供硬件 FIFO。在开发前一定要查阅芯片参考手册中的 UART 章节确认是否存在 FIFO、是否可以配置触发阈值。2.2 软件 FIFO开发者自己实现的环形缓冲区软件 FIFO 的本质是一个环形缓冲区它由一段内存数组和两个读写索引组成。写入索引负责记录数据写入位置读取索引负责记录数据读取位置。当写入索引追上读取索引时缓冲区满当读取索引追上写入索引时缓冲区空。软件 FIFO 解决的是中断接收与业务处理之间的速度差。中断服务函数只负责把数据写入环形缓冲区立刻退出主循环或任务系统则根据自己的节奏从缓冲区读取数据并解析。这样即使业务处理偶尔耗时较长数据也已经保存在内存中不会被下一个中断覆盖。环形缓冲区之所以叫 FIFO是因为它按照先进先出的顺序读写。实现时需要注意两个核心问题如何在写入和读取之间保证原子性避免索引被中断打断。如何判断缓冲区满和空防止数据覆盖或读取到无效数据。2.3 硬件 FIFO 与软件 FIFO 如何协同一条完整接收路径通常是这样的数据到达 UART 硬件。数据先进入硬件 FIFO。当硬件 FIFO 中的数据达到触发阈值触发接收中断或 DMA 请求。中断服务函数中的代码从硬件 FIFO 中读取一个或多个字节。读取到的数据写入软件 FIFO环形缓冲区。主循环或业务任务从软件 FIFO 读取并处理数据。如果使用 DMA则第 4 步由 DMA 控制器自动完成数据从硬件 FIFO 直接搬移到内存中的缓冲区CPU 不需要逐字节读取。DMA 搬运完成后产生“传输完成”或“半传输”中断此时 CPU 在中断中将整块数据转存到软件 FIFO。这里有一个容易混淆的地方DMA 搬运的内存缓冲区本身也可以看作一个 FIFO但它通常是一段固定大小的线性数组并不依赖索引。工程上既可以把它和软件环形缓冲区组合也可以直接解析这段数组中的数据。2.4 与异步 FIFO、FWFT 等概念的简单区分热词中会出现“异步 FIFO”“FIFO 以太网是 FWFT 吗”等内容。这些是数字逻辑设计中的概念与串口驱动中的 FIFO 属于不同层次。异步 FIFO通常指跨时钟域时使用的 FIFO 电路用于在两个不同时钟域之间安全传递数据。FWFTFirst Word Fall Through指第一个写入的数据可以立即出现在读侧不需要额外读请求。串口 FIFO属于外设内部缓冲或软件缓冲解决的是串口数据接收和 CPU 处理之间的速率匹配问题。对串口开发来说只要理解硬件 FIFO、软件 FIFO 以及它们在接收链路中的位置就够了。不必把 FPGA 设计中的异步 FIFO 概念混入进来。3. 从最小实现入手用一个环形缓冲区接收不定长串口数据3.1 设计数据结构与参数先写一个不依赖具体平台的通用环形缓冲区实现用 C 语言描述。该实现使用“留空一格”方式判断满/空即有效数据最多为BUFFER_SIZE - 1字节。#define RX_BUFFER_SIZE 256 typedef struct { uint8_t buffer[RX_BUFFER_SIZE]; volatile uint16_t head; // 写入索引 volatile uint16_t tail; // 读取索引 } ring_buffer_t; ring_buffer_t g_rx_ring;head指向下一个可写入的位置tail指向下一个可读取的位置。两个字段都标记为volatile因为它们在中断和主循环中都会被访问防止编译器把变量优化到寄存器中导致读写不一致。初始化代码void ring_buffer_init(ring_buffer_t *rb) { rb-head 0; rb-tail 0; }3.2 写入与读取操作写入操作需要判断缓冲区是否已满uint8_t ring_buffer_is_full(ring_buffer_t *rb) { return (uint16_t)(rb-head 1) % RX_BUFFER_SIZE rb-tail; } uint8_t ring_buffer_is_empty(ring_buffer_t *rb) { return rb-head rb-tail; } uint8_t ring_buffer_write(ring_buffer_t *rb, uint8_t byte) { if (ring_buffer_is_full(rb)) { return 0; // 写入失败缓冲区满 } rb-buffer[rb-head] byte; rb-head (rb-head 1) % RX_BUFFER_SIZE; return 1; } uint8_t ring_buffer_read(ring_buffer_t *rb, uint8_t *byte) { if (ring_buffer_is_empty(rb)) { return 0; // 读取失败缓冲区空 } *byte rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RX_BUFFER_SIZE; return 1; }这里使用%取模操作简单直观但在 8 位单片机上取模开销较大。如果缓冲区大小是 2 的幂可以把取模替换为按位与操作。例如当RX_BUFFER_SIZE 256时(head 1) 0xFF等价于(head 1) % 256。生产代码建议使用位运算优化同时要求缓冲区大小必须是 2 的幂。3.3 在串口中断中写入缓冲区以常见 MCU 的单字节接收中断为例伪代码如下void uart_rx_irq_handler(void) { uint8_t byte; // 读取硬件寄存器获取一字节数据 byte UART_RX_REG; // 写入软件环形缓冲区 if (ring_buffer_write(g_rx_ring, byte) 0) { // 缓冲区满可以在这里统计溢出次数供调试使用 g_rx_overflow_count; } }在 STM32 HAL 库中单字节中断接收的回调通常是这样的uint8_t g_rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { ring_buffer_write(g_rx_ring, g_rx_byte); HAL_UART_Receive_IT(huart, g_rx_byte, 1); } }启动接收HAL_UART_Receive_IT(huart1, g_rx_byte, 1);这种方式虽然简单但每个字节都会进入中断。波特率越高中断越频繁系统整体开销越大。对于 115200 波特率86 微秒一个字节如果中断服务函数耗时很短通常还能接受如果主循环中还有其他实时任务就需要考虑开启硬件 FIFO 或使用 DMA。3.4 最小实现的注意事项在这个最小实现里最容易踩的坑有三个缓冲区满后丢弃数据没有提示。建议增加一个溢出计数器方便在调试时确认是否发生过覆盖。在中断中和主循环同时访问head、tail需要注意访问顺序。写入时只修改head在主循环读取时先读取head到临时变量再读取数据避免出现一边写一边读的竞态。缓冲区大小与单包数据长度不匹配。如果一包数据 300 字节缓冲区只有 256 字节那么缓冲区一定会满。缓冲区大小至少要大于最大单包长度并预留余量。4. 进阶方案启用硬件 FIFO 加 DMA 加 IDLE 空闲中断彻底降低丢数据概率4.1 纯中断方式在高波特率下为什么容易丢数据单字节中断接收模式下每来一个字节CPU 都要进入中断、保存现场、读取数据、写环形缓冲区、恢复现场。这个过程哪怕只有 20 微秒也可能在 460800 波特率下超过字节间隔。这时候数据丢失是必然的。解决方案的总体思路是让 CPU 从“每字节中断”中解放出来开启 UART 硬件 FIFO让数据先在外设内缓存。使用 DMA 将硬件 FIFO 中的数据自动搬运到内存缓冲区。使用 UART 空闲中断IDLE判断一帧数据结束再统一处理整块数据。4.2 DMA 接收与 IDLE 中断的配合方式DMA 接收的原理UART 每收到一个字节DMA 控制器自动从数据寄存器读取并写入指定的内存地址。CPU 不需要参与每个字节的搬运。等收到一帧数据后总线上出现空闲状态UART 产生 IDLE 中断CPU 在 IDLE 中断中读取 DMA 当前剩余计数值计算出本次实际接收了多少字节然后处理数据。这种方式的优势是中断频率从每字节一次降为每帧一次。大量数据搬运由 DMA 完成CPU 可以去做其他事情。配合双缓冲可以做到“接收和处理并行”。需要注意不同芯片厂商对 IDLE 中断的实现方式不同。STM32 中__HAL_UART_GET_FLAG(huart, UART_FLAG_IDLE)用于检测 IDLE 标志__HAL_UART_CLEAR_IDLEFLAG用于清除标志。具体操作必须结合芯片参考手册。4.3 基于 HAL 库的配置步骤与代码骨架下面代码以 STM32 HAL 库为例说明一种常见的 DMA 接收不定长数据思路。实际项目里要根据芯片型号、DMA 通道、数据长度和波特率调整。首先是 UART 和 DMA 初始化。在 CubeMX 中将 UART 设置为 DMA 接收模式接收数据长度设为最大预期长度例如 1024#define RX_DMA_BUFFER_SIZE 1024 uint8_t g_rx_dma_buffer[RX_DMA_BUFFER_SIZE];启动 DMA 接收HAL_UART_Receive_DMA(huart1, g_rx_dma_buffer, RX_DMA_BUFFER_SIZE);在 UART 的 IDLE 中断回调中处理数据void HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 清除 IDLE 标志 __HAL_UART_CLEAR_IDLEFLAG(huart); // 当前 DMA 剩余未传输计数 uint16_t remaining __HAL_DMA_GET_COUNTER(huart-hdmarx); uint16_t rx_len RX_DMA_BUFFER_SIZE - remaining; if (rx_len 0) { // 将 DMA 缓冲区中的 rx_len 字节复制到软件环形缓冲区 ring_buffer_write_multi(g_rx_ring, g_rx_dma_buffer, rx_len); } // 重新启动 DMA 接收等待下一帧 HAL_UART_Receive_DMA(huart1, g_rx_dma_buffer, RX_DMA_BUFFER_SIZE); } }这里需要注意__HAL_DMA_GET_COUNTER读取的是 DMA 剩余未传输的数据个数不同芯片返回值可能不同有的返回剩余字节数有的返回剩余传输次数需要结合数据宽度确认。如果 DMA 配置为循环模式HAL_UART_Receive_DMA只需要启动一次如果使用普通模式一帧接收完成后要再次调用该函数否则后续数据不会继续搬移。ring_buffer_write_multi需要实现批量写入它内部循环调用单字节写入即可但要注意写入长度不能超过剩余空间。批量写入的简化实现uint16_t ring_buffer_write_multi(ring_buffer_t *rb, const uint8_t *src, uint16_t len) { uint16_t write_count 0; while (len 0 !ring_buffer_is_full(rb)) { rb-buffer[rb-head] *src; rb-head (rb-head 1) % RX_BUFFER_SIZE; src; len--; write_count; } return write_count; }4.4 双缓冲机制进一步降低处理阻塞带来的风险上面的代码在 IDLE 中断中把整块 DMA 数据复制到软件环形缓冲区后立即重启 DMA。如果复制过程比较长且这个时间段内又来了新数据可能会覆盖 DMA 缓冲区。解决方法是使用双缓冲。双缓冲的思想是准备两块 DMA 缓冲区。CPU 处理第一块数据时DMA 使用第二块缓冲接收新数据。处理完第一块后切换 DMA 缓冲区到第一块同时把第二块交给业务处理。这样可以做到接收和处理互不干扰。在 STM32 中可以通过 DMA 的半传输中断和传输完成中断来配合实现双缓冲也可以手动切换缓存地址。双缓冲会增加代码复杂度但对高波特率、大流量数据场景非常有效。如果项目只面对 115200 波特率且一帧数据不超过几百字节单缓冲加环形缓冲区通常已经足够。5. 验证丢数据是否真的解决测试方法与判断标准5.1 构造可靠测试环境想验证丢数据不能只靠“看打印日志是否完整”。建议按以下方式搭建测试环境一个 USB 转串口模块例如 CH340、CP2102、FT232。一个串口调试助手或者用 Python 脚本发送数据。目标板串口连接正确共地波特率一致。准备一段已知模式的测试帧帧中带帧号或固定特征。测试数据帧的结构可以自定义例如AA 55 帧序号高字节 帧序号低字节 负载数据... CRC16高字节 CRC16低字节帧序号从 0 递增到 65535再循环。接收端解析出帧序号后检查是否连续。如果发现跳号说明中间有数据丢失或错位。5.2 发送已知模式数据并检查是否丢帧可以用 Python 脚本循环发送测试帧import serial import struct import time ser serial.Serial(COM3, 115200, timeout1) for seq in range(0, 10000): seq_bytes struct.pack(H, seq) payload bytes([0xAA, 0x55]) seq_bytes bytes(32) # 32字节负载 ser.write(payload) time.sleep(0.001) ser.close()接收端记录帧序号然后对比发送序号。也可以在发送的同时让接收端回复接收到的帧序号用上位机实时统计丢帧率。这种测试能直接暴露线程问题。5.3 观察统计信息中断频率、缓冲区水位、溢出次数为了让调试更直观建议在代码中维护几个统计变量串口中断次数。软件缓冲区溢出次数。当前缓冲区中等待处理的数据量。最近一次收到的帧序号是否连续。例如volatile uint32_t g_rx_interrupt_count; volatile uint32_t g_ring_overflow_count;当怀疑丢数据时先看g_ring_overflow_count是否增长。如果增长说明软件缓冲区满业务读取速度太慢如果该值一直是 0但数据依然缺失问题可能出在硬件 FIFO 或 DMA 配置上。5.4 压力和长时间运行测试偶尔丢一帧和持续丢数据的原因往往不同。建议分别做以下测试短时高压测试连续发送 10000 帧观察丢帧率。长时稳定测试以实际生产速率运行 24 小时统计丢帧率是否随时间累积。波特率边界测试把波特率提高到项目标称值的 1.5 倍或 2 倍观察何时出现丢帧以确认系统余量。判断标准应结合项目需求确定。对于需要可靠传输的场景建议做到 0 丢包对于某些非关键数据则可以设定一个可接受的丢包率。测试结论要落到日志和统计数据上不能只靠肉眼看。6. 常见坑与排查路径丢数据的根因往往不在代码本身6.1 常见坑一中断优先级配置不合理现象串口接收中断偶尔不执行或者数据错位。原因串口中断优先级设置过低其他频繁中断如定时器、滴答定时器抢占 CPU导致串口中断响应延迟超过字节间隔。检查方式查看芯片中断优先级配置表。确认串口中断是否被更高优先级中断持续打断。统计串口中断响应时间可以在中断入口处翻转 GPIO 并用示波器测量。处理建议串口接收中断优先级应设置为相对较高的优先级。如果使用 DMA 接收DMA 中断优先级也要合理设置。不要长时间关闭全局中断确需关闭时控制临界区时长。6.2 常见坑二缓冲区大小与波特率不匹配现象平时不丢数据高峰时丢数据。原因缓冲区大小只考虑了单包长度没有考虑“突发流量”。如果业务处理逻辑每 10 毫秒读取一次数据而缓冲区只能缓存 8 毫秒的数据多余的 2 毫秒数据就会溢出。检查方式记录单位时间内接收的最大字节数。记录业务处理的最长间隔。计算缓冲区容量是否满足最大接收速率 × 最长处理间隔。处理建议增大软件缓冲区。优化业务读取频率缩短处理间隔。在应用层实行流控告知对端降低发送速度。6.3 常见坑三未处理空闲中断或接收超时现象使用 DMA IDLE 方案后数据长时间不触发中断直到缓冲区满才一次性处理。原因IDLE 中断未正确使能或者中断标志未清除导致空闲事件没有触发回调。检查方式确认 UART 初始化中启用了 IDLE 中断。在调试器中查看 IDLE 标志位是否置位。确认回调函数是否被正确注册。处理建议按芯片手册正确使能 UART IDLE 中断。中断处理中先清除 IDLE 标志再读取 DMA 剩余字节数。如果使用 DMA 循环模式需要正确处理半传输和传输完成中断。6.4 排查路径从现象倒推原因排查串口丢数据问题可以按下面顺序推进确认硬件连接和共地排除信号干扰。确认波特率一致排除通信参数不匹配。确认接收端缓冲区是否溢出统计g_ring_overflow_count。确认中断是否及时得到响应用 GPIO 翻转法测量中断响应时间。确认 DMA 配置是否正确检查 DMA 计数器值是否与预期一致。确认是否启用了硬件 FIFO 及触发阈值是否合理。确认上位机或对端设备是否真的发送了这些数据可以用串口调试助手抓取原始数据。下表汇总了常见现象与排查方向现象常见原因检查方式处理建议偶尔丢失一字节中断响应延迟示波器测量中断入口到读取寄存器的延迟提高串口中断优先级开启硬件 FIFO固定长度整包丢失DMA 计数错误或未重启接收打印 DMA 剩余计数重启 DMA 前完整处理数据高频数据大量丢失软件缓冲区满观察溢出计数器增长增大缓冲区优化业务读取速度数据错位或乱序缓冲区读写索引竞态检查缓冲区读写顺序使用临界区或原子操作保护索引程序运行一段时间后丢数据内存越界或缓冲区覆盖检查数组越界使用保护变量使用静态分析工具和看门狗6.5 可复用的检查清单在实际项目中可以把以下清单打印出来每次串口联调前逐项确认串口波特率、数据位、停止位、校验位是否一致。硬件 FIFO 是否开启触发阈值是否合理。是否启用了 DMADMA 通道、数据宽度、方向是否配置正确。缓冲区大小是否大于最大单包长度并预留至少两倍余量。软件环形缓冲区的读写索引是否使用volatile修饰。中断优先级的设置是否满足实时性要求。是否实现了溢出计数和错误统计。是否处理了空闲中断或接收超时。是否做了长时间压力测试。7. 最佳实践与扩展方向7.1 生产环境建议开发环境跑通并不等于生产环境可靠。生产环境还需要注意以下内容配置外置化波特率、缓冲区大小、DMA 缓冲区长度尽量不从代码硬编码而是通过配置文件或宏定义集中管理。日志与监控记录溢出错标志、缓冲区水位、最近接收时间。一旦出现丢数据可以通过日志定位是哪个环节出的问题。错误处理UART 帧错误、溢出错误、噪音检测错误都要正确处理。HC 系列库中可以通过HAL_UART_ErrorCallback感知错误。回滚方案对于高可靠性设备可以在应用层增加确认重传机制。即使底层偶尔出现数据丢失上层也能够检出并重发。版本管理串口驱动代码的修改要记录变更尤其是 DMA 配置和中断优先级调整这类问题非常隐蔽。7.2 参数设计清单串口接收缓冲区设计可以参考以下原则缓冲区大小 最大单包长度 × 最高突发发送倍数。硬件 FIFO 触发阈值设为硬件深度的一半到四分之一平衡中断频率和响应速度。DMA 缓冲区长度建议为最大包长的 2 倍以上。中断优先级排名串口接收中断应高于普通业务中断但可低于系统节拍中断。软件环形缓冲区大小应为 2 的幂便于使用位运算优化索引计算。7.3 扩展方向串口 FIFO 缓存方案可以进一步扩展自动流控结合 RTS/CTS 硬件流控在缓冲区满时通知对端暂停发送。协议帧解析在环形缓冲区上增加帧同步模块支持接收不定长协议帧。多串口管理当系统有多个串口时为每个串口独立配置接收缓冲区并统一抽象成“串口接收通道”。异构平台Linux 下同样可以使用termios配置底层 FIFO 缓冲区或者在驱动层面使用内核环形缓冲。上位机联动用 Python 或 C# 开发自动化压力测试工具将发送次数、接收次数和丢帧率输出为报告。串口 FIFO 缓存不是复杂的技术但它决定了整个接收链路的可靠性。理解硬件 FIFO、软件 FIFO、DMA 和环形缓冲区的协作关系之后再遇到丢数据问题就不会只靠“加大缓冲区”一条路走到黑。先定位是哪个环节处理不过来再针对性地配置硬件和软件才是解决丢数据的正确姿势。