3、协议帧结构设计:帧头帧尾定义、长度字段设计、类型字段设计、序列号机制
好,咱们今天聊点实在的。协议帧结构,说白了就是你和设备之间约定好的「暗号格式」。我见过太多项目,协议写得天花乱坠,一到现场联调就崩——十有八九是帧结构没设计好。这一节,我把这些年踩过的坑和总结的经验,掰开了揉碎了讲给你听。
3.1 帧头帧尾:协议的「门神」
帧头和帧尾,就是告诉接收方「我要开始说话了」和「我说完了」。这俩要是选不好,整个协议就废了。
帧头怎么选?我个人习惯用 0xAA 0x55 这种交替比特模式。为什么?因为 0xAA 是 10101010,0x55 是 01010101,这种模式在串口线上不容易被误判。我曾经在一个项目中,客户非要用 0xFF 0xFF 做帧头,结果现场有干扰,连续几个 0xFF 就把接收缓冲区冲乱了,整个系统直接死机。嗯,从那以后我再也不用全 1 或全 0 做帧头了。
帧尾怎么选?我建议用 0x0D 0x0A(回车换行)。这俩字符在 Modbus RTU 里是结束标志,在 ASCII 协议里也是。你想想看,如果帧尾和帧头一样,接收方怎么区分「这是上一帧的尾巴」还是「下一帧的头」?
来看一个典型的帧头帧尾定义:
#define FRAME_HEADER1 0xAA
#define FRAME_HEADER2 0x55
#define FRAME_FOOTER1 0x0D
#define FRAME_FOOTER2 0x0A
3.2 长度字段:别让接收方猜「你说了多少」
长度字段,就是告诉对方「我后面跟了多少数据」。没有这个字段,接收方只能靠超时来判断一帧结束——这在实时性要求高的场景下简直是灾难。
长度字段放哪里?我建议放在帧头之后、类型字段之前。为什么?因为接收方拿到帧头后,第一件事就是想知道「我要准备多大的缓冲区」。你想想看,如果长度字段放在帧尾,接收方得先把整帧收完才知道长度——那还要它干嘛?
长度字段包含什么?这里有个常见的坑。长度字段到底只算数据载荷,还是连类型、序列号、校验都算?我个人习惯:长度字段只包含从类型字段开始到校验字段之前的所有字节数。这样设计的好处是,接收方解析时逻辑清晰,不容易算错。
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 2 | 0xAA 0x55 |
| 长度 | 2 | 从类型到校验的字节数 |
| 类型 | 1 | 命令/响应标识 |
| 序列号 | 2 | 请求-响应配对 |
| 数据载荷 | N | 实际业务数据 |
| 校验 | 2 | CRC16 |
| 帧尾 | 2 | 0x0D 0x0A |
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 // 报警事件
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 {
// 序列号不匹配,可能是超时重传或乱序
}
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字节)
+--+--+--+--+--+--+--+--+--+--+--+--+-- 帧头
好,这一节就到这儿。下一节咱们聊聊「协议解析状态机设计」——说白了就是怎么把收到的字节流,正确地拆成一帧一帧的数据。到时候我会分享一个我在电力项目中用过的状态机模板,保证你拿来就能用。