简介:一套包含cJSON.c与cJSON.h的C语言JSON解析与生成源码包,面向嵌入式开发者和需要轻量级数据处理方案的C程序员。cJSON因代码精简、易于集成,常被用于设备通信、配置管理、协议解析等资源受限场景。资源共2个文件,即1个核心头文件与1个实现文件,压缩包仅19KB;头文件集中声明全部API与数据结构,实现文件则涵盖JSON解析流程、对象与数组创建、序列化输出、内存释放等底层逻辑,适合阅读、二次封装或直接移植。已有2902人学习下载。通过这份资源,可掌握cJSON_Parse、cJSON_Print、cJSON_GetObjectItemCaseSensitive等常用接口的实际用法,理解null、数字、字符串、数组、对象等类型的转换与嵌套操作,同时建立主动调用cJSON_Delete避免内存泄漏的意识,为在嵌入式环境中稳定处理JSON数据打下扎实基础。
1. 轻量级 JSON 解析库:为什么嵌入式工程师都绕不开 cJSON
做嵌入式软件开发的人,迟早会遇到这样的需求:设备通过 Wi-Fi 或 4G 模块上报传感器数据,云端返回一条控制指令,而这条指令大概率是 JSON。MCU 上的内存按 KB 算,跑一个通用 JSON 库是不可能的,这时候你会看到 cJSON。它只有两个源文件,加在一起几十 KB 的代码体积,却把 JSON 解析、创建、遍历、打印全部覆盖,而且 API 简洁到一天就能上手。但真正有意思的是,如果你把它当成普通库用,只是摸到皮毛;从源码层面看它怎么管理内存、怎么处理字符串转义、怎么用递归下降解析器逐字符读入,才是对 C 语言功底最好的锻炼。这篇内容面向用 C 做嵌入式、想了解 JSON 库内部机制、或者准备在项目里集成 cJSON 的读者。
2. cJSON 数据结构与 API 分组:从 cJSON.h 看设计思路
2.1 cJSON 结构体:一个指针链搞定对象、数组与值
cJSON.h里最核心的是cJSON结构体,它本身就是一个双向链表节点。常见版本里字段大致是这样的:
typedef struct cJSON { struct cJSON *next; struct cJSON *prev; struct cJSON *child; int type; char *valuestring; int valueint; double valuedouble; char *string; } cJSON;next和prev用于把对象里的多个键或数组里的多个元素串成链表;child指向第一个子节点。type标记当前节点的类型,比如cJSON_Object、cJSON_Array、cJSON_String、cJSON_Number。valuestring、valueint、valuedouble分别保存字符串和数值,string保存的是当前节点在对象里的键名。也就是说,一个 JSON 对象实际是一个链表头,它的每个子节点又是一个链表节点,嵌套结构就是通过child指针层层展开的。
这个设计带来的直接好处是:遍历操作非常简单,只需要从child开始沿着next走就能拿到所有兄弟节点。坏处也很明显,如果代码里写错了child和next的指向关系,很容易产生悬挂指针。因此理解这个结构体,是后续所有 API 调用的基础。
2.2 API 分组与内存所有权规则
把cJSON.h里的函数按用途划分,能明显看出它的设计思路。解析类用cJSON_Parse或cJSON_ParseWithOpts;创建类用cJSON_CreateObject、cJSON_CreateArray、cJSON_CreateString、cJSON_CreateNumber;访问类用cJSON_GetObjectItemCaseSensitive、cJSON_HasObjectItem;打印类用cJSON_Print和cJSON_PrintUnformatted;销毁统一用cJSON_Delete。
| 函数分类 | 典型函数 | 关键点 |
|---|---|---|
| 解析 | cJSON_Parse | 入参是 JSON 字符串,返回根节点指针 |
| 创建 | cJSON_CreateObject | 返回新节点,需要手动挂到父节点 |
| 添加 | cJSON_AddItemToObject | 把子节点移交给父节点,所有权转移 |
| 检查 | cJSON_GetObjectItemCaseSensitive | 大小写敏感,找不到返回 NULL |
| 打印 | cJSON_Print | 返回 malloc 出的格式化字符串 |
| 释放 | cJSON_Delete | 递归释放整棵链表 |
这里最容易被忽略的是内存所有权。cJSON_AddItemToObject或cJSON_AddItemToArray一旦执行,父节点就接管了子节点的内存,后续不能再对这个子节点单独调用cJSON_Delete,否则会 double free。而cJSON_Print返回的字符串也是堆内存,用完必须free。很多嵌入式工程师第一次用 cJSON 时只记得删除 JSON 根节点,忘了释放打印函数返回的字符串,结果一跑长时间就内存泄漏。
2.3 创建一个嵌套 JSON 并打印的最小示例
下面这段代码演示了从零创建对象、添加数组、打印和释放的完整链路,代码里加了注释说明每一步的归属关系。
#include <stdio.h> #include <stdlib.h> #include "cJSON.h" int main(void) { cJSON *root = cJSON_CreateObject(); if (root == NULL) { return -1; } cJSON_AddStringToObject(root, "device_id", "sensor_01"); cJSON_AddNumberToObject(root, "temperature", 26.5); cJSON *tags = cJSON_CreateArray(); cJSON_AddItemToArray(tags, cJSON_CreateString("indoor")); cJSON_AddItemToArray(tags, cJSON_CreateString("north")); cJSON_AddItemToObject(root, "tags", tags); char *text = cJSON_PrintUnformatted(root); if (text != NULL) { printf("%s\n", text); free(text); // 必须释放 } cJSON_Delete(root); // 只释放根节点,子树全部回收 return 0; }首先用cJSON_CreateObject创建根对象,得到的是独立链表节点,此时它还没有父节点。接着cJSON_AddStringToObject和cJSON_AddNumberToObject内部会创建子节点并挂到 root 下。cJSON_CreateArray再生成一个数组节点,然后用cJSON_AddItemToArray逐个加入字符串元素。注意tags数组在挂到 root 之前,可以认为它是一个独立树;一旦执行cJSON_AddItemToObject(root, "tags", tags),所有权就转移给了 root,之后只能通过 root 访问。
最终输出{"device_id":"sensor_01","temperature":26.5,"tags":["indoor","north"]}。这段代码在 PC 上直接编译就能验证,在 MCU 上则需要确保堆空间充足,后面会展开说。
3. 读 cJSON.c 源码:JSON 解析器与打印器的实现要点
3.1 递归下降:从 parse_value 开始拆解
cJSON.c的解析核心是一组按类型分工的函数,整体思路是递归下降。先看一个简化后的调用层次:入口是cJSON_Parse和cJSON_ParseWithOpts,它们初始化一个状态结构体,然后调用parse_value。parse_value根据当前第一个字符决定分支:{走parse_object,[走parse_array,"走parse_string,数字或-走parse_number,t、f、n分别走 true、false、null 的对应解析分支。
// 源码逻辑的伪代码片段,便于理解分支 static cJSON *parse_value(cJSON *item, const unsigned char *const input) { if (input[0] == 'n') { // null /* 设置 type 为 null */ } else if (input[0] == '{') { /* 创建子级,递归进入 parse_object */ } else if (input[0] == '"') { /* 调用 parse_string 解析字符串值 */ } else { /* 走 parse_number */ } }这个架构最有价值的地方在于:它把复杂问题拆成了一个小问题链。parse_object的工作是逐对解析键和值,遇到冒号后调用parse_value处理值,值本身又可能是一个嵌套对象,于是递归自然产生。parse_array也类似,读到一个元素后继续看下一个逗号还是右括号。嵌入式开发者在移植或裁剪时,只需要关注这几个函数,不需要改动外部调用接口。
3.2 内存分配与字符串处理的细节
cJSON 在解析过程中大量使用malloc。每解析一个新节点,会从内部函数cJSON_New_Item分配一整个cJSON结构体,然后把type置为 0xFF,相当于做初始化标记。解析字符串时还会额外分配valuestring的空间,并且处理转义字符,比如\\、\"、\n等。这里有个容易踩的坑:JSON 字符串长度和 C 字符串长度不是一回事,源码里必须维护一个op指针和一个ep指针,用来记录解析结束位置和错误位置,否则遇到多字节 UTF-8 数据,按strlen计算会越界。
源码里还有一个关键函数cJSON_strdup,它实际上是strdup的封装。在标准 C 库里strdup不是所有嵌入式编译器都支持,所以 cJSON 自己实现了一遍。如果你在移植时发现编译报错缺少strdup,多半是因为没有打开 cJSON 自带的宏,或者某个特定平台没有正确配置。内存分配失败的检查也集中在这些函数里,任何一个malloc返回 NULL,整个解析过程会立即停止,并留下error_ptr指向出错位置。
3.3 打印器与格式化输出
打印逻辑和解析是对称的。cJSON_Print内部会先计算整个 JSON 树需要的字符个数,然后一次性分配缓冲区,再递归填充。格式化版本比未格式化版本复杂,因为它要处理换行和缩进。源码中用一个printbuffer结构体来维护当前写入位置和剩余空间,每次写入前检查ensure是否足够。如果预估长度不够,会重新分配缓冲区。
这里给一个实际调用的示意,展示格式化打印和未格式化打印的差异:
char *formatted = cJSON_Print(root); // 带缩进和换行 char *compact = cJSON_PrintUnformatted(root); // 单行紧凑输出cJSON_Print适合调试时看结构,cJSON_PrintUnformatted适合网络传输。两者都返回值字符串,但内部计算量不同:格式化版本多一层缩进逻辑,在频繁调用时对 CPU 占用更明显。嵌入式上报场景里,如果每秒打印一次大 JSON,建议用后者,并配合cJSON_PrintBuffered指定固定缓冲区长度,避免重复动态分配。
4. 嵌入式实战:用 cJSON 完成设备状态上报与配置更新
4.1 从接收缓冲中安全解析 JSON 帧
实际嵌入式场景里,JSON 数据通常不是一次性完整到达,而是先存在一个环形缓冲区或 DMA 接收缓冲里。直接把这串数据丢给cJSON_Parse会有风险,因为如果数据被拆包截断,解析会失败。常见做法是先找到完整的 JSON 边界,或设置超时机制,确保拿到完整的一帧后再解析。
char recv_buf[512]; int len = get_full_frame(recv_buf, sizeof(recv_buf)); // 从设备缓冲区取完整帧 if (len <= 0) { /* 帧未完整,继续等待 */ return; } cJSON *root = cJSON_Parse(recv_buf); if (root == NULL) { const char *err = cJSON_GetErrorPtr(); /* err 指向解析失败的位置,可以做日志输出 */ return; }调用cJSON_Parse之后,第一件事不是取值,而是检查返回指针。如果要定位错误,cJSON_GetErrorPtr会返回出错位置的字符指针,记录它比单纯记录parse failed有用得多。另外,传入的recv_buf在解析完成后仍然可以复用,因为 cJSON 内部已经把需要的数据复制到malloc的节点里了。
4.2 构造发送报文并处理错误码
设备上报状态时,需要把传感器数值、开关量、活跃时间等打包成 JSON。直接一个个创建节点再AddItem是最直观的,但代码长了以后容易忘记清理中间变量。下面这个示例展示了一个可以搬上 STM32 的项目片段。
cJSON *report = cJSON_CreateObject(); if (report == NULL) return -1; cJSON_AddStringToObject(report, "type", "status"); cJSON_AddNumberToObject(report, "battery", get_battery_level()); cJSON_AddNumberToObject(report, "signal", get_signal_quality()); cJSON *metrics = cJSON_CreateObject(); cJSON_AddNumberToObject(metrics, "uptime", get_uptime_sec()); cJSON_AddNumberToObject(metrics, "errors", get_error_count()); cJSON_AddItemToObject(report, "metrics", metrics); char *payload = cJSON_PrintUnformatted(report); if (payload != NULL) { send_to_server(payload); free(payload); } cJSON_Delete(report);嵌套对象metrics在挂到report之后就不再需要单独遍历释放,cJSON_Delete(report)会递归清理所有子节点。如果发送函数需要等待应答,建议send_to_server返回后再free(payload),避免发送期间底层驱动还在访问缓冲区。
这里有一个值得加日志的点:每次上报前可以手动计算一下cJSON_PrintUnformatted的输出长度,若超过发送缓冲上限,就不要调用send_to_server,直接重试或降级。因为 JSON 里数字转字符串后长度可能超出预期,尤其是浮点数。
4.3 内存布局与常见崩溃点排查
在 MCU 上用 cJSON,内存问题排在最前面。下表总结了常见陷阱及对应排查思路。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 长时间运行后内存耗尽 | cJSON_Print 返回的字符串未释放 | 检查所有 free(payload) 分支 |
| 偶发 HardFault | AddItem 之后仍用子节点指针 | 搜索子节点在 AddItem 后是否被 delete |
| 解析复杂 JSON 时返回 NULL | 堆空间不足 | 调大 Heap Size,或用 cJSON_ParseWithOpts 限制深度 |
| 中文字符串乱码 | 源码内部按字节处理 | 确认 JSON 串是 UTF-8,分配足够字节数 |
| 移植到新平台编译失败 | 缺少 va_copy 或 stdint | 检查 cJSON.h 里平台相关宏,必要时手动定义 |
排查崩溃时,优先看cJSON_Delete是否被重复调用。一个典型错误是:在循环里每帧创建 root,处理完只free了 payload,却忘记cJSON_Delete(root)。这种问题不会立刻暴露,但跑几小时后就可能出现分配失败。给设备做保活上报时,建议在调试阶段做一个 malloc 计数器,每次心跳打印实时堆用量,比事后分析高效得多。
5. 进阶技巧:在有限 RAM 下把 cJSON 用到极致
5.1 用 cJSON_Compare 做本地配置比对
cJSON 提供cJSON_Compare函数,可以递归比较两棵 JSON 树是否语义相等。这个能力很适合做设备配置下发校验:云端的配置和本地当前配置做比对,如果一致就不写 flash,避免无谓的磨损。
int same = cJSON_Compare(local_config, remote_config, 1); if (same == 1) { /* 配置没有变化,不执行写入 */ } else { save_config_to_flash(remote_config); }第三个参数是case_sensitive,设置为 1 表示键名大小写敏感,设置 0 则忽略大小写。嵌入式配置系统里,我倾向于设置 0,这样云端调整字段大小写不会触发整包重写。这个函数在最新版 cJSON 中直接可用,但如果你的移植版本来自旧代码,可能需要自己实现递归比较,逻辑不复杂,遍历两棵树的同一键名节点比对值即可。
5.2 裁剪 cJSON.c:去掉不需要的特性
大部分嵌入式项目只需要解析,不需要格式化打印;或者只需要生成,不需要解析。cJSON.h 里预留了关掉功能的宏,比如CJSON_DISABLE_PARSER和CJSON_DISABLE_PRINT,把不需要的那部分代码从编译里排除,能显著减小固件体积。启用方式是在编译参数中加宏定义,或者在 cJSON.c 顶部统一开启。
# 只保留创建和打印功能,关闭解析 cc -DCJSON_DISABLE_PARSER -c cJSON.c裁剪后 API 依然可以编译,只是调用解析相关函数会返回 NULL 或直接断言。做固件版本管理时,建议在构建脚本里明确记录这次裁剪用到了哪些宏,避免换人维护后一头雾水。另外,新版 cJSON 支持数值解析优化,打开后能将double转换逻辑替换成更精简的实现,但兼容性要看编译器的数学库。
5.3 定位 parse_end 与 JSON 错误
调试解析问题时,除了cJSON_GetErrorPtr,还可以借parse_end指针判断 JSON 是否被截断。cJSON_ParseWithOpts的第二个参数return_parse_end会返回实际解析结束的位置。用法是:
const char *end_ptr = NULL; cJSON *root = cJSON_ParseWithOpts(json_str, &end_ptr, 0); if (root == NULL) { /* end_ptr 指向出错位置 */ } if (*end_ptr != '\0') { /* 说明 JSON 之后还有内容,可能是粘包 */ }这在处理多帧连续到达时特别有用。一般帧格式是{"cmd":"set","val":1}\n{"cmd":"get"},第一次解析会返回第一个对象,end_ptr停在换行位置。拿到这个位置后,把剩余字符串保存到下一次处理,就能可靠地拆包。如果接口返回错误 “Offset X: unexpected token”,X 就是end_ptr到起始地址的偏移,可以直接对应到收到的原始字节流,不需要猜。
本文还有配套的精品资源,点击获取