【STM32】循环缓冲区与状态机的数据解析实战指南
📦 串口循环缓冲区设计(基于keysking教程)及状态机改进
🧩 数据帧格式
串口通信采用如下数据帧结构:
包头 + 包长度 + 数据 + 校验和
0xAAsum & 0xFF)🧠 缓冲区基础设置
#define COMMAND_MIN_LENGTH 4 // 数据帧最小长度
#define BUFFER_SIZE 128 // 循环缓冲区大小
uint8_t buffer[BUFFER_SIZE]; // 缓冲区数组
uint8_t readIndex = 0; // 读指针
uint8_t writeIndex = 0; // 写指针
📌 函数说明
🔁 读写索引管理
➕ 增加读索引
void Command_AddReadIndex(uint8_t length){
readIndex = (readIndex + length) % BUFFER_SIZE;
}
📖 读取缓冲区中第 i 位的数据
uint8_t Command_Read(uint8_t i){
uint8_t index = i % BUFFER_SIZE;
return buffer[index];
}
📏 获取数据长度与空间长度
📦 获取未处理数据长度
uint8_t Command_GetLength(){
if(readIndex==writeIndex)
return 0;
if(writeIndex+1==readIndex ||(writeIndex==BUFFER_SIZE -1 &&readIndex==0)){
return BUFFER_SIZE;
}
if(readIndex<writeIndex){
return writeIndex - readIndex;
}else{
return BUFFER_SIZE -readIndex +writeIndex;
}
/*可直接用下面的公式*/
//return (writeIndex + BUFFER_SIZE - readIndex) % BUFFER_SIZE;
}
📭 获取剩余空间长度
uint8_t Command_GetRemain(){
return BUFFER_SIZE - Command_GetLength();
}
✍ 向缓冲区写入数据
uint8_t Command_Write(uint8_t *data, uint8_t length){
if (Command_GetRemain() < length) // 检查剩余空间是否足够
return 0;
if (writeIndex + length < BUFFER_SIZE){
// 数据不会越界,直接写入
memcpy(buffer + writeIndex, data, length);
writeIndex += length;
} else {
// 数据将越界,分两段写入
uint8_t firstLength = BUFFER_SIZE - writeIndex; // 尾部部分长度
memcpy(buffer + writeIndex, data, firstLength); // 写入尾部
memcpy(buffer, data + firstLength, length - firstLength); // 写入头部
writeIndex = length - firstLength; // 更新写指针
}
return length;
}
🧾 解析一条完整命令
uint8_t Command_GetCommand(uint8_t *command) {
// 寻找完整指令
while (1) {
// 如果缓冲区长度小于COMMAND_MIN_LENGTH 则不可能有完整的指令
if (Command_GetLength() < COMMAND_MIN_LENGTH) {
return 0;
}
// 如果不是包头 则跳过 重新开始寻找
if (Command_Read(readIndex) != 0xAA) {
Command_AddReadIndex(1);
continue;
}
// 如果缓冲区长度小于指令长度 则不可能有完整的指令
uint8_t length = Command_Read(readIndex + 1);
if (Command_GetLength() < length) {
return 0;
}
// 如果校验和不正确 则跳过 重新开始寻找
uint8_t sum = 0;
for (uint8_t i = 0; i < length - 1; i++) {
sum += Command_Read(readIndex + i);
}
if (sum != Command_Read(readIndex + length - 1)) {
Command_AddReadIndex(1);
continue;
}
// 如果找到完整指令 则将指令写入command 返回指令长度
for (uint8_t i = 0; i < length; i++) {
command[i] = Command_Read(readIndex + i);
}
Command_AddReadIndex(length);
return length;
}
}
🔧 串口接收初始化设置
为了启用 Rx To Idle 模式的串口接收,需要在初始化阶段显式调用以下函数:
HAL_UARTEx_ReceiveToIdle_IT(&huart2, readBuffer, sizeof(readBuffer));
📌 此调用应放置于主函数 main() 中 HAL 初始化完成后或用户自定义的串口初始化流程末尾。否则将无法正确接收数据。
📡 串口接收回调函数
用于 HAL 库的 Rx To Idle 接口,在接收数据后自动写入循环缓冲区:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size){
if (huart == &huart2){
Command_Write(readBuffer, Size);
HAL_UARTEx_ReceiveToIdle_IT(&huart2, readBuffer, sizeof(readBuffer));
}
}
🎯 为什么用状态机?
在原来的方式中,我们通过不断判断数据帧格式(包头、长度、校验)并不断跳过无效字节来尝试提取一帧数据,这种方法逻辑虽然直观,但:
对错包的处理较为混乱;
某些边界条件处理比较啰嗦;
代码结构不太清晰,可读性差;
状态之间的切换不明显,不易调试。
使用状态机之后,逻辑变得像“流程图”一样清晰,每一个接收到的字节只做一件事,根据当前状态决定怎么处理、转到哪个状态。
📐 状态机解析的核心思想
通过状态机逐字节解析串口接收数据,避免处理大缓冲、边界判断复杂的情况。每接收到一个字节就立即判断当前所处状态并处理,有效解决“粘包”、“断包”问题。
🔧 状态机实现方式
使用 enum 定义状态
typedef enum {
WAIT_FOR_HEADER,
WAIT_FOR_LENGTH,
WAIT_FOR_DATA,
WAIT_FOR_CHECKSUM
} ParseState;
📌 变量定义
#define MAX_FRAME_SIZE 64
ParseState currentState = WAIT_FOR_HEADER;
uint8_t rxFrame[MAX_FRAME_SIZE];
uint8_t rxIndex = 0;
uint8_t expectedLength = 0;
🧠 状态转移逻辑
void Parse_Byte(uint8_t byte) {
switch (currentState) {
case WAIT_FOR_HEADER:
if (byte == 0xAA) {
rxFrame[0] = byte;
rxIndex = 1;
currentState = WAIT_FOR_LENGTH;
}
break;
case WAIT_FOR_LENGTH:
rxFrame[rxIndex++] = byte;
expectedLength = byte;
if (expectedLength <= MAX_FRAME_SIZE && expectedLength >= 4) {
currentState = WAIT_FOR_DATA;
} else {
currentState = WAIT_FOR_HEADER; // 异常,重置状态机
}
break;
case WAIT_FOR_DATA:
rxFrame[rxIndex++] = byte;
if (rxIndex == expectedLength) {
currentState = WAIT_FOR_CHECKSUM;
}
break;
case WAIT_FOR_CHECKSUM:
{
uint8_t sum = 0;
for (uint8_t i = 0; i < expectedLength - 1; i++) {
sum += rxFrame[i];
}
if ((sum & 0xFF) == byte) {
// 校验成功,处理数据
Handle_Command(rxFrame, expectedLength);
}
currentState = WAIT_FOR_HEADER; // 无论成功失败,重置状态
}
break;
}
}
🧾 串口接收中断或回调中调用
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size){
if (huart == &huart2){
for (uint16_t i = 0; i < Size; i++) {
Parse_Byte(readBuffer[i]);
}
HAL_UARTEx_ReceiveToIdle_IT(&huart2, readBuffer, sizeof(readBuffer)); // 重启接收
}
}
🔄 循环缓冲区 vs 状态机 —— 串口数据接收解析方法对比
📆 一、循环缓冲区法【Ring Buffer】
✅ 优点
❌ 缺点
⚙️ 二、状态机法【State Machine】
✅ 优点
❌ 缺点
📊 三、适用场景对比
| 特性/方式 | 循环缓冲区 | 状态机 |
|---|---|---|
| 实时性 | 一般(靠轮询) | 高(到就处理) |
| 代码复用性 | 较高 | 较低 |
| 实现复杂度 | 中等(辅助函数多) | 低(逻辑线性) |
| 内存消耗 | 较大(需缓冲区) | 较小(字节处理) |
| 适合数据流类型 | 批量/黄包/断包 | 简单协议,字节流类 |
| 高速大数据处理 | 适合 | 效率低,容易卡顿 |
🧐 结论建议
作者:hallo-ooo