3.20. Serielle Protokolle, Framing und CRCs

UART im Code hat Bytes zwischen zwei Endpunkten verschoben. Für sich genommen reicht das nicht aus, um eine zuverlässige Verbindung aufzubauen. Drei Probleme treten in dem Moment auf, in dem ein echtes Gerät am anderen Ende der Leitung sitzt:

  • Wo beginnt und endet eine Nachricht? Bytes treffen als Strom ohne eingebauten Trenner ein. Wenn der Empfänger das erste Byte verpasst (er wird nach dem Sender eingeschaltet; eine kurze elektrische Störung auf der Leitung), ist jedes Byte danach um eins verschoben, bis der Empfänger einen neuen Resynchronisationspunkt findet.

  • Wie lang ist jede Nachricht? Ein 32-Byte-Sensorwert und eine 4-Byte-Statusantwort sehen auf Byte-Ebene identisch aus. Der Empfänger braucht eine Möglichkeit zu erkennen, wie viele Bytes zur aktuellen Nachricht gehören.

  • Sind die Bytes unversehrt angekommen? Rauschen kann einzelne Bits umkippen. Ohne Prüfung handelt der Empfänger bereitwillig auf Basis beschädigter Daten.

Die Standardantwort auf alle drei Probleme besteht darin, die Daten in einen Paketrahmen (Frame) einzupacken: eine bekannte Byte-Folge am Anfang, ein Längenfeld, die Nutzlast selbst und eine Prüfsumme am Ende.

3.20.1. Paket-Framing

Ein typisches Framing-Format:

Sechs Felder in Reihenfolge gezeichnet: ein Zwei-Byte-HEADER mit der Bezeichnung 0xAA 0x55, ein Ein-Byte-CMD-Feld, das auswählt, welchen Befehl dieses Paket trägt, ein Zwei-Byte-LEN- Feld, das die Nutzlastgröße angibt, ein Ein-Byte-HCRC-Feld, das HEADER plus CMD plus LEN abdeckt, eine variabel lange Nutzlast (PAYLOAD) von LEN Bytes, deren Format vom CMD abhängt, und ein Vier-Byte-DCRC-Feld, das die Nutzlast abdeckt.

Ein gerahmtes Paket mit getrennten CRCs für Header und Daten: Header (Magic Bytes), Befehl, Länge, Header-CRC, Nutzlast, Daten-CRC.

Jedes Feld erfüllt eine Aufgabe:

  • Header (Magic Bytes). Eine feste, ungewöhnliche Byte-Folge – oft zwei Bytes wie 0xAA 0x55 –, nach der der Empfänger im eingehenden Strom sucht. Wenn er die Folge findet, weiß er, dass ein neues Paket beginnt, und kann jeglichen Müll verwerfen, der davor kam.

  • Befehl (Command). Ein einzelnes Byte, das angibt, was das Paket ist. Verschiedene Befehlswerte verwenden unterschiedliche Nutzlastformate – ein Befehl könnte „Servowinkel setzen“ mit zwei Nutzlast-Bytes bedeuten, ein anderer „Sensor lesen“ ohne Nutzlast, ein weiterer „Logmeldung“ mit einer Zeichenkette. Der Empfänger verteilt anhand des Befehlsbytes, um zu wissen, wie der Rest des Pakets zu interpretieren ist.

  • Länge (Length). Zwei Bytes, die die Größe der Nutzlast in Bytes angeben (hier little-endian), wodurch Nutzlasten von bis zu etwa 64 KiB möglich sind. Der Empfänger liest genau so viele Bytes, sobald die Header-CRC verifiziert wurde.

  • Header-CRC. Eine Ein-Byte-Prüfsumme über die Felder HEADER, CMD und LEN. Der Empfänger prüft sie, bevor er irgendeine Nutzlast liest, sodass eine beschädigte LEN bereits nach wenigen Bytes erkannt wird (siehe den CRC-Abschnitt weiter unten dazu, warum das wichtig ist).

  • Nutzlast (Payload). Befehlsspezifische Anwendungsdaten, genau LEN Bytes lang. Das Format wird durch das Befehlsbyte bestimmt: ein mit struct gepackter Datensatz aus Feldern fester Breite, eine Zeichenkette, Rohspeicher – was auch immer beide Seiten für diesen Befehl vereinbart haben.

  • Daten-CRC. Eine Vier-Byte-CRC über die Nutzlast-Bytes. Der Empfänger berechnet sie aus den gerade gelesenen Bytes neu und verwirft das Paket, wenn sie nicht übereinstimmt.

3.20.2. CRCs

Die einfachste „Prüfsumme“ ist die Summe aller Bytes, modulo 256 oder 65536. Sie erkennt die meisten Einzelbit-Umkippungen, verpasst aber viele Mehrbit-Fehler und ignoriert die Byte-Reihenfolge.

Eine zyklische Redundanzprüfung (CRC) ist die übliche Verbesserung. Sie behandelt die Eingabe als eine lange Binärzahl und teilt sie (auf besondere Weise) durch ein festes Polynom; der Rest der Division ist die CRC. Verschiedene Polynome erkennen verschiedene Fehlerklassen; die gängigen 8-, 16- und 32-Bit-Polynome erkennen jeweils jeden Fehlerstoß, der kürzer als ihre Breite ist, sowie einen großen Anteil längerer Stöße.

3.20.2.1. Warum zwei CRCs

Das Paketdiagramm oben trägt zwei getrennte CRCs – eine über den Header (HEADER, CMD, LEN) und eine über die Nutzlast. Genau das braucht ein robustes Framing tatsächlich, und zwar aufgrund der Art und Weise, wie eine einzelne nachgestellte CRC versagt, wenn das LEN-Feld selbst während der Übertragung beschädigt wird:

  • Der Empfänger handelt anhand der beschädigten LEN und liest so viele Bytes von der Leitung – möglicherweise weit mehr, als der Sender beabsichtigt hatte.

  • Erst die nachgestellte CRC teilt dem Empfänger schließlich mit, dass etwas schiefgelaufen ist, und das auch erst, nachdem all diese Bytes verbraucht wurden.

  • Während der Parser auf die falsche Anzahl von Bytes wartet und feststeckt, werden echte Pakete, die hinter dem beschädigten eintreffen, als Nutzlast verschluckt, und der Empfänger verliert mehrere Pakete statt nur das eine.

Das Aufteilen der CRC behebt dies:

  • Die Header-CRC deckt HEADER, CMD und LEN ab. Der Empfänger prüft sie, bevor er irgendeine Nutzlast liest, sodass eine beschädigte LEN nach wenigen Bytes erkannt wird und der Parser sich sofort resynchronisiert, wodurch nur das eine fehlerhafte Paket verloren geht.

  • Die Daten-CRC deckt die Nutzlast ab. Sobald die Header-CRC bestanden hat, weiß der Empfänger, dass er LEN vertrauen kann, liest genau so viele Nutzlast-Bytes und verifiziert sie gegen die Daten-CRC.

Eine gängige Dimensionierung – und das, was diese Seite verwendet – ist ein Byte für die Header-CRC (eine CRC-8 reicht für einen Fünf-Byte-Header völlig aus) und vier Bytes für die Daten-CRC (eine CRC-32 deckt viele Kilobyte Nutzlast mit einer verschwindend geringen Kollisionsrate ab).

3.20.2.2. Hilfsfunktionen

MicroPython liefert binascii.crc32() direkt für die Vier-Byte-CRC mit. Für die Ein-Byte-Header-CRC ist eine kleine Hilfsfunktion, die das Polynom der 1-Wire-Geräte von Maxim verwendet (0x8C in gespiegelter Form), kurz genug, um sie inline zu schreiben:

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

Ein vollständiger Encoder kombiniert die beiden CRCs in einer Funktion:

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)

Die Umkehrfunktion gewinnt den Befehl und die Nutzlast aus einem vollständigen Paket zurück oder gibt None zurück, wenn eine der beiden CRC-Prüfungen fehlschlägt:

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 der Praxis bekommt der Empfänger kein ganzes Paket überreicht – die Bytes treffen einzeln über die UART ein, und ein Sender, der mitten im Paket pausiert (oder eine verrauschte Leitung, die ein Byte verliert), kann nicht einfach mit read() in einen Puffer der richtigen Größe gelesen werden. Der nächste Abschnitt führt dieselbe Decodierlogik Byte für Byte als Zustandsautomat aus.

3.20.3. Ein zustandsbasierter Empfänger

Der Empfänger kann nicht einfach uart.read(N) für ein festes N aufrufen – er weiß nicht, wie viele Bytes das nächste Paket umfassen wird, und jeglicher Schrott auf der Leitung bringt die Ausrichtung durcheinander. Die Lösung ist ein kleiner Zustandsautomat, der die Bytes einzeln verbraucht und je nachdem reagiert, an welcher Stelle im Paket er sich befindet. Die Hauptschleife fragt any() ab, um zu sehen, wie viele Bytes gepuffert sind, leert sie in einem einzigen read()-Aufruf und führt jedes Byte durch den Zustandsautomaten:

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

Jedes Byte bringt den Zustandsautomaten um einen Schritt voran oder fällt nach einem vollständigen Paket, einer fehlerhaften Header-CRC oder einer fehlerhaften Daten-CRC auf HUNT_FOR_HEADER zurück. Schrott auf der Leitung, der nicht zum Header passt, wird stillschweigend verworfen; der nächste gültige Header resynchronisiert den Empfänger. Die zentrale Sicherheitseigenschaft ergibt sich aus der Header-CRC: Wenn das LEN-Feld beschädigt ist, erkennt der Parser dies nach der Header-CRC-Prüfung (wenige Bytes) und nicht erst, nachdem er sich darauf festgelegt hat, eine völlig falsche Anzahl von Nutzlast-Bytes zu lesen.

3.20.4. Über die Grundlage hinaus

Das obige Framing ist das Minimum, das eine serielle Verbindung benötigt, um sich von Leitungsrauschen zu erholen: Header-Magic, Länge, Befehl und zwei CRCs. Es erkennt Beschädigungen und resynchronisiert sich nach verstümmelten Bytes, aber es gibt beschädigte Pakete auf, anstatt sie durchzubringen, und es lässt den Sender im Unklaren darüber, was der Empfänger tatsächlich gehört hat.

Reale serielle Protokolle schichten Funktionen über diese Grundlage. Nicht jede eingebettete Verbindung braucht sie alle – wähle aus, was die Anwendung tatsächlich erfordert:

  • Sequenznummern. Ein kleiner Zähler, der bei jedem Senden erhöht wird. Der Empfänger erkennt Lücken (ein Paket ging verloren), Duplikate (der Sender hat erneut übertragen, aber der Empfänger hatte die erste Kopie bereits angenommen) und – wo der Kanal umordnen kann – Ankünfte in falscher Reihenfolge.

  • Bestätigungen (Acknowledgements). Ein dediziertes ACK-Paket (oder ein Huckepack-Bit in einer Antwort), das der Empfänger zurücksendet, um jedes Paket zu bestätigen. Ohne ACKs hat der Sender keine Möglichkeit zu wissen, ob seine Daten angekommen sind.

  • Negative Bestätigungen. Ein NACK, das gesendet wird, wenn der Empfänger einen CRC-Fehler oder eine Sequenzlücke feststellt. Der Sender überträgt sofort erneut, anstatt auf das Auslösen eines ACK-Timeouts zu warten.

  • Erneute Übertragung (Retransmission). Der Sender behält jedes unbestätigte Paket in einer kleinen Warteschlange und sendet es nach einem Timeout (oder bei einem NACK) erneut. Eine Begrenzung der Wiederholungsversuche und ein gewisses Backoff zwischen den Versuchen verhindern, dass eine dauerhaft kaputte Verbindung endlos in einer Schleife läuft.

  • Gleitende Fenster (Sliding Windows). Mehrere Pakete gleichzeitig in der Übertragung zuzulassen, bevor ein ACK erforderlich ist, hält den Durchsatz hoch auf Verbindungen, bei denen die Umlaufzeit lang im Vergleich zur Sendezeit pro Paket ist. Der Preis dafür ist mehr senderseitiger Zustand – ein Slot pro in Übertragung befindlichem Paket.

  • Flusssteuerung (Flow Control). Ein Signal vom Empfänger, das den Sender auffordert, langsamer zu werden oder zu pausieren, wenn sich sein Puffer füllt. Die Implementierungen variieren – explizite XON-/XOFF-Bytes, kreditbasierte Freigaben, bei denen der Empfänger jeweils N weitere Pakete genehmigt, oder die RTS-/CTS-Hardwareleitungen auf der Leitung selbst. Ohne Flusssteuerung überrennt ein schneller Sender irgendwann einen langsamen Empfänger und Pakete gehen verloren.

  • Protokollversion. Ein Versionsfeld früh im Paket erlaubt es dem Format, sich weiterzuentwickeln. Jede Seite kann beim Start die höchste von beiden unterstützte Version aushandeln oder Pakete von inkompatiblen Gegenstellen ablehnen.

  • Fragmentierung und Reassemblierung. Eine Zwei-Byte-LEN begrenzt das Paket auf 64 KiB; Nachrichten, die größer sind, werden in mehrere Pakete aufgeteilt und auf der anderen Seite wieder zusammengesetzt. Die Fragmentierungs-Metadaten (Fragmentindex, Gesamtanzahl oder ein „more fragments“-Flag) befinden sich innerhalb der Nutzlast.

  • Heartbeats. Ein kleines periodisches Paket, das sagt „Ich bin noch da“. Die andere Seite bemerkt, wenn die Heartbeats aufhören, und stellt die Verbindung wieder her (oder schlägt lautstark fehl), anstatt stillschweigend hängen zu bleiben.

  • Kanäle (Channels). Eine Kanal- oder Stream-ID im Header, sodass eine physische Verbindung mehrere logische Streams trägt – einen Steuerkanal, einen Telemetriekanal, einen Logkanal –, die nur durch dieses Feld unterschieden werden.

  • Authentifizierung. Ein kurzes Tag, das aus der Nutzlast und einem geheimen Wert berechnet wird, den nur der legitime Sender und Empfänger kennen. Der Empfänger berechnet das Tag erneut aus den empfangenen Bytes und lehnt das Paket ab, wenn die beiden nicht übereinstimmen. Dies erkennt sowohl Manipulationen (ein Angreifer hat die Bytes verändert) als auch – wenn eine Sequenznummer oder ein Zeitstempel Teil dessen ist, was das Tag abdeckt – Replay-Angriffe, bei denen ein Angreifer ein echtes Paket von der Leitung aufzeichnet und es später erneut sendet, um den Empfänger dazu zu bringen, zweimal darauf zu reagieren.

  • Verschlüsselung. Das Verwürfeln der Nutzlast-Bytes mit einem gemeinsamen geheimen Schlüssel, sodass jeder, der die Leitung ohne diesen Schlüssel mitliest, nur Rauschen sieht. Üblicherweise kombiniert mit dem obigen Authentifizierungs-Tag – ohne dieses kann ein Angreifer Müll einspeisen, der zufällig die CRC besteht, und der Empfänger verschwendet Rechenzeit beim Versuch, Unsinn zu entschlüsseln.

Ein typisches „gutes“ Protokoll für industrielle Geräte endet mit Framing, dualer CRC, Sequenznummern, ACK / NACK mit erneuter Übertragung und Heartbeats. Reale Beispiele, die einen Blick wert sind: MAVLink (Drohnentelemetrie, mit Sequenznummern, System-/Komponenten-IDs und optionalen Paketsignaturen), Modbus (industrielle SPSen, mit Funktionscodes und CRC) und NMEA 0183 (das ASCII-Protokoll, das jeder Consumer-GPS-Empfänger spricht – zeilenbasierte Nachrichten mit einer Prüfsumme nach einem Stern-Trennzeichen).