恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C语言JSON解析不用造轮子:cJSON使用与集成实战
首页
资讯中心
/
C语言JSON解析不用造轮子:cJSON使用与集成实战
C语言JSON解析不用造轮子:cJSON使用与集成实战
发布时间:2026/9/3 18:11:02
简介cJSON是一个基于C语言的轻量级JSON解析与生成库压缩包内整理了一套可直接使用的完整源码与工程样例面向嵌入式系统开发者以及需要在C/C项目中处理JSON数据的程序员用于快速解决JSON数据的解析、构造、修改与序列化问题。压缩包共118个文件大小仅557KB主要文件包括cJSON核心源码cJSON.c、cJSON.h、封装后的dll与lib库、测试用例、VS2010工程配置、构建日志及说明文档目录结构清晰既能直接看源码学习API也可将封装库接入现有工程。资源特别包含库封装工程和测试工程可节省手动编译时间方便验证cJSON的常用API行为配合扩展组件cJSON_Utils还能覆盖对象合并、JSON Patch等进阶应用。目前已有395人学习下载对于想掌握cJSON库实际落地方法的开发者来说这是一份紧凑、实用的参考资源。C语言的JSON解析真的不用自己造轮子。先回答一个很多嵌入式和服务端C开发者问过我的问题项目里要对接接口数据格式是JSON网上都说用cJSON这玩意儿到底靠不靠谱我的答案是靠谱而且极其靠谱它就是目前在C语言生态里最流行的JSON解析库没有之一。cJSON是一个用纯C99标准编写的超轻量级JSON库核心代码就两个文件cJSON.c和cJSON.h加起来不过两千多行移植到任何平台都只需要把这两个文件拷进工程不需要任何额外依赖。它解决的问题非常直接用C语言解析和构造JSON数据。在嵌入式设备上报数据、Linux网关解析云端下发指令、网络协议栈数据打包这些场景里你基本找不到比它更合适的选择。这篇文章就围绕cJSON的核心数据结构、解析流程、内存管理、构建技巧以及和lwIP这类网络协议栈集成的实战经验展开把我在实际项目中踩过的坑和验证过的代码一起放出来给想用cJSON又不敢直接上手的人做个参考。1. 树形模型理解cJSON设计思路的唯一钥匙cJSON之所以好用是因为它把JSON的每一对花括号和方括号都映射成了一棵内存里的树操作JSON就像操作一棵树一样自然。JSON本身是嵌套结构对象里套数组数组里再套对象穷举不完。所以cJSON的设计者Dave Gamble采用了一个非常聪明的做法——用一棵双向链表表示的树把JSON完整装进去。核心结构体长这样typedef struct cJSON { struct cJSON *next; struct cJSON *prev; struct cJSON *child; int type; char *valuestring; int valueint; double valuedouble; char *string; } cJSON;我把这个结构体拆开解释看完你就明白了整棵树的搭建逻辑。type标记节点类型常见取值为cJSON_Object、cJSON_Array、cJSON_String、cJSON_Number、cJSON_True、cJSON_False、cJSON_NULL。这个字段决定了后面几个值字段里哪个是有效的。next和prev是双向链表的指针用来串起同一层的兄弟节点。child指向第一个子节点只有对象和数组类型的节点才会有子节点其他类型一律为NULL。valuestring、valueint、valuedouble是值字段。string存储的是当前节点对应的键名。举个例子这段JSON{ name: temp-sensor, values: [23.5, 24.1, 22.8] }在cJSON里的树形结构是这样的根节点是cJSON_Object它的child指向第一个键值对节点第一个键值对节点是nametype为cJSON_Stringstring保存namevaluestring保存temp-sensor它的next指向第二个键值对第二个键值对是valuestype为cJSON_Arraychild指向数组的第一个元素数组的每个元素节点type为cJSON_Numbervaluedouble存放浮点数元素之间通过next/prev串起来。想清楚这个结构后面所有API的调用逻辑就都在你掌控之中了。所谓解析就是把JSON文本转成这棵树所谓构建就是自己动手把这棵树搭出来再转回文本。1.1 为什么说cJSON适合C语言项目很多人问过我一个问题C语言没有反射也没有泛型解析JSON本来就很别扭为什么不能直接用Python、Java或者Go来搞如果项目允许当然可以直接用高级语言。但现实是大量项目就是对语言有硬性要求嵌入式设备跑的是RTOS或者裸机Java虚拟机跑不动Python解释器更是天方夜谭网关设备上已有大量C语言存量代码协议栈、驱动、业务逻辑全部是C写的为了解析JSON单独引入一套技术栈不现实硬件资源紧张只有几十KB RAM不允许引入重量级依赖。cJSON在这种场景下的优势极其突出它只用标准C写不依赖任何特定操作系统API纯粹用malloc和free管理内存所以裸机能跑、RTOS能跑、Linux能跑甚至你把它编译成Windows DLL也没有任何障碍。我手头就有一个基于STM32的项目RAM只有64KBcJSON跑得毫无压力解析一条200字节左右的JSON报文只用了不到2KB的堆空间。2. 解析从JSON文本到内存对象这些细节必须注意解析是cJSON最核心的功能一行cJSON_Parse就能把字符串转换成树形对象但真正用起来细节决定成败。2.1 解析API的完整调用链标准解析流程是这样的#include cJSON.h const char *json_str {\name\:\temp-sensor\,\values\:[23.5,24.1,22.8]}; cJSON *root cJSON_Parse(json_str); if (root NULL) { const char *err cJSON_GetErrorPtr(); printf(解析失败错误位置: %s\n, err ? err : unknown); return -1; } // 取出对象里的字段 cJSON *name_item cJSON_GetObjectItem(root, name); if (cJSON_IsString(name_item) (name_item-valuestring ! NULL)) { printf(传感器名称: %s\n, name_item-valuestring); } cJSON *values_array cJSON_GetObjectItem(root, values); if (cJSON_IsArray(values_array)) { int len cJSON_GetArraySize(values_array); for (int i 0; i len; i) { cJSON *val cJSON_GetArrayItem(values_array, i); if (cJSON_IsNumber(val)) { printf(第 %d 个温度值: %lf\n, i, val-valuedouble); } } } cJSON_Delete(root);几个容易踩的坑我逐一说明坑一没有判空就取字段。cJSON_Parse失败返回NULL的情况下任何对root的操作都是空指针访问程序直接崩。养成习惯解析完先判空这是最基本的防御。坑二cJSON_GetObjectItem找不到键时返回什么返回NULL。但是特别注意如果你传进去的对象本身就不是对象类型它同样返回NULL。所以正确姿势是先判断拿到的节点非空再判断类型对不对。坑三cJSON_GetErrorPtr是全局唯一的错误指针。它返回解析失败的位置字符串但这个指针是库内部的静态变量多线程同时解析时会被互相覆盖。多线程环境建议不用它或者自己在解析前备份字符串。2.2 类型检查为什么不能省cJSON早期版本直接通过判断type字段来区分类型后来官方推荐用一组内联函数cJSON_IsObject、cJSON_IsArray、cJSON_IsString、cJSON_IsNumber等。表面上看这两种方式差不多但内联函数有个隐藏优势当item为NULL时它们直接返回0不会触碰到空指针内部字段。我见过不少线下项目老代码里写的是if (item-type cJSON_String)一旦item为NULL就崩排查了半天才发现是上游数据缺失导致字段没解析出来。换成cJSON_IsString(item)一行改动就规避了整个这一类问题。2.3 嵌套获取的两种思路从嵌套对象里取值新手容易写出长串多层调用cJSON *data cJSON_GetObjectItem(root, data); cJSON *info cJSON_GetObjectItem(data, info); cJSON *version cJSON_GetObjectItem(info, version);这段代码能跑的前提是每层都存在任何一个中间字段缺失空指针立刻传下去程序崩溃。稳妥的方式是每层判空cJSON *data cJSON_GetObjectItem(root, data); if (!cJSON_IsObject(data)) return -1; cJSON *info cJSON_GetObjectItem(data, info); if (!cJSON_IsObject(info)) return -1; cJSON *version cJSON_GetObjectItem(info, version); if (!cJSON_IsString(version)) return -1;这样写虽然繁琐但在解析外部输入时是必须的。JSON这种格式本身没有强约束对方少传一个字段程序就不能崩顶多报个错。后来cJSON的版本也提供了cJSON_GetObjectItemCaseSensitive这种大小写敏感版本默认的GetObjectItem是大小写不敏感的在实际使用中我建议用敏感版本因为JSON规范本身是区分大小写的默认API反而容易掩盖字段名写错的问题。3. 构建在内存里长出一棵JSON树的正确姿势解析是从文本到树构建则是从树到文本。实际项目中构建JSON往往比解析更频繁——上报数据、返回响应、组装指令都要手动搭树。3.1 最常用的两类构建方法第一类是从零构建层层嵌套。cJSON *root cJSON_CreateObject(); if (root NULL) return -1; cJSON_AddStringToObject(root, device_id, sensor_001); cJSON_AddNumberToObject(root, temperature, 23.5); cJSON_AddBoolToObject(root, online, 1); cJSON *reading cJSON_CreateArray(); if (reading NULL) { cJSON_Delete(root); return -1; } for (int i 0; i 5; i) { cJSON_AddItemToArray(reading, cJSON_CreateNumber(20.0 i * 0.5)); } cJSON_AddItemToObject(root, history, reading); char *json_str cJSON_Print(root); if (json_str ! NULL) { printf(生成的JSON: %s\n, json_str); free(json_str); } cJSON_Delete(root);第二类是修改已有对象在解析出来的树上再加字段cJSON *root cJSON_Parse(json_str); if (root NULL) return -1; // 在已有对象上追加字段 cJSON_AddNumberToObject(root, battery, 86); char *out cJSON_PrintUnformatted(root); printf(追加后的JSON: %s\n, out); free(out); cJSON_Delete(root);3.2 一个必须刻进DNA的内存规则我先说结论cJSON_AddItemToObject和cJSON_AddItemToArray之后子节点的所有权归父节点所有你千万不要再手动cJSON_Delete子节点。这句话怎么强调都不过分。我见过太多新手写以下这种代码cJSON *root cJSON_CreateObject(); cJSON *child cJSON_CreateString(hello); cJSON_AddItemToObject(root, msg, child); cJSON_Delete(child); // 错误double free cJSON_Delete(root); // 崩了AddItemToObject只是把child指针挂到root的链表里并没有拷贝。你手动删了childroot的链表里还挂着这个已经释放的指针最后cJSON_Delete(root)遍历时就会访问野指针。正确做法添加之后就把child变量忘掉只删父节点。反过来如果你创建了一个子节点但最后没有挂进树里一定要记得手动释放否则就内存泄漏了。构建接口的返回值基本都返回新节点的指针用的时候多留个心眼。3.3cJSON_Print返回的字符串必须手动释放cJSON_Print和cJSON_PrintUnformatted返回一个char *这个字符串是通过malloc分配的不是cJSON内部保存的。用完必须free否则每转一次就泄漏一块内存。在公司内部一次代码评审里我统计过某个模块的泄漏点几乎全部集中在这两个接口忘记free上。一个每10秒上报一次的数据模块每次泄漏一个几百字节的字符串运行几天后内存占用肉眼可见地涨。排查很简单一个free(json_str)就解决了。Print和PrintUnformatted的区别在于前者生成带缩进和换行的格式化输出便于人类阅读和日志查看后者去掉所有空白字符更节省传输带宽。网络传输用Unformatted基本没有悬念。4. 内存管理谁创建谁释放在cJSON的世界里是铁律cJSON使用标准库的malloc/free来管理内存但这并不代表你可以随便malloc随便free。遵循谁创建谁释放原则才能把内存问题控制在最小范围。4.1 一条完整的生命周期路径我们分析一次典型的解析-使用-释放全流程// 1. Parse 创建一棵树 cJSON *root cJSON_Parse(text); // 2. 使用过程中某些操作会创建新节点 cJSON_AddNumberToObject(root, cpu_usage, 32.5); // 3. Print 创建一块新内存保存字符串 char *out cJSON_PrintUnformatted(root); // 4. 用完了分别释放 free(out); cJSON_Delete(root);如果你在Parse之后不再需要原始的text可以放心地把原始字符串在Parse之后立刻释放因为cJSON在解析时会把所有需要的数据拷贝到新分配的内存中不会持有原始字符串的引用。比较容易被忽略的是各种Create函数和Print函数的配对关系创建操作对应的释放方式常见错误cJSON_Parse()cJSON_Delete(root)忘了删根节点整棵树泄漏cJSON_CreateObject()挂到父节点后由父节点释放或单独cJSON_Delete既挂上去又手动删double freecJSON_CreateString(xxx)同上创建了但没挂载泄漏cJSON_Print()free(json_str)忘了释放字符串4.2 深度递归释放的隐藏风险cJSON_Delete是递归释放整棵树的这带来两个问题第一是递归深度。如果JSON嵌套层级极深例如恶意构造的嵌套数组递归释放可能导致栈溢出。cJSON源码里默认配置了CJSON_NESTING_LIMIT为1000超过会拒绝解析。正常业务场景1000层足够但如果你解析的是外部不可信数据这个限制就是你的保命符别轻易调大。第二是释放顺序。有依赖关系的业务逻辑必须在删除树之前完成。比如你先从树里取出一个valuestring指针然后删了树再使用这个字符串指针——这就是典型的use-after-free。正确做法是先把值拷到自己的缓冲区再删树。4.3 嵌入式环境下的内存适配如果你的目标平台是MCU自带的malloc堆很可能非常小或者根本不可靠。cJSON的设计也考虑到了这一点编译时可以通过宏cJSON_InitHooks定制自定义的内存分配释放函数void *my_malloc(size_t size) { return my_pool_alloc(size); } void my_free(void *ptr) { my_pool_free(ptr); } cJSON_Hooks hooks; hooks.malloc_fn my_malloc; hooks.free_fn my_free; cJSON_InitHooks(hooks);在项目里我们就是用这种方式把cJSON的内存分配导到了自己的内存池上配合RTOS的互斥锁既保证了确定性又兼顾了线程安全。在这里额外提醒一句如果你在多个任务里同时调cJSON务必自己加锁因为cJSON内部并没有做线程同步。定制的alloc/free函数也要保证线程安全否则底层就可能先出问题。5. 与lwIP集成嵌入式网络栈里最经典的搭配在热词里有一个lwip cjson 集成这个组合我太熟悉了。lwIP是嵌入式领域最主流的TCP/IP协议栈设备上跑lwIP之后上层往往需要和云端或APP通信数据格式十有八九就是JSON。cJSON在这里的价值是把结构化的业务数据变成一段可以放进TCP发送缓冲区的字符串也可以把收到的一串字节流重新结构化为程序能直接访问的字段。5.1 一个完整的设备上报示例以一个典型的NBIoT或WiFi测温设备为例设备维护一组传感器数据定时通过lwIP的TCP客户端把数据上报给服务器// 传感器数据结构 typedef struct { float temp; float humi; int battery_percent; } sensor_data_t; // 构造上报JSON char *build_report_json(const sensor_data_t *sensor) { cJSON *root cJSON_CreateObject(); if (root NULL) return NULL; cJSON_AddStringToObject(root, cmd, report); cJSON_AddStringToObject(root, dev_id, DEV-2024-001); cJSON_AddNumberToObject(root, temperature, sensor-temp); cJSON_AddNumberToObject(root, humidity, sensor-humi); cJSON_AddNumberToObject(root, battery, sensor-battery_percent); cJSON *tags cJSON_CreateArray(); cJSON_AddItemToArray(tags, cJSON_CreateString(indoor)); cJSON_AddItemToArray(tags, cJSON_CreateString(upstairs)); cJSON_AddItemToObject(root, tags, tags); char *json_str cJSON_PrintUnformatted(root); cJSON_Delete(root); return json_str; // 调用者负责free } // 回调里发送 void send_sensor_report(struct tcp_pcb *pcb, const sensor_data_t *sensor) { char *payload build_report_json(sensor); if (payload NULL) return; int len strlen(payload); err_t err tcp_write(pcb, payload, len, TCP_WRITE_FLAG_COPY); if (err ! ERR_OK) { printf(tcp_write失败\n); } free(payload); // 因为用了TCP_WRITE_FLAG_COPY可以立即free }这里用了TCP_WRITE_FLAG_COPY标志lwIP会把发送数据拷贝到协议栈的pbuf里所以应用层可以立刻释放JSON字符串如果不用这个标志必须等lwIP的发送完成回调执行后才能释放否则就是悬垂指针问题。这是lwIP新手最容易踩的坑之一我在代码注释里都会特意标出来。5.2 收到数据的解析流程对端下发的指令一般长这样{ cmd: set_threshold, threshold: 45, duration: 10 }接收端拆包的代码void handle_cloud_command(char *recv_buf, int len) { // 先确保字符串以\0结尾 char *json_str (char *)malloc(len 1); if (json_str NULL) return; memcpy(json_str, recv_buf, len); json_str[len] \0; cJSON *root cJSON_Parse(json_str); free(json_str); // Parse之后原始缓冲就可以释放了 if (root NULL) { printf(云端指令解析失败\n); return; } cJSON *cmd cJSON_GetObjectItem(root, cmd); if (cJSON_IsString(cmd) strcmp(cmd-valuestring, set_threshold) 0) { cJSON *threshold cJSON_GetObjectItem(root, threshold); cJSON *duration cJSON_GetObjectItem(root, duration); if (cJSON_IsNumber(threshold) cJSON_IsNumber(duration)) { set_threshold(threshold-valuedouble, duration-valuedouble); } } cJSON_Delete(root); }这里有两点值得注意第一TCP收到的数据不保证以\0结尾所以必须自己拷贝一份再补结束符直接拿原缓冲给cJSON_Parse很可能会越界读第二cJSON对字符串是完整拷贝的所以Parse之后立刻释放原始缓冲完全没问题。5.3 浮点数精度控制JSON里23.5这种数字cJSON统一解析为double存储。但在嵌入式设备上float是32位double虽然cJSON本身支持底层运算和存储却可能比较吃力。如果对精度有要求要特别注意两点第一cJSON_AddNumberToObject接收的是double但内部会同时计算一个int值存到valueint里。对低精度整数直接读valueint即可对浮点场景统一用valuedouble。第二cJSON处理浮点打印时默认格式有时候会出现类似23.500000这种不好看的结果。如果你需要控制小数位官方v1.7.15以后提供了cJSON_SetNumberHelper接口可以自定义数字格式化函数或者在构建值的时候直接创建字符串类型的数值字段。我实际项目里因为兼容性问题更常用后一种方案需要精确到两位小数时直接用snprintf拼好字符串再用cJSON_AddStringToObject添加字段。虽然类型不再是Number但下游解析端如果不严格校验类型这种方式在实际联调里最省心。6. 几个高频问题的排查经验用cJSON时间长了你会发现很多问题其实是共性的在此集中分享一下我的排查思路。6.1 中文乱码和字符编码问题JSON规范本身要求传输编码为UTF-8。如果你的代码文件是GBK编码中文字符串直接塞进cJSON生成的JSON就是GBK字节序列下游按UTF-8解析必然乱码。解决办法是统一工程编码为UTF-8。嵌入式Linux下编译时加-finput-charsetUTF-8 -fexec-charsetUTF-8Windows下用VS时把源文件另存为UTF-8 with BOM。这个是老生常谈但每次联调都会被翻出来。6.2 明明解析成功GetObjectItem却拿到空指针这种情况八成是字段名拼错了或者大小写不匹配。cJSON默认API大小写不敏感但如果你是手动遍历链表而不是用GetObjectItem就会遇到大小写导致的匹配失败。如果你用了cJSON_GetObjectItemCaseSensitive则必须确保键名完全一致。还有一个隐藏场景键名里有空格或特殊字符。比如JSON里写的user name你代码里写cJSON_GetObjectItem(root, user)永远返回NULL。这种问题肉眼很难发现建议排查时把整个JSON结构用cJSON_Print打印出来对着看。6.3 格式化打印导致的内存膨胀cJSON_Print会产生带缩进和换行的输出体积比原始数据大不少。如果一条JSON数据本身只有200字节Print后可能变成400字节而PrintUnformatted通常只多几个字节键名的引号、冒号、逗号也是原始内容的一部分。在内存紧张的嵌入式设备上优先用Unformatted版本除非你是要打日志。6.4 多线程下的锁粒度前面提到cJSON没有内置线程安全。实际项目中如果你的多个线程各自维护一棵独立的cJSON树那并行没问题因为根本不共享数据。但如果有共享的配置JSON树多个线程同时读取甚至修改就需要在最外层加锁。我的习惯是解析和构建阶段可以并行因为每个线程操作的是自己的树一旦树要被多个线程共享就在访问API的外围加互斥锁而不要尝试修改cJSON源码去加锁——维护第三方库的fork版本是自讨苦吃。6.5 性能不够时的优化方向cJSON以简单轻量为设计目标性能并不是它的强项但对于绝大多数物联网和嵌入式场景完全够用。如果你测试发现解析成了瓶颈优先考虑减少解析次数协议允许的情况下把数据结构设计成定长字段数组而不是超长嵌套对象。另外cJSON从1.7版本开始默认启用了一些编译器优化选项也可以用-O2编译整个库来提升执行速度。真正到了性能敏感的场景高频网关每秒解析上千条报文就得考虑rapidjson之类更高性能的C库或者自定义解析器了cJSON再往上压榨的空间有限。7. 在cJSON基础上继续扩展的方向cJSON本身定位是一个底层解析库它不提供HTTP、不提供MQTT、不提供任何上层协议但正因为这个不提供它才能被无缝嵌入各种框架。我在实际项目里见过几种典型的扩展模式模式一封装一层协议结构。很多项目有固定的消息格式例如统一字段{ code: 0, msg: ok, data: {...} }这时可以封装一层build_response(code, msg, data)和parse_response(json)函数内部统一处理通用字段业务层只关心data部分。这样能减少大量重复的GetObjectItem代码。模式二和流式序列化配合。cJSON需要把整个JSON对象构建完成后才能Print没法做到流式输出。对于超大JSON可以考虑分段构造或者干脆换用专门支持流式写出的序列化方案。但对90%的嵌入式上报场景数据量都在1KB以内流式的价值不大。模式三静态配置生成代码。我看到有的团队用脚本Python或Go从JSON Schema自动生成cJSON的解析和构建代码把结构体的字段映射、类型检查、错误处理全部模板化。对于字段特别多的协议这个思路能省下大量手写代码的时间也让字段遗漏问题在编译期就能发现一部分。根据我个人经验cJSON不是那种会让你学一次用一辈子的库但它是每个C开发者都应该掌握的基本功。因为它足够小、足够简单理解它的内部机制你就理解了C语言里大部分手动作树和递归遍历的套路。下次面试被问到你了解C语言怎么处理JSON吗能把这个库的设计思想讲清楚要比背几个API有价值得多。最后再分享一个小技巧在你调试任何JSON相关的报文时先用cJSON_Print把解析结果完整打印出来看结构再对比预期字段90%的字段取不到问题都是在这一步暴露的。这个习惯帮我省下的调试时间不计其数。本文还有配套的精品资源点击获取