串口和 TCP 都提供字节流或字节块,却不会替应用识别“这一条命令到哪里结束”。二进制协议设计的第一要务,是让接收端在拆包、粘包(一次读取返回半帧或多帧,见上一篇)、噪声和版本升级下仍能确定消息边界;TCP 传输下的完整请求/响应模型见本系列第 5 篇。
1. 一帧需要承担什么
一个常见帧结构如下:
| |
| 字段 | 作用 |
|---|---|
| Magic | 快速识别协议并在噪声后重新同步 |
| Version | 明确解析规则,不靠猜测 |
| Flags | 表示应答、错误、压缩等可选语义 |
| Command | 说明载荷类型 |
| Sequence | 关联请求与响应、识别迟到消息 |
| PayloadLength | 给出边界,并在分配前做上限检查 |
| Check | 检测传输或组帧错误,不等同于安全认证 |
字段不是越多越好。每个字段都应对应一个真实的演进、诊断或恢复需求。
2. 三种常见定界方式
2.1 固定长度
实现最简单且时间可预测,但浪费带宽、扩展困难。适合数据结构长期稳定且长度很小的周期报文。
2.2 长度前缀
头部携带载荷长度,适合二进制协议和 TCP。解析前必须满足:
| |
最大长度必须来自协议契约,而不是当前机器可用内存。
2.3 分隔符与转义
文本协议常用换行,二进制链路可用特殊字节加 byte stuffing。若载荷也可能出现分隔符,就要转义;解析器必须定义转义字节本身、连续转义和截断转义如何处理。
“静默时间作为边界”只适用于明确规定该语义的协议,例如 Modbus RTU。普通异步程序不能依赖一次 Read 的停顿猜帧结束。
3. 端序必须逐字段定义
多字节整数在协议中应明确为 little-endian 或 big-endian。不要把 C# 结构体直接复制到线路上:运行时布局、填充、CPU 端序和版本演进都会让它失去可移植性。
| |
对应读取也使用 BinaryPrimitives.Read*Endian,让线路端序在代码里可见。
4. 增量解析器的状态
解析器面对的是任意切片,至少需要这几个状态:
| |
关键不变量:
- 数据不足时返回“需要更多”,而不是把半帧判为错误;
- 数据非法时至少丢弃一个字节并继续寻找同步点;
- 产出一帧后继续解析缓冲区,处理粘连的下一帧;
- 累积缓冲区和单帧长度都有硬上限;
- 解析失败不会读取到缓冲区之外。
下面展示固定 8 字节头的最小解析函数。它只解析当前连续缓冲区,调用方负责保留未消费尾部:
| |
生产实现还要把校验字段纳入帧长,并决定错误后是丢一个字节、跳到下一个 Magic,还是重置整个会话。Magic 可能出现在载荷中,因此“直接跳到下一个 Magic”只是恢复启发式,不是正确性证明。
5. 请求、响应和异步事件
设备协议通常同时存在三类消息:
- 请求:主机发起操作;
- 响应:携带相同序列号或明确的关联字段;
- 事件:设备主动上报,不应被误配给当前请求。
只有一个在途请求时,也建议保留序列号。超时响应、重连前残留数据和日志关联都会用到它。序列号回绕时,要结合连接世代和有限的在途窗口判断,不能假设整数永不重复。命令合法性与排队执行的模型见《设备软件架构与控制模型(四):状态机、命令队列与流程编排》。
6. 版本兼容策略
版本字段不是万能开关。更稳健的演进规则是:
- 固定头尽量稳定,新字段放在有长度的扩展区;
- 未识别的可选字段可以跳过,未识别的关键语义必须拒绝;
- 枚举预留未知值处理,不把未来值映射成当前默认值;
- 请求和响应都显式协商能力,不仅比较一个版本数字;
- 旧固件无法安全理解的命令,不要靠“尽量解析”冒险执行。
7. 安全与资源边界
二进制解析器直接处理不可信输入。即使设备位于内网,也应防御:
- 长度字段导致的超大分配;
- 整数加法溢出;
- 无限等待未完成帧;
- 垃圾输入导致 O(n²) 扫描;
- 压缩载荷解压炸弹;
- 日志直接输出敏感载荷。
CRC 只能检测偶发错误,不能证明发送者身份,也不能阻止恶意篡改。需要安全边界时使用经过评审的认证与加密方案。
8. 测试矩阵
最少覆盖:空输入、逐字节输入、头部截断、载荷截断、两帧粘连、Magic 错误、未知版本、长度为零、最大合法长度、超过上限、校验错误、随机噪声后恢复和序列号回绕。
属性测试或模糊测试尤其适合解析器:对任意输入,函数都不应越界、挂死或无界分配;成功产出的帧再编码后应满足约定的不变量。与模拟器/回放环境的整体测试梯度见《设备软件架构与控制模型(七):实机、模拟器与 HIL》。
9. 小结
可靠协议帧的核心是明确边界和失败语义。长度、端序、版本、序列号与资源上限必须写进契约;解析器要按任意分片增量工作,而不是依赖底层读取次数。下一篇将继续讨论校验和与 CRC 的能力边界。