名词解释:
1、stack trace:调用堆栈信息
2、debug heap:调试堆
3、Allocation Hook:向调试堆注册的回调函数,当申请内存时,调试堆即调用此回调函数
前言
VC++提供内建的内存泄漏检测,但是其功能简陋。本文介绍的工具Visual Leak Detector(以下称VLD)它提被用于替代vc++内建的检测工具,供一些特性:
1、对每个泄漏内存块提供stack trace,包括源码文件名及行数信息。
2、提供泄漏内存块的完全数据诊断(dump),包括16进制与2进制表示。
3、对于泄漏报告的细节可定制
vc++下可以使用的还有一些商业化的内存检测工具,例如Purify或BoundsChecker都受到人们的欢迎,但价格不菲。有相当多的免费替代品,但通常是不可靠的、有局限性的。VLD相比于其他的免费替代品有如下的优势:
1、VLD被打包成易于使用的类库。你不需要编译它的源码,只需要在你的项目中整合少许代码。
2、额外的提供stack trace,包括源码文件名,行数,和函数名,并且能提供数据诊断(data dumps)
3、支持c/c++程序(兼容new/delete 和 malloc/free)
4、提供完整的、文档化的源码,所以,你可以轻松的定制它
使用VLD
本节简要介绍VLD的使用基础知识。对于更深入的讨论,例如:配置选项,API,更多的高级使用场景(例如在DLL中使用),请参见位于压缩包内的完整文档。
欲在你的项目中使用VLD,顺序执行如下几个简单步骤即可:
1、拷贝VLD lib文件至Visual C++安装目录下的lib子文件夹内
2、拷贝VLD头文件(vld.h and vldapi.h)至Visual C++安装目录下的"include"子文件夹
3、在程序入口点所在的源文件内包含vld.h。最好将此头文件包含在其他头文件之前,stdafx.h之后,但这并不是必须的。如果这个源文件包含了stdafx.h,那么vld.h应该在其后包含。
4、如果运行环境是windows2000或更新,则需要拷贝dbghelp.dll至被调试的可执行文件目录下。
5、编译debug版本的project
在vc++中使用调试器运行debug版本的程序时,VLD将会启动执行。在程序结束后,内存泄漏检测报告将会显示在vc++调试信息输出窗口。双击报告中的源码行数信息,vc++将会跳转至对应的源码处。
注意:在release版本下,VLD并不链接到可执行文件。所以对于release版本可安全的与VLD分离。这种方式保证了不会有任何的性能下降和不良开销。
创建VLD
VLD的目标是成为VC++内置检测器的更好的替代品。考虑到这一点,我们使用VC++内置检测器所使用的方法,即CRT调试堆(CRT Debug Heap)。但是VLD更强大的是拥有完全的stack trace功能,它可以尽可能的帮助你找到和修正泄漏。
vc++内置检测器
内置检测器非常简单。当程序退出,在main返回之后,CRT执行一堆清理代码。如果内置检测器被启用,则会在清理过程中执行一些泄漏检测。泄漏检测简单的查看debug heap:如果有用户分配的内存块还存在于调试堆上,那么必然是泄漏。
调试版本的malloc调用时,会分配一个内存块头结构(block's header),其中存储着源文件名和行数。内存检测器就是简单的从头结构中取出文件名和行数,来标示一个内存泄漏信息,并将信息报告给调试器显示出来。
注意:内置检测器对分配和释放内存没有任何的监控。它只是简单的在进程终止前为堆生成“快照”,并且基于“快照”确定是否有泄漏发生。堆的“快照”只告诉你是否泄漏了,而不能告诉你是什么导致了泄漏。当然,要确定“是什么导致了泄漏”,我们需要得到stack trace。然而,要得到stack trace,需要在运行时监控每一次内存分配操作。这就是VLD和内置检测器的区别。
Allocation Hooking
幸运的是,微软提供了一种简单的方式,用于监控每一次内存分配(从调试堆中):Allocation Hook。它是一个用户提供的回调函数,此函数会在内存分配前被调用。微软提供了_CrtSetAllocHook函数,用于注册回调函数至调试堆。
调试堆调用回调函数时,会传递一个参数,参数实际是一个唯一的串号,用于标示此次分配。串号并不能为我们提供关于block's header的任何信息,但是我们可以以串号作为key,去映射对应的内存块,以记录我们想要记录的信息。
调用堆栈遍历(Walking the Stack)
现在我们已经可以在每次分配内存时获得通知,以及获得串号,那么现在要做的就是记录调用堆栈信息了。我们可以尝试使用内联汇编进行栈展开(unwind the stack)。但是栈帧(stack frames)的产生可能源于不同的方式,其依赖于编译器的优化和调用约定。
幸好,微软提供了函数StackWalk64,这个函数被称之为调用堆栈遍历。它在dbghelp.dll中导出。调用StackWalk64后,其会填充用户传入的STACKFRAME64结构。它可以被循环的调用,直到到达堆栈的底部。
初始化
现在VLD有了良好的开端。我们可以监视每一次内存分配,并且拥有stack trace。
现在只需要确保在程序启动时就为debug heap注册好回调函数。当然,这可以简单的通过创建一个全局的C++对象实例(称VLD对象)来实现,VLD对象会在程序初始化时构造。在构造时,调用_CrtSetAllocHook注册回调函数。
等等,如果程序中有其他的全局对象在构造时申请了内存,我们将如何能确保VLD对象的构造被最先调用呢?(译者注:只有VLD对象最先被调用,才能监控到其他对象的内存申请操作,包括全局对象)遗憾的是,c++规范中并没有详述任何关于全局对象构造顺序的事宜。所以,不能保证VLD对象会被最先构造。
但我们必须尽量满足这一点,我们利用一个特别的编译器预处理指令,告诉编译器,让VLD对象尽快的构造,这个指令是:#pragma init_seg (compiler)。这条指令告诉编译器,将VLD对象置入compiler段(compiler segment)。在这个段内的对象将被最先构造,接着是libray段(library segment)的对象被构造,最后是User段(user segment)的对象被构造。用户定义的全局对象默认就是置于User段。一般来说,普通的用户定义的对象是不会放入compiler段的。所以,这基本可以使我们的VLD对象在其他全局对象前构造。
检测内存泄漏
介于 全局对象的销毁顺序与构造顺序相反,我们的VLD对象也会在其他全局对象之后销毁。现在我们就可以像内建检测器那样检查内存泄漏了。
如果我们发现了某个内存块没有被释放,那便是一个泄漏,我们能够利用挂钩函数返回给我们的串号,来检查stack trace。STL中的map恰好合用,它可以映射串号和其stack trace。但是VLD并没有使用STL map,这是希望对旧版本的vc++保持兼容性,因为旧版本的STL并不兼容于新版本,所以不能使用它。这恰好是一个模拟STL map的好机会,并且可以在其中做特定的优化。
还记得前面提及的,内建检测器会在内存块头部取得源文件名和行数信息吗?好的,我们现在所拥有的stack trace,只是一组地址而已。将这些信息输出到调试器并不完全够用。为了让这些地址更直观,需要将它们转换为可读的信息:文件名与行数(也需要函数名)。再一次,微软带来了合适的工具帮助我们解决难题,如同StackWalk64,它们也是Debug Help Library的一部分。它们是:
1、
SymGetLineFromAddr64:将给定的地址转换为源文件名和行数
2、
SymFromAddr:将给定的地址转换为函数名(symbol name)
源码中的关键点
考虑到你可能厌倦并且跳过了前述,我将在这里进行总结。
一言以蔽之,VLD的工作过程如下:
1、首先,一个全局对象被自动创建。这个对象被最早创建。在对象的构造函数中,向调试堆注册了我们的回调函数。
2、之后,每次申请内存时都会引发回调函数被调用,回调函数中获得并记录了stack trace。这些信息被记录于类似于STL map这样的结构中。
3、最后,程序终止,这个全局对象最后被销毁。它检查调试堆并识别泄漏。泄漏的内存块在map中被查找到,其stack trace经过处理后发送至调试器并显示出来。
步骤1:注册Allocation Hook
这是VisualLeakDetector
类的构造函数。
注意_CrtSetAllocHook的调用,allochook是我们的Allocation Hook。
linkdebughelplibrary完成了dbghelp.dll的动态链接。由于VLD自身就是一个library,隐式链接dbghelp.lib将使VLD库链接时依赖dbghelp.lib,而dbghelp.lib并非在所有的机器上都存在,同时,也是不可再发行的(not redistributable)。因此,隐式链接是不可行的。我们需要采取运行时动态链接来绕过lib。
// Constructor - Dynamically links with the Debug Help Library and installs the
// allocation hook function so that the C runtime's debug heap manager will
// call the hook function for every heap request.
VisualLeakDetector::VisualLeakDetector ()
{
// Initialize private data.
m_mallocmap = new BlockMap;
m_process = GetCurrentProcess();
m_selftestfile = __FILE__;
m_status = 0x0;
m_thread = GetCurrentThread();
m_tlsindex = TlsAlloc();
if (_VLD_configflags & VLD_CONFIG_SELF_TEST) {
// Self-test mode has been enabled.
// Intentionally leak a small amount of
// memory so that memory leak self-checking can be verified.
strncpy(new char [21], "Memory Leak Self-Test", 21);
m_selftestline = __LINE__;
}
if (m_tlsindex == TLS_OUT_OF_INDEXES) {
report("ERROR: Visual Leak Detector:"
" Couldn't allocate thread local storage.\n");
}
else if (linkdebughelplibrary()) {
// Register our allocation hook function with the debug heap.
m_poldhook = _CrtSetAllocHook(allochook);
report("Visual Leak Detector "
"Version "VLD_VERSION" installed ("VLD_LIBTYPE").\n");
reportconfig();
if (_VLD_configflags & VLD_CONFIG_START_DISABLED) {
// Memory leak detection will initially be disabled.
m_status |= VLD_STATUS_NEVER_ENABLED;
}
m_status |= VLD_STATUS_INSTALLED;
return;
}
report("Visual Leak Detector is NOT installed!\n");
}
步骤2:调用堆栈遍历
这个函数承担了获取stack trace的责任,这也许是整个程序中最棘手的部分。第一次调用StackWalk64
前的准备工作尤为棘手。开始之前,StackWalk64
需要确切的知道从栈上的何处开始遍历,因为它并不默认从当前的栈帧(stack frame)开始遍历。这就需要我们提供当前栈帧地址以及当前程序地址(MSDN解释:此地址正是EIP中存储的地址)。可以通过GetThreadContext
函数获取线程上下文,其中便包含这两个地址。但是正如MSDN的解释,GetThreadContext不能在线程运行时获取到有效的信息(据MSDN:调用前必须调用SuspendThread挂起线程)。那就是说,
GetThreadContext
在这里并不适用。更好的办法是直接取得所需的地址,欲达到这种效果,唯一的途径是使用内联汇编。
获取当前栈帧地址很简单:直接从CPU的EBP寄存器中读取。
而获取程序地址则有一些困难。尽管EIP寄存器中存储了当前程序地址,但是在X86下,它不能被软件读取。那么,我们采取一种间接的方式来实现:调用另一个函数,并从此函数中获取返回地址,原理是被调用者返回地址就是调用者地址。因此,我们创建了一个特别的函数getprogramcounterx86x64。既然我们已经使用了内联汇编,那么完全可以使用汇编写一个函数调用,但是考虑到可读性,还是使用C++。
在以下的代码中,pStackWalk64、pSymFunctionTableAccess64和
pSymGetModuleBase64
都是函数指针,指向dbghelp.dll中的对应的API。
// getstacktrace - Traces the stack, starting from this function, as far
// back as possible.
// - callstack (OUT): Pointer to an empty CallStack to be populated with
// entries from the stack trace.
// Return Value:
// None.
void VisualLeakDetector::getstacktrace (CallStack *callstack)
{
DWORD architecture;
CONTEXT context;
unsigned int count = 0;
STACKFRAME64 frame;
DWORD_PTR framepointer;
DWORD_PTR programcounter;
// Get the required values for initialization of the STACKFRAME64 structure
// to be passed to StackWalk64(). Required fields are AddrPC and AddrFrame.
#if defined(_M_IX86) || defined(_M_X64)
architecture = X86X64ARCHITECTURE;
programcounter = getprogramcounterx86x64();
__asm mov [framepointer], BPREG // Get the frame pointer (aka base pointer)
#else
// If you want to retarget Visual Leak Detector to another processor
// architecture then you'll need to provide architecture-specific code to
// retrieve the current frame pointer and program counter in order to initialize
// the STACKFRAME64 structure below.
#error "Visual Leak Detector is not supported on this architecture."
#endif // defined(_M_IX86) || defined(_M_X64)
// Initialize the STACKFRAME64 structure.
memset(&frame, 0x0, sizeof(frame));
frame.AddrPC.Offset = programcounter;
frame.AddrPC.Mode = AddrModeFlat;
frame.AddrFrame.Offset = framepointer;
frame.AddrFrame.Mode = AddrModeFlat;
// Walk the stack.
while (count < _VLD_maxtraceframes) {
count++;
if (!pStackWalk64(architecture, m_process, m_thread,
&frame, &context, NULL, pSymFunctionTableAccess64,
pSymGetModuleBase64, NULL)) {
// Couldn't trace back through any more frames.
break;
}
if (frame.AddrFrame.Offset == 0) {
// End of stack.
break;
}
// Push this frame's program counter onto the provided CallStack.
callstack->push_back((DWORD_PTR)frame.AddrPC.Offset);
}
}
// getprogramcounterx86x64 - Helper function that retrieves the program counter
// for getstacktrace() on Intel x86 or x64 architectures.
//
// Note: Inlining of this function must be disabled. The whole purpose of this
// function's existence depends upon it being a *called* function.
// Return Value:
// Returns the caller's program address.
#if defined(_M_IX86) || defined(_M_X64)
#pragma auto_inline(off)
DWORD_PTR VisualLeakDetector::getprogramcounterx86x64 ()
{
DWORD_PTR programcounter;
// Get the return address out of the current stack frame
__asm mov AXREG,
// Put the return address into the variable we'll return
__asm mov [programcounter], AXREG
return programcounter;
}
#pragma auto_inline(on)
#endif // defined(_M_IX86) || defined(_M_X64)
步骤3:产生更好的内存泄漏报告
最后,下面的这个函数将会转换堆栈遍历时获取的程序地址至函数名。注意“地址-函数名”的转换只发生在内存泄漏被检测到的时候。避免了在程序运行时查找符号表,这将会带来巨大的额外的开销,更不必存储符号名,因为已经存储了地址,再存储符号名是没有意义的。
关于已分配的内存块链表的访问权获取,CRT并没有公布相关文档。这个链表正是被内建检测器用以确定是否存在内存泄漏。
我已经想出了关于获取链表访问权的方法。原理是:无论何时申请新的内存块,那么这个内存块都将被放置链表的头部。那么,如果要获得链表的头部,只需要临时申请一个内存块,这个临时内存块的地址可以被转换成包含_CrtMemBlockHeader结构的地址,并且拥有了链表头指针。
在以下的代码中,pSymSetOptions、pSymInitialize、pSymGetLineFromAddr64和pSymFromAddr
都是函数指针,指向dbghelp.dll中导出的API。而report函数就类似于OutputDebugString
这样的输出调试信息函数。
这个函数相当长,为了更好的可读性,我省略了所有的琐碎部分,以突出重点。关于函数的完全实现,请参见源码。
// reportleaks - Generates a memory leak report when the program terminates if
// leaks were detected. The report is displayed in the debug output window.
// Return Value:
// None.
void VisualLeakDetector::reportleaks ()
{
...
// Initialize the symbol handler. We use it for obtaining source file/line
// number information and function names for the memory leak report.
symbolpath = buildsymbolsearchpath();
pSymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_DEFERRED_LOADS | SYMOPT_UNDNAME);
if (!pSymInitialize(m_process, symbolpath, TRUE)) {
report("WARNING: Visual Leak Detector: The symbol handler"
" failed to initialize (error=%lu).\n"
" Stack traces will probably not be available"
" for leaked blocks.\n", GetLastError());
}
...
#ifdef _MT
_mlock(_HEAP_LOCK);
#endif // _MT
pheap = new char;
pheader = pHdr(pheap)->pBlockHeaderNext;
delete pheap;
while (pheader) {
...
callstack = m_mallocmap->find(pheader->lRequest);
if (callstack) {
...
// Iterate through each frame in the call stack.
for (frame = 0; frame < callstack->size(); frame++) {
// Try to get the source file and line number associated with
// this program counter address.
if (pSymGetLineFromAddr64(m_process,
(*callstack)[frame], &displacement, &sourceinfo)) {
...
}
// Try to get the name of the function containing this program
// counter address.
if (pSymFromAddr(m_process, (*callstack)[frame],
&displacement64, pfunctioninfo)) {
functionname = pfunctioninfo->Name;
}
else {
functionname = "(Function name unavailable)";
}
...
}
...
}
pheader = pheader->pBlockHeaderNext;
}
#ifdef _MT
_munlock(_HEAP_LOCK);
#endif // _MT
...
}
已知的BUG和限制
以下是最新版本的已知BUG和限制:
1、VLD不能检测COM的泄漏,out-of-process资源泄漏,或者其他一些与CRT堆无关的泄漏。简单的说,VLD只能检测new或malloc所产生的泄漏。请记住VLD的目的就是取代内建检测器,而内建检测器只检测new或malloc引起的泄漏。
2、VLD不兼容6.5版本的dbghelp.dll。建议是使用6.3版本。6.3版本已经包含在源码包内。
3、源码包内自带的预编译好的lib可能与vs2005不兼容。如果你的环境是vs2005,建议使用VLD源码在VS2005下重新编译。