Windows下检测内存泄露的方法

        工作也有一些年头了,一直在使用C++做程序,程序做了不少,BUG也改了不少。这里面,内存泄露的跟踪,调试和修改是最耗时间的工作之一。这些年,还是积累了一些经验,在这里记录一下,也算做个总结。

        现在的高级语言,大多都有垃圾回收机制,除非语言本身的缺陷,一般不会遇到内存泄露的问题。但C/C++不一样,自己申请的资源必须自己释放,否则,就像慢性病一样慢慢侵蚀你的程序,在不经意间给你的程序致命一击,然后悄无声息的消失,让你不知所措。如果这个程序是你从头到尾负责的,解决起来还好;如果是从别人那里接手的程序出了问题,那解决起来才叫身心俱疲,头发不知道要掉多少。

        但是问题总得解决才行,解决了问题,才能睡好觉,吃好饭。那么遇到像内存泄露这样的问题,可以怎样解决呢?

第一种方法:代码回溯

        这是最简单,最节省时间的一种做法。有时候,前几天的代码还没有什么问题,今天突然出问题了,调试起来工作量比较大,就可以使用这种方式。在版本控制系统中以未出问题版本时间开始到今天为止,采用二分法方式取历史代码来调试,找到出问题的时间点,再进一步调试。其实不光内存泄露,比较麻烦的崩溃等问题,都可以采取这些方式来解决。采用这种方式,我还是解决了好几个看起来很麻烦的问题,尤其是接手别人的代码。

第二种方法:打日志

        这是程序员最常用的方法,不管什么问题,都可以用打日志的方法来定位问题,尤其是服务器程序和需要长时间运行的程序,虽然不能准确定位问题,但可以缩小分析问题的范围。在日志中,可以定时把内存占用情况打印出来,万一出现内存泄露,还有些痕迹可以查询。

第三种方法:代码调试

        VC中,在Debug模式下,可以在程序入口加入以下代码

_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF)

这样在程序退出的时候,就可以打印出泄露的代码码,看如下代码

int main()
{
	#ifdef _DEBUG
	_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
	#endif
	char* p = new char[1000];
	char* q = new char[500];
	return 0;
}

 调试完成后,Output会输出

 一个1000, 一个500。但这只知道有泄露,不知道哪里泄露啊。如果程序比较复杂,还得慢慢分析。

我们可以将new改写一下

#ifdef _DEBUG
#define MY_NEW new(_NORMAL_BLOCK, __FILE__, __LINE__) 
#else
#define MY_NEW new
#endif


int main()
{
	#ifdef _DEBUG
	_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
	#endif
	char* p = MY_NEW char[1000];
	char* q = MY_NEW char[500];
	return 0;
}

这样就知道了,哪里分配没有释放了

Windows下检测内存泄露的方法_第1张图片

        但这只适合自己从头到尾参与的项目,而且自己是主要开发人员。如果是中途接手的,那调整的工作量也是挺大的。但是项目有迭代,项目组也不只你一个人,如果有人没有按你的要求来做,还是有泄露风险的。

最后,放大招吧。使用WinDbg来调试

        接下来,我重点讲讲怎么通过WinDbg来定位内存泄露。

        WinDbg是Windows平台下一个非常强大的调试工具,不仅可以调试用户态的程序,还可以进行内核态的调试。这里不对该工具做过多的介绍,网上使用教程也多,现在我们只讨论下怎么使用它去检测内存泄露(用我习惯的做法)。

        安装完WinDbg后,还需要做一些简单的设置,才能开始我们后续的工作。

        1、由于我们要使用到WinDbg的扩展命令!heap,而这个命令需要下载ntdll.dll和kernel32.dll的符号,所以我们必须配置Windows的符号服务器。我们得添加一个环境变量

变量名必须是_NT_SYMBOL_PATH_,

变量值是 SRV*f:\symbols* http://msdl.microsoft.com/download/symbols,这里面,f:\symbols是我放符号的路径。当然,也可以在WinDbg里面使用命令加载符号服务器,我觉得不方便,这里就不做介绍了。不过我使用新版WinDbg后好像不用设置也行,但为了尊重以前的习惯,还是加上吧。

        2、接下来,我们需要监视我们的程序内存分配情况。在WinDbg目录下,有一个gflags.exe程序,我们使用命令行对程序进行设置

Gflags.exe /i F:\Test\Debug\Test.exe +ust

其中,/i 表示指定哪个文件,比如我的程序放在F:\Test\Debug目录下,就指定为F:\Test\Debug\Test.exe。+ust表示创建用户模式堆栈跟踪数据库。执行后,命令行提示

 表示成功。gflags更多的设置,可以在命令行下输入gflags.exe /?进行查阅。

(特别提醒,打开了内存监控以后,程序运行会特别占内存,所以调试完成以后一定要关闭监控,命令也很简单,Gflags.exe /i F:\Test\Debug\Test.exe -ust,+ust改为-ust即可)

        好了,现在可以调程序了。

        首先,我们打开WinDbg,然后通过File->Open Executable菜单打开我们的程序。如果我们的程序正在运行,那使用File->Attach to a process挂载上我们的程序。

         接下来,使用菜单File->Symbol file path加载Test.exe(我们自己的程序)的符号。我们的程序在Debug模式编译好后都会生成一个PDB文件,这个文件里面包含我们程序的调试符号。加载上程序的符号,我们才好对程序下断点,单步执行等操作,否则,只能去汇编代码里面找位置下断点或者在程序里面加中断了。

 符号路径之间使用分号“;”隔开,这里我的路径为(srv*;C:\Users\zy099\source\repos\Test\x64\Debug)点击OK。然后在WinDbg命令行中输入

.reload /f

加载符号,等待符号加载完成。在WinDbg命令行里也可以操作,但我这个记性不太好,又懒得去查,所以就用最简单的方式吧。

由于符号服务器在国外,在国内下载会比较慢,所以需要耐心等待一段时间....

当WinDbg命令行可输入的时候,说明符号已下载成功,这时使用“lm”命令查看符号加载情况

 这里请一定注意ntdll、KERNEL32和我们的Test程序符号加载上没有,如果后面是如上提示,说明符号加载成功。ntdll和KERNEL32主要是需要使用扩展命令,Test用于调试程序。

        接下来,可以使用菜单File->Open Source file打开要调试的源文件。一般如果符号加载正确,代码中有中断的话,就自动跳到源代码处。这里我们就手动打开。

如果Test.exe的符号加载成功,就可以在指定位置按F9下断点了。这里我在 main 的 return 处下了一个断点

        之后就是检查程序泄露点了,在这里,我们要用到WinDbg的扩展命令!heap,关于!heap命令的详细使用方式,这里不多做介绍,有兴趣的朋友可以去查看WinDbg的帮助文档,以后有时间,我也可以另起一篇介绍介绍。

代码如下

class Bad
{
public:
	void AllocMemory()
	{
		for (auto i = 0; i < 100; ++i)
		{
			char* p = new char[5000];
		}
	}
};

int main()
{
	Bad b;
	b.AllocMemory();
	return 0;
}

很简单吧,可以一眼看出哪儿有内存泄露,现在我们就来看看WinDbg是怎么去发现的

在程序执行前,我们先看一下堆的情况。

在WinDbg命令行中输入!heap -s显示所有堆的摘要信息

0:000> !heap -s
       Failed to read heap keySEGMENT HEAP ERROR: failed to initialize the extention
       NtGlobalFlag enables following debugging aids for new heaps:
       stack back traces
       LFH Key                   : 0xe48d63c61a6de263
       Termination on corruption : ENABLED
          Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                            (k)     (k)    (k)     (k) length      blocks cont. heap 
-------------------------------------------------------------------------------------
000001e134530000 08000002    1220     60   1020      2     2     1    0      0   LFH
000001e134500000 08008000      64      4     64      2     1     1    0      0      
-------------------------------------------------------------------------------------

然后按F5执行程序,命中断点后停下来。再来看一下堆信息

0:000> !heap -s
        Failed to read heap keySEGMENT HEAP ERROR: failed to initialize the extention
        NtGlobalFlag enables following debugging aids for new heaps:
        stack back traces
        LFH Key                   : 0xe48d63c61a6de263
        Termination on corruption : ENABLED
          Heap     Flags   Reserv  Commit  Virt   Free  List   UCR  Virt  Lock  Fast 
                            (k)     (k)    (k)     (k) length      blocks cont. heap 
-------------------------------------------------------------------------------------
000001e134530000 08000002    1220    652   1020     24     8     1    0      0   LFH
000001e134500000 08008000      64      4     64      2     1     1    0      0      
-------------------------------------------------------------------------------------

这里我们看到,地址为0x000001e134530000的堆有明显增长,之前Commit是60K,现在是652K

然后,我们使用命令!heap -stat -h 000001e134530000进行查看,其中参数-stat表示显示指定堆的使用情况统计信息,-h指定要查看的堆地址,这里是0x000001e134530000

0:000> !heap -stat -h 000001e134530000
     heap @ 000001e134530000
 group-by: TOTSIZE max-display: 20
    size     #blocks     total     ( %) (percent of total busy bytes)
    13bc 64 - 7b570  (90.02)
    1cf0 1 - 1cf0  (1.32)
    30 8d - 1a70  (1.21)
    1234 1 - 1234  (0.83)
    1034 1 - 1034  (0.74)
    df4 1 - df4  (0.64)
    400 2 - 800  (0.36)
    100 8 - 800  (0.36)
    7c4 1 - 7c4  (0.35)
    7a2 1 - 7a2  (0.35)
    138 6 - 750  (0.33)
    390 2 - 720  (0.33)
    695 1 - 695  (0.30)
    628 1 - 628  (0.28)
    1d8 3 - 588  (0.25)
    25c 2 - 4b8  (0.22)
    470 1 - 470  (0.20)
    168 2 - 2d0  (0.13)
    50 8 - 280  (0.11)
    238 1 - 238  (0.10)

我们看到,大小为0x13bc的块有0x64个,总大小0x7B570, 占整个正在使用块的90.02%。我们怀疑这些块就是泄露的块。

接下来我们获取这些块的地址。使用命令!heap -flt s 13bc。其中-flt将显示范围限定为指定大小或大小范围的堆,参数s 13bc就是指定大小为0x13bc的块。

0:000> !heap -flt s 13bc
    _HEAP @ 1e134530000
              HEAP_ENTRY Size Prev Flags            UserPtr UserSize - state
        000001e134546ce0 013f 0000  [00]   000001e134546d10    013bc - (busy)
          unknown!noop
        000001e1345480d0 013f 013f  [00]   000001e134548100    013bc - (busy)
        000001e1345494c0 013f 013f  [00]   000001e1345494f0    013bc - (busy)
        000001e13454a8b0 013f 013f  [00]   000001e13454a8e0    013bc - (busy)
        000001e13454bca0 013f 013f  [00]   000001e13454bcd0    013bc - (busy)
        000001e13454d090 013f 013f  [00]   000001e13454d0c0    013bc - (busy)
        000001e13454e480 013f 013f  [00]   000001e13454e4b0    013bc - (busy)
        000001e13454f870 013f 013f  [00]   000001e13454f8a0    013bc - (busy)
        000001e134550c60 013f 013f  [00]   000001e134550c90    013bc - (busy)
        000001e134552050 013f 013f  [00]   000001e134552080    013bc - (busy)
          unknown!noop
        000001e134553440 013f 013f  [00]   000001e134553470    013bc - (busy)
        000001e134554830 013f 013f  [00]   000001e134554860    013bc - (busy)
          unknown!printable
        000001e134555c20 013f 013f  [00]   000001e134555c50    013bc - (busy)
          unknown!printable

这里我只截取了部分数据,其实这儿比较长。这里我们会看到很多状态为busy的堆块,这些堆块应该就是没有释放的内存空间。

我们使用!heap -p -a 000001e134546ce0,来输出一下它的调用堆栈

0:000> !heap -p -a 000001e134546ce0 
    address 000001e134546ce0 found in
    _HEAP @ 1e134530000
              HEAP_ENTRY Size Prev Flags            UserPtr UserSize - state
        000001e134546ce0 013f 0000  [00]   000001e134546d10    013bc - (busy)
          unknown!noop
        7ff9c9d3d6c3 ntdll!RtlpAllocateHeapInternal+0x00000000000947d3
        7ff9730dd480 ucrtbased!heap_alloc_dbg_internal+0x0000000000000210
        7ff9730dd20d ucrtbased!heap_alloc_dbg+0x000000000000004d
        7ff9730e037f ucrtbased!_malloc_dbg+0x000000000000002f
        7ff9730e0dee ucrtbased!malloc+0x000000000000001e
        7ff60b1c1f73 Test!operator new+0x0000000000000013
        7ff60b1c19f3 Test!operator new[]+0x0000000000000013
        7ff60b1c1e10 Test!Bad::AllocMemory+0x0000000000000040
        7ff60b1c4746 Test!main+0x0000000000000046
        7ff60b1c1eb9 Test!invoke_main+0x0000000000000039
        7ff60b1c1d5e Test!__scrt_common_main_seh+0x000000000000012e
        7ff60b1c1c1e Test!__scrt_common_main+0x000000000000000e
        7ff60b1c1f4e Test!mainCRTStartup+0x000000000000000e
        7ff9c83354e0 KERNEL32!BaseThreadInitThunk+0x0000000000000010
        7ff9c9c8485b ntdll!RtlUserThreadStart+0x000000000000002b

在这里,我们看到了这个堆的调用堆栈,Test!Bad::AllocMemory,确实是我们分配没有释放的内存空间。这就是这个堆块分配的堆栈信息,通过这个信息,我们就可以定位到这块内存是哪里分配的,然后再到相应的函数里面去分析。

        真正在项目中,情况远没有这种简单,有时候,打印出来的堆信息就有很长一串,这就需要在这些信息里面去找有用的信息的。调试是很让人头痛的一件事,但一旦解决了这些难嗗的骨头,成就感还是有的。

写在最后

        C++程序员要想做到内存使用不出什么意外,好像还真不容易做到。但我们使用一些方法减少内存泄露的风险,比如,使用C++的智能指针,使用内存池技术来统一管理内存等。

你可能感兴趣的:(windows,c++)