3.20. Seriella protokoll, ramning och CRC:er¶
UART i kod flyttade byte mellan två ändar. I sig själv räcker inte det för att bygga en pålitlig länk. Tre problem dyker upp i samma ögonblick som en verklig enhet finns i andra änden av kabeln:
Var börjar och slutar ett meddelande? Byte anländer i en ström utan någon inbyggd avgränsare. Om mottagaren missar den första byten (slås på efter sändaren; en kort elektrisk störning på ledningen) hamnar varje byte därefter ett steg fel tills mottagaren hittar en ny synkroniseringspunkt.
Hur lång är varje meddelande? En 32-byte sensoravläsning och ett 4-byte statussvar ser identiska ut på bytenivå. Mottagaren behöver ett sätt att veta hur många byte som tillhör det aktuella meddelandet.
Anlände byten oskadda? Brus kan vända enskilda bitar. Utan en kontroll agerar mottagaren glatt på korrupt data.
Standardsvaret på alla tre är att slå in datan i en paketram: en känd bytesekvens i början, ett längdfält, själva nyttolasten och en kontrollsumma i slutet.
3.20.1. Paketramning¶
Ett typiskt ramningsformat:
Ett ramat paket med separata CRC:er för header och data: header (magiska byte), kommando, längd, header-CRC, nyttolast, data-CRC.¶
Varje fält gör ett jobb:
Header (magiska byte). En fast, ovanlig bytesekvens – ofta två byte som
0xAA 0x55– som mottagaren letar efter i den inkommande strömmen. När den hittar sekvensen vet den att ett nytt paket börjar och kan kasta bort allt skräp som kom innan.Kommando. En enda byte som anger vad paketet är. Olika kommandovärden använder olika nyttolastformat – ett kommando kan betyda ”ställ in servovinkel” med två nyttolastbyte, ett annat kan betyda ”läs sensor” utan nyttolast, ett tredje kan vara ”loggmeddelande” med en sträng. Mottagaren skickar vidare baserat på kommandobyten för att veta hur resten av paketet ska tolkas.
Längd. Två byte som anger nyttolastens storlek i byte (little-endian här), vilket tillåter nyttolaster upp till cirka 64 KiB. Mottagaren läser exakt så här många byte när header-CRC:n har verifierats.
Header-CRC. En en-byte kontrollsumma över fälten HEADER, CMD och LEN. Mottagaren kontrollerar den innan någon nyttolast läses, så en korrupt LEN fångas efter bara en handfull byte (se CRC-avsnittet nedan för varför detta är viktigt).
Nyttolast. Kommandospecifik applikationsdata, exakt LEN byte lång. Formatet bestäms av kommandobyten: en
struct-packad post med fält av fast bredd, en sträng, rått minne – vad än båda sidor är överens om för det kommandot.Data-CRC. En fyra-byte CRC över nyttolastbyten. Mottagaren beräknar den på nytt från de byte den just läste och kasserar paketet om den inte stämmer.
3.20.2. CRC:er¶
Den enklaste ”kontrollsumman” är summan av alla byte, modulo 256 eller 65536. Den fångar de flesta enkelbitsvändningar men missar många flerbitsfel och ignorerar byteordningen.
En cyklisk redundanskontroll (CRC) är standarduppgraderingen. Den behandlar indata som ett enda långt binärt tal och dividerar det (på ett särskilt sätt) med ett fast polynom; resten av divisionen är CRC:n. Olika polynom fångar olika klasser av fel; de vanliga 8-, 16- och 32-bitspolynomen fångar var och en alla felskurar som är kortare än sin bredd plus en stor andel av längre skurar.
3.20.2.1. Varför två CRC:er¶
Paketdiagrammet ovan bär två separata CRC:er – en över headern (HEADER, CMD, LEN) och en över nyttolasten. Detta är vad en robust ramning faktiskt behöver, på grund av hur en enda avslutande CRC fallerar när själva LEN-fältet blir korrupt under överföringen:
Mottagaren agerar på den korrupta LEN och läser så många byte från ledningen – möjligen långt fler än sändaren avsåg.
Endast den avslutande CRC:n talar så småningom om för mottagaren att något gick fel, och först efter att alla dessa byte har förbrukats.
Medan parsern sitter fast och väntar på fel antal byte sväljs verkliga paket som anländer bakom det korrupta som nyttolast, och mottagaren tappar flera paket i stället för bara ett.
Att dela upp CRC:n löser detta:
Header-CRC:n täcker HEADER, CMD och LEN. Mottagaren kontrollerar den innan någon nyttolast läses, så en korrupt LEN fångas efter en handfull byte och parsern synkroniserar om sig omedelbart, vilket tar ner endast det ena felaktiga paketet.
Data-CRC:n täcker nyttolasten. När header-CRC:n har godkänts vet mottagaren att den kan lita på LEN, läser exakt så många nyttolastbyte och verifierar dem mot data-CRC:n.
En vanlig dimensionering – och det som denna sida använder – är en byte för header-CRC:n (en CRC-8 räcker gott och väl för en fem-byte header) och fyra byte för data-CRC:n (en CRC-32 täcker många kilobyte nyttolast med en försvinnande låg kollisionsfrekvens).
3.20.2.2. Hjälpfunktioner¶
MicroPython levererar binascii.crc32() för den fyra-byte CRC:n direkt. För den en-byte header-CRC:n är en liten hjälpfunktion som använder polynomet som Maxims 1-wire-enheter använder (0x8C i reflekterad form) tillräckligt kort för att skrivas inline:
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
En komplett kodare kombinerar de två CRC:erna i en funktion:
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)
Den inversa funktionen återskapar kommandot och nyttolasten från ett komplett paket, eller returnerar None om någon av CRC-kontrollerna misslyckas:
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)
I praktiken får mottagaren inte ett helt paket överlämnat till sig – byte anländer en i taget över UART:en, och en sändare som pausar mitt i ett paket (eller en brusig ledning som tappar en byte) kan inte bara read():as in i en buffert av rätt storlek. Nästa avsnitt kör samma avkodningslogik byte för byte som en tillståndsmaskin.
3.20.3. En tillståndsmaskinsmottagare¶
Mottagaren kan inte bara anropa uart.read(N) för något fast N – den vet inte hur många byte nästa paket kommer att vara, och vilket skräp som helst på ledningen kastar av justeringen. Lösningen är en liten tillståndsmaskin som förbrukar byten en i taget och reagerar baserat på var den befinner sig i paketet. Huvudloopen pollar any() för att se hur många byte som är buffrade, tömmer dem i ett enda read()-anrop och matar varje byte genom tillståndsmaskinen:
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
Varje byte för tillståndsmaskinen ett steg framåt, eller faller tillbaka till HUNT_FOR_HEADER efter ett komplett paket, en felaktig header-CRC eller en felaktig data-CRC. Skräp på ledningen som inte matchar headern kasseras tyst; nästa giltiga header synkroniserar om mottagaren. Den viktigaste säkerhetsegenskapen kommer från header-CRC:n: om LEN-fältet är korrupt fångar parsern det efter header-CRC-kontrollen (en handfull byte), inte efter att ha förbundit sig att läsa ett vilt felaktigt antal nyttolastbyte.
3.20.4. Bortom grundnivån¶
Ramningen ovan är minimum som en seriell länk behöver för att återhämta sig från ledningsbrus: header-magi, längd, kommando och två CRC:er. Den upptäcker korruption och synkroniserar om efter förvanskade byte, men den ger upp skadade paket i stället för att få igenom dem, och den lämnar sändaren utan en aning om vad mottagaren faktiskt hörde.
Verkliga seriella protokoll lägger funktioner ovanpå den grundnivån. Inte varje inbäddad länk behöver alla – välj det som applikationen faktiskt kräver:
Sekvensnummer. En liten räknare som ökas vid varje sändning. Mottagaren upptäcker luckor (ett paket gick förlorat), dubbletter (sändaren skickade om men mottagaren hade redan accepterat den första kopian) och – där kanalen kan ändra ordning – ankomster i fel ordning.
Kvitteringar. Ett dedikerat ACK-paket (eller en medföljande bit i ett svar) som mottagaren skickar tillbaka för att bekräfta varje paket. Utan ACK:er har sändaren inget sätt att veta att dess data kom fram.
Negativa kvitteringar. En NACK skickas när mottagaren ser ett CRC-fel eller en sekvenslucka. Sändaren skickar om omedelbart i stället för att vänta på att en ACK-timeout ska lösa ut.
Omsändning. Sändaren håller varje okvitterat paket i en liten kö och skickar om det efter en timeout (eller vid en NACK). En omförsöksgräns och viss backoff mellan omförsök hindrar en permanent trasig länk från att loopa för evigt.
Glidande fönster. Att tillåta flera paket i luften innan en ACK krävs håller uppe genomströmningen på länkar där rundresan är lång jämfört med sändtiden per paket. Kostnaden är mer tillstånd på sändarsidan – en plats per paket i luften.
Flödeskontroll. En signal från mottagaren som säger åt sändaren att sakta ner eller pausa när dess buffert fylls upp. Implementeringar varierar – explicita XON/XOFF-byte, kreditbaserade tilldelningar där mottagaren licensierar N fler paket åt gången, eller hårdvaruledningarna RTS/CTS på själva kabeln. Utan flödeskontroll översvämmar en snabb sändare så småningom en långsam mottagare och paket tappas.
Protokollversion. Ett versionsfält tidigt i paketet låter formatet utvecklas. Varje sida kan förhandla fram den högsta version som båda stöder vid uppstart, eller avvisa paket från inkompatibla motparter.
Fragmentering och återsammansättning. En två-byte LEN begränsar paketet till 64 KiB; meddelanden större än så delas upp i flera paket och sätts ihop igen på andra sidan. Fragmenteringsmetadatan (fragmentindex, totalt antal eller en ”fler fragment”-flagga) lever inne i nyttolasten.
Hjärtslag. Ett litet periodiskt paket som säger ”jag är fortfarande här”. Den andra sidan märker när hjärtslagen slutar och återansluter (eller fallerar högljutt) i stället för att hänga sig tyst.
Kanaler. Ett kanal- eller ström-ID i headern så att en fysisk länk bär flera logiska strömmar – en styrkanal, en telemetrikanal, en loggkanal – åtskilda endast av det fältet.
Autentisering. En kort tagg beräknad från nyttolasten och ett hemligt värde som endast den legitima sändaren och mottagaren känner till. Mottagaren beräknar taggen igen från de byte den tog emot och avvisar paketet om de två inte stämmer överens. Detta fångar både manipulation (en angripare modifierade byten) och – om ett sekvensnummer eller en tidsstämpel ingår i det taggen täcker – replay, där en angripare spelar in ett verkligt paket från ledningen och skickar om det senare för att få mottagaren att agera på det två gånger.
Kryptering. Att förvränga nyttolastbyten med en delad hemlig nyckel så att vem som helst som läser ledningen utan den nyckeln bara ser brus. Vanligtvis kombinerat med autentiseringstaggen ovan – utan den kan en angripare mata in skräp som råkar passera CRC:n och mottagaren slösar cykler på att försöka dekryptera nonsens.
Ett typiskt ”bra” protokoll för industriell utrustning slutar med ramning, dubbel CRC, sekvensnummer, ACK/NACK med omsändning och hjärtslag. Verkliga exempel värda en titt: MAVLink (drönartelemetri, med sekvensnummer, system-/komponent-ID:n och valfria paketsignaturer), Modbus (industriella PLC:er, med funktionskoder och CRC) och NMEA 0183 (ASCII-protokollet som varje konsument-GPS-mottagare talar – radbaserade meddelanden med en kontrollsumma efter en stjärnavgränsare).