4. 软件协议优化:分包策略、校验机制、流控制、超时重传机制
硬件调完了,波特率也拉上去了。结果呢?烧到一半卡死,或者校验失败,反复重试。这时候你可能会怀疑人生——是不是硬件不行?
其实不一定。很多时候,问题出在软件协议上。我见过不少团队,硬件方案一模一样,就因为协议写得糙,速率死活上不去。反过来,协议优化好了,同样的硬件,速率能翻倍。
这一章,咱们就聊聊软件协议优化的四个核心点:分包策略、校验机制、流控制、超时重传。说白了,就是怎么把数据包组织好、怎么保证它没丢没坏、怎么让收发双方步调一致、出错了怎么补救。
核心观点:协议优化的目标不是“不出错”,而是“出错后能快速恢复”。稳定性和速率,从来不是二选一。
4.1 分包策略:别让缓冲区撑死,也别让链路饿死
先说说分包。你想想看,一次发 1 个字节,效率肯定低;一次发 10KB,接收端的缓冲区可能直接炸了。所以,分包的核心就是找到那个“刚刚好”的尺寸。
我个人的习惯是:先看接收端的缓冲区大小。比如 MCU 的 UART FIFO 只有 64 字节,那你一包发 128 字节,它根本接不住。这时候就得拆。
分包策略一般分两种:
- 固定长度分包:每包固定 N 字节。简单,好解析。缺点是如果数据本身不是 N 的整数倍,最后一包要填充,浪费带宽。
- 变长分包:包头带长度字段,接收端根据长度收完一包。灵活,但解析逻辑复杂一点。
我在项目中遇到过一种情况:用固定长度分包,每包 256 字节,结果接收端 FIFO 只有 128 字节。每次收完半包就溢出,数据全乱。后来改成 64 字节一包,问题立刻解决。嗯,这里要注意:分包尺寸一定要小于接收端缓冲区的一半,留出余量给协议处理。
| 分包方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定长度 | 解析简单,无长度字段 | 最后一包需填充,浪费带宽 | 数据块大小固定,如固件镜像 |
| 变长 | 带宽利用率高,无填充 | 需解析长度字段,逻辑复杂 | 数据长度不固定,如日志流 |
小技巧:如果数据量很大(比如几百 KB 的固件),建议用固定长度分包。接收端可以预分配缓冲区,解析效率高。变长分包适合数据长度变化大的场景,比如 AT 指令响应。
4.2 校验机制:别信运气,信算法
串口通信,干扰是常态。尤其是工业现场,电机一启动,数据就可能错位。所以,校验机制是必须的。
常见的校验方式有几种:
- 奇偶校验:硬件自带,开销小。但只能检测奇数位错误,而且不能纠错。说实话,我基本不用它做数据完整性校验,太弱了。
- 累加和校验:把数据字节加起来,取低 8 位或 16 位。简单,但碰撞概率高。两个字节互换位置,累加和不变,检测不出来。
- CRC 校验:循环冗余校验。这是工业标准,碰撞概率极低。CRC-16 或 CRC-32 都行,看你对可靠性的要求。
我曾经在一个项目中,用累加和校验,结果烧录 100 次有 3 次校验失败。换成 CRC-16 之后,跑了 10000 次,一次都没错。你想想看,这差距有多大。
我建议:只要不是极端资源受限(比如 8 位单片机,ROM 只剩几百字节),一律用 CRC。如果实在想省资源,至少用累加和 + 异或的双重校验,能降低碰撞概率。
避坑指南:我曾经见过有人把 CRC 算完直接放在包尾,结果接收端没算对。后来发现是字节序问题——发送端用大端,接收端用小端。CRC 算法本身不关心字节序,但你的实现必须统一。否则,校验永远过不了。
4.3 流控制:别让发送端跑得太快
流控制,说白了就是“你慢点,我跟不上”。串口通信中,接收端处理数据需要时间。如果发送端不管不顾地狂发,接收端的缓冲区迟早会满,然后丢数据。
流控制分两种:
- 硬件流控制(RTS/CTS):用两根额外的信号线。接收端缓冲区快满时,拉低 CTS,发送端暂停。这是最可靠的方式,但需要硬件支持。
- 软件流控制(XON/XOFF):用特殊字符。接收端发 XOFF(0x13)让发送端暂停,发 XON(0x11)让发送端继续。不需要额外引脚,但问题是,如果数据里恰好有 0x13 或 0x11,就冲突了。
我个人更倾向于硬件流控制。原因很简单:可靠。软件流控制虽然省引脚,但处理起来麻烦。你想想看,如果固件里正好有 0x13 这个字节,发送端收到后以为是 XOFF,直接停了,那不就乱套了?
当然,有些场景下硬件流控制不可用(比如只有 TX/RX 两根线)。这时候,我建议用数据包确认机制代替流控制:发送端发一包,等接收端确认后再发下一包。虽然效率低一点,但至少不会丢数据。
| 流控制方式 | 可靠性 | 硬件需求 | 适用场景 |
|---|---|---|---|
| 硬件流控制 | 高 | 需 RTS/CTS 引脚 | 高速、大数据量传输 |
| 软件流控制 | 中 | 仅需 TX/RX | 低速、数据中无特殊字符 |
| 包确认机制 | 高 | 仅需 TX/RX | 任何场景,但效率略低 |
4.4 超时重传机制:别死等,该放弃就放弃
通信不可能永远不出错。一旦出错,怎么办?死等?那系统就卡死了。所以,超时重传机制是必须的。
超时重传的核心是两个参数:超时时间和重传次数。
- 超时时间:发送端发完一包后,等待确认的最长时间。这个时间怎么定?我一般用“最大数据包传输时间 × 3”。比如,一包 64 字节,波特率 115200,传输时间大约是 5.5ms。那超时时间就设 15-20ms。太短了容易误判,太长了浪费等待时间。
- 重传次数:超时后重发几次?我建议 3 次。超过 3 次还没收到确认,基本可以判定链路断了,直接报错。别无限重传,否则系统会陷入死循环。
我曾经遇到过一个坑:超时时间设得太短,只有 5ms。结果接收端处理确认包稍微慢了一点,发送端就重传了。重传的包和原来的包混在一起,接收端彻底乱套。后来把超时时间调到 20ms,问题解决。嗯,这里要注意:超时时间一定要留够余量,考虑接收端的处理延迟和系统调度抖动。
经验公式:超时时间 = (数据包字节数 × 10 / 波特率) × 3 + 接收端处理时间。其中“×10”是因为串口每字节有 1 个起始位 + 8 个数据位 + 1 个停止位(无校验时)。
4.5 整体协议流程:把这些串起来
分包、校验、流控制、超时重传,这四个东西不是孤立的。它们需要组合成一个完整的协议流程。下面这张图,就是我常用的烧录协议框架:
这个流程,说白了就是“发一包,等一包,确认了再发下一包”。虽然看起来简单,但实际项目中,很多人会忽略超时和重传的细节。比如,超时时间设得太短,导致频繁重传,速率反而下降。或者重传次数设得太多,链路断了还在傻等。
我建议:在开发初期,就把超时时间和重传次数做成可配置的参数。这样在调试时,可以根据实际链路质量动态调整。等稳定了,再固化下来。
实战经验:我在一个项目中,用这套协议把烧录速率从 20KB/s 提升到了 80KB/s。关键就是两点:一是分包尺寸从 256 字节降到 64 字节,减少了单包出错概率;二是超时时间从 50ms 降到 15ms,减少了等待时间。你想想看,同样的硬件,只是协议优化了一下,速率翻了 4 倍。
4.6 代码示例:一个简单的超时重传实现
光说不练假把式。下面是一个简化版的超时重传代码,用伪代码写的,方便你移植到自己的平台。
// 发送一包,带超时重传
int send_packet_with_retry(uint8_t *data, uint16_t len, uint8_t max_retry) {
uint8_t retry_count = 0;
uint8_t ack_received = 0;
while (retry_count < max_retry) {
// 发送数据包
uart_send(data, len);
// 启动超时定时器
start_timer(TIMEOUT_MS);
// 等待ACK或超时
while (!is_timer_expired()) {
if (uart_has_data()) {
uint8_t response = uart_read();
if (response == ACK) {
ack_received = 1;
break;
} else if (response == NAK) {
// 收到NAK,立即重传
break;
}
}
}
if (ack_received) {
return 0; // 成功
}
retry_count++;
// 重传前可以加一点随机延迟,避免冲突
delay_ms(1 + (rand() % 5));
}
return -1; // 失败,链路故障
}
这段代码里,有几个细节要注意:
- 超时定时器:一定要用硬件定时器,别用软件延时。否则,等待期间 CPU 被占死,其他任务没法跑。
- 随机延迟:重传前加一点随机延迟,可以避免多个设备同时重传导致冲突。这在多设备烧录时特别有用。
- ACK/NAK 判断:收到 NAK 后,不要等超时,立即重传。这样可以节省时间。
避坑指南:我曾经在重传逻辑里忘了清空接收缓冲区。结果重传时,上次的 ACK 还在缓冲区里,程序误判为收到确认,直接发了下一包。数据全乱。所以,重传前一定要清空接收缓冲区。
4.7 小结
协议优化,说白了就是四个字:稳中求快。分包策略决定了链路利用率,校验机制决定了数据可靠性,流控制决定了收发节奏,超时重传决定了容错能力。这四个点,缺一不可。
你想想看,如果分包太大,缓冲区溢出;校验太弱,数据损坏;没有流控制,接收端被撑死;没有超时重传,一次出错就卡死。任何一个环节出问题,速率和稳定性都无从谈起。
所以,别急着调硬件。先把协议优化好,你会发现,很多问题其实根本不在硬件上。