STM32 串口数据包通信(HEX包与文本包)

基于江协科技 STM32 入门教程 9-3 串口收发HEX数据包、9-4 串口收发文本数据包 两个项目整理


一、为什么需要数据包

1.1 单字节通信的局限

在 9-2 中,我们实现了单字节的收发。但实际应用中,一个完整的信息往往由多个字节组成:

  • 控制 LED 开关:需要发送指令 "LED_ON"(6个字节)
  • 传输传感器数据:可能包含温度、湿度、光照等多个数值
  • 上位机通信:需要传递命令码 + 参数

如果只靠单字节逐个传输,会面临以下问题:

  1. 数据边界不清:收到一串字节,不知道从哪里开始、到哪里结束
  2. 数据粘包:多个数据连在一起,无法区分
  3. 容易丢包:中间丢一个字节,后续数据全部错位

1.2 数据包的基本结构

解决方案是把多个字节封装成数据包,典型格式:

┌──────┬──────────────┬──────┐
│ 包头 │    数据段    │ 包尾 │
└──────┴──────────────┴──────┘
  • 包头:标志数据包的开始,固定字节(如 0xFF'@'
  • 数据段:要传输的有效数据,长度可固定或可变
  • 包尾:标志数据包的结束,固定字节(如 0xFE"\r\n"

接收方通过识别包头和包尾,就能从连续的字节流中切分出完整的数据包。

1.3 两种数据包类型

类型 包头 数据段 包尾 数据长度 项目
HEX 数据包 0xFF 4字节十六进制数据 0xFE 固定长度 9-3
文本数据包 '@' 任意长度文本 "\r\n" 可变长度 9-4

二、状态机解析原理

2.1 什么是状态机

状态机(State Machine)是一种编程思想:程序在不同的状态下做不同的事,根据输入切换状态。

串口接收数据包时,字节是逐个到来的,我们需要跟踪"当前收到了数据包的哪个部分",这正是状态机的典型应用。

2.2 三状态状态机

两个项目都使用了三状态状态机:

状态0:等待包头
  │ 收到包头
  ▼
状态1:接收数据段
  │ 收够数据 / 收到第一个包尾
  ▼
状态2:接收包尾
  │ 收到包尾
  ▼
回到状态0,置接收完成标志
状态 含义 期望接收
0 等待包头 包头字节
1 接收数据段 有效数据字节
2 接收包尾 包尾字节

2.3 为什么用 static 局部变量

状态机的状态变量(RxStatepRxPacket)定义在中断函数内部,用 static 修饰:

static uint8_t RxState = 0;
static uint8_t pRxPacket = 0;
  • 普通局部变量:函数返回后销毁,下次进入中断重新初始化,无法保持状态
  • static 局部变量:生命周期贯穿整个程序运行,只在第一次进入时初始化一次,下次进入中断时值保持不变

这正是状态机需要的——跨多次中断记住当前状态。


三、项目一:9-3 串口收发 HEX 数据包

3.1 项目目标

定义固定格式的 HEX 数据包:FF 01 02 03 04 FE,实现:

  • 按下按键,STM32 发送一个 HEX 数据包
  • 接收电脑发来的 HEX 数据包,在 OLED 上显示数据段

3.2 数据包格式

┌──────┬──────┬──────┬──────┬──────┬──────┐
│ 0xFF │ 数据0 │ 数据1 │ 数据2 │ 数据3 │ 0xFE │
└──────┴──────┴──────┴──────┴──────┴──────┘
  包头                              包尾
  • 包头:0xFF(1字节)
  • 数据段:4 字节,固定长度
  • 包尾:0xFE(1字节)
  • 整包共 6 字节

3.3 发送数据包

发送数组定义

uint8_t Serial_TxPacket[4];  // 发送数据包的数据段

发送函数 Serial_SendPacket

void Serial_SendPacket(void)
{
    Serial_SendByte(0xFF);                    // 发送包头
    Serial_SendArray(Serial_TxPacket, 4);     // 发送4字节数据
    Serial_SendByte(0xFE);                    // 发送包尾
}

发送时先填充 Serial_TxPacket 数组,再调用此函数即可。

3.4 接收状态机逐行解析

uint8_t Serial_RxPacket[4];  // 接收数据段
uint8_t Serial_RxFlag;       // 接收完成标志

void USART1_IRQHandler(void)
{
    static uint8_t RxState = 0;     // 状态机当前状态
    static uint8_t pRxPacket = 0;   // 数据段写入位置

    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        uint8_t RxData = USART_ReceiveData(USART1);  // 读取收到的字节

        /* 状态0:等待包头 */
        if (RxState == 0)
        {
            if (RxData == 0xFF)       // 收到包头 0xFF
            {
                RxState = 1;          // 切换到接收数据状态
                pRxPacket = 0;        // 数据位置归零
            }
            // 不是包头则忽略,继续等待
        }
        /* 状态1:接收4字节数据 */
        else if (RxState == 1)
        {
            Serial_RxPacket[pRxPacket] = RxData;  // 存入数组
            pRxPacket++;                          // 位置后移
            if (pRxPacket >= 4)                   // 收够4个字节
            {
                RxState = 2;                      // 切换到等待包尾状态
            }
        }
        /* 状态2:等待包尾 */
        else if (RxState == 2)
        {
            if (RxData == 0xFE)       // 收到包尾 0xFE
            {
                RxState = 0;          // 状态归零,准备接收下一包
                Serial_RxFlag = 1;    // 置接收完成标志
            }
            // 不是包尾则丢弃(本包作废,状态不回0,会卡住?见注意事项)
        }

        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

状态机流程图解

收到字节流:FF 11 22 33 44 FE

字节0xFF → 状态0:是包头 → 进入状态1,pRxPacket=0
字节0x11 → 状态1:存入[0],pRxPacket=1
字节0x22 → 状态1:存入[1],pRxPacket=2
字节0x33 → 状态1:存入[2],pRxPacket=3
字节0x44 → 状态1:存入[3],pRxPacket=4 → 收够4个,进入状态2
字节0xFE → 状态2:是包尾 → 回到状态0,置RxFlag=1

主循环检测到RxFlag=1,读取Serial_RxPacket = {0x11, 0x22, 0x33, 0x44}

3.5 main.c 逻辑

uint8_t KeyNum;

int main(void)
{
    OLED_Init();
    Key_Init();
    Serial_Init();

    OLED_ShowString(1, 1, "TxPacket");
    OLED_ShowString(3, 1, "RxPacket");

    // 初始测试数据
    Serial_TxPacket[0] = 0x01;
    Serial_TxPacket[1] = 0x02;
    Serial_TxPacket[2] = 0x03;
    Serial_TxPacket[3] = 0x04;

    while (1)
    {
        KeyNum = Key_GetNum();
        if (KeyNum == 1)                // 按键1按下
        {
            Serial_TxPacket[0]++;       // 数据自增
            Serial_TxPacket[1]++;
            Serial_TxPacket[2]++;
            Serial_TxPacket[3]++;

            Serial_SendPacket();        // 发送数据包

            OLED_ShowHexNum(2, 1, Serial_TxPacket[0], 2);
            OLED_ShowHexNum(2, 4, Serial_TxPacket[1], 2);
            OLED_ShowHexNum(2, 7, Serial_TxPacket[2], 2);
            OLED_ShowHexNum(2, 10, Serial_TxPacket[3], 2);
        }

        if (Serial_GetRxFlag() == 1)    // 收到数据包
        {
            OLED_ShowHexNum(4, 1, Serial_RxPacket[0], 2);
            OLED_ShowHexNum(4, 4, Serial_RxPacket[1], 2);
            OLED_ShowHexNum(4, 7, Serial_RxPacket[2], 2);
            OLED_ShowHexNum(4, 10, Serial_RxPacket[3], 2);
        }
    }
}

3.6 实验现象

  • 按下按键:OLED 显示发送的数据(自增),串口助手收到 FF xx xx xx xx FE
  • 串口助手发送 FF 01 02 03 04 FE:OLED 显示收到的 4 个数据字节
  • 可以用两个 STM32 板子交叉验证:一个发一个收

四、项目二:9-4 串口收发文本数据包

4.1 项目目标

定义可变长度的文本数据包:@MSG\r\n,实现:

  • 接收电脑发来的文本指令(如 @LED_ON\r\n
  • 解析指令并控制 LED
  • 回传执行结果字符串

4.2 数据包格式

┌──────┬─────────────────┬──────┬──────┐
│ '@'  │  文本内容(变长)  │ '\r' │ '\n' │
└──────┴─────────────────┴──────┴──────┘
  包头                      包尾(2字节)
  • 包头:'@'(1字节,ASCII 0x40)
  • 数据段:任意长度的文本字符串(不含 \r
  • 包尾:"\r\n"(2字节,回车换行)
  • 示例:@LED_ON\r\n@LED_OFF\r\n

4.3 与 HEX 数据包的区别

对比项 HEX 数据包(9-3) 文本数据包(9-4)
数据段长度 固定 4 字节 可变长度
判断数据段结束 计数到 4 收到 \r
包尾长度 1 字节(0xFE) 2 字节(\r\n)
数据类型 原始字节 字符串(以 '\0' 结尾)
可读性 差(需十六进制查看) 好(直接可读)
适用场景 二进制数据、传感器值 文本指令、AT命令

4.4 接收状态机逐行解析

char Serial_RxPacket[100];   // 接收缓冲区,最多100字节
uint8_t Serial_RxFlag;

void USART1_IRQHandler(void)
{
    static uint8_t RxState = 0;
    static uint8_t pRxPacket = 0;

    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        uint8_t RxData = USART_ReceiveData(USART1);

        /* 状态0:等待包头 '@' */
        if (RxState == 0)
        {
            if (RxData == '@' && Serial_RxFlag == 0)  // 收到@且上一包已处理
            {
                RxState = 1;
                pRxPacket = 0;
            }
        }
        /* 状态1:接收文本内容,直到遇到 '\r' */
        else if (RxState == 1)
        {
            if (RxData == '\r')           // 收到第一个包尾字符
            {
                RxState = 2;
            }
            else                          // 正常文本字符
            {
                Serial_RxPacket[pRxPacket] = RxData;  // 存入缓冲区
                pRxPacket++;
            }
        }
        /* 状态2:等待第二个包尾字符 '\n' */
        else if (RxState == 2)
        {
            if (RxData == '\n')           // 收到第二个包尾字符
            {
                RxState = 0;
                Serial_RxPacket[pRxPacket] = '\0';  // 添加字符串结束符!
                Serial_RxFlag = 1;
            }
        }

        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

关键细节解析

(1)状态0 中为什么要判断 Serial_RxFlag == 0

if (RxData == '@' && Serial_RxFlag == 0)

如果上一个数据包还没被主循环处理(Serial_RxFlag 仍为 1),就不接收新包。这是一种简单的流控/防覆盖机制,避免新数据覆盖还未处理的旧数据。

(2)状态1 中为什么遇到 \r 就停止接收数据?

文本数据包的包尾是 \r\n 两个字符。在状态1中,\r 标志着文本内容的结束,所以收到 \r 后不再把它存入数据区,而是切换到状态2等待 \n

(3)为什么要手动添加 '\0'

Serial_RxPacket[pRxPacket] = '\0';

C 语言字符串以 '\0' 结尾。收到的文本数据本身不包含 '\0',如果不手动添加,后续用 strcmpprintf("%s") 等字符串函数时会越界访问,读到乱码。

状态机流程图解

收到字节流:@ L E D _ O N \r \n

字节'@'  → 状态0:是包头 → 进入状态1,pRxPacket=0
字节'L'  → 状态1:存入[0],pRxPacket=1
字节'E'  → 状态1:存入[1],pRxPacket=2
字节'D'  → 状态1:存入[2],pRxPacket=3
字节'_'  → 状态1:存入[3],pRxPacket=4
字节'O'  → 状态1:存入[4],pRxPacket=5
字节'N'  → 状态1:存入[5],pRxPacket=6
字节'\r' → 状态1:是包尾第一字节 → 进入状态2
字节'\n' → 状态2:是包尾第二字节 → 回到状态0
           添加'\0',Serial_RxPacket = "LED_ON",置RxFlag=1

4.5 main.c 指令解析逻辑

int main(void)
{
    OLED_Init();
    LED_Init();
    Serial_Init();

    OLED_ShowString(1, 1, "TxPacket");
    OLED_ShowString(3, 1, "RxPacket");

    while (1)
    {
        if (Serial_RxFlag == 1)
        {
            // 显示收到的数据包
            OLED_ShowString(4, 1, "                ");  // 先清除
            OLED_ShowString(4, 1, Serial_RxPacket);

            // 字符串比较,执行对应指令
            if (strcmp(Serial_RxPacket, "LED_ON") == 0)
            {
                LED1_ON();
                Serial_SendString("LED_ON_OK\r\n");    // 回传成功
                OLED_ShowString(2, 1, "                ");
                OLED_ShowString(2, 1, "LED_ON_OK");
            }
            else if (strcmp(Serial_RxPacket, "LED_OFF") == 0)
            {
                LED1_OFF();
                Serial_SendString("LED_OFF_OK\r\n");
                OLED_ShowString(2, 1, "                ");
                OLED_ShowString(2, 1, "LED_OFF_OK");
            }
            else
            {
                Serial_SendString("ERROR_COMMAND\r\n");  // 未知指令
                OLED_ShowString(2, 1, "                ");
                OLED_ShowString(2, 1, "ERROR_COMMAND");
            }

            Serial_RxFlag = 0;  // 手动清零标志位!
        }
    }
}

注意:9-4 的标志位清零方式与 9-3 不同

  • 9-3Serial_GetRxFlag() 函数内部读后清零
  • 9-4:直接暴露 Serial_RxFlag 变量,主循环处理完后手动赋值为 0

9-4 这样设计是因为状态0中需要判断 Serial_RxFlag == 0 来决定是否接收新包,所以标志位需要在处理完成后才清零,而不是读取时就清零。

4.6 实验现象

串口助手发送(注意要发送新行,即追加 \r\n):

  • 发送 @LED_ON → LED 点亮,串口助手收到 LED_ON_OK,OLED 显示 LED_ON_OK
  • 发送 @LED_OFF → LED 熄灭,串口助手收到 LED_OFF_OK,OLED 显示 LED_OFF_OK
  • 发送 @ABC → 串口助手收到 ERROR_COMMAND,OLED 显示 ERROR_COMMAND

五、两种数据包方案对比

对比维度 HEX 数据包 文本数据包
包头 0xFF '@'
包尾 0xFE '\r' '\n'
数据长度 固定 可变
数据类型 uint8_t 原始字节 char 字符串
结束符处理 不需要 需手动加 '\0'
标志位清零 GetRxFlag() 读后清零 主循环手动清零
传输效率 高(二进制紧凑) 低(ASCII 编码,数字也要多字节)
可读性 差(十六进制查看) 好(直接阅读)
调试难度 需十六进制工具 串口助手直接看
典型应用 传感器数据、固件升级 AT指令、控制命令、人机交互

六、数据包设计的进阶话题

6.1 包头包尾与数据冲突问题

如果数据段中恰好出现了包头/包尾字节怎么办?

  • HEX 包:数据段可能包含 0xFF0xFE,会导致状态机误判
  • 文本包:文本内容中如果出现 \r 会被误判为包尾

本教程的简化方案没有处理这个问题。实际工程中的解决方法:

  1. 固定数据长度(如9-3):数据段固定4字节,计数到4就等包尾,即使数据中有 0xFF 也不会误判为包头(因为状态1不检查包头)
  2. 转义机制:数据中出现包头包尾字节时用特殊转义序列替换
  3. 长度字段:包头后加一个长度字节,明确告知数据段长度
  4. 校验和:包尾前加校验字节,验证数据完整性

6.2 固定长度 vs 可变长度

  • 固定长度(9-3):实现简单,不需要在数据中找包尾,但不够灵活
  • 可变长度(9-4):灵活,适合文本指令,但需要特殊字符分隔

6.3 超时机制

本教程没有超时处理。如果收到包头后数据中断(只收到一半),状态机会一直卡在状态1或状态2。改进方法:

  • 记录最后一个字节的接收时间
  • 如果超时(如 50ms 没有新字节),强制状态机归零
  • 可以用定时器中断实现超时检测

6.4 校验和

为了防止数据传输出错,可以在数据包中加入校验和:

FF 01 02 03 04 SUM FE

其中 SUM = (0x01 + 0x02 + 0x03 + 0x04) & 0xFF,接收方重新计算校验和并比对,不一致则丢弃。


七、常见问题与注意事项

7.1 HEX 包:数据段中有 0xFF 会误判吗?

不会。状态1(接收数据段)只负责把字节存入数组并计数,不检查是否为 0xFF。只有状态0才检查包头。所以数据段中出现 0xFF 不影响解析。

但包尾 0xFE 如果出现在数据段中也没关系,因为状态1同样不检查 0xFE。问题只出现在"该收包尾时收到的不是 0xFE"——此时本包作废,但状态机卡在状态2不会回0(教程代码的小缺陷)。

7.2 文本包:发送时如何确保有 \r\n?

串口助手一般有"发送新行"选项,勾选后会自动在末尾追加 \r\n。如果手动输入,需要确保发送的数据以 @ 开头、以回车换行结尾。

7.3 文本包:为什么收不到数据?

检查清单:

  1. 数据是否以 @ 开头?
  2. 数据是否以 \r\n 结尾?(不是只有 \n
  3. Serial_RxFlag 是否在处理后被清零?(不清零则无法接收下一包)
  4. 串口助手是否设置了正确的波特率(9600)?

7.4 为什么文本包用 strcmp 而不是直接比较数字?

strcmp 是 C 标准库的字符串比较函数,返回 0 表示两个字符串相同。用字符串比较可以直接写出 if (strcmp(Serial_RxPacket, "LED_ON") == 0) 这样直观的指令判断,比逐字节比较简洁。

7.5 中断函数中的 static 变量会被多次初始化吗?

不会。static 局部变量只在程序启动时初始化一次,后续进入函数时保持上次的值。这正是状态机跨中断保持状态的关键。

7.6 缓冲区溢出风险

9-4 的 Serial_RxPacket[100] 最多存 100 字节。如果发送的文本超过 99 字节(留1字节给 '\0'),pRxPacket 会超出数组范围,导致内存越界。实际项目中应在状态1加入长度检查:

else if (RxState == 1)
{
    if (RxData == '\r') { RxState = 2; }
    else if (pRxPacket < 99)  // 防止溢出
    {
        Serial_RxPacket[pRxPacket] = RxData;
        pRxPacket++;
    }
}

八、代码模板

8.1 HEX 数据包状态机(模板)

#define RX_PACKET_SIZE 4
uint8_t Serial_RxPacket[RX_PACKET_SIZE];
uint8_t Serial_RxFlag;

void USART1_IRQHandler(void)
{
    static uint8_t RxState = 0;
    static uint8_t pRxPacket = 0;

    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        uint8_t RxData = USART_ReceiveData(USART1);

        if (RxState == 0)
        {
            if (RxData == 0xFF) { RxState = 1; pRxPacket = 0; }
        }
        else if (RxState == 1)
        {
            Serial_RxPacket[pRxPacket++] = RxData;
            if (pRxPacket >= RX_PACKET_SIZE) { RxState = 2; }
        }
        else if (RxState == 2)
        {
            if (RxData == 0xFE) { RxState = 0; Serial_RxFlag = 1; }
            else { RxState = 0; }  // 包尾错误,丢弃本包
        }

        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

8.2 文本数据包状态机(模板)

char Serial_RxPacket[100];
uint8_t Serial_RxFlag;

void USART1_IRQHandler(void)
{
    static uint8_t RxState = 0;
    static uint8_t pRxPacket = 0;

    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET)
    {
        uint8_t RxData = USART_ReceiveData(USART1);

        if (RxState == 0)
        {
            if (RxData == '@' && Serial_RxFlag == 0)
            {
                RxState = 1; pRxPacket = 0;
            }
        }
        else if (RxState == 1)
        {
            if (RxData == '\r') { RxState = 2; }
            else if (pRxPacket < 99)
            {
                Serial_RxPacket[pRxPacket++] = RxData;
            }
        }
        else if (RxState == 2)
        {
            if (RxData == '\n')
            {
                RxState = 0;
                Serial_RxPacket[pRxPacket] = '\0';
                Serial_RxFlag = 1;
            }
            else { RxState = 0; }  // 包尾错误,丢弃
        }

        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

8.3 主循环处理模板

while (1)
{
    if (Serial_RxFlag == 1)
    {
        // 处理 Serial_RxPacket 中的数据
        // ...

        Serial_RxFlag = 0;  // 处理完后清零
    }
}

九、学习建议

  1. 先理解状态机思想:在纸上画出状态转换图,模拟几个字节的接收过程
  2. 跑通 9-3:用串口助手手动发送 FF 01 02 03 04 FE,观察 OLED 显示
  3. 跑通 9-4:发送 @LED_ON\r\n,理解变长包和字符串处理
  4. 动手修改
  • 把 HEX 包的数据段改成 2 字节或 8 字节
  • 给文本包增加更多指令(如 @BLINK 让 LED 闪烁)
  • 给 HEX 包增加校验和验证 5. 思考改进:如果数据段中出现包头包尾怎么办?如何加超时机制?这些是实际项目中必须考虑的问题

至此,串口通信的四个实验全部覆盖:从最基础的发送,到中断接收,再到 HEX 数据包和文本数据包的状态机解析。掌握这些内容后,你就可以实现 STM32 与上位机、其他单片机之间的可靠通信了。