3.20. 직렬 프로토콜, 프레이밍, 그리고 CRC

코드로 보는 UART 는 두 끝점 사이에서 바이트를 이동시켰습니다. 하지만 그것만으로는 신뢰할 수 있는 링크를 구축하기에 충분하지 않습니다. 실제 장치가 선의 반대편에 연결되는 순간 세 가지 문제가 나타납니다:

  • 메시지는 어디에서 시작하고 끝나는가? 바이트는 내장된 구분자 없이 스트림으로 도착합니다. 수신 측이 첫 번째 바이트를 놓치면(송신 측보다 늦게 전원이 켜졌거나, 선에 잠깐 전기적 결함이 발생한 경우), 수신 측이 새로운 재동기화 지점을 찾을 때까지 그 이후의 모든 바이트는 하나씩 어긋나게 됩니다.

  • 각 메시지의 길이는 얼마인가? 32바이트 센서 판독값과 4바이트 상태 응답은 바이트 수준에서는 동일하게 보입니다. 수신 측은 현재 메시지에 몇 바이트가 속하는지 알 수 있는 방법이 필요합니다.

  • 바이트가 손상 없이 도착했는가? 잡음은 개별 비트를 뒤집을 수 있습니다. 검사가 없으면 수신 측은 손상된 데이터를 그대로 처리해 버립니다.

이 세 가지 모두에 대한 표준적인 해답은 데이터를 패킷 프레임으로 감싸는 것입니다: 시작 부분의 알려진 바이트 시퀀스, 길이 필드, 페이로드 자체, 그리고 끝부분의 체크섬으로 구성됩니다.

3.20.1. 패킷 프레이밍

전형적인 프레이밍 형식:

여섯 개의 필드가 순서대로 그려져 있습니다: 0xAA 0x55로 표시된 2바이트 HEADER, 이 패킷이 전달하는 명령을 선택하는 1바이트 CMD 필드, 페이로드 크기를 나타내는 2바이트 LEN 필드, HEADER와 CMD와 LEN을 포함하는 1바이트 HCRC 필드, 형식이 CMD에 따라 달라지는 LEN 바이트의 가변 길이 PAYLOAD, 그리고 페이로드를 포함하는 4바이트 DCRC 필드입니다.

헤더 CRC와 데이터 CRC가 분리된 프레임 패킷: 헤더(매직 바이트), 명령, 길이, 헤더 CRC, 페이로드, 데이터 CRC.

각 필드는 한 가지 역할을 합니다:

  • 헤더(매직 바이트). 수신 측이 들어오는 스트림에서 찾는, 고정되고 흔하지 않은 바이트 시퀀스로 – 흔히 0xAA 0x55 와 같은 두 바이트입니다. 이 시퀀스를 발견하면 새 패킷이 시작된다는 것을 알게 되며 그 앞에 있던 잡동사니를 모두 버릴 수 있습니다.

  • 명령. 패킷이 무엇 인지를 나타내는 단일 바이트입니다. 서로 다른 명령 값은 서로 다른 페이로드 형식을 사용합니다 – 어떤 명령은 두 페이로드 바이트와 함께 “서보 각도 설정”을 의미할 수 있고, 다른 명령은 페이로드 없이 “센서 읽기”를 의미할 수 있으며, 또 다른 명령은 문자열과 함께 “로그 메시지”를 의미할 수 있습니다. 수신 측은 명령 바이트에 따라 분기하여 패킷의 나머지 부분을 어떻게 해석할지 결정합니다.

  • 길이. 페이로드의 크기를 바이트 단위로 나타내는 두 바이트(여기서는 리틀 엔디언)로, 약 64 KiB까지의 페이로드를 허용합니다. 수신 측은 헤더 CRC가 검증되면 정확히 이 만큼의 바이트를 읽습니다.

  • 헤더 CRC. HEADER, CMD, LEN 필드에 대한 1바이트 체크섬입니다. 수신 측은 어떤 페이로드든 읽기 전에 이를 검사하므로, 손상된 LEN은 단 몇 바이트만으로 잡아낼 수 있습니다(이것이 왜 중요한지는 아래 CRC 섹션을 참조하십시오).

  • 페이로드. 명령에 특화된 애플리케이션 데이터로, 정확히 LEN 바이트 길이입니다. 형식은 명령 바이트에 의해 결정됩니다: 고정 폭 필드의 struct -패킹된 레코드, 문자열, 원시 메모리 등 해당 명령에 대해 양측이 합의한 무엇이든 될 수 있습니다.

  • 데이터 CRC. 페이로드 바이트에 대한 4바이트 CRC입니다. 수신 측은 방금 읽은 바이트로부터 이를 다시 계산하고, 일치하지 않으면 패킷을 버립니다.

3.20.2. CRC

가장 단순한 “체크섬”은 모든 바이트의 합을 256 또는 65536으로 나눈 나머지입니다. 이것은 대부분의 단일 비트 뒤집힘은 잡아내지만 많은 다중 비트 오류를 놓치고 바이트 순서를 무시합니다.

순환 중복 검사 (CRC)는 표준적인 업그레이드입니다. 입력을 하나의 긴 이진수로 취급하여 이를 고정된 다항식 으로 (특별한 방식으로) 나눕니다. 이 나눗셈의 나머지가 CRC입니다. 서로 다른 다항식은 서로 다른 종류의 오류를 잡아냅니다. 일반적인 8비트, 16비트, 32비트 다항식은 각각 자신의 폭보다 짧은 모든 버스트 오류와 더 긴 버스트의 상당 부분을 잡아냅니다.

3.20.2.1. 왜 CRC가 두 개인가

위의 패킷 다이어그램은 개의 분리된 CRC를 담고 있습니다 – 하나는 헤더(HEADER, CMD, LEN)에 대한 것이고 다른 하나는 페이로드에 대한 것입니다. 견고한 프레이밍이 실제로 필요로 하는 것이 바로 이것인데, 이는 LEN 필드 자체가 전송 중에 손상될 때 끝에 붙은 단일 CRC가 어떻게 실패하는지 때문입니다:

  • 수신 측은 손상된 LEN에 따라 동작하여 선에서 그 만큼의 바이트를 읽습니다 – 송신 측이 의도한 것보다 훨씬 많을 수도 있습니다.

  • 끝에 붙은 CRC만이 결국 수신 측에게 무언가 잘못되었음을 알려주며, 그것도 그 모든 바이트가 소비된 후에야 알려줍니다.

  • 파서가 잘못된 수의 바이트를 기다리며 멈춰 있는 동안, 손상된 패킷 뒤에 도착하는 실제 패킷들이 페이로드로 삼켜지고, 수신 측은 단 하나가 아니라 여러 패킷을 잃게 됩니다.

CRC를 분리하면 이 문제가 해결됩니다:

  • 헤더 CRC 는 HEADER, CMD, LEN을 포함합니다. 수신 측은 어떤 페이로드든 읽기 전에 이를 검사하므로, 손상된 LEN은 몇 바이트만으로 잡아낼 수 있고 파서는 즉시 재동기화하여 그 하나의 잘못된 패킷만 잃게 됩니다.

  • 데이터 CRC 는 페이로드를 포함합니다. 헤더 CRC가 통과하고 나면 수신 측은 LEN을 신뢰할 수 있음을 알고, 정확히 그 만큼의 페이로드 바이트를 읽어 데이터 CRC와 대조하여 검증합니다.

일반적인 크기 – 그리고 이 페이지에서 사용하는 것 – 는 헤더 CRC에 1바이트(5바이트 헤더에는 CRC-8로 충분합니다), 데이터 CRC에 4바이트(CRC-32는 극히 낮은 충돌률로 수 킬로바이트의 페이로드를 포함합니다)입니다.

3.20.2.2. 헬퍼

MicroPython은 4바이트 CRC를 위해 binascii.crc32() 를 직접 제공합니다. 1바이트 헤더 CRC의 경우, Maxim의 1-wire 장치가 사용하는 다항식(반사된 형태로 0x8C)을 이용한 작은 헬퍼는 인라인으로 작성할 수 있을 만큼 짧습니다:

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

완전한 인코더는 두 CRC를 하나의 함수에 결합합니다:

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)

역함수는 완전한 패킷에서 명령과 페이로드를 복구하거나, 두 CRC 검사 중 하나라도 실패하면 None 을 반환합니다:

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)

실제로 수신 측은 패킷 전체를 한꺼번에 받지 않습니다 – 바이트는 UART를 통해 하나씩 도착하며, 패킷 중간에 멈추는 송신 측(또는 바이트를 잃는 잡음 있는 선)은 적절한 크기의 버퍼로 그냥 read() 될 수 없습니다. 다음 섹션에서는 동일한 디코드 로직을 상태 기계로 한 바이트씩 실행합니다.

3.20.3. 상태 기계 수신기

수신 측은 어떤 고정된 N 에 대해 단순히 uart.read(N) 을 호출할 수 없습니다 – 다음 패킷이 몇 바이트가 될지 알 수 없으며, 선에 있는 어떤 잡동사니든 정렬을 어긋나게 합니다. 해결책은 바이트를 하나씩 소비하고 패킷 내에서 자신이 어디에 있는지에 따라 반응하는 작은 상태 기계입니다. 메인 루프는 any() 를 폴링하여 몇 바이트가 버퍼링되어 있는지 확인하고, 단 한 번의 read() 호출로 이를 비운 다음, 각 바이트를 상태 기계에 공급합니다:

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

각 바이트는 상태 기계를 한 단계 전진시키거나, 완전한 패킷이나 잘못된 헤더 CRC, 또는 잘못된 데이터 CRC 이후에 HUNT_FOR_HEADER 로 되돌아갑니다. 헤더와 일치하지 않는 선상의 잡동사니는 조용히 폐기됩니다. 다음 유효한 헤더가 수신기를 재동기화합니다. 핵심 안전성은 헤더 CRC에서 나옵니다: LEN 필드가 손상되면 파서는 헤더 CRC 검사 이후(몇 바이트 만에)에 이를 잡아내며, 터무니없이 잘못된 수의 페이로드 바이트를 읽기로 약속한 이후가 아닙니다.

3.20.4. 기본을 넘어서

위의 프레이밍은 직렬 링크가 선 잡음으로부터 복구하기 위해 필요한 최소한 입니다: 헤더 매직, 길이, 명령, 그리고 두 개의 CRC. 이는 손상을 감지하고 깨진 바이트 이후에 재동기화하지만, 손상된 패킷을 통과시키기보다는 포기해 버리며, 송신 측에게는 수신 측이 실제로 무엇을 들었는지 전혀 알 수 없게 둡니다.

실제 직렬 프로토콜은 그 기본 위에 기능들을 계층화합니다. 모든 임베디드 링크가 그 모두를 필요로 하는 것은 아닙니다 – 애플리케이션이 실제로 요구하는 것을 골라 쓰십시오:

  • 시퀀스 번호. 송신할 때마다 증가하는 작은 카운터입니다. 수신 측은 간격(패킷 손실), 중복(송신 측이 재전송했지만 수신 측이 이미 첫 사본을 받아들인 경우), 그리고 – 채널이 순서를 바꿀 수 있는 경우 – 순서가 뒤바뀐 도착을 감지합니다.

  • 확인 응답(ACK). 수신 측이 각 패킷을 확인하기 위해 되돌려 보내는 전용 ACK 패킷(또는 응답에 함께 실린 비트)입니다. ACK가 없으면 송신 측은 자신의 데이터가 도착했는지 알 방법이 없습니다.

  • 부정 확인 응답(NACK). 수신 측이 CRC 실패나 시퀀스 간격을 발견했을 때 보내는 NACK입니다. 송신 측은 ACK 타임아웃이 발동하기를 기다리지 않고 즉시 재전송합니다.

  • 재전송. 송신 측은 확인되지 않은 각 패킷을 작은 큐에 유지하고 타임아웃 후(또는 NACK를 받으면) 다시 보냅니다. 재시도 한도와 재시도 사이의 약간의 백오프는 영구적으로 끊긴 링크가 영원히 반복되는 것을 막아 줍니다.

  • 슬라이딩 윈도우. ACK를 요구하기 전에 여러 패킷이 전송 중일 수 있도록 허용하면, 왕복 시간이 패킷당 송신 시간에 비해 긴 링크에서 처리량을 높게 유지합니다. 그 대가는 더 많은 송신 측 상태입니다 – 전송 중인 패킷마다 슬롯 하나씩.

  • 흐름 제어. 수신 측의 버퍼가 가득 차고 있을 때 송신 측에게 속도를 늦추거나 멈추라고 알리는 신호입니다. 구현은 다양합니다 – 명시적인 XON / XOFF 바이트, 수신 측이 한 번에 N개의 패킷을 추가로 허가하는 크레딧 기반 허가, 또는 선 자체의 RTS / CTS 하드웨어 라인 등이 있습니다. 흐름 제어가 없으면 빠른 송신 측이 결국 느린 수신 측을 압도하고 패킷이 버려집니다.

  • 프로토콜 버전. 패킷 앞부분의 버전 필드는 형식이 진화할 수 있게 해 줍니다. 각 측은 시작 시 양측이 모두 지원하는 가장 높은 버전을 협상하거나, 호환되지 않는 피어로부터의 패킷을 거부할 수 있습니다.

  • 단편화 및 재조립. 2바이트 LEN은 패킷을 64 KiB로 제한합니다. 그보다 큰 메시지는 여러 패킷으로 나뉘어 반대편에서 재조립됩니다. 단편화 메타데이터(단편 인덱스, 총 개수, 또는 “단편이 더 있음” 플래그)는 페이로드 안에 들어 있습니다.

  • 하트비트. “나 아직 여기 있어”라고 말하는 작은 주기적 패킷입니다. 반대편은 하트비트가 멈추면 이를 알아채고 조용히 멈춰 있는 대신 재연결합니다(또는 요란하게 실패합니다).

  • 채널. 헤더의 채널 또는 스트림 ID로, 하나의 물리적 링크가 여러 논리적 스트림을 운반하게 합니다 – 제어 채널, 텔레메트리 채널, 로그 채널 등을 그 필드만으로 구별합니다.

  • 인증. 페이로드와, 정당한 송신 측과 수신 측만 아는 비밀 값으로부터 계산된 짧은 태그입니다. 수신 측은 자신이 받은 바이트로부터 태그를 다시 계산하고, 둘이 일치하지 않으면 패킷을 거부합니다. 이는 변조(공격자가 바이트를 수정함)와 – 시퀀스 번호나 타임스탬프가 태그가 포함하는 대상의 일부인 경우 – 재생 공격을 모두 잡아냅니다. 재생 공격이란 공격자가 선에서 실제 패킷을 녹화해 두었다가 나중에 다시 보내어 수신 측이 그것을 두 번 처리하도록 만드는 것입니다.

  • 암호화. 공유 비밀 키로 페이로드 바이트를 뒤섞어, 그 키 없이 선을 읽는 누구든 잡음만 보게 합니다. 보통 위의 인증 태그와 결합됩니다 – 그것이 없으면 공격자는 우연히 CRC를 통과하는 쓰레기 값을 흘려보낼 수 있고, 수신 측은 의미 없는 데이터를 해독하려 애쓰며 사이클을 낭비합니다.

산업용 장비를 위한 전형적인 “좋은” 프로토콜은 결국 프레이밍, 이중 CRC, 시퀀스 번호, 재전송이 있는 ACK / NACK, 그리고 하트비트를 갖추게 됩니다. 살펴볼 만한 실제 사례들: MAVLink (드론 텔레메트리로, 시퀀스 번호, 시스템 / 컴포넌트 ID, 그리고 선택적 패킷 서명을 갖춤), Modbus (산업용 PLC로, 기능 코드와 CRC를 갖춤), 그리고 NMEA 0183 (모든 소비자용 GPS 수신기가 사용하는 ASCII 프로토콜로 – 별표 구분자 뒤에 체크섬이 붙는 줄 기반 메시지).