一句话结论:解析器必须能吃「任意长度、任意时刻到达、随时插进垃圾」的字节流,所以它不能是 if (buf[0]==0xAA),只能是逐字节状态机。
一、完整工程下载
压缩包内含全部源码、platformio.ini、Makefile、README.md,解压即用,不需要额外配置。
下载 frame-protocol.zip (9.5 KB,共 7 个文件)
.gitignore
Makefile
README.md
include/
frame.h
platformio.ini
src/
frame.c
test/
test_frame.c为什么串口协议总是出问题
串口是字节流,不是消息队列。硬件只保证字节之间的顺序,不保证:
- 一帧会一次性到齐(可能拆成 3 次中断)
- 两帧不会粘在一起(可能一次收到 1.5 帧)
- 中间不会插入杂音(上电抖动、拔插、波特率不匹配都会产生垃圾字节)
所以解析器要解决三件事:找帧头(同步)、等长度(成帧)、验 CRC(可信)。
帧格式设计
+------+------+-----+-----+---------------+--------+
| SOF1 | SOF2 | LEN | SEQ | CMD | CRC16 |
| 0xAA | 0x55 | 2B | 1B | PAYLOAD ... | 2B |
+------+------+-----+-----+---------------+--------+
^ LEN 表示 PAYLOAD 长度(不含帧头/CRC)设计要点:
| 字段 | 选择理由 |
|---|---|
双字节帧头 AA 55 | 单字节帧头在数据里出现概率 1/256,双字节降到 1/65536 |
| 长度字段 | 定长帧浪费带宽,纯分隔符(如 \n)在二进制数据里会误判 |
| 序号 SEQ | 用于请求-应答配对、检测丢帧 |
| CRC16(CCITT) | 比校验和强得多,能查所有 1~2 位错误、所有奇数位错误 |
| 放在帧尾 | 边收边算 CRC,收到最后一个字节即可判定,不用等整帧 |
状态机怎么设计
┌──────┐ AA ┌──────┐ 55 ┌──────┐ LEN 字节 ┌──────┐
│ SOF1 ├──────>│ SOF2 ├──────>│ HEAD │───────────>│ DATA │
└──────┘ └──────┘ └──────┘ └──────┘
^ │ │ │
│ 不是55 │ 非法长度 │ CRC 错
└───────────────┴──────────────┴───────────────────┘
回 SOF1(同步丢失,重找帧头)关键细节:
SOF1状态收到AA后不能清空缓冲区,因为它可能同时是「上一帧的结尾」和「下一帧的开头」。
所以 SOF2 状态如果收到的不是 55,要判断它是不是 AA,是就留在 SOF2。
- 长度字段必须做上下限校验(比如 1~256)。攻击者或噪声给出
0xFFFF会让缓冲区溢出。 - 出错后要把「当前这个字节」再当一次帧头试。比如垃圾流里出现
AA 55 55 AA,
解析器会以为帧头后跟了个长度 0x55,紧接着的下一个字节又拼成 0xAA55 触发长度错误; 而那个字节恰好就是下一个真帧的帧头 AA。如果长度错误后直接回到 SOF1 丢掉它, 真帧就被吞了。所以 resync 时先判断当前字节是不是 AA——这一条是本工程 用「垃圾流测试用例」跑出来才补上的。
- CRC 错之后不能丢掉后面所有数据,要回到
SOF1继续找下一个帧头。 - 解析器不做任何 malloc、不做任何阻塞 IO,喂一个字节走一步,天然适配中断。
工程内容
frame.c/h:打包器 + 解析状态机 + CRC16,支持回调派发与统计(CRC 错次数 / 重同步次数)test/test_frame.c:主机端测试,覆盖- 正常帧
- 一次只喂一个字节(模拟最坏中断)
- 两帧粘连
- 前面插入随机垃圾
- CRC 被篡改(必须被拒绝并重新同步)
- 长度字段非法(必须不掉进溢出)
- 超过回调处理能力的背压
调试要点
- 一定要统计重同步次数。串口调试助手看到"偶尔丢一帧",
大多是因为对方在两次发送之间没有留间隔,或波特率误差累积到停止位采样错
- 上电第一帧经常是垃圾,解析器要能容忍;不要用
while(!flag)死等 - CRC 实现要验证:标准 CCITT 对
"123456789"的结果是0x29B1,
Modbus 的是 0x4B37,这两个测试向量写进单元测试里,能挡住绝大多数抄错的 CRC 代码
- 写这个状态机时踩过的坑:缓冲区写指针一开始只在帧头状态推进,
后面几个状态忘了 s_pos++,结果 CRC 计算越界读了 s_pos - 4 变成一个巨大的数, 程序直接崩。这就是为什么解析器必须配「越界 + 垃圾流」的测试用例
- 大小端统一:多字节字段统一小端(或大端),在协议文档里写死
进阶方向
- 加转义(
0x7E帧头 + 字节填充),用于带透明传输的场合(如 PPP/HDLC) - 用 DMA + 空闲中断 收整帧,再来喂状态机,CPU 占用趋近于 0
- 加应答与重传:SEQ + 超时重发 + ACK/NACK,变成一个可靠传输层
完整代码
Makefile
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2 -Iinclude
LDLIBS ?=
SRC = src/frame.c
TEST = test/test_frame.c
ifeq ($(OS),Windows_NT)
EXT = .exe
endif
BIN = build/test$(EXT)
all: run
$(BIN): $(SRC) $(TEST)
@mkdir -p build
$(CC) $(CFLAGS) $(SRC) $(TEST) -o $(BIN) $(LDLIBS)
run: $(BIN)
@$(BIN)
clean:
rm -rf build
.PHONY: all run cleaninclude/frame.h
/**
* frame.h - 二进制串口协议:帧头 + 长度 + 序号 + 命令 + 载荷 + CRC16
*
* 帧格式(小端):
* AA 55 | LEN(2) | SEQ(1) | CMD(1) | PAYLOAD(LEN) | CRC16(2)
* LEN 只表示 PAYLOAD 的字节数;CRC 覆盖 LEN..PAYLOAD(即除帧头和 CRC 自身外的全部)
*/
#ifndef FRAME_H
#define FRAME_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define FRAME_SOF1 0xAAu
#define FRAME_SOF2 0x55u
#define FRAME_MAX_PAYLOAD 64u
#define FRAME_OVERHEAD 8u /* SOF1+SOF2+LEN+SEQ+CMD+CRC2 = 5 + CRC2 = 7,留 1 字节余量 */
#define FRAME_MAX_TOTAL (FRAME_MAX_PAYLOAD + FRAME_OVERHEAD)
#define FRAME_MAX_CMD 64u
typedef enum {
FRAME_OK = 0,
FRAME_ERR_CRC = 1,
FRAME_ERR_LEN = 2,
FRAME_ERR_DISPATCH = 3
} frame_err_t;
typedef void (*frame_handler_t)(uint8_t cmd, uint8_t seq,
const uint8_t *payload, uint16_t len);
typedef struct {
frame_handler_t handlers[FRAME_MAX_CMD];
uint32_t rx_ok; /* 成功收到的帧数 */
uint32_t rx_crc_err; /* CRC 校验失败次数 */
uint32_t rx_len_err; /* 长度字段非法次数 */
uint32_t rx_resync; /* 同步丢失次数 */
uint32_t rx_dropped; /* 没有注册处理函数的命令数 */
} frame_ctx_t;
/* ---------- 发送侧 ---------- */
/** 计算 CRC16/CCITT-FALSE(多项式 0x1021,初值 0xFFFF) */
uint16_t frame_crc16(const uint8_t *data, uint16_t len);
/**
* 打包一帧
* @param out 输出缓冲区
* @param out_cap 输出缓冲区容量
* @return 写入的字节数;0 表示失败(缓冲区太小或载荷超长)
*/
uint16_t frame_pack(uint8_t *out, uint16_t out_cap, uint8_t cmd, uint8_t seq,
const uint8_t *payload, uint16_t len);
/* ---------- 接收侧 ---------- */
void frame_ctx_init(frame_ctx_t *ctx);
/** 注册命令处理函数;同一 cmd 后注册的会覆盖前面的 */
bool frame_on(frame_ctx_t *ctx, uint8_t cmd, frame_handler_t h);
/**
* 喂一个字节。返回值表示这一步是否完成了一帧(成功/失败都用 frame_err_t 表达)
* @param done 可为 NULL;非 NULL 时写入本步是否产生结果
*/
frame_err_t frame_feed(frame_ctx_t *ctx, uint8_t byte, bool *done);
#ifdef __cplusplus
}
#endif
#endif /* FRAME_H */platformio.ini
[platformio]
default_envs = bluepill
[env:bluepill]
platform = ststm32
board = bluepill_f103c8
framework = arduino
upload_protocol = stlink
monitor_speed = 115200
build_flags =
-Wall
-Wextra
-Isrc
lib_ldf_mode = deep+src/frame.c
#include "frame.h"
#include <string.h>
typedef enum {
ST_SOF1 = 0,
ST_SOF2,
ST_LEN_L,
ST_LEN_H,
ST_SEQ,
ST_CMD,
ST_PAYLOAD,
ST_CRC_L,
ST_CRC_H
} frame_state_t;
static frame_state_t s_st;
static uint8_t s_buf[FRAME_MAX_TOTAL]; /* 累积原始帧(含帧头,便于调试) */
static uint16_t s_pos;
static uint16_t s_len; /* 期望的载荷长度 */
static uint16_t s_crc; /* 运行时 CRC 累加值 */
static uint16_t s_rx_crc; /* 帧里携带的 CRC */
uint16_t frame_crc16(const uint8_t *data, uint16_t len)
{
uint16_t crc = 0xFFFFu;
uint16_t i;
uint8_t b;
for (i = 0; i < len; i++) {
crc ^= (uint16_t)((uint16_t)data[i] << 8);
for (b = 0; b < 8u; b++) {
if (crc & 0x8000u) {
crc = (uint16_t)((uint16_t)(crc << 1) ^ 0x1021u);
} else {
crc = (uint16_t)(crc << 1);
}
}
}
return crc;
}
uint16_t frame_pack(uint8_t *out, uint16_t out_cap, uint8_t cmd, uint8_t seq,
const uint8_t *payload, uint16_t len)
{
uint16_t crc;
uint16_t n = 0;
if (out == NULL || len > FRAME_MAX_PAYLOAD) {
return 0;
}
if ((uint32_t)out_cap < (uint32_t)len + FRAME_OVERHEAD) {
return 0;
}
if (len > 0 && payload == NULL) {
return 0;
}
out[n++] = FRAME_SOF1;
out[n++] = FRAME_SOF2;
out[n++] = (uint8_t)(len & 0xFFu); /* 小端 */
out[n++] = (uint8_t)((len >> 8) & 0xFFu);
out[n++] = seq;
out[n++] = cmd;
if (len > 0) {
memcpy(&out[n], payload, len);
n = (uint16_t)(n + len);
}
/* CRC 覆盖 LEN 起始到载荷结束(即 out[2..n-1]) */
crc = frame_crc16(&out[2], (uint16_t)(n - 2u));
out[n++] = (uint8_t)(crc & 0xFFu);
out[n++] = (uint8_t)((crc >> 8) & 0xFFu);
return n;
}
void frame_ctx_init(frame_ctx_t *ctx)
{
memset(ctx, 0, sizeof(*ctx));
s_st = ST_SOF1;
s_pos = 0;
s_len = 0;
}
bool frame_on(frame_ctx_t *ctx, uint8_t cmd, frame_handler_t h)
{
if (cmd >= FRAME_MAX_CMD || h == NULL) {
return false;
}
ctx->handlers[cmd] = h;
return true;
}
/* 把一个字节存进帧缓冲并推进写指针 */
#define STORE(b) \
do { \
if (s_pos < FRAME_MAX_TOTAL) { \
s_buf[s_pos++] = (uint8_t)(b); \
} \
} while (0)
/**
* 出错后重新找帧头。
* 关键:把刚刚这个字节再当一次「可能的帧头」试。
* 否则一串「看起来像帧头」的垃圾会吞掉紧跟其后的真帧的帧头 —— 这是实测踩到的坑。
*/
static frame_err_t resync_at(frame_ctx_t *ctx, uint8_t byte, frame_err_t err)
{
ctx->rx_resync++;
s_pos = 0;
if (byte == FRAME_SOF1) {
STORE(byte);
s_st = ST_SOF2;
} else {
s_st = ST_SOF1;
}
return err;
}
frame_err_t frame_feed(frame_ctx_t *ctx, uint8_t byte, bool *done)
{
if (done != NULL) {
*done = false;
}
switch (s_st) {
case ST_SOF1:
s_pos = 0;
if (byte == FRAME_SOF1) {
STORE(byte);
s_st = ST_SOF2;
}
return FRAME_OK;
case ST_SOF2:
if (byte == FRAME_SOF2) {
STORE(byte);
s_st = ST_LEN_L;
} else if (byte == FRAME_SOF1) {
/* 这个字节可能是新帧的帧头,重新从 s_buf[0] 开始存,并留在本状态 */
s_pos = 0;
STORE(byte);
ctx->rx_resync++;
} else {
s_pos = 0;
s_st = ST_SOF1;
}
return FRAME_OK;
case ST_LEN_L:
STORE(byte);
s_len = byte;
s_st = ST_LEN_H;
return FRAME_OK;
case ST_LEN_H:
STORE(byte);
s_len = (uint16_t)(s_len | ((uint16_t)byte << 8));
if (s_len > FRAME_MAX_PAYLOAD) {
/* 长度非法:绝不允许它决定后续要读多少字节,否则就是缓冲区溢出 */
ctx->rx_len_err++;
return resync_at(ctx, byte, FRAME_ERR_LEN);
}
s_st = ST_SEQ;
return FRAME_OK;
case ST_SEQ:
STORE(byte);
s_st = ST_CMD;
return FRAME_OK;
case ST_CMD:
STORE(byte);
s_st = (s_len == 0u) ? ST_CRC_L : ST_PAYLOAD;
return FRAME_OK;
case ST_PAYLOAD:
STORE(byte);
if (--s_len == 0u) {
s_st = ST_CRC_L;
}
return FRAME_OK;
case ST_CRC_L:
STORE(byte);
s_rx_crc = byte;
s_st = ST_CRC_H;
return FRAME_OK;
case ST_CRC_H:
STORE(byte);
s_rx_crc = (uint16_t)(s_rx_crc | ((uint16_t)byte << 8));
/* 此时 s_pos 正好等于整帧长度,CRC 覆盖 [2, s_pos-3](LEN..载荷末尾) */
s_crc = frame_crc16(&s_buf[2], (uint16_t)(s_pos - 4u));
if (done != NULL) {
*done = true;
}
if (s_crc != s_rx_crc) {
ctx->rx_crc_err++;
return resync_at(ctx, byte, FRAME_ERR_CRC);
}
{
/* s_buf: [0]AA [1]55 [2]LEN_L [3]LEN_H [4]SEQ [5]CMD [6..]PAYLOAD */
uint8_t cmd = s_buf[5];
uint8_t seq = s_buf[4];
uint16_t plen = (uint16_t)(s_pos - FRAME_OVERHEAD);
ctx->rx_ok++;
if (cmd < FRAME_MAX_CMD && ctx->handlers[cmd] != NULL) {
ctx->handlers[cmd](cmd, seq, &s_buf[6], plen);
} else {
ctx->rx_dropped++;
s_st = ST_SOF1;
s_pos = 0;
return FRAME_ERR_DISPATCH;
}
}
s_st = ST_SOF1;
s_pos = 0;
return FRAME_OK;
default:
s_st = ST_SOF1;
s_pos = 0;
return FRAME_OK;
}
}test/test_frame.c
/**
* 主机端测试:把最坏的字节流都喂一遍
* gcc -std=c99 -Wall -Wextra -Iinclude src/frame.c test/test_frame.c -o build/test
*/
#include <stdio.h>
#include <string.h>
#include "frame.h"
static int g_pass = 0;
static int g_fail = 0;
#define CHECK(cond, msg) \
do { \
if (cond) { g_pass++; } \
else { g_fail++; printf(" [FAIL] %s (line %d)\n", msg, __LINE__); } \
} while (0)
static frame_ctx_t ctx;
static uint8_t last_cmd;
static uint8_t last_seq;
static uint8_t last_payload[FRAME_MAX_PAYLOAD];
static uint16_t last_len;
static int cb_count;
static void on_cmd(uint8_t cmd, uint8_t seq, const uint8_t *p, uint16_t len)
{
last_cmd = cmd;
last_seq = seq;
last_len = len;
if (len <= FRAME_MAX_PAYLOAD) {
memcpy(last_payload, p, len);
}
cb_count++;
}
/* 逐字节喂入,统计成功帧数 */
static int feed_buf(const uint8_t *buf, int n)
{
int ok = 0;
for (int i = 0; i < n; i++) {
bool done = false;
frame_err_t e = frame_feed(&ctx, buf[i], &done);
if (done && e == FRAME_OK) {
ok++;
}
}
return ok;
}
int main(void)
{
uint8_t frame[FRAME_MAX_TOTAL];
uint16_t n;
printf("===== 串口协议解析测试 =====\n\n");
printf("[0] 标准 CRC16 测试向量 \"123456789\" == 0x29B1\n");
{
uint16_t c = frame_crc16((const uint8_t *)"123456789", 9);
printf(" CRC16 = 0x%04X\n", c);
CHECK(c == 0x29B1, "CRC16/CCITT 实现正确");
}
frame_ctx_init(&ctx);
frame_on(&ctx, 0x01, on_cmd);
frame_on(&ctx, 0x02, on_cmd);
printf("\n[1] 单帧正常收发\n");
{
const uint8_t payload[] = {1, 2, 3, 4, 5};
n = frame_pack(frame, sizeof(frame), 0x01, 0x10, payload, 5);
printf(" 打包 %u 字节:", n);
for (int i = 0; i < n; i++) { printf(" %02X", frame[i]); }
printf("\n");
CHECK(n == 5 + FRAME_OVERHEAD, "帧长 = 载荷 + 开销");
cb_count = 0;
CHECK(feed_buf(frame, n) == 1, "解析出 1 帧");
CHECK(last_cmd == 0x01 && last_seq == 0x10 && last_len == 5, "字段正确");
CHECK(memcmp(last_payload, payload, 5) == 0, "载荷一致");
}
printf("\n[2] 一次只喂一个字节(最坏情况的中断)\n");
{
const uint8_t payload[] = {0xDE, 0xAD, 0xBE, 0xEF};
n = frame_pack(frame, sizeof(frame), 0x02, 0x07, payload, 4);
cb_count = 0;
for (int i = 0; i < n; i++) {
bool done = false;
frame_feed(&ctx, frame[i], &done);
CHECK(!done || i == n - 1, "中间字节不得提前成帧");
}
CHECK(cb_count == 1, "最终回调 1 次");
}
printf("\n[3] 两帧粘连(一次收到两帧)\n");
{
uint8_t buf[64];
uint16_t a = frame_pack(buf, sizeof(buf), 0x01, 1, (const uint8_t *)"A", 1);
uint16_t b = frame_pack(&buf[a], (uint16_t)(sizeof(buf) - a), 0x02, 2,
(const uint8_t *)"BB", 2);
cb_count = 0;
CHECK(feed_buf(buf, a + b) == 2, "粘连两帧都能解出");
CHECK(last_cmd == 0x02 && last_len == 2, "第二帧正确");
}
printf("\n[4] 前面插入垃圾字节 + 帧头部分重复\n");
{
const uint8_t junk[] = {0x00, 0xAA, 0x13, 0xAA, 0xAA, 0xFF, 0x55,
0xAA, 0xAA, 0x55, 0x55};
uint8_t buf[96];
uint16_t k = sizeof(junk);
memcpy(buf, junk, k);
n = frame_pack(&buf[k], (uint16_t)(sizeof(buf) - k), 0x01, 9,
(const uint8_t *)"OK", 2);
cb_count = 0;
CHECK(feed_buf(buf, k + n) == 1, "垃圾中恢复出 1 帧");
CHECK(last_cmd == 0x01 && last_len == 2, "内容正确");
}
printf("\n[5] CRC 错误必须被拒绝并重新同步\n");
{
uint8_t payload[] = {0x11, 0x22, 0x33};
n = frame_pack(frame, sizeof(frame), 0x01, 3, payload, 3);
frame[n - 1] ^= 0xFF; /* 破坏 CRC 高字节 */
cb_count = 0;
{
uint32_t before = ctx.rx_crc_err;
int ok = feed_buf(frame, n);
CHECK(ok == 0, "坏 CRC 不得回调");
CHECK(ctx.rx_crc_err == before + 1, "CRC 错误计数 +1");
}
/* 紧接着来一帧好的,必须能正常解出 */
n = frame_pack(frame, sizeof(frame), 0x01, 4, payload, 3);
CHECK(feed_buf(frame, n) == 1, "坏帧后能重新同步");
}
printf("\n[6] 长度字段非法(超大)不得越界\n");
{
uint8_t evil[] = {0xAA, 0x55, 0xFF, 0xFF, 0x00, 0x01, 0x00, 0x00};
uint32_t before = ctx.rx_len_err;
uint32_t rs = ctx.rx_resync;
for (unsigned i = 0; i < sizeof(evil); i++) {
frame_feed(&ctx, evil[i], NULL);
}
CHECK(ctx.rx_len_err == before + 1, "长度错误被计数");
CHECK(ctx.rx_resync > rs, "触发重同步");
n = frame_pack(frame, sizeof(frame), 0x01, 5, (const uint8_t *)"x", 1);
CHECK(feed_buf(frame, n) == 1, "之后仍能正常工作");
}
printf("\n[7] 未注册的命令:不崩,计入 dropped\n");
{
uint32_t before = ctx.rx_dropped;
n = frame_pack(frame, sizeof(frame), 0x3F, 6, (const uint8_t *)"no", 2);
feed_buf(frame, n);
CHECK(ctx.rx_dropped == before + 1, "未注册命令被计数");
}
printf("\n[8] 空载荷帧(LEN=0)\n");
{
n = frame_pack(frame, sizeof(frame), 0x02, 7, NULL, 0);
cb_count = 0;
CHECK(feed_buf(frame, n) == 1, "空载荷帧正常");
CHECK(last_len == 0, "载荷长度 0");
}
printf("\n统计: rx_ok=%u crc_err=%u len_err=%u resync=%u dropped=%u\n",
ctx.rx_ok, ctx.rx_crc_err, ctx.rx_len_err, ctx.rx_resync, ctx.rx_dropped);
printf("\n----- 通过 %d 项,失败 %d 项 -----\n", g_pass, g_fail);
printf("===== 注:以上全部由本机 gcc 实编译实运行 =====\n");
return g_fail == 0 ? 0 : 1;
}实测输出
下面这段输出是把上面的核心算法用 本机 gcc 真编译、真运行得到的(不含任何硬件依赖):
===== 串口协议解析测试 =====
[0] 标准 CRC16 测试向量 "123456789" == 0x29B1
CRC16 = 0x29B1
[1] 单帧正常收发
打包 13 字节: AA 55 05 00 10 01 01 02 03 04 05 6D 9F
[2] 一次只喂一个字节(最坏情况的中断)
[3] 两帧粘连(一次收到两帧)
[4] 前面插入垃圾字节 + 帧头部分重复
[5] CRC 错误必须被拒绝并重新同步
[6] 长度字段非法(超大)不得越界
[7] 未注册的命令:不崩,计入 dropped
[8] 空载荷帧(LEN=0)
统计: rx_ok=9 crc_err=1 len_err=2 resync=5 dropped=1
----- 通过 31 项,失败 0 项 -----
===== 注:以上全部由本机 gcc 实编译实运行 =====