📦 串口循环缓冲区设计(基于keysking教程)及状态机改进
🧩 数据帧格式
串口通信采用如下数据帧结构:
包头 + 包长度 + 数据 + 校验和
- 包头:人为规定,例如
0xAA
- 包长度:整个数据帧的长度(含包头、长度、数据、校验)
- 数据:用户自定义的数据
- 校验和:前面所有字节(不含自身)的求和结果的最后一个字节(即取
sum & 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】
✅ 优点
- 【逻辑简洁直观】:通过状态转移逐步解析,流程清晰,易于调试
- 【适合字节流处理】:边接收边处理,无需等待整完数据局
- 【内存占用小】:不依赖大缓冲区,适合内存缺乏场景
- 【响应速度快】:字节到达立即处理,适合实时性要求高的场景
❌ 缺点
- 【难以复用】:状态机逻辑高度依赖协议格式,移植性较差
- 【不适合大数据量】:频繁状态切换和字节处理在高速场景下效率低
- 【可读性略差】:太多状态跳转时,代码可读性大降
📊 三、适用场景对比
特性/方式 | 循环缓冲区 | 状态机 |
---|---|---|
实时性 | 一般(靠轮询) | 高(到就处理) |
代码复用性 | 较高 | 较低 |
实现复杂度 | 中等(辅助函数多) | 低(逻辑线性) |
内存消耗 | 较大(需缓冲区) | 较小(字节处理) |
适合数据流类型 | 批量/黄包/断包 | 简单协议,字节流类 |
高速大数据处理 | 适合 | 效率低,容易卡顿 |
🧐 结论建议
- 【协议复杂/大数据量】:建议使用 循环缓冲区,配合解析函数
- 【协议简单/实时性要求高】:建议使用 状态机方式,响应较快
- 【开发初期或调试阶段】:状态机更易部署和解析
- 【生产级高速设计】:循环缓冲更稳定可靠