FSoE: Como funciona a herança de CRC?

Functional Safety over EtherCAT (FSoE) usa um CRC de 16 bits personalizado para proteger cada Safety PDU (Protocol Data Unit). À primeira vista, o algoritmo parece um CRC-16 padrão com tabela de consulta, mas duas escolhas de design o tornam incomum:

  1. Cada byte de dados é multiplicado por um fator extra de $x^{24}$ em comparação com uma atualização de CRC padrão byte a byte.
  2. O CRC do quadro anterior é dobrado no CRC do próximo quadro, criando uma cadeia entre quadros (“herança de CRC”).

Este artigo explica ambos os mecanismos em detalhe, com base no código-fonte original do FSoE e nas tabelas de consulta geradas automaticamente em Tables.c.

O polinômio e as duas tabelas

O CRC do FSoE usa o polinômio 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 $$

empacotado como 0x39B7 (o termo $x^{16}$ é implícito). O CRC é processado MSB-first (a direção “normal”, não refletida).

Duas tabelas de consulta de 256 entradas são pré-computadas a partir deste polinômio:

TabelaFórmulaDeslocamentosSignificado
CRC16_TABLE ($T_0$)$T_0[i] = (i \cdot x^{16}) \bmod P$8Tabela padrão byte a byte
CRC16_TABLE2 ($T_3$)$T_3[i] = (i \cdot x^{40}) \bmod P$32Tabela slice-by-4 = $T_0$ aplicada 4× (byte $i$ + 3 bytes zero)

Ambas as tabelas são geradas carregando $i$ no byte alto de um registrador de 16 bits e deslocando para a esquerda, fazendo XOR com o polinômio sempre que o bit 15 estiver definido:

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

A etapa de atualização por byte

Cada byte é dobrado no CRC com este padrão (repetido para cada byte no cálculo):

fsoe-update.c
w1 = aCRCTab1[crc >> 8];       /* T0[crc_hi]  -- deslocar registrador CRC 1 byte */
w2 = aCRCTab2[input_byte];     /* T3[input]   -- incorporar o byte de entrada  */
w  = w1 ^ w2;
crc = (w & 0xFF) | ((w >> 8 ^ crc_lo) << 8);

Simplificando (isso pode ser verificado expandindo as operações de byte):

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

O termo $(\text{crc}{\text{lo}} \ll 8) \oplus T_0[\text{crc}{\text{hi}}]$ é exatamente o deslocamento padrão do registrador CRC MSB-first por um byte (equivalente a processar um byte zero). Então $T_3[\text{input}]$ é adicionado via XOR.

Como isso difere de um CRC-16 padrão

Uma atualização de CRC-16 padrão byte a byte seria:

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

que se expande para:

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

O código FSoE substitui $T_0[\text{input}]$ por $T_3[\text{input}]$. Como a aritmética de CRC é linear sobre GF(2):

  • $T_0[\text{input}] = (\text{input} \cdot x^{16}) \bmod P$ — o byte de entrada em sua posição natural
  • $T_3[\text{input}] = (\text{input} \cdot x^{40}) \bmod P$ — o byte de entrada deslocado 24 bits (3 bytes) para cima

Portanto, cada byte de entrada recebe um fator extra de $x^{24}$ em comparação com um CRC padrão. Após processar $n$ bytes $b_0 \dots b_{n-1}$ a partir do valor inicial $\text{startCrc}$ (que ele próprio é dobrado como os primeiros 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 $$

versus o padrão:

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

A única diferença é $x^{40}$ vs $x^{16}$ para cada byte de dados — um extra de $x^{24}$ por byte. Equivalentemente, o CRC do FSoE é o CRC da mensagem como se cada byte fosse seguido por 3 bytes zero, mas com o valor inicial deslocado apenas pela contagem real de bytes (não pela contagem preenchida). Esta é uma variante personalizada específica do FSoE, não equivalente a nenhuma convenção padrão de CRC preenchido.

Ordem de processamento de bytes

O CRC é redefinido para 0 no início de cada iteração, então os bytes são processados nesta ordem:

example.txt
oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi, SeqNo-Lo, SeqNo-Hi, Command, [Data...]
  • oldCRC vem do parâmetro startCrc (o CRC do quadro anterior — veja Herança abaixo)
  • ConnID é lido do final da PDU: au8Data[size-2] (Lo) e au8Data[size-1] (Hi)
  • SeqNo vem do ponteiro seqNo
  • Command está em au8Data[OFFS_COMMAND]
  • Data começa em au8Data[OFFS_DATA]

Herança de CRC — dois níveis

Nível 1: Herança entre quadros (a cadeia startCrc / oldCRC)

Os primeiros dois bytes dobrados em cada cálculo de CRC são o CRC do quadro anterior (startCrc). Isso cria uma cadeia entre quadros:

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

O CRC de cada quadro depende dos CRCs de todos os quadros anteriores. Um atacante não pode reproduzir um quadro antigo porque a cadeia de CRC seria quebrada.

O parâmetro oldCrc é usado para detecção de colisão no loop do…while:

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

Se o CRC recém-computado acontecer de ser igual ao CRC do quadro anterior, o número de sequência é incrementado (seqNo[0]++, pulando zero) e o CRC é recomputado. Isso continua até que o CRC difira de oldCrc, garantindo que dois quadros consecutivos nunca compartilhem o mesmo CRC — um requisito de segurança para tornar as colisões de CRC detectáveis.

Nível 2: Herança intra-quadro (a base crc_common)

Dentro de uma única PDU, quando o payload excede 10 bytes, múltiplos CRCs (CRC₀, CRC₁, CRC₂, …) são embutidos — cada um protegendo um segmento de dados de 2 bytes. O mecanismo chave é o crc_common.

Passo 1 — Computar a base comum (7 bytes processados):

crc-common.c
crc = 0;
/* processar: oldCRC-Lo, oldCRC-Hi, ConnID-Lo, ConnID-Hi,
            SeqNo-Lo, SeqNo-Hi, Command */
crc_common = crc;    /* salvo aqui */

Essa base encapsula todos os campos de “cabeçalho”: o CRC antigo herdado, o Connection ID, o Sequence Number e o byte Command.

Passo 2 — CRC₀ (primeiro segmento, continuando de crc_common):

crc0.c
/* continuar de crc_common: processar Data[0], Data[1] */
/* resultado = CRC₀  (armazenado/verificado em pCrc) */

Passo 3 — CRCᵢ para i ≥ 1 (segmentos subsequentes, cada um reiniciando de crc_common):

crci.c
crc = crc_common;           /* REINICIAR da base compartilhada */
/* processar: Index-Lo (i & 0xFF), Index-Hi (i >> 8),
            Data[2i], Data[2i+1] */
/* resultado = CRCᵢ  (armazenado/verificado na próxima posição pCrc) */

A estrutura do loop:

multi-crc-loop.c
while (size >= 4) {         /* cada iteração = 2 bytes de dados + 2 bytes CRC */
    crc = crc_common;       /* reiniciar da base compartilhada */
    /* incorporar: i_lo, i_hi, data[2i], data[2i+1] */
    /* verificar/inserir CRC em pCrc[0..1] */
    size -= 4;
    pSafeData += 4;
    pCrc += 4;
    i++;
}

Por que o byte de índice importa

Cada CRC de segmento CRCᵢ inclui o índice $i$ (baseado em 1, como um valor little-endian de 2 bytes) entre o Command e o Data. Isso garante que mesmo se dois segmentos contiverem bytes de dados idênticos, seus CRCs serão diferentes porque o índice difere. Sem o índice, a troca de dois segmentos de dados passaria despercebida.

Visualização

O diagrama a seguir mostra como a herança de CRC funciona entre quadros e dentro de uma única PDU multi-CRC. Cada CRC de segmento reinicia de crc_common (a base compartilhada), e o CRC de cada quadro se torna o startCrc para o próximo quadro.

Abreviações usadas no diagrama

RótuloSignificado
oC.Lo, oC.HioldCRC (oC) — o CRC do quadro anterior, passado como startCrc; dividido em byte baixo/alto
CID.Lo, CID.HiConnection ID (ConnID) — byte baixo/alto, lido dos últimos 2 bytes da PDU
Sq.Lo, Sq.HiSequence Number (SeqNo) — byte baixo/alto do número de sequência do quadro
Cmdbyte de Command — o opcode de comando FSoE no offset OFFS_COMMAND
D0, D1, D2, …bytes de Data do payload (a partir de OFFS_DATA)
i.Lo, i.Hiíndice de segmento i (base 1, little-endian) — byte baixo/alto, incluído em CRCᵢ para i ≥ 1
N.Lo, N.Hino quadro N+1: o byte baixo/alto do CRC₀ do quadro N (que se torna o oldCRC do quadro N+1)
CRC₀, CRC₁, CRC₂o valor de CRC calculado para o segmento 0, 1, 2 — cada um é inserido (ou verificado) na PDU
Base comum (crc_common)
Byte de dados
CRC (inserido/verificado)
Índice
ConnID
Herança entre quadros: o CRC₀ de cada quadro se torna o startCrc (oldCRC) do próximo quadro
Quadro N−1
CRC₀: oC.Lo oC.Hi CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd D0 D1
CRC₀ = 0xDD27 (exemplo)
Quadro N (startCrc = 0xDD27)
crc_common (base compartilhada para todos os segmentos)
0x27 0xDD CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd
CRC₀: (da comum) D0 D1 CRC₀
CRC₁: (reinicia comum) i.Lo i.Hi D2 D3 CRC₁
CRC₂: (reinicia comum) i.Lo i.Hi D4 D5 CRC₂
Quadro N+1 (startCrc = CRC₀ de N)
CRC₀: N.Lo N.Hi CID.Lo CID.Hi Sq.Lo Sq.Hi Cmd D0 D1
CRC₀ alimenta o Quadro 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 )  /* salvo, não transmitido */
nextFrame.startCrc = thisFrame.CRC₀
Esquerda: O Quadro N−1 produz CRC₀ = 0xDD27. Centro: O Quadro N usa 0xDD27 como seu startCrc — os primeiros dois bytes dobrados em cada computação de CRC. A base comum (azul) é computada uma vez e reutilizada para cada segmento. Cada CRC de segmento reinicia dessa base, adiciona seu índice e bytes de dados, e produz um CRC independente. Direita: O CRC₀ do Quadro N se torna o startCrc do Quadro N+1, continuando a cadeia.

Layout da PDU

O layout de bytes da PDU (reconstruído a partir dos acessos de campo em CalcCrc) depende do tamanho 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 está sempre nos últimos 2 bytes (au8Data[size-2..size-1])
  • CRC₀ está no offset 2 (size ≤ 6) ou offset 3 (size > 6)
  • Cada segmento adicional tem 4 bytes: 2 dados + 2 CRC
  • size -= 7 contabiliza a primeira parte (ConnID 2 + CRC₀ 2 + Command 1 + Data 2), então size -= 4 por segmento adicional

Valores de referência de CRC

Os seguintes valores de referência foram gerados por uma transcrição fiel da função original CalcCrc e verificados independentemente usando divisão polinomial longa bit a bit (sem tabelas de consulta). Todas as entradas usam startCrc = 0x0000, seqNo = 0x0001, oldCrc = 0x0000 salvo indicação em contrário.

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

Teste 6b demonstra herança entre quadros: ele usa o CRC₀ do Teste 2 (0xDD27) como seu startCrc, produzindo um CRC diferente (0x41F5) para os mesmos dados — mostrando como a cadeia de herança muda o CRC mesmo quando o payload é idêntico.

Resumo

O mecanismo de CRC do FSoE tem duas camadas de herança:

  1. Entre quadros: O CRC₀ do quadro anterior é dobrado no CRC do próximo quadro como os primeiros dois bytes, criando uma cadeia que previne ataques de repetição. Um loop do…while garante que dois quadros consecutivos não compartilhem o mesmo CRC.

  2. Intra-quadro: Para PDUs multi-segmento, uma base compartilhada crc_common (contendo oldCRC, ConnID, SeqNo, Command) é computada uma vez e reutilizada como ponto de partida para cada CRC de segmento. Cada segmento adiciona seu índice e 2 bytes de dados, produzindo um CRC independente que protege contra reordenação de segmentos.

A etapa de atualização em si é um deslocamento padrão de registrador CRC-16 MSB-first, mas com o byte de entrada dobrado via $T_3$ (a tabela $x^{40}$) em vez de $T_0$ (a tabela $x^{16}$), dando a cada byte de dados um fator extra de $x^{24}$. Isso torna o CRC do FSoE uma variante personalizada, não equivalente a nenhuma convenção padrão de CRC-16.

Artigos relacionados

Visão geral e noções básicas

CRC

Códigos de erro e formato de dados


Check out similar posts by category: Functional Safety EtherCAT CRC