3.20. Protokol serial, framing, dan CRC

UART dalam kode memindahkan byte antara dua ujung. Dengan sendirinya, itu tidak cukup untuk membangun tautan yang andal. Tiga masalah muncul begitu perangkat nyata berada di ujung lain kabel:

  • Di mana pesan dimulai dan berakhir? Byte tiba dalam aliran tanpa pembatas bawaan. Jika penerima melewatkan byte pertama (dinyalakan setelah pengirim; gangguan listrik singkat pada jalur), setiap byte setelahnya akan bergeser satu byte hingga penerima menemukan titik resinkronisasi baru.

  • Berapa panjang setiap pesan? Pembacaan sensor 32-byte dan balasan status 4-byte terlihat identik di tingkat byte. Penerima membutuhkan cara untuk mengetahui berapa banyak byte yang termasuk dalam pesan saat ini.

  • Apakah byte-byte tersebut tiba dengan utuh? Gangguan dapat membalik bit individual. Tanpa pemeriksaan, penerima dengan senang hati bertindak atas data yang rusak.

Jawaban standar untuk ketiganya adalah membungkus data dalam bingkai paket: urutan byte yang dikenal di awal, field panjang, payload itu sendiri, dan checksum di akhir.

3.20.1. Framing paket

Format framing yang umum:

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.

Paket berframe dengan CRC header dan data yang terpisah: header (magic bytes), perintah, panjang, CRC header, payload, CRC data.

Setiap field melakukan satu pekerjaan:

  • Header (magic bytes). Urutan byte tetap yang tidak biasa -- sering dua byte seperti 0xAA 0x55 -- yang dipindai oleh penerima dalam aliran masuk. Ketika urutan ditemukan, penerima mengetahui bahwa paket baru sedang dimulai dan dapat membuang semua sampah yang datang sebelumnya.

  • Command. Satu byte yang menyatakan apa paket itu. Nilai command yang berbeda menggunakan format payload yang berbeda -- satu command mungkin berarti "atur sudut servo" dengan dua byte payload, yang lain mungkin berarti "baca sensor" tanpa payload, yang lain mungkin merupakan "pesan log" dengan string. Penerima mendispatch pada byte command untuk mengetahui cara menafsirkan sisa paket.

  • Length. Dua byte yang memberikan ukuran payload dalam byte (little-endian di sini), memungkinkan payload hingga sekitar 64 KiB. Penerima membaca tepat sebanyak byte ini setelah CRC header diverifikasi.

  • Header CRC. Checksum satu byte atas field HEADER, CMD, dan LEN. Penerima memeriksanya sebelum membaca payload apa pun, sehingga LEN yang rusak tertangkap setelah hanya beberapa byte (lihat bagian CRC di bawah untuk alasan mengapa ini penting).

  • Payload. Data aplikasi spesifik command, tepat LEN byte panjangnya. Formatnya ditentukan oleh byte command: record yang dikemas dengan struct dari field lebar tetap, string, memori mentah -- apapun yang disepakati kedua pihak untuk command tersebut.

  • Data CRC. CRC empat byte atas byte payload. Penerima menghitung ulang dari byte yang baru dibaca dan membuang paket jika tidak cocok.

3.20.2. CRC

"Checksum" paling sederhana adalah jumlah semua byte, modulo 256 atau 65536. Ini menangkap sebagian besar pembalikan satu bit tetapi melewatkan banyak kesalahan multi-bit dan mengabaikan pengurutan byte.

Cyclic redundancy check (CRC) adalah peningkatan standar. Ini memperlakukan input sebagai satu bilangan biner panjang dan membaginya (dengan cara khusus) dengan polinomial tetap; sisa pembagian adalah CRC. Polinomial yang berbeda menangkap kelas kesalahan yang berbeda; polinomial 8-, 16-, dan 32-bit umum masing-masing menangkap setiap ledakan kesalahan yang lebih pendek dari lebarnya ditambah sebagian besar ledakan yang lebih panjang.

3.20.2.1. Mengapa dua CRC

Diagram paket di atas membawa dua CRC terpisah -- satu atas header (HEADER, CMD, LEN) dan satu atas payload. Inilah yang sebenarnya dibutuhkan framing yang kuat, karena bagaimana satu CRC di akhir gagal ketika field LEN itu sendiri rusak dalam transit:

  • Penerima bertindak atas LEN yang rusak dan membaca sebanyak byte itu dari kabel -- kemungkinan jauh lebih banyak dari yang dimaksudkan pengirim.

  • Hanya CRC di akhir yang pada akhirnya memberi tahu penerima bahwa ada yang salah, dan hanya setelah semua byte tersebut telah dikonsumsi.

  • Sementara parser terjebak menunggu jumlah byte yang salah, paket nyata yang tiba di belakang paket yang rusak tertelan sebagai payload, dan penerima kehilangan beberapa paket alih-alih hanya satu.

Memisahkan CRC memperbaiki ini:

  • Header CRC mencakup HEADER, CMD, dan LEN. Penerima memeriksanya sebelum membaca payload apa pun, sehingga LEN yang rusak tertangkap setelah beberapa byte dan parser segera mengsinkronisasi ulang, hanya mengambil satu paket buruk.

  • Data CRC mencakup payload. Setelah header CRC lulus, penerima mengetahui bahwa ia dapat mempercayai LEN, membaca tepat sebanyak byte payload, dan memverifikasinya terhadap data CRC.

Ukuran umum -- dan yang digunakan halaman ini -- adalah satu byte untuk header CRC (CRC-8 sudah cukup untuk header lima byte) dan empat byte untuk data CRC (CRC-32 mencakup banyak kilobyte payload dengan tingkat tabrakan yang sangat rendah).

3.20.2.2. Fungsi pembantu

MicroPython menyertakan binascii.crc32() untuk CRC empat byte secara langsung. Untuk header CRC satu byte, fungsi pembantu kecil menggunakan polinomial yang digunakan perangkat 1-wire Maxim (0x8C dalam bentuk tercermin) cukup pendek untuk ditulis secara 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

Encoder lengkap menggabungkan dua CRC dalam satu fungsi:

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)

Fungsi invers memulihkan command dan payload dari paket lengkap, atau mengembalikan None jika salah satu pemeriksaan CRC gagal:

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)

Dalam praktiknya penerima tidak mendapatkan seluruh paket yang diserahkan kepadanya -- byte tiba satu per satu melalui UART, dan pengirim yang berhenti di tengah paket (atau jalur yang berisik yang kehilangan satu byte) tidak bisa begitu saja di-read() ke dalam buffer berukuran tepat. Bagian selanjutnya menjalankan logika dekode yang sama byte per byte sebagai state machine.

3.20.3. Penerima state machine

Penerima tidak bisa hanya memanggil uart.read(N) untuk N tetap tertentu -- ia tidak tahu berapa banyak byte yang akan ada pada paket berikutnya, dan setiap sampah pada jalur membuang penyelarasan. Solusinya adalah state machine kecil yang mengonsumsi byte satu per satu dan bereaksi berdasarkan posisinya dalam paket. Loop utama melakukan polling any() untuk melihat berapa banyak byte yang ada dalam buffer, menguras semuanya dalam satu panggilan read(), dan mengumpankan setiap byte melalui state machine:

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

Setiap byte memajukan state machine satu langkah, atau kembali ke HUNT_FOR_HEADER setelah paket lengkap, header CRC yang buruk, atau data CRC yang buruk. Sampah pada jalur yang tidak cocok dengan header dibuang secara diam-diam; header valid berikutnya mensinkronisasi ulang penerima. Properti keamanan utama berasal dari header CRC: jika field LEN rusak, parser menangkapnya setelah pemeriksaan header-CRC (beberapa byte), bukan setelah berkomitmen untuk membaca jumlah byte payload yang sangat salah.

3.20.4. Melampaui baseline

Framing di atas adalah minimum yang dibutuhkan tautan serial untuk pulih dari gangguan jalur: header magic, panjang, command, dan dua CRC. Ini mendeteksi korupsi dan mensinkronisasi ulang setelah byte yang rusak, tetapi menyerah pada paket yang rusak daripada membuatnya lewat, dan meninggalkan pengirim tanpa mengetahui apa yang sebenarnya didengar penerima.

Protokol serial dunia nyata melapisi fitur di atas baseline tersebut. Tidak setiap tautan tertanam membutuhkan semuanya -- pilih apa yang sebenarnya dibutuhkan aplikasi:

  • Sequence numbers. Penghitung kecil yang bertambah pada setiap pengiriman. Penerima mendeteksi kesenjangan (paket hilang), duplikat (pengirim mengirim ulang tetapi penerima sudah menerima salinan pertama), dan -- di mana saluran dapat menyusun ulang -- kedatangan tidak berurutan.

  • Acknowledgements. Paket ACK khusus (atau bit piggyback dalam balasan) yang dikirim penerima kembali untuk mengonfirmasi setiap paket. Tanpa ACK pengirim tidak memiliki cara untuk mengetahui bahwa datanya tiba.

  • Negative acknowledgements. NACK yang dikirim ketika penerima melihat kegagalan CRC atau kesenjangan urutan. Pengirim segera mengirim ulang, alih-alih menunggu timeout ACK untuk aktif.

  • Retransmission. Pengirim menyimpan setiap paket yang belum diakui dalam antrian kecil dan mengirimnya ulang setelah timeout (atau pada NACK). Batas percobaan ulang dan beberapa backoff antara percobaan ulang mencegah tautan yang rusak permanen dari looping selamanya.

  • Sliding windows. Mengizinkan beberapa paket dalam penerbangan sebelum membutuhkan ACK menjaga throughput tetap tinggi pada tautan di mana round-trip panjang dibandingkan dengan waktu pengiriman per paket. Biayanya adalah lebih banyak state sisi pengirim -- satu slot per paket dalam penerbangan.

  • Flow control. Sinyal dari penerima yang memberi tahu pengirim untuk memperlambat atau berhenti sejenak ketika buffernya mulai penuh. Implementasi bervariasi -- byte XON / XOFF eksplisit, grant berbasis kredit di mana penerima melisensikan N paket lagi sekaligus, atau jalur hardware RTS / CTS pada kabel itu sendiri. Tanpa flow control pengirim yang cepat akhirnya mengalahkan penerima yang lambat dan paket dijatuhkan.

  • Protocol version. Field versi di awal paket memungkinkan format untuk berkembang. Setiap pihak dapat bernegosiasi versi tertinggi yang keduanya mendukung saat startup, atau menolak paket dari peer yang tidak kompatibel.

  • Fragmentation and reassembly. LEN dua byte membatasi paket pada 64 KiB; pesan yang lebih besar dari itu dipecah menjadi beberapa paket dan disusun kembali di sisi lain. Metadata fragmentasi (indeks fragmen, total hitungan, atau flag "more fragments") berada di dalam payload.

  • Heartbeats. Paket periodik kecil yang mengatakan "Saya masih di sini". Sisi lain memperhatikan ketika heartbeat berhenti dan menghubungkan kembali (atau gagal dengan keras) alih-alih menggantung secara diam-diam.

  • Channels. ID saluran atau aliran dalam header sehingga satu tautan fisik membawa beberapa aliran logis -- saluran kontrol, saluran telemetri, saluran log -- yang dibedakan hanya oleh field tersebut.

  • Authentication. Tag pendek yang dihitung dari payload dan nilai rahasia yang hanya pengirim dan penerima yang sah yang mengetahuinya. Penerima menghitung tag kembali dari byte yang diterimanya dan menolak paket jika keduanya tidak cocok. Ini menangkap baik gangguan (penyerang memodifikasi byte) maupun -- jika nomor urut atau timestamp adalah bagian dari apa yang dicakup tag -- pemutaran ulang, di mana penyerang merekam paket nyata dari kabel dan mengirimnya kembali nanti untuk membuat penerima bertindak dua kali.

  • Encryption. Mengacak byte payload dengan kunci rahasia bersama sehingga siapa pun yang membaca jalur tanpa kunci tersebut hanya melihat noise. Biasanya dikombinasikan dengan tag autentikasi di atas -- tanpanya, penyerang dapat memberikan sampah yang kebetulan lolos CRC dan penerima membuang siklus untuk mencoba mendekripsi omong kosong.

Protokol "baik" yang khas untuk peralatan industri berakhir dengan framing, CRC ganda, sequence numbers, ACK / NACK dengan retransmit, dan heartbeat. Contoh dunia nyata yang layak dilihat: MAVLink (telemetri drone, dengan sequence numbers, ID sistem / komponen, dan tanda tangan paket opsional), Modbus (PLC industri, dengan kode fungsi dan CRC), dan NMEA 0183 (protokol ASCII yang berbicara setiap penerima GPS konsumen -- pesan berbasis baris dengan checksum setelah pembatas bintang).