4. 通信协议设计(下):自定义应用层协议、报文格式定义、CRC校验实现、超时与重传机制
好,咱们接着聊。上一节我们把物理层和链路层的基本套路捋了一遍,这一节要进入真正「肉疼」的部分了——自定义应用层协议。
说白了,Modbus、CANopen这些标准协议虽然好用,但在电池化成这种高实时、多通道、数据量大的场景下,往往不够灵活。我做过好几个项目,一开始图省事直接用Modbus,结果到了后期发现报文太臃肿,一个帧里塞不下几个数据,效率低得让人抓狂。后来就老老实实自己设计了一套协议。
嗯,今天我就把当年踩过的坑和总结出来的经验,一次性倒给你们。
4.1 为什么需要自定义应用层协议?
你想想看,电池化成设备一个机柜少说几十个通道,多则上百个。每个通道要实时上报电压、电流、温度、容量、状态……如果用标准协议,光是地址和功能码就占掉一大半字节,真正有效的数据反而没多少。
我在项目中遇到过最极端的情况:一个机柜256个通道,每100ms要刷新一次全部数据。用Modbus RTU算下来,光轮询一遍就要好几秒,根本没法用。
所以,自定义协议的核心目标就三个:
- 紧凑——能用一个字节表达的,绝不用两个
- 高效——一次交互能传尽量多的有效数据
- 可靠——丢包、错包能发现,能恢复
核心原则:协议设计不是越复杂越好,而是刚好够用,留有余地。
4.2 报文格式定义——我的「三板斧」
我个人习惯把报文分成三大块:帧头、数据域、帧尾。每一块都有讲究。
4.2.1 帧头设计
帧头我一般固定用4个字节:
| 字节偏移 | 字段名 | 长度 | 说明 |
|---|---|---|---|
| 0 | 起始标识 | 1字节 | 固定0xAA,用于帧同步 |
| 1 | 协议版本 | 1字节 | 0x01表示V1.0,方便后续升级 |
| 2 | 报文类型 | 1字节 | 0x01=读请求,0x02=写请求,0x81=读响应,0x82=写响应 |
| 3 | 数据长度 | 1字节 | 数据域的有效字节数,最大255 |
这里有个小细节:起始标识我选0xAA而不是0xFF或0x00。为什么?0xAA的二进制是10101010,在串口通信中不容易被误判为空闲或断线状态。这是我当年被坑过一次后学乖的——有次选了0x00做起始,结果设备断电重启时一堆0x00的垃圾数据,直接把协议搞崩了。
4.2.2 数据域设计
数据域我习惯用TLV格式——Type、Length、Value。这样扩展性最好。
// 数据域结构示例
typedef struct {
uint8_t type; // 数据类型:0x01=电压,0x02=电流,0x03=温度...
uint8_t length; // 数据长度(字节数)
uint8_t* value; // 实际数据
} TLV_Item;
// 一个完整的读响应报文示例
// 帧头: AA 01 81 06
// 数据域:
// 01 02 0A 1E -- 电压: 0x0A1E = 2590mV
// 02 02 00 32 -- 电流: 0x0032 = 50mA
// 03 01 1E -- 温度: 0x1E = 30°C
// CRC: 两个字节
你可能会问:为什么不用固定偏移?固定偏移解析快啊。没错,但固定偏移有个致命问题——一旦协议升级加了新字段,老设备就解析不了。TLV虽然解析慢一点,但兼容性极好。我个人建议:控制类报文用固定偏移(追求速度),数据类报文用TLV(追求灵活)。
4.2.3 帧尾设计
帧尾就两样东西:CRC校验和结束标识。
| 字节偏移 | 字段名 | 长度 | 说明 |
|---|---|---|---|
| N | CRC16 | 2字节 | 从帧头到数据域的CRC校验 |
| N+2 | 结束标识 | 1字节 | 固定0x55,辅助帧同步 |
小技巧:结束标识用0x55(二进制01010101),和起始标识0xAA正好互补。这样在调试时用逻辑分析仪一看,帧边界一目了然。
4.3 CRC校验实现——别自己发明算法
CRC校验这块,我见过太多人自己写算法了。说实话,千万别自己发明CRC算法。标准CRC16、CRC32已经经过几十年验证,你非要自己搞一个,大概率有漏洞。
我一般直接用查表法实现CRC16-CCITT,速度快,占用资源少。
// CRC16-CCITT 查表法实现
// 多项式: 0x1021
// 初始值: 0xFFFF
static const uint16_t crc16_table[256] = {
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
// ... 省略中间数据,实际使用时需要完整256项
};
uint16_t calc_crc16(uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++)
{
crc = (crc << 8) ^ crc16_table[((crc >> 8) ^ data[i]) & 0xFF];
}
return crc;
}
// 使用示例
uint8_t tx_buffer[256];
uint16_t crc_value = calc_crc16(tx_buffer, data_len);
tx_buffer[data_len] = (crc_value >> 8) & 0xFF; // CRC高字节
tx_buffer[data_len + 1] = crc_value & 0xFF; // CRC低字节
注意:CRC计算的范围一定要明确。我建议从帧头的第一个字节开始算,到数据域的最后一个字节结束。不要把起始标识和结束标识算进去,否则帧同步和校验会互相干扰。
我曾经在一个项目里犯过这个错——把起始标识也算进CRC了。结果有一次通信受到干扰,起始标识被误判,整个帧解析全乱套了。从那以后,我定了个规矩:帧同步字段不参与校验,校验只保护有效数据。
4.4 超时与重传机制——别让设备死等
通信不可能永远不出错。线缆老化、电磁干扰、设备繁忙……随便一个原因就能让报文石沉大海。所以,超时和重传机制是必须的。
4.4.1 超时时间怎么定?
这个没有标准答案,但我有个经验公式:
超时时间 = 正常响应时间 × 3 + 50ms
举个例子,如果正常情况下设备收到请求后10ms内必须回复,那超时时间就设为80ms。为什么是3倍?因为要考虑最坏情况——设备刚好在处理其他任务,或者总线刚好在忙。
我建议把超时时间做成可配置的参数,放在配置文件中。这样现场调试时可以灵活调整,不用改代码。
4.4.2 重传策略
重传不是简单的「发一次,等超时,再发一次」。我一般用指数退避策略:
- 第一次重传:等待 1 倍超时时间
- 第二次重传:等待 2 倍超时时间
- 第三次重传:等待 4 倍超时时间
- ……
- 最多重传 5 次,之后报错
为什么要指数退避?你想想看,如果网络已经拥塞了,你还在拼命重传,只会让情况更糟。指数退避相当于给网络一个喘息的机会。
重要:重传次数一定要有限制。我见过有人没设上限,结果设备死循环重传,CPU占用率100%,其他任务全被饿死了。
4.4.3 去重处理
重传会带来一个新问题——重复报文。比如设备收到了请求,也处理了,但响应报文在回传时丢了。上位机超时重传,设备又收到一次同样的请求……
解决办法很简单:给每个报文加一个序列号。
// 在帧头中增加序列号字段
typedef struct {
uint8_t start_flag; // 0xAA
uint8_t version; // 0x01
uint8_t msg_type; // 报文类型
uint8_t data_len; // 数据长度
uint16_t sequence; // 序列号,递增
uint8_t* data; // 数据域
uint16_t crc; // CRC16
uint8_t end_flag; // 0x55
} Frame_Header;
// 设备端处理逻辑
if (frame.sequence == last_sequence) {
// 重复报文,直接丢弃,但重新发送上一次的响应
resend_last_response();
} else {
// 新报文,正常处理
last_sequence = frame.sequence;
process_frame(&frame);
}
这个序列号的设计,说白了就是让通信双方能「对账」。我当年调试一个多主站系统时,没有序列号,结果两个主站同时发请求,设备根本分不清哪个响应该给谁。加上序列号后,问题迎刃而解。
4.5 实战经验总结
好了,讲了这么多,我最后总结几条实战经验:
- 协议设计文档一定要写。别偷懒,哪怕只有你一个人开发。三个月后你自己都记不住每个字节的含义。
- 预留扩展位。帧头里留几个保留字节,数据域里留一些未定义的Type。你不知道未来会加什么功能。
- 调试工具要跟上。我习惯写一个协议分析器,能实时解析报文、显示CRC是否正确、统计超时次数。没有工具,调试就是瞎子摸象。
- 先模拟后联调。写一个虚拟设备程序,在上位机开发阶段就能模拟各种异常情况——丢包、错包、延迟响应。等真机联调时,问题会少很多。
避坑指南:我曾经在一个项目里,协议设计好了,代码写完了,结果联调时发现设备端和上位机的字节序不一样——设备是大端,上位机是小端。一个uint16_t的数据,解析出来完全不对。所以,协议文档里一定要明确字节序,最好统一用大端(网络字节序)。
嗯,这一节的内容就到这儿。自定义协议设计说难不难,说简单也不简单。关键是要想清楚你的应用场景,然后做取舍。下一节我们开始聊上位机的软件架构设计,那又是一个大话题。