
cJSON是我见过用得最顺手、也是踩坑最多的C语言JSON解析库没有之一。小巧、零依赖、API直观几乎成了嵌入式C项目的标配。但越简单的库越容易在内存管理上栽跟头。最近帮同事排查一个运行几天后内存持续上涨的问题查到最后就是cJSON的释放姿势不对有的地方只用了free有的地方把cJSON_Delete用在了错误的对象上。今天就借着这个问题把cJSON内存释放这件事从头到尾讲透从源码原理到实际代码从常见误用到排查工具一次性说清楚。1. 先说结论cJSON的内存释放到底该怎么理解1.1 一个根节点对应一次cJSON_Delete这是铁律先给结论后面再展开讲原理。在一个标准用法里你从cJSON_Parse拿到的那个根节点或者用cJSON_CreateObject / cJSON_CreateArray创建的那个根节点整个JSON树用完以后只需要对根节点调用一次cJSON_Delete就够了。// 正确示范解析后释放 cJSON *root cJSON_Parse(json_str); if (root NULL) { // 处理解析失败 return -1; } // ... 使用root ... cJSON_Delete(root); // 一次调用整棵树都释放 // 正确示范创建后释放 cJSON *obj cJSON_CreateObject(); cJSON_AddStringToObject(obj, name, test); cJSON_AddNumberToObject(obj, age, 30); // ... 使用obj ... cJSON_Delete(obj); // 一次调用所有挂载的item都释放1.2 为什么很多人对free念念不忘因为看源码会发现cJSON内部就是用的malloc和free。很多人第一反应是“既然内部是malloc的那我直接free不就行了”这个想法坑了很多人。我见过最典型的一个bug就是有人对cJSON_Parse返回的root直接调用了free(root)而不是cJSON_Delete(root)结果就是root本身这一个结构体的内存确实还给了堆但是root下面挂着的所有子节点包括各种valuestring、child链表、next/prev指针串起来的整棵树全部泄漏了。还有一个更隐蔽的版本有人知道要调cJSON_Delete但同时又对root调了free结果cJSON_Delete内部已经把root释放掉了再次free就触发了double free程序直接崩溃。这说明很多人并没有真正理解cJSON_Delete和free到底是啥关系。2. cJSON的内存模型搞清楚这些释放就明白了2.1 源码级拆解一个JSON节点在内存里长什么样cJSON的核心结构体就一个在cJSON.h里定义每个JSON值无论是一个对象、一个数组、一个字符串还是一个数字在内存里都是一个cJSON结构体实例。typedef struct cJSON { struct cJSON *next; struct cJSON *prev; struct cJSON *child; int type; char *valuestring; int valueint; double valuedouble; char *string; } cJSON;这个结构体就是理解整个cJSON内存管理的钥匙。next和prev是链表指针用来把同一个父节点下的所有子节点串成双向链表。child指向第一个子节点。type标记节点类型。valuestring是指向字符串的指针保存字符串类型的值或者键名。string保存的是这个节点作为子节点时的键名。2.2 Parse和Create两条路径到底malloc了哪些东西cJSON的内存分配总共有三个来源这一点必须心里有数结构体本身每个节点是一个cJSON结构体cJSON_New_Item函数里通过malloc(sizeof(cJSON))分配约几十个字节。valuestring指向的字符串存字符串值时会为字符串内容单独malloc一块内存。string指向的键名作为对象成员时键名也会单独malloc一块内存。光是一个简单的{name: test}cJSON就要分配三块内存一个cJSON结构体节点、一个存键名name的字符串、一个存值test的字符串。如果只free掉最外层那个结构体指针另外两块就泄漏了。如果完整的JSON有几十个字段、嵌套好几层泄漏的就是几十块甚至上百块小块内存。内存碎片化之后程序运行时间越长表现越诡异不是马上崩而是慢慢卡顿、内存持续上涨、最终在某次比较大的分配请求时直接OOM。2.3 cJSON_Delete为什么能“一次全删”cJSON_Delete的源码逻辑很直白就是一个递归加循环的遍历过程。每处理一个节点都执行同样的流程先递归删除它的所有child再删除它的兄弟节点最后free掉这个节点自身挂在里的valuestring、string和结构体本身。void cJSON_Delete(cJSON *c) { cJSON *next; while (c) { next c-next; if (c-child) { cJSON_Delete(c-child); // 递归删除子节点 } if (c-valuestring) { free(c-valuestring); // 释放字符串值 } if (c-string) { free(c-string); // 释放键名字符串 } free(c); // 最后释放结构体本身 c next; } }所以cJSON_Delete内部本来就会free节点自身、valuestring、string。这就是为什么对同一个节点cJSON_Delete和free二选一并且必须选cJSON_Delete。对这个库来说free防不住子节点和字符串的泄漏cJSON_Delete才是整个内存树的完整析构函数。3. 什么时候该用cJSON_Delete什么时候只用free3.1 五种典型场景对照表我用一个表把这些年遇到的高频场景直接列出来方便对号入座。场景释放方式说明cJSON_Parse返回的根节点cJSON_Delete(root)整棵解析树一次性释放cJSON_CreateObject/CreateArray等创建的根节点cJSON_Delete(root)创建的树用完整体释放cJSON_AddItemToObject/AddItemToArray挂载的子节点只挂在父节点上父节点Delete时会连带释放不要单独Delete特别容易误操作cJSON_GetObjectItem/GetObjectItemCaseSensitive拿到的子节点指针不要单独Delete也不要free它不属于你属于整棵树cJSON_CreateString/CreateNumber等单独创建的还没有挂到树上的节点cJSON_Delete(node)没有挂载的根节点或者中途放弃挂载时手动释放3.2 最容易出事的场景误删挂在树上的子节点我见过最经典的一个崩溃现场是这样的。假如解析了一段JSON想单独取出某个字段的值做二次处理脑子一热就把取得的节点给释放了。cJSON *root cJSON_Parse(text); cJSON *name cJSON_GetObjectItem(root, name); // ... 用了name ... cJSON_Delete(name); // 错误这个name还挂在root的树里 cJSON_Delete(root); // double free崩溃这个操作本质上等于先把树上的某个节点提前删了但root的child链表里还残留着指向这块内存的指针。最后再cJSON_Delete(root)时遍历整棵树就会碰到这块已经释放的内存轻则double free崩溃重则内存被复用后产生脏数据。正确做法就是cJSON_GetObjectItem拿到的指针用完后别管它最后统一对root做一次cJSON_Delete整棵树连带这个节点一起释放。3.3 额外注意打印用的cJSON_Print返回值需要单独free有一个非常常见的释放特例凡是做过cJSON格式化输出的人基本都踩过cJSON_Print和cJSON_PrintUnformatted会在内部malloc一块char缓冲区返回给你一个char*指针。这块缓冲区是独立的和JSON树没有关系必须用free手动释放。char *json_str cJSON_Print(root); if (json_str) { // 使用json_str比如写入文件或发送网络 fputs(json_str, file); free(json_str); // 这一步是必须的用free而不是cJSON_Delete }这个容易被忽略的点在代码里频繁执行时非常致命。我自己就在一个日志打印模块里面漏过每次打一条日志就泄漏几百字节跑一天下来就是几十MB隔几天就需要重启一次服务。4. 手把手实战写一个安全的JSON构造与解析流程4.1 场景构造请求JSON发送再解析响应JSON用一个实际开发场景来演示完整的内存管理流程。假设要给一个HTTP接口构造一个登录请求然后解析登录响应里的token字段。#include cJSON.h #include stdio.h #include stdlib.h #include string.h char *build_login_request(const char *username, const char *password) { // 创建根节点这是一个独立的JSON树 cJSON *root cJSON_CreateObject(); if (root NULL) { return NULL; } // AddItemToXxx系列的内部处理逻辑 // 如果传入的item已经挂在树上它会接管所有权 // 如果传入的是值类型字符串/数字它会自行创建节点并挂载。 cJSON_AddStringToObject(root, username, username); cJSON_AddStringToObject(root, password, password); // 紧急情况下的安全释放 // 如果后续操作失败了对root调用cJSON_Delete // 之前Add进去的所有内容都会被递归释放不会泄漏。 cJSON *payload cJSON_CreateObject(); if (payload NULL) { cJSON_Delete(root); return NULL; } cJSON_AddStringToObject(payload, client, test_client); cJSON_AddItemToObject(root, payload, payload); // 注意payload挂载后所有权转移给root // 格式化输出 char *text cJSON_PrintUnformatted(root); if (text NULL) { cJSON_Delete(root); return NULL; } // 注意这里text是独立malloc出来的需要free // root这棵树也要cJSON_Delete cJSON_Delete(root); return text; // 调用方负责free(text) } int parse_login_response(const char *response_text, char *token_out, size_t token_size) { cJSON *root cJSON_Parse(response_text); if (root NULL) { return -1; } cJSON *token cJSON_GetObjectItem(root, token); if (cJSON_IsString(token) (token-valuestring ! NULL)) { snprintf(token_out, token_size, %s, token-valuestring); } else { cJSON_Delete(root); return -2; } // 关键点token指针不用单独释放它属于root这棵树 cJSON_Delete(root); return 0; }这个示例里build_login_request函数构造JSON树格式化字符串后树就可以释放了text返回给调用方由调用方free。parse_login_response解析JSON后取出来的token指针只是借读不负责释放最后统一cJSON_Delete(root)收尾。4.2 解读所有权归谁谁就负责释放整个cJSON内存管理的核心其实就是一个词所有权归属。每一个节点在创建时是独立的一旦通过cJSON_AddItemToObject、cJSON_AddItemToArray挂到树上它的所有权就转移给树了。这时候你不需要、也不应该再单独释放它。cJSON_Delete的递归设计就是为了照顾这个所有权转移的模型——树根知道自己的所有子孙能一路清理干净。判断一个指针能不能free、该不该Delete就问两个问题这个节点是否还挂在某棵JSON树上是的话由树根统一释放。这个节点是不是一棵树的根是的话cJSON_Delete。cJSON_GetObjectItem返回的指针两个问题都不占所以什么都不用做。5. 高频错误自查这几种写法几乎都踩过5.1 只free不Delete只对根节点调用free是最常见但最“安静”的泄漏方式程序不会崩就是内存缓慢涨。链表上的所有子节点、valuestring、string全部丢失。这种bug用肉眼特别难发现因为根节点的结构体确实被释放了从逻辑上“好像释放干净了”实际上漏了一大片。5.2 对GetObjectItem拿到的节点调cJSON_Delete前面说过了这会导致这棵树的链表悬垂。运气好double free崩溃运气不好某个完全无关的malloc复用了这块内存产生极其诡异的数据错乱排查起来能让人怀疑人生。5.3 在循环里重复Parse同一段文本但没有释放很多配置文件解析循环、网络消息循环里同一个人反复执行cJSON_Parse然后忘了Delete每次循环泄漏一棵树。这类问题的一个明显特征是内存上涨速度与消息/请求频率强相关消息量越大内存涨得越快。5.4 重复Delete同一棵根节点有一些防御性比较强的程序员会在多处地方写cJSON_Delete比如函数内释放一次调用方再释放一次。如果中间的指针没有置NULL第二次Delete就是一个游离指针操作直接崩溃。建议在Delete之后顺手把指针赋NULL养成习惯能挡掉很多不必要的崩溃。5.5 把GetObjectItem的返回值当成新树来Delete有些人对cJSON_Delete的理解是“释放一个节点”因而看到任何cJSON指针都想释放一下。实际上cJSON_Delete是“释放从该节点开始的整条链表以及所有子孙节点”。如果把一个中间节点传给cJSON_Delete它不只是释放这一个节点还会把这个节点的所有兄弟节点一并释放。这绝对不是你想要的。6. 排查cJSON内存泄漏的实战工具和方法6.1 Valgrind定位泄漏点Valgrind是排查这类泄漏的首选工具尤其是Linux环境下的C程序。使用方式特别简单编译时加-g保留调试符号然后用valgrind运行程序。gcc -g -o my_app my_app.c -lcjson valgrind --leak-checkfull --show-leak-kindsall ./my_app程序跑完后Valgrind会输出非常详细的分配与未释放记录。重点看definitely lost和indirectly lost两栏。如果泄漏的是cJSON相关的内存在输出里搜索cJSON关键字基本能直接找到是哪个文件哪一行调用的cJSON_Parse或者cJSON_CreateObject没有配对Delete。indirectly lost尤其关键——它代表“虽然根节点内存丢失但还有一堆子节点也找不到了”这正是只free不Delete的典型特征。6.2 AddressSanitizer检测悬垂指针和重复释放如果程序出现的是崩溃而不是缓慢泄漏用AddressSanitizer比Valgrind更快。编译时加上-fsanitizeaddress -g运行时如果触发了double free或者堆溢出它会直接告诉你出错的调用栈。gcc -fsanitizeaddress -g -o my_app my_app.c -lcjson ./my_appASan出来的错误信息里会明确标出ERROR: AddressSanitizer: attempting double-free紧跟着的就是当时的调用栈。很多cJSON_Delete和free混用的场景用这个工具一把就能抓到。6.3 代码审查阶段的小技巧在写代码阶段养成一个习惯每一处cJSON_Parse和cJSON_CreateObject立刻写下对应的cJSON_Delete即使后面的代码还没写完。这有点像写锁的时候立刻写unlock把配对关系落实在代码结构上而不是靠记忆。另一个技巧是尽量把树的创建、使用、释放封装在一个函数内部通过参数传递而不是靠返回值满天飞这样函数退出时集中释放从设计上避免泄漏。7. 进阶话题自定义内存钩子后如何释放7.1 cJSON内存钩子机制cJSON允许替换底层的内存分配和释放函数通过cJSON_InitHooks可以在初始化时注入自定义的malloc和free。void *my_malloc(size_t size) { // 比如用内存池分配 } void my_free(void *ptr) { // 释放到内存池 } // 初始化 cJSON_Hooks hooks; hooks.malloc_fn my_malloc; hooks.free_fn my_free; cJSON_InitHooks(hooks);这个机制在需要精确控制内存场景下非常有用比如在某些RTOS里标准malloc可能不是线程安全或者效率不够换成内存池能大幅提升分配性能。7.2 内存池模式下释放时的注意事项换成自定义内存钩子后cJSON_Delete内部的free调用也会走你自定义的free函数。这里有一个容易被忽略的问题如果你混合使用cJSON内部分配的内存和cJSON外部分配的内存外部内存仍然需要用free或者你的自定义free释放不能交给cJSON_Delete处理。比如cJSON_Print返回的char缓冲区你如果在有自定义free的池里分配了这块缓冲区释放时仍然需要调你的free函数而不是cJSON_Delete。另外如果你的自定义malloc和free不是线程安全的在多线程环境里要格外小心cJSON_Delete在释放整个树时可能会被跨线程调用需要外部加锁保护。8. 聊聊我实际使用中的体会用cJSON这些年我踩过最大的坑还真不是API不会用而是太自信地认为“内存管理很简单”。free和cJSON_Delete的区别表面上看只是一个函数名的问题背后是“知不知道每个节点所有权的归属”。嵌入式环境里内存本来就紧张一次泄漏几百字节可能一两天都看不出问题等到问题爆发时排查成本和事故代价早就超过了写代码时多花的那几秒钟思考时间。我个人现在的习惯是所有涉及cJSON的函数写出来第一件事就是先确认树的根节点是谁、释放点在哪里然后再写业务逻辑。这个习惯帮我避掉了很多需要熬夜查内存的坑。如果你们也在做长时间运行的C程序推荐在CI流程里加上Valgrind或者ASan这一道关卡让工具在代码提交时就把内存问题拦住别等到线上环境自己暴露出来。最后再分享一个小技巧如果你在代码里看到一个cJSON*类型的指针但又拿不准它是不是根节点最快的确认方法是回溯它是由谁创建的——凡是cJSON_Parse、cJSON_CreateObject、cJSON_CreateArray这类函数返回的就是根节点凡是cJSON_GetObjectItem这类函数返回的就是树上借读的不用管它。