Windows下资源泄漏检测

前言

内存泄露和资源泄露是C\C++程序员不得不面对的一个问题,随着程序越来越大,稍不留神就可能在程序中留下了内存泄露的隐患,这个问题很多人可能觉得没什么,就泄露点内存而已,只要程序逻辑没问题,但是如果程序运行时间很长或者泄露的内存很大的话,会导致系统资源占用过多,严重的也可能使得程序崩溃。
正文
前段时间为了分析程序中是否存在内存泄露问题,使用了Devpartner工具来进行分析,但是该工具要对程序进行重新装载,插入很多的监测代码,导致程序运行速度相当慢,效率也很低,一次运行下来就得个几天时间,而且也只能对操作系统管理的内存来进行分析,但是如果是自己申请的一大块内存堆中发生了内存泄露的话,工具也没办法查出来了。
于是准备自己弄一个内存监测的,其实很简单的东西,在封装的内存申请函数中插入代码来保存分配的指针和大小,在释放的地方则把这个指针删除掉,最后没有被删除掉的指针则是可能存在内存泄露的地方了,但是目前为止只能判断是否发生了内存泄露,接下来就是要把发生内存泄露时刻的调用堆栈打印出来,这样才能方便的跟踪问题到底发生在哪里。
像VC、GDB,都提供调试过程中打印调用堆栈、变量值等功能,例如VC设置为debug模式的话,则会生成一个pdb的文件,这是程序数据库,保存着调试和项目状态信息,既然VC可以得到调试信息的话,那么从技术角度上来说的话,应该可以编程来实现得到调试信息的。
接下来首先实现Windows下面的获取调用堆栈,查找MSDN,有一个StackWalk64函数可以获取堆栈的内容,具体的参数可以查看MSDN,里面有一个主要的输入输出参数就是LPSTACKFRAME64 StackFrame,STACKFRAME64结构表示了堆栈中的一个frame,首先需要对该参数进行初始化,也就是首先要获得当前函数堆栈的一些信息,我们知道函数调用的时候,会首先保存调用点的一些信息,从而在函数结束的时候可以返回到调用处继续执行,通过一小段汇编代码,我们可以很容易得到函数堆栈中的一些基本信息,利用这些信息初始化StackFrame参数,然后调用StackWalk64函数,该函数就会将函数地址的偏移量记录下来,接下来就是要将这些偏移量转换为实际的函数名、文件名、行号等信息,Windows API提供了另外两个函数SymGetLineFromAddr,SymGetSymFromAddr。前一个函数通过偏移量得到函数所在文件及行号,后面一个函数通过偏移量得到函数的实际名称,StackWalk64函数每次调用完后会自动的设置frame参数,下次再调用则得到的是上一层的函数信息,以此类推,通过判断frame的返回函数地址偏移量是否为0则可以用来判断堆栈结束。
在Windows下我们可能需要手动添加某些代码到分配和释放内存的地方,感觉更加智能化一点的话应该可以使用钩子来实现。
Linux下获取调用堆栈相比而言更加简单,有个backtrace函数和backtrace_symbols函数,一次就搞定了。


改进 

上面的内存泄露检测的工具,可以在系统退出的时候检测是否发生内存泄露,并打印出泄露内存处的函数调用堆栈,该工具对于发现的泄露的程序确实能够快速的定位到泄露发生的函数调用位置,但是人总是懒惰的动物,使用了几次后发现用起来实在是有点不爽,不爽点主要有:
1、 每次申请内存的时候都记录了调用堆栈,这个时候有多次分配内存操作用来保存文件名、函数名、行号等信息,但是这些信息用起来却非常的少,因为发生内存泄露的可能性还是比较低的,这样使用该工具后程序的性能大打折扣,对于数据库这样的软件来说,非常影响测试的效率。
2、 源代码的修改,而且修改起来比较繁琐,比如要初始化,记录内存指针和大小,释放的时候要从链表中将其删除,选择合适的位置将泄露的内存打印出来。第一次还很新鲜,第二次开始嫌麻烦了,还出了点错,第三次则决定将这个方法放弃了,这实在太麻烦了,而且程序里面还不一定会有问题。
上述理由应该足够支持我对内存泄露检测工具进行优化了,期望达到的效果就是在对程序改动尽可能小的情况下,可以配置的对程序内存泄露问题进行检测。
第一想法肯定就是用钩子截获内存分配和释放函数,这样在内存分配完后将指针和大小记录下来,释放的时候则将其删除掉,最后剩下的就是有内存泄露的地方了。接下来就是研究如何截获程序内的函数了,Windows下面提供了专门的hook函数,但是只能截获消息,程序内的函数调用并没有消息的通信,这个方法基本上被否定了,在《编程高手箴言》中,有讲述Windows上C的挂钩,这里截取其中部分文字说明下。
在Windows中,所有编译出来的程序都有一个Import。Import中有一个JMP表,所有的函数调用时,先会跳到Import表中,再通过JMP跳到对应的执行函数。所以如果要挂钩的话,只需要把JMP的地址换成要挂钩的函数的地址即可。别人调用函数时就会JMP到挂钩的函数处。
OK,实践一下看看Windows下面函数调用到底是怎么回事吧,写个简单的程序。
#include <stdlib.h>
#include <stdio.h>
void test1()
{
printf("call test1\n");
return;
}
void test2()
{
printf("call test2\n");
return;
}
void
main()
{
test1();
}
在test1的调用处设置一个断点,调试到此处,查看一下汇编代码:
call @ILT+10(test) (0040100f)
对test1的调用是call了一个@ILT+10,ILT的意思就是Incremental Link Table(只在Debug版本下才有),即test1函数对应的是ILT偏移10处的JMP指令,即call跳转到地址0040100f 处,OK,按F11跟进去看看是什么情况吧。
0040100F jmp test1 (00401020)
00401014 jmp test2 (00401070)
在地址0040100f 处果然有一个JMP指令,它是跳转到00401020处,另外test2函数的JMP指令也在这里哦。看看00401020内存是什么吧,按F11继续跟进,发现我们终于来到了test1函数定义的地方了,首先肯定还是一些函数调用最基本的压栈操作什么的,这里就不细说了,对我们hook并没有什么用处。
现在知道函数调用的基本方式了:call 然后JMP,要挂钩的话,我们只需要修改ILT中jmp的地址即可,例如上面,如果要hook函数test1使得其跳转到test2中去,只需要将jmp后面的地址修改为test2的地址即可。但是要注意的是,这块内存可不是随随便便就可以让你改的,不然不小心写错内存地址,Windows就惨了,但是Windows也不是那么绝情啊,提供了函数来让一个WriteProcessMemory的函数来让你写EXE的内存,也就是说你很清楚自己在做什么了。另外有点要说明一下,在debug下,当把断点设置到test1()调用处的时候,watch一下test1,发现其值是00401020,也就是函数定义的位置,但是如果我们将test1打印出来的话,却发现是0040100F,也就是test1在ILT中的位置,这个刚开始走了不少弯路才发现。
写个简单的程序试下我们的想法是否能够行得通吧。
void hookfunc()
{
LPBYTE lpByte1;
LPBYTE lpByte2;
DWORD dwAddr1;
lpByte1 = (LPBYTE)test1; //Get old function JMP Addr
lpByte1 = (LPBYTE)&lpByte1[1];
lpByte2 = (LPBYTE)test2; //Get new function JMP Addr
lpByte2 = (LPBYTE)&lpByte2[1]; //get new and old function's addr
memcpy(&dwAddr1, lpByte2, sizeof(DWORD));
WriteProcessMemory(GetCurrentProcess(), lpByte1, &dwAddr1,sizeof(DWORD), NULL);
}
这个程序首先获得test1和test2函数的地址,也就是ILT中的内存地址,JMP指令占用了一个字节,后面JMP的地址是一个DWORD,所以先跳过JMP的一个字节,将指针指向JMP后面的地址,将test2函数的地址拷贝出来,用WriteProcessMemory函数将test2的JMP地址写入到test1的JMP地址中,调试运行下吧,很不幸,程序挂了。上面的想法似乎哪里出问题了?还是继续跟踪一下吧,在test1的调用处设置断点,跟踪到ILT中,发现:
00401005 jmp test1+4Bh (0040107b)0040100A jmp test2 (00401080)
JMP指令和test2处的还是不同,后面的地址并不是我们预想的test2的地址,理论上此处应该是00401070才符合我们的想法,看来上面的想法还是有问题,不如把JMP的值打印出来看看吧,分别打印test1和test2后面JMP的地址发现,一个是0X26一个是0X71,这个肯定不会是函数的绝对地址了,看来跳转的是一个相对地址,真是笨啊,居然把汇编的知识给忘了,点击右键打开Code Bytes,再来看看吧:
00401005 E9 26 00 00 00 jmp test1 (00401030)
这条JMP指令的16进制形式为E9 26 00 00 00,而E9是远距离跳转,即此处的跳转到的地址为:00401005 + 5(本条指令的长度)+ 26 = 00401030,而00401030是test1函数的真实的地址。现在疑团都解开了,按照这个逻辑,我们上面修改过后,实际跳转的位置就应该是00401005 + 5 + 71 = 0040107B 这个并不是test2函数的真实地址。所以上面的程序还要做下改动,即计算出JMP test1和JMP test2两条指令内存地址的偏移,调整之后应该是:
00401005 + 5 + (0040100A - 00401005)+ 71 = 00401080
而00401080就是test2函数的真实地址。因此上面dwAddr1的值应该按照上面的公式计算出一个偏移量,即71 +(0040100A - 00401005)= 76
再试一下,是不是发现调用test1,打印出来的却是call test2
Hook函数test1成功了!
但是现在却出现了另外一个问题,如果要调用真实的test1函数怎么办呢?现在我们在ILT中已经没有test1的JMP指令了,一种办法是再创建一个空的函数,把这个函数在ILT中的JMP指令修改为JMP test1去,调用那个空的函数就等于是调用了真实的test1了,另外一种办法就是把JMP test2修改成JMP test1,即交换test1和test2的调用。第二种办法似乎更加合适点,因为我们有理由这么假设,test2函数是一个钩子函数,我们一般情况下肯定不会直接来调用test2,而是通过hook的方式来调用到test2的,所以,把test1和test2在ILT中的指令交换之后,显式的调用test1的话就会跳转到test2函数,显式的调用test2函数的话则跳转到test1函数。另外还有一种办法就是记录下test1的绝对地址,自己通过汇编代码来直接调用,这个相对来说就麻烦多了,没有试验。
另外有一点要说的就是,对于Windows的API函数,debug下也并不会产生类似的JMP指令,而是直接通过CALL指令跳转到该API函数的地址,在《编程高手箴言》中对这种情况进行了处理,其方法是通过申请一个全局变量,使得其仍然按照JMP的方式来调用,然后修改JMP的地址就可以了。
至此我们已经实现了挂接本地进程内部函数,从根本上解决了内存泄露程序优化的最大障碍。接下来考虑另外一个问题,我们是否直接提供源代码,给一个初始化的函数,将所有这些代码加入到工程中去,然后调用初始化函数来hook?相比于最开始的版本需要对所有内存分配、释放代码都做改动,这个已经进步了很多,但是似乎还不是那么的透明,能否做得再方便一点呢?能不能做出动态链接库的形式,然后隐式的调用初始化函数?这样用户只需要在程序编译的时候加载.lib文件即可。这里需要提一下#pragma指令的一个用法:
#pragma comment(linker,”/include:…
pragma comment指令将一个注释记录放入一个对象文件或可执行文件中,最常用的就是#pragma comment(lib,”ws2_32.lib”)这个指令告诉编译器将ws2_32.lib库文件链接到目标文件中。而linker的作用则是将一个链接选项放入目标文件中,/include则可以强制包含某个对象。因此我们可以在DLL中创建一个用来初始化的类,并声明一个该类的全局对象,例如__declspec(dllexport) ResourceLeakDetector rld; 将rld对象导出,在rld.h的头文件中,加上一条pragma指令:#pragma comment(linker, "/include:__imp_? rld@@3VResourceLeakDetector@@A") 用来强制包含rld对象,后面的@符合是因为我们是用c++方式导出的,而__imp_的意思则是使用导入对象的一个前缀。为了方便用户使用,我们另外在rld.h头文件中再加入一个:#pragma comment(lib, "rld.lib")
至此我们要使用内存泄露检测程序只需要在工程中加入rld.h,然后随便在哪个文件里面将rld.h include尽量就可以了。
最后就是调用堆栈的效率问题,因为我们在内存泄露情况发生很少的前提下,每次函数调用的时候都获取调用堆栈并将文件名、函数名解析出来保存是很耗资源的事情,一个比较合理的方法就是只获取函数的偏移地址,而不去解析其文件名、函数名。在最后有泄露发生的地方再根据偏移地址来解析文件名、函数名。
最后出来的内存泄露检测程序在运行效率上比原始的版本提升了很多,而且使用起来也不是那么的复杂了:)。
另外,对于路径中包含中文名的话,以前显示会截断字符,究其原因是很多机器上安装的DbgHelp.dll版本太老,没有提供解析路径为宽字符的函数,从windows网站下载了最新的Debug工具库之后,该问题也已经解决。关于Vista下无法获得调用堆栈的问题也随之解决了。

 作者:GFfan

本文首发于: 百度测试技术空间http://hi.baidu.com/baiduqa/blog/item/1916abeb12683c24b80e2de9.html
关注百度技术沙龙

你可能感兴趣的:(windows,资源,检测,泄漏,休闲)