ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

黑通道协议能否自定义?安全通信层设计要点详解

黑通道协议能否自定义?安全通信层设计要点详解

被别人问到“黑通道协议能不能自己定义”的时候,我其实愣了一下。因为这问题里藏着一个很常见的理解偏差:不少做单片机和工控的朋友,一听“协议”两个字,下意识就会想到帧头帧尾、CRC校验、超时重发这些东西,但“黑通道”这个词,在工业通信里并不是指某一种具体的协议,它描述的是一种对底层传输系统的假设。这篇文章就把这件事拆开讲清楚:黑通道到底是什么,哪些部分真的可以自定义,以及如果你打算自己造一套带安全保护的自定义协议,具体应该怎么设计、怎么写、怎么测。

1. 一句话先说结论:黑通道不是让你写的协议,而是你要绕过的那条河

1.1 黑通道是从哪来的

“Black Channel”这个术语,主要来自功能安全通信标准,比如 IEC 61508 和 IEC 61784-3。当年做安全通信规范的人面临一个很现实的问题:现场总线像 Modbus、CAN、以太网,本身并不是为“安全功能”设计的。它们可能会丢帧、可能会重发一帧、也可能因为电磁干扰把某个位从 0 变成 1。如果一套安全系统直接建立在这种总线上,谁敢保证关键时刻它能可靠工作?

标准给出的解决办法很有意思:不指望底层通道可靠,干脆把它当成一个行为未知、可能随机出错的黑盒子。所有安全机制都放到两端设备自己的通信层里去做。这个“被默认不可靠、被刻意黑箱化”的底层传输系统,就是黑通道。换句话说,黑通道不是一个你要去实现的协议栈,它是一个你需要正视的假设:下面的路很烂,但上层要有能力在烂路上保证安全。

1.2 为什么要把通道当成“黑”的

你可以把黑通道想象成快递配送。你在意的是餐有没有送到、对不对,至于骑手走哪条路线、中途有没有爆胎,你既不关心也控制不了。安全通信的逻辑也一样:底层的 CAN、串口、以太网可能延迟、乱序、丢包、篡改数据,安全层要做的,就是“即使通道这么乱,我也能从收到的数据里发现不对劲,然后触发保护动作”。

标准里明确列了黑通道可能制造的七类问题:数据损坏、非预期插入、错误顺序、不可接受延迟、数据丢失、寻址错乱、不安全响应。我们自己写的安全协议,本质上就是针对这七个劫难,一层一层做防御。理解了这一点,再看“自定义协议”这件事,你的思路就不是“我怎么把黑通道写好”,而是“我怎么在黑通道之上把安全层写透”。

1.3 所以“自定义黑通道”的正确理解

既然黑通道本身只是那套被放弃治疗的底层系统,那它其实没有固定协议格式需要你去改写。这句话翻译成实际操作层面的意思是:你不需要、也不可能在黑通道内部做什么自定义,你能自定义的,是黑通道两端之间的安全通信层。

重要理解:黑通道是承载对象,安全层才是自定义对象。底层用串口、CAN、485、以太网都可以,你要写的是上面那层带序号、带校验、带时间戳的安全帧规则。

一旦想清楚这点,很多同学困惑的问题就消失了。比如“为什么我改不了总线的错误处理机制”“为什么底层自动重发会影响我的安全判断”——因为那些都是黑通道的事,你的安全层压根不该依赖它们,甚至应该假设它们不存在。

2. 既然黑通道不用写,那自定义到底发生在哪

2.1 安全通信层必须完成的四件事

安全层不是“在业务数据外面包一层壳”那么简单,它至少要同时完成四件事:

第一,完整性校验。底层通道可能翻转比特,所以整帧要加 CRC 这类强校验。第二,数据源认证。你得确认收到的帧确实来自预期的设备,而不是隔壁一台故障设备或者一条被干扰的线路,常见做法是加入设备标识,或者双方约定一组密钥因子。第三,新鲜性和时序校验。防止旧帧被重放到系统里,也防止帧在通道里被卡了很久又突然冒出来,这要靠序列号加时间戳配合实现。第四,确认机制。发送端需要知道接收端到底有没有安全地收到关键帧,尤其是急停、故障确认这类命令。

举个实际场景:你在 PLC 和远程 IO 之间用 RS485 当黑通道,每 10 毫秒传一次状态字。某一行状态帧在总线上被干扰卡了 2 秒,又正常发完,被接收端当成新数据。如果没有序列号去重和超时判断,设备就会误以为收到了实时状态,从而错过真正的保护动作。这是非常典型的黑通道事故。

2.2 动手前必须拍板的协议参数

开始画帧格式之前,有几个参数必须定死,不然后面联调会栽跟头:

  • 帧同步方式:用固定双字节帧头,还是靠字节间隔超时切帧?我最常用的是 0xAA 0x55 做帧头,再配合长度字段二次确认。
  • 长度字段:协议栈只有知道整帧长度,才能判断帧在哪里结束、什么时候该做 CRC 校验。长度字段本身要紧挨着帧头,并且设置一个合法上限,防止异常数据导致缓冲区溢出。
  • 序列号字段:至少留两字节。工控场景一秒钟几百帧很常见,两字节序列号能跑很久,再用窗口法判断新旧,基本不会出错。
  • CRC 算法:CRC8、CRC16、CRC32 要提前想清楚,不只是看数据量,还得考虑单片机的计算能力和有没有硬件 CRC 外设。
  • 超时参数:帧内字节间超时、整帧接收超时、重发超时,这三组值往往要靠实际通信压力测试去调,绝对不能拍脑袋随便填。

很多刚接触自定义协议的朋友,上来就直接写“帧尾 0x7E”,结果业务数据里一旦出现 0x7E 这个字节,整帧解析就崩了。明明有更稳的“帧头 + 长度 + CRC”方案,何必非要走钢丝。

2.3 两条路线:标准协议还是完全自己造

如果你做的设备未来要过 SIL3 这类功能安全认证,我会劝你别在安全协议上自己发明花样。老老实实根据 IEC 61784-3 选一段已经被验证过的安全通信层,或者直接采购经过认证的协议栈。认证机构看的是你用了标准规定的方法和机制,而不是你发明了一个多精巧的结构,外观再漂亮也抵不过“未经充分验证”这个硬伤。

但如果你的项目没有强制安全认证要求,只是做一套自有设备之间的可靠通信,完全可以用“自定义安全帧 + 黑通道思路”自己搞定。我自己在好几个项目里就是这么干的:底层走串口透传,上面跑私有安全帧。收益是极其灵活,想加生产批次字段、想加电池电量字段、想换更紧凑的帧结构,随时能改,调试时看到的是自己熟悉的格式,排查问题也直观。

注意:自定义协议不受任何标准保护,所有防御机制都得自己写全。千万别因为“我写的协议”就默认它是安全的,该做的故障注入测试一项都不能省。

3. 手把手搭一个“黑通道 + 自定义安全层”的最小原型

这里我带着你搭一个能真正跑通的最小系统。假设有两块开发板通过串口互连,串口就是我们眼里的黑通道。它理论上很可靠,但代码里必须默认它会丢字节、会受干扰、甚至会错乱。

3.1 整体架构:两端夹住黑通道

数据流是这样的:设备 A 的应用层把业务数据交出来,安全层负责封装成安全帧,通过串口发出去;串口把字节流送到设备 B;设备 B 的安全层做校验、去重、超时判断,确认无误后才把业务数据往上递。

这里有个容易被忽略的架构要求:安全层和应用层绝不能在同一个函数里乱搅。应用层只关心业务逻辑,比如“温度超过 80 度就把报警输出拉高”;安全层只管收发和防护。一旦安全层检测到异常,它通知应用层转入安全状态,而不是自己直接去操作输出。很多自定义协议出安全事故,不是因为协议设计得不好,是因为层与层之间的界限模糊,异常判断结果被业务逻辑干掉了。

3.2 最小可用的帧格式设计

我给出一个可以直接套用的帧结构:

| 帧头(0xAA55) | 长度LEN | 序列号SEQ | 命令CMD | 数据区PAYLOAD | CRC16 |

具体字节分配:

  • 帧头占 2 字节,固定为 0xAA 和 0x55,用来做同步。
  • LEN 占 1 字节,表示从 CMD 开始到数据区末尾的总长度,不含帧头也不含 CRC。这样最大数据载荷可以到 250 字节出头。
  • SEQ 占 2 字节,每发送一帧递增 1,接收端用它判断新旧并去重。
  • CMD 占 1 字节,标明这帧是什么用途,比如 0x01 表示周期状态上报,0x02 表示写参数。
  • PAYLOAD 是业务数据,长度可变,由 LEN 决定。
  • CRC16 用 MODBUS 多项式,即 0x8005、初值 0xFFFF、逐位输入输出不异或。覆盖范围从 LEN 字段开始到 PAYLOAD 结束,这样即使帧头两字节本身就损坏了,长度和 CRC 还有一次兜底纠错的机会。

3.3 接收端状态机设计

接收端千万不能写成“每来一个中断就读一个字节、马上处理”,那样遇到粘包和断帧时逻辑会乱成一锅粥。我建议用一个有限状态机来维护接收流程:

  • 状态 IDLE:等待帧头的第一个字节 0xAA。
  • 确认帧头:收到 0xAA 后再收一字节,必须是 0x55,否则退回 IDLE。
  • 读取长度:收到 LEN 后,立刻检查是否超过缓冲区上限,超过就当作非法帧,丢弃并回 IDLE。
  • 收集载荷:按 LEN 指定的字节数,把 CMD 和 PAYLOAD 全部收完,期间任何超时都直接认定为废帧。
  • CRC 校验:整帧收齐后重新计算 CRC,和帧尾的 2 字节比对;不一致就丢弃,同时错误计数器加一。
  • 序列号去重:校验通过后,用窗口法判断序列号是否比当前已确认的最新序号新。是则递交给应用层,否则丢弃,防止重放。

发送端反而简单,唯一的要点是:发送之前确保帧缓冲区内容不会再被业务线程修改。不然发了一半,数据被改了,接收端就会隔三差五报 CRC 错误,那种偶发问题排查起来极其折磨人。

3.4 核心代码:CRC 计算和序列号窗口

CRC16 MODBUS 算法我直接给出一段适合微控制器运行的 C 代码:

uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }

0xA001 是多项式 0x8005 反转之后的结果。这段逐位算法在 ARM Cortex-M 上跑没有压力,但如果你用的是非常低端的 8 位单片机,又要把串口波特率拉满,建议改成查表法。查表法就是提前离线生成一张 256 项的 CRC 表,运行时每处理一个字节查一次表,速度能快好几倍。表项生成代码也不复杂,网上到处都能查到,这里就不占篇幅了。

序列号去重,我要特别强调:不能简单地“如果收到的序号大于上次的序号就是新帧”。因为两字节序号最终会从 0xFFFF 回绕到 0x0000,到那时你会把真正的“新数据”(序号 0)误判成旧帧。正确做法是让接收端维持一个基准序号,计算收到序号和基准序号的差值,如果差值落在 0 到 32767 这个半区,那就是新帧,否则就是重放帧。这个半区窗口对应的就是“新”和“旧”的边界。

4. 自定义协议时最容易踩的五个坑

4.1 序列号回绕:你以为永远不会发生的边界情况

序列号回绕不是理论概念,是真实会发生的。假设你的系统一毫秒发一帧,两字节序号只要约 65 秒就绕一圈。如果接收端不判断窗口,直接比较递增大小,那每过一分钟左右,系统就会短暂地进入“假丢包、假重放”状态,而且只持续几帧,非常隐蔽。解决办法已经在 3.4 说了,用窗口差值判断,这是所有工业安全协议的标准做法。

4.2 CRC 选型:不是算出来对得上就行

CRC 的坑主要在两端算法参数不一致。串口社区里常见的 CRC16/MODBUS 和 CRC16/CCITT,多项式不同、初值不同、输出是否反转也不同。同一个数据,两种算法算出来的字节完全可能不一样,如果甲方用了 CCITT、乙方用了 MODBUS,联调时就会出现“时好时坏”的诡异现象。

我建议从项目一开始就把 CRC 参数写进协议文档里,明确标出:多项式、初值、输入是否反转、输出是否反转、校验覆盖范围。哪怕显得啰嗦,也值得写。我见过太多团队因为 CRC 参数定义模糊,在联调现场耗掉一整天,最后发现两边调用了不同的库。

4.3 粘包与断帧:状态机里必须有重同步机制

串口本质上是字节流,没有消息边界,粘包、拆包不可避免。光靠帧头识别还不够,因为某次干扰丢了一个字节后,后续的字节可能会错位成“伪帧头”。所以状态机设计时,无论当前处于哪个状态,一旦出现不可能出现的字节序列,就要立刻回到 IDLE 状态,丢弃所有半包。

这种“放弃半包、重新同步”的策略,代价只是损失一帧数据,但换来的是接收状态机永远不会陷入死循环。我早期写协议时选择“硬等正确帧头”,结果链路一抖,接收端一连几十帧全部错位,这种体验会让你怀疑人生。

4.4 超时和重试策略:不是所有帧都值得重发

设计重发机制前,先想清楚底层是单向还是双向。如果只是设备单向上报状态,重发机制本身就不合理,你只需要超时判断和数据丢弃。如果要做请求-响应,那就要考虑重发次数、间隔,以及最终失败后做什么。

更细一层的经验是:周期性的状态帧不值得重发。因为它下一秒又会来新的,重发旧的反而浪费时间。但急停命令、写参数命令这类帧,必须有确认机制,宁可重发多次也不能漏。协议设计里最好把“周期帧”和“事件帧”分开处理,否则到了高负载时,重发风暴会把整个链路拖垮。

4.5 故障注入:不测你就不知道协议有多弱

没有故障注入,自定义协议的安全性等于零。我在项目里会专门花时间写一个随机干扰脚本,随机丢字节、随机改字节、随机重复整帧。接收端必须在这种压力测试下,持续一段时间不产生哪怕一次错误的“新数据交付”。如果期间出现一次错误,说明协议设计或者代码实现不过关,重新回去修。

电脑端的串口调试助手一般不支持这种随机故障注入,我建议自己写脚本。Python 调用 pyserial 就能轻松实现,把要发的帧读进来,按概率做三类破坏,然后再逐帧发送。几十行代码能得到一个远比你手工测试可靠得多的结论,这笔投资非常划算。

5. 基于 STM32 的落地实现与调试记录

5.1 硬件环境与分层代码结构

我实际测试时用了两个 STM32F103 开发板,USART1 交叉相连,共地,波特率 115200。其中一块作为主站,每 10 毫秒发一帧心跳;从站收到后解析,再回一帧确认。代码严格分为三层:应用层定时准备业务数据;协议层负责封装安全帧和解析安全帧;驱动层负责串口收发,并用了一个环形缓冲区连接驱动层和协议层,避免高波特率下丢字节。

这个分层结构看起来是常规操作,但真正值钱的是“协议层不依赖底层实现”这件事。协议层只跟两个函数打交道:一个把一字节送出去,一个从接收缓冲区取一字节。至于实际是串口还是 CAN,协议层根本不关心。

5.2 关键代码:发送封装与接收处理

发送封装的实现,核心就是把帧头、长度、序号、命令、载荷填好,再算一遍 CRC 塞到尾部。我直接给出函数:

void safe_proto_build_and_send(uint8_t cmd, uint8_t *payload, uint8_t len) { uint8_t tx_buf[256]; uint16_t seq = proto_next_seq(); uint16_t crc; uint8_t i = 0; tx_buf[i++] = 0xAA; tx_buf[i++] = 0x55; tx_buf[i++] = len; tx_buf[i++] = seq & 0xFF; tx_buf[i++] = (seq >> 8) & 0xFF; tx_buf[i++] = cmd; if (len) { memcpy(&tx_buf[i], payload, len); i += len; } crc = crc16_modbus(&tx_buf[2], i - 2); tx_buf[i++] = crc & 0xFF; tx_buf[i++] = (crc >> 8) & 0xFF; HAL_UART_Transmit_DMA(&huart1, tx_buf, i); }

接收侧我强烈推荐“DMA 接收 + 串口空闲中断”的方式。串口空闲中断的作用是检测一帧传输结束:当总线空闲下来,就认为当前 DMA 已经收完一包,然后把数据交给协议解析函数。这种方案比逐字节中断处理稳得多,尤其在高波特率下,逐字节中断很容易因为进中断频率太高而丢字节。

5.3 实测中踩过的两个具体问题

第一次联调时,从站频繁报“CRC error”。排查到最后发现,问题根本不在 CRC,而是主站发送缓冲区里有上次残留的数据,长度字段又把旧数据包进来了。后来我在每次发送前先 memset 整个发送缓冲区,问题立刻消失。这个教训让我养成一个习惯:发送缓冲区内容在填帧前一定要清零,不要赌它上一帧已经覆盖完。

第二个问题是协议版本管理。我把设备升级了一版,加了新命令,结果忘了同步协议文档。两天后另一台设备联调,抓包后发现帧格式对不上,两边差点吵起来。后来我在协议文档开头加了一行版本号和日期,代码里也定义一个协议版本宏,每次发帧都把这个版本带进去。现在联调一旦出现格式不一致,看一眼版本号就知道谁没升级,省掉大量沟通成本。

6. 最后再补充一点实操上的体会

做了几年自定义协议,我最大的感受是:协议设计得好不好,不在帧格式飘不漂亮,而在你对底层黑通道的“恶意假设”够不够狠。写代码之前,先花一两个小时想一遍最坏情况:如果通道随机丢掉一半帧、随机延迟两秒、随机把数据改成乱码,我的协议能不能每次都发现异常?如果能,这套自定义协议才算及格。

后续想深一点,你可以从三个方向继续扩展:一是把 CRC 从逐位算法改成查表法,再配合单片机的硬件 CRC 外设,吞吐量会明显上升;二是在帧里加入设备白名单和轻量密钥字段,防止无关设备无意混进网络;三是把协议状态机彻底做成独立模块,未来从串口换到 CAN 或者以太网,应用层一行不用改。这样一来,你自定义的黑通道上层安全层,才算真正具备了工程上的复用价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进