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 所有编解码逻辑都是围绕这个头字节展开的。
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 行。
宽度选择的边界
几个值得注意的宽度判断:
value <= 23走 1 字节头(单字节 0x00–0x17 直接表示小整数,这是 CBOR 定义的)。23 这个分界来自 RFC 本身,microcbor 并未自定义。- 有符号整数
mcbor_enc_int对非负数转调 uint 编码(major 0),对负数走 major 1 并把值映射为-(value+1)的无符号表示。-1 编码成0x20(major 1, info=0),是最短负整数。 - float32 固定走 major 7 + info=26 + 4 字节 IEEE 754 bit pattern;库用
memcpy(&bits, &f, 4)做 bit cast,避开 strict aliasing,并用编译期typedef char assert[(sizeof(float)==4 && ...)?1:-1]硬约束 float 必须是 binary32。
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 分发:
- uint/negint:直接把 argument 填进
val.val.uint_val / int_val(negint 做-1-argument转换并检查argument > INT32_MAX防越界)。 - bytes/text:先
dec_require_bytes(dec, argument)校验剩余字节够,text 额外走is_valid_utf8,然后把val.val.bytes.ptr指向输入 buffer 里这段起始位置,pos 前移长度。这就是mcbor_dec_bytes_ref零拷贝的来源:它不复制数据,只返回指针。 - array/map:只把 count 填到
val.val.container,不递归,也不校验后面有没有那么多项。 - simple(major 7):info 20/21/22 对应 false/null/true 单字节;info=26 读 4 字节 float bit pattern;其余返回 invalid。
所有 typed convenience 函数(mcbor_dec_uint/float/str/...)都遵循同一个模板:先 next = *dec 局部拷贝,在副本上跑 dec_next_core,成功了才 *dec = next 提交读指针。失败时 dec 状态保持不变,调用方可以回退或报告错误。这是 commit-or-rollback 模式,代价是一个结构体拷贝(很小),换回来的是错误路径下不会把读指针推到半中间状态。
mcbor_dec_str 和 mcbor_dec_bytes_ref 的差异体现了一个明确的责任边界:
- 文本字符串用
mcbor_dec_str,库负责拷贝到调用方 buffer 并补\0,保证拿到的是普通 C 字符串; - 二进制 payload 用
mcbor_dec_bytes_ref,库只返回指针和长度,不拷贝,调用方保证解码期间输入 buffer 不被释放或覆写。
迭代式 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;
}工作方式:
- 初始
pending = 1(要跳过当前这一项)。 - 每迭代一次读出一个完整项(
dec_next_core对 string/bytes 已经前移了 payload 指针),pending 减 1。 - 如果这一项是 array(n),说明后面还紧跟着 n 个直接子项,把 n 加到 pending;如果是 map(n),后面紧跟着 2n 个项(n 个 key + n 个 value),加 2n。
- pending 回到 0 时,整个值(包括所有嵌套)都被"读掉了",读指针正好停在下一项之前。
两个防整数溢出的检查是必要的:val.val.container * 2 在 n 接近 SIZE_MAX/2 时会回绕,所以先判断 > SIZE_MAX/2;pending += 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 也会再跑一次。两次校验看起来冗余,但责任不同:
- 编码端校验是让"我自己编码出的 CBOR 一定合法",发现调用方传入了非法 UTF-8(比如二进制数据误当字符串),在源头报错。
- 解码端校验是让"从外部接收到的 CBOR 如果含非法 UTF-8 文本就拒绝",防御对端问题。
is_valid_utf8 的实现是手写的单遍状态机,覆盖 1–4 字节序列以及 0xE0/0xED/0xF0/0xF4 这几个 lead byte 的特殊范围(拒绝 overlong 编码、UTF-16 surrogate halves、超过 U+10FFFF 的码位)。嵌入式目标库选择手写校验具有明确原因:无外部依赖、代码量固定、可在裸机上跑。
浮点位模式转换
float_to_bits / bits_to_float 用 memcpy 在 uint32_t 和 float 之间搬 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 的说明和代码,几个裁剪决策是连贯的:
- 不定长:需要在数据流里打 "break" 标记,decoder 要维护一个"读到哪个容器的 break"栈,显著增大代码。遥测消息长度在编码时一定已知。
- 64 位整数:8 字节参数会让所有
uint32_t argument局部变量升级成 64 位,在 Cortex-M0 这类 32 位核上产生软件模拟运算;传感器值、时间戳(到 2106 年的秒数都在 2^32 内)极少需要 64 位。 - Tag:给值加语义注解(比如"这是个日期"、"这是个正 bignum"),但通信双方一般有预定义 schema,Tag 只是在 wire 上多加 1–2 字节。
- 半精度/双精度:float16 需要转换表,float64 加大 payload 并把所有 bit cast 升级成 64 位;遥测里 float32 足够。
这些裁剪共同把实现压到单文件 ~500 行,且保证每条 API 的栈用量都是常数(skip 用迭代、encode 无递归、decode 无递归),对极小 RAM 的 MCU 友好。
适用边界回顾
理解内部实现之后,几个使用边界就有明确原因:
- 必须提前知道 map/array 元素数,encoder 不回填长度。
- 解码字符串默认拷贝,解码字节串零拷贝;想零拷贝处理文本需用
mcbor_dec_next拿val.val.bytes.ptr,返回的是未补 NUL 的原始切片。 - 输入 buffer 必须在解码期间保持有效,
bytes_ref返回的是内部指针。 - 编码输出非 canonical/deterministic CBOR:map key 顺序、整数宽度按调用方传入原样写入,不适合需要哈希签名的场景。
- float 端序是按
memcpy出来的 bit pattern 走大端写出,要求平台是 IEEE 754 binary32(断言保证)。
入门篇里那个 demo 跑通之后,回头看这几个机制,就能判断什么时候该用 microcbor、什么时候需要换 Nanopb/cn-cbor 这类更完整的实现。