3.20. Seri protokoller, çerçeveleme ve CRC’ler

Kodda UART baytları iki uç arasında taşıyordu. Tek başına bu, güvenilir bir bağlantı kurmak için yeterli değildir. Telin diğer ucunda gerçek bir cihaz olduğu anda üç sorun ortaya çıkar:

  • Bir mesaj nerede başlar ve nerede biter? Baytlar, yerleşik bir ayraç olmaksızın bir akış halinde gelir. Alıcı ilk baytı kaçırırsa (göndericiden sonra açılmıştır; hatta kısa bir elektriksel arıza olmuştur), alıcı yeni bir yeniden senkronizasyon noktası bulana kadar ondan sonraki her bayt bir kayar.

  • Her mesaj ne kadar uzun? 32 baytlık bir sensör okuması ile 4 baytlık bir durum yanıtı bayt düzeyinde aynı görünür. Alıcının, geçerli mesaja kaç baytın ait olduğunu bilmesi için bir yola ihtiyacı vardır.

  • Baytlar bozulmadan geldi mi? Gürültü tek tek bitleri ters çevirebilir. Bir kontrol olmadan, alıcı bozuk veriyi gönül rahatlığıyla işler.

Bu üçünün de standart yanıtı, veriyi bir paket çerçevesine sarmaktır: başta bilinen bir bayt dizisi, bir uzunluk alanı, yükün kendisi ve sonda bir sağlama toplamı.

3.20.1. Paket çerçeveleme

Tipik bir çerçeveleme biçimi:

Sırayla çizilmiş altı alan: 0xAA 0x55 olarak etiketlenmiş iki baytlık bir HEADER, bu paketin hangi komutu taşıdığını seçen bir baytlık bir CMD alanı, yük boyutunu veren iki baytlık bir LEN alanı, HEADER artı CMD artı LEN'i kapsayan bir baytlık bir HCRC alanı, biçimi CMD'ye bağlı olan LEN baytlık değişken uzunlukta bir PAYLOAD ve yükü kapsayan dört baytlık bir DCRC alanı.

Ayrı başlık ve veri CRC’lerine sahip çerçevelenmiş bir paket: başlık (sihirli baytlar), komut, uzunluk, başlık CRC’si, yük, veri CRC’si.

Her alan tek bir iş yapar:

  • Başlık (sihirli baytlar). Alıcının gelen akışta taradığı, sabit ve sıra dışı bir bayt dizisi – çoğu zaman 0xAA 0x55 gibi iki bayt. Bu diziyi bulduğunda, yeni bir paketin başladığını anlar ve daha önce gelen her türlü çöpü atabilir.

  • Komut. Paketin ne olduğunu söyleyen tek bir bayt. Farklı komut değerleri farklı yük biçimleri kullanır – bir komut iki yük baytı ile “servo açısını ayarla” anlamına gelebilir, bir başkası yüksüz olarak “sensörü oku” anlamına gelebilir, bir başkası ise bir dize ile “mesaj kaydet” olabilir. Alıcı, paketin geri kalanını nasıl yorumlayacağını bilmek için komut baytına göre dağıtım yapar.

  • Uzunluk. Yükün boyutunu bayt cinsinden veren iki bayt (burada little-endian), yaklaşık 64 KiB’ye kadar yüklere izin verir. Başlık CRC’si doğrulandıktan sonra alıcı tam olarak bu kadar bayt okur.

  • Başlık CRC’si. HEADER, CMD ve LEN alanları üzerinde bir baytlık bir sağlama toplamı. Alıcı, herhangi bir yük okumadan önce bunu kontrol eder, böylece bozuk bir LEN yalnızca birkaç baytın ardından yakalanır (bunun neden önemli olduğu için aşağıdaki CRC bölümüne bakın).

  • Yük. Komuta özgü uygulama verisi, tam olarak LEN bayt uzunluğunda. Biçim, komut baytı tarafından belirlenir: sabit genişlikli alanlardan oluşan struct ile paketlenmiş bir kayıt, bir dize, ham bellek – her iki tarafın da o komut için üzerinde anlaştığı her ne ise.

  • Veri CRC’si. Yük baytları üzerinde dört baytlık bir CRC. Alıcı, az önce okuduğu baytlardan bunu yeniden hesaplar ve eşleşmezse paketi atar.

3.20.2. CRC’ler

En basit “sağlama toplamı”, tüm baytların 256 ya da 65536’ya göre modülünün toplamıdır. Çoğu tek bit ters dönmesini yakalar ancak birçok çok bitli hatayı kaçırır ve bayt sırasını göz ardı eder.

Bir döngüsel artıklık denetimi (CRC) standart bir yükseltmedir. Girdiyi tek bir uzun ikili sayı olarak ele alır ve onu (özel bir şekilde) sabit bir polinoma böler; bölme işleminin kalanı CRC’dir. Farklı polinomlar farklı hata sınıflarını yakalar; yaygın 8, 16 ve 32 bitlik polinomların her biri, genişliklerinden daha kısa her hata patlamasını ve daha uzun patlamaların büyük bir bölümünü yakalar.

3.20.2.1. Neden iki CRC

Yukarıdaki paket diyagramı iki ayrı CRC taşır – biri başlık (HEADER, CMD, LEN) üzerinde, diğeri yük üzerinde. Sağlam bir çerçevelemenin gerçekten ihtiyaç duyduğu şey budur, çünkü LEN alanının kendisi iletim sırasında bozulduğunda tek bir kuyruk CRC’si şu şekilde başarısız olur:

  • Alıcı bozuk LEN’e göre hareket eder ve telden o kadar bayt okur – muhtemelen göndericinin amaçladığından çok daha fazlasını.

  • Sonunda alıcıya bir şeylerin yanlış gittiğini ancak kuyruk CRC’si söyler, ve yalnızca tüm o baytlar tüketildikten sonra.

  • Ayrıştırıcı yanlış sayıda bayt beklerken takılıp kaldığı sürece, bozuk paketin arkasından gelen gerçek paketler yük olarak yutulur ve alıcı yalnızca birini değil, birkaç paketi kaybeder.

CRC’yi bölmek bunu düzeltir:

  • Başlık CRC’si HEADER, CMD ve LEN’i kapsar. Alıcı, herhangi bir yük okumadan önce bunu kontrol eder, böylece bozuk bir LEN birkaç baytın ardından yakalanır ve ayrıştırıcı yalnızca tek bir hatalı paketi devre dışı bırakarak hemen yeniden senkronize olur.

  • Veri CRC’si yükü kapsar. Başlık CRC’si geçtikten sonra, alıcı LEN’e güvenebileceğini bilir, tam olarak o kadar yük baytı okur ve bunları veri CRC’sine karşı doğrular.

Yaygın bir boyutlandırma – ve bu sayfanın kullandığı şey – başlık CRC’si için bir bayt (beş baytlık bir başlık için bir CRC-8 fazlasıyla yeterlidir) ve veri CRC’si için dört bayttır (bir CRC-32, birçok kilobaytlık yükü neredeyse sıfır çakışma oranıyla kapsar).

3.20.2.2. Yardımcılar

MicroPython, dört baytlık CRC için doğrudan binascii.crc32() sağlar. Bir baytlık başlık CRC’si için, Maxim’in 1-wire cihazlarının kullandığı polinomu (yansıtılmış biçimde 0x8C) kullanan küçük bir yardımcı, satır içinde yazılacak kadar kısadır:

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

Eksiksiz bir kodlayıcı iki CRC’yi tek bir işlevde birleştirir:

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)

Ters işlev, komut ve yükü eksiksiz bir paketten kurtarır veya CRC kontrollerinden herhangi biri başarısız olursa None döndürür:

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)

Pratikte alıcıya tüm bir paket teslim edilmez – baytlar UART üzerinden birer birer gelir ve paketin ortasında duraklayan bir gönderici (ya da bir bayt kaybeden gürültülü bir hat) basitçe doğru boyutta bir arabelleğe read() ile okunamaz. Bir sonraki bölüm, aynı çözme mantığını bir durum makinesi olarak bayt bayt çalıştırır.

3.20.3. Durum makinesi tabanlı bir alıcı

Alıcı, sabit bir N için yalnızca uart.read(N) çağıramaz – bir sonraki paketin kaç bayt olacağını bilmez ve hattaki herhangi bir çöp hizalamayı bozar. Çözüm, baytları birer birer tüketen ve paketin neresinde olduğuna göre tepki veren küçük bir durum makinesidir. Ana döngü, kaç baytın arabelleğe alındığını görmek için any() ile yoklama yapar, bunları tek bir read() çağrısında boşaltır ve her baytı durum makinesinden geçirir:

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

Her bayt durum makinesini bir adım ilerletir ya da eksiksiz bir paketten, hatalı bir başlık CRC’sinden veya hatalı bir veri CRC’sinden sonra HUNT_FOR_HEADER durumuna geri döner. Hatta başlıkla eşleşmeyen çöp sessizce atılır; bir sonraki geçerli başlık alıcıyı yeniden senkronize eder. Anahtar güvenlik özelliği başlık CRC’sinden gelir: LEN alanı bozulmuşsa, ayrıştırıcı bunu çılgınca yanlış sayıda yük baytı okumaya başlamadan değil, başlık CRC kontrolünden sonra (birkaç bayt) yakalar.

3.20.4. Temel düzeyin ötesi

Yukarıdaki çerçeveleme, bir seri bağlantının hat gürültüsünden kurtulmak için ihtiyaç duyduğu asgari şeydir: başlık sihirli baytları, uzunluk, komut ve iki CRC. Bozulmayı tespit eder ve karışmış baytlardan sonra yeniden senkronize olur, ancak hasarlı paketleri geçirmek yerine onlardan vazgeçer ve göndericiyi, alıcının gerçekte ne duyduğu konusunda habersiz bırakır.

Gerçek dünyadaki seri protokoller bu temel düzeyin üzerine özellikler katmanlandırır. Her gömülü bağlantının bunların hepsine ihtiyacı yoktur – uygulamanın gerçekte gerektirdiğini seçin:

  • Sıra numaraları. Her gönderimde artan küçük bir sayaç. Alıcı boşlukları (bir paket kaybolmuştur), kopyaları (gönderici yeniden iletmiştir ancak alıcı ilk kopyayı zaten kabul etmiştir) ve – kanalın yeniden sıralayabildiği durumlarda – sıra dışı gelişleri tespit eder.

  • Onaylar. Alıcının her paketi onaylamak için geri gönderdiği özel bir ACK paketi (ya da yanıttaki sırtlanmış bir bit). ACK’ler olmadan göndericinin, verisinin ulaştığını bilmesinin hiçbir yolu yoktur.

  • Olumsuz onaylar. Alıcı bir CRC başarısızlığı ya da bir sıra boşluğu gördüğünde gönderilen bir NACK. Gönderici, bir ACK zaman aşımının dolmasını beklemek yerine hemen yeniden iletir.

  • Yeniden iletim. Gönderici, onaylanmamış her paketi küçük bir kuyrukta tutar ve bir zaman aşımından sonra (veya bir NACK üzerine) yeniden gönderir. Bir yeniden deneme sınırı ve denemeler arasında biraz geri çekilme, kalıcı olarak bozuk bir bağlantının sonsuza kadar döngüye girmesini durdurur.

  • Kayan pencereler. Bir ACK gerektirmeden önce uçuşta birkaç pakete izin vermek, gidiş-dönüş süresinin paket başına gönderim süresine kıyasla uzun olduğu bağlantılarda iş hacmini yüksek tutar. Bunun bedeli, daha fazla gönderici tarafı durumudur – uçuşta olan her paket için bir yuva.

  • Akış denetimi. Arabelleği dolmaya başladığında göndericiye yavaşlamasını ya da durmasını söyleyen, alıcıdan gelen bir sinyal. Uygulamalar değişir – açık XON / XOFF baytları, alıcının her seferinde N paket daha lisansladığı kredi tabanlı tahsisler veya telin kendisindeki RTS / CTS donanım hatları. Akış denetimi olmadan hızlı bir gönderici eninde sonunda yavaş bir alıcıyı aşırı yükler ve paketler düşer.

  • Protokol sürümü. Paketin başlarındaki bir sürüm alanı biçimin evrilmesine olanak tanır. Her taraf, başlangıçta her ikisinin de desteklediği en yüksek sürümü müzakere edebilir ya da uyumsuz eşlerden gelen paketleri reddedebilir.

  • Parçalama ve yeniden birleştirme. İki baytlık bir LEN paketi 64 KiB ile sınırlar; bundan büyük mesajlar birden çok pakete bölünür ve diğer tarafta yeniden birleştirilir. Parçalama meta verisi (parça indeksi, toplam sayı ya da bir “daha fazla parça” bayrağı) yükün içinde yer alır.

  • Kalp atışları. “Hâlâ buradayım” diyen küçük, periyodik bir paket. Diğer taraf, kalp atışları durduğunda sessizce askıda kalmak yerine bunu fark eder ve yeniden bağlanır (ya da sesli bir şekilde başarısız olur).

  • Kanallar. Tek bir fiziksel bağlantının birkaç mantıksal akışı – bir kontrol kanalı, bir telemetri kanalı, bir kayıt kanalı – taşıması için başlıktaki bir kanal ya da akış kimliği, yalnızca bu alanla ayırt edilir.

  • Kimlik doğrulama. Yalnızca meşru gönderici ve alıcının bildiği yük ve gizli bir değerden hesaplanan kısa bir etiket. Alıcı, etiketi aldığı baytlardan yeniden hesaplar ve ikisi eşleşmezse paketi reddeder. Bu, hem kurcalamayı (bir saldırgan baytları değiştirmiştir) hem de – bir sıra numarası ya da zaman damgası, etiketin kapsadığı şeyin bir parçasıysa – bir saldırganın gerçek bir paketi telden kaydedip alıcının üzerinde iki kez işlem yapması için daha sonra yeniden gönderdiği yeniden oynatmayı yakalar.

  • Şifreleme. Yük baytlarını paylaşılan gizli bir anahtarla karıştırarak o anahtar olmadan hattı okuyan herkesin yalnızca gürültü görmesini sağlamak. Genellikle yukarıdaki kimlik doğrulama etiketiyle birleştirilir – onsuz, bir saldırgan CRC’yi geçen rastgele çöp besleyebilir ve alıcı saçmalığı çözmeye çalışırken döngülerini boşa harcar.

Endüstriyel ekipman için tipik bir “iyi” protokol; çerçeveleme, çift CRC, sıra numaraları, yeniden iletimli ACK / NACK ve kalp atışlarıyla sonuçlanır. Göz atmaya değer gerçek dünya örnekleri: MAVLink (drone telemetrisi; sıra numaraları, sistem / bileşen kimlikleri ve isteğe bağlı paket imzalarıyla), Modbus (endüstriyel PLC’ler; işlev kodları ve CRC ile) ve NMEA 0183 (her tüketici GPS alıcısının konuştuğu ASCII protokolü – bir yıldız ayracından sonra sağlama toplamı bulunan satır tabanlı mesajlar).