第二章 自定义协议基础:协议分层模型、帧结构设计原则、字节序与对齐规则

各位同学,咱们今天聊点实在的。做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 设计原则:简单、明确、无歧义

这里我总结了三条铁律:

  1. 帧头帧尾必须唯一:不能和数据域里的任何字节重复。如果重复,必须做转义。
  2. 长度字段必须可靠:长度字段本身要包含在校验范围内。否则有人篡改长度,你根本发现不了。
  3. 校验要覆盖整个帧:从地址字段开始,到数据域结束,全部参与校验。帧头和帧尾可以不参与,因为它们固定不变。

你想想看,如果校验只覆盖数据域,攻击者改了地址字段,你校验通过,但报文却发给了错误的设备。这后果,在工业现场可能就是一条生产线停摆。

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天。因为所有细节都在文档里定死了,写代码就是翻译工作。

嗯,这一章的内容就到这里。下一章,我们聊聊如何用代码实现一个完整的自定义协议栈。到时候,我会带你手写一个轻量级的帧解析器。