FSoE: Как работает наследование CRC?

Functional Safety over EtherCAT (FSoE) использует специальный 16-битный CRC для защиты каждой Safety PDU (Protocol Data Unit). На первый взгляд алгоритм выглядит как стандартный CRC-16 с таблицей поиска, но два архитектурных решения делают его необычным:

  1. Каждый байт данных умножается на дополнительный множитель $x^{24}$ по сравнению со стандартным побайтовым обновлением CRC.
  2. CRC предыдущего кадра включается в CRC следующего кадра, создавая цепочку между кадрами («наследование CRC»).

В этой статье подробно объясняются оба механизма на основе исходного кода FSoE и автоматически сгенерированных таблиц поиска в Tables.c.

Полином и две таблицы

CRC FSoE использует 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-битного регистра и сдвига влево с XOR полинома каждый раз, когда установлен бит 15:

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]  -- сдвиг регистра CRC на 1 байт */
w2 = aCRCTab2[input_byte];     /* T3[input]   -- включение входного байта  */
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}}]$ — это в точности стандартный сдвиг регистра CRC MSB-first на один байт (эквивалент обработки нулевого байта). Затем $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_0[\text{input}]$ на $T_3[\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 байта) выше

Таким образом, каждый входной байт получает дополнительный множитель $x^{24}$ по сравнению со стандартным CRC. После обработки $n$ байтов $b_0 \dots b_{n-1}$ начиная с начального значения $\text{startCrc}$ (которое само включается как первые 2 байта):

$$ \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}$ вместо $x^{16}$ для каждого байта данных, то есть дополнительные $x^{24}$ на каждый байт. Эквивалентно, CRC FSoE — это 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]
  • Data начинается с au8Data[OFFS_DATA]

Наследование CRC — два уровня

Уровень 1: Межкадровое наследование (цепочка startCrc / oldCRC)

Первые два байта, включаемые в каждое вычисление CRC, — это CRC предыдущего кадра (startCrc). Это создаёт цепочку между кадрами:

$$ \begin{aligned} \text{CRC кадра } N &= f(\text{данные кадра } N,\; \text{CRC кадра } N{-}1) \\ \text{CRC кадра } N{+}1 &= f(\text{данные кадра } N{+}1,\; \text{CRC кадра } N) \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;
/* обработка: oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi,
            SeqNo-Lo, SeqNo-Hi, Command */
crc_common = crc;    /* сохраняется здесь */

Эта база инкапсулирует все «заголовочные» поля: унаследованный старый CRC, Connection ID, порядковый номер и байт команды.

Шаг 2 — CRC₀ (первый сегмент, продолжение от crc_common):

crc0.c
/* продолжение от crc_common: обработка Data[0], Data[1] */
/* результат = CRC₀  (сохраняется/проверяется по адресу pCrc) */

Шаг 3 — CRCᵢ для i ≥ 1 (последующие сегменты, каждый с перезапуском от crc_common):

crci.c
crc = crc_common;           /* ПЕРЕЗАПУСК от общей базы */
/* обработка: Index-Lo (i & 0xFF), Index-Hi (i >> 8),
            Data[2i], Data[2i+1] */
/* результат = CRCᵢ  (сохраняется/проверяется по следующей позиции pCrc) */

Структура цикла:

multi-crc-loop.c
while (size >= 4) {         /* каждая итерация = 2 байта данных + 2 байта CRC */
    crc = crc_common;       /* перезапуск от общей базы */
    /* включение: i_lo, i_hi, data[2i], data[2i+1] */
    /* проверка/вставка CRC по адресу pCrc[0..1] */
    size -= 4;
    pSafeData += 4;
    pCrc += 4;
    i++;
}

Почему важен байт индекса

Каждый сегментный CRCᵢ включает индекс $i$ (с нумерацией с 1, как 2-байтовое значение little-endian) между Command и Data. Это гарантирует, что даже если два сегмента содержат одинаковые байты данных, их CRC будут различаться, поскольку различаются индексы. Без индекса перестановка двух сегментов данных осталась бы незамеченной.

Визуализация

Следующая диаграмма показывает, как работает наследование CRC между кадрами и внутри одной много-CRC PDU. Каждый сегментный CRC перезапускается от crc_common (общей базы), а CRC каждого кадра становится startCrc для следующего кадра.

Сокращения, используемые на диаграмме

МеткаЗначение
oC.Lo, oC.HioldCRC (oC) — CRC предыдущего кадра, передаваемый как startCrc; разделён на младший/старший байт
CID.Lo, CID.HiConnection ID (ConnID) — младший/старший байт, читаемый из последних 2 байтов PDU
Sq.Lo, Sq.HiПорядковый номер (SeqNo) — младший/старший байт порядкового номера кадра
CmdБайт команды — код команды FSoE по адресу OFFS_COMMAND
D0, D1, D2, …Байты данных полезной нагрузки (начиная с OFFS_DATA)
i.Lo, i.HiИндекс сегмента i (с нумерацией с 1, little-endian) — младший/старший байт, включаемый в CRCᵢ для i ≥ 1
N.Lo, N.Hiв Кадре N+1: младший/старший байт CRC₀ Кадра N (который становится oldCRC Кадра N+1)
CRC₀, CRC₁, CRC₂значение CRC, вычисленное для сегмента 0, 1, 2 — каждое вставляется в 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 = CRC₀ кадра N)
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. Справа: CRC₀ Кадра N становится startCrc для Кадра N+1, продолжая цепочку.

Структура 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 = 0x0000, seqNo = 0x0001, oldCrc = 0x0000, если не указано иное.

ТестsizestartCrcConnIDCmdDataCRC₀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 демонстрирует межкадровое наследование: он использует CRC₀ из Теста 2 (0xDD27) как свой startCrc, вырабатывая другой CRC (0x41F5) для тех же данных — показывая, как цепочка наследования изменяет CRC даже при одинаковой полезной нагрузке.

Итоги

Механизм CRC FSoE имеет два уровня наследования:

  1. Межкадровое: CRC₀ предыдущего кадра включается в CRC следующего кадра как первые два байта, создавая цепочку, предотвращающую атаки воспроизведения. Цикл do…while гарантирует, что никакие два последовательных кадра не имеют одинаковый CRC.

  2. Внутрикадровое: Для многосегментных PDU общая база crc_common (содержащая oldCRC, ConnID, SeqNo, Command) вычисляется один раз и повторно используется как начальная точка для каждого сегментного CRC. Каждый сегмент добавляет свой индекс и 2 байта данных, вырабатывая независимый CRC, защищающий от перестановки сегментов.

Сам шаг обновления — это стандартный сдвиг регистра CRC-16 MSB-first, но с входным байтом, включаемым через $T_3$ (таблица $x^{40}$) вместо $T_0$ (таблица $x^{16}$), что даёт каждому байту данных дополнительный множитель $x^{24}$. Это делает CRC FSoE специальным вариантом, не эквивалентным ни одной стандартной конвенции CRC-16.

Связанные статьи

Обзор и основы

Структуры PDU по состояниям

CRC

Коды ошибок и формат данных


Check out similar posts by category: Functional Safety EtherCAT CRC