平台:STM32F103ZET6 @ 72MHz · USART1(PA9=TX / PA10=RX)· 115200-8-N-1 · LED=PB5 低电平点亮 前置知识:USART 中断接收 + 环形缓冲区
一、要解决的问题
printf 重定向解决了"怎么发",中断接收 + 环形缓冲区解决了"怎么收"。但那两课搬运的都只是字节,而真实项目需要的是命令——PC 发一条指令,板子解析、执行、回确认。
核心矛盾在这里:串口是一条无边界字节流。PC 连续发来 12 个字节,串口本身不会告诉你这是"两条 6 字节命令",还是"一条 12 字节命令",还是"一堆垃圾 + 一条命令"。噪声、丢字节、粘包、劈包,在串口世界里都是家常便饭。
所以这个课题分两半:
- 设计帧格式——给字节流加上结构和边界
- 写解析器——从字节流里可靠地"切"出完整帧,且坏帧不能影响好帧
二、帧格式设计
┌──────┬──────┬──────┬─────────────┬──────────┐
│ 0xAA │ 0x55 │ len │ data[len] │ XOR CRC │
└──────┴──────┴──────┴─────────────┴──────────┘
帧头1 帧头2 长度 数据域 校验
| 字段 | 长度 | 作用 | 设计理由 |
|---|---|---|---|
| 帧头 0xAA 0x55 | 2 字节 | 标记帧的起点 | 双字节帧头,随机数据误命中概率降到 1/65536 |
| len | 1 字节 | 数据域长度 | 断帧依据:收满 len 个就知道数据域结束了 |
| 数据域 | len 字节 | 命令码 + 参数 | 业务载荷,本例 [CMD][参数...] |
| CRC | 1 字节 | 异或校验 | 检错(不是纠错),坏帧整帧丢弃 |
开灯帧逐字节拆解:AA 55 02 01 01 02
AA 55:帧头02:数据域长度为 201 01:CMD=0x01(LED 控制),参数=1(亮)02:校验 =02 ^ 01 ^ 01(长度字节也参与异或,这是收发双方的约定)
两种断帧方案对比
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 长度断帧(本例) | 帧头后跟长度,收满即断 | 不依赖发送方时序习惯,帧劈开也能拼回;代价是多一个长度字段 |
| IDLE 空闲断帧 | 总线空闲(HAL_UARTEx_ReceiveToIdle_IT)即一帧结束 |
省掉长度字段;但依赖"发送方一口气发完一帧",劈包后无依据 |
三、运行思路:一台两级节奏的流水线
整台机器是两条流水线 + 一个中转仓库:
PC串口助手(HEX) ──AA 55 02 01 01 02──► CH340 ──► PA10(RX引脚)
│ 每到 1 字节: RXNE→NVIC→中断
▼
┌── 中断上下文【生产者·快进快出】──────────┐
│ RxCpltCallback: │
│ rxbuf[rxHead]=字节; rxHead++; │
│ 接力挂单(下一个字节继续中断收) │
└──────────────┬─────────────────────────────┘
▼
〖环形缓冲区 rxbuf[128]〗←时间解耦的中转仓库
│
┌── 主循环【消费者·10ms 醒一次】─────────────┐
│ rb_read() 批量取 → frame_parse() │
│ 状态机: HDR1→HDR2→LEN→DATA→CRC │
└──────────────┬─────────────────────────────┘
▼ CRC 通过(唯一合法入口)
frame_exec()
┌─────┴──────┐
▼ ▼
PB5 拉低→LED亮 组ACK帧→HAL_UART_Transmit
→PA9(TX)→CH340→PC显示
分层的本质是三种节奏的解耦:
- 字节节奏(86.8µs/字节):115200 波特率 8N1,每字节 10 位。这个节奏由硬件决定,用中断接——轮询要么丢字节要么烧 CPU
- 帧节奏(10ms):主循环每 10ms 醒一次,把仓库里现有字节一次性搬空喂给解析器。这个值就是命令响应延迟上限
- 中间的环形缓冲区:两种节奏差两个数量级,仓库负责削峰填谷。head 只有中断写、tail 只有主循环写——每个变量单一写者,加 volatile 就够,不用关中断
为什么解析不放中断里做?中断必须快进快出——解析器最坏要跑完一整帧状态机,还会调用阻塞发送回 ACK,这些都不能在中断上下文里干。收字节的归中断,动脑子的归主循环。
四、状态机解析器:代码与逐段说明
五个状态就是帧格式五个区段的镜像:
状态 等什么 成功去哪 失败去哪
S_HDR1 0xAA S_HDR2 原地不动(垃圾字节直接无视)
S_HDR2 0x55 S_LEN 见AA留下(可能是真帧头),其他回S_HDR1
S_LEN 1~32 S_DATA 非法长度回S_HDR1(这道闸防住 data[] 溢出)
S_DATA 收满len个 S_CRC 无失败分支(来者不拒)
S_CRC 校验字节 通过→执行 无论过不过都回S_HDR1(丢弃式恢复)
帧执行:合法帧的唯一出口
#define FRAME_HDR1 0xAA // 帧头第1字节:在无边界的字节流里"认出"帧的起跑线
#define FRAME_HDR2 0x55 // 帧头第2字节:双字节帧头,大幅降低随机数据误同步的概率
#define FRAME_MAX 32 // 数据域最大长度:决定 data[] 大小和长度字节的合法上限
// 谁能调用我:只有 frame_parse 的 S_CRC 分支(五关全过才轮到我)
// 参数:p=数据域首地址 len=数据域长度
// 职责:解释数据域含义并执行 + 组确认帧回传(闭环)
void frame_exec(uint8_t *p, uint8_t len)
{
// 数据域约定格式:[命令码][参数...]
// len>=2:至少要有"命令码+参数"两个字节,命令才有意义
if (len >= 2 && p[0] == 0x01) { // CMD=0x01:LED 控制
// 板子 LED 共阳接法(3.3V→LED→510Ω→PB5),低电平才亮
// 参数非0 → 写 RESET(低电平) 点亮;参数0 → 写 SET(高电平) 熄灭
HAL_GPIO_WritePin(led1_GPIO_Port, led1_Pin,
p[1] ? GPIO_PIN_RESET : GPIO_PIN_SET);
// ---- 组确认帧(ACK):把收到的帧原样回传 ----
// 发送方收到一模一样的帧 = "命令已落地"的闭环确认
uint8_t ack[4 + FRAME_MAX]; // 栈上缓冲:4+32=36字节,装得下最长合法帧
ack[0] = FRAME_HDR1; // 帧头1
ack[1] = FRAME_HDR2; // 帧头2
ack[2] = len; // 长度
uint8_t crc = len; // 校验初值=长度字节(与发送方的约定)
for (uint8_t i = 0; i < len; i++) {
ack[3 + i] = p[i]; // 数据域原样拷贝
crc ^= p[i]; // 边拷边异或,一趟干两件事
}
ack[3 + len] = crc; // 校验字节收尾
// 阻塞式发送:这是主循环上下文(不是中断),等得起
// 6字节@115200 约0.5ms发完,100ms 超时只是保险丝
HAL_UART_Transmit(&huart1, ack, 4 + len, 100);
}
// 其他命令码:静默忽略(帧是合法的,只是本机不认识这个命令)
}
状态机主体
void frame_parse(uint8_t *buf, uint16_t n)
{
typedef enum { S_HDR1, S_HDR2, S_LEN, S_DATA, S_CRC } PState;
static PState st = S_HDR1; // static:跨调用保留 → 帧劈成两半也能拼回
static uint8_t data[FRAME_MAX]; // static:半成品帧的暂存区(每次清零就拼不回了)
static uint8_t idx = 0; // 已收数据字节数
static uint8_t len = 0; // 本帧数据域长度
static uint8_t crc = 0; // 边收边算的校验值
for (uint16_t i = 0; i < n; i++) { // 这批字节逐个喂进状态机
uint8_t b = buf[i];
switch (st) {
case S_HDR1: // 状态1:扫帧头第1字节
if (b == FRAME_HDR1) st = S_HDR2; // 见AA → 前进一步
// 不是AA:原地不动(重新同步的第1道闸)
break;
case S_HDR2: // 状态2:等帧头第2字节
if (b == FRAME_HDR2) st = S_LEN; // AA 55 → 帧头成立
else if (b == FRAME_HDR1) st = S_HDR2; // AA AA:这个AA可能是真帧头,
// 留在原地再等一个55
else st = S_HDR1; // 前面那个AA是假帧头,回炉重扫
break;
case S_LEN: // 状态3:长度字节
if (b > 0 && b <= FRAME_MAX) { // 合法区间 1~32(data[] 装得下的范围)
len = b; // 记住本帧数据域长度
idx = 0; // 新帧新起点:计数器清零
crc = b; // 校验初值=长度字节(约定)
st = S_DATA;
}
else st = S_HDR1; // 0或>32 → 非法,丢弃重新同步
break;
case S_DATA: // 状态4:收数据域
data[idx++] = b; // 存入半成品区,计数+1
crc ^= b; // 边收边异或,收完校验值也算完
if (idx == len) st = S_CRC; // 收满 → 去校验关
break;
case S_CRC: // 状态5:对校验字节
if (crc == b) frame_exec(data, len);// 算出的==送来的 → 合法帧,执行!
st = S_HDR1; // 无论过不过都回起点:坏帧不回ACK,
break; // 发送方超时重发即可(丢弃式恢复)
}
}
}
主循环(消费者侧)
uint8_t tmp[RXBUF_SIZE]; // 主循环的提货筐:一次最多搬128字节
while (1)
{
uint16_t n = rb_read(tmp, sizeof(tmp)); // 消费者:把环形缓冲区现有字节一次搬空
if (n) frame_parse(tmp, n); // 有货就整批喂给状态机
HAL_Delay(10); // 10ms轮询节奏 = 命令响应延迟上限
}
三个关键机制
- static 跨调用记忆:一帧 6 字节可能这批来 3 个、下批来 3 个(发送方分两次发、或主循环轮询切在中间)。状态机的进度存在 static 变量里,函数返回也不丢——帧劈开也能拼回。这是它和"一次性处理完整帧"思路的本质区别
- AA AA 55 分支:收到
AA AA 55时,第一个 AA 之后等来的是又一个 AA——它可能是真帧头。所以留在 S_HDR2 原地再等一个 55,而不是回 S_HDR1。没有这个分支,AA AA 55 ...开头的帧会丢 - 丢弃式错误恢复:CRC 不过,整帧丢弃、回 S_HDR1、不回任何东西。实现最简,且坏帧不回 ACK 本身就是信号——发送方超时没等到确认,重发即可
五、验收与踩坑
验收五连(串口助手 HEX 模式):①开灯 AA 55 02 01 01 02→灯亮+回同帧;②关灯 AA 55 02 01 00 03→灯灭+回同帧;③坏CRC AA 55 02 01 01 99→彻底沉默;④垃圾前缀 12 34 56+好帧→重新同步成功;⑤双AA AA AA 55 02 01 01 02→第二个AA当真帧头。①②验常规通路,③④⑤专考状态机的防御分支。
实战踩过两个坑,都值得记一辈子:
HAL_Delay单位是毫秒:把 10ms 写成HAL_Delay(10000),编译零警告、逻辑全对,但命令响应延迟变成 0~10 秒随机。这类 bug 最阴险:不报错,只让系统慢得莫名其妙- 长度判断 off-by-one:
b < FRAME_MAX会把 len=32 的满帧也拒掉(data[32]下标 0~31,len=32 完全合法),正确写法b <= FRAME_MAX。教训:边界值必须专门测——len=0、1、31、32、33 各来一发
另一个强烈推荐的手法:主机侧仿真测试——上真机前把解析器原样搬到 PC,HAL 调用换成记录桩(HAL_GPIO_WritePin 写个 led 变量、HAL_UART_Transmit 把字节拷进内存),本机 gcc 毫秒级跑完全部测试向量。不碰硬件、可回归、出错定位在逻辑层而不是"板子抽风"。
六、要点速记
- 串口是无边界字节流,协议帧 = 自己造边界(帧头+长度+校验)
- 双字节帧头把误同步概率降到 1/65536;长度断帧不依赖发送方时序
- 状态机五状态 = 帧格式五区段的镜像;
static让帧劈开也能拼回 AA AA 55:S_HDR2 见 AA 留原地;长度闸门(1~32)防数组溢出- 丢弃式恢复:坏帧不回 ACK,靠发送方超时重发;ACK 原样回传 = 闭环确认
- 收字节的归中断(快进快出),动脑子的归主循环;环形缓冲区是两种节奏之间的仓库
- 10ms 轮询 = 命令响应延迟上限;
HAL_Delay单位是毫秒 - 上真机前,主机侧仿真先跑一遍测试向量