第二章 自定义协议基础:协议分层模型、帧结构设计原则、字节序与对齐规则
各位同学,咱们今天聊点实在的。做SCADA系统,免不了要跟各种设备打交道。很多时候,现成的Modbus、DNP3不够用,或者效率太低,你就得自己动手设计协议。这一章,我带你打好地基。
说白了,自定义协议就是你和设备之间约定好的一套“黑话”。怎么约?从哪开始?别急,咱们一步步来。
2.1 协议分层模型:别一个人扛所有事
我刚开始做协议的时候,总想把所有功能塞到一个数据包里。结果呢?改一处,崩一片。后来我才明白,分层是解决复杂问题的唯一出路。
你想想看,TCP/IP协议栈为什么能统治世界?因为它把通信拆成了几层,每层只管自己的事。咱们自定义协议,也得这么干。
2.1.1 我们该用哪套模型?
OSI七层模型太理想化了,实际工业现场没人用全。我个人的习惯是,直接参考TCP/IP四层模型,但只取下面两层:
- 物理层:RS-485、以太网、光纤……你用什么介质,这层就管什么。
- 数据链路层:这才是我们自定义协议的主战场。帧结构、寻址、差错校验,全在这层搞定。
至于上面的网络层和传输层,如果你跑在TCP/UDP上,操作系统帮你做了。如果你跑在串口上,那这两层基本可以合并到数据链路层里。
我的经验:在PLC和RTU之间通信,我通常只设计两层。一层管物理收发,一层管业务逻辑。层数多了,嵌入式设备跑不动。
2.1.2 分层的好处是什么?
举个例子。你原来用RS-485,现在想换成以太网。如果协议不分层,你得重写整个通信栈。如果分层了,你只需要换掉最底层的物理收发代码,上面的帧解析、业务逻辑完全不用动。
嗯,这就是分层的魅力。我在一个水处理项目中,就因为当初分了层,后来从串口升级到以太网,只花了两天时间。
2.2 帧结构设计原则:别让设备看不懂
帧结构,就是数据包的“格式”。设计得好,设备解析起来像喝水一样简单。设计得不好,那就是一场灾难。
我见过最离谱的协议,帧头帧尾居然用同一个字节。结果设备经常“串帧”,把两条报文当成一条解析。你说这数据能准吗?
2.2.1 一个标准的帧结构长什么样?
我个人习惯,帧结构至少包含以下五个部分:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2~4 | 固定值,比如0xAA 0x55,用于同步 |
| 地址 | 1~4 | 设备地址或功能码 |
| 长度 | 1~2 | 数据域的长度 |
| 数据域 | 可变 | 真正的业务数据 |
| 校验 | 1~4 | CRC16或CRC32,保证数据完整性 |
| 帧尾 | 1~2 | 固定值,比如0x0D 0x0A |
避坑指南:我曾经设计过一个协议,帧头用了0x7E。结果发现数据域里也经常出现0x7E,导致解析混乱。后来我学乖了,帧头用两个字节,并且做字节填充(Byte Stuffing),把数据域里的0x7E转义成0x7D 0x5E。
2.2.2 设计原则:简单、明确、无歧义
这里我总结了三条铁律:
- 帧头帧尾必须唯一:不能和数据域里的任何字节重复。如果重复,必须做转义。
- 长度字段必须可靠:长度字段本身要包含在校验范围内。否则有人篡改长度,你根本发现不了。
- 校验要覆盖整个帧:从地址字段开始,到数据域结束,全部参与校验。帧头和帧尾可以不参与,因为它们固定不变。
你想想看,如果校验只覆盖数据域,攻击者改了地址字段,你校验通过,但报文却发给了错误的设备。这后果,在工业现场可能就是一条生产线停摆。
2.3 字节序与对齐规则:大端小端,傻傻分不清楚
这个问题,我敢说90%的协议坑都出在这里。不同CPU,不同编译器,对多字节数据的存储方式完全不同。
我记得有一次,一个合作伙伴的PLC发过来的32位浮点数,我这边怎么解析都不对。折腾了一整天,最后发现他们用的是大端,我用的是小端。就这一个字节序问题,差点让项目延期。
2.3.1 大端 vs 小端
- 大端(Big-Endian):高位字节存低地址。比如0x1234,内存里是0x12 0x34。Motorola的CPU、网络协议(TCP/IP)默认用这个。
- 小端(Little-Endian):低位字节存低地址。比如0x1234,内存里是0x34 0x12。Intel的CPU、Windows系统默认用这个。
说白了,就是谁先谁后的问题。大端更符合人类阅读习惯,小端更符合CPU处理习惯。
重要提醒:在自定义协议中,你必须明确指定字节序。我个人的建议是,统一使用大端(网络字节序)。因为SCADA系统经常要跨平台,大端是业界共识。
2.3.2 对齐规则:结构体不是你想的那样
C语言里,结构体成员在内存中不是紧挨着的。编译器会自动填充(Padding),让每个成员对齐到它的自然边界。比如一个4字节的int,它的地址必须是4的倍数。
这会导致什么问题?你定义了一个结构体,直接memcpy发出去,接收方用同样的结构体解析,结果数据全错位了。
看个例子:
// 发送方定义
struct Data {
uint8_t flag; // 1字节
uint32_t value; // 4字节
uint16_t crc; // 2字节
};
// 你以为的内存布局:1 + 4 + 2 = 7字节
// 实际的内存布局:1 + 3填充 + 4 + 2 = 10字节
为什么会这样?因为value是4字节,它的起始地址必须是4的倍数。flag占了1字节,后面3字节被编译器填充了。
解决方案有两种:
- 手动打包:不用结构体,用字节数组手动拼接。虽然麻烦,但绝对可控。
- 使用#pragma pack(1):告诉编译器不要填充,按1字节对齐。但要注意,这会影响性能,而且有些ARM芯片不支持非对齐访问。
我的习惯:在协议设计阶段,我就把字段按4字节对齐排列。比如上面的例子,我会把flag和crc合并成一个uint32_t,或者把flag放到最后。这样既不用手动打包,也不用担心对齐问题。
2.4 实战建议:从设计文档开始
说了这么多,最后给你一个实操建议。不要急着写代码,先画一张帧结构图。把每个字段的偏移、长度、字节序、取值范围都写清楚。
我曾经带过一个团队,协议文档写了30页,但代码只写了3天。因为所有细节都在文档里定死了,写代码就是翻译工作。
嗯,这一章的内容就到这里。下一章,我们聊聊如何用代码实现一个完整的自定义协议栈。到时候,我会带你手写一个轻量级的帧解析器。