3.20. Giao thức nối tiếp, đóng khung và CRC

UART trong code di chuyển các byte giữa hai đầu. Bản thân điều đó chưa đủ để xây dựng một liên kết đáng tin cậy. Ba vấn đề xuất hiện ngay khi có thiết bị thực ở đầu kia của dây:

  • Tin nhắn bắt đầu và kết thúc ở đâu? Các byte đến dưới dạng luồng không có ký tự phân cách tích hợp sẵn. Nếu bộ nhận bỏ lỡ byte đầu tiên (khởi động sau bên gửi; nhiễu điện ngắn trên đường dây), mọi byte sau đó sẽ bị lệch một vị trí cho đến khi bộ nhận tìm thấy điểm đồng bộ lại mới.

  • Mỗi tin nhắn dài bao nhiêu? Một giá trị đọc cảm biến 32 byte và một phản hồi trạng thái 4 byte trông giống hệt nhau ở cấp độ byte. Bộ nhận cần có cách biết bao nhiêu byte thuộc về tin nhắn hiện tại.

  • Các byte có đến nguyên vẹn không? Nhiễu có thể lật các bit riêng lẻ. Nếu không có kiểm tra, bộ nhận sẽ vô tư xử lý dữ liệu bị hỏng.

Câu trả lời tiêu chuẩn cho cả ba vấn đề là bọc dữ liệu trong một khung gói tin: một chuỗi byte đã biết ở đầu, một trường độ dài, chính dữ liệu tải và một tổng kiểm tra ở cuối.

3.20.1. Đóng khung gói tin

Một định dạng đóng khung điển hình:

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.

Một gói tin có khung với hai CRC riêng cho header và dữ liệu: header (magic byte), lệnh, độ dài, CRC header, tải, CRC dữ liệu.

Mỗi trường thực hiện một nhiệm vụ:

  • Header (magic byte). Một chuỗi byte cố định, bất thường -- thường là hai byte như 0xAA 0x55 -- mà bộ nhận quét trong luồng đến. Khi tìm thấy chuỗi đó, bộ nhận biết rằng một gói tin mới đang bắt đầu và có thể loại bỏ bất kỳ dữ liệu rác nào đến trước.

  • Lệnh. Một byte duy nhất cho biết nội dung của gói tin. Các giá trị lệnh khác nhau sử dụng các định dạng tải khác nhau -- một lệnh có thể có nghĩa là "đặt góc servo" với hai byte tải, lệnh khác có thể là "đọc cảm biến" không có tải, lệnh khác có thể là "ghi log tin nhắn" với một chuỗi. Bộ nhận phân phối theo byte lệnh để biết cách diễn giải phần còn lại của gói tin.

  • Độ dài. Hai byte cho biết kích thước của tải tính bằng byte (little-endian ở đây), cho phép tải lên đến khoảng 64 KiB. Bộ nhận đọc chính xác số byte này sau khi CRC header đã được xác minh.

  • CRC Header. Tổng kiểm tra một byte trên các trường HEADER, CMD và LEN. Bộ nhận kiểm tra nó trước khi đọc bất kỳ tải nào, do đó một LEN bị hỏng sẽ bị phát hiện sau chỉ một vài byte (xem phần CRC bên dưới để hiểu tại sao điều này quan trọng).

  • Tải. Dữ liệu ứng dụng theo từng lệnh cụ thể, chính xác LEN byte. Định dạng được xác định bởi byte lệnh: một bản ghi được đóng gói bởi struct với các trường chiều rộng cố định, một chuỗi, bộ nhớ thô -- bất cứ điều gì cả hai bên đồng ý cho lệnh đó.

  • CRC Dữ liệu. Một CRC bốn byte trên các byte tải. Bộ nhận tính toán lại từ các byte vừa đọc và loại bỏ gói tin nếu không khớp.

3.20.2. CRC

"Tổng kiểm tra" đơn giản nhất là tổng của tất cả các byte, modulo 256 hoặc 65536. Nó bắt được hầu hết các lần lật bit đơn nhưng bỏ lỡ nhiều lỗi nhiều bit và bỏ qua thứ tự byte.

Một kiểm tra dư theo chu kỳ (CRC) là bản nâng cấp tiêu chuẩn. Nó coi đầu vào là một số nhị phân dài và chia nó (theo cách đặc biệt) cho một đa thức cố định; phần dư của phép chia là CRC. Các đa thức khác nhau bắt các lớp lỗi khác nhau; các đa thức 8-, 16- và 32-bit phổ biến đều bắt được mọi chuỗi lỗi ngắn hơn chiều rộng của chúng cộng với một phần lớn các chuỗi dài hơn.

3.20.2.1. Tại sao cần hai CRC

Sơ đồ gói tin ở trên mang hai CRC riêng biệt -- một cho header (HEADER, CMD, LEN) và một cho tải. Đây là điều mà một cơ chế đóng khung mạnh mẽ thực sự cần, vì cách một CRC đuôi duy nhất thất bại khi chính trường LEN bị hỏng trong quá trình truyền:

  • Bộ nhận xử lý theo LEN bị hỏng và đọc số byte đó từ dây -- có thể nhiều hơn nhiều so với bên gửi dự định.

  • Chỉ đến khi CRC đuôi cuối cùng mới cho bộ nhận biết có gì đó sai, và chỉ sau khi tất cả các byte đó đã bị tiêu thụ.

  • Trong khi bộ phân tích bị kẹt chờ số byte sai, các gói tin thực đến sau gói bị hỏng bị nuốt vào tải, và bộ nhận mất nhiều gói tin thay vì chỉ một.

Tách CRC sửa điều này:

  • CRC header bao phủ HEADER, CMD và LEN. Bộ nhận kiểm tra nó trước khi đọc bất kỳ tải nào, do đó một LEN bị hỏng sẽ bị phát hiện sau một vài byte và bộ phân tích tái đồng bộ ngay lập tức, chỉ làm hỏng một gói tin xấu duy nhất.

  • CRC dữ liệu bao phủ tải. Sau khi CRC header đã thông qua, bộ nhận biết rằng nó có thể tin tưởng LEN, đọc chính xác số byte tải đó và xác minh chúng với CRC dữ liệu.

Kích thước phổ biến -- và những gì trang này sử dụng -- là một byte cho CRC header (CRC-8 là quá đủ cho một header năm byte) và bốn byte cho CRC dữ liệu (CRC-32 bao phủ nhiều kilobyte tải với tỷ lệ va chạm cực kỳ thấp).

3.20.2.2. Các hàm hỗ trợ

MicroPython đi kèm binascii.crc32() cho CRC bốn byte trực tiếp. Đối với CRC header một byte, một hàm trợ giúp nhỏ sử dụng đa thức mà các thiết bị 1-wire của Maxim dùng (0x8C ở dạng phản chiếu) ngắn gọn đủ để viết 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

Một bộ mã hóa hoàn chỉnh kết hợp hai CRC trong một hàm:

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)

Hàm nghịch đảo khôi phục lệnh và tải từ một gói tin hoàn chỉnh, hoặc trả về None nếu một trong hai kiểm tra CRC thất bại:

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)

Trong thực tế, bộ nhận không nhận được toàn bộ gói tin cùng lúc -- các byte đến từng cái một qua UART, và một bên gửi tạm dừng giữa chừng (hoặc một đường dây nhiễu mất một byte) không thể chỉ đơn giản là read() vào bộ đệm có kích thước đúng. Phần tiếp theo chạy cùng logic giải mã từng byte bằng một máy trạng thái.

3.20.3. Bộ nhận dùng máy trạng thái

Bộ nhận không thể chỉ gọi uart.read(N) với một N cố định nào đó -- nó không biết gói tin tiếp theo sẽ có bao nhiêu byte, và bất kỳ rác nào trên đường dây sẽ làm mất căn chỉnh. Giải pháp là một máy trạng thái nhỏ tiêu thụ các byte từng cái một và phản ứng dựa trên vị trí trong gói tin. Vòng lặp chính thăm dò any() để xem có bao nhiêu byte được đệm, thoát hết chúng trong một lần gọi read(), và đưa từng byte qua máy trạng thái:

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

Mỗi byte tiến máy trạng thái một bước, hoặc quay lại HUNT_FOR_HEADER sau khi gói tin hoàn chỉnh, CRC header xấu hoặc CRC dữ liệu xấu. Rác trên đường dây không khớp với header sẽ bị loại bỏ âm thầm; header hợp lệ tiếp theo tái đồng bộ bộ nhận. Tính an toàn chính đến từ CRC header: nếu trường LEN bị hỏng, bộ phân tích bắt nó sau khi kiểm tra CRC header (chỉ một vài byte), không phải sau khi cam kết đọc số byte tải sai hoàn toàn.

3.20.4. Ngoài mức cơ bản

Cơ chế đóng khung ở trên là mức tối thiểu một liên kết nối tiếp cần để phục hồi sau nhiễu đường dây: magic header, độ dài, lệnh và hai CRC. Nó phát hiện hỏng hóc và tái đồng bộ sau các byte bị nhiễu, nhưng bỏ cuộc với các gói tin bị hỏng thay vì truyền chúng qua, và bên gửi không biết bộ nhận thực sự đã nhận được gì.

Các giao thức nối tiếp thực tế xếp chồng các tính năng trên nền tảng đó. Không phải mọi liên kết nhúng đều cần tất cả chúng -- hãy chọn những gì ứng dụng thực sự cần:

  • Số thứ tự. Một bộ đếm nhỏ tăng lên sau mỗi lần gửi. Bộ nhận phát hiện khoảng trống (một gói tin bị mất), trùng lặp (bên gửi truyền lại nhưng bộ nhận đã chấp nhận bản đầu tiên), và -- ở nơi kênh có thể sắp xếp lại -- các lần đến không theo thứ tự.

  • Xác nhận. Một gói ACK chuyên dụng (hoặc bit piggyback trong một phản hồi) mà bộ nhận gửi lại để xác nhận từng gói tin. Nếu không có ACK, bên gửi không có cách nào biết dữ liệu của mình đã đến.

  • Xác nhận phủ định. Một NACK được gửi khi bộ nhận phát hiện lỗi CRC hoặc khoảng trống thứ tự. Bên gửi truyền lại ngay lập tức, thay vì chờ ACK timeout kích hoạt.

  • Truyền lại. Bên gửi giữ mỗi gói tin chưa được xác nhận trong một hàng đợi nhỏ và gửi lại sau timeout (hoặc khi nhận NACK). Giới hạn thử lại và khoảng nghỉ giữa các lần thử ngăn một liên kết bị hỏng vĩnh viễn lặp mãi mãi.

  • Cửa sổ trượt. Cho phép nhiều gói tin đang truyền trước khi yêu cầu ACK giúp duy trì thông lượng trên các liên kết có thời gian round-trip dài so với thời gian gửi mỗi gói tin. Chi phí là nhiều trạng thái phía bên gửi hơn -- một slot cho mỗi gói tin đang truyền.

  • Kiểm soát luồng. Một tín hiệu từ bộ nhận yêu cầu bên gửi giảm tốc hoặc tạm dừng khi bộ đệm của nó đang đầy. Các cách triển khai khác nhau -- byte XON / XOFF rõ ràng, cấp phát dựa trên tín dụng nơi bộ nhận cấp phép thêm N gói tin mỗi lần, hoặc các đường phần cứng RTS / CTS trên chính dây. Nếu không có kiểm soát luồng, bên gửi nhanh cuối cùng sẽ vượt quá bộ nhận chậm và các gói tin bị rớt.

  • Phiên bản giao thức. Một trường phiên bản sớm trong gói tin cho phép định dạng phát triển. Mỗi bên có thể thương lượng phiên bản cao nhất mà cả hai hỗ trợ khi khởi động, hoặc từ chối các gói tin từ các đầu nối không tương thích.

  • Phân mảnh và tái lắp ghép. Một LEN hai byte giới hạn gói tin ở 64 KiB; các tin nhắn lớn hơn được chia thành nhiều gói tin và tái lắp ghép ở bên kia. Siêu dữ liệu phân mảnh (chỉ số mảnh, tổng số, hoặc cờ "còn mảnh") nằm bên trong tải.

  • Heartbeat. Một gói tin nhỏ định kỳ nói "Tôi vẫn ở đây". Bên kia nhận thấy khi các heartbeat dừng lại và kết nối lại (hoặc báo lỗi rõ ràng) thay vì treo im lặng.

  • Kênh. Một ID kênh hoặc luồng trong header để một liên kết vật lý mang nhiều luồng logic -- một kênh điều khiển, một kênh đo từ xa, một kênh log -- được phân biệt chỉ bởi trường đó.

  • Xác thực. Một thẻ ngắn được tính từ tải và một giá trị bí mật mà chỉ bên gửi và bên nhận hợp lệ biết. Bộ nhận tính lại thẻ từ các byte nhận được và từ chối gói tin nếu hai thẻ không khớp. Điều này phát hiện cả việc giả mạo (kẻ tấn công đã sửa đổi các byte) và -- nếu số thứ tự hoặc dấu thời gian là một phần của những gì thẻ bao phủ -- replay, nơi kẻ tấn công ghi lại một gói tin thực từ dây và gửi lại sau để bộ nhận xử lý nó hai lần.

  • Mã hóa. Xáo trộn các byte tải bằng một khóa bí mật chia sẻ để bất kỳ ai đọc đường dây mà không có khóa đó chỉ thấy nhiễu. Thường kết hợp với thẻ xác thực ở trên -- nếu không có nó, kẻ tấn công có thể đưa vào rác ngẫu nhiên vẫn vượt qua CRC và bộ nhận lãng phí chu kỳ cố giải mã vô nghĩa.

Một giao thức "tốt" điển hình cho thiết bị công nghiệp kết thúc với đóng khung, CRC kép, số thứ tự, ACK / NACK với truyền lại và heartbeat. Các ví dụ thực tế đáng tham khảo: MAVLink (đo từ xa máy bay không người lái, với số thứ tự, ID hệ thống / thành phần và chữ ký gói tin tùy chọn), Modbus (PLC công nghiệp, với mã hàm và CRC), và NMEA 0183 (giao thức ASCII mà mọi bộ thu GPS tiêu dùng đều nói -- tin nhắn dựa trên dòng với tổng kiểm tra sau ký tự dấu hoa thị).