3.20. Seriële protocollen, framing en CRC’s

UART in code verplaatste bytes tussen twee uiteinden. Op zichzelf is dat niet genoeg om een betrouwbare verbinding op te bouwen. Drie problemen duiken op zodra er een echt apparaat aan het andere uiteinde van de draad zit:

  • Waar begint en eindigt een bericht? Bytes komen binnen in een stroom zonder ingebouwd scheidingsteken. Als de ontvanger de eerste byte mist (ingeschakeld na de zender; een korte elektrische storing op de lijn), staat elke byte daarna één positie verschoven totdat de ontvanger een nieuw resynchronisatiepunt vindt.

  • Hoe lang is elk bericht? Een sensormeting van 32 byte en een statusantwoord van 4 byte zien er op byteniveau identiek uit. De ontvanger heeft een manier nodig om te weten hoeveel bytes bij het huidige bericht horen.

  • Zijn de bytes intact aangekomen? Ruis kan afzonderlijke bits omklappen. Zonder controle handelt de ontvanger zonder problemen op basis van beschadigde gegevens.

Het standaardantwoord op alle drie is om de gegevens in een pakketframe te verpakken: een bekende bytereeks aan het begin, een lengteveld, de payload zelf, en een controlesom aan het eind.

3.20.1. Pakketframing

Een typisch framingformaat:

Zes velden in volgorde getekend: een HEADER van twee bytes gelabeld 0xAA 0x55, een CMD-veld van één byte dat selecteert welk commando dit pakket draagt, een LEN-veld van twee bytes dat de payloadgrootte aangeeft, een HCRC-veld van één byte dat HEADER plus CMD plus LEN dekt, een PAYLOAD van variabele lengte van LEN bytes waarvan het formaat afhangt van de CMD, en een DCRC-veld van vier bytes dat de payload dekt.

Een geframed pakket met aparte header- en data-CRC’s: header (magische bytes), commando, lengte, header-CRC, payload, data-CRC.

Elk veld doet één taak:

  • Header (magische bytes). Een vaste, ongebruikelijke bytereeks – vaak twee bytes zoals 0xAA 0x55 – waar de ontvanger in de binnenkomende stroom naar zoekt. Wanneer het de reeks vindt, weet het dat er een nieuw pakket begint en kan het alle rommel die ervoor kwam weggooien.

  • Commando. Eén byte die aangeeft wat het pakket is. Verschillende commandowaarden gebruiken verschillende payloadformaten – één commando kan “stel servohoek in” betekenen met twee payloadbytes, een ander kan “lees sensor” betekenen zonder payload, weer een ander kan “logbericht” zijn met een string. De ontvanger handelt op basis van de commandobyte af hoe de rest van het pakket moet worden geïnterpreteerd.

  • Lengte. Twee bytes die de grootte van de payload in bytes aangeven (hier little-endian), waarmee payloads tot ongeveer 64 KiB mogelijk zijn. De ontvanger leest precies dit aantal bytes zodra de header-CRC is geverifieerd.

  • Header-CRC. Een controlesom van één byte over de velden HEADER, CMD en LEN. De ontvanger controleert het voordat er een payload wordt gelezen, zodat een beschadigde LEN al na een handvol bytes wordt opgemerkt (zie het CRC-gedeelte hieronder voor waarom dit belangrijk is).

  • Payload. Commandospecifieke toepassingsgegevens, exact LEN bytes lang. Het formaat wordt bepaald door de commandobyte: een met struct ingepakte record van velden met vaste breedte, een string, ruw geheugen – wat beide kanten ook overeenkomen voor dat commando.

  • Data-CRC. Een CRC van vier bytes over de payloadbytes. De ontvanger herberekent het uit de bytes die het zojuist heeft gelezen en verwerpt het pakket als het niet overeenkomt.

3.20.2. CRC’s

De eenvoudigste “controlesom” is de som van alle bytes, modulo 256 of 65536. Het vangt de meeste enkele-bit-omklappingen op, maar mist veel multi-bit-fouten en negeert de bytevolgorde.

Een cyclic redundancy check (CRC) is de standaardupgrade. Het behandelt de invoer als één lang binair getal en deelt het (op een speciale manier) door een vaste polynoom; de rest van de deling is de CRC. Verschillende polynomen vangen verschillende klassen fouten op; de gangbare 8-, 16- en 32-bits polynomen vangen elk elke foutenburst op die korter is dan hun breedte, plus een groot deel van langere bursts.

3.20.2.1. Waarom twee CRC’s

Het pakketdiagram hierboven draagt twee aparte CRC’s – één over de header (HEADER, CMD, LEN) en één over de payload. Dit is wat robuuste framing daadwerkelijk nodig heeft, vanwege hoe een enkele afsluitende CRC faalt wanneer het LEN-veld zelf onderweg beschadigd raakt:

  • De ontvanger handelt op basis van de beschadigde LEN en leest dat aantal bytes van de draad – mogelijk veel meer dan de zender bedoelde.

  • Pas de afsluitende CRC vertelt de ontvanger uiteindelijk dat er iets mis is gegaan, en pas nadat al die bytes zijn verbruikt.

  • Terwijl de parser vastzit te wachten op het verkeerde aantal bytes, worden echte pakketten die achter het beschadigde pakket binnenkomen als payload opgeslokt, en verliest de ontvanger meerdere pakketten in plaats van slechts dat ene.

Het splitsen van de CRC lost dit op:

  • De header-CRC dekt HEADER, CMD en LEN. De ontvanger controleert het voordat er een payload wordt gelezen, zodat een beschadigde LEN na een handvol bytes wordt opgemerkt en de parser onmiddellijk resynchroniseert, waarbij alleen dat ene slechte pakket verloren gaat.

  • De data-CRC dekt de payload. Zodra de header-CRC is geslaagd, weet de ontvanger dat hij LEN kan vertrouwen, leest precies dat aantal payloadbytes, en verifieert ze tegen de data-CRC.

Een gangbare maatvoering – en wat deze pagina gebruikt – is één byte voor de header-CRC (een CRC-8 is ruim voldoende voor een header van vijf bytes) en vier bytes voor de data-CRC (een CRC-32 dekt vele kilobytes payload met een verwaarloosbaar lage botsingskans).

3.20.2.2. Hulpfuncties

MicroPython levert binascii.crc32() voor de CRC van vier bytes direct. Voor de header-CRC van één byte is een kleine hulpfunctie die de polynoom gebruikt die de 1-wire-apparaten van Maxim gebruiken (0x8C in gespiegelde vorm) kort genoeg om inline te schrijven:

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

Een complete encoder combineert de twee CRC’s in één functie:

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)

De inverse functie herstelt het commando en de payload uit een compleet pakket, of retourneert None als een van beide CRC-controles faalt:

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)

In de praktijk krijgt de ontvanger niet een heel pakket overhandigd – bytes komen één voor één binnen via de UART, en een zender die midden in een pakket pauzeert (of een ruizige lijn die een byte verliest) kan niet zomaar via read() in een buffer van de juiste grootte worden gelezen. In het volgende gedeelte wordt dezelfde decodeerlogica byte voor byte als een toestandsmachine uitgevoerd.

3.20.3. Een toestandsmachine-ontvanger

De ontvanger kan niet zomaar uart.read(N) aanroepen voor een vaste N – hij weet niet hoeveel bytes het volgende pakket zal zijn, en elke rommel op de lijn verstoort de uitlijning. De oplossing is een kleine toestandsmachine die de bytes één voor één verbruikt en reageert op basis van waar het zich in het pakket bevindt. De hoofdlus pollt any() om te zien hoeveel bytes er gebufferd zijn, leegt ze in één read()-aanroep, en voert elke byte door de toestandsmachine:

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

Elke byte brengt de toestandsmachine één stap verder, of valt terug naar HUNT_FOR_HEADER na een compleet pakket, een slechte header-CRC, of een slechte data-CRC. Rommel op de lijn die niet overeenkomt met de header wordt stilletjes weggegooid; de volgende geldige header resynchroniseert de ontvanger. De belangrijkste veiligheidseigenschap komt van de header-CRC: als het LEN-veld beschadigd is, vangt de parser dit op na de header-CRC-controle (een handvol bytes), niet nadat hij zich heeft vastgelegd op het lezen van een volkomen verkeerd aantal payloadbytes.

3.20.4. Voorbij de basis

De bovenstaande framing is het minimum dat een seriële verbinding nodig heeft om te herstellen van lijnruis: header-magie, lengte, commando en twee CRC’s. Het detecteert beschadiging en resynchroniseert na verminkte bytes, maar het geeft beschadigde pakketten op in plaats van ze door te krijgen, en het laat de zender geen idee van wat de ontvanger daadwerkelijk heeft gehoord.

Seriële protocollen uit de praktijk stapelen functies bovenop die basis. Niet elke embedded verbinding heeft ze allemaal nodig – kies wat de toepassing daadwerkelijk vereist:

  • Volgnummers. Een kleine teller die bij elke verzending oploopt. De ontvanger detecteert gaten (een pakket ging verloren), duplicaten (de zender heeft opnieuw verzonden maar de ontvanger had de eerste kopie al geaccepteerd), en – waar het kanaal kan herordenen – aankomsten in verkeerde volgorde.

  • Bevestigingen. Een speciaal ACK-pakket (of een meeliftende bit in een antwoord) dat de ontvanger terugstuurt om elk pakket te bevestigen. Zonder ACK’s heeft de zender geen manier om te weten of zijn gegevens zijn aangekomen.

  • Negatieve bevestigingen. Een NACK die wordt verzonden wanneer de ontvanger een CRC-fout of een volgnummergat ziet. De zender verzendt onmiddellijk opnieuw, in plaats van te wachten tot een ACK-time-out afgaat.

  • Hertransmissie. De zender bewaart elk niet-bevestigd pakket in een kleine wachtrij en verzendt het opnieuw na een time-out (of bij een NACK). Een herhaallimiet en wat backoff tussen pogingen voorkomt dat een permanent kapotte verbinding eindeloos blijft herhalen.

  • Schuivende vensters. Door meerdere pakketten onderweg toe te staan voordat een ACK vereist is, blijft de doorvoer hoog op verbindingen waar de retourtijd lang is vergeleken met de verzendtijd per pakket. De kosten zijn meer toestand aan de zenderkant – één slot per pakket dat onderweg is.

  • Stroomregeling. Een signaal van de ontvanger dat de zender vertelt om af te remmen of te pauzeren wanneer zijn buffer volraakt. Implementaties verschillen – expliciete XON/XOFF-bytes, kredietgebaseerde toekenningen waarbij de ontvanger N pakketten tegelijk toestaat, of de RTS/CTS-hardwarelijnen op de draad zelf. Zonder stroomregeling overspoelt een snelle zender uiteindelijk een trage ontvanger en gaan pakketten verloren.

  • Protocolversie. Een versieveld vroeg in het pakket laat het formaat evolueren. Elke kant kan bij het opstarten de hoogste versie onderhandelen die beide ondersteunen, of pakketten van incompatibele peers afwijzen.

  • Fragmentatie en herassemblage. Een LEN van twee bytes begrenst het pakket op 64 KiB; berichten die groter zijn dan dat worden opgesplitst in meerdere pakketten en aan de andere kant weer samengevoegd. De fragmentatiemetagegevens (fragmentindex, totaalaantal, of een “meer fragmenten”-vlag) leven binnen de payload.

  • Hartslagen. Een klein periodiek pakket dat zegt “ik ben er nog”. De andere kant merkt op wanneer de hartslagen stoppen en maakt opnieuw verbinding (of faalt luidruchtig) in plaats van stilletjes te blijven hangen.

  • Kanalen. Een kanaal- of stream-ID in de header zodat één fysieke verbinding meerdere logische streams draagt – een controlekanaal, een telemetriekanaal, een logkanaal – alleen onderscheiden door dat veld.

  • Authenticatie. Een korte tag berekend uit de payload en een geheime waarde die alleen de legitieme zender en ontvanger kennen. De ontvanger berekent de tag opnieuw uit de bytes die hij heeft ontvangen en wijst het pakket af als de twee niet overeenkomen. Dit vangt zowel manipulatie op (een aanvaller heeft de bytes gewijzigd) als – als een volgnummer of tijdstempel deel uitmaakt van wat de tag dekt – replay, waarbij een aanvaller een echt pakket van de draad opneemt en het later opnieuw verzendt om de ontvanger er twee keer op te laten reageren.

  • Versleuteling. Het door elkaar husselen van de payloadbytes met een gedeelde geheime sleutel zodat iedereen die de lijn leest zonder die sleutel alleen ruis ziet. Meestal gecombineerd met de bovenstaande authenticatietag – zonder die tag kan een aanvaller rommel invoeren die toevallig de CRC passeert en verspilt de ontvanger cycli aan het proberen onzin te ontsleutelen.

Een typisch “goed” protocol voor industriële apparatuur eindigt met framing, dubbele CRC, volgnummers, ACK/NACK met hertransmissie, en hartslagen. Praktijkvoorbeelden die een blik waard zijn: MAVLink (drone-telemetrie, met volgnummers, systeem-/component-ID’s, en optionele pakkethandtekeningen), Modbus (industriële PLC’s, met functiecodes en CRC), en NMEA 0183 (het ASCII-protocol dat elke consumenten-GPS-ontvanger spreekt – regelgebaseerde berichten met een controlesom na een sterscheidingsteken).