3.20. Protocolos série, enquadramento e CRCs

UART em código moveu bytes entre dois extremos. Por si só, isso não é suficiente para construir uma ligação fiável. Três problemas surgem assim que existe um dispositivo real no outro extremo do fio:

  • Onde começa e termina uma mensagem? Os bytes chegam em fluxo sem delimitador incorporado. Se o receptor perder o primeiro byte (ligado após o emissor; breve falha eléctrica na linha), todos os bytes seguintes ficam desalinhados por um até o receptor encontrar um ponto de ressincronização.

  • Qual é o comprimento de cada mensagem? Uma leitura de sensor de 32 bytes e uma resposta de estado de 4 bytes parecem idênticas ao nível do byte. O receptor precisa de uma forma de saber quantos bytes pertencem à mensagem actual.

  • Os bytes chegaram intactos? O ruído pode inverter bits individuais. Sem uma verificação, o receptor age alegremente sobre dados corrompidos.

A resposta padrão para os três problemas é envolver os dados num quadro de pacote: uma sequência de bytes conhecida no início, um campo de comprimento, os próprios dados e uma soma de verificação no final.

3.20.1. Enquadramento de pacotes

Um formato de enquadramento típico:

Six fields drawn in sequence: a two-byte HEADER labelled 0xAA 0x55, a one-byte CMD field selecting which command this packet carries, a two-byte LEN field giving the payload size, a one-byte HCRC field covering HEADER plus CMD plus LEN, a variable-length PAYLOAD of LEN bytes whose format depends on the CMD, and a four-byte DCRC field covering the payload.

Um pacote enquadrado com CRCs separados para cabeçalho e dados: cabeçalho (bytes mágicos), comando, comprimento, CRC do cabeçalho, payload, CRC dos dados.

Cada campo desempenha uma função:

  • Cabeçalho (bytes mágicos). Uma sequência de bytes fixa e pouco comum – frequentemente dois bytes como 0xAA 0x55 – que o receptor procura no fluxo de entrada. Quando encontra a sequência, sabe que um novo pacote está a começar e pode descartar qualquer lixo que tenha chegado antes.

  • Comando. Um único byte que indica o que é o pacote. Valores de comando diferentes utilizam formatos de payload diferentes – um comando pode significar «definir ângulo do servo» com dois bytes de payload, outro pode significar «ler sensor» sem payload, outro pode ser «mensagem de registo» com uma cadeia de caracteres. O receptor despacha com base no byte de comando para saber como interpretar o resto do pacote.

  • Comprimento. Dois bytes que indicam o tamanho do payload em bytes (little-endian aqui), permitindo payloads até cerca de 64 KiB. O receptor lê exactamente este número de bytes assim que o CRC do cabeçalho tiver sido verificado.

  • CRC do cabeçalho. Uma soma de verificação de um byte sobre os campos HEADER, CMD e LEN. O receptor verifica-o antes de ler qualquer payload, pelo que um LEN corrompido é detectado após apenas alguns bytes (ver a secção CRC abaixo para perceber a importância disto).

  • Payload. Dados de aplicação específicos do comando, exactamente LEN bytes. O formato é determinado pelo byte de comando: um registo de campos de largura fixa compactado com struct, uma cadeia de caracteres, memória em bruto – o que ambos os lados acordarem para esse comando.

  • CRC dos dados. Um CRC de quatro bytes sobre os bytes do payload. O receptor recalcula-o a partir dos bytes que acabou de ler e descarta o pacote se não coincidir.

3.20.2. CRCs

A «soma de verificação» mais simples é a soma de todos os bytes, módulo 256 ou 65536. Detecta a maioria das inversões de um único bit, mas falha muitos erros de múltiplos bits e ignora a ordenação dos bytes.

Uma verificação de redundância cíclica (CRC) é a melhoria padrão. Trata a entrada como um longo número binário e divide-o (de forma especial) por um polinómio fixo; o resto da divisão é o CRC. Polinómios diferentes detectam classes de erros diferentes; os polinómios comuns de 8, 16 e 32 bits detectam cada rajada de erros mais curta que a sua largura, mais uma grande fracção de rajadas mais longas.

3.20.2.1. Porquê dois CRCs

O diagrama de pacote acima contém dois CRCs separados – um sobre o cabeçalho (HEADER, CMD, LEN) e outro sobre o payload. Isto é o que um enquadramento robusto realmente precisa, por causa de como um único CRC no final falha quando o próprio campo LEN fica corrompido em trânsito:

  • O receptor age sobre o LEN corrompido e lê esse número de bytes do fio – possivelmente muito mais do que o emissor pretendia.

  • Apenas o CRC no final acaba por informar o receptor que algo correu mal, e só depois de todos esses bytes terem sido consumidos.

  • Enquanto o analisador está preso à espera do número errado de bytes, os 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 isto:

  • O CRC do cabeçalho cobre HEADER, CMD e LEN. O receptor verifica-o antes de ler qualquer payload, pelo que um LEN corrompido é detectado após alguns bytes e o analisador ressincroniza imediatamente, afectando apenas o pacote defeituoso.

  • O CRC dos dados cobre o payload. Assim que o CRC do cabeçalho passar, o receptor sabe que pode confiar no LEN, lê exactamente esse número de bytes de payload e verifica-os contra o CRC dos dados.

Um dimensionamento comum – e o que esta página utiliza – é um byte para o CRC do cabeçalho (um CRC-8 é mais que suficiente para um cabeçalho de cinco bytes) e quatro bytes para o CRC dos dados (um CRC-32 cobre muitos kilobytes de payload com uma taxa de colisão extremamente baixa).

3.20.2.2. Auxiliares

O MicroPython inclui binascii.crc32() para o CRC de quatro bytes directamente. Para o CRC do cabeçalho de um byte, um pequeno auxiliar que utiliza o polinómio dos dispositivos 1-wire da Maxim (0x8C em forma reflectida) é suficientemente curto para escrever em linha:

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 numa 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 devolve None se qualquer verificação 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 de cada vez pelo UART, e um emissor que faz uma pausa a meio do pacote (ou uma linha com ruído que perde um byte) não pode ser simplesmente lido com read() para um buffer do tamanho certo. A secção seguinte executa a mesma lógica de descodificação byte a byte como uma máquina de estados.

3.20.3. Um receptor em máquina de estados

O receptor não pode simplesmente chamar uart.read(N) para um N fixo – não sabe quantos bytes terá o próximo pacote, e qualquer lixo na linha desfaz o alinhamento. A solução é uma pequena máquina de estados que consome os bytes um de cada vez e reage com base em onde está no pacote. O ciclo principal consulta any() para ver quantos bytes estão em buffer, esvazia-os numa única chamada read() e passa cada byte pela 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 um passo, ou recua para HUNT_FOR_HEADER após um pacote completo, um CRC do cabeçalho inválido ou um CRC dos 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 propriedade de segurança chave vem do CRC do cabeçalho: se o campo LEN estiver corrompido, o analisador detecta-o após a verificação do CRC do cabeçalho (alguns bytes), não depois de se comprometer a ler um número completamente errado de bytes de payload.

3.20.4. Para além do básico

O enquadramento acima é o mínimo que uma ligação série precisa para recuperar do ruído da linha: magia do cabeçalho, comprimento, comando e dois CRCs. Detecta corrupção e ressincroniza após bytes corrompidos, mas desiste dos pacotes danificados em vez de os fazer chegar, e deixa o emissor sem saber o que o receptor realmente recebeu.

Os protocolos série do mundo real adicionam funcionalidades sobre essa base. Nem toda a ligação embebida precisa de todas – escolha o que a aplicação realmente requer:

  • Números de sequência. Um pequeno contador que incrementa a cada envio. O receptor detecta lacunas (um pacote foi perdido), duplicados (o emissor retransmitiu mas o receptor já tinha aceite a primeira cópia), e – onde o canal pode reordenar – chegadas fora de ordem.

  • Confirmações. Um pacote ACK dedicado (ou bit piggyback numa resposta) que o receptor envia de volta para confirmar cada pacote. Sem ACKs, o emissor não tem forma de saber se os seus dados chegaram.

  • Confirmações negativas. Um NACK enviado quando o receptor detecta uma falha de CRC ou uma lacuna de sequência. O emissor retransmite imediatamente, em vez de esperar que um temporizador de ACK expire.

  • Retransmissão. O emissor mantém cada pacote não confirmado numa pequena fila e reencaminha-o após um temporizador (ou num NACK). Um limite de tentativas e algum recuo entre tentativas impede que uma ligação permanentemente quebrada entre em ciclo infinito.

  • Janelas deslizantes. Permitir vários pacotes em voo antes de exigir um ACK mantém o débito elevado em ligações onde o tempo de ida e volta é longo comparado com o tempo de envio por pacote. O custo é mais estado no lado do emissor – um slot por pacote em voo.

  • Controlo de fluxo. Um sinal do receptor a dizer ao emissor para abrandar ou pausar quando o seu buffer está a encher. As implementações variam – bytes XON / XOFF explícitos, concessões baseadas em crédito onde o receptor autoriza N pacotes adicionais de cada vez, ou as linhas de hardware RTS / CTS no próprio fio. Sem controlo de fluxo, um emissor rápido acaba por ultrapassar um receptor lento e os 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 a versão mais alta suportada por ambos no arranque, ou rejeitar pacotes de pares 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 múltiplos pacotes e remontadas no outro lado. Os metadados de fragmentação (índice do fragmento, contagem total, ou um indicador «mais fragmentos») ficam dentro do payload.

  • Heartbeats. Um pequeno pacote periódico que diz «ainda estou aqui». O outro lado nota quando os heartbeats param e reconecta (ou falha ruidosamente) em vez de ficar pendurado silenciosamente.

  • Canais. Um ID de canal ou fluxo no cabeçalho para que uma ligação física transporte vários fluxos lógicos – um canal de controlo, um canal de telemetria, um canal de registo – distinguidos apenas por esse campo.

  • Autenticação. Uma etiqueta curta calculada a partir do payload e de um valor secreto que apenas o emissor e receptor legítimos conhecem. O receptor calcula novamente a etiqueta a partir dos bytes recebidos e rejeita o pacote se os dois não coincidirem. Isto detecta tanto a adulteração (um atacante modificou os bytes) como – se um número de sequência ou timestamp fizer parte do que a etiqueta cobre – a repetição, onde um atacante regista um pacote real do fio e o reenvia mais tarde para fazer o receptor agir sobre ele duas vezes.

  • Encriptação. Embaralhar os bytes do payload com uma chave secreta partilhada para que qualquer pessoa que leia a linha sem essa chave veja apenas ruído. Geralmente combinada com a etiqueta de autenticação acima – sem ela, um atacante pode enviar lixo que por acaso passa o CRC e o receptor desperdiça ciclos a tentar desencriptar nonsense.

Um protocolo «bom» típico para equipamento industrial acaba com enquadramento, CRC duplo, números de sequência, ACK / NACK com retransmissão e heartbeats. Exemplos do mundo real que valem a pena consultar: MAVLink (telemetria de drones, com números de sequência, IDs de sistema / componente e assinaturas de pacotes opcionais), Modbus (PLCs industriais, com códigos de função e CRC), e NMEA 0183 (o protocolo ASCII que todos os receptores GPS de consumo falam – mensagens baseadas em linha com uma soma de verificação após um delimitador de asterisco).