202509
systems

剖析 sj.h:150 行 C99 实现零分配 JSON 解析器核心设计

详解 sj.h 如何通过状态机与指针不复制策略,在 150 行内实现零内存分配的 JSON 解析,附带迭代器使用与错误处理范式。

在嵌入式系统、高性能服务或内存敏感场景中,JSON 解析器的内存开销常成为瓶颈。传统库如 cJSON 或 RapidJSON 虽功能完备,却因动态分配或复杂结构带来额外负担。sj.h——一个仅约 150 行的 C99 头文件库,以“零分配”为核心设计哲学,提供了一种极致轻量的解决方案。本文将深入剖析其如何通过状态机驱动与指针不复制策略,在不牺牲错误定位能力的前提下,实现真正的零内存分配解析。

sj.h 的零分配设计并非魔法,而是源于两项关键技术决策:状态机无栈迭代与原始字符串指针引用。首先,它摒弃了递归下降或显式 token 栈(如 jsmn 所用),转而采用一个紧凑的状态机(sj_Reader),在单次遍历中逐字符推进。状态机仅维护当前解析位置、嵌套层级与少量标志位,避免了栈空间的动态增长。其次,也是最关键的,sj.h 不复制任何 JSON 字符串内容。其核心数据结构 sj_Value 仅包含两个 char* 指针:start 与 end,分别指向原始 JSON 缓冲区中某个值(如键名、数字字符串、布尔字面量)的起始与结束位置。这意味着,无论 JSON 文档多大,sj.h 本身不会调用 malloc 或触发任何堆分配;所有“数据”都只是对输入缓冲区的视图。

这种设计将内存管理的责任完全交还给用户。库不调用 atoi、strtod 或处理 Unicode 转义,而是将数值字符串或转义序列的指针直接暴露。用户可根据上下文选择最合适的转换方式:在资源受限设备上用轻量级自定义函数,在服务器端则可调用标准库。这不仅实现了零分配,还赋予了用户极大的灵活性。例如,若只需比较键名,可直接用 memcmp 对比指针区间,无需先转换为 C 字符串;若需提取数字,再针对性调用 atoi。以下代码片段展示了这一范式:

bool eq(sj_Value val, char *target) {
    size_t len = val.end - val.start;
    return strlen(target) == len && !memcmp(val.start, target, len);
}

// 在迭代中使用
sj_Value key, val;
while (sj_iter_object(&reader, obj, &key, &val)) {
    if (eq(key, "timeout_ms")) {
        config.timeout = atoi(val.start); // 用户在此处决定如何转换
    }
}

sj.h 的迭代器接口是其无栈设计的自然延伸。解析顶层对象或数组后,用户通过 sj_iter_object 或 sj_iter_array 获得一个轻量级迭代器。每次调用迭代器函数,状态机仅前进一步,返回当前元素的键与值(均为 sj_Value 指针),并更新内部状态以备下次调用。整个过程无中间数据结构,无递归,内存开销恒定。这与需要先构建完整 DOM 树或 token 列表的库形成鲜明对比,特别适合流式处理或仅需部分字段的场景。

零分配不等于无错误处理。sj.h 在保持极简的同时,提供了精确到行列号的错误定位。状态机在遇到非法字符或结构时,会记录当前行号与列号,并通过 sj_error 返回。用户可据此快速定位 JSON 文本中的问题。错误处理同样轻量:状态机在出错后进入“错误”状态,后续调用将快速失败,避免无效计算。一个健壮的使用模式是:先检查 sj_read 返回的顶层值是否有效,再在迭代循环中定期检查 reader.error。

当然,sj.h 的设计有其适用边界。它不解析数字或字符串内容,这意味着用户需自行处理数值溢出、浮点精度或 Unicode 代理对。对于需要完整 DOM 树或频繁随机访问的场景,它可能不是最佳选择。然而,对于配置文件加载、协议解析或仅需提取少数字段的用例,sj.h 的零分配、低开销特性极具吸引力。其代码之精简(约 150 行),使得开发者可轻松阅读、审计甚至按需修改,这在安全敏感或定制化要求高的项目中是巨大优势。

总结而言,sj.h 通过“不分配、不复制、不转换”的三不原则,实现了极致的轻量级 JSON 解析。其核心在于将复杂性外推:状态机处理结构,用户处理语义。这种设计哲学使其在嵌入式与系统编程领域独树一帜。当你需要一个可预测、零开销的 JSON 前端时,sj.h 值得你深入研究与集成。