嵌入式必学:cJSON轻量级JSON解析库核心机制与实战
2026/9/15 7:15:15 网站建设 项目流程

简介:一套包含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;

nextprev用于把对象里的多个键或数组里的多个元素串成链表;child指向第一个子节点。type标记当前节点的类型,比如cJSON_ObjectcJSON_ArraycJSON_StringcJSON_Numbervaluestringvalueintvaluedouble分别保存字符串和数值,string保存的是当前节点在对象里的键名。也就是说,一个 JSON 对象实际是一个链表头,它的每个子节点又是一个链表节点,嵌套结构就是通过child指针层层展开的。

这个设计带来的直接好处是:遍历操作非常简单,只需要从child开始沿着next走就能拿到所有兄弟节点。坏处也很明显,如果代码里写错了childnext的指向关系,很容易产生悬挂指针。因此理解这个结构体,是后续所有 API 调用的基础。

2.2 API 分组与内存所有权规则

cJSON.h里的函数按用途划分,能明显看出它的设计思路。解析类用cJSON_ParsecJSON_ParseWithOpts;创建类用cJSON_CreateObjectcJSON_CreateArraycJSON_CreateStringcJSON_CreateNumber;访问类用cJSON_GetObjectItemCaseSensitivecJSON_HasObjectItem;打印类用cJSON_PrintcJSON_PrintUnformatted;销毁统一用cJSON_Delete

函数分类典型函数关键点
解析cJSON_Parse入参是 JSON 字符串,返回根节点指针
创建cJSON_CreateObject返回新节点,需要手动挂到父节点
添加cJSON_AddItemToObject把子节点移交给父节点,所有权转移
检查cJSON_GetObjectItemCaseSensitive大小写敏感,找不到返回 NULL
打印cJSON_Print返回 malloc 出的格式化字符串
释放cJSON_Delete递归释放整棵链表

这里最容易被忽略的是内存所有权。cJSON_AddItemToObjectcJSON_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_AddStringToObjectcJSON_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_ParsecJSON_ParseWithOpts,它们初始化一个状态结构体,然后调用parse_valueparse_value根据当前第一个字符决定分支:{parse_object[parse_array"parse_string,数字或-parse_numbertfn分别走 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) 分支
偶发 HardFaultAddItem 之后仍用子节点指针搜索子节点在 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_PARSERCJSON_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到起始地址的偏移,可以直接对应到收到的原始字节流,不需要猜。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询