3.20. Protocolos serie, tramado y CRCs

UART en código movió bytes entre dos extremos. Por sí solo, eso no basta para construir un enlace fiable. Tres problemas aparecen en cuanto hay un dispositivo real al otro lado del cable:

  • ¿Dónde empieza y termina un mensaje? Los bytes llegan en un flujo sin ningún delimitador incorporado. Si el receptor pierde el primer byte (porque se encendió después del emisor, o por un breve fallo eléctrico en la línea), todos los bytes posteriores quedan desfasados en uno hasta que el receptor encuentra un nuevo punto de resincronización.

  • ¿Cuánto mide cada mensaje? Una lectura de sensor de 32 bytes y una respuesta de estado de 4 bytes son idénticas a nivel de byte. El receptor necesita una forma de saber cuántos bytes pertenecen al mensaje actual.

  • ¿Llegaron los bytes intactos? El ruido puede invertir bits individuales. Sin una comprobación, el receptor actúa tranquilamente sobre datos corruptos.

La respuesta estándar a los tres problemas es envolver los datos en una trama de paquete: una secuencia de bytes conocida al principio, un campo de longitud, la propia carga útil y una suma de comprobación al final.

3.20.1. Tramado de paquetes

Un formato de tramado típico:

Seis campos dibujados en secuencia: una HEADER de dos bytes etiquetada 0xAA 0x55, un campo CMD de un byte que selecciona qué comando transporta este paquete, un campo LEN de dos bytes que indica el tamaño de la carga útil, un campo HCRC de un byte que cubre HEADER más CMD más LEN, una PAYLOAD de longitud variable de LEN bytes cuyo formato depende del CMD, y un campo DCRC de cuatro bytes que cubre la carga útil.

Un paquete tramado con CRCs separados para la cabecera y los datos: cabecera (bytes mágicos), comando, longitud, CRC de cabecera, carga útil, CRC de datos.

Cada campo cumple una función:

  • Cabecera (bytes mágicos). Una secuencia de bytes fija e inusual – a menudo dos bytes como 0xAA 0x55 – que el receptor busca en el flujo entrante. Cuando encuentra la secuencia, sabe que comienza un nuevo paquete y puede descartar cualquier basura que viniera antes.

  • Comando. Un único byte que indica qué es el paquete. Distintos valores de comando usan distintos formatos de carga útil – un comando podría significar «fijar ángulo del servo» con dos bytes de carga útil, otro podría significar «leer sensor» sin carga útil, otro podría ser «mensaje de registro» con una cadena. El receptor despacha según el byte de comando para saber cómo interpretar el resto del paquete.

  • Longitud. Dos bytes que dan el tamaño de la carga útil en bytes (little-endian aquí), permitiendo cargas útiles de hasta unos 64 KiB. El receptor lee exactamente esta cantidad de bytes una vez verificado el CRC de cabecera.

  • CRC de cabecera. Una suma de comprobación de un byte sobre los campos HEADER, CMD y LEN. El receptor la comprueba antes de leer cualquier carga útil, de modo que un LEN corrupto se detecta tras apenas un puñado de bytes (véase la sección de CRC más abajo para entender por qué esto importa).

  • Carga útil. Datos de aplicación específicos del comando, de exactamente LEN bytes de longitud. El formato lo determina el byte de comando: un registro empaquetado con struct de campos de ancho fijo, una cadena, memoria en bruto – lo que ambos lados acuerden para ese comando.

  • CRC de datos. Un CRC de cuatro bytes sobre los bytes de la carga útil. El receptor lo recalcula a partir de los bytes que acaba de leer y descarta el paquete si no coincide.

3.20.2. CRCs

La «suma de comprobación» más simple es la suma de todos los bytes, módulo 256 o 65536. Detecta la mayoría de las inversiones de un solo bit, pero se le escapan muchos errores de varios bits e ignora el orden de los bytes.

Una comprobación de redundancia cíclica (CRC) es la mejora estándar. Trata la entrada como un único número binario largo y lo divide (de una forma especial) por un polinomio fijo; el resto de la división es el CRC. Distintos polinomios detectan distintas clases de errores; los polinomios comunes de 8, 16 y 32 bits detectan cada uno toda ráfaga de errores más corta que su anchura, más una gran proporción de las ráfagas más largas.

3.20.2.1. Por qué dos CRCs

El diagrama de paquete de arriba lleva dos CRCs separados – uno sobre la cabecera (HEADER, CMD, LEN) y otro sobre la carga útil. Esto es lo que realmente necesita un tramado robusto, por la forma en que un único CRC final falla cuando el propio campo LEN se corrompe durante el tránsito:

  • El receptor actúa sobre el LEN corrupto y lee esa cantidad de bytes del cable – posiblemente muchos más de los que el emisor pretendía.

  • Solo el CRC final acaba por avisar al receptor de que algo salió mal, y solo después de que todos esos bytes hayan sido consumidos.

  • Mientras el analizador está bloqueado esperando el número equivocado de bytes, los paquetes reales que llegan detrás del corrupto son engullidos como carga útil, y el receptor pierde varios paquetes en lugar de solo uno.

Dividir el CRC soluciona esto:

  • El CRC de cabecera cubre HEADER, CMD y LEN. El receptor lo comprueba antes de leer cualquier carga útil, de modo que un LEN corrupto se detecta tras un puñado de bytes y el analizador se resincroniza de inmediato, derribando solo el único paquete defectuoso.

  • El CRC de datos cubre la carga útil. Una vez que el CRC de cabecera ha pasado, el receptor sabe que puede confiar en LEN, lee exactamente esa cantidad de bytes de carga útil y los verifica contra el CRC de datos.

Un dimensionamiento común – y el que usa esta página – es un byte para el CRC de cabecera (un CRC-8 sobra para una cabecera de cinco bytes) y cuatro bytes para el CRC de datos (un CRC-32 cubre muchos kilobytes de carga útil con una tasa de colisión ínfima).

3.20.2.2. Funciones auxiliares

MicroPython incluye binascii.crc32() para el CRC de cuatro bytes directamente. Para el CRC de cabecera de un byte, una pequeña función auxiliar que usa el polinomio que emplean los dispositivos 1-wire de Maxim (0x8C en forma reflejada) es lo bastante corta como para escribirla en línea:

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

Un codificador completo combina los dos CRCs en una sola función:

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)

La función inversa recupera el comando y la carga útil de un paquete completo, o devuelve None si falla cualquiera de las comprobaciones de CRC:

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)

En la práctica, al receptor no se le entrega un paquete entero – los bytes llegan de uno en uno por el UART, y un emisor que se pausa a mitad de paquete (o una línea ruidosa que pierde un byte) no puede simplemente ser leído con read() en un búfer del tamaño correcto. La siguiente sección ejecuta la misma lógica de decodificación byte a byte como una máquina de estados.

3.20.3. Un receptor basado en máquina de estados

El receptor no puede limitarse a llamar a uart.read(N) con algún N fijo – no sabe cuántos bytes tendrá el próximo paquete, y cualquier basura en la línea desbarata la alineación. La solución es una pequeña máquina de estados que consume los bytes de uno en uno y reacciona según en qué punto del paquete se encuentre. El bucle principal sondea any() para ver cuántos bytes hay en el búfer, los vacía en una sola llamada a read() y pasa cada byte por la 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 avanza la máquina de estados un paso, o vuelve a HUNT_FOR_HEADER tras un paquete completo, un CRC de cabecera erróneo o un CRC de datos erróneo. La basura en la línea que no coincide con la cabecera se descarta silenciosamente; la siguiente cabecera válida resincroniza el receptor. La propiedad de seguridad clave proviene del CRC de cabecera: si el campo LEN está corrupto, el analizador lo detecta tras la comprobación del CRC de cabecera (un puñado de bytes), no después de comprometerse a leer un número descabelladamente erróneo de bytes de carga útil.

3.20.4. Más allá de lo básico

El tramado anterior es el mínimo que un enlace serie necesita para recuperarse del ruido de línea: bytes mágicos de cabecera, longitud, comando y dos CRCs. Detecta la corrupción y se resincroniza tras bytes alterados, pero descarta los paquetes dañados en lugar de hacerlos pasar, y deja al emisor sin idea de lo que el receptor realmente escuchó.

Los protocolos serie del mundo real superponen funciones sobre esa base. No todo enlace embebido las necesita todas – elige lo que la aplicación realmente requiera:

  • Números de secuencia. Un pequeño contador que se incrementa en cada envío. El receptor detecta huecos (se perdió un paquete), duplicados (el emisor retransmitió pero el receptor ya había aceptado la primera copia) y – cuando el canal puede reordenar – llegadas fuera de orden.

  • Confirmaciones (ACK). Un paquete ACK dedicado (o un bit a cuestas en una respuesta) que el receptor devuelve para confirmar cada paquete. Sin ACKs el emisor no tiene forma de saber si sus datos llegaron.

  • Confirmaciones negativas (NACK). Un NACK enviado cuando el receptor ve un fallo de CRC o un hueco de secuencia. El emisor retransmite de inmediato, en lugar de esperar a que salte un tiempo de espera de ACK.

  • Retransmisión. El emisor mantiene cada paquete no confirmado en una pequeña cola y lo reenvía tras un tiempo de espera (o ante un NACK). Un límite de reintentos y cierta espera incremental entre reintentos evita que un enlace permanentemente roto entre en un bucle infinito.

  • Ventanas deslizantes. Permitir varios paquetes en vuelo antes de exigir un ACK mantiene alto el rendimiento en enlaces donde el tiempo de ida y vuelta es largo comparado con el tiempo de envío por paquete. El coste es más estado en el lado del emisor – una ranura por cada paquete en vuelo.

  • Control de flujo. Una señal del receptor que indica al emisor que reduzca la velocidad o se detenga cuando su búfer se está llenando. Las implementaciones varían – bytes XON / XOFF explícitos, concesiones basadas en créditos en las que el receptor autoriza N paquetes más cada vez, o las líneas hardware RTS / CTS del propio cable. Sin control de flujo, un emisor rápido acaba desbordando a un receptor lento y se descartan paquetes.

  • Versión de protocolo. Un campo de versión al principio del paquete permite que el formato evolucione. Cada lado puede negociar al arrancar la versión más alta que ambos admiten, o rechazar paquetes de pares incompatibles.

  • Fragmentación y reensamblado. Un LEN de dos bytes limita el paquete a 64 KiB; los mensajes mayores que eso se dividen en varios paquetes y se reensamblan en el otro lado. Los metadatos de fragmentación (índice de fragmento, recuento total o un indicador de «más fragmentos») residen dentro de la carga útil.

  • Latidos (heartbeats). Un pequeño paquete periódico que dice «sigo aquí». El otro lado se da cuenta cuando los latidos se detienen y se reconecta (o falla de forma ruidosa) en lugar de quedarse colgado en silencio.

  • Canales. Un ID de canal o de flujo en la cabecera, de modo que un único enlace físico transporta varios flujos lógicos – un canal de control, un canal de telemetría, un canal de registro – distinguidos únicamente por ese campo.

  • Autenticación. Una etiqueta corta calculada a partir de la carga útil y un valor secreto que solo el emisor y el receptor legítimos conocen. El receptor vuelve a calcular la etiqueta a partir de los bytes que recibió y rechaza el paquete si ambas no coinciden. Esto detecta tanto la manipulación (un atacante modificó los bytes) como – si un número de secuencia o una marca de tiempo forman parte de lo que cubre la etiqueta – la repetición, en la que un atacante graba un paquete real del cable y lo reenvía más tarde para que el receptor actúe sobre él dos veces.

  • Cifrado. Codificar los bytes de la carga útil con una clave secreta compartida, de modo que cualquiera que lea la línea sin esa clave solo vea ruido. Suele combinarse con la etiqueta de autenticación anterior – sin ella, un atacante puede inyectar basura que casualmente pase el CRC y el receptor desperdicia ciclos intentando descifrar disparates.

Un protocolo «bueno» típico para equipamiento industrial acaba teniendo tramado, doble CRC, números de secuencia, ACK / NACK con retransmisión y latidos. Ejemplos del mundo real que vale la pena mirar: MAVLink (telemetría de drones, con números de secuencia, IDs de sistema / componente y firmas de paquete opcionales), Modbus (PLCs industriales, con códigos de función y CRC) y NMEA 0183 (el protocolo ASCII que habla todo receptor GPS de consumo – mensajes por líneas con una suma de comprobación tras un delimitador de asterisco).