cJSON 大概是用得最广的 C 语言 JSON 库之一,两个文件、无依赖、MIT 协议,嵌入式项目里到处能见到它。最近因为给项目加注释,顺便把它的数据结构和解析流程完整走了一遍,记在这里。
cJSON 结构体
所有 JSON 值——不管是对象、数组、字符串还是数字——都用同一个结构体表示,定义在 cJSON.h 里:
typedef struct cJSON {
struct cJSON* next; /* 同级下一个兄弟节点 */
struct cJSON* prev; /* 同级上一个兄弟节点 */
struct cJSON* child; /* 第一个子节点(仅 Array/Object 使用) */
int type; /* 节点类型(位掩码) */
char* valuestring; /* 字符串值(String/Raw 类型) */
int valueint; /* 整数值(已废弃,建议用 valuedouble) */
double valuedouble; /* 数字值(Number 类型) */
char* string; /* 键名(仅当节点是对象的子项时有效) */
} cJSON;
一个节点同时干两件事:表示一个值,也链接着同级的兄弟和下一级的子节点。next/prev 组成双向链表,child 指向第一个子节点。对象里的键名存在 string 字段里,字符串值存在 valuestring,数字用 valuedouble,valueint 已经标记为废弃了。
也就是说,整棵 JSON 树就是一棵用 next/prev 和 child 串起来的树状链表。
在 64 位系统上,一个节点大约占 64 字节(对齐后),具体布局如下:
[图示:64 位系统下 cJSON 结构体内存偏移]
| 偏移 (字节) | 大小 | 成员 |
|---|---|---|
| 0 | 8 字节 | next |
| 8 | 8 字节 | prev |
| 16 | 8 字节 | child |
| 24 | 4 字节 | type |
| 28 | 4 字节 | (padding) |
| 32 | 8 字节 | valuestring |
| 40 | 4 字节 | valueint |
| 44 | 4 字节 | (padding) |
| 48 | 8 字节 | valuedouble |
| 56 | 8 字节 | string |
32 位下因为指针短,大概 40 字节左右。
类型没有用 enum,而是用位掩码定义在低 8 位:cJSON_False 是 1,True 是 2,Number 是 8,String 是 16,Array 是 32,Object 是 64,Raw 是 128。这样一来,还可以在高位上附加标记,比如 cJSON_IsReference (256) 和 cJSON_StringIsConst (512),用来控制释放行为。要取纯类型就 type & 0xFF,判断是否引用就 type & cJSON_IsReference,很干净。
举个例子,JSON 字符串 {"name": "Alice", "age": 25, "scores": [90, 95, 88]} 解析之后的内存结构差不多是这样:
- 根节点 type 是 Object,child 指向第一个键值对 "name":"Alice"。
- 该键值对节点:string="name",valuestring="Alice",type 是 String。它的 next 指向下一个键值对 "age":25,再 next 指向 "scores":[90,95,88]。
- "scores" 的值是一个 Array 节点,它的 child 指向第一个数字节点 90,90 的 next 指向 95,再 next 指向 88。
数组/对象的一层就是一条双向链表(next/prev),向下走一层靠 child。另外有个小技巧:链表头的 prev 始终指向最后一个节点,这样在尾部追加节点就是 O(1)。
解析过程:从字符串到 cJSON 树
入口是 cJSON_Parse(const char *value),它内部调用 cJSON_ParseWithOpts,再调用 cJSON_ParseWithLengthOpts,初始化一个 parse_buffer,分配根节点,跳过可能的 BOM 和空白,然后调 parse_value 开始递归解析。如果要求必须 0 结尾,最后还会检查 \0。失败会走 goto fail 释放已分配节点,并设置 global_error。
所以核心就落在 parse_value 上,它根据当前字符决定下一步:
- 见到
n就匹配 null f匹配 falset匹配 true"调 parse_string 解析字符串,包括\uXXXX转义-或数字开头的调 parse_number,内部用 strtod,还做了 int 溢出饱和[调 parse_array{调 parse_object- 其他字符直接失败
parse_array 和 parse_object 都会先检查深度是否超过 CJSON_NESTING_LIMIT(默认 1000),防止恶意嵌套撑爆栈。
parse_array 省略 [ 后,如果是 ] 就生成空数组;否则进 do-while 循环:每次 new 一个节点,挂到当前链表尾,然后递归 parse_value 解析这个元素,直到遇到 ] 或 , 继续。最后把 head->prev 指向尾节点,item->child = head。
parse_object 类似,只是每个循环里先 parse_string 得到键名(先临时存在 valuestring,然后移到 string 字段),跳过 :,再 parse_value 得到值,用 next/prev 串起来。
解析失败的时候,错误位置会记在 global_error,可以拿 cJSON_GetErrorPtr() 取到——不过多线程下这个变量是全局的,得小心。
生成:从 cJSON 树到字符串
生成由 cJSON_Print(带缩进和换行)或 cJSON_PrintUnformatted(紧凑)开始。内部 print 函数先分配一个 256 字节的 printbuffer,然后调 print_value 递归输出。输出过程中用 ensure 函数动态扩容,空间不够就两倍 realloc。
print_value 同样按 type 分派:Null 输出 "null",True/False 对应 "true"/"false",数字调用 print_number(NaN/Inf 输出 "null",整数用 %d,浮点数先用 15 位精度,不够再 17 位,并处理 locale 小数点),字符串调用 print_string_ptr 加引号并转义,Raw 类型直接输出 valuestring,数组和对象则分别输出 [...] 和 {...},中间递归调用 print_value。
返回的字符串需要用 cJSON_free 释放,否则内存泄漏。
回过头看,cJSON 有几个设计点我觉得挺巧的:
- 树状链表:child 指向子节点,next/prev 串兄弟,加上 head->prev 指尾,既能表达复杂的 JSON 结构,又保证尾部插入 O(1)。
- 位掩码类型:一个 int 既表示基本类型,又能标记是否引用、键名是否常量,省字段、好扩展。
- 内存管理:解析失败统一 goto fail + cJSON_Delete,释放时对兄弟用循环、对子树用递归,并且尊重 IsReference 和 StringIsConst,避免错误释放。
- 可插拔的分配器:用 internal_hooks 封装 malloc/free/realloc,用户能自己替换;如果不用标准库,就把 realloc 置空,内部会用 malloc+memcpy+free 来模拟扩容。
- 数字处理:NaN/Inf 输出 "null",确保不出非法 JSON;小数解析时还会将 locale 下的其他小数点字符(比如逗号)替换回
.,兼顾本地化。 - 嵌套深度限制:默认 1000 层,防止栈溢出,实际中很少有人会用到这么深的 JSON。
深度注释实践
如果像我一样要给 cJSON 加注释,可以分三层来写:
函数级:用 Doxygen 风格,说明作用、参数、返回值,尤其要写清楚谁负责释放内存、是否线程安全。
代码块注释:对一整段逻辑说明在做啥、为什么这么做、有哪些内存/错误处理的注意点。
关键行注释:对容易误解或者和安全相关的行做简短说明,比如 offset++ 跳过 [ 时注释一下,或者嵌套深度检查那里说明是为了防栈溢出。
在仓库的 docs/ 目录下,我已经放了两份带注释的副本 cJSON_annotated.c 和 cJSON_annotated.h,可以参考——注意它们不能直接替换原始文件用,只是文档。
如何运行与测试
说明:docs/cJSON_annotated.c 和 docs/cJSON_annotated.h 是带注释的文档型副本,不能直接替代工程里的 cJSON 使用;实际编译、运行、测试请用仓库根目录下的原始 cJSON.c 和 cJSON.h。
单文件 demo 编译(推荐)
在 fuzzing/docs/ 下已提供一个单文件测试程序 test_cjson_demo.c,用「原始 cJSON」编译即可:
Linux / macOS / MinGW(gcc):
cd /path/to/cJSON-1.7.19/fuzzing/docs
gcc -o test_cjson_demo test_cjson_demo.c ../../cJSON.c -I../../ -lm
./test_cjson_demo
Windows(MSVC Developer Command Prompt):
cd d:\path\to\cJSON-1.7.19\fuzzing\docs
cl test_cjson_demo.c ../../cJSON.c /I../../ /Fe:test_cjson_demo.exe
test_cjson_demo.exe
该 demo 覆盖:解析、打印、手动建树、类型判断、链表遍历、错误处理、Minify、Duplicate、版本信息等,可以和 cJSON_annotated.c/.h 的注释对照看。
用官方测试套件(CMake)
若要跑 cJSON 仓库自带的单元测试:
cd /path/to/cJSON-1.7.19
mkdir build && cd build
cmake ..
cmake --build .
ctest
以上大概就是 cJSON 1.7.19 的数据结构和解析/生成流程。真正用的时候,一个 demo 跑一遍,对照着看源码,很快就能上手。
参考资料
- cJSON 源码:https://github.com/DaveGamble/cJSON
- JSON 规范:https://www.json.org/


