☰
如何设计一套轻量无线通信协议?帧格式与重传机制实战解析
2026/10/19 5:22:54 网站建设 项目流程

1. 为什么还要自己设计协议:现成方案的痛与"足够用"的边界

干嵌入式、物联网这一行的,大多都经历过这种场景:项目刚立项,需要让几个节点无线通信,第一反应是翻手册、找成熟协议栈。蓝牙、Wi-Fi、LoRa、ZigBee,随便挑一个都有完整的生态和现成的协议栈,按说没必要自己折腾"无线通信协议"。但我在实际项目中越来越发现,现成方案不是万能的,很多场景下它们要么太重,要么太黑。

先说重。一套完整的BLE协议栈,运行起来要占不少Flash和RAM,连接事件、GATT服务、配对绑定,这些机制在"两个单片机之间传一串温湿度数据"这种任务里,九成是用不上的。更麻烦的是调试困难,协议栈分层太多,出了问题你很难定位到是自己数据拼错了,还是空中传输丢了包,还是协议栈在某个状态机里卡住了。我记得某次调试一款低功耗蓝牙模块,设备偶尔唤醒后连不上,查了两三天,最后发现是协议栈的广播参数和低功耗时钟配置冲突,这种问题在没有源码的情况下非常难追。

再说黑。很多专用无线芯片虽然给你封装好了协议,但你拿不到完整的帧结构细节,QoS、重传、加密、信标管理这些策略都是写死的。你想在某个节点上压缩一下报文长度、减少一下握手次数,对不起,做不到。要么全用,要么别用。整套协议是为通用场景设计的,不可能为一个具体业务做瘦身。

于是就有了自己动手设计一套轻量协议的想法。这里的"轻量"不只是代码量少,而是整套协议的开销可以量化、可以裁剪、可以完全掌控。某个实验项目里,我设计了一套面向"少量传感器节点定期上报"场景的简单无线通信协议,整包最小只有8个字节,CRC校验、ACK重传、帧同步全都有,跑在常见的Sub-GHz收发芯片上,单次报文的空中占用时间不到10毫秒,节点侧的解析代码用C写不到两百行。这套东西不一定适合所有项目,但它把"设计一个无线通信协议"这件事完整走了一遍,里面的取舍和踩坑,我觉得值得拿出来聊一聊。

这篇内容适合两类人:一是正在做物联网节点、觉得现成协议栈太重,想自己做一套但不知道从哪下手的人;二是想通过一个完整案例理解协议栈底层原理、为将来阅读更复杂协议打基础的人。我会把链路层的参数选择、帧格式设计、编解码实现、误码和重传策略、实测优化这几个环节按我做项目时的思路展开,不空谈理论,全部是可落地、可复现的细节。

2. 链路层之前的功课:频率、速率、调制与通信模型的选择

很多人一上来就画帧结构,这是本末倒置了。帧结构是协议的上层建筑,它的长度、超时时间、重传间隔都取决于物理层的能力。无线通信协议里,物理层的频率、速率、调制方式决定了信道的脾气——这个信道能传多快、能被什么干扰、延迟大概多少。不知道这些就设计帧结构,就像不量地形就画马路。所以第一步是先确定链路层参数。我在这个项目里选的是常见的Sub-GHz频段,具体来说在典型免申请ISM频段范围内选一个信道。为什么不是2.4GHz?2.4GHz频段太拥挤,Wi-Fi、蓝牙、USB 3.0都在这个频段出没,实测中同频干扰出现的概率非常高。我把一个节点放在办公室角落,旁边有路由器工作,用频谱仪扫一遍就能看到底噪明显抬高,数据重传率上升。Sub-GHz频段相对干净,绕射能力强,穿透墙壁后的信号衰减比2.4GHz好不少,代价是数据速率低、天线尺寸大。对于"几分钟上报一次温湿度"这种场景,速率低根本不是问题。

速率和带宽是绑在一起的。通用的FSK调制方式下,数据速率选多少合适?我做过对比测试:速率定在50kbps时,灵敏度比200kbps好大约3到5个dB,通信距离能远不少;但50kbps意味着每字节在空中的时间约0.16毫秒(加上前导码和同步开销会更长),发送8字节的帧大概需要不到2毫秒,仍然可以接受。所以最终锚定在比较低的速率上,换灵敏度余量。调制方式选FSK或者说是2-FSK就足够了,实现简单、抗干扰能力尚可,绝大多数Sub-GHz收发芯片直接支持。有人会觉得LoRa的扩频调制更好、灵敏度更高,这没错,但LoRa的码片同步和数据率调节在某些芯片上比较复杂,而且专利和生态限制也多。对于一个"简单"的协议,FSK已经把80%的收益做到了20%的成本。

通信模型也要在动手前明确。这个项目采用最简单的"一对多主从模型":一个主节点(网关),多个从节点(传感器),平时从节点休眠,定时醒来发数据,主节点负责接收、解帧、上报到上位机。为什么不做成一跳对等网络?因为会增加路由和链路维护的复杂度。做一个简单的协议,第一原则就是砍需求——凡是能通过部署拓扑解决的,就不用协议机制解决。主从模型下,只要约定好每个从节点的上报时隙或者简单加一个随机退避,就能把碰撞概率压到很低。从节点不用监听信道,也不用转发别人的数据,睡眠时间可以拉得很长,功耗也好控。

链路层还有一个容易被忽略的参数:收发转换时间。无线芯片从接收模式切到发送模式,通常需要几百微秒到几毫秒,这取决于芯片的频率合成器锁相速度。这个参数直接影响ACK的响应超时设置。我在设计时专门查了选型芯片的手册,锁定到几百微秒级别,然后把ACK超时定在了这一数值的三倍以上,留足余量。如果把超时定得太短,芯片还没来得及完成收发切换,重传就触发了,信道里全是冗余帧;定得太长又拖慢了交互节奏。这个"长短"不是拍脑袋定的,是拿示波器连着SPI口实测出来的。链路层参数确定之后,帧格式才有了设计依据。

3. 帧格式设计:从头码到校验位的每一次取舍

帧格式是整个协议的灵魂,也是最能看出设计者功力的一层。好的帧格式应该是"能少一个字节就少一个字节,但关键信息一个都不能少"。我一共设计了五个部分:前导码、帧头、长度字段、负载、校验字段。没有任何扩展头部,没有保留字段——在简单的场景里,保留字段只会让解析代码多一堆永远走不到的分支。

前导码用于接收端做位同步和增益调整。无线传输不像有线那样有固定的时钟参考,接收端需要根据发射端发来的一串特定跳变来锁定符号边界。前导码长一点能提高同步成功率,但会占用额外的空中时间。实测下来,在低速率FSK下4字节的55模式前导码是性价比不错的选择。这个模式在业界很常见,因为它是01010101的交替模式,接收端的位同步环路可以很快锁定。有朋友问能不能省掉前导码直接发帧头,我的回答是:除非你使用外部时钟同步或者数据包非常长、能够容忍较大的同步损失,否则别省。我从实测里观察到的现象是,前导码不足时,接收端丢帧率会明显上升,而且是"第一帧总是丢"这种规律性丢失。

帧头的作用是告知接收端"真正有效的数据从这开始"。我用两个字节做帧头,内容固定为0xAA 0x55,一高一低的模式有助于接收端做最后的字节对齐确认。选这两个值还有一个考虑:它们的二进制形态分别是10101010和01010101,和长度字段、负载字段都不容易混淆。再往后是1字节的长度字段,只描述负载的长度,不含帧头和校验。为什么要单独的帧头加长度,而不是像某些轻协议那样直接由物理层导出数据长度?因为很多Sub-GHz芯片的FIFO在可变长度模式下会受限于最大寄存器配置,而且硬件FIFO的边界未必和你协议的帧边界对齐,一个显式长度字段让软件解析完全摆脱硬件限制。负载最长我限制在16字节,这意味着长度字段只用低4位就够了,但保留完整的8位是为了将来扩展更长的负载时协议不用重设计,这是一个很小但很实际的远见。

设计帧格式时,我给自己定了一条原则:任何字段都要有明确的、可解释的作用,说不出理由的字段不下协议。比如有的协议会放一个源地址字段,我这里就没放。为什么?主从模型下,帧是通过"节点ID"来识别来源的,而这个节点ID放在负载的第一个字节里,而不是单独做一个地址字段。单独做地址字段会让每个帧多出1字节开销,而这1字节在当前模型下属于"物理上可省去、逻辑上可从负载中获得"的冗余。这就是简单协议和复杂协议在思路上的核心差异:复杂协议把所有常见操作都抽象成通用字段,简单协议只保留当前必须的东西,把不必须的交给上层解决。

我在这里用一个实际的上报帧来演示完整流程。假设节点ID是0x03,传感器测得温度25.6摄氏度,湿度60.2%。为了减少浮点数传输的开销,温度用整数表示,单位0.1摄氏度,即256;湿度同样用整数,单位0.1%,即602。负载数据安排为:节点ID(1字节)、温度值(2字节,大端序)、湿度值(2字节,大端序),总共5字节。加上前导码4字节、帧头2字节、长度1字节、校验1字节,整包从天线发出去一共13字节。其中真正承载有效信息的负载只有5字节,协议开销占比约六成——这个比例听上去不低,但对于短报文场景,固定的开销被摊到每次传输中,只要传输频率不高就完全可接受。如果将来要传长数据,开销占比会迅速降下来。各字段的字节设计如下表所示:

字段长度(字节)值/格式说明
前导码40x55 ×4位同步和AGC校准
帧头20xAA 0x55字节对齐和帧起始标记
长度10x05负载长度,不包含帧头和校验
负载5ID+温度+湿度业务数据,大端序
校验1CRC8从帧头到负载末尾的CRC校验

校验字段我选择CRC8而不是求和校验。很多人图省事用累加和,但累加和对"位翻转导致的值恰好抵消"这种情况完全没有抵抗力,无线信道里这是一种实际存在的概率事件。CRC8虽然也只能覆盖8位,但对随机误码的侦测能力远强于求和。具体采用多项式0x07的多项式,查表实现,1字节开销换来对最长16字节负载的可靠保护,这个开销值得花。无线信道的误码通常是一簇一簇出现的,如果连CRC8都测不出的错误发生了,那大概率是信道已经坏到协议层怎么兜都兜不住的程度,该做的是重传而不是纠错。

4. 打包与解析的代码实现:CRC、状态机与边界处理

协议定义得再好,落到实处还是在代码里。我不太喜欢那种"只给帧格式图不给代码"的文章,因为代码里的边界处理才是真正的深坑。先看CRC8查表实现,这是整个协议里最简单、最不容易出错的部分。查表法的关键是先离线生成一个256项的表。生成表的代码不复杂,系统初始化时执行一次即可。以下是生成表的核心代码:

uint8_t crc8_table[256]; void crc8_init(void) { for (int i = 0; i < 256; i++) { uint8_t crc = i; for (int j = 0; j < 8; j++) { crc = (crc & 0x80) ? (crc << 1) ^ 0x07 : crc << 1; } crc8_table[i] = crc; } }

计算帧校验时,按字节顺序逐字节查表更新即可。初始值采用0x00,计算范围从帧头的第一个字节开始,一直到负载的最后一个字节。前导码不参与校验——因为在接收端,前导码是用来做同步的,可能因为接收增益调整而被丢弃或损坏,把它纳入校验没有任何意义。生成表和查表计算可以放在一起看:

uint8_t crc8_update(uint8_t crc, uint8_t data) { return crc8_table[(crc ^ data) & 0xFF]; }

打包成帧的函数,是一个纯粹的字节拼接操作。这里的字节序要特别注意:协议里我用大端序,也就是说多字节数值的最高有效字节在最低地址。这个约定必须在协议文档里写清楚,并且在代码里用宏或专门的小函数处理,不能各写各的。我在早期版本里吃过亏:温度值在代码里是小端序,到了另一个平台被解析成大端序,读出来的温度变成了负一百多度,排查了半天才发现是字节序问题。从那以后我养成一个习惯,凡是协议里的多字节字段,强制通过移位操作拆开,而不是直接用memcpy拷贝结构体。因为结构体在C语言里有对齐填充问题,不同编译器可能填充出不同结果,而且直接把结构体指针转成字节数组往外发,等于把编译器的内存布局暴露到了协议里,这是设计协议的大忌。

接收端的解析是整个实现里最需要耐心的地方。因为无线信道上不存在"数据包完整的到达"这个假设,可能出现半包、粘包、错位、多字节丢失等各种情况。如果直接在中断里傻等一帧完整数据,状态机一团乱麻。我采用一个简单但可靠的办法:逐字节状态机。每次从串口或SPI收到一个字节,就喂给解析状态机,状态机根据当前状态决定是跳过前导码、校验帧头、缓存负载还是触发CRC校验。核心思路如下:

#define ST_SYNC 0 #define ST_FRAME1 1 #define ST_FRAME2 2 #define ST_LENGTH 3 #define ST_PAYLOAD 4 typedef struct { uint8_t state; uint8_t len; uint8_t count; uint8_t buf[32]; uint8_t crc; } rx_parser_t; void rx_parse(rx_parser_t *p, uint8_t byte) { switch (p->state) { case ST_SYNC: if (byte == 0xAA) p->state = ST_FRAME1; break; case ST_FRAME1: if (byte == 0x55) { p->state = ST_LENGTH; p->crc = 0; p->crc = crc8_update(p->crc, 0xAA); p->crc = crc8_update(p->crc, 0x55); } else { p->state = ST_SYNC; } break; case ST_LENGTH: if (byte > 16) { p->state = ST_SYNC; // 非法长度,重新同步 } else { p->len = byte; p->count = 0; p->state = ST_PAYLOAD; p->crc = crc8_update(p->crc, byte); } break; case ST_PAYLOAD: p->buf[p->count++] = byte; p->crc = crc8_update(p->crc, byte); if (p->count >= p->len) { p->state = ST_SYNC; // 结束,等待接收校验字节 } break; } }

这里要特别关注的是非法长度字段的处理。接收端如果收到一个损坏的长度字段,比如负载长度从5被干扰成了200,那接收端就会傻等200个字节,整个通信卡死。所以解析器必须对长度字段设一个上限,超过上限直接放弃重新同步。我在代码里限制负载最长16字节,因此长度值大于0x10的帧一律当作出错。这个上限约束了硬件FIFO和协议设计时的最大负载长度,是配套的。

还有一个细节是关于"伪同步"的。前导码之后跟的是0xAA 0x55帧头,但前导码本身是0x55模式,如果发送端从0x55切换到0xAA,在接收端看来字节流可能产生错位。所以状态机里,在ST_FRAME1状态下收到非0x55的字节时,需要回退到ST_SYNC而不是直接从ST_SYNC的0xAA检测开始。究其原因,0xAA和0x55的比特模式在FSK解调后有可能因为时钟恢复略有偏差而产生采样偏移,从而导致字节边界错位。回退到SYNC能容忍单字节的位偏移,却不能完全消除所有窗边界问题,这就是为什么前导码足够长(4字节)也很重要。结合足够的前导码和回退机制,实际测试的同步成功率很高,连续丢帧只在信号强度很差时出现。

5. 无线环境里的"脏数据":误码、重传与应答策略

有线通信里,数据只要发出去了,基本可以假设能完整到达。无线通信完全不是这样。即使CRC校验通过,接收端仍然可能收到重复的帧、乱序的帧、甚至被临近信道串扰的"鬼帧"。所以我在这套协议里保留了ACK应答和超时重传机制——这是"简单"和"能用"之间的底线。

ACK也是协议内的一个独立帧,我在帧头里加了一个帧类型标志位。具体做法是:负载的第一个字节作为类型字段,0x01表示数据帧,0x02表示ACK帧。这样ACK帧就不需要单独设计另一套帧结构了,它只是负载长度等于1的普通帧,从节点收到数据后,回发一个负载为0x02的帧,主节点据此知道数据送达。这个设计保持了协议的对称性,接收端和发送端共用同一套解析代码,只用类型字段区分业务帧和控制帧即可。

ACK回发是立即的还是延迟的?这个细节也值得推敲。我的做法是从节点收到完整CRC正确的数据帧后,立刻进入发送模式回ACK。因为从节点本身处于接收窗口内,时钟同步已经建立,立刻回发ACK可以用最小的额外能量完成确认。如果延迟回发,主节点可能已经进入超时重传流程,再回ACK就是重复劳动了。所以收发切换时间越短越好——这也是当初选芯片时关注收发切换时间的原因。

重传策略上,我没有采用指数退避这种复杂的算法,而是用固定的3次重传上限和每次间隔一个固定周期。为什么定3次?实测数据显示,在这个信道条件下,首次丢失概率约1%,连续丢两次的概率约0.01%,三次重传已经覆盖了绝大多数情况。间隔则根据最坏情况下的ACK往返时间设定,确保上一次尝试的ACK不会与下一次重试的数据帧在空气中打架。如果3次重传后仍然失败,从节点就直接放弃本次上报,进入睡眠,等待下一个上报周期。相比于让节点"死磕"直至信道恢复,及时放弃并通过上层统计丢包率更经济。这个策略是我在实测后定下来的:有一阵子我故意把主节点关掉,观察从节点的行为,发现如果没有超时放弃机制,从节点会被困在重传循环里,电池电量肉眼可见地往下掉。后来加了放弃逻辑,从节点在最坏情况下也只是多花几毫秒和三次发送的能量,不会出现灾难性的功耗问题。

重复帧的处理也是无线协议绕不开的问题。场景是:主节点收到了数据帧,但在回发ACK时ACK丢包了,于是从节点超时重传同一份数据。主节点在重传间隔内再次收到同一帧怎么办?如果直接当新数据处理,上位机会得到重复的记录。所以我给每个数据帧加了一个递增序列号,放在负载的第二个字节。主节点每收到一个数据帧,就检查序列号是否与上一帧相同,相同则认为是重传帧,只回ACK不提交业务逻辑。序列号只有1字节,范围0到255,对于定期上报的传感器来说,重复周期很长,足够用了。这个去重逻辑非常小,但能把"恰好丢掉ACK"和"恰好丢掉数据"这两种情况都处理干净。

不得不提的还有"伪ACK"的情况。如果一个损坏帧碰巧通过了CRC校验且恰好类型字段是ACK,会发生什么?从节点会认为自己收到了确认,放弃重传,结果主节点其实没收到数据。这就是为什么ACK帧的负载里也放一个序列号,标明它对哪个序列号的数据帧做出了确认。从节点在收到ACK后检查序列号是否匹配,匹配才停止重传。这个细节很多人会忽略,但它把"盲目信任"变成了"有条件信任",代价仅仅是ACK帧多一个字节,非常值得。

我把这层策略称为"双保险而不兜底":ACK应答、序列号、重传放弃这三样东西解决90%的无线传输异常,剩下10%靠上层应用自己感知和报错,协议层不做过度设计。这是简单协议一个重要的哲学——不是把所有问题都塞到协议里,而是把"信道可能出错"这个事实交给有限的重传机制,把"业务上是否需要容忍丢包"这个判断留给应用层。

6. 实测复盘:距离、功耗与调试中易被忽视的细节

协议写完、代码调通,只能算完成了50%。无线协议最终要在真实射频环境里接受检验。这一节我把我做实测时的完整记录和调整过程写出来,很多结论和最初的设计假设并不一样。

先说测试环境。我在某高校一个老旧的实验楼里做测试,走廊多、墙体厚,楼层间有大量金属管道,算是比较恶劣的室内环境。刚开始我把主节点放在三楼一个房间角落,从节点放在走廊尽头,直线距离不到20米,但中间隔了三堵砖墙和一扇防火门。第一次联调,数据接收成功率只有六成左右,大量丢帧。我知道无线的物理规律是"每穿透一堵墙信号就会掉一截",但没想到砖墙加门这么严重。后来把主节点移到走廊外侧靠近窗户的位置,并把天线从PCB天线换成了外置鞭状天线,丢包率立刻降到很低。这个经验很重要:在室内环境,天线位置和外置天线形态对通信质量的影响,往往大于你在协议层做的一切优化。协议再怎么重传,也不可能把天线放在金属盒子里、又被机柜挡住的情况救回来。所以做无线协议调试时,要把射频环境和协议优化当成两个独立变量分开调。

功耗的实测账也要算清楚。从节点平时处于休眠状态,只有定时唤醒发数据时才会进入活跃状态。休眠电流在微安级别,收发状态电流在毫安级甚至更高。一个典型的例子:某颗Sub-GHz芯片在接收模式下的电流大概是十几毫安,发送模式大概二十到四十毫安,而休眠模式只有几微安。假设节点每30秒发送一次,发送和接收窗口合计约20毫秒,那么平均电流大约只有几十微安,两节AA电池撑大半年完全没问题。但如果把接收窗口设得太长——比如为了等待主节点下发指令而一直保持接收状态,这个功耗会直接变成常数项,电池寿命会从"年"变成"天"。我的建议是:简单协议下除非有明确的实时性需求,否则从节点上报完毕后立刻回床,不要悬挂在接收态上。这个结论不是靠想象,是我拿着电流探头记录过一条完整的唤醒-发送-接收ACK-休眠曲线后得出来的。

调试阶段有几个特别容易踩的坑,单独拿出来说。第一是逻辑分析仪看不全SPI波形。射频芯片和主控之间往往是SPI接口,但SPI波形只能确认主控芯片是否正确收发数据,完全看不到空中发生了什么。排查"发射端发送了但接收端没收到"之类的问题,必须靠频谱仪和无线监听工具。频谱仪能看到发射信号是否存在、功率够不够、是否有频偏;而一个真正好用的无线抓包工具能把空中的帧解析成十六进制显示,让你直接看出协议层面上数据有没有发错。我调试早期没有无线抓包工具,只能靠收发两端串口打印来回对比,效率低得让人抓狂。后来装上抓包工具,五分钟就能定位一次链路层问题。

第二是串口打印会影响时序。从节点的串口打印速度如果没调好,打印一段日志可能要花掉几十毫秒,这在调试收发切换时会导致微秒级的时序全部被破坏。我后来养成的习惯是:调试协议时序时,把所有串口打印全部关掉,用GPIO翻电平配合逻辑分析仪看时序;确认逻辑正确后再打开打印做业务层的调试。这个习惯帮我避免了很多次"代码看着没问题,时序实际一团糟"的尴尬。

第三是接收端的晶振精度。Sub-GHz通信的收发两端如果晶振偏差太大,会引起频偏,导致接收灵敏度下降。我遇到过一批从节点批量测试时丢包率偏高,查了很久发现是其中一部分板子的晶振精度和主节点不匹配,空中频偏超出了芯片的容忍范围。后来在设计PCB时把晶振选型统一,并在协议里把前导码保留足够长度,接收端才能在频偏情况下完成同步。这个坑很多新手注意不到,说穿了其实很基础:射频的所有指标都是围绕晶振建立的,晶振误差是所有无线系统需要面对的基础问题。

最后说一个性能指标供参考。在这个协议配置下,实测室内短距离通信的丢包率(不重传的情况下)在附近无强干扰时大约是0.1%到1%,在拥挤的2.4GHz频段则明显恶化,但Sub-GHz频段表现稳定。加一次ACK重传之后,有效成功率能到99.9%以上,多数情况下完全够用。整个接收端的解析函数,包括状态机、CRC校验、去重判断,在常见的8位单片机上运行大约占用几百字节RAM和不到两千字节Flash,开销极小。

整个协议从设计到稳定运行,一共花了两周左右时间。期间两度推翻初版设计:一次是长度字段忘了做上限,导致接收端卡死;一次是ACK没有带序列号,导致重复数据处理不干净。每一次推倒重来都让我更确信一个观点——简单协议不是"随便设计一下就完事",而是"在把复杂问题想清楚之后,只保留最必要机制的克制"。这套协议现在还在那个项目上跑着,后续我打算把上位机解析那部分也做成一个独立的日志工具,这样换信道参数或者调整帧格式时,不用动节点代码,直接改工具配置就能验证。如果你也在做一个轻量无线项目,希望这篇能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询