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:
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).