首页
社区
课程
招聘
[原创]软件调试基础--09向内存泄露Say Goodbye!
发表于: 2016-1-27 15:14 6824

[原创]软件调试基础--09向内存泄露Say Goodbye!

2016-1-27 15:14
6824

方法一:Windbg手动分析堆内存变化,找出某一尺寸的堆块在频繁增长,拿到这些堆块的首地址,然后...嘿嘿,还记得前面我们讲的用户态栈回溯数据库是干什么的吧。

下面我们故意写出一个有内存泄露的程序,用于分析,代码如下:

void test()

{

while (1)

{

char *p = new char[100];

Sleep(500);

}

}

int _tmain(int argc, _TCHAR* argv[])

{

test();

system("pause");

return 0;

}

明显上面的程序频繁申请了100字节的堆内存,并且没有释放,而真实项目中的情况也是这样,只不过没有这么简单而已。接下来的分析过程,我们并不去看源代码,而是根据对堆的分析,找出内存泄露所在的代码行号。

既然用到用户态栈回溯数据库,那肯定要对这个进程进行设置了,gflags.exe去开启+UST吧,不会的同学,翻翻前面的章节吧。

Windbg启动调试后,让程序跑一会。然后我们ctrl + break,中断下来,看一下堆内存的使用情况。如下图:



可以看到,只有一个堆,提交大小为488k,这个程序比较简单,只有一个堆,那内存泄露肯定就发生在这个堆上了(其实我们已经知道了,程序运行过程中,并没有动态创建新的堆,哈哈)。

接下来,我们让程序再跑一会,然后再次查看堆的使用情况,然后对比两次堆使用情况,看看哪个堆使用的内存增长较大。再跑一会,然后ctrl + break,如下图:



提交大小已经有1176k,明显在增长,如果程序中有多个堆,我们对比的第一步就是找到哪个堆的内存在发生明显增长。

接下来,我们看一下这个堆的详细信息,如下图:



这个堆中,size=88的堆块,分配了13fd次,这是一个很大的数字了,而且这个堆块总体大小占这个堆大小的95.62%,也就是说,这个堆里的堆块,基本上都是88大小的,这个88很明显是16进制。接下来,我们找一下程序中所有88大小的堆块的首地址。如下图:



其实后面还有很多,有时候需要列很长时间,不过我们列出一些就够了,如果太多,我们可以ctrl+break,我们看到UserPtr这列,就是堆块的首地址了,随便找几个,试试我们前面的(!heap -p -a 地址)命令,如下图:



就拿第一条做实验了,很明显我们运气足够好,一下子找到了一个地方test函数里调用了new,申请了内存,并且首地址是02cf4428,如此,我们便找到了内存泄露的位置。其实我们拿别的UserPtr去看申请时的调用堆栈,基本上都是跟这个一样,也就是说,在这个test函数里,申请了好多次同样大小的内存块,并且没有释放。

方法一总结:很明显,这个方法对比堆内存的变化,都是靠“大概”来猜测的,当一个程序中有明显泄露时,用此方法比较合适,也就是在任务管理器里都能明显看到内存在增长,程序内存泄露越严重,我们这个思路中的“大概”就越准确。比如说,在这个例子中,我们观察堆中堆块的详细信息时,恰好有88这个大小的堆块是最多的,而真实的项目情况中,并不是这样的,有可能这个堆块并不是真的泄露,而是另外一个在整个堆中占比例比较小的堆块在泄露。也有可能当前堆上有很多个块同时在泄露,这样的情况下,就不会有占用比例90%+ 的堆块了,总体来说,我们这个方法,还是比较准的,一般情况下,如果一个程序有内存泄露,我们会想尽办法让他泄露的比较严重,比如进行压力测试,当泄露比较严重的时候,这个方法,就很容易找出泄露位置了。下面我们介绍一种更精确的方法。

方法二:UMDH工具,其原理是对比两个时间点上,堆内存使用的差别,把两个时间点上内存块有差异的部分,打印出其申请时的调用堆栈。简单举例:

时间点1程序中的内存块:1 2 3 4

时间点2程序中的内存块:1 2 3 4 5 6 7

明显时间点2时,程序中新增了5 6 7这三个内存块,这时如果我们能准确拿到5 6 7这三个内存块申请时的调用堆栈,那就很容易找出内存泄露的位置了。其实5 6 7这三个内存块到底释放了没有,我们还是要看代码中的情况的。如果说我们拿到了5 6 7的调用堆栈,就说这三个块有泄露,那是不合逻辑的。因为时间点2时,我们的程序不一定执行到释放5 6 7的代码了。说不定后面会执行到释放。但有一点可以肯定,如果某个内存块,申请了,并且真的没有释放,那肯定会出现在我们对比出的差异里,也就是说,一定会出现在我们列出的所有新增的内存块的申请时调用堆栈里。

原理其实很简单,操作也十分容易,但是上面的那个例子由于泄露的太严重,如果用那个例子的话,讲会找出一大片内存泄露的调用堆栈。所以我们重新写一个mfc程序,来模拟一次内存泄露。关键代码如下:

void CMFCTestDlg::OnBnClickedOk()

{

// TODO:  在此添加控件通知处理程序代码

char *p = new char[100];

}

点击一次按钮,就触发一次申请内存。下面开始操作,此方法同样会用到用户态栈回溯数据库,所以,赶紧去开+UST吧。



上图,两次采样中间,我们点击一下按钮,执行一次内存分配。



然后两次采样完毕后,我们比对结果,将不同的部分筛选出来,放到result.log。然后我们打开result.log文件,这里会有很多个调用堆栈。从中找出哪个调用堆栈中包含着内存泄露。这里有一个技巧,如果一个调用堆栈中,全部都是系统的调用或者框架的调用,没有我们自己写的代码,那这个调用堆栈中基本就不会包含内存泄露了,现在被我们抓到,只不过是人家这次申请的内存还没有来得及释放,后面会有机会释放掉的,我们基本无需关心。

截取result.log文件内容中我们找到的内存泄露部分,(因为测试程序是我们自己写的嘛,这个调用堆栈肯定是内存泄露啦。。。真实的情况,要比这个复杂,要一个一个调用堆栈去看,然后对比源码,看是否是真的泄露了)如下:



这里的88是堆块大小。1 allocs是 一次分配。而且因为我们有MFCTest.exe的符号文件,所以这个调用栈中,我们自己的函数,直接定位到了行号。但为什么mfc120ud模块的符号都没显示出来呢。因为我们没有mfc120ud.pdb文件啊。。。  还记得前面我们用umdh时候那个警告不,



就是这个,告诉我们系统模块的符号路径我们没有设置,默认设置成windows系统目录下的symbols目录了,但是我这个目录里并没有符号,所以...

解决办法很简单,_NT_SYMBOL_PATH是一个环境变量,我们可以设置这个环境变量的值,这个值VS Windbg等调试器都会自动去读,并且作为系统模块符号路径,之前我们在windbg中设置的符号路径,也可以设置到这个环境变量里,这样我们以后调试就不用每次都填写微软那个符号服务器路径了,但是这样有一个弊端,就是启动调试速度会变慢,因为每次启动都会加载符号,如果某个系统模块的符号没有找到,则会连接微软符号服务器去查找,所以...忍受吧。

方法二总结:此方法,不再依靠“大概”,可以说,只要是内存泄露了,一定会被我们抓到,然后就是分析出哪些是真正的内存泄露了。这种方法,很精确,即便程序中只有几个字节的泄露,而任务管理器中根本就看不出来内存在增长,我们同样可以准确找出内存泄露的位置,而且分析起来比方法一简单的多。

全篇总结:两种方法互补,基本解决了C/C++程序中内存泄露的问题,作为一名C/C++程序员,如果还在被内存泄露困扰,拿不出来一个解决方案,那自信心肯定是倍受打击的,本系列的教程的目的,就是要解决那些C/C++比其他语言更难解决的那部分复杂问题,只要我们的基础够好,其实那些看起来很难的问题,其实都很简单。

留下一个问题,我们程序中申请的是100个字节,十六进制应该是0x64,但为什么我们在windbg和后面umdh的报告里都是0x88呢?


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

收藏
免费 3
支持
分享
最新回复 (6)
雪    币: 79
活跃值: (184)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
2
为什么没有人顶
2016-2-15 09:33
0
雪    币: 1112
活跃值: (184)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
对我这种小白,表示这个教程还是很不错的
2016-5-24 09:47
0
雪    币: 35
活跃值: (612)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
不错 期待更多windbg操作教程
2016-8-30 13:06
0
雪    币: 346
活跃值: (25)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
5
收获很多,感谢楼主!
2016-8-30 13:14
0
雪    币: 0
活跃值: (143)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
谢谢lz讲解
2016-9-1 09:11
0
雪    币: 3
活跃值: (11)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
赞赞赞赞
2016-9-1 15:29
0
游客
登录 | 注册 方可回帖
返回
//