浮云落笔

浮云落笔

首页
瞬间
反馈
浮云落笔

浮云落笔

首页 瞬间 反馈
  1. 首页
  2. stm32
  3. 串口协议帧设计

串口协议帧设计

  • stm32
  • 发布于 2026-09-21
  • 6 次阅读
flor
flor

平台:STM32F103ZET6 @ 72MHz · USART1(PA9=TX / PA10=RX)· 115200-8-N-1 · LED=PB5 低电平点亮 前置知识:USART 中断接收 + 环形缓冲区

一、要解决的问题

printf 重定向解决了"怎么发",中断接收 + 环形缓冲区解决了"怎么收"。但那两课搬运的都只是字节,而真实项目需要的是命令——PC 发一条指令,板子解析、执行、回确认。

核心矛盾在这里:串口是一条无边界字节流。PC 连续发来 12 个字节,串口本身不会告诉你这是"两条 6 字节命令",还是"一条 12 字节命令",还是"一堆垃圾 + 一条命令"。噪声、丢字节、粘包、劈包,在串口世界里都是家常便饭。

所以这个课题分两半:

  1. 设计帧格式——给字节流加上结构和边界
  2. 写解析器——从字节流里可靠地"切"出完整帧,且坏帧不能影响好帧

二、帧格式设计

┌──────┬──────┬──────┬─────────────┬──────────┐
│ 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:数据域长度为 2
  • 01 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轮询节奏 = 命令响应延迟上限
}

三个关键机制

  1. static 跨调用记忆:一帧 6 字节可能这批来 3 个、下批来 3 个(发送方分两次发、或主循环轮询切在中间)。状态机的进度存在 static 变量里,函数返回也不丢——帧劈开也能拼回。这是它和"一次性处理完整帧"思路的本质区别
  2. AA AA 55 分支:收到 AA AA 55 时,第一个 AA 之后等来的是又一个 AA——它可能是真帧头。所以留在 S_HDR2 原地再等一个 55,而不是回 S_HDR1。没有这个分支,AA AA 55 ... 开头的帧会丢
  3. 丢弃式错误恢复: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 单位是毫秒
  • 上真机前,主机侧仿真先跑一遍测试向量
目录
湘ICP备2025147565号-1
gongan beian 湘公网安备43102602000213号