数据帧格式
串口通信采用如下数据帧结构:
包头 + 包长度 + 数据 + 校验和
包头:人为规定,例如 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 = 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;
}
if ((sum & 0xFF) == byte) {
// 校验成功,处理数据
Handle_Command(rxFrame, expectedLength);
}
currentState = WAIT_FOR_HEADER; // 无论成功失败,重置状态
}
break;
}
}
|