3.20. Protocoles série, trame et CRC

L’UART en code déplaçait des octets entre deux extrémités. À lui seul, cela ne suffit pas pour construire une liaison fiable. Trois problèmes apparaissent dès qu’un véritable périphérique se trouve à l’autre bout du fil :

  • Où commence et où finit un message ? Les octets arrivent dans un flux sans délimiteur intégré. Si le récepteur manque le premier octet (mise sous tension après l’émetteur ; bref parasite électrique sur la ligne), chaque octet suivant est décalé d’un cran jusqu’à ce que le récepteur trouve un nouveau point de resynchronisation.

  • Quelle est la longueur de chaque message ? Une lecture de capteur de 32 octets et une réponse d’état de 4 octets sont identiques au niveau des octets. Le récepteur a besoin d’un moyen de savoir combien d’octets appartiennent au message courant.

  • Les octets sont-ils arrivés intacts ? Le bruit peut inverser des bits individuels. Sans vérification, le récepteur agit sans hésiter sur des données corrompues.

La réponse standard à ces trois problèmes consiste à encapsuler les données dans une trame de paquet : une séquence d’octets connue au début, un champ de longueur, la charge utile elle-même et une somme de contrôle à la fin.

3.20.1. Trame de paquet

Un format de trame typique :

Six champs représentés en séquence : un HEADER de deux octets étiqueté 0xAA 0x55, un champ CMD d'un octet sélectionnant la commande que transporte ce paquet, un champ LEN de deux octets donnant la taille de la charge utile, un champ HCRC d'un octet couvrant HEADER plus CMD plus LEN, un PAYLOAD de longueur variable de LEN octets dont le format dépend du CMD, et un champ DCRC de quatre octets couvrant la charge utile.

Un paquet structuré avec des CRC distincts pour l’en-tête et les données : en-tête (octets magiques), commande, longueur, CRC d’en-tête, charge utile, CRC de données.

Chaque champ remplit une fonction :

  • En-tête (octets magiques). Une séquence d’octets fixe et inhabituelle – souvent deux octets comme 0xAA 0x55 – que le récepteur recherche dans le flux entrant. Lorsqu’il trouve la séquence, il sait qu’un nouveau paquet commence et peut rejeter tout ce qui était parasite avant.

  • Commande. Un octet unique qui indique ce qu’est le paquet. Différentes valeurs de commande utilisent différents formats de charge utile – une commande peut signifier « régler l’angle du servo » avec deux octets de charge utile, une autre peut signifier « lire le capteur » sans charge utile, une autre encore « message de journal » avec une chaîne. Le récepteur s’aiguille en fonction de l’octet de commande pour savoir comment interpréter le reste du paquet.

  • Longueur. Deux octets donnant la taille de la charge utile en octets (petit-boutiste ici), autorisant des charges utiles allant jusqu’à environ 64 Kio. Le récepteur lit exactement ce nombre d’octets une fois le CRC d’en-tête vérifié.

  • CRC d’en-tête. Une somme de contrôle d’un octet portant sur les champs HEADER, CMD et LEN. Le récepteur la vérifie avant de lire toute charge utile, de sorte qu’un LEN corrompu est détecté après seulement quelques octets (voir la section CRC ci-dessous pour comprendre pourquoi cela compte).

  • Charge utile. Données applicatives spécifiques à la commande, longues d’exactement LEN octets. Le format est déterminé par l’octet de commande : un enregistrement de champs de largeur fixe empaqueté avec struct, une chaîne, de la mémoire brute – ce sur quoi les deux côtés se sont mis d’accord pour cette commande.

  • CRC de données. Un CRC de quatre octets portant sur les octets de la charge utile. Le récepteur le recalcule à partir des octets qu’il vient de lire et rejette le paquet s’il ne correspond pas.

3.20.2. CRC

La « somme de contrôle » la plus simple est la somme de tous les octets, modulo 256 ou 65536. Elle détecte la plupart des inversions d’un seul bit mais manque beaucoup d’erreurs multi-bits et ignore l’ordre des octets.

Un contrôle de redondance cyclique (CRC) en est l’amélioration standard. Il traite l’entrée comme un seul long nombre binaire et le divise (d’une manière particulière) par un polynôme fixe ; le reste de la division est le CRC. Différents polynômes détectent différentes catégories d’erreurs ; les polynômes courants de 8, 16 et 32 bits détectent chacun toute rafale d’erreurs plus courte que leur largeur, plus une large fraction des rafales plus longues.

3.20.2.1. Pourquoi deux CRC

Le schéma de paquet ci-dessus transporte deux CRC distincts – un sur l’en-tête (HEADER, CMD, LEN) et un sur la charge utile. C’est ce dont une trame robuste a réellement besoin, en raison de la façon dont un unique CRC final échoue lorsque le champ LEN lui-même est corrompu en transit :

  • Le récepteur agit sur le LEN corrompu et lit ce nombre d’octets sur le fil – possiblement bien plus que ce que l’émetteur prévoyait.

  • Seul le CRC final finit par indiquer au récepteur que quelque chose s’est mal passé, et seulement après que tous ces octets ont été consommés.

  • Pendant que l’analyseur reste bloqué à attendre le mauvais nombre d’octets, les vrais paquets arrivant derrière celui qui est corrompu sont avalés comme charge utile, et le récepteur perd plusieurs paquets au lieu d’un seul.

Séparer le CRC corrige cela :

  • Le CRC d’en-tête couvre HEADER, CMD et LEN. Le récepteur le vérifie avant de lire toute charge utile, de sorte qu’un LEN corrompu est détecté après une poignée d’octets et que l’analyseur se resynchronise immédiatement, ne perdant que ce seul mauvais paquet.

  • Le CRC de données couvre la charge utile. Une fois le CRC d’en-tête validé, le récepteur sait qu’il peut faire confiance à LEN, lit exactement ce nombre d’octets de charge utile et les vérifie par rapport au CRC de données.

Un dimensionnement courant – et ce qu’utilise cette page – est d’un octet pour le CRC d’en-tête (un CRC-8 est amplement suffisant pour un en-tête de cinq octets) et de quatre octets pour le CRC de données (un CRC-32 couvre plusieurs kilooctets de charge utile avec un taux de collision infinitésimal).

3.20.2.2. Fonctions utilitaires

MicroPython fournit directement binascii.crc32() pour le CRC de quatre octets. Pour le CRC d’en-tête d’un octet, une petite fonction utilitaire utilisant le polynôme qu’emploient les périphériques 1-wire de Maxim (0x8C sous forme réfléchie) est assez courte pour être écrite en ligne :

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 encodeur complet combine les deux CRC en une seule fonction :

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 fonction inverse récupère la commande et la charge utile à partir d’un paquet complet, ou renvoie None si l’une des vérifications de CRC échoue :

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 pratique, le récepteur ne reçoit pas un paquet entier d’un coup – les octets arrivent un par un sur l’UART, et un émetteur qui fait une pause en milieu de paquet (ou une ligne bruitée qui perd un octet) ne peut pas simplement être lu via read() dans un tampon de la bonne taille. La section suivante exécute la même logique de décodage octet par octet sous forme de machine à états.

3.20.3. Un récepteur à machine à états

Le récepteur ne peut pas se contenter d’appeler uart.read(N) pour un N fixe – il ne sait pas combien d’octets fera le prochain paquet, et tout parasite sur la ligne perturbe l’alignement. La solution est une petite machine à états qui consomme les octets un par un et réagit selon l’endroit où elle se trouve dans le paquet. La boucle principale interroge any() pour voir combien d’octets sont mis en tampon, les vide en un seul appel à read(), et fait passer chaque octet à travers la machine à états :

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

Chaque octet fait avancer la machine à états d’un pas, ou la fait revenir à HUNT_FOR_HEADER après un paquet complet, un mauvais CRC d’en-tête ou un mauvais CRC de données. Les parasites sur la ligne qui ne correspondent pas à l’en-tête sont silencieusement écartés ; le prochain en-tête valide resynchronise le récepteur. La propriété de sûreté essentielle vient du CRC d’en-tête : si le champ LEN est corrompu, l’analyseur le détecte après la vérification du CRC d’en-tête (une poignée d’octets), et non après s’être engagé à lire un nombre d’octets de charge utile complètement erroné.

3.20.4. Au-delà des fondamentaux

La trame ci-dessus constitue le minimum dont une liaison série a besoin pour se rétablir après du bruit sur la ligne : octets magiques d’en-tête, longueur, commande et deux CRC. Elle détecte la corruption et se resynchronise après des octets brouillés, mais elle abandonne les paquets endommagés au lieu de les faire passer, et elle laisse l’émetteur sans aucune idée de ce que le récepteur a réellement reçu.

Les protocoles série du monde réel ajoutent des fonctionnalités par-dessus ces fondamentaux. Toutes ne sont pas nécessaires à chaque liaison embarquée – choisissez ce dont l’application a réellement besoin :

  • Numéros de séquence. Un petit compteur qui s’incrémente à chaque envoi. Le récepteur détecte les manques (un paquet a été perdu), les doublons (l’émetteur a retransmis alors que le récepteur avait déjà accepté la première copie) et – lorsque le canal peut réordonner – les arrivées dans le désordre.

  • Accusés de réception. Un paquet ACK dédié (ou un bit greffé dans une réponse) que le récepteur renvoie pour confirmer chaque paquet. Sans ACK, l’émetteur n’a aucun moyen de savoir que ses données sont arrivées.

  • Accusés de réception négatifs. Un NACK envoyé lorsque le récepteur constate un échec de CRC ou un manque dans la séquence. L’émetteur retransmet immédiatement, au lieu d’attendre l’expiration d’un délai d’ACK.

  • Retransmission. L’émetteur conserve chaque paquet non acquitté dans une petite file et le renvoie après un délai (ou sur réception d’un NACK). Une limite de tentatives et un certain délai d’attente entre les tentatives empêchent une liaison définitivement rompue de boucler indéfiniment.

  • Fenêtres glissantes. Autoriser plusieurs paquets en vol avant d’exiger un ACK maintient le débit élevé sur les liaisons où l’aller-retour est long par rapport au temps d’envoi par paquet. Le coût est un état plus important côté émetteur – un emplacement par paquet en vol.

  • Contrôle de flux. Un signal du récepteur disant à l’émetteur de ralentir ou de faire une pause lorsque son tampon se remplit. Les implémentations varient – octets XON / XOFF explicites, octrois fondés sur des crédits où le récepteur autorise N paquets supplémentaires à la fois, ou les lignes matérielles RTS / CTS sur le fil lui-même. Sans contrôle de flux, un émetteur rapide finit par submerger un récepteur lent et des paquets sont perdus.

  • Version du protocole. Un champ de version au début du paquet permet au format d’évoluer. Chaque côté peut négocier au démarrage la version la plus élevée que tous deux prennent en charge, ou rejeter les paquets provenant de pairs incompatibles.

  • Fragmentation et réassemblage. Un LEN de deux octets plafonne le paquet à 64 Kio ; les messages plus grands sont découpés en plusieurs paquets et réassemblés de l’autre côté. Les métadonnées de fragmentation (index du fragment, nombre total, ou un indicateur « d’autres fragments suivent ») se trouvent à l’intérieur de la charge utile.

  • Battements de cœur. Un petit paquet périodique qui dit « je suis toujours là ». L’autre côté remarque quand les battements de cœur s’arrêtent et se reconnecte (ou échoue bruyamment) au lieu de rester silencieusement bloqué.

  • Canaux. Un identifiant de canal ou de flux dans l’en-tête, de sorte qu’une seule liaison physique transporte plusieurs flux logiques – un canal de contrôle, un canal de télémétrie, un canal de journalisation – distingués uniquement par ce champ.

  • Authentification. Une courte étiquette calculée à partir de la charge utile et d’une valeur secrète que seuls l’émetteur et le récepteur légitimes connaissent. Le récepteur recalcule l’étiquette à partir des octets reçus et rejette le paquet si les deux ne correspondent pas. Cela détecte à la fois l’altération (un attaquant a modifié les octets) et – si un numéro de séquence ou un horodatage fait partie de ce que couvre l’étiquette – la réinjection, où un attaquant enregistre un vrai paquet sur le fil et le renvoie plus tard pour faire agir le récepteur deux fois.

  • Chiffrement. Brouiller les octets de la charge utile avec une clé secrète partagée, de sorte que quiconque lit la ligne sans cette clé ne voit que du bruit. Généralement combiné avec l’étiquette d’authentification ci-dessus – sans elle, un attaquant peut injecter des données arbitraires qui passent par hasard le CRC, et le récepteur gaspille des cycles à tenter de déchiffrer des absurdités.

Un « bon » protocole typique pour le matériel industriel finit par comporter une trame, un double CRC, des numéros de séquence, des ACK / NACK avec retransmission, et des battements de cœur. Quelques exemples concrets qui valent le coup d’œil : MAVLink (télémétrie de drones, avec numéros de séquence, identifiants de système / composant et signatures de paquet optionnelles), Modbus (automates programmables industriels, avec codes de fonction et CRC), et NMEA 0183 (le protocole ASCII que parle tout récepteur GPS grand public – messages structurés par lignes avec une somme de contrôle après un délimiteur astérisque).