一种通用 HEX 协议解析器的简单实现

背景描述

在嵌入式开发中,MCU 通常会与其它模块进行数据交互。简单来说,常见的数据大概有两种:一种是明文字符串流,比如类似 AT 指令;另一种是 HEX 字节流。

本文实现一种能够较为广泛使用的通用协议解析器,用来处理 HEX 数据。

本文按 protocol_lib 仓库当前最新源码整理,对应提交为 5b0c86b。和最初版本相比,现在输入、输出缓冲区可以按实例配置,多协议之间的匹配状态也已经完全分开。


一、前言

HEX 协议基本都是私有定制的,但它们又有一定共性。比如下面这一种:

帧头 帧类型 数据长度 数据 校验 帧尾
A5 5A AA 55 1 byte 1 byte n byte XOR 0D 0A 0D 0A

一般 HEX 协议的数据流都类似这种,或者是它的变种。这些数据可以通过 UART、网口、蓝牙、2.4G、SPI 等等等等进行传输。简单点,我们就串口而言,解析方式大致有两种。

一种是利用串口空闲中断,逐帧接收处理。这种方式最简单,CPU 开销也小,但不是每一种 MCU 都有可靠的串口空闲中断。另外,发送过程中还可能出现黏包,比如网口通信时由于网络延迟,几帧数据会一起收到。

另一种就是本文要实现的:把收到的数据先丢进环形缓冲区,再逐字节匹配并解析。它不怕拆包,也不怕多帧一起到达;配合 DMA 或接收缓冲区使用,CPU 开销也还能接受,只是代码写起来稍微麻烦一点。

于是本文应运而生。直接用这个模块,几分钟就能把协议接收部分搭起来~~~


二、实现思路

简单的才是稳定好用的。这个模块要足够容易移植,如果是我用,我最关心的主流程只有下面几件事:

  1. 初始化:把帧头、帧尾、长度计算方式、校验方式和缓冲区告诉解析器。
  2. 放入数据:UART、SPI 或网口收到多少数据,就往解析器里放多少。
  3. 获取完整帧:在主循环或任务中轮询,解析器自己完成同步、组帧、帧尾匹配和校验。
  4. 提供心跳:通常每 1 ms 调用一次,让异常帧能够通过空闲超时恢复。

1、提取协议共性

要把一帧数据完整提取出来,首先需要匹配帧头;然后要知道完整帧长,才能判断数据是否收齐并找到帧尾;最后再对整帧进行校验。

所以,当前版本的协议描述结构如下:

typedef uint8_t b_check_cb_t(uint8_t *buffer, uint16_t len);
typedef uint16_t b_get_frame_len_cb_t(uint8_t *buffer, uint16_t len);

typedef struct
{
    const uint8_t *pname;
    const char *head;
    const char *end;
    uint16_t head_len;
    uint16_t end_len;
    b_get_frame_len_cb_t *get_frame_len_cb;
    b_check_cb_t *check_cb;
    uint8_t *in_frame_buffer;
    uint8_t *out_frame_buffer;
    uint16_t in_buffer_len;
    uint16_t out_buffer_len;
    uint8_t log_level;
} b_frame_init_type;

用户只需要描述帧头、帧尾,实现“获取完整帧长”和“校验完整帧”两个回调,再给每个解析器实例准备输入、输出缓冲区即可。

这里有一个很重要的约定:get_frame_len_cb 返回的是完整帧长度,帧头、类型、长度字节、数据、校验和帧尾全部都要算进去;当前数据还不足以判断长度时返回 0。

仍然按上面的协议举例。数据长度在 buffer[5],那么完整帧长应当这样计算:

static uint16_t get_frame_len_cb(uint8_t *buffer, uint16_t len)
{
    if (len < 6)
    {
        return 0;
    }

    return (uint16_t)(4 + 1 + 1 + buffer[5] + 1 + 4);
}

下面假设 XOR 的计算范围是“帧类型 + 数据长度 + 数据”,校验字节放在帧尾前面:

static uint8_t check_frame_cb(uint8_t *buffer, uint16_t len)
{
    uint8_t xor_value = 0;

    if (len < 11)
    {
        return B_ERROR;
    }

    for (uint16_t i = 4; i < len - 5; i++)
    {
        xor_value ^= buffer[i];
    }

    return (xor_value == buffer[len - 5]) ? B_SUCCESS : B_ERROR;
}

校验成功要返回 B_SUCCESS,失败返回 B_ERROR。当前源码中,即使协议本身没有 CRC 或 Checksum,也不能简单把 check_cb 设成 NULL,因为 b_frame_check_get 只会在校验回调存在并返回成功时交出完整帧。没有校验字段时,写一个最基本的长度或字段合法性检查就好。

2、API 设计

这套解析器最开始只能同时支持一种协议。如果一个项目里出现两种不同的 HEX 协议,难道还要搞两份 .c、两份 .h,然后再改一遍函数名吗?刚毕业工作时我还真这样干过……

所以这里稍微稍微借鉴了一点面向对象的思路:一个 b_frame_type 就是一个独立的协议解析器实例。

typedef struct
{
    b_frame_init_type frame_init;
    ring_buf_t _frame_ring;
    uint32_t _idie_timer;
    uint32_t _systick;
    uint8_t head_match_flg;
} b_frame_type;

协议描述、环形缓冲区、空闲计时和帧头匹配状态全部属于实例。需要同时解析多少种协议,就创建多少个 b_frame_type,每个实例再配一套独立缓冲区。这样多协议之间不会互相影响。

底层环形缓冲区目前主要用到这些接口:

typedef struct
{
    unsigned char *buf;
    unsigned int size;
    unsigned int front;
    unsigned int rear;
} ring_buf_t;

bool ring_buf_init(ring_buf_t *r, unsigned char *buf, unsigned int size);
void ring_buf_clr(ring_buf_t *r);
unsigned int ring_buf_len(ring_buf_t *r);
unsigned int ring_buf_put(ring_buf_t *r, unsigned char *buf, unsigned int len);
unsigned int ring_buf_get(ring_buf_t *r, unsigned char *buf, unsigned int len);
unsigned int ring_buf_check_get(ring_buf_t *r, unsigned char *buf, unsigned int len);
unsigned int ring_buf_clr_len(ring_buf_t *r, unsigned int len);
unsigned char *ring_buf_peek(ring_buf_t *r, unsigned int offset, unsigned int *len);

ring_buf_get 会在读取后移动队列指针,ring_buf_check_get 只偷看数据,不消费;ring_buf_peek 则按偏移查看环形缓冲区中的一个字节。后面匹配帧头时会用到它。

解析器的公共 API 如下:

uint8_t b_frame_init(b_frame_type *pframe,
                     b_frame_init_type *pframeinit);

void b_frame_idie_timer(b_frame_type *pframe);

uint8_t b_frame_put(b_frame_type *pframe,
                    uint8_t *dat,
                    uint32_t len);

const uint8_t *b_frame_check_get(b_frame_type *pframe,
                                 uint16_t *len);

void b_frame_fifo_clear(b_frame_type *pframe);

uint32_t b_frame_fifo_get(b_frame_type *pframe,
                          uint8_t *dat,
                          uint32_t len,
                          uint32_t timeout);

正常的协议解析只需要前四个。b_frame_fifo_clear 用于主动清空接收 FIFO,b_frame_fifo_get 更像一个阻塞式读取指定长度数据的辅助接口。

3、初始化和使用

每个协议实例都要准备独立的输入缓冲区和输出缓冲区。输入缓冲区给 ringbuffer 使用,建议大小直接取 2 的 N 次幂;输出缓冲区必须能够放下最大完整帧。

#include "b_protocol_core.h"

static b_frame_type frame_engine;
static uint8_t frame_in_buffer[256];
static uint8_t frame_out_buffer[256];

static void protocol_init(void)
{
    b_frame_init_type frame_init = {
        .pname = (const uint8_t *)"engine",
        .head = "\xA5\x5A\xAA\x55",
        .end = "\x0D\x0A\x0D\x0A",
        .head_len = 4,
        .end_len = 4,
        .get_frame_len_cb = get_frame_len_cb,
        .check_cb = check_frame_cb,
        .in_frame_buffer = frame_in_buffer,
        .out_frame_buffer = frame_out_buffer,
        .in_buffer_len = sizeof(frame_in_buffer),
        .out_buffer_len = sizeof(frame_out_buffer),
        .log_level = 0,
    };

    (void)b_frame_init(&frame_engine, &frame_init);
}

收到数据后,不用管这次收到的是半帧、一帧还是好几帧,直接调用 b_frame_put:

void protocol_rx(uint8_t *data, uint16_t len)
{
    (void)b_frame_put(&frame_engine, data, len);
}

然后在主循环或任务中轮询:

static void protocol_poll(void)
{
    uint16_t frame_len = 0;
    const uint8_t *frame =
        b_frame_check_get(&frame_engine, &frame_len);

    if (frame == NULL)
    {
        return;
    }

    switch (frame[4])
    {
        /* 根据帧类型处理数据 */
        default:
            break;
    }
}

返回的 frame 指向实例自己的 out_frame_buffer,下一次解析可能覆盖它。如果后面的业务要长期保存这一帧,记得及时拷贝。


三、代码实现

1、日志

协议解析最难受的地方通常不是写,而是出问题后不知道错在哪,所以保留一点日志还是很有必要的。

当前版本支持两层开关:EN_FRAME_DEBUG 用来全局编译关闭;打开后,每个协议实例还可以通过 log_level 控制输出等级。

#define EN_FRAME_DEBUG 0

#define FRAME_LOG(p, lvl, ...)                         \
    do                                                 \
    {                                                  \
        if ((p) && (p)->frame_init.log_level >= (lvl)) \
        {                                              \
            xprintf(__VA_ARGS__);                      \
        }                                              \
    } while (0)

#define FRAME_RAW_INFO_PRINTF(p, ...) FRAME_LOG(p, 1, __VA_ARGS__)
#define FRAME_LOG_INFO_PRINTF(p, ...) FRAME_LOG(p, 2, __VA_ARGS__)

EN_FRAME_DEBUG 为 0 时,这些日志宏会被编译为空;打开时工程需要提供 xprintf。嗯,够简单了~

2、初始化

初始化主要做三件事:检查必要参数,把协议描述复制到实例里,然后用用户提供的输入缓冲区初始化 ringbuffer。

uint8_t b_frame_init(b_frame_type *pframe,
                     b_frame_init_type *pframeinit)
{
    uint8_t err = 0;
    if ((!pframeinit) || (!pframe) ||
        (!pframeinit->in_frame_buffer) ||
        (!pframeinit->out_frame_buffer))
    {
        return B_ERROR;
    }

    if (pframeinit->head == NULL)
        pframeinit->head_len = 0;
    if (pframeinit->end == NULL)
        pframeinit->end_len = 0;

    pframe->frame_init.pname = pframeinit->pname;
    pframe->frame_init.check_cb = pframeinit->check_cb;
    pframe->frame_init.end = pframeinit->end;
    pframe->frame_init.end_len = pframeinit->end_len;
    pframe->frame_init.get_frame_len_cb =
        pframeinit->get_frame_len_cb;
    pframe->frame_init.head = pframeinit->head;
    pframe->frame_init.head_len = pframeinit->head_len;
    pframe->frame_init.in_frame_buffer =
        pframeinit->in_frame_buffer;
    pframe->frame_init.out_frame_buffer =
        pframeinit->out_frame_buffer;
    pframe->frame_init.in_buffer_len =
        pframeinit->in_buffer_len;
    pframe->frame_init.out_buffer_len =
        pframeinit->out_buffer_len;
    pframe->frame_init.log_level = pframeinit->log_level;
    pframe->head_match_flg = 0;

    err = ring_buf_init(&pframe->_frame_ring,
                        pframe->frame_init.in_frame_buffer,
                        pframe->frame_init.in_buffer_len);
    b_frame_fifo_clear(pframe);

    return err ? B_SUCCESS : B_ERROR;
}

frame_init 结构体本身可以是局部变量,因为配置已经逐字段复制进 frame_engine。不过这些字段里的指针只是被保存,并没有深拷贝;pname、head、end 以及输入、输出缓冲区指向的数据都必须一直有效。示例里的名称、帧头和帧尾是字符串常量,两个缓冲区则是静态数组,正好满足这个条件。

如果协议没有帧头,就让 head = NULL;没有帧尾,就让 end = NULL。初始化函数会自动把对应长度置 0。

3、put 函数

这个函数基本没什么好讲的,就是把数据丢进队列,再加一点安全处理。

uint8_t b_frame_put(b_frame_type *pframe,
                    uint8_t *dat,
                    uint32_t len)
{
    if (!dat || len == 0)
    {
        return B_ERROR;
    }

    pframe->_idie_timer = 0;

    uint32_t putlen =
        ring_buf_put(&pframe->_frame_ring, dat, len);

    if (putlen != len)
    {
        ring_buf_clr(&pframe->_frame_ring);
        return B_ERROR;
    }

    return B_SUCCESS;
}

当前实现要求本次数据全部写入。空间不够而只能部分写入时,会直接清空输入 FIFO 并返回错误,避免留下半截、不知道从哪里开始的数据。

4、匹配帧头

最新版本不再一边比较一边直接拿走所有候选字节,而是通过 ring_buf_peek 查看;完整帧头匹配成功后才一次性消费帧头。遇到不匹配就丢掉一个字节,再从新的队首重新开始。

static uint8_t b_check_head(b_frame_type *pframe)
{
    if (pframe->frame_init.head_len == 0)
        return B_SUCCESS;

    uint8_t i = 0;
    uint8_t *pd = NULL;
    uint16_t len = ring_buf_len(&pframe->_frame_ring);

    if (len < (pframe->frame_init.head_len +
               pframe->frame_init.end_len + 1))
    {
        return B_ERROR;
    }

    do
    {
        pd = ring_buf_peek(&pframe->_frame_ring, i, NULL);
        if (pd == NULL)
            break;

        if (*pd == (uint8_t)pframe->frame_init.head[i])
        {
            if (++i == pframe->frame_init.head_len)
            {
                ring_buf_clr_len(&pframe->_frame_ring,
                                 pframe->frame_init.head_len);
                return B_SUCCESS;
            }
        }
        else
        {
            ring_buf_clr_len(&pframe->_frame_ring, 1);
            i = 0;
        }
    } while (1);

    return B_ERROR;
}

这种写法还有一个好处:遇到 AA AA 55 这种和帧头局部重叠的数据时,每次只丢一个字节,不会轻易跨过真正的帧头。

5、解析完整帧

b_frame_check_get 是整个模块的核心,流程大致如下:

  1. 当前实例还没有匹配到帧头时,调用 b_check_head。
  2. 帧头匹配成功后,把帧头复制到 out_frame_buffer,并置位实例自己的 head_match_flg。
  3. 用 ring_buf_check_get 偷看剩余数据,不移动输入 FIFO。
  4. 调用 get_frame_len_cb,得到包含帧头和帧尾的完整帧长。
  5. 数据不够就继续等;长时间没有新数据则复位匹配状态。
  6. 数据够了以后检查帧尾,再调用 check_cb 校验完整帧。
  7. 校验通过才真正消费 FIFO 中剩余帧数据,并返回 out_frame_buffer。

其中最关键的长度处理是下面这段:

uint16_t data_len =
    ring_buf_check_get(
        &pframe->_frame_ring,
        &pframe->frame_init.out_frame_buffer[
            pframe->frame_init.head_len],
        pframe->frame_init.out_buffer_len -
            pframe->frame_init.head_len);

unhead_frame_len =
    pframe->frame_init.get_frame_len_cb(
        pframe->frame_init.out_frame_buffer,
        data_len + pframe->frame_init.head_len);

if (unhead_frame_len >= pframe->frame_init.head_len)
{
    unhead_frame_len -= pframe->frame_init.head_len;
}

if ((data_len < unhead_frame_len) ||
    (unhead_frame_len == 0))
{
    return NULL;
}

回调返回完整帧长,解析器再减去已经消费掉的帧头长度,得到 FIFO 中还需要多少数据。帧尾匹配和校验通过后,才把这部分数据真正丢出 FIFO:

*len = pframe->frame_init.head_len + unhead_frame_len;

if (pframe->frame_init.check_cb != NULL)
{
    uint8_t err =
        pframe->frame_init.check_cb(
            pframe->frame_init.out_frame_buffer,
            *len);

    if (err == B_SUCCESS)
    {
        pframe->head_match_flg = 0;
        ring_buf_clr_len(&pframe->_frame_ring,
                         unhead_frame_len);
        return pframe->frame_init.out_frame_buffer;
    }
}

pframe->head_match_flg = 0;
return NULL;

head_match_flg 现在是 b_frame_type 的成员,不再是函数里的静态变量。这一点对多协议支持非常关键,否则两个解析器实例轮询时会共用同一个匹配状态,迟早串台。


四、杂项

还记得协议实例里的 _idie_timer 和 _systick 吗?它们主要用来做异常恢复。

我曾经遇到过一种协议,长度字段有 2 个字节,数据量又非常大。某次帧头在数据区里发生冲突,长度字段刚好被解释成 0xFFFF,解析器就会一直等一个永远收不完整的超长帧。哦豁,卡死。

所以模块里模拟了串口空闲超时。每收到一批新数据,_idie_timer 会清零;如果已经算出帧长但后续数据迟迟不来,超过阈值后就放弃本次匹配,重新寻找帧头。

需要给模块提供一个心跳,按 IDIE_TIMER_US 周期调用。默认值是 1000 us,也就是通常每 1 ms 调用一次:

void b_frame_idie_timer(b_frame_type *pframe)
{
    pframe->_idie_timer++;
    pframe->_systick++;
}

实际移植时,还有几个细节值得再强调一遍:

  • 每一种协议使用独立的 b_frame_type、输入缓冲区和输出缓冲区。
  • in_buffer_len 建议直接使用 64、128、256、512 这类 2 的 N 次幂。
  • out_buffer_len 必须能放下最大完整帧,并且不能小于 head_len。
  • get_frame_len_cb 返回完整帧长;信息不足时返回 0。
  • check_cb 不能为 NULL,成功时返回 B_SUCCESS。
  • b_frame_check_get 返回的是内部输出缓冲区,长期使用前要及时拷贝。
  • b_frame_put 空间不足时会清空输入 FIFO,调用方要检查返回值。
  • 打开 EN_FRAME_DEBUG 后需要由工程提供 xprintf。

结尾

okkkkk,基本讲得差不多了。

完整代码放在 Gitee 仓库,本文整理时对应最新提交 5b0c86b。

欢迎点赞关注。如果你在使用这个模块时发现什么 bug,请务必反馈,不胜感激~~~

返回文章列表