3.20. Sarjaprotokollat, kehystys ja CRC-tarkistukset

UART koodissa siirsi tavuja kahden pään välillä. Yksinään se ei kuitenkaan riitä luotettavan yhteyden rakentamiseen. Heti kun johdon toisessa päässä on todellinen laite, ilmenee kolme ongelmaa:

  • Mistä viesti alkaa ja mihin se päättyy? Tavut saapuvat virtana ilman valmista erotinta. Jos vastaanottaja menettää ensimmäisen tavun (käynnistettiin lähettäjän jälkeen; lyhyt sähköinen häiriö linjalla), jokainen sen jälkeinen tavu on yhden verran väärässä paikassa, kunnes vastaanottaja löytää tuoreen uudelleentahdistuspisteen.

  • Kuinka pitkä kukin viesti on? 32-tavuinen sensorilukema ja 4-tavuinen tilavastaus näyttävät tavutasolla identtisiltä. Vastaanottaja tarvitsee keinon tietää, kuinka monta tavua kuuluu nykyiseen viestiin.

  • Saapuivatko tavut ehjinä? Kohina voi kääntää yksittäisiä bittejä. Ilman tarkistusta vastaanottaja toimii surutta vioittuneen datan perusteella.

Vakioratkaisu kaikkiin kolmeen on kääriä data pakettikehykseen: tunnettu tavusekvenssi alussa, pituuskenttä, varsinainen hyötykuorma ja tarkistussumma lopussa.

3.20.1. Pakettikehystys

Tyypillinen kehystysmuoto:

Kuusi kenttää peräkkäin piirrettynä: kaksitavuinen HEADER merkitty 0xAA 0x55, yksitavuinen CMD-kenttä joka valitsee minkä komennon tämä paketti kantaa, kaksitavuinen LEN- kenttä joka ilmoittaa hyötykuorman koon, yksitavuinen HCRC-kenttä joka kattaa HEADERin, CMD:n ja LENin, vaihtelevanmittainen LEN tavun PAYLOAD jonka muoto riippuu CMD:stä, ja nelitavuinen DCRC-kenttä joka kattaa hyötykuorman.

Kehystetty paketti, jolla on erilliset otsikon ja datan CRC-tarkistukset: otsikko (taikatavut), komento, pituus, otsikon CRC, hyötykuorma, datan CRC.

Jokainen kenttä tekee yhden tehtävän:

  • Otsikko (taikatavut). Kiinteä, epätavallinen tavusekvenssi – usein kaksi tavua kuten 0xAA 0x55 – jota vastaanottaja etsii saapuvasta virrasta. Kun se löytää sekvenssin, se tietää uuden paketin alkavan ja voi heittää pois kaiken sitä edeltäneen roskan.

  • Komento. Yksittäinen tavu, joka kertoo, mikä paketti on. Eri komentoarvot käyttävät eri hyötykuormamuotoja – yksi komento voisi tarkoittaa ”aseta servon kulma” kahdella hyötykuormatavulla, toinen ”lue sensori” ilman hyötykuormaa, kolmas ”loki-viesti” merkkijonolla. Vastaanottaja ohjaa toiminnan komentotavun perusteella tietääkseen, miten paketin loppuosa tulkitaan.

  • Pituus. Kaksi tavua, jotka ilmoittavat hyötykuorman koon tavuina (tässä little-endian), mikä sallii jopa noin 64 KiB:n hyötykuormat. Vastaanottaja lukee täsmälleen tämän verran tavuja, kun otsikon CRC on varmennettu.

  • Otsikon CRC. Yksitavuinen tarkistussumma HEADER-, CMD- ja LEN-kentistä. Vastaanottaja tarkistaa sen ennen minkään hyötykuorman lukemista, joten vioittunut LEN havaitaan jo muutaman tavun jälkeen (katso alta CRC-osio, miksi tämä on tärkeää).

  • Hyötykuorma. Komentokohtainen sovellusdata, täsmälleen LEN tavua pitkä. Muodon määrää komentotavu: struct-pakattu kiinteäleveyksisten kenttien tietue, merkkijono, raaka muisti – mitä tahansa, mistä molemmat osapuolet ovat sopineet kyseiselle komennolle.

  • Datan CRC. Nelitavuinen CRC hyötykuorman tavuista. Vastaanottaja laskee sen uudelleen juuri lukemistaan tavuista ja hylkää paketin, jos se ei täsmää.

3.20.2. CRC-tarkistukset

Yksinkertaisin ”tarkistussumma” on kaikkien tavujen summa modulo 256 tai 65536. Se havaitsee useimmat yksittäiset bittikääntymät, mutta jättää monet usean bitin virheet huomaamatta eikä huomioi tavujärjestystä.

Syklinen redundanssitarkistus (CRC) on vakioparannus. Se käsittelee syötettä yhtenä pitkänä binäärilukuna ja jakaa sen (erityisellä tavalla) kiinteällä polynomilla; jakojäännös on CRC. Eri polynomit havaitsevat eri virheluokkia; yleiset 8-, 16- ja 32-bittiset polynomit kukin havaitsevat jokaisen virhepurskeen, joka on lyhyempi kuin niiden leveys, sekä suuren osan pidemmistä purskeista.

3.20.2.1. Miksi kaksi CRC-tarkistusta

Yllä oleva pakettidiagrammi kantaa kaksi erillistä CRC-tarkistusta – yhden otsikosta (HEADER, CMD, LEN) ja yhden hyötykuormasta. Tätä vankka kehystys todella tarvitsee, koska yksittäinen perässä oleva CRC epäonnistuu silloin, kun itse LEN-kenttä vioittuu siirron aikana:

  • Vastaanottaja toimii vioittuneen LENin perusteella ja lukee linjalta sen verran tavuja – mahdollisesti paljon enemmän kuin lähettäjä tarkoitti.

  • Vain perässä oleva CRC lopulta kertoo vastaanottajalle, että jokin meni pieleen, ja vasta sen jälkeen kun kaikki nuo tavut on kulutettu.

  • Sillä aikaa kun jäsennin on jumissa odottamassa väärää määrää tavuja, vioittuneen paketin perässä saapuvat oikeat paketit nielaistaan hyötykuormaksi, ja vastaanottaja menettää useita paketteja vain yhden sijaan.

CRC:n jakaminen korjaa tämän:

  • Otsikon CRC kattaa HEADERin, CMD:n ja LENin. Vastaanottaja tarkistaa sen ennen minkään hyötykuorman lukemista, joten vioittunut LEN havaitaan muutaman tavun jälkeen ja jäsennin tahdistuu uudelleen välittömästi, kaataen vain sen yhden viallisen paketin.

  • Datan CRC kattaa hyötykuorman. Kun otsikon CRC on läpäissyt, vastaanottaja tietää voivansa luottaa LENiin, lukee täsmälleen sen verran hyötykuormatavuja ja varmentaa ne datan CRC:tä vasten.

Yleinen mitoitus – ja se, mitä tällä sivulla käytetään – on yksi tavu otsikon CRC:lle (CRC-8 riittää viiden tavun otsikolle) ja neljä tavua datan CRC:lle (CRC-32 kattaa monta kilotavua hyötykuormaa häviävän pienellä törmäysasteella).

3.20.2.2. Apufunktiot

MicroPython tarjoaa binascii.crc32() nelitavuiselle CRC:lle suoraan. Yksitavuiselle otsikon CRC:lle pieni apufunktio, joka käyttää Maximin 1-wire-laitteiden polynomia (0x8C peilatussa muodossa), on tarpeeksi lyhyt kirjoitettavaksi suoraan koodiin:

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

Täydellinen koodain yhdistää kaksi CRC:tä yhteen funktioon:

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)

Käänteisfunktio palauttaa komennon ja hyötykuorman täydellisestä paketista tai palauttaa None, jos jompikumpi CRC-tarkistus epäonnistuu:

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)

Käytännössä vastaanottaja ei saa kokonaista pakettia ojennettuna – tavut saapuvat yksi kerrallaan UARTin yli, eikä lähettäjää, joka pysähtyy kesken paketin (tai kohinaista linjaa, joka menettää tavun), voi vain lukea read()-kutsulla oikeankokoiseen puskuriin. Seuraava osio ajaa saman dekoodauslogiikan tavu kerrallaan tilakoneena.

3.20.3. Tilakoneeseen perustuva vastaanottaja

Vastaanottaja ei voi vain kutsua uart.read(N) jollekin kiinteälle N:lle – se ei tiedä, kuinka monta tavua seuraava paketti on, ja mikä tahansa roska linjalla sotkee tahdistuksen. Ratkaisu on pieni tilakone, joka kuluttaa tavut yksi kerrallaan ja reagoi sen mukaan, missä kohtaa pakettia se on. Pääsilmukka kysyy any()-metodilla, kuinka monta tavua on puskuroituna, valuttaa ne yhdellä read()-kutsulla ja syöttää jokaisen tavun tilakoneen läpi:

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

Jokainen tavu vie tilakonetta yhden askeleen eteenpäin tai palaa tilaan HUNT_FOR_HEADER täydellisen paketin, virheellisen otsikon CRC:n tai virheellisen datan CRC:n jälkeen. Linjalla oleva roska, joka ei täsmää otsikkoon, hylätään hiljaisesti; seuraava kelvollinen otsikko tahdistaa vastaanottajan uudelleen. Keskeinen turvallisuusominaisuus tulee otsikon CRC:stä: jos LEN-kenttä on vioittunut, jäsennin havaitsee sen otsikon CRC-tarkistuksen jälkeen (muutaman tavun jälkeen) eikä vasta sitouduttuaan lukemaan rajusti väärää määrää hyötykuormatavuja.

3.20.4. Perustasoa pidemmälle

Yllä oleva kehystys on vähimmäismäärä, jonka sarjayhteys tarvitsee toipuakseen linjakohinasta: otsikon taikatavut, pituus, komento ja kaksi CRC:tä. Se havaitsee vioittumisen ja tahdistuu uudelleen sotkettujen tavujen jälkeen, mutta se luopuu vaurioituneista paketeista sen sijaan, että saisi ne perille, ja jättää lähettäjän vaille mitään tietoa siitä, mitä vastaanottaja todella kuuli.

Todellisen maailman sarjaprotokollat kerrostavat ominaisuuksia tuon perustason päälle. Jokainen sulautettu yhteys ei tarvitse niitä kaikkia – valitse se, mitä sovellus todella vaatii:

  • Järjestysnumerot. Pieni laskuri, joka kasvaa jokaisella lähetyksellä. Vastaanottaja havaitsee aukot (paketti hävisi), kaksoiskappaleet (lähettäjä lähetti uudelleen, mutta vastaanottaja oli jo hyväksynyt ensimmäisen kopion) ja – silloin kun kanava voi vaihtaa järjestystä – väärässä järjestyksessä saapumiset.

  • Kuittaukset. Erillinen ACK-paketti (tai mukana kulkeva bitti vastauksessa), jonka vastaanottaja lähettää takaisin vahvistaakseen jokaisen paketin. Ilman kuittauksia lähettäjällä ei ole mitään keinoa tietää, että sen data saapui perille.

  • Kielteiset kuittaukset. NACK lähetetään, kun vastaanottaja näkee CRC-virheen tai järjestysaukon. Lähettäjä lähettää uudelleen välittömästi sen sijaan, että odottaisi ACK-aikakatkaisun laukeamista.

  • Uudelleenlähetys. Lähettäjä pitää jokaisen kuittaamattoman paketin pienessä jonossa ja lähettää sen uudelleen aikakatkaisun jälkeen (tai NACKin yhteydessä). Uudelleenyritysraja ja jonkin verran taukoa yritysten välillä estää pysyvästi rikkinäistä yhteyttä jäämästä ikuiseen silmukkaan.

  • Liukuvat ikkunat. Useamman paketin salliminen samanaikaisesti ennen kuittauksen vaatimista pitää läpäisykyvyn korkealla yhteyksissä, joissa edestakainen viive on pitkä verrattuna pakettikohtaiseen lähetysaikaan. Hintana on enemmän lähettäjäpuolen tilaa – yksi paikka kutakin matkalla olevaa pakettia kohden.

  • Vuonohjaus. Vastaanottajan signaali, joka käskee lähettäjää hidastamaan tai keskeyttämään, kun sen puskuri täyttyy. Toteutukset vaihtelevat – eksplisiittiset XON / XOFF -tavut, luottoon perustuvat myöntämiset, joissa vastaanottaja sallii N pakettia lisää kerrallaan, tai itse johdon RTS / CTS -laitteistolinjat. Ilman vuonohjausta nopea lähettäjä ylittää lopulta hitaan vastaanottajan kapasiteetin ja paketteja menetetään.

  • Protokollaversio. Versiokenttä paketin alkupäässä antaa muodon kehittyä. Kumpikin osapuoli voi neuvotella käynnistyksessä korkeimman version, jota molemmat tukevat, tai hylätä paketit yhteensopimattomilta vertaisilta.

  • Pirstominen ja uudelleenkokoaminen. Kaksitavuinen LEN rajoittaa paketin 64 KiB:iin; sitä suuremmat viestit jaetaan useaksi paketiksi ja kootaan uudelleen toisessa päässä. Pirstomisen metatieto (palasen indeksi, kokonaismäärä tai ”lisää palasia” -lippu) sijaitsee hyötykuorman sisällä.

  • Sydämenlyönnit. Pieni säännöllinen paketti, joka sanoo ”olen yhä täällä”. Toinen osapuoli huomaa, kun sydämenlyönnit lakkaavat, ja muodostaa yhteyden uudelleen (tai epäonnistuu äänekkäästi) sen sijaan, että jäisi hiljaa roikkumaan.

  • Kanavat. Kanava- tai virta-ID otsikossa, jotta yksi fyysinen yhteys kantaa useita loogisia virtoja – ohjauskanavan, telemetriakanavan, lokikanavan – jotka erotetaan vain tuon kentän perusteella.

  • Todennus. Lyhyt tunniste, joka lasketaan hyötykuormasta ja salaisesta arvosta, jonka vain laillinen lähettäjä ja vastaanottaja tuntevat. Vastaanottaja laskee tunnisteen uudelleen vastaanottamistaan tavuista ja hylkää paketin, jos nämä kaksi eivät täsmää. Tämä havaitsee sekä peukaloinnin (hyökkääjä muutti tavuja) että – jos järjestysnumero tai aikaleima on osa tunnisteen kattamaa aluetta – toiston, jossa hyökkääjä tallentaa todellisen paketin linjalta ja lähettää sen myöhemmin uudelleen saadakseen vastaanottajan toimimaan sen perusteella kahdesti.

  • Salaus. Hyötykuorman tavujen sekoittaminen jaetulla salaisella avaimella siten, että kuka tahansa linjaa ilman tuota avainta lukeva näkee vain kohinaa. Yleensä yhdistetään yllä olevaan todennustunnisteeseen – ilman sitä hyökkääjä voi syöttää roskaa, joka sattuu läpäisemään CRC:n, ja vastaanottaja tuhlaa syklejä yrittäessään purkaa hölynpölyä.

Tyypillinen ”hyvä” protokolla teollisuuslaitteille päätyy sisältämään kehystyksen, kaksoiscrc:n, järjestysnumerot, ACK / NACK -kuittaukset uudelleenlähetyksellä sekä sydämenlyönnit. Tarkastelun arvoisia todellisen maailman esimerkkejä: MAVLink (lennokkitelemetria, jossa on järjestysnumerot, järjestelmä- / komponentti-ID:t ja valinnaiset pakettiallekirjoitukset), Modbus (teollisuus-PLC:t, jossa on funktiokoodit ja CRC) sekä NMEA 0183 (ASCII-protokolla, jota jokainen kuluttaja-GPS-vastaanotin puhuu – rivipohjaiset viestit, joissa on tarkistussumma tähtierottimen jälkeen).