3.20. Serijski protokoli, uokvirivanje i CRC-ovi

UART u kodu premjestio je bajtove između dvaju krajeva. Sam po sebi, to nije dovoljno za izgradnju pouzdane veze. Tri se problema pojavljuju u trenutku kada se na drugom kraju žice nalazi stvarni uređaj:

  • Gdje poruka počinje i završava? Bajtovi stižu u toku bez ugrađenog razdjelnika. Ako prijemnik propusti prvi bajt (uključen nakon pošiljatelja; kratak električni poremećaj na liniji), svaki bajt nakon njega pomaknut je za jedan sve dok prijemnik ne pronađe novu točku ponovne sinkronizacije.

  • Koliko je duga svaka poruka? Očitanje senzora od 32 bajta i statusni odgovor od 4 bajta izgledaju jednako na razini bajtova. Prijemniku je potreban način da zna koliko bajtova pripada trenutnoj poruci.

  • Jesu li bajtovi stigli neoštećeni? Šum može preokrenuti pojedinačne bitove. Bez provjere, prijemnik spremno djeluje na temelju oštećenih podataka.

Standardni odgovor na sva tri problema jest umotati podatke u okvir paketa: poznati niz bajtova na početku, polje duljine, sam korisni sadržaj te kontrolni zbroj na kraju.

3.20.1. Uokvirivanje paketa

Tipičan format uokvirivanja:

Šest polja prikazanih u nizu: dvobajtni HEADER označen kao 0xAA 0x55, jednobajtno polje CMD koje odabire koju naredbu ovaj paket prenosi, dvobajtno polje LEN koje daje veličinu korisnog sadržaja, jednobajtno polje HCRC koje pokriva HEADER plus CMD plus LEN, korisni sadržaj PAYLOAD promjenjive duljine od LEN bajtova čiji format ovisi o CMD, te četverobajtno polje DCRC koje pokriva korisni sadržaj.

Uokvireni paket s odvojenim CRC-ovima zaglavlja i podataka: zaglavlje (magični bajtovi), naredba, duljina, CRC zaglavlja, korisni sadržaj, CRC podataka.

Svako polje obavlja jedan posao:

  • Zaglavlje (magični bajtovi). Fiksan, neuobičajen niz bajtova – često dva bajta poput 0xAA 0x55 – koji prijemnik traži u dolaznom toku. Kada pronađe taj niz, zna da počinje novi paket i može odbaciti sve smeće koje je stiglo prije.

  • Naredba. Pojedinačni bajt koji govori što paket jest. Različite vrijednosti naredbe koriste različite formate korisnog sadržaja – jedna naredba može značiti „postavi kut servomotora” s dva bajta korisnog sadržaja, druga može značiti „očitaj senzor” bez korisnog sadržaja, a treća može biti „zabilježi poruku” s nizom znakova. Prijemnik se na temelju bajta naredbe odlučuje kako interpretirati ostatak paketa.

  • Duljina. Dva bajta koja daju veličinu korisnog sadržaja u bajtovima (ovdje little-endian), dopuštajući korisni sadržaj do otprilike 64 KiB. Prijemnik čita točno toliko bajtova nakon što je CRC zaglavlja provjeren.

  • CRC zaglavlja. Jednobajtni kontrolni zbroj nad poljima HEADER, CMD i LEN. Prijemnik ga provjerava prije čitanja bilo kakvog korisnog sadržaja, tako da se oštećeni LEN uhvati već nakon nekolicine bajtova (pogledajte odjeljak o CRC-u u nastavku za razlog zašto je to važno).

  • Korisni sadržaj. Aplikacijski podaci specifični za naredbu, dugi točno LEN bajtova. Format je određen bajtom naredbe: zapis spakiran pomoću struct od polja fiksne širine, niz znakova, sirova memorija – što god obje strane dogovore za tu naredbu.

  • CRC podataka. Četverobajtni CRC nad bajtovima korisnog sadržaja. Prijemnik ga ponovno izračunava iz bajtova koje je upravo pročitao i odbacuje paket ako se ne podudara.

3.20.2. CRC-ovi

Najjednostavniji „kontrolni zbroj” jest zbroj svih bajtova, modulo 256 ili 65536. Hvata većinu jednobitnih preokreta, ali propušta mnoge višebitne pogreške i zanemaruje redoslijed bajtova.

Ciklička provjera redundancije (CRC) standardna je nadogradnja. Ona tretira ulaz kao jedan dugačak binarni broj i dijeli ga (na poseban način) fiksnim polinomom; ostatak dijeljenja je CRC. Različiti polinomi hvataju različite klase pogrešaka; uobičajeni 8-, 16- i 32-bitni polinomi svaki hvataju svaki niz pogrešaka kraći od njihove širine plus veliki dio dužih nizova.

3.20.2.1. Zašto dva CRC-a

Dijagram paketa iznad nosi dva odvojena CRC-a – jedan nad zaglavljem (HEADER, CMD, LEN) i jedan nad korisnim sadržajem. Upravo to robusno uokvirivanje zapravo treba, zbog načina na koji jedan jedini završni CRC zakaže kada se samo polje LEN ošteti tijekom prijenosa:

  • Prijemnik djeluje na temelju oštećenog LEN-a i čita toliko bajtova s žice – možda daleko više nego što je pošiljatelj namjeravao.

  • Tek završni CRC naposljetku javlja prijemniku da je nešto pošlo po zlu, i to tek nakon što su svi ti bajtovi potrošeni.

  • Dok parser čeka pogrešan broj bajtova, stvarni paketi koji stižu iza oštećenog bivaju progutani kao korisni sadržaj, pa prijemnik gubi nekoliko paketa umjesto samo jednoga.

Razdvajanje CRC-a rješava ovo:

  • CRC zaglavlja pokriva HEADER, CMD i LEN. Prijemnik ga provjerava prije čitanja bilo kakvog korisnog sadržaja, tako da se oštećeni LEN uhvati nakon nekolicine bajtova, a parser se odmah ponovno sinkronizira, obarajući samo taj jedan loš paket.

  • CRC podataka pokriva korisni sadržaj. Nakon što CRC zaglavlja prođe, prijemnik zna da može vjerovati LEN-u, čita točno toliko bajtova korisnog sadržaja i provjerava ih prema CRC-u podataka.

Uobičajeno dimenzioniranje – i ono koje ova stranica koristi – jest jedan bajt za CRC zaglavlja (CRC-8 je sasvim dovoljan za petobajtno zaglavlje) i četiri bajta za CRC podataka (CRC-32 pokriva mnogo kilobajta korisnog sadržaja s iščezavajuće niskom stopom kolizije).

3.20.2.2. Pomoćne funkcije

MicroPython isporučuje binascii.crc32() izravno za četverobajtni CRC. Za jednobajtni CRC zaglavlja, mala pomoćna funkcija koja koristi polinom kakav koriste Maximovi 1-wire uređaji (0x8C u reflektiranom obliku) dovoljno je kratka da se napiše ugrađeno:

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

Potpuni koder kombinira dva CRC-a u jednoj funkciji:

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)

Inverzna funkcija oporavlja naredbu i korisni sadržaj iz potpunog paketa, ili vraća None ako bilo koja CRC provjera ne uspije:

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)

U praksi prijemnik ne dobiva cijeli paket predan u ruke – bajtovi stižu jedan po jedan preko UART-a, a pošiljatelj koji zastane usred paketa (ili bučna linija koja izgubi bajt) ne može se jednostavno read()-ati u međuspremnik prave veličine. Sljedeći odjeljak provodi istu logiku dekodiranja bajt po bajt kao stroj stanja.

3.20.3. Prijemnik kao stroj stanja

Prijemnik ne može jednostavno pozvati uart.read(N) za neki fiksni N – ne zna koliko će bajtova imati sljedeći paket, a bilo kakvo smeće na liniji izbacuje poravnanje iz takta. Rješenje je mali stroj stanja koji troši bajtove jedan po jedan i reagira ovisno o tome gdje se nalazi u paketu. Glavna petlja ispituje any() kako bi vidjela koliko je bajtova u međuspremniku, isprazni ih u jednom pozivu read() te svaki bajt provodi kroz stroj stanja:

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

Svaki bajt pomiče stroj stanja za jedan korak, ili se vraća na HUNT_FOR_HEADER nakon potpunog paketa, lošeg CRC-a zaglavlja ili lošeg CRC-a podataka. Smeće na liniji koje se ne podudara sa zaglavljem tiho se odbacuje; sljedeće valjano zaglavlje ponovno sinkronizira prijemnik. Ključno sigurnosno svojstvo dolazi od CRC-a zaglavlja: ako je polje LEN oštećeno, parser to uhvati nakon provjere CRC-a zaglavlja (nekolicina bajtova), a ne nakon što se obveže pročitati posve pogrešan broj bajtova korisnog sadržaja.

3.20.4. Iznad osnovne razine

Gore opisano uokvirivanje minimum je koji serijskoj vezi treba za oporavak od šuma na liniji: magični bajtovi zaglavlja, duljina, naredba i dva CRC-a. Otkriva oštećenja i ponovno se sinkronizira nakon izobličenih bajtova, ali odustaje od oštećenih paketa umjesto da ih progura, a pošiljatelja ostavlja bez ikakve predodžbe o tome što je prijemnik zapravo čuo.

Serijski protokoli iz stvarnog svijeta slažu značajke povrh te osnovne razine. Ne treba svakoj ugrađenoj vezi sve od njih – odaberite ono što aplikacija zapravo zahtijeva:

  • Redni brojevi. Mali brojač koji se uvećava pri svakom slanju. Prijemnik otkriva praznine (paket je izgubljen), duplikate (pošiljatelj je ponovno prenio, ali je prijemnik već prihvatio prvu kopiju) te – gdje kanal može preurediti – dolaske izvan redoslijeda.

  • Potvrde. Namjenski ACK paket (ili pridruženi bit u odgovoru) koji prijemnik šalje natrag kako bi potvrdio svaki paket. Bez ACK-ova pošiljatelj nema načina znati da su njegovi podaci stigli.

  • Negativne potvrde. NACK koji se šalje kada prijemnik vidi neuspjeh CRC-a ili prazninu u rednim brojevima. Pošiljatelj odmah ponovno prenosi, umjesto da čeka istek vremenskog ograničenja ACK-a.

  • Ponovni prijenos. Pošiljatelj zadržava svaki nepotvrđeni paket u maloj redu i ponovno ga šalje nakon isteka vremenskog ograničenja (ili na NACK). Ograničenje broja pokušaja i određeno odgađanje između pokušaja sprječavaju trajno pokvarenu vezu da se vrti zauvijek.

  • Klizni prozori. Dopuštanje nekoliko paketa u letu prije nego se zatraži ACK održava propusnost visokom na vezama gdje je vrijeme povratnog puta dugo u usporedbi s vremenom slanja po paketu. Cijena je više stanja na strani pošiljatelja – jedno mjesto po paketu u letu.

  • Kontrola toka. Signal od prijemnika koji govori pošiljatelju da uspori ili zastane kada se njegov međuspremnik puni. Implementacije variraju – eksplicitni XON / XOFF bajtovi, dodjele temeljene na kreditima gdje prijemnik odjednom licencira N dodatnih paketa, ili hardverske RTS / CTS linije na samoj žici. Bez kontrole toka brz pošiljatelj naposljetku preplavi spor prijemnik i paketi se odbacuju.

  • Verzija protokola. Polje verzije rano u paketu omogućuje formatu da se razvija. Svaka strana može pri pokretanju ispregovarati najvišu verziju koju obje podržavaju, ili odbiti pakete od nekompatibilnih sudionika.

  • Fragmentacija i ponovno sastavljanje. Dvobajtni LEN ograničava paket na 64 KiB; poruke veće od toga dijele se na više paketa i ponovno sastavljaju na drugoj strani. Metapodaci o fragmentaciji (indeks fragmenta, ukupni broj ili oznaka „još fragmenata”) žive unutar korisnog sadržaja.

  • Otkucaji srca. Mali periodični paket koji govori „još sam tu”. Druga strana primijeti kada otkucaji prestanu i ponovno se poveže (ili glasno zakaže) umjesto da tiho visi.

  • Kanali. Identifikator kanala ili toka u zaglavlju kako bi jedna fizička veza nosila nekoliko logičkih tokova – upravljački kanal, telemetrijski kanal, kanal zapisa – razlikovanih samo tim poljem.

  • Autentikacija. Kratka oznaka izračunata iz korisnog sadržaja i tajne vrijednosti koju znaju samo legitimni pošiljatelj i prijemnik. Prijemnik ponovno izračunava oznaku iz bajtova koje je primio i odbacuje paket ako se dvije ne podudaraju. Ovo hvata i neovlašteno mijenjanje (napadač je izmijenio bajtove) i – ako su redni broj ili vremenska oznaka dio onoga što oznaka pokriva – ponavljanje, gdje napadač snimi stvarni paket s žice i ponovno ga pošalje kasnije kako bi naveo prijemnik da na temelju njega djeluje dvaput.

  • Šifriranje. Razmrsivanje bajtova korisnog sadržaja zajedničkim tajnim ključem tako da svatko tko čita liniju bez tog ključa vidi samo šum. Obično se kombinira s gore opisanom oznakom autentikacije – bez nje, napadač može dovesti smeće koje slučajno prođe CRC, pa prijemnik troši cikluse pokušavajući dešifrirati besmislice.

Tipičan „dobar” protokol za industrijsku opremu naposljetku dolazi s uokvirivanjem, dvostrukim CRC-om, rednim brojevima, ACK / NACK s ponovnim prijenosom te otkucajima srca. Primjeri iz stvarnog svijeta vrijedni pogleda: MAVLink (telemetrija dronova, s rednim brojevima, ID-ovima sustava / komponenti i opcionalnim potpisima paketa), Modbus (industrijski PLC-ovi, s funkcijskim kodovima i CRC-om) te NMEA 0183 (ASCII protokol koji govori svaki potrošački GPS prijemnik – poruke temeljene na linijama s kontrolnim zbrojem nakon razdjelnika zvjezdice).