
1. 先搞清楚资源在哪cJSON的内存分配模型做C语言开发的人基本都跟cJSON打过交道。这个小巧的JSON解析库在嵌入式领域几乎是标配一个.c一个.h就能跑起来但“用完以后内存泄漏”这个坑几乎每个用它的人都踩过。我最早用cJSON做传感器数据上报协议时程序跑两天内存就会偷偷涨几十KB最后被看门狗干掉。后来把问题定位到cJSON_Delete和free用错才彻底根治。今天这篇就围绕cJSON库把资源释放这件事从头到尾捋一遍节点内存从哪里来、cJSON_Delete到底释放了什么、什么时候该用free、什么时候不该碰free最后再分享几个实际排查内存泄漏的方法。既有原理也有可直接抄作业的示例代码适合嵌入式开发者、C语言初学者以及那些正被valgrind报告折磨的兄弟。1.1 cJSON节点到底在堆上占了几块内存cJSON的内存泄漏问题根源不在用法花哨而在很多开发者在动手写代码之前根本没有把cJSON的内存分配模型搞清楚。我们先看最核心的结构体cJSON的节点类型大致是下面这个样子typedef struct cJSON { struct cJSON *next; struct cJSON *prev; struct cJSON *child; int type; char *valuestring; int valueint; double valuedouble; char *string; } cJSON;一个看似简单的JSON节点背后至少涉及两块动态内存一块是cJSON结构体本身也就是上面这个结构体的大小由cJSON内部负责分配另一块是它内部挂着的字符串指针比如valuestring存字符串值、string存键名。这两块内存在创建节点时都会通过malloc分配出来释放时也必须一一归还。以cJSON_AddStringToObject(root, name, sensor01)为例这一行代码背后做的事情比你想象的多cJSON内部会先创建一个值节点给这个节点分配结构体内存再把键名name拷贝到节点的string字段又分配一块内存再把字符串值sensor01拷贝到节点的valuestring字段再分配一块内存。如果是数组里有多个元素每个元素节点还有next/prev指针串联起来形成一条链表。也就是说一条看起来只有十几个字节的JSON数据在堆上实际占用的可能是几百字节甚至更多。如果写完代码只释放了根节点指针却没有逐层释放子节点和内部字符串这些内存就会一直悬挂在堆上直到程序退出。更麻烦的是嵌入式环境里很多时候是长时间运行的比如一个网关设备每隔几秒组装一次JSON上报数据一次泄漏几百字节一天就是几MB跑几天系统内存就被吃干净了。所以理解cJSON内存泄漏的第一步就是心里要有一个账本一个JSON树有多少个节点每个节点内部有多少个字符串字段这些都需要在释放时一并归还。光记住“用cJSON_Delete”是不够的你得知道它到底帮你做了什么。1.2 忘记释放时泄漏的是哪些“坑”我在很多项目里见过这样的代码cJSON_Parse接收网络数据把JSON内容解析成一棵节点树然后从中取出某个字段处理完业务逻辑后直接return。整棵树的根节点指针随着函数栈帧一起消失堆上的所有节点全部失去引用这属于最典型的“definitely lost”valgrind一眼就能看出来。还有一种更隐蔽的泄漏模式不是完全没有释放而是只释放了部分。比如只调用了free(root)并没有调用cJSON_Delete(root)。这种情况下根节点的结构体内存确实归还了但根节点内部的string字段、child指向的子树、子节点的valuestring等等全都没有释放。这会产生“indirectly lost”同样会让内存一路增长。另外漏掉打印字符串的释放也是高频问题。cJSON_Print(root)返回一个char*这个字符串不是静态缓冲区是cJSON内部用malloc实时拼出来的。很多人在printf(%s, out)之后就以为万事大吉out指向的内存一直没人释放每次上报数据都漏一块。这个问题单独拿出来看很小但在频繁发送JSON日志的系统中累积起来非常夸张。我的建议是写代码之前先画一张简单的内存归属图哪个指针是cJSON树、哪个指针是打印出来的字符串、哪个指针是外部传入的缓冲区一一对应好释放方式。这样比事后拿着valgrind穷举要高效得多。2. cJSON_Delete内部循环释放整棵树的原理cJSON_Delete是cJSON库中最关键的释放接口很多人只用它却不清楚它到底做了什么。这一节我们就直接看源码层面的逻辑搞清楚为什么一个cJSON_Delete(root)就能把整棵树都带走。2.1 看源码子节点和兄弟节点是怎么被带走的cJSON_Delete的核心实现并不复杂核心逻辑大致是这样的void cJSON_Delete(cJSON *item) { cJSON *next NULL; while (item ! NULL) { next item-next; if (!(item-type cJSON_IsReference) (item-child ! NULL)) { cJSON_Delete(item-child); } if (!(item-type cJSON_IsReference) (item-valuestring ! NULL)) { cJSON_free(item-valuestring); } if (!(item-type cJSON_IsReference) (item-string ! NULL)) { cJSON_free(item-string); } cJSON_free(item); item next; } }这个函数是递归加循环同时使用。while循环负责遍历同一层级的所有兄弟节点也就是通过next指针串起来的链表if里面又通过cJSON_Delete(item-child)递归处理子节点。所以只要你传入的是整棵树的根节点cJSON_Delete就会沿着child往深处走再沿着next往宽处扫把树上的所有节点全部释放掉。这里有个容易被忽略的细节cJSON_IsReference标志。cJSON允许创建所谓的“引用节点”引用节点本身是一个完整的cJSON结构体但它指向的child和valuestring内存并不归它所有。所以cJSON_Delete在释放这种节点时只释放节点结构体本身不释放它引用的那部分内存。如果你用cJSON_AddItemReference创建过共享节点释放时就要额外注意被引用对象的生命周期不能让它被提前释放也不能在整棵树删完后还试图通过旧指针访问它。还有一个常见误区有人以为cJSON_Delete内部会调用free于是手动在调用前把某个节点的子节点先free掉。这是错误的。如果你先把子节点释放了再调用cJSON_Delete它就会对一块已经释放的内存再次执行释放操作造成double free。正确做法是把整棵树交给cJSON_Delete不要自己动手去“帮忙”释放树内部的任何字段。2.2 cJSON_Delete和全局内存钩子的关系cJSON为了适配不同平台提供了内存分配钩子机制。你可以在使用前调用cJSON_InitHooks把库内部的malloc、free替换成自己实现的版本cJSON_Hooks hooks; hooks.malloc_fn my_malloc; hooks.free_fn my_free; cJSON_InitHooks(hooks);这个功能在嵌入式项目中很实用比如你可以封装一个带统计功能的malloc记录分配次数和总字节数用来做内存峰值监控。但这里也埋着一个大坑如果某个模块在设置自定义钩子之前就创建了cJSON节点之后你又把钩子改成别的实现释放时就会出现分配器和释放器不匹配的问题。比如节点A由系统默认malloc分配但你自定义的free_fn底层用的是内存池把内存池的释放函数作用在一个普通堆块上轻则释放无效重则直接崩溃。所以我的经验是如果项目里要用cJSON_InitHooks就在程序启动的最早阶段、创建任何cJSON节点之前统一设置好之后不要再改。这一点尤其要提醒做动态库集成的朋友别人可能在你的库调用之前就已经初始化过cJSON了你贸然换钩子会让旧节点全部变成“孤儿内存”。cJSON_Delete内部统一走cJSON_free这个函数在没有自定义钩子时等价于标准free。这也是为什么很多老代码里直接写free(p)也能跑得动因为默认情况下cJSON_free就是free。但为了安全性只要代码里可能设置自定义钩子一律使用cJSON_free来释放cJSON库返回的字符串和节点能少踩很多坑。3. free和cJSON_Delete的分工几句话讲明白项目群里经常看到有人问“释放cJSON到底用free还是cJSON_Delete”答案很简单释放cJSON节点树用cJSON_Delete释放cJSON库返回的普通字符串比如cJSON_Print的输出结果用cJSON_free或者free。两者根本不是同一个层面的操作混用就会出事。3.1 什么时候用free什么时候用cJSON_Delete我习惯先问自己一个问题这个指针指向的是“cJSON节点树”还是“cJSON库分配出来的一块普通内存”。如果是前者无条件用cJSON_Delete如果是后者用cJSON_free在默认钩子下它等价于free。这两种情况典型区分如下指针来源数据结构释放方式cJSON_Parse 返回值cJSON 节点树cJSON_Delete(root)cJSON_CreateObject / CreateArray 返回值cJSON 节点树cJSON_Delete(root)cJSON_Print / PrintUnformatted 返回值char* 字符串cJSON_free(out)cJSON_PrintBuffered 返回值char* 字符串cJSON_free(out)你自己malloc的字符串再传给cJSON_AddStringToObject外部缓冲区自己free外部所有权有人可能会问为什么不干脆直接对根节点调用free(root)原因在第一节里已经说过free(root)只能释放一个cJSON结构体本身根节点内部的string字段、valuestring字段、child指向的整棵子树全都会漏掉。而且cJSON_Delete在释放时会递归遍历所有子节点这个遍历顺序是设计好的保证不会重复释放、不会漏释放。你要是绕过它等于手动把一棵树的枝枝叶叶砍下来最后只把树干扔了树叶全留在堆上。还有一种情况是使用cJSON_ReplaceItemInObject这类替换接口cJSON库在替换时会自动释放被替换掉的旧节点不需要你手动先删一遍。如果你在替换前手动把旧节点删了库再去操作一个已经失效的节点程序基本必崩。记住一个原则cJSON内部管理的内存交给cJSON的接口去释放外部传入的内存由外部自己负责。两边不要互相越界。3.2 打印结果字符串的释放最容易搞错cJSON_Print是很多人第一次接触cJSON时最常用的接口它能把cJSON对象树格式化成一行字符串方便打印或发送到网络。但它返回的不是静态字符串而是malloc出来的缓冲区。以下面这段代码为例cJSON *root cJSON_CreateObject(); cJSON_AddStringToObject(root, name, sensor01); cJSON_AddNumberToObject(root, value, 36.5); char *out cJSON_Print(root); if (out ! NULL) { printf(%s\n, out); // 这里必须释放 out但用的不是 cJSON_Delete cJSON_free(out); } cJSON_Delete(root);注意看这两行cJSON_Print分配的内存用cJSON_free释放root这棵节点树用cJSON_Delete释放。两者的释放函数不能互换也不能拿free(out)替代cJSON_free(out)。虽然默认钩子下cJSON_free就是free但在设置了自定义内存钩子的项目中两者会指向完全不同的释放例程一旦不匹配就会报堆损坏。另外cJSON_PrintBuffered和cJSON_PrintPreallocated这两个接口也值得了解。前者允许你指定缓冲区长度生成字符串前估算好大小减少内存碎片后者直接把JSON格式化成你给定的预分配缓冲区完全不涉及内部malloc。在内存紧张的MCU环境里cJSON_PrintPreallocated其实是比cJSON_Print更安全的选择因为它从源头上避免了打印字符串带来的内存管理负担。3.3 从对象中分离节点后的释放策略cJSON提供了两组语义完全相反的接口很多人因为没分清而写出泄漏代码。一组是cJSON_DeleteItemFromObject和cJSON_DeleteItemFromArray它们会把指定节点从树中摘除并释放另一组是cJSON_DetachItemFromObject和cJSON_DetachItemFromArray它们只把节点从树中摘除但保留节点内存由调用方接管。使用前者时节点已经释放调用方绝不能再用返回的指针去访问节点使用后者时调用方必须负责后续释放通常是在处理完分离出来的节点后调用一次cJSON_Delete。我见过一个项目里开发想要“保留一个数组元素单独处理”却用了cJSON_DeleteItemFromArray弹出的节点直接被释放了后面再读就是踩空指针而另一个项目用了cJSON_DetachItemFromObject分离出来的节点没释放每次循环都漏一块。这里我给一个可复用的判断公式如果你想“删掉并释放”选Delete带头的接口如果你想“拿出来自己用用完再自己释放”选Detach带头的接口。两者不要混用更不要先Detach之后再调用DeleteItem系列那样等于二次释放同一个节点。4. 手把手实战从创建到销毁的完整生命周期光讲原理容易觉得虚这一节直接写几个完整的示例从创建JSON到释放资源把每一步的释放方式都标清楚。你可以直接复制这些代码去验证再对照自己的项目找问题。4.1 基础对象创建、打印、释放的标准样板我们模拟一个最常见的使用场景组装一条传感器数据格式化成字符串发送给服务器最后释放所有资源。完整代码如下#include stdio.h #include cJSON.h int build_and_send_sensor_data(void) { cJSON *root NULL; char *payload NULL; root cJSON_CreateObject(); if (root NULL) { return -1; } if (cJSON_AddStringToObject(root, device_id, sensor-001) NULL) { cJSON_Delete(root); return -1; } if (cJSON_AddNumberToObject(root, temperature, 36.5) NULL) { cJSON_Delete(root); return -1; } if (cJSON_AddNumberToObject(root, humidity, 78.2) NULL) { cJSON_Delete(root); return -1; } payload cJSON_PrintUnformatted(root); if (payload NULL) { cJSON_Delete(root); return -1; } printf(send payload: %s\n, payload); /* 释放顺序先释放字符串再释放节点树 */ cJSON_free(payload); payload NULL; cJSON_Delete(root); root NULL; return 0; }这段代码里有几个细节值得敲黑板。第一每添加一个字段都进行了空指针检查。cJSON在内存不足时可能返回NULL如果你不检查后面函数崩溃了都找不到原因。第二失败路径上都要记得释放已经创建出来的root。很多初学者只写成功路径一旦中途某个字段添加失败直接return前面分配的内存全丢。第三释放顺序上我习惯先释放payload再释放root并把两个指针都置NULL。这个顺序不是必须的但它们之间的依赖关系很清楚先释放字符串、再释放树逻辑上比较顺。有人可能会说添加字段时检查NULL太啰嗦。我的看法是如果你只想在MCU上快速跑通可以不做但如果这个函数会被调用几十万次还是老老实实加上因为内存不足在高负载下是真实会发生的。4.2 数组和嵌套对象删除一个根节点就够了实际项目里JSON结构很少是单一扁平对象更多是层嵌套加数组。比如一个设备要上报多组历史数据结构可能是这样的{ device_id: sensor-001, history: [ {time: 1700000000, value: 36.5}, {time: 1700000060, value: 36.8}, {time: 1700000120, value: 37.1} ] }用cJSON构建这个结构时典型的代码如下cJSON *root cJSON_CreateObject(); cJSON *history cJSON_CreateArray(); cJSON *item NULL; cJSON_AddStringToObject(root, device_id, sensor-001); for (int i 0; i 3; i) { item cJSON_CreateObject(); cJSON_AddNumberToObject(item, time, 1700000000 i * 60); cJSON_AddNumberToObject(item, value, 36.5 i * 0.3); cJSON_AddItemToArray(history, item); } cJSON_AddItemToObject(root, history, history); char *out cJSON_PrintUnformatted(root); printf(%s\n, out); cJSON_free(out); cJSON_Delete(root);这一步最关键的点是无论是数组history还是数组里嵌套的多个对象item在释放时你都只需要调用一次cJSON_Delete(root)。因为root的child指向history节点history节点的child指向第一个itemitem之间又通过next指针串在一起cJSON_Delete会从根节点出发把整棵树上所有节点都递归释放掉。有一种错误做法是在释放root之前先手动把history数组里的每个item删掉再删history最后删root。这个操作的问题在于你删完item之后history节点里的child指针还指向已经释放的内存再调cJSON_Delete(history)就会碰到悬垂指针结果不可预知。正确姿势只有一个找到根节点把整棵树交给cJSON_Delete其他什么都别自己动手。4.3 动态字符串与替换操作谁的内存谁负责cJSON在处理字符串时有一个容易让新人迷糊的行为cJSON_AddStringToObject会把传入的字符串内容拷贝一份进内部缓冲区而不是直接持有你传入的指针。这意味着外面传的字符串可以在添加完成后立即释放不会影响JSON树里的内容。反过来如果你想修改某个字符串字段用cJSON_SetValuestring是最直接的方式它会自动处理旧字符串的释放cJSON *root cJSON_CreateObject(); cJSON_AddStringToObject(root, name, old_name); cJSON *name_item cJSON_GetObjectItem(root, name); cJSON_SetValuestring(name_item, new_name); cJSON_Delete(root);cJSON_SetValuestring内部会判断新字符串长度如果新长度小于等于当前valuestring的容量就直接在原有缓冲区里覆盖如果超出则会重新分配一个更大的缓冲区并把旧缓冲区释放掉。看到这里你应该明白cJSON内部对字符串有自己的一套生命周期管理外部不要试图去直接free掉name_item-valuestring否则cJSON内部再次释放时就是double free。再看另一个场景我需要从一个外部模块拿到一段动态分配的字符串然后把它加进JSON树里。这里的“谁分配谁释放”问题尤其明显char *external_str get_dynamic_string(); // 外部 malloc 出来的字符串 cJSON_AddStringToObject(root, data, external_str); free(external_str); // 安全因为 cJSON 已经复制了一份cJSON复制了external_str的内容所以外部可以直接free。但反过来如果你把external_str直接赋给某个cJSON节点的valuestring字段这就属于越权操作cJSON不知道这个指针来源释放时可能会用错误的分配器或者由于指针所有权混乱造成多重释放。我的规矩很简单不要直接改cJSON的内部指针字段一律通过cJSON提供的接口去操作。4.4 一个容易让人迷惑的场景引用节点和共享内存cJSON还支持一种比较少见但很实用的功能cJSON_AddItemReference。它允许你把一个已有节点作为引用添加到另一个对象或数组中而不复制节点内容。这在构建一些大型配置时能节省内存但也让释放变得更加复杂。看这个例子cJSON *shared cJSON_CreateObject(); cJSON_AddStringToObject(shared, config, common); cJSON *root cJSON_CreateObject(); cJSON_AddItemReferenceToObject(root, shared_config, shared); cJSON_Delete(root); // 不会释放 shared因为它是引用节点 cJSON_Delete(shared); // 需要单独释放当你调用cJSON_Delete(root)时由于shared_config节点的type里带了cJSON_IsReference标志cJSON不会去释放它指向的shared节点只会释放这个引用节点本身。因此shared节点需要你在合适的时机单独调用cJSON_Delete释放。这个设计本身是为了内存共享但在实际项目中很容易变成泄漏源。如果你用了引用节点一定要给它们建立清晰的“生命周期登记表”谁创建、谁引用、谁释放、谁最后释放。我通常把被引用的共享节点放在一个独立的全局或静态对象里统一管理释放时机避免被多个root节点引用后搞不清该哪个地方释放。5. 内存泄漏排查实战记录就算你仔细读了前面的原理代码写多了难免还是会漏。这一节分享几个我用过的排查方法以及从真实项目中踩出来的经验。5.1 valgrind和ASan怎么告诉我漏在哪Linux环境下valgrind是我排查cJSON泄漏的首选工具。编译时不要开优化加上调试信息然后正常跑一遍程序gcc -g -O0 main.c cJSON.c -o test valgrind --leak-checkfull --show-leak-kindsall ./test如果存在泄漏valgrind会直接告诉你泄漏类型和调用栈。比如出现“definitely lost: 32 bytes in 1 blocks”通常就是一个cJSON节点没释放如果出现“indirectly lost”多半是根节点释放了但子节点的内存没跟上。看到这类报告先顺着调用栈定位到创建节点的那一行再检查是否每个创建路径都有对应的cJSON_Delete。ASanAddressSanitizer是另一种更轻量、更适合集成进CI的方案。编译命令如下gcc -g -fsanitizeaddress -fno-omit-frame-pointer main.c cJSON.c -o test_asan ASAN_OPTIONSdetect_leaks1 ./test_asanASan能在程序退出时输出泄漏摘要也能在堆溢出、double free发生的第一时间中断到出错位置。我在一个长期运行的网关程序里就靠它定位到一处“先free再使用”的隐性问题valgrind跑太久没抓到ASan秒秒钟暴露。建议本地开发用ASan跑单元测试定期再用valgrind做一次全量检查两种工具互补。5.2 我实际踩过的几个坑第一个坑是删除节点后没有把指针置NULL。cJSON_Delete之后根节点指针仍然指向一块已释放的内存如果程序在某个错误分支里再次访问它就会读到脏数据。后来我强制自己在每次cJSON_Delete后都写一句root NULL虽然多了几行代码但省去了一堆潜在的崩溃问题。第二个坑是在回调函数和线程里操作同一个cJSON树。cJSON本身没有加锁多线程并发读写同一棵树轻则内存错乱重则直接段错误。曾经有个项目一个线程在往root节点里添加字段另一个线程又调用cJSON_Delete释放了root前者再操作就是访问野指针。解决办法是给整棵树的访问加互斥锁或者把JSON处理集中到同一个线程做。第三个坑是忘记处理cJSON_GetObjectItem返回NULL的情况。在一个配置解析模块里我写代码时非常自信认为配置里一定有某个key结果线上有一批设备因为配置文件缺了字段导致读取空指针然后崩溃。更恶心的是这种崩溃还时不时触发内存泄漏上报。现在我的习惯是从JSON树里取的每一个节点使用前都检查是否为NULL宁多勿少。第四个坑是泄漏报告中先把“still reachable”当成问题。valgrind里有一类叫“still reachable”的内存通常是指针仍存在但不再释放的全局或静态分配这个一般不是严重问题真正要优先处理的是“definitely lost”和“indirectly lost”。很多新手看到满屏报告就慌了其实分清类型比到处改代码更重要。5.3 嵌入式环境下的cJSON释放建议在MCU这类资源受限的环境里cJSON的内存释放策略要更加谨慎。我的第一建议是减少动态创建和释放的次数尽量复用已经构建好的cJSON树结构。比如一个设备上报的JSON字段名基本不变只是数值变化那么你完全可以在初始化时构建一次cJSON树之后只更新数值字段而不是每次上报都重新创建和释放整棵树。第二建议是使用内存池封装钩子。通过cJSON_InitHooks把malloc和free换成自己实现的内存池版本并维护一个分配计数器。在每次上报周期结束后检查分配计数是否归零如果没归零说明有节点没释放可以立刻定位到具体流程。我在一个电表采集项目里就是靠这套手段把周期内动态内存抖动从几十次降到了零次。第三建议是序列化时优先使用预分配缓冲区。cJSON_PrintPreallocated允许你指定一个固定缓冲区来接收格式化后的JSON字符串这样就不需要担心cJSON_Print返回的字符串怎么释放的问题。配合固定大小栈缓冲区整个JSON处理路径可以做到完全不使用堆内存对长时间运行的嵌入式设备非常友好。最后再分享一个小技巧我在实际项目里会写一个极简的cJSON内存跟踪工具在console输出时打印当前未释放节点数量。具体做法是给malloc和free函数包一层计数器创建节点1、释放节点-1周期上报时如果计数器不为0就说明有泄漏。这个方法在嵌入式环境里比valgrind好用得多因为很多MCU压根跑不起来valgrind。先把计数器搞到长期为0再谈性能和优化这个思路帮我把好几个项目的内存稳定性都提到了一个新的水平。