3.20. Sériové protokoly, rámcování a kontrolní součty CRC¶
UART v kódu přesouvalo bajty mezi dvěma konci. Samo o sobě to ale nestačí k vytvoření spolehlivého spojení. Jakmile se na druhém konci vodiče objeví reálné zařízení, vyvstanou tři problémy:
Kde zpráva začíná a kde končí? Bajty přicházejí v proudu bez vestavěného oddělovače. Pokud přijímač zmešká první bajt (zapne se až po odesílateli; krátký elektrický zákmit na lince), je každý následující bajt posunutý o jedničku, dokud přijímač nenajde nový bod resynchronizace.
Jak dlouhá je každá zpráva? 32bajtové čtení ze senzoru a 4bajtová stavová odpověď vypadají na úrovni bajtů identicky. Přijímač potřebuje způsob, jak zjistit, kolik bajtů patří k aktuální zprávě.
Dorazily bajty neporušené? Šum může převrátit jednotlivé bity. Bez kontroly přijímač vesele zpracuje poškozená data.
Standardní odpovědí na všechny tři problémy je zabalit data do rámce paketu: známá posloupnost bajtů na začátku, pole délky, samotná užitečná data (payload) a kontrolní součet na konci.
3.20.1. Rámcování paketů¶
Typický formát rámcování:
Rámcovaný paket se samostatnými CRC pro hlavičku a data: hlavička (magické bajty), příkaz, délka, CRC hlavičky, payload, CRC dat.¶
Každé pole plní jeden úkol:
Hlavička (magické bajty). Pevná, neobvyklá posloupnost bajtů – často dva bajty jako
0xAA 0x55– kterou přijímač vyhledává v příchozím proudu. Když posloupnost najde, ví, že začíná nový paket, a může zahodit jakýkoli nepořádek, který přišel předtím.Příkaz. Jediný bajt, který říká, co paket je. Různé hodnoty příkazu používají různé formáty payloadu – jeden příkaz může znamenat „nastav úhel serva“ se dvěma bajty payloadu, jiný může znamenat „přečti senzor“ bez payloadu, další může být „zaloguj zprávu“ s řetězcem. Přijímač podle příkazového bajtu rozhodne, jak interpretovat zbytek paketu.
Délka. Dva bajty udávající velikost payloadu v bajtech (zde little-endian), což umožňuje payloady až přibližně 64 KiB. Přijímač přečte přesně tolik bajtů, jakmile je ověřeno CRC hlavičky.
CRC hlavičky. Jednobajtový kontrolní součet přes pole HEADER, CMD a LEN. Přijímač jej zkontroluje předtím, než přečte jakýkoli payload, takže poškozené LEN je odhaleno už po hrstce bajtů (proč na tom záleží, viz sekce o CRC níže).
Payload. Aplikační data specifická pro daný příkaz, dlouhá přesně LEN bajtů. Formát je určen příkazovým bajtem: záznam zabalený pomocí
structs poli pevné šířky, řetězec, surová paměť – cokoli, na čem se obě strany pro daný příkaz dohodnou.CRC dat. Čtyřbajtové CRC přes bajty payloadu. Přijímač jej znovu vypočítá z právě přečtených bajtů a paket zahodí, pokud se neshoduje.
3.20.2. Kontrolní součty CRC¶
Nejjednodušší „kontrolní součet“ je součet všech bajtů modulo 256 nebo 65536. Zachytí většinu převrácení jednoho bitu, ale spoustu vícebitových chyb mine a ignoruje pořadí bajtů.
Cyklický redundantní součet (CRC) je standardním vylepšením. Vstup zpracuje jako jedno dlouhé binární číslo a vydělí jej (zvláštním způsobem) pevným polynomem; zbytek po dělení je CRC. Různé polynomy zachycují různé třídy chyb; běžné 8-, 16- a 32bitové polynomy každý zachytí každou dávku chyb kratší než jejich šířka plus velkou část delších dávek.
3.20.2.1. Proč dvě CRC¶
Diagram paketu výše nese dvě samostatná CRC – jedno přes hlavičku (HEADER, CMD, LEN) a jedno přes payload. Přesně to robustní rámcování ve skutečnosti potřebuje, kvůli tomu, jak jediné koncové CRC selhává, když se při přenosu poškodí samotné pole LEN:
Přijímač zareaguje na poškozené LEN a přečte z linky tolik bajtů – možná mnohem více, než odesílatel zamýšlel.
Až nakonec teprve koncové CRC řekne přijímači, že se něco pokazilo, a to až poté, co byly všechny tyto bajty spotřebovány.
Zatímco parser uvízne v čekání na nesprávný počet bajtů, skutečné pakety přicházející za tím poškozeným jsou pohlceny jako payload a přijímač přijde o několik paketů místo jen o jeden.
Rozdělení CRC to řeší:
CRC hlavičky pokrývá HEADER, CMD a LEN. Přijímač jej zkontroluje předtím, než přečte jakýkoli payload, takže poškozené LEN je odhaleno po hrstce bajtů a parser se okamžitě resynchronizuje, čímž ztratí jen ten jeden vadný paket.
CRC dat pokrývá payload. Jakmile CRC hlavičky projde, přijímač ví, že může LEN důvěřovat, přečte přesně tolik bajtů payloadu a ověří je proti CRC dat.
Běžné rozměry – a to, co používá tato stránka – je jeden bajt pro CRC hlavičky (CRC-8 na pětibajtovou hlavičku bohatě stačí) a čtyři bajty pro CRC dat (CRC-32 pokryje mnoho kilobajtů payloadu s mizivě nízkou pravděpodobností kolize).
3.20.2.2. Pomocné funkce¶
MicroPython dodává binascii.crc32() přímo pro čtyřbajtové CRC. Pro jednobajtové CRC hlavičky je malá pomocná funkce používající polynom, který používají 1-wire zařízení od firmy Maxim (0x8C v reflektované podobě), dostatečně krátká na to, aby se napsala přímo na místě:
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
Kompletní enkodér kombinuje obě CRC v jedné funkci:
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)
Inverzní funkce z kompletního paketu obnoví příkaz a payload, nebo vrátí None, pokud selže některá z kontrol CRC:
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)
V praxi přijímač nedostane celý paket najednou – bajty přicházejí po jednom přes UART a odesílatel, který se zastaví uprostřed paketu (nebo zašuměná linka, která ztratí bajt), nelze prostě read() načíst do bufferu správné velikosti. Následující sekce spouští stejnou dekódovací logiku bajt po bajtu jako stavový automat.
3.20.3. Přijímač jako stavový automat¶
Přijímač nemůže prostě zavolat uart.read(N) pro nějaké pevné N – neví, kolik bajtů bude mít následující paket, a jakýkoli nepořádek na lince naruší zarovnání. Řešením je malý stavový automat, který bajty spotřebovává po jednom a reaguje podle toho, kde se v paketu nachází. Hlavní smyčka se pomocí any() ptá, kolik bajtů je v bufferu, vyprázdní je jediným voláním read() a každý bajt protáhne stavovým automatem:
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
Každý bajt posune stavový automat o jeden krok vpřed, nebo se vrátí do HUNT_FOR_HEADER po kompletním paketu, špatném CRC hlavičky nebo špatném CRC dat. Nepořádek na lince, který neodpovídá hlavičce, je tiše zahozen; další platná hlavička přijímač znovu synchronizuje. Klíčová bezpečnostní vlastnost pochází z CRC hlavičky: pokud je pole LEN poškozeno, parser to zachytí po kontrole CRC hlavičky (hrstka bajtů), nikoli až poté, co se zavázal přečíst naprosto nesprávný počet bajtů payloadu.
3.20.4. Nad rámec základu¶
Výše uvedené rámcování je minimem, které sériové spojení potřebuje k zotavení ze šumu na lince: magická hlavička, délka, příkaz a dvě CRC. Detekuje poškození a po pokažených bajtech se resynchronizuje, ale poškozené pakety vzdává místo toho, aby je propustil, a odesílatele ponechává bez tušení, co přijímač vlastně slyšel.
Reálné sériové protokoly nad tímto základem vrství další funkce. Ne každé vestavěné spojení potřebuje všechny z nich – vyberte si to, co aplikace skutečně vyžaduje:
Pořadová čísla. Malý čítač, který se zvyšuje při každém odeslání. Přijímač detekuje mezery (paket byl ztracen), duplikáty (odesílatel paket znovu odeslal, ale přijímač již první kopii přijal) a – tam, kde kanál může měnit pořadí – příchody mimo pořadí.
Potvrzení (ACK). Vyhrazený paket ACK (nebo přiložený bit v odpovědi), který přijímač posílá zpět k potvrzení každého paketu. Bez ACK nemá odesílatel jak zjistit, že jeho data dorazila.
Záporná potvrzení (NACK). NACK odeslané, když přijímač zaznamená selhání CRC nebo mezeru v pořadí. Odesílatel okamžitě paket znovu odešle, místo aby čekal na vypršení časového limitu ACK.
Opětovné odesílání. Odesílatel uchovává každý nepotvrzený paket v malé frontě a po vypršení časového limitu (nebo při NACK) jej znovu odešle. Limit počtu pokusů a určité zpoždění (backoff) mezi pokusy zabrání tomu, aby se trvale rozbité spojení donekonečna zacyklilo.
Posuvná okna. Povolení několika paketů „v letu“ před vyžádáním ACK udržuje propustnost na spojeních, kde je doba obrátky (round-trip) dlouhá ve srovnání s dobou odeslání jednoho paketu. Cenou je více stavu na straně odesílatele – jeden slot na každý paket v letu.
Řízení toku. Signál od přijímače, který říká odesílateli, aby zpomalil nebo se pozastavil, když se jeho buffer plní. Implementace se liší – explicitní bajty XON / XOFF, kreditní udělování, kdy přijímač povoluje vždy N dalších paketů, nebo hardwarové linky RTS / CTS přímo na vodiči. Bez řízení toku rychlý odesílatel nakonec zahltí pomalý přijímač a pakety se zahodí.
Verze protokolu. Pole verze na začátku paketu umožňuje formátu vyvíjet se. Každá strana může při startu vyjednat nejvyšší verzi, kterou obě podporují, nebo odmítnout pakety od nekompatibilních protějšků.
Fragmentace a opětovné sestavení. Dvoubajtové LEN omezuje paket na 64 KiB; zprávy větší než to se rozdělí do více paketů a na druhé straně se znovu sestaví. Metadata fragmentace (index fragmentu, celkový počet nebo příznak „další fragmenty“) žijí uvnitř payloadu.
Heartbeaty. Malý periodický paket, který říká „jsem stále tady“. Druhá strana si všimne, když heartbeaty ustanou, a znovu se připojí (nebo hlasitě selže) místo toho, aby tiše visela.
Kanály. ID kanálu nebo proudu v hlavičce, takže jedno fyzické spojení nese několik logických proudů – řídicí kanál, telemetrický kanál, logovací kanál – rozlišených pouze tímto polem.
Autentizace. Krátká značka vypočtená z payloadu a tajné hodnoty, kterou znají jen legitimní odesílatel a přijímač. Přijímač značku znovu vypočítá z přijatých bajtů a paket odmítne, pokud se obě neshodují. To zachytí jak manipulaci (útočník upravil bajty), tak – pokud je pořadové číslo nebo časové razítko součástí toho, co značka pokrývá – přehrání (replay), kdy útočník zaznamená skutečný paket z linky a později jej znovu odešle, aby přiměl přijímač jednat podle něj dvakrát.
Šifrování. Zamíchání bajtů payloadu sdíleným tajným klíčem, takže každý, kdo čte linku bez tohoto klíče, vidí jen šum. Obvykle se kombinuje s výše uvedenou autentizační značkou – bez ní může útočník podstrčit nesmysly, které náhodou projdou CRC, a přijímač plýtvá cykly pokusy o dešifrování nesmyslů.
Typický „dobrý“ protokol pro průmyslová zařízení nakonec obsahuje rámcování, duální CRC, pořadová čísla, ACK / NACK s opětovným odesíláním a heartbeaty. Reálné příklady, na které stojí za to se podívat: MAVLink (telemetrie dronů, s pořadovými čísly, ID systému / komponenty a volitelnými podpisy paketů), Modbus (průmyslové PLC, s funkčními kódy a CRC) a NMEA 0183 (ASCII protokol, kterým mluví každý spotřebitelský GPS přijímač – zprávy po řádcích s kontrolním součtem za oddělovačem hvězdičky).