cJSON库用完后内存泄漏手把手教你正确使用cJSON_Delete和free释放资源做嵌入式或者C语言服务端开发的十有八九都用过cJSON。这个小库轻量、接口简洁一个cJSON_Parse进去、一个cJSON_Delete出来看起来没什么好讲的。但实际跑起来尤其是项目跑个几天几夜之后内存就像漏水的杯子一样一点一点往上涨。排查到最后很多问题的根子都出在cJSON的释放资源上。这篇文章不说虚的直接围绕cJSON_Delete和free这两个函数把cJSON的内存模型、谁该删、谁该释放、以及我在实际项目中踩过的坑和排查过程完整拆给你看。适合刚接触cJSON的初学者也适合已经用了很久但没仔细抠过内存归属的C/C开发者。1. 先从cJSON最容易被误解的内存模型讲起很多人在用cJSON的时候其实处于一种“半懂不懂”的状态。会用cJSON_Parse和cJSON_Delete配对但一旦遇到字符串提取、数组遍历、或者自己构造JSON对象就开始迷糊了。要搞清楚内存泄漏第一步不是学函数而是搞懂cJSON的内存到底是怎么组织的。1.1 cJSON的内存所有权到底归谁cJSON里面所有的节点本质上是cJSON结构体的链表。每一个cJSON结构体都代表JSON里的一个值可以是object、array、string、number、true、false或null。typedef struct cJSON { struct cJSON *next; struct cJSON *prev; struct cJSON *child; int type; char *valuestring; /* 字符串值 */ int valueint; /* 整数值已废弃但仍在 */ double valuedouble; /* 浮点数值 */ char *string; /* key的名字 */ } cJSON;这个结构体里的关键信息是valuestring和string都是char*它们指向的内存是通过malloc单独分配的不包含在cJSON结构体本身占用的内存里。这就引出了核心问题一个cJSON节点其实牵扯到至少两块内存。一块是cJSON结构体本身的内存一般32字节左右跟编译器和配置有关。另一块是string和valuestring指向的字符串内存。如果你自己用malloc创建了一个cJSON节点或者自己malloc了一块字符串挂到节点上那么释放的时候除了要释放结构体还要释放那两块字符串内存。漏了任何一块就是泄漏。1.2 别小看那两个malloc字符串和节点是分开分配的假设你用cJSON_CreateString(hello)创建一个字符串节点。看cJSON的源码就知道这个函数内部实际做了两件事cJSON *cJSON_CreateString(const char *string) { cJSON *item cJSON_New_Item(); if(item) { item-type cJSON_String; item-valuestring (char*)cJSON_strdup(string); if(!item-valuestring) { cJSON_Delete(item); return NULL; } } return item; }cJSON_New_Item里面malloc了结构体cJSON_strdup里面malloc了字符串空间。所以一个字符串节点一旦创建成功就已经占了两块heap内存。我见过有粗心的同事在自己构造JSON树的时候手动写了类似这样的代码cJSON *obj cJSON_CreateObject(); char *key malloc(64); strcpy(key, name); obj-string key;然后释放的时候只调用cJSON_Delete(obj)。你们猜会发生什么cJSON_Delete根本不知道这个string是你自己的malloc还是cJSON内部cJSON_strdup出来的它只会无脑free。如果你把obj-string直接指向自己malloc的keycJSON_Delete会帮你free掉看起来没事。但如果你后来又自己free(key)了一次那就double free直接崩。反过来如果你用cJSON_SetValuestring之类的接口给节点设置字符串值cJSON内部会先释放旧的、再分配新的这时候你如果再手动free就又崩了。结论很简单不要手动干预cJSON内部node的string指针除非你真的清楚自己在干什么。1.3 什么时候你会“手动接管”一块内存有一种合法情况需要手动free从cJSON节点里提取字符串时如果你用的是cJSON_Print或者cJSON_PrintUnformatted这两个函数会返回一个char*这个字符串是函数内部分配的必须由你调用free释放。char *text cJSON_Print(root); if (text) { // 打印或者发送text free(text); // 这里必须freecJSON_Delete不会替你处理 }这个text不是cJSON树的一部分cJSON_Delete再厉害也管不到它。所以它不是“cJSON节点的内存”而是“cJSON返回给你的独立字符串”。谁申请谁释放这是C语言内存管理的不二法则。除了cJSON_Print还有一类函数也比较容易混淆cJSON_DetachItemFromObject系列。这个后续我会专门展开。2. cJSON_Delete和free的真正分工边界很多人心里有个朴素想法cJSON相关的内存cJSON_Delete不都得管吗不对。cJSON_Delete只管理“cJSON节点树内部”的内存而free管理的是“由cJSON接口返回给你、且明确约定你负责释放”的内存。搞清这条边界内存泄漏就少了一半。2.1 cJSON_Delete源码级别的删除逻辑cJSON的删除实现很清晰是递归的。我们来看看它的核心逻辑void cJSON_Delete(cJSON *item) { cJSON *next NULL; while (item) { next item-next; if (!(item-type cJSON_IsReference) item-child) { cJSON_Delete(item-child); } if (!(item-type cJSON_IsReference) item-valuestring) { free(item-valuestring); } if (!(item-type cJSON_IsReference) item-string) { free(item-string); } free(item); item next; } }注意几个关键点cJSON_Delete会沿着next指针一直释放所以如果你对一个根节点调用cJSON_Delete同一层级的兄弟节点也全都会被释放。不需要也不能只删除根节点再单独删除子节点那会导致double free。child子节点会被递归删除所以整棵对象树、数组树都能被完整释放。cJSON_IsReference这个标志位很关键。如果节点被标记为IsReference说明它只是一个“引用”并不拥有实际的数据内存因此不会被free。第三种情况是很多隐藏bug的来源。当你使用cJSON_AddItemReferenceToObject或者cJSON_AddItemReferenceToArray时新加的节点只是指向已有节点并不拷贝数据。如果对两个地方都调用cJSON_Delete第二次就会触发double free。2.2 哪些地方必须自己调用free我总结了下在cJSON的使用中需要自己调用free的场合主要有下面几个场景释放函数原因cJSON_Print/cJSON_PrintUnformatted返回的字符串free独立分配的字符串不属于cJSON树cJSON_DetachItemFromObject/DetachItemFromArray分离出来的节点看完再用cJSON_Delete节点已从树中断开cJSON不再管理它但你拿到手需要手动释放自己malloc后手动赋给cJSON外部的变量free自己的内存自己释放自己实现的序列化/反序列化过程中malloc的缓冲区free与cJSON树无关的临时内存这里特别要强调cJSON_DetachItemFromObject。看名字就能猜到它把节点从对象里“摘下来”摘下来之后这个节点已经不属于原来的树了。如果你对它调cJSON_Delete它会被正常释放但因为它在原树中的next、prev已经被处理好所以不会影响原树。一个很典型的错误是cJSON *root cJSON_Parse(json_str); cJSON *item cJSON_DetachItemFromObject(root, key); // 用完item之后 cJSON_Delete(item); cJSON_Delete(root);这段代码其实是安全的。因为Detach之后item已经不在root的树上了两者互不干扰。真正危险的是cJSON *item cJSON_GetObjectItemCaseSensitive(root, key); cJSON_Delete(item); cJSON_Delete(root);cJSON_GetObjectItemCaseSensitive只是返回一个指针它指向的内存仍然归root管。你提前把item删了等cJSON_Delete(root)的时候root再去访问已经被free的item轻则段错误重则产生不可预知的内存踩踏。2.3 cJSON_Delete最常见的使用错误重复删除与悬空指针接上面那个例子说cJSON_Delete之后必须立刻把指针置为NULL。这不是洁癖是保命手段。cJSON_Delete(root); root NULL;如果你忘了置NULL后面代码里某个分支又调了一次cJSON_Delete(root)第二次的free操作是未定义行为大概率在运行时崩溃但也有可能“侥幸”没崩然后过几个小时在另一个完全无关的地方报内存错误。这种bug最恶心因为它不遵循“立竿见影”的规律让你很难把它和几天前的那行代码联系起来。我习惯的做法是封装一个宏或者函数#define CJSON_SAFE_DELETE(item) do { \ if (item) { \ cJSON_Delete(item); \ item NULL; \ } \ } while(0)一个项目中所有释放cJSON树的地方都走这个宏从根上杜绝“删了又删”的隐患。代价是引入了一个小工具宏收益是省掉无数深夜排查时间值。3. 一个真实项目里的泄漏排查全过程前面讲原理可能还有点抽象我拿一个真实场景来复盘。我在做某嵌入式设备配套的上位机监控程序时程序需要定期从设备拉取状态数据数据以JSON格式返回然后通过cJSON解析并展示在界面上。程序本身不大但长期运行后内存占用从初始的20MB慢慢涨到300MB显然有泄漏。3.1 现象跑了一晚上的服务内存只涨不跌最开始没人注意到内存问题因为单次解析数据消耗很小也就几百KB。但程序是循环拉取的一小时拉一次然后更新界面循环往复。跑了一天内存上涨跑了两天内存还在上涨跑了三天UI开始卡顿。用任务管理器一看内存占用已经离谱。这种“缓慢爬坡式”内存增长是最多见的内存泄漏特征。它不是一口气泄露好几MB而是每轮循环漏几百字节日积月累量变引发质变。我怀疑跟JSON解析有关因为程序其他部分的内存分配非常规律不太可能存在这样持续不断的泄漏。于是我用valgrind跑了一次。3.2 排查链路从valgrind报告到三处问题代码编译的时候加上调试信息然后用valgrind的memcheck工具启动程序gcc -g -o monitor monitor.c -lcjson valgrind --leak-checkfull --show-leak-kindsall ./monitorvalgrind的输出会专门报告“definitely lost”和“indirectly lost”的泄漏点。我这次跑的结果定位到三处问题第一处也是最常见的char *response http_get_status(device); // 从网络拿字符串 cJSON *root cJSON_Parse(response); free(response); // 没问题网络返回的字符串自己释放 // ... 处理逻辑 ... cJSON_Delete(root); // 没写或者写在某个提前return的分支里漏了程序里有个分支在解析出错时会直接return结果cJSON_Delete(root)没执行到。leak就发生在这些异常分支上。说实话这是新手最容易犯的错——代码里写了Delete但放在了所有分支的最后中间任何一个return、continue、goto都会把它跳过。这个问题的修复方法是“单一出口”原则让函数所有分支都汇聚到一个清理点而不是每个分支各写各的。或者用goto cleanup这样的模式cJSON *root NULL; char *response NULL; response http_get_status(device); if (!response) goto cleanup; root cJSON_Parse(response); free(response); // 到这里response已经没用了 response NULL; if (!root) goto cleanup; // ... 处理逻辑 ... cleanup: free(response); CJSON_SAFE_DELETE(root); return result;第二处问题是关于cJSON_Print的cJSON *result_obj cJSON_CreateObject(); cJSON_AddStringToObject(result_obj, status, ok); char *msg cJSON_Print(result_obj); send_message(msg); // 忘了free(msg) cJSON_Delete(result_obj);cJSON_Print返回的字符串必须有调用方手动free我漏掉了。每次网络交互都会泄漏一个格式化后的JSON字符串大小有时能到几KB。长期跑下来这部分占整个泄漏量的六成以上。第三处问题是数组误用。代码原本要把一组数据塞进JSON数组发给前端结果在遍历中循环里每次都用cJSON_AddItemToArray添加这本没错。但后来有人改需求要求对已有的某个item做修改他先cJSON_Delete(existing_item);再重新创建添加。这个existing_item是从cJSON_GetArrayItem取出来的它本来还挂在数组树上你直接Delete就是把正在使用的内存给提前释放了数组的child链接已经指向一块野内存。后面再遍历数组每次读到的都是不确定的数据。这种bug不一定会崩溃但会表现为间歇性的数据错乱伴随内存泄漏。这个修复方案很直接对数组中已有元素做修改不要先删再建而是直接修改它的值或者用cJSON_ReplaceItemInArray替换。如果必须删除就用cJSON_DetachItemFromArray先摘下来再处理。3.3 修复前后的代码对比与验证修复前泄漏是每轮交互都发生异常分支一篇的代码处处是雷。修复后我把所有网络的、解析的、cJSON树和Print返回值的释放逻辑集中管理并且把关键函数都改成了统一的cleanup出口。修复后用valgrind再跑一遍效果非常直观之前LEAK SUMMARY: definitely lost: 1,204 bytes in 12 blocks 之后LEAK SUMMARY: definitely lost: 0 bytes in 0 blocks内存占用连续稳定运行48小时稳定在22~25MB之间不再上涨。这里多提一句valgrind在嵌入式交叉编译环境下不一定能直接跑因为目标板上可能没有valgrind。一个替代方案是代码里自己写一个带行号的内存分配/释放日志宏把每次malloc和对应的free打出来再写脚本统计配对情况。虽然粗糙但在没有工具的环境里确实能救命。4. 把这些坑挡在编码阶段的手段经验都是踩坑踩出来的但有些坑完全可以靠规范和方法提前堵住。说到底内存泄漏的核心不是“写不出正确的释放代码”而是在大量逻辑分支、异常路径下防守出现了漏洞。4.1 用断言和宏封装Delete让野指针无处可逃除了前面说的CJSON_SAFE_DELETE宏还可以结合平时调试用的断言机制在释放之后主动把指针置空并对着整个树做一次遍历校验。有一种做法是在项目的公共头文件里加入一个cjson_helper.h把这些惯用法固化下来#ifndef CJSON_HELPER_H #define CJSON_HELPER_H #include cjson/cJSON.h #define CJSON_SAFE_DELETE(obj) \ do { \ cJSON *__tmp (obj); \ if (__tmp) { \ cJSON_Delete(__tmp); \ (obj) NULL; \ } \ } while(0) #define CJSON_FREE_AND_NULL(ptr) \ do { \ if ((ptr)) { \ free((ptr)); \ (ptr) NULL; \ } \ } while(0) // 只用于调试递归打印树节点数和字符串总长度帮助判断是否有异常节点 void cjson_debug_dump_tree(const cJSON *node, int depth); #endif这个头文件在团队里推广之后所有人写cJSON释放时都统一走这两个宏。好处有两个第一不会忘记置NULL第二即使有人不小心双删第二次传进去的是NULLcJSON_Delete不会执行安全。4.2 ASAN和valgrind在cJSON场景下的用法建议valgrind是静态分析工具跑起来比较慢适合回归测试。ASANAddressSanitizer则是编译期插桩速度比valgrind快好几个数量级更适合本地开发时日常跑。两者搭配使用基本能覆盖大部分内存错误。用ASAN编译cJSON项目很简单gcc -g -fsanitizeaddress -fno-omit-frame-pointer -o monitor monitor.c cJSON.c ./monitorASAN一旦检测到堆越界、double free、use-after-free会在触发点直接终止并打印详细的调用栈。对cJSON来说最常见被ASAN抓到的就是两类一类是删除了还在被引用的节点后继续用另一类是手动free了cJSON内部的valuestring指针。建议的做法是每写一个涉及cJSON解析或构造的模块本地都先用ASAN跑一遍对应的单元测试再合入主干。valgrind只在完整回归测试时跑因为它慢但报告更详细能把所有泄漏点一次列全。4.3 容易踩的“家族相似”坑Detach、Array、Print的内存归属cJSON里这几个和相关函数的区别值得单独拎出来说。它们名字相近但内存归属完全不同非常容易搞混。这些函数我做成了一张速查表贴在工位上函数返回值/操作对象内存归属正确的回收方式cJSON_Parse(str)新的cJSON树根调用方拥有cJSON_DeletecJSON_Print(node)新的字符串调用方拥有freecJSON_PrintUnformatted(node)新的字符串调用方拥有freecJSON_GetObjectItem(obj, key)指向树内节点的指针原树拥有不要单独释放cJSON_GetArrayItem(arr, idx)指向树内节点的指针原树拥有不要单独释放cJSON_DetachItemFromObject(obj, key)独立节点调用方拥有cJSON_DeletecJSON_DetachItemFromArray(arr, idx)独立节点调用方拥有cJSON_DeletecJSON_ReplaceItemInObject(obj, key, newitem)替换节点旧节点被删除由cJSON负责cJSON_Delete(newitem)当替换失败时cJSON_AddItemToArray(arr, item)添加节点数组树拥有不要单独释放itemcJSON_AddItemToObject(obj, key, item)添加节点对象树拥有不要单独释放item看了这张表再回去看那些“崩了”“泄漏了”的现场你会发现大半问题都能在表格里找到对应。一个特别容易迷惑的场景是cJSON_AddItemToObject之后原来的item指针不能再用。很多人觉得cJSON_AddItemToObject是复制了一份数据只要之后cJSON_Delete(item)就没问题。错了这个函数只是把item指针挂到对象树的child链上并没有复制数据。如果对item再执行一次cJSON_Delete实际上就是把对象树里的节点给删了后面遍历对象树必然出错。4.4 我的使用习惯总结一套能直接照抄的释放模板几次被cJSON内存问题折磨之后我给自己定了一套“铁律”只要是新写的解析函数外层结构固定成这样int parse_and_handle(const char *json_text) { int ret -1; char *raw_text NULL; cJSON *root NULL; cJSON *cur NULL; if (!json_text) return -1; root cJSON_Parse(json_text); if (!root) { // cJSON_Parse失败时内部已经清理不需要Delete return -1; } cur cJSON_GetObjectItemCaseSensitive(root, data); if (!cur || !cJSON_IsObject(cur)) { ret -2; goto cleanup; } // 正常的处理逻辑 // ... ret 0; cleanup: CJSON_SAFE_DELETE(root); return ret; }这个模板几个要点每个函数只负责一种资源的创建和释放raw_text这类独立malloc的字符串也在cleanup里free并置NULL。所有中间节点cur、item等一律不单独释放只释放根节点root。所有return都改成goto cleanup的单一出口确保任何分支都不会跳过清理。如果函数中途需要把某个节点“摘出去”变成自己的数据用Detach系列函数摘出来之后该节点由调用方负责。这套模板写起来确实啰嗦一点但绝对值得。因为内存泄漏的修复成本比多写两行goto的成本高太多了。5. 几个容易被忽略但会导致实际泄漏的边界情况前面的案例和模板覆盖了90%的场景。但既然已经聊到这个份上了我把剩下10%的边界情况也整理了这些情况稍微冷门一点但一旦碰上排查起来更费劲。5.1 cJSON_Parse解析失败后到底要不要Delete很多人纠结一个问题cJSON_Parse返回NULL时需不需要调用cJSON_Delete(NULL)答案是不需要调了也没事。cJSON_Parse内部如果解析失败会在跳出时把已分配的节点以cJSON_Delete方式清理掉然后返回NULL。你拿到NULL时内存已经处理干净了。再对NULL调一次cJSON_Delete也是安全的因为cJSON_Delete进来就直接while(item)item是NULL循环直接跳过。但有一个隐患在cJSON_ParseWithOpts。这个函数有个return_parse_end参数会让你知道解析到了字符串哪个位置。用这个接口时要特别注意如果在JSON字符串尾部还有额外字符cJSON_ParseWithOpts默认允许这种“解析了一部分”的情况不会返回NULL而是返回一个完整节点。如果不校验return_parse_end是否指向字符串末尾你可能会拿着一个“只解析了前半段”的树继续用这不算内存泄漏但属于逻辑错误。正确做法是const char *end NULL; cJSON *root cJSON_ParseWithOpts(json_text, end, 1); if (!root) { // 解析失败 } if (end *end ! \0) { // 表示json_text里还有未解析的垃圾字符按需报错 }第三个参数require_null_terminated设为1时如果末尾有多余字符ParseWithOpts会返回NULL这是更严格也更安全的模式。5.2 cJSON_IsReference标志和共享节点的释放问题前面提到了cJSON_IsReference标志位。如果代码里用了cJSON_AddItemReferenceToObject或者cJSON_AddItemReferenceToArray那么节点之间的内存关系就是“引用共享”而不是“树状归属”。这时主要靠逻辑上约定谁拥有底层内存cJSON本身帮不了你。cJSON *shared_inner cJSON_CreateObject(); cJSON_AddStringToObject(shared_inner, version, 1.0); cJSON *root1 cJSON_CreateObject(); cJSON_AddItemReferenceToObject(root1, inner, shared_inner); cJSON *root2 cJSON_CreateObject(); cJSON_AddItemReferenceToObject(root2, inner, shared_inner); // 此时shared_inner被root1和root2分别引用 // 如果你分别对root1和root2调用cJSON_Delete // 由于shared_inner是引用节点它的valuestring不会被free // 但它本身仍然是一个cJSON节点也不会被free - 泄漏这个坑的实际触发场景是构造一个公共配置块多处引用它然后分别释放各个根节点。公共块因为是引用方式挂上去的不在任何一棵树的child链上所以没有树会释放它。正确的做法是最后单独cJSON_Delete(shared_inner)并且在释放root1/root2时不要试图去修改通过引用拿到的内容。用共享引用的频率不算高但一旦用了就要有这个意识引用节点是“借用”关系最终必须有一个人负责释放底层对象。5.3 自定义内存分配器时的配套释放cJSON允许你替换内存分配函数通过cJSON_InitHooks设置自定义的malloc_fn和free_fn。有些场景下尤其是RTOS或者带内存池的嵌入式环境确实需要把cJSON的内存分配导向自己的内存池。这时候特别容易出事如果只自定义了malloc_fn忘了自定义free_fn或者自定义的分配函数和free函数不配对会导致分配和释放走两套不同的内存管理系统轻则泄漏重则直接hardfault。cJSON_Hooks hooks; hooks.malloc_fn my_pool_malloc; hooks.free_fn my_pool_free; cJSON_InitHooks(hooks);只要这两者是对应的就没问题。但别混着来my_pool_malloc配free这种组合是许多嵌入式项目内存错乱的经典原因。检查你的代码如果初始化了hooks就要确认后续所有的cJSON释放都走同一个free_fn。5.4 cJSON_Print再次强调格式化输出字符串是最易被漏掉的free对象我专门再提一次cJSON_Print因为它真的是所有cJSON泄漏里占比最高的单个原因。这个函数返回的字符串不是普通小字符串它可能是整个JSON树序列化后的结果几百字节到几MB都可能。只要忘了free一次就是一次实打实的大块泄漏。而且注意cJSON_Print和cJSON_PrintUnformatted返回的字符串底层用的也是cJSON的malloc_fn所以如果你自定义了hooks释放它们也应该走cJSON的free_fn而不是直接用C标准库的free。这一点很多人想不到尤其在某些交叉编译环境下libc的free和cJSON内部自定义free指向不同的堆管理器混用会导致heap corruption。如果项目里大量使用cJSON_Print建议写一个非常薄的包装char *cjson_print_strdup(const cJSON *item) { char *result cJSON_Print(item); // 可以在外面统一包装一层日志方便追踪最后一次Print return result; }然后在所有打印/发送文本的逻辑里统一走这个包装统一释放。6. 我最后的个人建议把内存释放当作代码设计的一部分而不是事后补救代码写得多了会发现内存泄漏很少是因为“不知道要释放”更多是因为“释放逻辑没有设计过”。大家习惯先写创建和业务流程最后才补释放这就导致释放代码在循环、分支、异常路径里变得支离破碎。我自己现在的做法是写任何一段涉及cJSON的代码前先在草稿上画出树的生命周期这棵树什么时候创建谁负责Delete中间有没有Detach出来单独管理的节点有没有Print返回的字符串需要free。这个流程只需几分钟但能省掉后面好几个小时的调试时间。另外如果项目组用的语言是C可以考虑用RAII封装一下cJSON的释放把裸指针装进一个智能指针或者一个简单的scope guard里手动释放就不容易漏。像这样的封装并不复杂struct CJsonDeleter { void operator()(cJSON* p) const { if (p) cJSON_Delete(p); } }; using CJsonPtr std::unique_ptrcJSON, CJsonDeleter;用CJsonPtr root(cJSON_Parse(...))之后它在作用域结束时会自动执行cJSON_Delete从根本上杜绝了return分支忘删的问题。实测下来这比任何代码审查都有效。C语言环境下没有RAII那就老老实实遵守“单一出口统一清理”的模板。多写点goto cleanup不丢人丢人的是上线一周后看着内存曲线一路飙升还不知道从哪下手。实际上把前面说的这些规则一条条落实下来之后我被cJSON内存问题坑的频率已经降到了接近零。偶尔再碰到第一反应也不是怀疑cJSON本身而是检查自己的代码结构。这个库本身很成熟绝大部分内存问题都是使用方式的问题。希望这篇东西能把你的排查时间从按天计算压缩到按小时计算。