C 패킷 프로토콜 파서

CEmbeddedProtocol

UART, SPI 등 바이트 스트림에서 구조화된 패킷을 추출하는 것은 임베디드 통신의 기본이다. 상태 머신 기반 파서로 바이트 단위로 처리하면 버퍼 경계와 불완전 수신을 자연스럽게 처리할 수 있다.

패킷 포맷 예시

┌──────┬────┬────────┬─────────────┬───────┐
│ SYNC │ ID │ LENGTH │ PAYLOAD     │ CRC16 │
│ 0xAA │ 1B │  1B    │ LENGTH 바이트│  2B   │
└──────┴────┴────────┴─────────────┴───────┘
  • SYNC: 패킷 시작 표시. 고정값 (0xAA, 0x55 등)
  • ID: 메시지 종류
  • LENGTH: payload 길이
  • PAYLOAD: 가변 길이 데이터
  • CRC16: SYNC부터 PAYLOAD 끝까지의 CRC

상태 머신 파서

바이트가 들어올 때마다 호출한다. 한 번에 전체 패킷이 올 필요가 없다.

#define SYNC_BYTE  0xAA
#define MAX_PAYLOAD 128

typedef enum {
    PS_SYNC,
    PS_ID,
    PS_LENGTH,
    PS_PAYLOAD,
    PS_CRC_LO,
    PS_CRC_HI,
} parser_state_t;

typedef struct {
    parser_state_t state;
    uint8_t id;
    uint8_t length;
    uint8_t payload[MAX_PAYLOAD];
    uint8_t payload_idx;
    uint16_t crc_received;
} parser_t;

typedef void (*packet_handler_t)(uint8_t id, const uint8_t *payload, uint8_t len);

void parser_init(parser_t *p) {
    p->state = PS_SYNC;
}

void parser_feed(parser_t *p, uint8_t byte, packet_handler_t handler) {
    switch (p->state) {
    case PS_SYNC:
        if (byte == SYNC_BYTE) p->state = PS_ID;
        break;

    case PS_ID:
        p->id = byte;
        p->state = PS_LENGTH;
        break;

    case PS_LENGTH:
        if (byte > MAX_PAYLOAD) {
            p->state = PS_SYNC;  // 비정상 길이 → 리셋
            break;
        }
        p->length = byte;
        p->payload_idx = 0;
        p->state = (byte == 0) ? PS_CRC_LO : PS_PAYLOAD;
        break;

    case PS_PAYLOAD:
        p->payload[p->payload_idx++] = byte;
        if (p->payload_idx >= p->length) p->state = PS_CRC_LO;
        break;

    case PS_CRC_LO:
        p->crc_received = byte;
        p->state = PS_CRC_HI;
        break;

    case PS_CRC_HI:
        p->crc_received |= (uint16_t)byte << 8;
        // CRC 검증
        uint16_t crc_calc = crc16_calc(p);
        if (crc_calc == p->crc_received) {
            handler(p->id, p->payload, p->length);
        }
        p->state = PS_SYNC;
        break;
    }
}

UART ISR 연동

static parser_t parser;

void UART_IRQHandler(void) {
    uint8_t byte = UART->RXD;
    parser_feed(&parser, byte, on_packet_received);
}

void on_packet_received(uint8_t id, const uint8_t *payload, uint8_t len) {
    // ISR 컨텍스트이므로 최소한의 처리만
    // 큐에 넣고 태스크에서 처리하는 것이 안전
    memcpy(pkt_buf, payload, len);
    post_event(EV_PACKET, id);
}

Endianness 변환

멀티바이트 값을 바이트 스트림으로 전송할 때 바이트 순서가 문제된다.

0x12345678 (32-bit)

Big-endian (네트워크 바이트 순서):
주소  0x00  0x01  0x02  0x03
값    0x12  0x34  0x56  0x78   ← MSB 먼저

Little-endian (ARM, x86 등):
주소  0x00  0x01  0x02  0x03
값    0x78  0x56  0x34  0x12   ← LSB 먼저

수동 변환

프로토콜 문서에서 바이트 순서가 명시되어 있으면 직접 변환한다.

// Big-endian 버퍼 → uint16_t (호스트)
uint16_t read_be16(const uint8_t *buf) {
    return ((uint16_t)buf[0] << 8) | buf[1];
}

// Big-endian 버퍼 → uint32_t (호스트)
uint32_t read_be32(const uint8_t *buf) {
    return ((uint32_t)buf[0] << 24) |
           ((uint32_t)buf[1] << 16) |
           ((uint32_t)buf[2] << 8)  |
           buf[3];
}

// uint16_t (호스트) → Big-endian 버퍼
void write_be16(uint8_t *buf, uint16_t val) {
    buf[0] = (val >> 8) & 0xFF;
    buf[1] = val & 0xFF;
}

// Little-endian 버퍼 → uint16_t
uint16_t read_le16(const uint8_t *buf) {
    return ((uint16_t)buf[1] << 8) | buf[0];
}

이 방법이 가장 안전하다. 호스트의 endianness에 관계없이 동일하게 동작한다.

표준 함수

POSIX 환경에서는 <arpa/inet.h>의 함수를 사용할 수 있다.

함수변환
htonshost → network (16-bit)
htonlhost → network (32-bit)
ntohsnetwork → host (16-bit)
ntohlnetwork → host (32-bit)

네트워크 바이트 순서는 big-endian이다. 호스트가 이미 big-endian이면 이 함수들은 아무것도 하지 않는다.

Packed 구조체 vs 수동 파싱

Packed 구조체

#pragma pack(push, 1)
typedef struct {
    uint8_t sync;
    uint8_t id;
    uint8_t length;
    uint8_t payload[MAX_PAYLOAD];
    uint16_t crc;
} __attribute__((packed)) packet_raw_t;
#pragma pack(pop)

// 수신 버퍼를 캐스팅
packet_raw_t *pkt = (packet_raw_t *)rx_buffer;
uint8_t id = pkt->id;
uint16_t crc = pkt->crc;  // endianness 문제 가능

문제점:

  • Endianness를 해결하지 않는다. pkt->crc는 호스트 바이트 순서로 해석된다
  • Unaligned access: ARM Cortex-M0에서 정렬되지 않은 주소의 32-bit 접근은 HardFault를 발생시킨다
  • 가변 길이 payload: 구조체로 표현이 어렵다

수동 파싱

uint8_t id     = buf[1];
uint8_t length = buf[2];
uint16_t crc   = read_le16(&buf[3 + length]);

장점:

  • 명시적 endianness 변환
  • 정렬 문제 없음
  • 가변 길이 필드 처리 가능

실무 판단: 고정 크기, 동일 endianness의 간단한 프로토콜에서만 packed 구조체를 사용한다. 그 외에는 수동 파싱이 안전하다.

패킷 빌더

송신 측에서 패킷을 조립한다.

uint16_t packet_build(uint8_t *buf, uint8_t id,
                      const uint8_t *payload, uint8_t len) {
    uint16_t idx = 0;
    buf[idx++] = SYNC_BYTE;
    buf[idx++] = id;
    buf[idx++] = len;
    memcpy(&buf[idx], payload, len);
    idx += len;

    uint16_t crc = crc16(buf, idx);
    write_le16(&buf[idx], crc);
    idx += 2;

    return idx;  // 전체 패킷 길이
}

메모

  • SYNC 바이트로 0xAA(10101010)를 많이 쓰는 이유: UART 라인의 비트 전환이 많아 클럭 동기화에 도움이 된다
  • 파서가 중간 상태에서 타임아웃을 감지해야 한다. 바이트가 끊기면 p->state = PS_SYNC로 리셋한다
  • memcpy로 정렬되지 않은 주소에서 값을 복사하는 것도 안전한 방법이다: memcpy(&val, &buf[offset], sizeof(val))
  • 패킷의 LENGTH 필드 최댓값은 수신 버퍼 크기와 일치해야 한다. 검증 없이 LENGTH만큼 읽으면 버퍼 오버플로우가 발생한다
  • BLE의 ATT 패킷은 little-endian, TCP/IP는 big-endian이다. 같은 프로젝트 안에서도 프로토콜마다 바이트 순서가 다를 수 있다