STM32 串口数据包通信(HEX包与文本包)
基于江协科技 STM32 入门教程 9-3 串口收发HEX数据包、9-4 串口收发文本数据包 两个项目整理
一、为什么需要数据包
1.1 单字节通信的局限
在 9-2 中,我们实现了单字节的收发。但实际应用中,一个完整的信息往往由多个字节组成:
- 控制 LED 开关:需要发送指令
"LED_ON"(6个字节) - 传输传感器数据:可能包含温度、湿度、光照等多个数值
- 上位机通信:需要传递命令码 + 参数
如果只靠单字节逐个传输,会面临以下问题:
- 数据边界不清:收到一串字节,不知道从哪里开始、到哪里结束
- 数据粘包:多个数据连在一起,无法区分
- 容易丢包:中间丢一个字节,后续数据全部错位
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 局部变量
状态机的状态变量(RxState、pRxPacket)定义在中断函数内部,用 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',如果不手动添加,后续用 strcmp、printf("%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-3:
Serial_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 包:数据段可能包含
0xFF或0xFE,会导致状态机误判 - 文本包:文本内容中如果出现
\r会被误判为包尾
本教程的简化方案没有处理这个问题。实际工程中的解决方法:
- 固定数据长度(如9-3):数据段固定4字节,计数到4就等包尾,即使数据中有 0xFF 也不会误判为包头(因为状态1不检查包头)
- 转义机制:数据中出现包头包尾字节时用特殊转义序列替换
- 长度字段:包头后加一个长度字节,明确告知数据段长度
- 校验和:包尾前加校验字节,验证数据完整性
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 文本包:为什么收不到数据?
检查清单:
- 数据是否以
@开头? - 数据是否以
\r\n结尾?(不是只有\n) Serial_RxFlag是否在处理后被清零?(不清零则无法接收下一包)- 串口助手是否设置了正确的波特率(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; // 处理完后清零
}
}
九、学习建议
- 先理解状态机思想:在纸上画出状态转换图,模拟几个字节的接收过程
- 跑通 9-3:用串口助手手动发送
FF 01 02 03 04 FE,观察 OLED 显示 - 跑通 9-4:发送
@LED_ON\r\n,理解变长包和字符串处理 - 动手修改:
- 把 HEX 包的数据段改成 2 字节或 8 字节
- 给文本包增加更多指令(如
@BLINK让 LED 闪烁) - 给 HEX 包增加校验和验证 5. 思考改进:如果数据段中出现包头包尾怎么办?如何加超时机制?这些是实际项目中必须考虑的问题
至此,串口通信的四个实验全部覆盖:从最基础的发送,到中断接收,再到 HEX 数据包和文本数据包的状态机解析。掌握这些内容后,你就可以实现 STM32 与上位机、其他单片机之间的可靠通信了。