FSoE:CRC继承是如何工作的?

Functional Safety over EtherCAT (FSoE) 使用自定义的16位CRC来保护每个Safety PDU(协议数据单元)。乍一看,该算法像是带查找表的标准CRC-16,但两个设计选择使其与众不同:

  1. 与标准逐字节CRC更新相比,每个数据字节都乘以额外的 $x^{24}$ 因子
  2. 前一帧的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$32Slice-by-4表 = $T_0$ 应用4次(字节 $i$ + 3个零字节)

两个表都是通过将 $i$ 加载到16位寄存器的高字节并左移、每当第15位被设置时与多项式进行XOR来生成的:

table-entry.c
for (n = 0; n < 8 + 8*k; n++)
    crc = (crc & 0x8000) ? ((crc << 1) ^ 0x39B7) : (crc << 1);

逐字节更新步骤

每个字节都按以下模式折叠到CRC中(对计算中的每个字节重复):

fsoe-update.c
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,然后按以下顺序处理字节:

example.txt
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]
  • Dataau8Data[OFFS_DATA] 开始

CRC继承 — 两个层级

第1层:跨帧继承(startCrc / oldCRC 链)

折叠到每个CRC计算中的前两个字节是前一帧的CRCstartCrc)。这在帧之间创建了一条链:

$$ \begin{aligned} \text{Frame } N\text{'s CRC} &= f(\text{Frame } N\text{'s data},\; \text{Frame } N{-}1\text{'s CRC}) \\ \text{Frame } N{+}1\text{'s CRC} &= f(\text{Frame } N{+}1\text{'s data},\; \text{Frame } N\text{'s CRC}) \end{aligned} $$

每帧的CRC取决于所有先前帧的CRC。攻击者无法重放旧帧,因为CRC链会断裂。

oldCrc 参数用于 do…while 循环中的碰撞检测

collision-avoidance.c
} 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-common.c
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 继续):

crc0.c
/* continue from crc_common: process Data[0], Data[1] */
/* result = CRC₀  (stored/verified at pCrc) */

步骤3 — CRCᵢ(i ≥ 1)(后续段,每个从 crc_common 重新开始):

crci.c
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) */

循环结构:

multi-crc-loop.c
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.HioldCRCoC)——上一帧的CRC,作为 startCrc 传入;拆分为低/高字节
CID.Lo, CID.HiConnection ID(ConnID)——低/高字节,从PDU的最后2个字节读取
Sq.Lo, Sq.HiSequence Number(SeqNo)——帧序列号的低/高字节
CmdCommand 字节——位于 OFFS_COMMAND 的FSoE命令操作码
D0, D1, D2, …负载的 Data 数据字节(从 OFFS_DATA 开始)
i.Lo, i.Hiindex 索引 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_common)
数据字节
CRC(插入/验证)
索引
ConnID
跨帧继承:每帧的CRC₀成为下一帧的startCrc (oldCRC)
帧 N−1
CRC₀: oC.Lo oC.Hi CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd D0 D1
CRC₀ = 0xDD27(示例)
帧 N (startCrc = 0xDD27)
crc_common(所有段的共享基础)
0x27 0xDD CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd
CRC₀: (来自公共) D0 D1 CRC₀
CRC₁: (重启公共) i.Lo i.Hi D2 D3 CRC₁
CRC₂: (重启公共) i.Lo i.Hi D4 D5 CRC₂
帧 N+1 (startCrc = N的CRC₀)
CRC₀: N.Lo N.Hi CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd D0 D1
CRC₀ 馈入帧 N+2…
CRC₀ = f( oldCRC, ConnID, SeqNo, Command, Data[0], Data[1] )
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₀
左:帧 N−1 产生 CRC₀ = 0xDD27。中:帧 N 使用 0xDD27 作为其 startCrc — 折叠到每个CRC计算中的前两个字节。公共基础(蓝色)计算一次并为每个段重用。每个段CRC从该基础重新开始,添加其索引和数据字节,产生独立的CRC。右:帧 N 的 CRC₀ 成为帧 N+1 的 startCrc,继续这条链。

PDU布局

PDU字节布局(从 CalcCrc 中的字段访问重建)取决于总大小:

example.txt
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 = 0x0000seqNo = 0x0001oldCrc = 0x0000,除非另有说明。

TestsizestartCrcConnIDCmdDataCRC₀CRC₁CRC₂CRC₃
160x00000x12340x01AB0x2C82
270x00000x12340x01AB CD0xDD27
3110x00000x12340x01AB CD EF 120xDD270x05A5
4150x00000x12340x01AB CD EF 12 34 560xDD270x05A50x93AA
570x00000x56780x0501 020x82DC
6b70xDD270x12340x01AB CD0x41F5
770xBEEF0x42420x03DE AD0x945E
8110x00000x00000x0000 00 00 000xC8640x7BC5
9150x00000xFFFF0xFFFF FF FF FF FF FF0x26690xDB400xCD65
10190x00000xCAFE0x0210 20 30 40 50 60 70 800xE6BD0x687A0xB2650x657C

测试 6b 演示了跨帧继承:它使用测试2的CRC₀(0xDD27)作为其 startCrc,为相同数据产生不同的CRC(0x41F5)— 展示了继承链如何在有效载荷相同时仍然改变CRC。

总结

FSoE CRC机制有两个继承层:

  1. 跨帧:前一帧的CRC₀作为前两个字节折叠到下一帧的CRC中,创建一条防止重放攻击的链。do…while 循环确保没有两个连续帧共享相同的CRC。

  2. 帧内:对于多段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约定。

相关文章

概述与基础

按状态划分的 PDU 结构

CRC

错误代码与数据格式


按类别查看类似文章: Functional Safety EtherCAT CRC