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:

Six fields drawn in sequence: a two-byte HEADER labelled 0xAA 0x55, a one-byte CMD field selecting which command this packet carries, a two-byte LEN field giving the payload size, a one-byte HCRC field covering HEADER plus CMD plus LEN, a variable-length PAYLOAD of LEN bytes whose format depends on the CMD, and a four-byte DCRC field covering the payload.

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).