cJSON内存溢出,踩了一个前人没踩过的坑
最近TFC(具体介绍参见这里)有了新的需求,需要16个通道同时以1kHz以上的采样率进行数据采集。以前AI转换这一部分的内容没有启用DMA,不支持AI数据的批量传送,最大能支持的采集频率约为每秒5次。但基于新的需求开发了定时器触发ADC采集-ADC转换完成触发DMA传送-DMA传送一半触发DMA中断-DMA中断触发UART数据传送的链路。还新增了一条通信指令。当PC向TFC发送JSON字符串:
{ “cmd”: “ai_bulk_read”, “channel”:[10,11],”max_bulk”:5,”msg_id”:”0123456789″}
后,TFC便开始启动AI批量数据传输。(上述指令中cmd表示指令名,channel表示返回的AI通道编号,max_bulk表示每次触发返回的数据包数量,msg_id表示消息的id。只有cmd与channel是必填的)
正常情况下,当发送上面的指令后,TFC会马上回复一条确认信息,然后立即开始批量传送AI数据。
确认信息:
{“cmd”:”ai_bulk_read”,”channel”:[10,11],”bulk_len”:250,”bus_load”:5,”status”:1,”msg_id”:”0123456789″,”time_stamp”:398779}
批量AI数据传送:
{“cmd”: “report_ai_bulk”,”bulk_len”:250,”bulk_counter”:1,”channel”:[10,11],”ai_val”:[1,1,1,1,0,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,0,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,0,1,1,1,0,1,1,1,0,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,0,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,0,1,1,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,1,1,0,0,1,1,1,0,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,0,1,1,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],”status”:1,”msg_id”:”0123456789″,”time_stamp”:398789}
如果发送的channel对象为空数组,则停止AI数据的批量传送:
{ “cmd”: “ai_bulk_read”, “channel”:[],”max_bulk”:0}
执行压测的时候,发现一旦连续发送一定数量的指令之后开始返回乱码。但此时还可以接收并执行指令(如果指定通道号则report_ai_bulk还能继续返回,如果发beep指令,蜂鸣器也能正常鸣叫)。
但如果在此基础上继续连续发送
{ “cmd”: “ai_bulk_read”, “channel”:[],”max_bulk”:0}
命令,可能TFC就直接卡死了。
经过测试发现,TFC重启之后,每次能回应的指令的数量是固定的。而且无论快发还是慢发,都不会改变这个数量值。
测试发送不同长度的指令,能回应的不乱码的指令数量是一定的。统计关系如下:
如果发送的指令长度短于某个值(比如30左右的{ “cmd”: “ai_bulk_read”, “channel”:[]}),则无论如何都不会复现上述问题。
从上面表格中可以看出,发送的指令越长,能正常回应的数量越少,呈现明显的负相关性。但他们的乘积并不是一个固定值,也就是说跟TFC端的RX Buffer关系不一定很大。
另外,将TFC端的堆的大小由0x4000改为0x5000(增大4096Byte),上述指令长度为76的指令能正常回应的指令数量由96变为122。
完了,内存溢出了。
问题探究
试着改变栈的大小,发现并不会影响能正常回应的指令数量。但改变堆的大小可以。说明问题就出现在malloc等分配的内存没有被回收上。全局在项目中所有文件搜索“alloc”,发现这个函数只出现在cJSON.c文件中。是的,就是因为怕内存溢出,剩余接近2w行的手码代码我甚至没敢使用malloc。
在网上搜了无数的cJSON内存回收被前人踩过的坑,其实无非是以下三点:
- 使用了cJSON_Parse创建的对象,没有被cJSON_Delete回收;
- 使用了cJSON_CreateObject创建的对象,没有被cJSON_Delete回收;
- 使用了cJSON_PrintUnformatted输出的字符串,没有被cJSON_free回收。
另外还有一些文章友情的提示了对象的子对象可以被cJSON_Delete从主节点递归回收,以及cJSON的malloc机制有问题、建议使用自己更新之后的mallc函数等等。
逐一排查了使用cJSON_Parse、cJSON_CreateObject函数的代码的所有数据流,均未发现问题。
烧录回2021年的老固件,发现其他指令都不会存在类似问题,连续发送数千个指令之后TFC仍然能够正常工作。也就是说只在新固件的 “cmd”: “ai_bulk_read”指令执行时才会出现这个问题。
这个问题指令时只是多执行了int TFCCallbackAiBulkRead(cJSON* root, cJSON* retObj)这个函数。那么问题就集中在了重点排查这个函数上。
这个函数的代码如下(删除部分跟本问题无关的纯业务代码以及部分故障异常处理代码):
int TFCCallbackAiBulkRead(cJSON* root, cJSON* retObj)
{
int i;
int ch;
cJSON* channel = cJSON_GetObjectItem(root,”channel”);
if(channel==NULL)
{
cJSON_AddStringToObject(retObj, “report”, “channel object is necessary.” );
return 0;
}
int arraySize = cJSON_GetArraySize(channel);
for(i=0;i<arraySize;i++)//已经考虑了arraySize=0的情况
{
ch = cJSON_GetArrayItem(channel, i)->valueint;
bulkAiCfg.channel[i] = ch;
}
for(;i<16;i++)
{
bulkAiCfg.channel[i] = -1;
}
char* msgPtr;
if(cJSON_GetObjectItem(root,”msg_id”))
{
msgPtr = cJSON_GetObjectItem(root, “msg_id”)->valuestring;
if(strlen(msgPtr)>32)
{
cJSON_AddStringToObject(retObj, “report”, “msg_id is too long”);
return 0;
}
strcpy(bulkAiCfg.msg_id,msgPtr);
}
else strcpy(bulkAiCfg.msg_id,””);
if(cJSON_GetObjectItem(root,”max_bulk”)) bulkAiCfg.max_bulk = cJSON_GetObjectItem(root,”max_bulk”)->valueint;
else bulkAiCfg.max_bulk = 0;
cJSON_AddItemToObject(retObj, “channel”, channel);
cJSON_AddNumberToObject(retObj, “bulk_len”, HALF_BUFFER_LEN );
if(arraySize) bulkAiCfg.bulk_enabled = 1;
else bulkAiCfg.bulk_enabled = 0;
return 1;
}
这个函数总体的逻辑是将接受到的json对象root中的必要元素提取出来存在bulkAiCfg结构体中(其他业务处理逻辑根据这个结构体的信息来触发),并把返回值retObj的信息给填充满,回复给PC。
整体的代码逻辑也是跟其他的处理函数相似,唯独不一致的是channel对象。channel对象在接收处理并存放到了bulkAiCfg.channel数组之后,又转而发送了回去,添加到了retObj中。
有没有可能是channel对象被单独提取出来,需要通过cJSON_Delete被回收呢?试着在使用完channel之后cJSON_Delete掉channel,在最后的return 1之前增加cJSON_Delete(channel)的语句,结果如下:
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3488}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3503}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3518}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3533}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3548}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3563}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3578}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3598}
{“cmd”:”ai_bulk_read”,”status”:1,”time_stamp”:3613}
回复的消息变得出奇的短,刚刚添加的“bulk_len”对象都不见了。也就是说cJSON_Delete(channel)语句,不仅回收了channel对象,还回收了接下来的“bulk_len”对象。至于后面的”status”对象为什么没有被删除,原因也简单,因为cJSON_Delete发生在”status”对象被添加之前,执行cJSON_Delete时”status”对象还没被加入到retObj中。
单步调试查看channel对象,发现channel对象其实是一个链表。这就是为什么cJSON_Delete(channel)语句执行之后,不只是channel对象被删除,“bulk_len”对象也被释放的原因。
恍然大悟
搞清楚了cJSON对象其实是以链表方式运作的之后,整个问题似乎变得明朗起来:
- channel对象的内容是个数组,我们获取它也是为了拿到数组里面的内容。
- 当我们获取root对象中的channel对象之后,其实获取的不只是channel对象中的数组,还有它的next、prev节点信息。只不过我们只使用了channel对象中的数组而已,它的next、prev节点信息由cJSON.c库文件来维护。
- 当我们将root中获取到的channel对象添加进retObj对象后,channel对象中的数组信息当然不变,但它的next、prev节点信息被维护成了retObj对象的前后节点信息。
- root以及retObj对象都使用完成之后,都会调用cJSON_Delete()函数递归释放对象信息。但root对象在释放到channel这个子对象时,channel子对象链表的next节点已经指向了retObj中的channel的下一个节点。而原本在root中channel的next节点因为失去了指针链,无法被释放。
- retObj对象在释放到channel子节点时,其内存要被第二遍释放。但在之前root递归释放时其内存已经被释放,所以这里可能会抛出异常(在本案例中没有抛出)。
- root中channel的下一个节点在内存中无法释放的后果就是,每来一条新的指令,堆的剩余空间变小一点点,直到堆被全部使用完,程序便出现了肉眼可见的异常。
了解清楚了原理之后,解决的方案也简单,就是在retObj对象中不要再复用channel对象即可。我们在函数中添加了一个cJSON* channel_dup数组对象,复制了channel的内容。
cJSON* channel_dup = cJSON_CreateArray();
for(i=0;i<arraySize;i++)
{
if(bulkAiCfg.channel[i]<0) break;
cJSON_AddItemToArray(channel_dup,cJSON_CreateNumber(bulkAiCfg.channel[i]));
}
cJSON_AddItemToObject(retObj, “channel”, channel_dup);
把这段代码添加进TFCCallbackAiBulkRead函数之后,问题解决。把堆从0x5000调低到0x4000,程序依然能正常运行,持续发送几万条指令都不再发生异常。