设备通信与协议编程(三):二进制协议帧、消息边界与版本兼容

串口和 TCP 都提供字节流或字节块,却不会替应用识别“这一条命令到哪里结束”。二进制协议设计的第一要务,是让接收端在拆包、粘包(一次读取返回半帧或多帧,见上一篇)、噪声和版本升级下仍能确定消息边界;TCP 传输下的完整请求/响应模型见本系列第 5 篇。

1. 一帧需要承担什么

一个常见帧结构如下:

1
Magic | Version | Flags | Command | Sequence | PayloadLength | Payload | Check
字段作用
Magic快速识别协议并在噪声后重新同步
Version明确解析规则,不靠猜测
Flags表示应答、错误、压缩等可选语义
Command说明载荷类型
Sequence关联请求与响应、识别迟到消息
PayloadLength给出边界,并在分配前做上限检查
Check检测传输或组帧错误,不等同于安全认证

字段不是越多越好。每个字段都应对应一个真实的演进、诊断或恢复需求。

2. 三种常见定界方式

2.1 固定长度

实现最简单且时间可预测,但浪费带宽、扩展困难。适合数据结构长期稳定且长度很小的周期报文。

2.2 长度前缀

头部携带载荷长度,适合二进制协议和 TCP。解析前必须满足:

1
0 <= PayloadLength <= MaxPayloadLength

最大长度必须来自协议契约,而不是当前机器可用内存。

2.3 分隔符与转义

文本协议常用换行,二进制链路可用特殊字节加 byte stuffing。若载荷也可能出现分隔符,就要转义;解析器必须定义转义字节本身、连续转义和截断转义如何处理。

“静默时间作为边界”只适用于明确规定该语义的协议,例如 Modbus RTU。普通异步程序不能依赖一次 Read 的停顿猜帧结束。

3. 端序必须逐字段定义

多字节整数在协议中应明确为 little-endian 或 big-endian。不要把 C# 结构体直接复制到线路上:运行时布局、填充、CPU 端序和版本演进都会让它失去可移植性。

1
2
3
4
5
6
7
using System.Buffers.Binary;

Span<byte> header = stackalloc byte[8];
header[0] = 0xA5;
header[1] = 1;
BinaryPrimitives.WriteUInt16BigEndian(header[2..], command);
BinaryPrimitives.WriteUInt32BigEndian(header[4..], payloadLength);

对应读取也使用 BinaryPrimitives.Read*Endian,让线路端序在代码里可见。

4. 增量解析器的状态

解析器面对的是任意切片,至少需要这几个状态:

1
寻找 Magic → 等待固定头 → 校验长度 → 等待完整载荷 → 校验并产出帧

关键不变量:

  • 数据不足时返回“需要更多”,而不是把半帧判为错误;
  • 数据非法时至少丢弃一个字节并继续寻找同步点;
  • 产出一帧后继续解析缓冲区,处理粘连的下一帧;
  • 累积缓冲区和单帧长度都有硬上限;
  • 解析失败不会读取到缓冲区之外。

下面展示固定 8 字节头的最小解析函数。它只解析当前连续缓冲区,调用方负责保留未消费尾部:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System.Buffers.Binary;

public readonly record struct DeviceFrame(
    byte Version,
    ushort Command,
    ReadOnlyMemory<byte> Payload);

public enum ParseResult { NeedMoreData, Frame, InvalidData }

public static class FrameParser
{
    private const byte Magic = 0xA5;
    private const int HeaderSize = 8;
    private const int MaxPayloadLength = 1024 * 1024;

    public static ParseResult TryParse(
        ReadOnlyMemory<byte> input,
        out DeviceFrame frame,
        out int consumed)
    {
        frame = default;
        consumed = 0;
        var span = input.Span;

        if (span.Length == 0)
            return ParseResult.NeedMoreData;

        if (span[0] != Magic)
        {
            consumed = 1;
            return ParseResult.InvalidData;
        }

        if (span.Length < HeaderSize)
            return ParseResult.NeedMoreData;

        byte version = span[1];
        ushort command = BinaryPrimitives.ReadUInt16BigEndian(span[2..4]);
        uint length = BinaryPrimitives.ReadUInt32BigEndian(span[4..8]);

        if (version != 1 || length > MaxPayloadLength)
        {
            consumed = 1;
            return ParseResult.InvalidData;
        }

        int frameLength = checked(HeaderSize + (int)length);
        if (span.Length < frameLength)
            return ParseResult.NeedMoreData;

        // ToArray 让产出的帧不依赖调用方随后复用的接收缓冲区。
        frame = new DeviceFrame(version, command,
            input.Slice(HeaderSize, (int)length).ToArray());
        consumed = frameLength;
        return ParseResult.Frame;
    }
}

生产实现还要把校验字段纳入帧长,并决定错误后是丢一个字节、跳到下一个 Magic,还是重置整个会话。Magic 可能出现在载荷中,因此“直接跳到下一个 Magic”只是恢复启发式,不是正确性证明。

5. 请求、响应和异步事件

设备协议通常同时存在三类消息:

  • 请求:主机发起操作;
  • 响应:携带相同序列号或明确的关联字段;
  • 事件:设备主动上报,不应被误配给当前请求。

只有一个在途请求时,也建议保留序列号。超时响应、重连前残留数据和日志关联都会用到它。序列号回绕时,要结合连接世代和有限的在途窗口判断,不能假设整数永不重复。命令合法性与排队执行的模型见《设备软件架构与控制模型(四):状态机、命令队列与流程编排》。

6. 版本兼容策略

版本字段不是万能开关。更稳健的演进规则是:

  1. 固定头尽量稳定,新字段放在有长度的扩展区;
  2. 未识别的可选字段可以跳过,未识别的关键语义必须拒绝;
  3. 枚举预留未知值处理,不把未来值映射成当前默认值;
  4. 请求和响应都显式协商能力,不仅比较一个版本数字;
  5. 旧固件无法安全理解的命令,不要靠“尽量解析”冒险执行。

7. 安全与资源边界

二进制解析器直接处理不可信输入。即使设备位于内网,也应防御:

  • 长度字段导致的超大分配;
  • 整数加法溢出;
  • 无限等待未完成帧;
  • 垃圾输入导致 O(n²) 扫描;
  • 压缩载荷解压炸弹;
  • 日志直接输出敏感载荷。

CRC 只能检测偶发错误,不能证明发送者身份,也不能阻止恶意篡改。需要安全边界时使用经过评审的认证与加密方案。

8. 测试矩阵

最少覆盖:空输入、逐字节输入、头部截断、载荷截断、两帧粘连、Magic 错误、未知版本、长度为零、最大合法长度、超过上限、校验错误、随机噪声后恢复和序列号回绕。

属性测试或模糊测试尤其适合解析器:对任意输入,函数都不应越界、挂死或无界分配;成功产出的帧再编码后应满足约定的不变量。与模拟器/回放环境的整体测试梯度见《设备软件架构与控制模型(七):实机、模拟器与 HIL》。

9. 小结

可靠协议帧的核心是明确边界和失败语义。长度、端序、版本、序列号与资源上限必须写进契约;解析器要按任意分片增量工作,而不是依赖底层读取次数。下一篇将继续讨论校验和与 CRC 的能力边界。

参考资料

Licensed under CC BY-NC-SA 4.0