3.20. Protocolos seriais, framing e CRCs¶
UART em código moveu bytes entre duas extremidades. Por si só, isso não basta para construir um enlace confiável. Três problemas aparecem assim que há um dispositivo real do outro lado do fio:
Onde uma mensagem começa e termina? Os bytes chegam em um fluxo sem nenhum delimitador embutido. Se o receptor perder o primeiro byte (ligado depois do transmissor; uma breve falha elétrica na linha), todos os bytes seguintes ficam deslocados de uma posição até que o receptor encontre um novo ponto de ressincronização.
Qual é o tamanho de cada mensagem? Uma leitura de sensor de 32 bytes e uma resposta de status de 4 bytes parecem idênticas no nível dos bytes. O receptor precisa de uma forma de saber quantos bytes pertencem à mensagem atual.
Os bytes chegaram intactos? O ruído pode inverter bits individuais. Sem uma verificação, o receptor age tranquilamente sobre dados corrompidos.
A resposta padrão para os três problemas é envolver os dados em um quadro de pacote (packet frame): uma sequência de bytes conhecida no início, um campo de comprimento, o payload em si e uma soma de verificação (checksum) no fim.
3.20.1. Framing de pacotes¶
Um formato típico de framing:
Um pacote com framing e CRCs separados para cabeçalho e dados: cabeçalho (magic bytes), comando, comprimento, CRC de cabeçalho, payload, CRC de dados.¶
Cada campo cumpre uma função:
Header (magic bytes). Uma sequência de bytes fixa e incomum – muitas vezes dois bytes como
0xAA 0x55– que o receptor procura no fluxo de entrada. Quando encontra a sequência, ele sabe que um novo pacote está começando e pode descartar qualquer lixo que tenha vindo antes.Command. Um único byte que diz o que o pacote é. Valores de comando diferentes usam formatos de payload diferentes – um comando pode significar “definir o ângulo do servo” com dois bytes de payload, outro pode significar “ler sensor” sem payload, outro pode ser “mensagem de log” com uma string. O receptor faz o dispatch com base no byte de comando para saber como interpretar o resto do pacote.
Length. Dois bytes que informam o tamanho do payload em bytes (little-endian aqui), permitindo payloads de até cerca de 64 KiB. O receptor lê exatamente essa quantidade de bytes depois que o CRC do cabeçalho é verificado.
Header CRC. Uma soma de verificação de um byte sobre os campos HEADER, CMD e LEN. O receptor a verifica antes de ler qualquer payload, de modo que um LEN corrompido é detectado após apenas um punhado de bytes (veja a seção sobre CRC abaixo para entender por que isso importa).
Payload. Dados de aplicação específicos do comando, com exatamente LEN bytes de comprimento. O formato é determinado pelo byte de comando: um registro empacotado com
structde campos de largura fixa, uma string, memória bruta – o que quer que ambos os lados combinem para aquele comando.Data CRC. Um CRC de quatro bytes sobre os bytes do payload. O receptor o recalcula a partir dos bytes que acabou de ler e descarta o pacote se não houver correspondência.
3.20.2. CRCs¶
A “soma de verificação” mais simples é a soma de todos os bytes, módulo 256 ou 65536. Ela detecta a maioria das inversões de bit único, mas deixa passar muitos erros de múltiplos bits e ignora a ordem dos bytes.
Uma verificação de redundância cíclica (CRC) é o aprimoramento padrão. Ela trata a entrada como um único número binário longo e o divide (de uma forma especial) por um polinômio fixo; o resto da divisão é o CRC. Polinômios diferentes detectam classes diferentes de erros; os polinômios comuns de 8, 16 e 32 bits detectam cada um todas as rajadas de erros mais curtas que sua largura, além de uma grande fração das rajadas mais longas.
3.20.2.1. Por que dois CRCs¶
O diagrama de pacote acima carrega dois CRCs separados – um sobre o cabeçalho (HEADER, CMD, LEN) e outro sobre o payload. É disso que um framing robusto realmente precisa, por causa de como um único CRC final falha quando o próprio campo LEN é corrompido durante o trânsito:
O receptor age sobre o LEN corrompido e lê essa quantidade de bytes do fio – possivelmente muito mais do que o transmissor pretendia.
Somente o CRC final acaba avisando o receptor de que algo deu errado, e apenas depois que todos esses bytes já foram consumidos.
Enquanto o parser fica preso esperando pelo número errado de bytes, pacotes reais que chegam atrás do corrompido são engolidos como payload, e o receptor perde vários pacotes em vez de apenas um.
Dividir o CRC resolve isso:
O CRC de cabeçalho cobre HEADER, CMD e LEN. O receptor o verifica antes de ler qualquer payload, de modo que um LEN corrompido é detectado após um punhado de bytes e o parser ressincroniza imediatamente, derrubando apenas o pacote defeituoso.
O CRC de dados cobre o payload. Uma vez que o CRC de cabeçalho passou, o receptor sabe que pode confiar no LEN, lê exatamente essa quantidade de bytes de payload e os verifica contra o CRC de dados.
Um dimensionamento comum – e o que esta página usa – é um byte para o CRC de cabeçalho (um CRC-8 é mais que suficiente para um cabeçalho de cinco bytes) e quatro bytes para o CRC de dados (um CRC-32 cobre muitos kilobytes de payload com uma taxa de colisão extremamente baixa).
3.20.2.2. Funções auxiliares¶
O MicroPython inclui binascii.crc32() para o CRC de quatro bytes diretamente. Para o CRC de cabeçalho de um byte, uma pequena função auxiliar que usa o polinômio que os dispositivos 1-wire da Maxim usam (0x8C na forma refletida) é curta o bastante para escrever inline:
def crc8(data: bytes) -> int:
crc = 0
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0x8C if crc & 1 else crc >> 1
return crc & 0xFF
Um codificador completo combina os dois CRCs em uma única função:
import binascii
import struct
def encode_packet(cmd: int, payload: bytes) -> bytes:
header = b"\xAA\x55" + bytes([cmd]) + struct.pack("<H", len(payload))
hcrc = crc8(header)
dcrc = binascii.crc32(payload)
return header + bytes([hcrc]) + payload + struct.pack("<I", dcrc)
A função inversa recupera o comando e o payload de um pacote completo, ou retorna None se qualquer uma das verificações de CRC falhar:
def decode_packet(packet: bytes):
# Layout: HEADER(2) + CMD(1) + LEN(2) + HCRC(1) + PAYLOAD(LEN) + DCRC(4)
if len(packet) < 10 or packet[0:2] != b"\xAA\x55":
return None
header = packet[0:5]
if crc8(header) != packet[5]:
return None # header CRC mismatch
cmd = packet[2]
length = struct.unpack("<H", packet[3:5])[0]
if len(packet) != 6 + length + 4:
return None # truncated or oversized
payload = packet[6:6 + length]
received_dcrc = struct.unpack("<I", packet[6 + length:])[0]
if binascii.crc32(payload) != received_dcrc:
return None # data CRC mismatch
return cmd, bytes(payload)
Na prática, o receptor não recebe um pacote inteiro de uma vez – os bytes chegam um a um pela UART, e um transmissor que faz uma pausa no meio do pacote (ou uma linha ruidosa que perde um byte) não pode simplesmente ser lido com read() para um buffer do tamanho certo. A próxima seção executa a mesma lógica de decodificação byte a byte como uma máquina de estados.
3.20.3. Um receptor baseado em máquina de estados¶
O receptor não pode simplesmente chamar uart.read(N) para algum N fixo – ele não sabe quantos bytes terá o próximo pacote, e qualquer lixo na linha desalinha tudo. A solução é uma pequena máquina de estados que consome os bytes um a um e reage com base em onde está dentro do pacote. O laço principal consulta any() para ver quantos bytes estão no buffer, os esvazia em uma única chamada read() e alimenta cada byte na máquina de estados:
import time
import binascii
import struct
from machine import UART
HEADER = b"\xAA\x55"
HEADER_LEN = len(HEADER)
# States, in the order the receiver walks through them per packet.
HUNT_FOR_HEADER = 0
READ_COMMAND = 1
READ_LENGTH = 2
READ_HEADER_CRC = 3
READ_PAYLOAD = 4
READ_DATA_CRC = 5
uart = UART(3, baudrate=115200)
# Receiver state plus partial-field buffers.
state = HUNT_FOR_HEADER
matched = 0 # bytes of HEADER matched so far
cmd = 0 # CMD captured in READ_COMMAND
length_bytes = bytearray() # raw LEN bytes (kept for the header CRC)
length = 0 # unpacked LEN
payload = bytearray() # payload bytes accumulated in READ_PAYLOAD
crc_bytes = bytearray() # DCRC bytes accumulated in READ_DATA_CRC
def handle_packet(cmd: int, payload: bytes) -> None:
print("cmd", cmd, "payload", payload)
while True:
# Drain whatever bytes have arrived since the last poll. Idle
# briefly when the line is quiet so the loop is not a busy spin.
n = uart.any()
if not n:
time.sleep_ms(1)
continue
for b in uart.read(n):
if state == HUNT_FOR_HEADER:
# Walk the magic bytes. On a mismatch, back off by one
# so a stray HEADER[0] in the noise still counts as a
# possible start.
if b == HEADER[matched]:
matched += 1
if matched == HEADER_LEN:
state = READ_COMMAND
matched = 0
else:
matched = 1 if b == HEADER[0] else 0
elif state == READ_COMMAND:
cmd = b
length_bytes = bytearray()
state = READ_LENGTH
elif state == READ_LENGTH:
# LEN is two bytes little-endian.
length_bytes.append(b)
if len(length_bytes) == 2:
length = struct.unpack("<H", length_bytes)[0]
state = READ_HEADER_CRC
elif state == READ_HEADER_CRC:
# Verify the CRC over HEADER + CMD + LEN before
# committing to read LEN payload bytes. A mismatch
# aborts here, after just five header bytes -- the
# next valid header re-syncs quickly.
expected = crc8(HEADER + bytes([cmd]) + length_bytes)
if b == expected:
payload = bytearray()
state = READ_PAYLOAD
else:
state = HUNT_FOR_HEADER
elif state == READ_PAYLOAD:
payload.append(b)
if len(payload) == length:
crc_bytes = bytearray()
state = READ_DATA_CRC
elif state == READ_DATA_CRC:
# Verify the CRC over the payload and either deliver
# the packet or drop it. Either way, go back to
# looking for the next header.
crc_bytes.append(b)
if len(crc_bytes) == 4:
expected = binascii.crc32(payload)
received = struct.unpack("<I", crc_bytes)[0]
if expected == received:
handle_packet(cmd, bytes(payload))
state = HUNT_FOR_HEADER
Cada byte avança a máquina de estados em um passo, ou volta para HUNT_FOR_HEADER após um pacote completo, um CRC de cabeçalho inválido ou um CRC de dados inválido. O lixo na linha que não corresponde ao cabeçalho é descartado silenciosamente; o próximo cabeçalho válido ressincroniza o receptor. A principal propriedade de segurança vem do CRC de cabeçalho: se o campo LEN for corrompido, o parser detecta isso após a verificação do CRC de cabeçalho (um punhado de bytes), e não depois de se comprometer a ler um número absurdamente errado de bytes de payload.
3.20.4. Além do básico¶
O framing acima é o mínimo de que um enlace serial precisa para se recuperar de ruído na linha: magic do cabeçalho, comprimento, comando e dois CRCs. Ele detecta corrupção e ressincroniza após bytes embaralhados, mas desiste de pacotes danificados em vez de fazê-los passar, e deixa o transmissor sem ideia do que o receptor de fato recebeu.
Protocolos seriais do mundo real adicionam camadas de recursos sobre essa base. Nem todo enlace embarcado precisa de todos eles – escolha o que a aplicação realmente exige:
Números de sequência. Um pequeno contador que incrementa a cada envio. O receptor detecta lacunas (um pacote foi perdido), duplicatas (o transmissor retransmitiu, mas o receptor já havia aceitado a primeira cópia) e – onde o canal pode reordenar – chegadas fora de ordem.
Confirmações (Acknowledgements). Um pacote ACK dedicado (ou um bit embutido em uma resposta) que o receptor envia de volta para confirmar cada pacote. Sem ACKs, o transmissor não tem como saber se seus dados chegaram.
Confirmações negativas (Negative acknowledgements). Um NACK enviado quando o receptor detecta uma falha de CRC ou uma lacuna de sequência. O transmissor retransmite imediatamente, em vez de esperar que um timeout de ACK dispare.
Retransmissão. O transmissor mantém cada pacote não confirmado em uma pequena fila e o reenvia após um timeout (ou ao receber um NACK). Um limite de tentativas e algum backoff entre elas impedem que um enlace permanentemente quebrado fique em loop para sempre.
Janelas deslizantes (Sliding windows). Permitir vários pacotes em trânsito antes de exigir um ACK mantém o throughput alto em enlaces onde o tempo de ida e volta é longo em comparação com o tempo de envio por pacote. O custo é mais estado no lado do transmissor – um slot por pacote em trânsito.
Controle de fluxo. Um sinal do receptor que diz ao transmissor para desacelerar ou pausar quando seu buffer está enchendo. As implementações variam – bytes XON / XOFF explícitos, concessões baseadas em crédito em que o receptor licencia mais N pacotes por vez, ou as linhas de hardware RTS / CTS no próprio fio. Sem controle de fluxo, um transmissor rápido acaba sobrecarregando um receptor lento e pacotes são descartados.
Versão do protocolo. Um campo de versão no início do pacote permite que o formato evolua. Cada lado pode negociar na inicialização a versão mais alta que ambos suportam, ou rejeitar pacotes de peers incompatíveis.
Fragmentação e remontagem. Um LEN de dois bytes limita o pacote a 64 KiB; mensagens maiores que isso são divididas em vários pacotes e remontadas do outro lado. Os metadados de fragmentação (índice do fragmento, contagem total ou uma flag de “mais fragmentos”) ficam dentro do payload.
Heartbeats. Um pequeno pacote periódico que diz “ainda estou aqui”. O outro lado percebe quando os heartbeats param e reconecta (ou falha de forma explícita) em vez de travar silenciosamente.
Canais. Um ID de canal ou stream no cabeçalho para que um único enlace físico carregue vários streams lógicos – um canal de controle, um canal de telemetria, um canal de log – distinguidos apenas por esse campo.
Autenticação. Uma tag curta calculada a partir do payload e de um valor secreto que apenas o transmissor e o receptor legítimos conhecem. O receptor calcula a tag novamente a partir dos bytes que recebeu e rejeita o pacote se as duas não coincidirem. Isso detecta tanto adulteração (um atacante modificou os bytes) quanto – se um número de sequência ou um timestamp fizer parte do que a tag cobre – replay, em que um atacante grava um pacote real da linha e o reenvia depois para fazer o receptor agir sobre ele duas vezes.
Criptografia. Embaralhar os bytes do payload com uma chave secreta compartilhada para que qualquer um que leia a linha sem essa chave veja apenas ruído. Normalmente combinada com a tag de autenticação acima – sem ela, um atacante pode injetar lixo que por acaso passa no CRC e o receptor desperdiça ciclos tentando decifrar um amontoado sem sentido.
Um protocolo “bom” típico para equipamentos industriais acaba tendo framing, CRC duplo, números de sequência, ACK / NACK com retransmissão e heartbeats. Exemplos do mundo real que vale a pena conhecer: MAVLink (telemetria de drones, com números de sequência, IDs de sistema / componente e assinaturas de pacote opcionais), Modbus (CLPs industriais, com códigos de função e CRC) e NMEA 0183 (o protocolo ASCII que todo receptor GPS de consumo fala – mensagens baseadas em linha com uma soma de verificação após um delimitador de asterisco).