3、协议帧结构设计:帧头帧尾定义、长度字段设计、类型字段设计、序列号机制

好,咱们今天聊点实在的。协议帧结构,说白了就是你和设备之间约定好的「暗号格式」。我见过太多项目,协议写得天花乱坠,一到现场联调就崩——十有八九是帧结构没设计好。这一节,我把这些年踩过的坑和总结的经验,掰开了揉碎了讲给你听。

3.1 帧头帧尾:协议的「门神」

帧头和帧尾,就是告诉接收方「我要开始说话了」和「我说完了」。这俩要是选不好,整个协议就废了。

帧头怎么选?我个人习惯用 0xAA 0x55 这种交替比特模式。为什么?因为 0xAA 是 10101010,0x55 是 01010101,这种模式在串口线上不容易被误判。我曾经在一个项目中,客户非要用 0xFF 0xFF 做帧头,结果现场有干扰,连续几个 0xFF 就把接收缓冲区冲乱了,整个系统直接死机。嗯,从那以后我再也不用全 1 或全 0 做帧头了。

帧尾怎么选?我建议用 0x0D 0x0A(回车换行)。这俩字符在 Modbus RTU 里是结束标志,在 ASCII 协议里也是。你想想看,如果帧尾和帧头一样,接收方怎么区分「这是上一帧的尾巴」还是「下一帧的头」?

核心原则:帧头和帧尾必须互不相同,且不能出现在数据载荷中。如果数据里可能包含帧头字节,必须做字节填充(Byte Stuffing)或转义处理。

来看一个典型的帧头帧尾定义:

#define FRAME_HEADER1    0xAA
#define FRAME_HEADER2    0x55
#define FRAME_FOOTER1    0x0D
#define FRAME_FOOTER2    0x0A
小技巧:帧头用 2 个字节,帧尾也用 2 个字节。单字节太容易被误触发,3 个字节又浪费带宽。2 字节是性价比最高的选择。

3.2 长度字段:别让接收方猜「你说了多少」

长度字段,就是告诉对方「我后面跟了多少数据」。没有这个字段,接收方只能靠超时来判断一帧结束——这在实时性要求高的场景下简直是灾难。

长度字段放哪里?我建议放在帧头之后、类型字段之前。为什么?因为接收方拿到帧头后,第一件事就是想知道「我要准备多大的缓冲区」。你想想看,如果长度字段放在帧尾,接收方得先把整帧收完才知道长度——那还要它干嘛?

长度字段包含什么?这里有个常见的坑。长度字段到底只算数据载荷,还是连类型、序列号、校验都算?我个人习惯:长度字段只包含从类型字段开始到校验字段之前的所有字节数。这样设计的好处是,接收方解析时逻辑清晰,不容易算错。

字段 字节数 说明
帧头 2 0xAA 0x55
长度 2 从类型到校验的字节数
类型 1 命令/响应标识
序列号 2 请求-响应配对
数据载荷 N 实际业务数据
校验 2 CRC16
帧尾 2 0x0D 0x0A
注意:长度字段本身不要算自己。比如上表中长度字段占 2 字节,但长度值里不包含这 2 字节。否则接收方解析时会出现「先有鸡还是先有蛋」的死循环。

3.3 类型字段:给协议装上「交通灯」

类型字段,就是告诉接收方「我这条消息是干嘛的」。没有类型字段,你只能靠数据内容去猜——这在复杂系统中根本行不通。

类型怎么分类?我一般分成三大类:

  • 命令帧(0x01-0x3F):主站发给从站的指令,比如读寄存器、写参数、启动设备。
  • 响应帧(0x41-0x7F):从站回复主站的结果,比如成功、失败、数据内容。
  • 事件帧(0x81-0xBF):从站主动上报的信息,比如报警、状态变化。

为什么要留出 0x40 的偏移?说白了就是为了区分请求和响应。你想想看,如果命令 0x01 和响应 0x01 都用同一个值,接收方怎么知道这是「我发的命令」还是「对方回的响应」?

我曾经在一个项目中,客户把命令和响应混在一起,结果调试时抓包一看,满屏都是 0x01,根本分不清谁是谁。后来我强制要求:命令帧最高位为 0,响应帧最高位为 1。这样一眼就能看出来。

#define CMD_READ_REG      0x01   // 读寄存器命令
#define RSP_READ_REG      0x41   // 读寄存器响应
#define CMD_WRITE_REG     0x02   // 写寄存器命令
#define RSP_WRITE_REG     0x42   // 写寄存器响应
#define EVT_ALARM         0x81   // 报警事件
经验之谈:类型字段用 1 个字节就够了。2 个字节太浪费,1 个字节能定义 256 种类型,对于绝大多数 SCADA 系统来说绰绰有余。

3.4 序列号机制:给每条消息贴上「身份证」

序列号,就是给每条消息一个唯一的编号。没有序列号,你根本不知道「这条响应是对应哪条请求的」。

序列号怎么生成?我建议用 16 位无符号整数,从 0 开始递增,到 65535 后回绕到 0。为什么不用 8 位?因为 256 个序列号在高速通信中很快就会被用完,容易出现重复。为什么不用 32 位?太浪费,16 位刚刚好。

序列号怎么用?主站发送请求时,把当前序列号填入帧中。从站收到后,在响应帧中把同样的序列号带回来。这样主站收到响应时,就能知道「哦,这是对应我第 123 号请求的回复」。

我记得有一次,现场有 200 多个从站同时上报数据,主站收到的响应乱成一锅粥。就是因为没有序列号,主站根本不知道哪条响应对应哪个请求。后来我加上序列号机制,问题立刻解决。你想想看,如果没有序列号,你怎么知道「这条数据是 5 秒前请求的,还是 5 毫秒前请求的」?

序列号的核心作用:
  • 请求-响应配对:知道谁对应谁
  • 去重:收到重复的响应可以丢弃
  • 超时检测:如果某个序列号长时间没收到响应,可以重发
  • 乱序重排:如果响应顺序和请求顺序不一致,可以按序列号排序
// 序列号管理示例
uint16_t g_seq_num = 0;

uint16_t get_next_seq(void) {
    return g_seq_num++;
}

// 发送请求时
uint16_t seq = get_next_seq();
send_frame(CMD_READ_REG, seq, data, len);

// 接收响应时
if (recv_frame.seq == expected_seq) {
    // 匹配成功,处理数据
} else {
    // 序列号不匹配,可能是超时重传或乱序
}
避坑指南:我曾经遇到过一个情况,从站重启后序列号从 0 开始,而主站还在用之前的序列号等待响应。结果主站收到从站重启后的第一条响应,发现序列号对不上,直接丢弃了。解决方案是:从站重启后发送一个「重启通知」帧,主站收到后重置所有等待中的请求。

3.5 综合示例:一个完整的帧结构

好了,咱们把上面这些串起来,看一个完整的帧结构长什么样:

帧结构定义(大端模式):
+--------+--------+--------+--------+--------+--------+--------+--------+
| 帧头1  | 帧头2  | 长度H  | 长度L  | 类型   | 序列号H| 序列号L| 数据...|
| 0xAA   | 0x55   |        |        |        |        |        |        |
+--------+--------+--------+--------+--------+--------+--------+--------+
| 数据...| 校验H  | 校验L  | 帧尾1  | 帧尾2  |
|        | CRC16  | CRC16  | 0x0D   | 0x0A   |
+--------+--------+--------+--------+--------+

示例:读取从站地址 0x01 的寄存器
请求帧:AA 55 00 05 01 00 01 01 02 03 04 0D 0A
         |  |  |  |  |  |  |  |  |  |  |  |  |
         |  |  |  |  |  |  |  |  +--+--+--+--+-- 帧尾
         |  |  |  |  |  |  |  +--+--+--+--+--+-- CRC16校验
         |  |  |  |  |  |  +--+--+--+--+--+--+-- 数据载荷(寄存器地址+数量)
         |  |  |  |  |  +--+--+--+--+--+--+--+-- 序列号=0x0001
         |  |  |  |  +--+--+--+--+--+--+--+--+-- 类型=0x01(读寄存器命令)
         |  |  +--+--+--+--+--+--+--+--+--+--+-- 长度=0x0005(从类型到校验共5字节)
         +--+--+--+--+--+--+--+--+--+--+--+--+-- 帧头
最后说一句:帧结构设计没有绝对的对错,但有一些「最好别碰」的雷区。比如帧头和帧尾不要用相同值,长度字段不要把自己算进去,序列号一定要有回绕处理。把这些基础打牢了,后面的协议解析、状态机设计才能顺风顺水。

好,这一节就到这儿。下一节咱们聊聊「协议解析状态机设计」——说白了就是怎么把收到的字节流,正确地拆成一帧一帧的数据。到时候我会分享一个我在电力项目中用过的状态机模板,保证你拿来就能用。