FSoE: ¿Cómo funciona la herencia de CRC?

Functional Safety over EtherCAT (FSoE) usa un CRC de 16 bits personalizado para proteger cada Safety PDU (Protocol Data Unit). A primera vista el algoritmo parece un CRC-16 estándar con tabla de búsqueda, pero dos decisiones de diseño lo hacen inusual:

  1. Cada byte de datos se multiplica por un factor extra de $x^{24}$ comparado con una actualización CRC estándar byte a byte.
  2. El CRC de la trama anterior se incorpora al CRC de la siguiente trama, creando una cadena entre tramas (“herencia de CRC”).

Este artículo explica ambos mecanismos en detalle, basándose en el código fuente original de FSoE y las tablas autogeneradas en Tables.c.

El polinomio y las dos tablas

El CRC de FSoE usa el polinomio de 17 bits

$$ P(x) = x^{16} + x^{13} + x^{12} + x^{11} + x^{8} + x^{7} + x^{5} + x^{4} + x^{2} + x + 1 $$

empaquetado como 0x39B7 (el término $x^{16}$ es implícito). El CRC se procesa MSB-first (la dirección “normal”, no reflejada).

Dos tablas de búsqueda de 256 entradas se precalculan a partir de este polinomio:

TablaFórmulaDesplazamientosSignificado
CRC16_TABLE ($T_0$)$T_0[i] = (i \cdot x^{16}) \bmod P$8Tabla estándar byte a byte
CRC16_TABLE2 ($T_3$)$T_3[i] = (i \cdot x^{40}) \bmod P$32Tabla slice-by-4 = $T_0$ aplicada 4× (byte $i$ + 3 bytes cero)

Ambas tablas se generan cargando $i$ en el byte alto de un registro de 16 bits y desplazando a la izquierda, haciendo XOR con el polinomio cuando el bit 15 está activo:

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

El paso de actualización por byte

Cada byte se incorpora al CRC con este patrón (repetido para cada byte en el cálculo):

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);

Simplificando (esto puede verificarse expandiendo las operaciones de bytes):

$$ \text{crc}_{\text{new}} = (\text{crc}_{\text{lo}} \ll 8) \;\oplus\; T_0[\text{crc}_{\text{hi}}] \;\oplus\; T_3[\text{input}] $$

El término $(\text{crc}{\text{lo}} \ll 8) \oplus T_0[\text{crc}{\text{hi}}]$ es exactamente el desplazamiento estándar MSB-first del registro CRC un byte (equivalente a procesar un byte cero). Luego se aplica XOR con $T_3[\text{input}]$.

Cómo difiere de un CRC-16 estándar

Una actualización CRC-16 estándar byte a byte sería:

$$ \text{crc}_{\text{std}} = (\text{crc}_{\text{lo}} \ll 8) \;\oplus\; T_0[\text{crc}_{\text{hi}} \oplus \text{input}] $$

que se expande a:

$$ \text{crc}_{\text{std}} = (\text{crc}_{\text{lo}} \ll 8) \;\oplus\; T_0[\text{crc}_{\text{hi}}] \;\oplus\; T_0[\text{input}] $$

El código FSoE reemplaza $T_0[\text{input}]$ con $T_3[\text{input}]$. Como la aritmética CRC es lineal sobre GF(2):

  • $T_0[\text{input}] = (\text{input} \cdot x^{16}) \bmod P$ — el byte de entrada en su posición natural
  • $T_3[\text{input}] = (\text{input} \cdot x^{40}) \bmod P$ — el byte de entrada desplazado 24 bits (3 bytes) más arriba

Así, cada byte de entrada recibe un factor extra de $x^{24}$ comparado con un CRC estándar. Tras procesar $n$ bytes $b_0 \dots b_{n-1}$ desde el valor inicial $\text{startCrc}$ (que a su vez se incorpora como los primeros 2 bytes):

$$ \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 $$

frente al estándar:

$$ \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 $$

La única diferencia es $x^{40}$ frente a $x^{16}$ para cada byte de datos — un $x^{24}$ extra por byte. De forma equivalente, el CRC de FSoE es el CRC del mensaje como si cada byte estuviera seguido de 3 bytes cero, pero con el valor inicial desplazado solo por el número real de bytes (no por el número con relleno). Es una variante personalizada específica de FSoE, no equivalente a ninguna convención estándar de CRC con relleno.

Orden de procesamiento de bytes

El CRC se reinicia a 0 al inicio de cada iteración, luego los bytes se procesan en este orden:

example.txt
oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi, SeqNo-Lo, SeqNo-Hi, Command, [Data...]
  • oldCRC proviene del parámetro startCrc (el CRC de la trama anterior — ver Herencia más abajo)
  • ConnID se lee del final de la PDU: au8Data[size-2] (Lo) y au8Data[size-1] (Hi)
  • SeqNo proviene del puntero seqNo
  • Command está en au8Data[OFFS_COMMAND]
  • Data empieza en au8Data[OFFS_DATA]

Herencia de CRC — dos niveles

Nivel 1: Herencia entre tramas (la cadena startCrc / oldCRC)

Los primeros dos bytes incorporados a cada cálculo de CRC son el CRC de la trama anterior (startCrc). Esto crea una cadena entre tramas:

$$ \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} $$

El CRC de cada trama depende de los CRC de todas las tramas anteriores. Un atacante no puede reenviar una trama antigua porque la cadena CRC se rompería.

El parámetro oldCrc se usa para detección de colisiones en el bucle do…while:

collision-avoidance.c
} while (crc == oldCrc && (bRcvDir & NEW_CRC) != 0);

Si el CRC recién calculado resulta ser igual al CRC de la trama anterior, el número de secuencia se incrementa (seqNo[0]++, saltando el cero) y el CRC se recalcula. Esto continúa hasta que el CRC difiere de oldCrc, garantizando que dos tramas consecutivas nunca compartan el mismo CRC — un requisito de seguridad para hacer detectables las colisiones de CRC.

Nivel 2: Herencia intra-trama (la base crc_common)

Dentro de una sola PDU, cuando la carga útil excede 10 bytes, se incrustan múltiples CRCs (CRC₀, CRC₁, CRC₂, …) — cada uno protegiendo un segmento de datos de 2 bytes. El mecanismo clave es crc_common.

Paso 1 — Calcular la base común (7 bytes procesados):

crc-common.c
crc = 0;
/* process: oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi,
            SeqNo-Lo, SeqNo-Hi, Command */
crc_common = crc;    /* saved here */

Esta base encapsula todos los campos de “cabecera”: el CRC antiguo heredado, el Connection ID, el Sequence Number y el byte Command.

Paso 2 — CRC₀ (primer segmento, continuando desde crc_common):

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

Paso 3 — CRCᵢ para i ≥ 1 (segmentos siguientes, cada uno reiniciando desde 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) */

La estructura del bucle:

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++;
}

Por qué importa el byte de índice

Cada CRC de segmento CRCᵢ incluye el índice $i$ (basado en 1, como valor little-endian de 2 bytes) entre el Command y los Data. Esto asegura que incluso si dos segmentos contienen bytes de datos idénticos, sus CRCs diferirán porque el índice difiere. Sin el índice, intercambiar dos segmentos de datos pasaría desapercibido.

Visualización

El siguiente diagrama muestra cómo funciona la herencia de CRC entre tramas y dentro de una sola PDU multi-CRC. Cada CRC de segmento se reinicia desde crc_common (la base compartida), y el CRC de cada trama se convierte en el startCrc de la siguiente trama.

Abreviaturas usadas en el diagrama

EtiquetaSignificado
oC.Lo, oC.HioldCRC (oC) — el CRC de la trama anterior, pasado como startCrc; dividido en byte bajo/alto
CID.Lo, CID.HiConnection ID (ConnID) — byte bajo/alto, leído de los últimos 2 bytes de la PDU
Sq.Lo, Sq.HiSequence Number (SeqNo) — byte bajo/alto del número de secuencia de la trama
Cmdbyte de Command — el opcode de comando FSoE en el offset OFFS_COMMAND
D0, D1, D2, …bytes de Data del payload (a partir de OFFS_DATA)
i.Lo, i.Hiíndice de segmento i (base 1, little-endian) — byte bajo/alto, incluido en CRCᵢ para i ≥ 1
N.Lo, N.Hien la trama N+1: el byte bajo/alto del CRC₀ de la trama N (que se convierte en el oldCRC de la trama N+1)
CRC₀, CRC₁, CRC₂el valor de CRC calculado para el segmento 0, 1, 2 — cada uno se inserta (o verifica) en la PDU
Base común (crc_common)
Byte de datos
CRC (insertado/verificado)
Índice
ConnID
Herencia entre tramas: el CRC₀ de cada trama se convierte en el startCrc (oldCRC) de la siguiente trama
Frame N−1
CRC₀: oC.Lo oC.Hi CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd D0 D1
CRC₀ = 0xDD27 (ejemplo)
Frame N (startCrc = 0xDD27)
crc_common (base compartida para todos los segmentos)
0x27 0xDD CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd
CRC₀: (desde común) D0 D1 CRC₀
CRC₁: (reiniciar común) i.Lo i.Hi D2 D3 CRC₁
CRC₂: (reiniciar común) i.Lo i.Hi D4 D5 CRC₂
Frame N+1 (startCrc = CRC₀ de N)
CRC₀: N.Lo N.Hi CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd D0 D1
CRC₀ alimenta Frame 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 )  /* guardado, no transmitido */
nextFrame.startCrc = thisFrame.CRC₀
Izquierda: Frame N−1 produce CRC₀ = 0xDD27. Centro: Frame N usa 0xDD27 como startCrc — los primeros dos bytes incorporados a cada cálculo de CRC. La base común (azul) se calcula una vez y se reutiliza para cada segmento. Cada CRC de segmento se reinicia desde esta base, añade su índice y bytes de datos, y produce un CRC independiente. Derecha: El CRC₀ de Frame N se convierte en el startCrc de Frame N+1, continuando la cadena.

Disposición de la PDU

La disposición de bytes de la PDU (reconstruida a partir de los accesos a campos en CalcCrc) depende del tamaño total:

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 siempre está en los últimos 2 bytes (au8Data[size-2..size-1])
  • CRC₀ está en el offset 2 (size ≤ 6) o offset 3 (size > 6)
  • Cada segmento adicional son 4 bytes: 2 datos + 2 CRC
  • size -= 7 contabiliza la primera parte (ConnID 2 + CRC₀ 2 + Command 1 + Data 2), luego size -= 4 por cada segmento adicional

Valores CRC de referencia

Los siguientes valores de referencia se generaron con una transcripción fiel de la función original CalcCrc y se verificaron independientemente usando división polinómica bit a bit (sin tablas de búsqueda). Todas las entradas usan startCrc = 0x0000, seqNo = 0x0001, oldCrc = 0x0000 salvo que se indique lo contrario.

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

Test 6b demuestra herencia entre tramas: usa el CRC₀ del Test 2 (0xDD27) como su startCrc, produciendo un CRC diferente (0x41F5) para los mismos datos — mostrando cómo la cadena de herencia cambia el CRC incluso cuando la carga útil es idéntica.

Resumen

El mecanismo CRC de FSoE tiene dos capas de herencia:

  1. Entre tramas: El CRC₀ de la trama anterior se incorpora al CRC de la siguiente trama como los primeros dos bytes, creando una cadena que previene ataques de replay. Un bucle do…while asegura que dos tramas consecutivas no compartan el mismo CRC.

  2. Intra-trama: Para PDUs multi-segmento, una base crc_common compartida (que contiene oldCRC, ConnID, SeqNo, Command) se calcula una vez y se reutiliza como punto de partida para cada CRC de segmento. Cada segmento añade su índice y 2 bytes de datos, produciendo un CRC independiente que protege contra reordenamiento de segmentos.

El paso de actualización en sí es un desplazamiento estándar MSB-first del registro CRC-16, pero con el byte de entrada incorporado vía $T_3$ (la tabla $x^{40}$) en lugar de $T_0$ (la tabla $x^{16}$), dando a cada byte de datos un factor extra de $x^{24}$. Esto hace que el CRC de FSoE sea una variante personalizada, no equivalente a ninguna convención estándar de CRC-16.

Artículos relacionados

Visión general y conceptos básicos

CRC

Códigos de error y formato de datos


Echa un vistazo a artículos similares por categoría: Functional Safety EtherCAT CRC