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>의 함수를 사용할 수 있다.
| 함수 | 변환 |
|---|---|
htons | host → network (16-bit) |
htonl | host → network (32-bit) |
ntohs | network → host (16-bit) |
ntohl | network → 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이다. 같은 프로젝트 안에서도 프로토콜마다 바이트 순서가 다를 수 있다