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 实战经验总结

好了,讲了这么多,我最后总结几条实战经验:

  1. 协议设计文档一定要写。别偷懒,哪怕只有你一个人开发。三个月后你自己都记不住每个字节的含义。
  2. 预留扩展位。帧头里留几个保留字节,数据域里留一些未定义的Type。你不知道未来会加什么功能。
  3. 调试工具要跟上。我习惯写一个协议分析器,能实时解析报文、显示CRC是否正确、统计超时次数。没有工具,调试就是瞎子摸象。
  4. 先模拟后联调。写一个虚拟设备程序,在上位机开发阶段就能模拟各种异常情况——丢包、错包、延迟响应。等真机联调时,问题会少很多。

避坑指南:我曾经在一个项目里,协议设计好了,代码写完了,结果联调时发现设备端和上位机的字节序不一样——设备是大端,上位机是小端。一个uint16_t的数据,解析出来完全不对。所以,协议文档里一定要明确字节序,最好统一用大端(网络字节序)。

嗯,这一节的内容就到这儿。自定义协议设计说难不难,说简单也不简单。关键是要想清楚你的应用场景,然后做取舍。下一节我们开始聊上位机的软件架构设计,那又是一个大话题。