3.20. Soros protokollok, keretezés és CRC-k¶
UART a kódban bájtokat mozgatott két végpont között. Önmagában ez nem elég egy megbízható kapcsolat kiépítéséhez. Három probléma azonnal felbukkan, amint a vezeték másik végén egy valódi eszköz van:
Hol kezdődik és hol végződik egy üzenet? A bájtok beépített elválasztó nélküli adatfolyamként érkeznek. Ha a vevő lemarad az első bájtról (a küldő után kapcsolták be; rövid elektromos zavar a vonalon), akkor minden utána következő bájt eggyel elcsúszik, amíg a vevő nem talál egy új újraszinkronizálási pontot.
Milyen hosszú az egyes üzenet? Egy 32 bájtos érzékelőolvasás és egy 4 bájtos állapotválasz bájtszinten azonosnak tűnik. A vevőnek tudnia kell, hány bájt tartozik az aktuális üzenethez.
Sértetlenül érkeztek-e meg a bájtok? A zaj egyes bitek értékét megváltoztathatja. Ellenőrzés nélkül a vevő boldogan dolgozik a sérült adatokkal.
Mindhárom esetre a szabványos válasz az, hogy az adatot egy csomagkeretbe csomagoljuk: egy ismert bájtsorozat az elején, egy hosszúságmező, maga a hasznos adat, és egy ellenőrzőösszeg a végén.
3.20.1. Csomagkeretezés¶
Egy tipikus keretezési formátum:
Egy keretezett csomag külön fejléc- és adat-CRC-vel: fejléc (varázsbájtok), parancs, hosszúság, fejléc-CRC, hasznos adat, adat-CRC.¶
Minden mező egyetlen feladatot lát el:
Fejléc (varázsbájtok). Egy rögzített, szokatlan bájtsorozat – gyakran két bájt, például
0xAA 0x55–, amelyet a vevő keres a beérkező adatfolyamban. Amikor megtalálja a sorozatot, tudja, hogy egy új csomag kezdődik, és eldobhatja az előtte érkezett szemetet.Parancs. Egyetlen bájt, amely megmondja, mi a csomag. A különböző parancsértékek különböző hasznosadat-formátumokat használnak – az egyik parancs jelentheti azt, hogy „szervó szögének beállítása” két hasznos adatbájttal, egy másik azt, hogy „érzékelő olvasása” hasznos adat nélkül, megint egy másik „naplóüzenet” lehet egy karakterlánccal. A vevő a parancsbájt alapján dönt arról, hogyan értelmezze a csomag többi részét.
Hosszúság. Két bájt, amely megadja a hasznos adat méretét bájtban (itt little-endian sorrendben), így körülbelül 64 KiB-ig terjedő hasznos adat lehetséges. A vevő pontosan ennyi bájtot olvas be, miután a fejléc-CRC ellenőrzése megtörtént.
Fejléc-CRC. Egy egybájtos ellenőrzőösszeg a HEADER, a CMD és a LEN mezőkre. A vevő ellenőrzi, mielőtt bármilyen hasznos adatot olvasna, így egy sérült LEN már néhány bájt után kiszűrhető (lásd lentebb a CRC szakaszt arról, miért fontos ez).
Hasznos adat. Parancsspecifikus alkalmazásadat, pontosan LEN bájt hosszú. A formátumot a parancsbájt határozza meg: egy
struct-tal becsomagolt, rögzített szélességű mezőkből álló rekord, egy karakterlánc, nyers memória – bármi, amiben a két fél az adott parancshoz megegyezett.Adat-CRC. Egy négybájtos CRC a hasznos adat bájtjaira. A vevő újraszámítja a most beolvasott bájtokból, és eldobja a csomagot, ha nem egyezik.
3.20.2. CRC-k¶
A legegyszerűbb „ellenőrzőösszeg” az összes bájt összege 256-tal vagy 65536-tal vett modulója. A legtöbb egybites átfordulást elkapja, de sok többbites hibát figyelmen kívül hagy, és a bájtsorrendet sem veszi figyelembe.
A ciklikus redundanciaellenőrzés (CRC) a szabványos továbbfejlesztés. A bemenetet egyetlen hosszú bináris számként kezeli, és (egy speciális módon) elosztja egy rögzített polinommal; az osztás maradéka a CRC. A különböző polinomok különböző hibaosztályokat kapnak el; az elterjedt 8, 16 és 32 bites polinomok mindegyike elkap minden, a szélességüknél rövidebb hibakitörést, valamint a hosszabb kitörések nagy hányadát.
3.20.2.1. Miért két CRC¶
A fenti csomagdiagram két külön CRC-t hordoz – egyet a fejlécre (HEADER, CMD, LEN) és egyet a hasznos adatra. Erre van szüksége egy robusztus keretezésnek, mégpedig azért, ahogyan egyetlen záró CRC felmondja a szolgálatot, ha maga a LEN mező sérül meg az átvitel során:
A vevő a sérült LEN alapján cselekszik, és annyi bájtot olvas be a vonalról – esetleg jóval többet, mint amennyit a küldő szándékozott.
Csak a záró CRC árulja el végül a vevőnek, hogy valami elromlott, és csak azután, hogy az összes bájtot felemésztette.
Amíg az értelmező a rossz számú bájtra várakozva elakad, a sérült csomag mögött érkező valódi csomagokat hasznos adatként nyeli el, és a vevő nem csak egyet, hanem több csomagot is elveszít.
A CRC szétválasztása ezt orvosolja:
A fejléc-CRC a HEADER, a CMD és a LEN mezőkre terjed ki. A vevő ellenőrzi, mielőtt bármilyen hasznos adatot olvasna, így egy sérült LEN néhány bájt után kiszűrhető, az értelmező azonnal újraszinkronizál, és csak az egyetlen rossz csomagot veszíti el.
Az adat-CRC a hasznos adatra terjed ki. Miután a fejléc-CRC sikeresen lefutott, a vevő tudja, hogy megbízhat a LEN-ben, pontosan annyi hasznos adatbájtot olvas be, és összeveti azokat az adat-CRC-vel.
Egy gyakori méretezés – és amit ez az oldal is használ – egy bájt a fejléc-CRC-hez (egy CRC-8 bőven elég egy ötbájtos fejléchez) és négy bájt az adat-CRC-hez (egy CRC-32 sok kilobájtnyi hasznos adatot fed le elenyészően alacsony ütközési rátával).
3.20.2.2. Segédfüggvények¶
A MicroPython közvetlenül tartalmazza a binascii.crc32() függvényt a négybájtos CRC-hez. Az egybájtos fejléc-CRC-hez a Maxim 1-wire eszközei által használt polinommal (0x8C tükrözött formában) készülő kis segédfüggvény elég rövid ahhoz, hogy helyben megírjuk:
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
Egy teljes kódoló a két CRC-t egyetlen függvényben egyesíti:
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)
Az inverz függvény visszaállítja a parancsot és a hasznos adatot egy teljes csomagból, vagy None értéket ad vissza, ha bármelyik CRC-ellenőrzés sikertelen:
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)
A gyakorlatban a vevő nem kap egy egész csomagot a kezébe – a bájtok egyesével érkeznek az UART-on, és egy olyan küldő, amely csomag közben szünetet tart (vagy egy zajos vonal, amely elveszít egy bájtot), nem olvasható egyszerűen read()-del a megfelelő méretű pufferbe. A következő szakasz ugyanezt a dekódolási logikát futtatja bájtonként állapotgépként.
3.20.3. Állapotgépes vevő¶
A vevő nem hívhatja egyszerűen az uart.read(N)-t valamilyen rögzített N értékre – nem tudja, hány bájt lesz a következő csomag, és bármilyen szemét a vonalon felborítja az igazítást. A megoldás egy kis állapotgép, amely a bájtokat egyesével dolgozza fel, és aszerint reagál, hol tart a csomagban. A főciklus a any() segítségével lekérdezi, hány bájt van pufferelve, egyetlen read() hívással kiüríti azokat, és minden bájtot átad az állapotgépnek:
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
Minden bájt egy lépéssel előrébb viszi az állapotgépet, vagy visszaesik a HUNT_FOR_HEADER állapotba egy teljes csomag, egy rossz fejléc-CRC vagy egy rossz adat-CRC után. A vonalon lévő, a fejléchez nem illeszkedő szemét csendben eldobódik; a következő érvényes fejléc újraszinkronizálja a vevőt. A kulcsfontosságú biztonsági tulajdonság a fejléc-CRC-ből ered: ha a LEN mező sérül meg, az értelmező a fejléc-CRC ellenőrzése után (néhány bájt) elkapja, nem pedig azután, hogy elkötelezte magát egy vadul rossz számú hasznos adatbájt beolvasása mellett.
3.20.4. Az alapszinten túl¶
A fenti keretezés a minimum, amire egy soros kapcsolatnak szüksége van a vonalzajból való helyreálláshoz: fejléc-varázsbájtok, hosszúság, parancs és két CRC. Észleli a sérülést és újraszinkronizál a torzult bájtok után, de a sérült csomagokat inkább feladja, semmint átjuttatná őket, és a küldőt teljes bizonytalanságban hagyja afelől, hogy a vevő valójában mit hallott.
A valós soros protokollok ezen alapszint fölé rétegeznek funkciókat. Nem minden beágyazott kapcsolatnak van szüksége mindegyikre – válaszd ki, amire az alkalmazás ténylegesen igényt tart:
Sorszámok. Egy kis számláló, amely minden küldésnél növekszik. A vevő észleli a kihagyásokat (egy csomag elveszett), a duplikátumokat (a küldő újraküldte, de a vevő már elfogadta az első másolatot), és – ahol a csatorna átrendezhet – a sorrenden kívüli érkezéseket.
Nyugtázások. Egy dedikált ACK csomag (vagy egy ráültetett bit egy válaszban), amelyet a vevő visszaküld minden csomag megerősítésére. ACK-k nélkül a küldőnek nincs módja tudni, hogy az adatai megérkeztek.
Negatív nyugtázások. Egy NACK, amelyet a vevő akkor küld, amikor CRC-hibát vagy sorszámkihagyást észlel. A küldő azonnal újraküld, ahelyett, hogy egy ACK-időtúllépés bekövetkezésére várna.
Újraküldés. A küldő minden nyugtázatlan csomagot egy kis sorban tart, és időtúllépés után (vagy NACK esetén) újraküldi. Egy újrapróbálkozási korlát és némi várakozás az újrapróbálkozások között megakadályozza, hogy egy tartósan elromlott kapcsolat örökké ciklusban maradjon.
Csúszóablakok. Ha több csomag lehet úton, mielőtt ACK-ra lenne szükség, fenntartja az átviteli teljesítményt olyan kapcsolatokon, ahol az oda-vissza út hosszú a csomagonkénti küldési időhöz képest. Ennek ára több küldőoldali állapot – egy hely minden úton lévő csomaghoz.
Folyamvezérlés. A vevőtől érkező jelzés, amely arra utasítja a küldőt, hogy lassítson vagy szüneteltessen, amikor a puffere kezd megtelni. A megvalósítások eltérőek – explicit XON / XOFF bájtok, kreditalapú engedélyek, ahol a vevő egyszerre N újabb csomagot engedélyez, vagy maguk a vezetéken lévő RTS / CTS hardveres vonalak. Folyamvezérlés nélkül egy gyors küldő végül túlterhel egy lassú vevőt, és csomagok vesznek el.
Protokollverzió. Egy verziómező a csomag elején lehetővé teszi a formátum fejlődését. Mindkét fél indításkor egyeztetheti a legmagasabb, mindkettő által támogatott verziót, vagy elutasíthatja az inkompatibilis társaktól érkező csomagokat.
Töredezés és újraösszeállítás. Egy kétbájtos LEN 64 KiB-re korlátozza a csomagot; az ennél nagyobb üzeneteket több csomagra bontjuk, és a másik oldalon újra összeállítjuk. A töredezési metaadatok (töredékindex, teljes darabszám vagy egy „további töredékek” jelző) a hasznos adaton belül helyezkednek el.
Életjelek. Egy kis, időszakos csomag, amely azt mondja: „Még itt vagyok”. A másik oldal észreveszi, amikor az életjelek elmaradnak, és újracsatlakozik (vagy hangosan hibát jelez), ahelyett, hogy némán lefagyna.
Csatornák. Egy csatorna- vagy adatfolyam-azonosító a fejlécben, így egy fizikai kapcsolat több logikai adatfolyamot hordoz – egy vezérlőcsatornát, egy telemetriacsatornát, egy naplócsatornát –, amelyeket csak ez a mező különböztet meg.
Hitelesítés. Egy rövid címke, amelyet a hasznos adatból és egy titkos értékből számítanak ki, amelyet csak a jogosult küldő és vevő ismer. A vevő a kapott bájtokból újraszámítja a címkét, és elutasítja a csomagot, ha a kettő nem egyezik. Ez elkapja mind a manipulációt (egy támadó módosította a bájtokat), mind – ha egy sorszám vagy időbélyeg is része annak, amit a címke lefed – a visszajátszást, amikor egy támadó leveszi a vonalról egy valódi csomagot, és később újraküldi, hogy a vevő kétszer is cselekedjen alapján.
Titkosítás. A hasznos adatbájtok összekeverése egy megosztott titkos kulccsal, így bárki, aki a vonalat e kulcs nélkül olvassa, csak zajt lát. Általában a fenti hitelesítési címkével kombinálva – enélkül egy támadó beadhat olyan szemetet, amely véletlenül átmegy a CRC-n, és a vevő ciklusokat pazarol értelmetlenség visszafejtésével.
Egy tipikus „jó” ipari berendezésekhez való protokoll végül keretezést, kettős CRC-t, sorszámokat, újraküldéses ACK / NACK-ot és életjeleket tartalmaz. Megnézésre érdemes valós példák: MAVLink (dróntelemetria sorszámokkal, rendszer- / komponensazonosítókkal és opcionális csomagaláírásokkal), Modbus (ipari PLC-k függvénykódokkal és CRC-vel) és NMEA 0183 (az ASCII protokoll, amelyet minden fogyasztói GPS-vevő beszél – sor alapú üzenetek egy ellenőrzőösszeggel egy csillag-elválasztó után).