【STM32】循环缓冲区与状态机的数据解析实战指南

📦 串口循环缓冲区设计(基于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】

    ✅ 优点

  • 【逻辑简洁直观】:通过状态转移逐步解析,流程清晰,易于调试
  • 【适合字节流处理】:边接收边处理,无需等待整完数据局
  • 【内存占用小】:不依赖大缓冲区,适合内存缺乏场景
  • 【响应速度快】:字节到达立即处理,适合实时性要求高的场景
  • ❌ 缺点

  • 【难以复用】:状态机逻辑高度依赖协议格式,移植性较差
  • 【不适合大数据量】:频繁状态切换和字节处理在高速场景下效率低
  • 【可读性略差】:太多状态跳转时,代码可读性大降

  • 📊 三、适用场景对比

    特性/方式 循环缓冲区 状态机
    实时性 一般(靠轮询) 高(到就处理)
    代码复用性 较高 较低
    实现复杂度 中等(辅助函数多) 低(逻辑线性)
    内存消耗 较大(需缓冲区) 较小(字节处理)
    适合数据流类型 批量/黄包/断包 简单协议,字节流类
    高速大数据处理 适合 效率低,容易卡顿

    🧐 结论建议

  • 【协议复杂/大数据量】:建议使用 循环缓冲区,配合解析函数
  • 【协议简单/实时性要求高】:建议使用 状态机方式,响应较快
  • 【开发初期或调试阶段】:状态机更易部署和解析
  • 【生产级高速设计】:循环缓冲更稳定可靠
  • 作者:hallo-ooo

    物联沃分享整理
    物联沃-IOTWORD物联网 » 【STM32】循环缓冲区与状态机的数据解析实战指南

    发表回复