现场:一条永远发不出去的广播
几年前做一个分布式采集系统,主控通过广播通道向所有节点下发同步命令和参数。协议栈的单帧净荷上限是 31 字节,这是链路层硬约束,改不了。前期功能很简单,同步命令就几个字节,跑得好好的。
后来需求加了一条:广播下发一组通道配置参数。我把参数序列化后一算,48 字节。当时没多想,直接往发送接口里塞。发送函数返回成功,我就以为完事了。
测试的时候发现问题了。节点端有一部分能收到配置,有一部分收不到;能收到的里面,数据还是截断的。用监听工具抓包,只看到一帧,长度顶格 31 字节,后面 17 字节凭空消失。更麻烦的是,这 31 字节里前几个字节被协议头占掉了,真正到达应用层的有效数据更少。节点端拿着半截配置去解析,有的直接校验失败,有的解析出乱七八糟的参数,把执行机构设到了错误状态。
这个坑让我明白一件事:广播发送接口返回成功,不等于数据发出去了。长度超出承载上限时,底层要么截断、要么直接丢,而且往往不报错。
先搞清楚“上限”到底卡在哪一层
很多人一说分包,上来就按应用层数据长度切。这是错的。
实际能装多少有效数据,要一层一层扣:
应用层有效载荷 = 链路层单帧净荷上限 - 链路层帧头 - 网络层/传输层头(如果有) - 应用层协议头 - 可能的校验字段我那个项目里,链路层 31 字节,协议头占了 5 字节,应用层协议头又占 3 字节,实际每帧只能装 23 字节有效数据。48 字节要拆成 3 帧,不是 2 帧。当初按 31 算,怎么算都错。
这里有个隐形陷阱:不同协议栈对“净荷”的定义不一样。有的把帧头算进去,有的不算。调试时不要凭记忆,抓一帧实际数据,从字节偏移上数,确认应用层第一个有效字节到底在第几位。这个数错一位,后面全错。
分包不只是“切开”,接收端要能拼回去
分包发送本身不难,难的是接收端怎么知道这些帧属于同一条消息、怎么知道拼完了、拼的顺序对不对。
公开资料里讲分包,通常只讲发送端切片,接收端重组一笔带过。但工程上出问题,十有八九出在重组环节。
我见过几种常见的错误做法:
第一种,靠“帧间隔”判断。发送端连续发几帧,接收端认为间隔小于某个阈值的就是同一组。这在广播场景下极不可靠,多个发送源、重传、信道拥塞都会打乱间隔。现象是偶尔把两条消息拼成一条,数据错乱,而且极难复现。
第二种,只带一个总长度字段,不带序号。接收端收到第一帧知道总长,但如果中间丢了一帧,它不知道缺的是哪一段,只能干等或者拼出错误结果。广播没有确认重传,丢帧是常态。
第三种,用固定分片大小,但发送端和接收端对“每片有效数据长度”理解不一致。发送端按 23 切,接收端按 31 拼,拼出来全是错位的。
一个能用的分包结构
我后来固定用一套结构,发送端和接收端都按同一套规则来。核心是三个字段:消息标识、总分片数、当前分片序号。可选加一个总长度用于校验。
/* 应用层分片头,放在每帧有效数据的开头 */typedef半导体厂商ruct{uint16_tmsg_id;/* 同一消息的所有分片共用 */uint8_ttotal_frag;/* 总分片数 */uint8_tfrag_index;/* 从 0 开始 */uint8_tpayload_len;/* 本片有效数据长度 */}frag_header_t;发送端逻辑:
#defineMAX_FRAG_PAYLOAD23/* 实测得出,不是猜的 */intbroadca半导体厂商_send(uint16_tmsg_id,con半导体厂商uint8_t*data,uint16_tlen){uint8_ttotal=(len+MAX_FRAG_PAYLOAD-1)/MAX_FRAG_PAYLOAD;uint8_tframe[MAX_FRAG_PAYLOAD+sizeof(frag_header_t)];frag_header_t*hdr=(frag_header_t*)frame;for(uint8_ti=0;i<total;i++){uint16_toffset=i*MAX_FRAG_PAYLOAD;uint16_tremain=len-offset;uint8_tchunk=(remain>MAX_FRAG_PAYLOAD)?MAX_FRAG_PAYLOAD:remain;hdr->msg_id=msg_id;hdr->total_frag=total;hdr->frag_index=i;hdr->payload_len=chunk;memcpy(frame+sizeof(frag_header_t),data+offset,chunk);if(raw_send(frame,sizeof(frag_header_t)+chunk)!=0){return-1;/* 中途失败,整条消息作废 */}}return0;}接收端维护一个重组缓冲,按 msg_id 索引。每收到一片,按 frag_index 放到对应位置,记录已收到的片数。收齐 total_frag 片后,校验 payload_len 累加是否等于预期总长,然后交给上层。
typedef半导体厂商ruct{uint16_tmsg_id;uint8_ttotal_frag;uint8_treceived;uint8_tgot_flag[FRAG_MAX];/* 位图,标记哪些片已到 */uint8_tbuf[FRAG_BUF_SIZE];uint32_tla半导体厂商_国外半导体厂商ck;/* 用于超时清理 */}reassembly_ctx_t;广播场景下必须处理的几个异常
广播没有 ACK,发送端不知道接收端收没收到。所以接收端的重组逻辑必须能处理不完整。
丢片。中间某片没收到,received 永远到不了 total_frag。必须有超时机制,比如 500ms 内没收齐就丢弃整个缓冲,释放给新消息用。否则缓冲被占满,后续消息全丢。现象是:节点一开始能收配置,跑一段时间后彻底收不到任何广播,重启才好。这就是缓冲泄漏。
乱序。虽然同一发送源连续发送通常有序,但多路径、中继转发可能造成乱序。用位图按 frag_index 落位,不要假设按顺序到达。
重复。重传或信道反射可能造成同一片收到两次。位图能天然去重,已置位的片直接丢弃。
新消息覆盖旧消息。如果同一个 msg_id 在旧消息未收齐时又来了新消息,要么用更大的 msg_id 空间避免回绕,要么在收到 frag_index 0 时重置该 msg_id 的缓冲。我倾向于后者,简单可靠。
判断和排查:现场怎么观察
分包出问题,现象往往不是“完全收不到”,而是“时好时坏”“部分节点异常”。这类问题最难查。
第一步,抓原始帧。不要看应用层日志,直接看链路层实际发出的每一帧。确认:帧数对不对、每帧长度对不对、分片头字段有没有填错。我遇到过发送端 total_frag 算成 2 但实际发了 3 帧的情况,接收端收齐 2 片就交给上层,第三片永远被当成新消息的开头。
第二步,在接收端打印重组状态。每收到一片,打印 msg_id、frag_index、received/total_frag。一眼就能看出是丢片、乱序还是 msg_id 冲突。
第三步,构造边界用例。数据长度刚好等于 MAX_FRAG_PAYLOAD 时,total 应该是 1,不要算成 2。长度等于 MAX_FRAG_PAYLOAD + 1 时,total 是 2,第二片只有 1 字节。这两个边界最容易算错。
第四步,检查超时清理。人为让发送端只发前两片,看接收端缓冲是否在超时后被释放。如果不释放,反复触发,观察缓冲是否耗尽。
权衡:分包粒度越细越好吗
不是。
分片越小,单帧有效载荷越少,同样数据需要的帧数越多。广播场景下帧数多意味着:占用信道时间更长、冲突概率更高、接收端重组缓冲压力更大、丢任意一片的概率累积更高。
分片越大,单帧越接近上限,帧数少,但每帧出错影响的数据量更大,而且留给协议头的空间更紧。
我的经验值是:每片有效载荷控制在承载上限的 70% 到 85% 之间。既留出余量应对协议头变动,又不至于帧数膨胀。具体到 31 字节上限、8 字节头的情况,23 字节左右是合适的。
另一个权衡是重组缓冲大小。缓冲开小了,并发消息一多就丢;开大了占内存。广播场景下同时到达多条长消息的概率不高,一般 2 到 4 个重组槽位够用。但必须确认最坏情况:如果上层可能连续下发多条配置,槽位不够时新消息会顶掉旧消息,要确保这个行为是可接受的。
一个容易忽略的点:发送端的中途失败
分包发送时,如果第 2 片发送失败(底层返回错误),第 1 片已经出去了,接收端会一直等后续片,直到超时。这期间接收端的重组槽位被占用。
发送端能做的:要么整条消息重发(msg_id 不变,接收端收到 frag_index 0 时重置),要么放弃并确保接收端超时足够短。我倾向于整条重发,并且 msg_id 保持不变。接收端在收到重复的 frag_index 0 时,清空该 msg_id 的旧状态重新开始。这样即使上一次发了一半,这一次也能正确重组。
但要注意:如果两次发送的 msg_id 相同但内容不同,接收端可能把两次的片混在一起。所以 msg_id 的分配要保证一条消息的生命周期内唯一。用递增计数器,回绕周期足够长即可。
最后一点提醒
分包处理写完之后,不要只在“正常长度”下测试。专门构造这些用例:长度刚好等于上限、上限加一、上限的整数倍、上限整数倍加一、远大于缓冲容量的长度。每一个都跑一遍,观察发送帧数、接收重组结果、超时行为。
广播分包的问题,从来不是“能不能切开”,而是“接收端在丢片、乱序、重复、并发的情况下,还能不能拼出正确结果”。把异常路径走通了,这个功能才算真正做完。