防内存泄漏问题有多种方法,如加强代码检视、工具检测和内存测试等。

一、内存泄漏问题原理

1、堆内存在C代码中的存储方式

内存泄漏问题只有在使用堆内存的时候才会出现,栈内存不存在内存泄漏问题,因为栈内存会自动分配和释放。C代码中堆内存的申请函数是malloc,常见的内存申请代码如下:

  1. char *info = NULL; /**转换后的字符串**/
  2. info = (char*)malloc(NB_MEM_SPD_INFO_MAX_SIZE);
  3. if( NULL == info)
  4. {
  5. (void)tdm_error("malloc error!\n");
  6. return NB_SA_ERR_HPI_OUT_OF_MEMORY;
  7. }

由于malloc函数返回的实际上是一个内存地址,所以保存堆内存的变量一定是一个指针(除非代码编写极其不规范)。再重复一遍,保存堆内存的变量一定是一个指针,这对本文主旨的理解很重要。当然,这个指针可以是单指针,也可以是多重指针。
malloc函数有很多变种或封装,如g_malloc、g_malloc0、VOS_Malloc等,这些函数最终都会调用malloc函数。

2、堆内存的获取方法

看到本小节标题,可能有些同学有疑惑,上一小节中的malloc函数,不就是堆内存的获取方法吗?的确是,通过malloc函数申请是最直接的获取方法,如果只知道这种堆内存获取方法,就容易掉到坑里了。一般的来讲,堆内存有如下两种获取方法:

//方法一:将函数返回值直接赋给指针,一般表现形式如下:
char *local_pointer_xx = NULL;
local_pointer_xx = (char*)function_xx(para_xx, …);

//该类涉及到内存申请的函数,返回值一般都指针类型,例如:
GSList* g_slist_append (GSList *list, gpointer data)

//方法二:将指针地址作为函数返回参数,通过返回参数保存堆内存地址,一般表现形式如下:
int ret;
char *local_pointer_xx = NULL; /**转换后的字符串**/
ret = (char*)function_xx(..., &local_pointer_xx, ...);
//该类涉及到内存申请的函数,一般都有一个入参是双重指针,例如:
__STDIO_INLINE _IO_ssize_t
getline (char **__lineptr, size_t *__n, FILE *__stream)

前面说通过malloc申请内存,就属于方法一的一个具体表现形式。其实这两类方法的本质是一样的,都是函数内部间接申请了内存,只是传递内存的方法不一样,方法一通过返回值传递内存指针,方法二通过参数传递内存指针。

3、内存泄漏三要素

最常见的内存泄漏问题,包含以下三个要素:

  • 要素一:函数内有局部指针变量定义;
  • 要素二:对该局部指针有通过上一小节中“两种堆内存获取方法”之一获取内存;
  • 要素三:在函数返回前(含正常分支和异常分支)未释放该内存,也未保存到其它全局变量或返回给上一级函数。

    4、内存释放误区

    稍微使用过C语言编写代码的人,都应该知道堆内存申请之后是需要释放的。但为何还这么容易出现内存泄漏问题呢?
    一方面,是开发人员经验不足、意识不到位或一时疏忽导致;
    另一方面,是内存释放误区导致。很多开发人员,认为要释放的内存应该局限于以下两种:
    1)直接使用内存申请函数申请出来的内存,如malloc、g_malloc等;
    2)该开发人员熟悉的接口中,存在内存申请的情况,如iBMC的兄弟,都应该知道调用如下接口需要释放list指向的内存:
    dfl_get_object_list(const char* class_name, GSList **list)
    
    按照以上思维编写代码,一旦遇到不熟悉的接口中需要释放内存的问题,就完全没有释放内存的意识,内存泄漏问题就自然产生了。
    当然值得注意的是,错误释放内存,比如内存未申请成功,却进行释放;比如同一块内存多次被释放,会导致SegmentFault的错误,导致整个进程退出

    二、内存泄漏问题检视方法

    检视内存泄漏问题,关键还是要养成良好的编码检视习惯。与内存泄漏三要素对应,需要做到如下三点:
    (1)在函数中看到有局部指针,就要警惕内存泄漏问题,养成进一步排查的习惯
    (2)分析对局部指针的赋值操作,是否属于前面所说的“两种堆内存获取方法”之一,如果是,就要分析函数返回的指针到底指向啥?是全局数据、静态数据还是堆内存?对于不熟悉的接口,要找到对应的接口文档或源代码分析;又或者看看代码中其它地方对该接口的引用,是否进行了内存释放;
    (3)如果确认对局部指针存在内存申请操作,就需要分析该内存的去向,是会被保存在全局变量吗?又或者会被作为函数返回值吗?如果都不是,就需要排查函数所有有”return“的地方,保证内存被正确释放。

三、使用工具valgrind检查内存

1、介绍

Valgrind是一款用于内存调试、内存泄漏检测以及性能分析、检测线程错误的软件开发工具。
主要包括Memcheck、Callgrind、Cachegrind 等工具,每个工具都能完成一项任务调试、检测或分析。可以检测内存泄露、线程违例和Cache 的使用等。

(1)memcheck:内存分析

探测程序中内存管理存在的问题它检查所有对内存的读/写操作,并截取所有的malloc/new/free/delete调用。因此memcheck工具能够探测到以下问题:

  • 使用未初始化的内存:Use of uninitialised memory
  • 使用已释放的内存 :Reading/writing memory after it has been free’d
  • 使用超过 malloc 分配的内存空间:Reading/writing off the end of malloc’d blocks
  • 对堆栈的非法访问:Reading/writing inappropriate areas on the stack
  • 申请的空间是否有释放:Memory leaks – where pointers to malloc’d blocks are lost forever
  • malloc/free/new/delete 申请和释放内存的匹配:Mismatched use of malloc/new/new [] vs free/delete/delete []
  • src 和 dst 的重叠:Overlapping src and dst pointers in memcpy() and related functions

(2)callgrind:函数调用分析

和gprof类似的分析工具,但它对程序的运行观察更是入微,能给我们提供更多的信息。和gprof不同,它不需要在编译源代码时附加特殊选项,但加上调试选项是推荐的。Callgrind收集程序运行时的一些数据,建立函数调用关系图,还可以有选择地进行cache模拟。在运行结束时,它会把分析数据写入一个文件。callgrind_annotate可以把这个文件的内容转化成可读的形式。

(3)cachegrind:cpu缓存分析

cachegrind是一个cache分析器。它模拟执行CPU中的L1, D1和L2 cache,因此它能很精确的指出代码中的cache未命中。如果你需要,它可以打印出cache未命中的次数,内存引用和发生cache未命中的每一行代码,每一个函数,每一个模块和整个程序的摘要。如果你要求更细致的信息,它可以打印出每一行机器码的未命中次数。在x86和amd64上,cachegrind通过CPUID自动探测机器的cache配置,所以在多数情况下它不再需要更多的配置信息了。

(4)helgrind:竞争问题分析

helgrind查找多线程程序中的竞争数据。helgrind查找内存地址,那些被多于一条线程访问的内存地址,但是没有使用一致的锁就会被查出。这表示这些地址在多线程间访问的时候没有进行同步,很可能会引起很难查找的时序问题。

(5) massif:堆栈分析

它能测量程序在堆栈中使用了多少内存,告诉我们堆块,堆管理块和栈的大小。Massif 能帮助我们减少内存的使用,在带有虚拟内存的现代系统中,它还能够加速我们程序的运行,减少程序停留在交换区中的几率。

Valgrind 基于仿真方式对程序进行调试,它先于应用程序获取实际处理器的控制权,并在实际处理器的基础上仿真一个虚拟处理器,并使应用程序运行于这个虚拟处理器之上,从而对应用程序的运行进行监视。应用程序并不知道该处理器是虚拟的还是实际的,已经编译成二进制代码的应用程序并不用重新进行编译,Valgrind 直接解释二进制代码使得应用程序基于它运行,从而能够检查内存操作时可能出现的错误。所以在Valgrind下运行的程序运行速度要慢得多,而且使用的内存要多得多 。 例如,Memcheck工具下的程序是正常情况的两倍多。

2、首先安装软件

(1)命令安装

开发主机安装的发行版可以使用命安装

sudo apt  install valgrind

(2)源码安装

对于开发板可能需要使用源码进行安装
下载

wget https://sourceware.org/pub/valgrind/valgrind-3.19.0.tar.bz2

编译

3、编译时加参数-g生成debug版本

举例:我们这里故意申请了内存没手动释放

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>

int main()
{
    char **allleak = (char **)malloc(sizeof(char*));
    for(int i=0;i<10;i++)
    {
        allleack[i]=(char*)malloc(sizeof(char));
    }
    exit(0);                                                                        
}

编译

gcc -g main.c -o main

4、内存分析时常用命令

valgrind_manual.pdf

valgrind --tool=memcheck ./main

image.png
如图画出的地方就显示了程序的运行情况,申请了11次内存空间释放了0次。18字节被分配。(循环内的10字节和一个指针的8字节—64位机器)。

四、项目中遇到的问题

1、忘记释放mysql相关描述符

void mysql_free_result(MYSQL_RES* result)      //释放结果集MYSQL_RES* result是指针,应该是是使用了动态内存
void mysql_close(MYSQL* mysql)                //释放连接信息描述MYSQL* mysql是指针,应该是使用了动态内存

2、忘记释放redis相关描述符

• 释放redisReply结构指针
void freeReplyObject(void *reply);
• 释放redisContext结构指针
redisFree(redisContext *c);

函数中看到有局部指针,应该是使用了动态内存,就要警惕内存泄漏问题,养成进一步排查的习惯

参考资料