microcbor 原理:头字节、sticky overflow 与迭代式 skip

声明: microcbor 是一个开源项目,作者为 Vanderhell。本文是阅读该项目源码和文档后整理的学习笔记,用于理解嵌入式场景下 CBOR 编解码的工程实现方式。本文作者不是该项目的开发者,未参与该项目的任何代码贡献。 文中所有工程细节均来自对开源代码的分析,不代表本文作者的设计决策。

项目仓库:github.com/Vanderhell/microcbor(单文件 C99 实现,~500 行)

配套入门篇:microcbor 入门:MCU 上用 CBOR 编码遥测消息

CBOR 线格式基础

CBOR 把每一项编码成「头 + 可选负载」。头字节的高 3 位是 major type(大类),低 5 位是 additional info(参数长度)。microcbor 所有编解码逻辑都是围绕这个头字节展开的。

CBOR 初始字节结构

major type 与 microcbor 支持情况:

major 含义 microcbor 支持
0 unsigned int ✅ 32 位
1 negative int ✅ 32 位(编码为 -1-n)
2 byte string ✅ 定长
3 text string ✅ 定长 + UTF-8 校验
4 array ✅ 定长
5 map ✅ 定长
6 tag ❌ 直接报 invalid
7 simple/float ✅ bool/null/float32

additional info 的宽度规则在 RFC 里是统一表:0–23 直接当值;24 表示后面跟 1 字节参数;25 跟 2 字节大端;26 跟 4 字节大端;27 跟 8 字节(microcbor 不处理);31 表示不定长(microcbor 不处理)。

编码器:一次写一个头

编码端的核心是 enc_head,它根据参数大小选最短宽度,把 major type 和宽度编码写进 5 字节临时 buffer,返回实际占用的头长度:

static size_t enc_head(uint8_t major, uint32_t value, uint8_t out[5]) {
    if (value <= 23) {
        out[0] = (uint8_t)(major | (uint8_t)value);
        return 1;
    } else if (value <= 0xFF) {
        out[0] = (uint8_t)(major | CBOR_INFO_1BYTE);  /* 24 */
        out[1] = (uint8_t)value;
        return 2;
    } else if (value <= 0xFFFF) {
        out[0] = (uint8_t)(major | CBOR_INFO_2BYTE);  /* 25 */
        put_u16(out + 1, (uint16_t)value);
        return 3;
    } else {
        out[0] = (uint8_t)(major | CBOR_INFO_4BYTE);  /* 26 */
        put_u32(out + 1, value);
        return 5;
    }
}

mcbor_enc_uint 直接调用 enc_append_head_only(enc, CBOR_MAJOR_UINT, value)mcbor_enc_int 对负数做 -1-n 转换后改用 major 1;字符串/字节串先用 enc_head(CBOR_MAJOR_TEXT, len, ...) 写长度头,再 memcpy payload;array/map 只写头,不校验后续是否真有那么多项。

这种设计的结果是:编码器是纯顺序写,没有回溯修补长度的操作(因为只支持定长容器,调用方必须在写 mcbor_enc_map(n) 前就知道有 n 对)。代价是调用方必须对结构有把握;收益是 encoder 不需要维护容器栈,代码只有 ~130 行。

宽度选择的边界

几个值得注意的宽度判断:

sticky overflow:链式编码只查一次

编码器结构体只维护 buf / capacity / pos / overflow 四个字段,其中 overflow 是 sticky bit:

static mcbor_err_t enc_prepare_write(const mcbor_enc_t *enc, size_t len) {
    if (enc->overflow) return MCBOR_ERR_OVERFLOW;
    if (enc->pos > enc->capacity) return MCBOR_ERR_INVALID;
    if (len > enc->capacity - enc->pos) {
        return MCBOR_ERR_OVERFLOW;
    }
    return MCBOR_OK;
}

一旦某次写入触发 overflow,enc_append_item 会把 enc->overflow = true 置位,后续所有写入在 enc_prepare_write 第一行就直接返回错误,不再推进 pos。这样调用方可以安全地连写一长串:

mcbor_enc_map(&enc, 4);
mcbor_enc_str(&enc, "dev");   mcbor_enc_str(&enc, "s-01");
mcbor_enc_str(&enc, "temp");  mcbor_enc_float(&enc, 23.4f);
/* ... */
if (mcbor_enc_overflow(&enc, &ovf) != MCBOR_OK || ovf) {
    /* 整个消息放弃 */
}

不用每次都检查返回值,也不会因为中间某次溢出导致 pos 越界写内存。这是典型的"记录错误、继续做空跑、收尾统一处理"模式,在 telemetry 这种"消息要么完整发出、要么整块丢弃"的场景里比逐次 goto fail 更实用。

另一个防御性细节是 ranges_overlap 检查:如果调用方把输出 buffer 内的某段作为 payload 传给 mcbor_enc_text/bytes,且这段区间与即将写入的位置重叠(典型出现在"边编码边用自己 buffer 里的字符串"的错误用法),enc_append_item 直接返回 MCBOR_ERR_INVALID,避免 memcpy 自覆盖产生未定义行为。

解码器:dec_next_core 一次读一个完整项

解码入口是内部的 dec_next_core,它先读 1 字节 initial byte,拆出 major/info,再通过 dec_argument 按 info 宽度读取参数(整数 argument 或长度),然后按 major 分发:

所有 typed convenience 函数(mcbor_dec_uint/float/str/...)都遵循同一个模板:先 next = *dec 局部拷贝,在副本上跑 dec_next_core,成功了才 *dec = next 提交读指针。失败时 dec 状态保持不变,调用方可以回退或报告错误。这是 commit-or-rollback 模式,代价是一个结构体拷贝(很小),换回来的是错误路径下不会把读指针推到半中间状态。

mcbor_dec_strmcbor_dec_bytes_ref 的差异体现了一个明确的责任边界:

迭代式 skip:不用递归跳嵌套

mcbor_dec_skip 是整个库里唯一需要处理嵌套结构的地方,实现上完全没有递归,用一个 pending 计数器做广度展开:

static mcbor_err_t dec_skip_core(mcbor_dec_t *dec) {
    size_t pending = 1;
    while (pending != 0) {
        mcbor_value_t val;
        size_t child_count = 0;
        mcbor_err_t err = dec_next_core(dec, &val);
        if (err != MCBOR_OK) return err;
        pending--;
        if (val.type == MCBOR_ARRAY) {
            child_count = val.val.container;
        } else if (val.type == MCBOR_MAP) {
            if (val.val.container > SIZE_MAX / 2) return MCBOR_ERR_RANGE;
            child_count = val.val.container * 2;
        }
        if (child_count != 0) {
            size_t remaining = dec->size - dec->pos;
            if (child_count > remaining) return MCBOR_ERR_INVALID;
            if (child_count > SIZE_MAX - pending) return MCBOR_ERR_RANGE;
            pending += child_count;
        }
    }
    return MCBOR_OK;
}

工作方式:

  1. 初始 pending = 1(要跳过当前这一项)。
  2. 每迭代一次读出一个完整项(dec_next_core 对 string/bytes 已经前移了 payload 指针),pending 减 1。
  3. 如果这一项是 array(n),说明后面还紧跟着 n 个直接子项,把 n 加到 pending;如果是 map(n),后面紧跟着 2n 个项(n 个 key + n 个 value),加 2n。
  4. pending 回到 0 时,整个值(包括所有嵌套)都被"读掉了",读指针正好停在下一项之前。

两个防整数溢出的检查是必要的:val.val.container * 2 在 n 接近 SIZE_MAX/2 时会回绕,所以先判断 > SIZE_MAX/2pending += child_count 也可能溢出,所以判断 > SIZE_MAX - pending。恶意 CBOR 报文可以把 n 设成 0xFFFFFFFF,递归实现会爆栈,这个迭代实现如果漏了检查就会 pending 回绕到 0 导致 skip 提前结束、后续解析错位。

mcbor_validate_one 的实现就是 mcbor_dec_init → mcbor_dec_skip → 检查 dec.pos == dec.size,复用 skip 做完整校验,不另写校验路径。这是个很干净的设计:skip 只要保证不漏读、不越界、不无限循环,validate 自然正确。

UTF-8 校验放在编码端

mcbor_enc_text 在写文本串之前先跑一次 is_valid_utf8,校验失败直接返回 MCBOR_ERR_INVALID;解码端 dec_next_core 对 major 3 也会再跑一次。两次校验看起来冗余,但责任不同:

is_valid_utf8 的实现是手写的单遍状态机,覆盖 1–4 字节序列以及 0xE0/0xED/0xF0/0xF4 这几个 lead byte 的特殊范围(拒绝 overlong 编码、UTF-16 surrogate halves、超过 U+10FFFF 的码位)。嵌入式目标库选择手写校验具有明确原因:无外部依赖、代码量固定、可在裸机上跑。

浮点位模式转换

float_to_bits / bits_to_floatmemcpyuint32_tfloat 之间搬 4 字节,不做类型 punning。前面提到的编译期静态断言:

typedef char mcbor_float32_must_be_ieee754_binary32[
    (sizeof(float) == 4 && FLT_RADIX == 2 &&
     FLT_MANT_DIG == 24 && FLT_MAX_EXP == 128) ? 1 : -1];

它通过 <float.h> 的常量判断宿主平台的 float 为 binary32:基数 2、尾数 24 位(含隐含 1)、最大指数 128,这三个条件合起来等价于 IEEE 754 single precision。在不符合这个约束的平台(极少数嵌入式编译器有非 IEEE 浮点模式)上会直接编译报错,避免静默生成错误的 bit pattern。float16/float64 不在子集里,省掉了 half<->single 转换表和 64 位端序读写。

设计取舍:砍掉的特性与原因

结合 DESIGN.md 的说明和代码,几个裁剪决策是连贯的:

这些裁剪共同把实现压到单文件 ~500 行,且保证每条 API 的栈用量都是常数(skip 用迭代、encode 无递归、decode 无递归),对极小 RAM 的 MCU 友好。

适用边界回顾

理解内部实现之后,几个使用边界就有明确原因:

入门篇里那个 demo 跑通之后,回头看这几个机制,就能判断什么时候该用 microcbor、什么时候需要换 Nanopb/cn-cbor 这类更完整的实现。