Functional Safety over EtherCAT (FSoE) 使用自定义的16位CRC来保护每个Safety PDU(协议数据单元)。乍一看,该算法像是带查找表的标准CRC-16,但两个设计选择使其与众不同:
- 与标准逐字节CRC更新相比,每个数据字节都乘以额外的 $x^{24}$ 因子。
- 前一帧的CRC被折叠到下一帧的CRC中,在帧之间创建了一条链(“CRC继承”)。
本文基于原始FSoE源代码和 Tables.c 中自动生成的查找表,详细解释这两种机制。
多项式和两个表
FSoE CRC使用17位多项式
$$ P(x) = x^{16} + x^{13} + x^{12} + x^{11} + x^{8} + x^{7} + x^{5} + x^{4} + x^{2} + x + 1 $$打包为 0x39B7($x^{16}$ 项是隐含的)。CRC按 MSB-first(“正常”、非反射方向)处理。
从该多项式预计算两个256条目的查找表:
| 表 | 公式 | 移位 | 含义 |
|---|---|---|---|
CRC16_TABLE ($T_0$) | $T_0[i] = (i \cdot x^{16}) \bmod P$ | 8 | 标准逐字节表 |
CRC16_TABLE2 ($T_3$) | $T_3[i] = (i \cdot x^{40}) \bmod P$ | 32 | Slice-by-4表 = $T_0$ 应用4次(字节 $i$ + 3个零字节) |
两个表都是通过将 $i$ 加载到16位寄存器的高字节并左移、每当第15位被设置时与多项式进行XOR来生成的:
for (n = 0; n < 8 + 8*k; n++)
crc = (crc & 0x8000) ? ((crc << 1) ^ 0x39B7) : (crc << 1);逐字节更新步骤
每个字节都按以下模式折叠到CRC中(对计算中的每个字节重复):
w1 = aCRCTab1[crc >> 8]; /* T0[crc_hi] -- shift CRC register 1 byte */
w2 = aCRCTab2[input_byte]; /* T3[input] -- fold in the input byte */
w = w1 ^ w2;
crc = (w & 0xFF) | ((w >> 8 ^ crc_lo) << 8);简化后(可以通过展开字节操作来验证):
$$ \text{crc}_{\text{new}} = (\text{crc}_{\text{lo}} \ll 8) \;\oplus\; T_0[\text{crc}_{\text{hi}}] \;\oplus\; T_3[\text{input}] $$项 $(\text{crc}{\text{lo}} \ll 8) \oplus T_0[\text{crc}{\text{hi}}]$ 正好是 标准MSB-first CRC寄存器移位一个字节(等同于处理一个零字节)。然后 $T_3[\text{input}]$ 被XOR进去。
这与标准CRC-16有何不同
标准的逐字节CRC-16更新为:
$$ \text{crc}_{\text{std}} = (\text{crc}_{\text{lo}} \ll 8) \;\oplus\; T_0[\text{crc}_{\text{hi}} \oplus \text{input}] $$展开为:
$$ \text{crc}_{\text{std}} = (\text{crc}_{\text{lo}} \ll 8) \;\oplus\; T_0[\text{crc}_{\text{hi}}] \;\oplus\; T_0[\text{input}] $$FSoE代码用 $T_3[\text{input}]$ 替换了 $T_0[\text{input}]$。由于CRC算术在GF(2)上是线性的:
- $T_0[\text{input}] = (\text{input} \cdot x^{16}) \bmod P$ — 输入字节在其自然位置
- $T_3[\text{input}] = (\text{input} \cdot x^{40}) \bmod P$ — 输入字节**高24位(3字节)**移位
因此,与标准CRC相比,每个输入字节获得额外的 $x^{24}$ 因子。从初始值 $\text{startCrc}$(它本身作为前2个字节被折叠进去)处理 $n$ 个字节 $b_0 \dots b_{n-1}$ 后:
$$ \text{FSoE CRC} = \left( \text{startCrc} \cdot x^{8n} + \sum_{k=0}^{n-1} b_k \cdot x^{40 + 8(n-1-k)} \right) \bmod P $$对比标准:
$$ \text{Standard CRC} = \left( \text{startCrc} \cdot x^{8n} + \sum_{k=0}^{n-1} b_k \cdot x^{16 + 8(n-1-k)} \right) \bmod P $$唯一的区别是每个数据字节的 $x^{40}$ vs $x^{16}$ — 每个字节额外的 $x^{24}$。等价地,FSoE CRC是消息的CRC,就像每个字节后面跟着3个零字节一样,但初始值只按实际字节数移位(不是填充后的计数)。这是FSoE特有的自定义变体,不等同于任何标准的填充CRC约定。
字节处理顺序
CRC在每次迭代开始时重置为0,然后按以下顺序处理字节:
oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi, SeqNo-Lo, SeqNo-Hi, Command, [Data...]- oldCRC 来自
startCrc参数(前一帧的CRC — 见下文继承) - ConnID 从PDU末尾读取:
au8Data[size-2](Lo)和au8Data[size-1](Hi) - SeqNo 来自
seqNo指针 - Command 位于
au8Data[OFFS_COMMAND] - Data 从
au8Data[OFFS_DATA]开始
CRC继承 — 两个层级
第1层:跨帧继承(startCrc / oldCRC 链)
折叠到每个CRC计算中的前两个字节是前一帧的CRC(startCrc)。这在帧之间创建了一条链:
每帧的CRC取决于所有先前帧的CRC。攻击者无法重放旧帧,因为CRC链会断裂。
oldCrc 参数用于 do…while 循环中的碰撞检测:
} while (crc == oldCrc && (bRcvDir & NEW_CRC) != 0);如果新计算的CRC恰好等于前一帧的CRC,序列号会递增(seqNo[0]++,跳过零)并重新计算CRC。这会持续直到CRC与 oldCrc 不同,确保没有两个连续帧共享相同的CRC — 这是使CRC碰撞可检测的安全要求。
第2层:帧内继承(crc_common 基础)
在单个PDU内,当有效载荷超过10字节时,会嵌入多个CRC(CRC₀, CRC₁, CRC₂, …)— 每个保护一个2字节数据段。关键机制是 crc_common。
步骤1 — 计算公共基础(处理7个字节):
crc = 0;
/* process: oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi,
SeqNo-Lo, SeqNo-Hi, Command */
crc_common = crc; /* saved here */该基础封装了所有"头部"字段:继承的旧CRC、Connection ID、Sequence Number和Command字节。
步骤2 — CRC₀(第一个段,从 crc_common 继续):
/* continue from crc_common: process Data[0], Data[1] */
/* result = CRC₀ (stored/verified at pCrc) */步骤3 — CRCᵢ(i ≥ 1)(后续段,每个从 crc_common 重新开始):
crc = crc_common; /* RESTART from the shared base */
/* process: Index-Lo (i & 0xFF), Index-Hi (i >> 8),
Data[2i], Data[2i+1] */
/* result = CRCᵢ (stored/verified at next pCrc position) */循环结构:
while (size >= 4) { /* each iteration = 2 data bytes + 2 CRC bytes */
crc = crc_common; /* restart from shared base */
/* fold in: i_lo, i_hi, data[2i], data[2i+1] */
/* verify/insert CRC at pCrc[0..1] */
size -= 4;
pSafeData += 4;
pCrc += 4;
i++;
}为什么索引字节很重要
每个段CRCᵢ在Command和Data之间包含索引 $i$(从1开始,作为2字节小端值)。这确保即使两个段包含相同的数据字节,它们的CRC也会因索引不同而不同。没有索引,交换两个数据段将无法被检测到。
可视化
下图展示了CRC继承如何跨帧工作以及在单个多CRC PDU内部如何工作。每个段CRC从 crc_common(共享基础)重新开始,每帧的CRC成为下一帧的 startCrc。
图中使用的缩写
| 标记 | 含义 |
|---|---|
oC.Lo, oC.Hi | oldCRC(oC)——上一帧的CRC,作为 startCrc 传入;拆分为低/高字节 |
CID.Lo, CID.Hi | Connection ID(ConnID)——低/高字节,从PDU的最后2个字节读取 |
Sq.Lo, Sq.Hi | Sequence Number(SeqNo)——帧序列号的低/高字节 |
Cmd | Command 字节——位于 OFFS_COMMAND 的FSoE命令操作码 |
D0, D1, D2, … | 负载的 Data 数据字节(从 OFFS_DATA 开始) |
i.Lo, i.Hi | 段 index 索引 i(从1开始,小端序)——低/高字节,在 i ≥ 1 时折入 CRCᵢ |
N.Lo, N.Hi | 在帧 N+1 中:帧 N 的 CRC₀ 的低/高字节(即帧 N+1 的 oldCRC) |
CRC₀, CRC₁, CRC₂ | 为段 0、1、2 计算出的 CRC 值——每个都插入(或校验)到 PDU 中 |
CRCᵢ = f( crc_common, Index=i, Data[2i], Data[2i+1] ) for i ≥ 1
crc_common = f( oldCRC, ConnID, SeqNo, Command ) /* 保存,不传输 */
nextFrame.startCrc = thisFrame.CRC₀
PDU布局
PDU字节布局(从 CalcCrc 中的字段访问重建)取决于总大小:
size = 6 (1 data byte, 1 CRC):
[Command, Data0, CRC0Lo, CRC0Hi, ConnIDLo, ConnIDHi]
size = 7..10 (2 data bytes, 1 CRC):
[Command, Data0, Data1, CRC0Lo, CRC0Hi, ConnIDLo, ConnIDHi]
size > 10 (multi-CRC, 2+2k data bytes):
[Command, Data0, Data1, CRC0(2), Data2, Data3, CRC1(2),
Data4, Data5, CRC2(2), ..., ConnIDLo, ConnIDHi]- ConnID 始终在最后2个字节(
au8Data[size-2..size-1]) - CRC₀ 在偏移量2(size ≤ 6)或偏移量3(size > 6)
- 每个额外段为4字节:2数据 + 2 CRC
size -= 7计算第一部分(ConnID 2 + CRC₀ 2 + Command 1 + Data 2),然后每个额外段size -= 4
参考CRC值
以下参考值由原始 CalcCrc 函数的忠实转录生成,并使用逐位多项式长除法(无查找表)独立验证。所有输入使用 startCrc = 0x0000、seqNo = 0x0001、oldCrc = 0x0000,除非另有说明。
| Test | size | startCrc | ConnID | Cmd | Data | CRC₀ | CRC₁ | CRC₂ | CRC₃ |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 6 | 0x0000 | 0x1234 | 0x01 | AB | 0x2C82 | — | — | — |
| 2 | 7 | 0x0000 | 0x1234 | 0x01 | AB CD | 0xDD27 | — | — | — |
| 3 | 11 | 0x0000 | 0x1234 | 0x01 | AB CD EF 12 | 0xDD27 | 0x05A5 | — | — |
| 4 | 15 | 0x0000 | 0x1234 | 0x01 | AB CD EF 12 34 56 | 0xDD27 | 0x05A5 | 0x93AA | — |
| 5 | 7 | 0x0000 | 0x5678 | 0x05 | 01 02 | 0x82DC | — | — | — |
| 6b | 7 | 0xDD27 | 0x1234 | 0x01 | AB CD | 0x41F5 | — | — | — |
| 7 | 7 | 0xBEEF | 0x4242 | 0x03 | DE AD | 0x945E | — | — | — |
| 8 | 11 | 0x0000 | 0x0000 | 0x00 | 00 00 00 00 | 0xC864 | 0x7BC5 | — | — |
| 9 | 15 | 0x0000 | 0xFFFF | 0xFF | FF FF FF FF FF FF | 0x2669 | 0xDB40 | 0xCD65 | — |
| 10 | 19 | 0x0000 | 0xCAFE | 0x02 | 10 20 30 40 50 60 70 80 | 0xE6BD | 0x687A | 0xB265 | 0x657C |
测试 6b 演示了跨帧继承:它使用测试2的CRC₀(0xDD27)作为其 startCrc,为相同数据产生不同的CRC(0x41F5)— 展示了继承链如何在有效载荷相同时仍然改变CRC。
总结
FSoE CRC机制有两个继承层:
跨帧:前一帧的CRC₀作为前两个字节折叠到下一帧的CRC中,创建一条防止重放攻击的链。
do…while循环确保没有两个连续帧共享相同的CRC。帧内:对于多段PDU,共享的
crc_common基础(包含oldCRC、ConnID、SeqNo、Command)计算一次并作为每个段CRC的起点重用。每个段添加其索引和2个数据字节,产生独立的CRC以防止段重排序。
更新步骤本身是标准MSB-first CRC-16寄存器移位,但输入字节通过 $T_3$($x^{40}$ 表)而非 $T_0$($x^{16}$ 表)折叠进去,给每个数据字节额外的 $x^{24}$ 因子。这使FSoE CRC成为自定义变体,不等同于任何标准CRC-16约定。
相关文章
概述与基础
- FSoE 这个缩写究竟是什么意思? — FSoE 缩写的含义
- 通过示例解析 FSoE 帧结构 — Safety PDU 总体结构
- FSoE 状态机的所有状态 — FSoE 的五个状态及其转换
- FSoE:安全 PDU 命令表 — FSoE 命令完整列表
按状态划分的 PDU 结构
- FSoE Reset State PDU:Master 和 Slave 结构 — Reset 状态的 PDU 布局和错误代码
- FSoE Session PDU:Master 和 Slave 结构 — Session 状态的 PDU 布局
- FSoE Connection PDU:Master 和 Slave 结构 — Connection 状态的 PDU 布局
- FSoE Parameter PDU:Master 和 Slave 结构 — Parameter 状态的 PDU 布局
- FSoE Data PDU:Master 和 Slave 结构 — Data 状态的 PDU 布局
- FSoE Reset PDU:Master 和 Slave 结构 — 所有 safety-data 长度的字节布局
CRC
- FSoE CRC:使用哪个多项式? — FSoE CRC 的 17 位多项式
- FSoE:CRC 表是如何构造的? — CRC 查找表的生成方法
- 如何计算 FSoE PDU 的 CRC 校验和? — 逐字节 CRC 计算算法
错误代码与数据格式
- FSoE:所有通信错误代码列表 — 所有 FSoE 通信错误代码
- FSoE:数据以小端序还是大端序传输? — 多字节字段的字节序