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:
- Cada byte de datos se multiplica por un factor extra de $x^{24}$ comparado con una actualización CRC estándar byte a byte.
- 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:
| Tabla | Fórmula | Desplazamientos | Significado |
|---|---|---|---|
CRC16_TABLE ($T_0$) | $T_0[i] = (i \cdot x^{16}) \bmod P$ | 8 | Tabla estándar byte a byte |
CRC16_TABLE2 ($T_3$) | $T_3[i] = (i \cdot x^{40}) \bmod P$ | 32 | Tabla 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:
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):
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:
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) yau8Data[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:
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:
} 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 = 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):
/* 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):
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:
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
| Etiqueta | Significado |
|---|---|
oC.Lo, oC.Hi | oldCRC (oC) — el CRC de la trama anterior, pasado como startCrc; dividido en byte bajo/alto |
CID.Lo, CID.Hi | Connection ID (ConnID) — byte bajo/alto, leído de los últimos 2 bytes de la PDU |
Sq.Lo, Sq.Hi | Sequence Number (SeqNo) — byte bajo/alto del número de secuencia de la trama |
Cmd | byte 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.Hi | en 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 |
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₀
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:
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 -= 7contabiliza la primera parte (ConnID 2 + CRC₀ 2 + Command 1 + Data 2), luegosize -= 4por 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.
| 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 |
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:
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…whileasegura que dos tramas consecutivas no compartan el mismo CRC.Intra-trama: Para PDUs multi-segmento, una base
crc_commoncompartida (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
- ¿Qué significa realmente la abreviatura FSoE? — qué significa el acrónimo FSoE
- FSoE: estructura de trama explicada con ejemplos — estructura general de la Safety PDU
- FSoE: tabla de comandos de Safety PDU — la lista completa de comandos FSoE
CRC
- FSoE CRC: ¿qué polinomio utiliza? — el polinomio de 17 bits del CRC de FSoE
- FSoE: ¿Cómo se construyen las tablas CRC? — cómo se generan las tablas de lookup CRC
Códigos de error y formato de datos
- FSoE: lista de todos los códigos de error de comunicación — todos los códigos de error de comunicación FSoE
- FSoE: ¿los datos se transmiten en little-endian o big-endian? — orden de bytes de los campos multibyte