工作也有一些年头了,一直在使用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;
}
这样就知道了,哪里分配没有释放了
但这只适合自己从头到尾参与的项目,而且自己是主要开发人员。如果是中途接手的,那调整的工作量也是挺大的。但是项目有迭代,项目组也不只你一个人,如果有人没有按你的要求来做,还是有泄露风险的。
接下来,我重点讲讲怎么通过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++的智能指针,使用内存池技术来统一管理内存等。