干嵌入式这行,几乎天天跟UART打交道。别看串口协议简单,真要把数据稳定、高效地从设备A搬到设备B,里面全是坑:字节越传越乱、偶发丢包、数据错位、CPU被阻塞式发送卡死……系统一复杂,裸用UART根本撑不住。
我这几年的解决方案是 BAVA,全称Buffer–Validate–Acknowledge,一套面向 UART 字节流的轻量传输协议与实现思路。它不依赖厂商SDK,无论你是 STM32 还是 Linux 串口设备,都能按同一套规则把数据“聪明地送过去”。
BAVA 解决的核心问题有三个:冗余数据怎么切分、错误数据怎么发现、发送和接收怎么不互相拖累。针对的目标读者是正在做固件通信、多板级联、或者设备上报数据的开发者,尤其是用 UART 协议但是还在靠“先延时再发送”控制节奏的朋友。
为了让这篇文章真正做到可落地,我会把帧格式、状态机、发送队列、调试方法按项目实战的顺序全展开,每个环节都会给出代码或配置参考,并附上实测踩坑记录。你可以直接把这套思路移植到自己的项目里,不需要跑通整个RTOS,裸机也能用。
1. BAVA 与裸 UART 的本质差异
1.1 传统串口发送为什么“不聪明”
很多工程师刚接触单片机串口时,最熟悉的操作是:
HAL_UART_Transmit(&huart1, (uint8_t*)"hello", 5, 1000); HAL_Delay(10);看起来没问题,但项目一复杂,痛苦就来了。
先说阻塞问题。HAL_UART_Transmit如果用的是阻塞模式,整个 CPU 会一直等到所有字节发送完毕才返回。哪怕只发 20 个字节,在 9600 波特率下也要等 20ms 左右,期间中断响应全被耽误。如果发送端每秒要更新多个传感器数据,CPU 原则上有接近一半时间在等串口。
再说边界问题。串口物理层送出去的只是连续的电平波形,接收方根本不知道哪里是一个完整的数据包。如果发送端只丢出几个字节,接收端就只能靠“自己猜”:比如加延时、读固定长度、或者判断空闲时间。这些方法在低速率、高频次、多从机的场合下,往往会触发粘包和错位。
最后是错误处理。UART 在通信线长、电磁干扰强的环境里非常容易产生误码。裸操作下收到一个 0xAA,你没法判断这个字节是真值还是错误值。没有校验、没有重传、没有确认,数据丢没丢只能等上层逻辑发觉。
1.2 BAVA 做对了什么
BAVA 把“发送字节”升级为“发送数据帧”。设计上,它有四个核心部分:
- 帧协议:规定每一包数据的起始标记、长度、负载、CRC校验、结束标记。
- 流式解析器:接收端不用等收完一个包再处理,来一个字节解析一个字节,支持连续多帧。
- 发送调度器:用 DMA 或阻塞队列配合中断发送,不让发送动作卡住主循环。
- 重传与确认机制:在需要强可靠性的链路上,靠 ACK 帧实现丢包重传。
BAVA 并不是一个死板的库,它更像一套传输规范。只要你按帧规则打包,用什么 MCU、什么串口驱动都不重要。这样换来的是可移植性、可维护性,以及调试时“看到字节就能定位问题”的爽快。
2. BAVA 帧格式设计:让字节流有边界
2.1 帧头与帧尾:给数据包画一条隐形的线
BAVA 在链路层用帧头 + 控制段 + 负载 + 校验 + 帧尾的格式传输数据。一帧的基础结构如下:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| SOF (Start of Frame) | 1 | 帧起始标记,典型值 0xAA |
| Len | 2 | 负载长度,大端序,范围 0~1024 |
| Seq | 1 | 帧序号,用于重传与去重 |
| Type | 1 | 帧类型,比如数据帧、命令帧、ACK帧 |
| Payload | Len | 实际业务数据 |
| CRC16 | 2 | 针对上述所有字段的校验 |
| EOF (End of Frame) | 1 | 帧结束标记,典型值 0x55 |
为什么要 SOF 和 EOF 同时存在?因为串口线路上没走真正的“数据长度”,接收端只能靠标记判断边界。SOF 是找到一帧开始的地方,EOF 是确认一帧结束的地方。光有 SOF 不够,万一中间负载字节恰好等于 0x55,接收端就会误判成帧尾。
解决办法很简单:转义(Byte Stuffing)。如果负载或控制字段中出现 0xAA 或 0x55,就替换成 0x11 0x22 / 0x33 0x44 这样的组合,接收端反向还原即可。这在所有串口协议里都很常见。
2.2 长度字段与转义机制
长度字段我比较推荐固定 2 字节。很多人喜欢 1 字节,最多表示 255 字节负载。但我的经验是,BAVA 帧除了业务数据还可能带路径、时间戳、密钥等字段,255 字节太紧,做协议演进时会很被动。
长度字段本身也要防错:接收端在收到长度值后,会判断它是否超出“最大允许帧长”。如果 Len = 0xFFFF,直接判定为非法帧,重新搜索 SOF。这也算最基础的安全边界。
转义动作放在计算校验之前。也就是发送端先填充所有字段,再进行转义,最后算 CRC 并追加 EOF。接收端则反着来:先找到 SOF,然后收完整个帧到 EOF,再做反转义,最后校验 CRC。顺序一旦反了,校验结果一定会崩,调试时非常容易遇到。
2.3 CRC 校验:为什么我坚持用 CRC16
UART 单字节偶发错误率不算高,但总线一旦受到电机或电源干扰,错误经常是整段连续。单字节奇偶校验根本扛不住,所以我直接用 CRC16/CCITT。
CRC16 的计算量对 ARM Cortex-M 系列来说很小,但如果你的 MCU 主频很低,可以换 CRC8 作为可选帧类型。不要把校验算法写死,让上层能协商选择,这样低端设备也能按精简模式运行。
一个需要注意的实现细节:CRC 初始值建议固定为 0x0000 或 0xFFFF,具体不重要,关键是收发两端保持一致。还要记得统一 CRC 结果大小端。我见过太多项目因为“CRC看起来对,但实际是反的”折腾了一下午。
3. BAVA 接收端状态机:从杂乱字节流到干净数据包
3.1 用状态机代替“等一整个包”
很多初学者接收串口数据时会这样:
uint8_t buf[64]; HAL_UART_Receive(&huart1, buf, 10, 1000); // 等待10个字节这是典型的一次性接收思路,对不定长的 BAVA 帧完全不可用。BAVA 推荐的是逐字节驱动的状态机。在中断或 DMA 回调里,每收到一个字节就喂给解析器一次。
状态机最少包含以下状态:
- WAIT_SOF:等待帧头 0xAA
- WAIT_LEN:读取长度
- WAIT_DATA:收集负载
- WAIT_CRC:校验
- HANDLE_FRAME:交给业务逻辑
实现时不要用 if 嵌套处理所有情况,建议用一个变量state配合switch,每进来一个字节就跳转到对应状态。
3.2 环形缓冲区:防丢包的第一步
接收状态机处理字节本身很快,但业务逻辑(比如要把数据存到 Flash、通过 WiFi 上传)不见得快。如果串口以 115200 波特率连续来数据,平均每字节约 86us,你不可能在主循环里一直秒回。所以必须加一个环形缓冲区(Ring Buffer)。
环形缓冲区解决的是“生产者和消费者速度不匹配”的问题。中断或 DMA 是生产者,把原始字节放进缓冲区;主循环或任务里取出字节喂给 BAVA 状态机。缓冲区大小要根据最差情况来定,我的经验是至少能存放 3 个最大帧。
部署时要特别留意“缓冲区溢出”的静默问题。如果只有生产者和消费者两个指针,溢出时生产者把最老的数据覆盖掉,协议解析就会出现莫名其妙的花帧。强烈建议加一个 overflow 标志,一旦溢出就丢帧并且上报告警,不要默默覆盖。
3.3 解析器核心代码参考
这里给出一个 C 语言的精简实现,你可以在 STM32、GD32 或任何裸机平台上直接借鉴(去除平台相关部分):
typedef enum { WAIT_SOF = 0, WAIT_LEN_H, WAIT_LEN_L, WAIT_DATA, WAIT_CRC_H, WAIT_CRC_L, WAIT_EOF, } bava_state_t; typedef struct { bava_state_t state; uint8_t sof; uint16_t len; uint16_t recv_len; uint16_t crc_calc; uint16_t crc_recv; uint8_t buf[1024]; } bava_parser_t; void bava_byte_parse(bava_parser_t *p, uint8_t ch) { switch (p->state) { case WAIT_SOF: if (ch == 0xAA) { p->state = WAIT_LEN_H; p->crc_calc = 0xFFFF; /* 初始CRC */ p->recv_len = 0; } break; case WAIT_LEN_H: p->len = ch << 8; p->state = WAIT_LEN_L; break; case WAIT_LEN_L: p->len |= ch; p->state = WAIT_DATA; break; case WAIT_DATA: p->buf[p->recv_len++] = ch; if (p->recv_len >= p->len) { p->state = WAIT_CRC_H; } break; case WAIT_CRC_H: p->crc_recv = ch << 8; p->state = WAIT_CRC_L; break; case WAIT_CRC_L: p->crc_recv |= ch; p->state = WAIT_EOF; break; case WAIT_EOF: if (ch == 0x55) { /* 执行CRC比较,然后把完整帧交给业务层 */ if (p->crc_recv == p->crc_calc) { handle_bava_frame(p->buf, p->len); } } p->state = WAIT_SOF; break; } }代码里特别要留意把 CRC 计算分散到每个字节的接收流程中,不要在收到 EOF 后再遍历整个帧数据重新计算。否则数据量一大,解析一个包的时间可能超过串口的一个字节间隔,系统会越来越卡。
4. 发送端调度:不占用 CPU 的高效发送
4.1 别在定时发送里直接阻塞发送
我见过很多项目把“串口发送”直接放在 while 循环或者定时器回调里,执行完HAL_UART_Transmit再干别的。这在数据量很小时没问题,但 BAVA 帧往往带几十字节负载,一旦发送频率上来,阻塞延迟会积累,导致传感器采集抖动。
正确做法是把发送变成异步操作。在 STM32 上,做法是把要发送的数据交给 DMA,让 DMA 按照串口速率一字节一字节地搬,CPU 可以继续跑其他逻辑。你只需要在 DMA 完成回调里标记“当前帧已发送完”,然后送下一帧。
4.2 发送队列与 DMA 握手
BAVA 发送端建议维护一个发送队列,队列元素就是已经封装好的 BAVA 帧。主循环或者业务线程调用bava_send_frame(payload, len),这个接口只做三件事:
- 封帧(加 SOF / Len / CRC / EOF)
- 把帧数据拷贝到发送缓冲
- 启动一次 DMA 发送
这样即便上层业务来得很急,也不会阻塞业务本身。DMA 发送完成中断里检查队列是否还有帧,有就继续发。
需要注意 DMA 缓冲区的生命周期。如果发送缓冲是静态数组,在 DMA 还没传完时千万不要改写它。我的做法是准备两个发送缓冲区,轮换使用(double-buffer),这样上一帧还在发送时,新一帧已经能在另一个缓冲区里排队了。
4.3 流控:让接收方喘口气
如果你只有单向通信,接收方处理再快也有可能因为业务代码卡了一下而溢出。BAVA 里我设计了一个可选填空控制字段FLOW。它可以让接收方向发送方回复一个“接收窗口剩余大小”。发送方看到窗口为 0,就暂停发送,不给对方缓冲添堵。
在裸机上实现简单的流控不需要额外引脚,直接在 ACK 帧里带一个空闲缓冲区长度字段就可以。这样比硬件 RTS/CTS 更灵活,也少布线。
5. 在 STM32 和 Linux 下的实际部署
5.1 STM32 管脚配置与底层串口对接
很多朋友刚开始搞 STM32 UART 时,经常卡在管脚复用上。以最常见的 STM32F103 为例,USART1 的 TX 是 PA9,RX 是 PA10,都要配置成复用推挽与浮空输入,并开启对应 GPIO 时钟和 USART 时钟。
实际配置里我习惯用 CubeMX 先把 USART 参数填好:波特率 115200、8 位数据、无校验、1 停止位。然后在main.c里手动启用 DMA 接收中断。为什么要手动?因为 CubeMX 生成的接收代码默认用阻塞接收,而 BAVA 需要逐字节或 DMA 不定长接收。
如果启用 DMA 接收空闲中断(IDLE line interrupt),每次一批数据到达时中断一次,然后在中断里把 DMA 剩余计数减掉,得到本次收到的字节数,再喂给 BAVA 解析器。这种方式比每字节中断更省 CPU,也需要更严谨的缓冲管理。
5.2 Linux 下用 BAVA 与单片机通信
在 Linux 端使用 BAVA 并不需要特殊驱动,只要打开/dev/ttyUSB0,配置 termios 参数即可。一条最小配置代码如下:
#include <stdio.h> #include <termios.h> int configure_uart(int fd) { struct termios opts; tcgetattr(fd, &opts); cfsetispeed(&opts, B115200); cfsetospeed(&opts, B115200); opts.c_cflag |= (CLOCAL | CREAD); opts.c_cflag &= ~CSIZE; opts.c_cflag |= CS8; opts.c_cflag &= ~PARENB; opts.c_cflag &= ~CSTOPB; opts.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); opts.c_iflag &= ~(IXON | IXOFF | IXANY); opts.c_oflag &= ~OPOST; tcsetattr(fd, TCSANOW, &opts); return 0; }Linux 端的核心是把串口当作普通文件来读。读取时建议用select()或poll()轮询,拿到字节后同样喂给 BAVA 解析器,不要直接read()一个固定长度然后去凑帧。因为你不知道模块什么时候发数据,只能按字节流不断解析。
常见坑是 USB 转串口芯片(比如 FT232R 或 CH340)在 Linux 下默认存在“延迟合并”问题,小数据包可能会被内核缓存,不能实时到达。这时可以在打开串口后设置termios的VTIME和VMIN,或者把tty设备切换为 raw 模式,否则 BAVA 帧等半天才收到一包,调试体验极差。
5.3 与 RS-485 协议结合时的问题
如果你的设备用的是 RS-485 半双工总线,因为只有一对差分线,所以不能同时收发。BAVA 本身是全双工设计,但在 485 上必须增加收发方向切换逻辑。
我在 BAVA 的发送队列里加了一个“发送完成回调”,当最后一字节发送完毕后,立刻把 485 的方向引脚拉低,切换到接收模式。这个动作要非常实时,通常放在 UART 发送完成中断里做,不要放在 DMA 完成中断里再延时,否则很容易吃掉最后一个字节。
还有一个容易忽略的点:RS-485 总线上如果多从机同时发数据,会发生碰撞。BAVA 帧里有 Seq 和 CRC,能发现错误帧,但更高层的冲突避免需要考虑上位机统一轮询或者使用令牌机制。不要把 BAVA 当成冲突避免协议。
6. 调试与排查实录
6.1 丢字节、粘包和数据错位怎么查
排查串口问题,不要一上来就写业务代码,建议先做一次“串口回环测试”。把发送端 TX 直接短接到 RX,让设备自己发自己收,如果能正常解析,说明底层的驱动与管脚配置没问题;如果解析失败,问题多半出在波特率、帧格式或电平转换。
如果回环通过,但对接两个设备后出现粘包,首先怀疑 SOF 同步问题。比如发送端在帧和帧之间间隔过长,接收端 DMA 空闲中断超时后把两段数据误认为一包。解决方法是让接收端仍然按 SOF 开始解析,遇到 EOF 或长度不合法就自动重同步,不要依赖时间搓。
丢字节问题多出在两个地方:一是 DMA 缓冲太小,二是中断抢占。DMA 缓冲溢出时,老数据还没被主循环拿走,新数据已经覆盖。你可以在环形缓冲区里加一个计数统计,连续多次溢出就提高缓冲区大小,BAVA 解析器也自然能处理更大的帧。
6.2 波特率误差与重同步技巧
串口收发双方只要波特率稍有偏差,数据位积累后就会采样错位。标准 UART 每个字节其实有 10 个位周期(1 start + 8 data + 1 stop)。如果两边误差超过 2%,长帧传输时大概率会出乱码。
在调试 BAVA 时遇到“偶发掉包、错误率随温度变化”的情况,我会优先检查目标板外部晶振精度。很多便宜的板子用内部 RC 振荡器,波特率误差能达到 3% 到 5%,在高温下更严重。建议使用外部晶体,并在终端里实际测量两边的时钟偏差,而不是相信配置界面的数值。
如果硬件和波特率无法调整,BAVA 可以配合“SOF 重同步”技巧:每当接收状态机收到非法数据,自动从当前位置重新搜索 0xAA。这样即便一帧出错,也不会影响后续帧的定位。
6.3 常见问题速查表
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 完全收不到数据 | TX/RX 接反、波特率不匹配 | 回环测试,确认物理层 |
| 偶发丢字节 | 缓冲区溢出 / 中断优先级不当 | 加大缓冲区,提高串口中断优先级 |
| 帧解析出错率高 | CRC大小端不一致 / 转义顺序错误 | 收发两端统一 CRC 定义,检查转义前后顺序 |
| 粘包 | 接收端按空闲时间切包 | 改由 SOF/EOF 做帧边界 |
| CPU占用过高 | 使用阻塞发送 | 改为 DMA + 发送队列 |
| RS-485 方向切换失误 | 最后字节仍在发送时切RX | 在TX完成中断中切换方向 |
| 噪声环境下大量CRC错误 | 线材过长 / 共地不良 | 使用屏蔽线、降低波特率、增加重传机制 |
6.4 几个我反复踩过的坑
坑一:CRC计算时没排除转义后的字节。
有段时间我把转义后的数据也拿去算 CRC,导致同一帧发送端和接收端算出的 CRC 永远不一致。后来才发现必须先把转义还原,再算校验。凡是涉及帧边界与转义的协议,一定要在文档里写清楚“CRC 排除 SOF/EOF 之外是否包含转义字节”。
坑二:在串口中断里做业务逻辑处理。
我曾经为了方便,在 BAVA 解析到完整帧后直接调用 JSON 解析和数据库写入。结果每收几个包,主循环就被中断卡住,丢包率直线上升。后来才把处理函数挪到主循环事件队列中,中断里只做“收字节 + 入队 + 置标志”,性能立刻回来了。
坑三:只想着重传,不考虑乱序。
BAVA 的 Seq 字段主要用来去重和排序。如果接收端发现 Seq 跳变,但应用层没有做缓冲排序,重传回来的旧帧就会覆盖掉新帧数据。正确做法是对 Seq 做“窗口判断”,乱序帧要么缓冲,要么丢弃,不要直接上报。
7. BAVA 的扩展思路
BAVA 并不只是一套固定的代码,它更像是一种传输思维方式。你可以在此基础上扩展出命令响应模式、时间同步帧、OTA 固件升级分包传输等功能。
我在实际项目中扩展过两种方式:
一种是在帧头后加“通道号”字段,使一个串口可以同时承载控制命令和传感器数据,总线上的设备按通道号决定是否处理该帧。另一种是增加“心跳帧”和“重启帧”,让上位机能够及时发现从机掉线,并控制从机重启。
如果后续你要在 FPGA 或 Verilog 里实现同样的逻辑,思路可以照搬。把状态机用硬件描述语言实现,SOF、Len、CRC、EOF 的判定完全一致,只是把“环形缓冲”换成了 FPGA 内部的 FIFO。这样 MCU 侧用软件 BAVA,FPGA 侧用硬件 BAVA,两边的字节流可以完全互通。
8. 最后再分享一点个人经验
我在做 BAVA 这套方案时,最深的体会是:协议设计不是把规则写得越复杂越好,而是要在实现简单、扩展容易、调试方便三者之间找平衡。帧头帧尾、长度、CRC、转义、超时重传,这些听起来好像很复杂,但只要你每一步都有明确的测试用例,串口通信问题其实比很多业务逻辑更容易定位。
另外,建议你在正式接入业务前,先写一个自动化测试脚本,周期性发送固定 BAVA 帧,并检验回包内容。这样既能验证驱动是否有问题,也能在后期做回归测试时秒级发现协议被改动破坏了。
如果你也在为 UART 数据混乱、数据丢失、发送阻塞这些问题头疼,不妨试试 BAVA 这套思路。不用一上来就移植大项目,先在一个小开发板上把帧解析和 DMA 发送跑通,你会感受到“发送字节”其实也可以很聪明。