Visual Leak Detector - Visual C++ 的增强内存泄漏检测






4.94/5 (395投票s)
2005年3月12日
19分钟阅读

6563684

103194
适用于 Visual C++ 的内存泄漏检测器,打包成易于使用的库!
- 下载 Visual Leak Detector 1.0 - 476 Kb
- 下载 Visual Leak Detector (包含源代码) 1.9d 测试版 - 728 Kb
- 下载源代码 (1.0 版) - 50.3 Kb
目录
引言
Visual C++ 提供了内置的内存泄漏检测功能,但其功能非常有限。此内存泄漏检测器是作为 Visual C++ 内置内存泄漏检测器的免费替代品而创建的。以下是 Visual Leak Detector 的一些功能,而这些功能在内置检测器中都不存在:
- 为每个泄漏的块提供完整的堆栈跟踪,并在可用时包含源文件和行号信息。
- 为泄漏的块提供完整的数据转储(十六进制和 ASCII)。
- 内存泄漏报告的详细程度可自定义。
市面上已经有其他用于 Visual C++ 的泄漏检测器。但大多数非常受欢迎的,如 Purify 和 BoundsChecker,都非常昂贵。存在一些免费替代品,但它们通常过于侵入性、限制性或不可靠。以下是 Visual Leak Detector 相较于许多其他免费替代品的优势:
- Visual Leak Detector 被打包成一个易于使用的库。您无需编译其源代码即可使用它。并且您只需要对自己的源代码进行少量修改即可将其集成到您的程序中。
- 除了提供带有源文件、行号和函数名称的堆栈跟踪外,Visual Leak Detector 还提供数据转储。
- 它同时支持 C++ 和 C 程序(兼容
new
/delete
和malloc
/free
)。 - 该库的完整源代码都包含在内,并且文档齐全,因此很容易根据您的需求进行自定义。
Visual Leak Detector 免费获得 许可,作为对 Windows 开发者社区的一项服务。
最新消息?
2006 年 11 月 14 日: 发布了一个新的测试版。此版本修复了一些错误,最显著的是一个死锁错误和在 1.9c 测试版中发现的几个断言失败错误。
请注意,测试版不具有 1.0 版本相同的限制。虽然 1.0 只能检测由 new
或 malloc
引起的泄漏,但测试版可以检测许多不同类型的泄漏。
同时请注意,下文描述了 1.0 版本的内部工作原理。由于测试版在下一个官方版本发布之前可能会发生重大变化,因此在官方发布完成之前,本文不会更新以反映测试版的设计。
使用 Visual Leak Detector
本节简要介绍了使用 Visual Leak Detector (VLD) 的基础知识。有关配置选项、运行时 API 的更深入讨论,以及关于更高级用法场景(如将 VLD 与 DLL 结合使用)的讨论,请参阅可下载 Zip 文件中包含的完整文档。
要将 VLD 与您的项目结合使用,请遵循以下简单步骤:
- 将 VLD 库 (*.lib) 文件复制到您的 Visual C++ 安装目录下的“lib”子目录。
- 将 VLD 头文件 (vld.h 和 vldapi.h) 复制到您的 Visual C++ 安装目录下的“include”子目录。
- 在包含程序主入口点的源文件中,包含 vld.h 头文件。最好(但非绝对必需)在包含此头文件之前包含任何其他头文件,除了 stdafx.h。如果源文件包含 stdafx.h,则 vld.h 应在其之后包含。
- 如果您运行的是 Windows 2000 或更早版本,则需要将 dbghelp.dll 复制到正在调试的可执行文件所在的目录。
- 构建项目的调试版本。
当您在 Visual C++ 调试器下运行调试版本时,VLD 将检测程序中的内存泄漏。当程序退出时,所有检测到的内存泄漏的报告将显示在调试器的输出窗口中。在内存泄漏报告中双击源文件的行号将把您带到编辑器窗口中的该文件和行,从而轻松导航到导致内存泄漏分配的代码路径。
注意:当您构建程序的发布版本时,VLD 不会链接到可执行文件中。因此,将 vld.h 保留在源文件中进行发布版本构建是安全的。这样做不会导致任何性能下降或其他不良开销。
制作内存泄漏检测器
Visual Leak Detector 的目标是构建一个优于 Visual C++ 内置内存泄漏检测器的替代品。考虑到这一点,我着手使用与内置检测器相同的方法,即 CRT 调试堆。但这个新的检测器将提供增强功能——主要是完整的堆栈跟踪,这对于查找和修复泄漏非常有帮助。
内置检测器
内置检测器实际上非常简单。当程序退出时,CRT 会在 main
返回后运行一些清理代码。如果启用了内置检测器,则它会在清理过程中运行内存泄漏检查。内存泄漏检查只是查看调试堆:如果调试堆上仍有任何用户块已分配,那么它们就是内存泄漏。malloc
的调试版本在分配时会将分配每个块的文件和行号存储在块的头中。当内置检测器识别到内存泄漏时,它只需查看块头即可获取文件和行号。然后,它将该信息报告给调试器进行显示。
请注意,内置检测器在不监控分配或释放的情况下检测泄漏。它只是在进程终止前拍摄堆的快照,并根据该快照确定是否存在泄漏。堆的快照只告诉我们是否存在泄漏;它没有告诉我们它们是如何泄漏的。显然,为了确定“如何”泄漏,我们还需要获得堆栈跟踪。但要获得堆栈跟踪,我们需要能够在运行时实时监控每一次分配。这就是将我们的泄漏检测器与内置检测器区分开来的地方。
分配钩子
幸运的是,微软提供了一种简单的方法来监控由调试堆进行的每一次分配:分配钩子。分配钩子只是一个用户提供的回调函数,在每次从调试堆进行分配之前都会被调用。微软提供了一个函数 _CrtSetAllocHook
,用于将分配钩子函数注册到调试堆。当调试堆调用分配钩子时,传递的参数之一是唯一标识每次分配的 ID 号——它基本上是每次分配内存块的序列号。内存块头中没有足够的空间让我们直接记录信息,但我们可以使用此唯一 ID 号作为键,将每个块映射到我们想要记录的任何数据。
行走堆栈
现在我们有了一种每次分配块时都能得到通知的方法,以及一种唯一标识每次分配的方法,剩下要做的就是记录每次发生分配时的调用堆栈。我们可以设想使用内联汇编来展开堆栈。但堆栈帧的组织方式可能不同,具体取决于编译器优化和调用约定,因此那样做可能会很复杂。微软再次为我们提供了一个工具来帮助我们。这次是一个我们可以迭代调用的函数,逐帧遍历堆栈。该函数是 StackWalk64
。它是调试帮助库 (dbghelp.dll) 的一部分。只要我们为它提供建立“参考帧”所需的信息,它就可以从中检查我们的堆栈并可靠地展开它。每次调用 StackWalk64
时,它都会返回一个 STACKFRAME64
结构,该结构可作为下一次调用 StackWalk64
的输入。可以以这种方式重复调用它,直到达到堆栈的末尾。
初始化内存泄漏检测器
现在我们有了一个更好的内存泄漏检测器的雏形。我们可以监控每一次分配,并且对于每一次监控的分配,我们可以获取并记录堆栈跟踪。唯一剩下的挑战是确保在程序开始执行时将分配钩子函数注册到调试堆。这可以通过创建一个 C++ 类对象的全局实例来非常简单地解决。构造函数将在程序初始化时运行。在构造函数中,我们可以调用 _CrtSetAllocHook
来注册我们的分配钩子函数。但是,如果我们要调试的程序已经有 *其他* 分配内存的全局 C++ 类对象怎么办?我们如何确保我们的构造函数会先被调用,并且我们的分配钩子函数会在任何其他全局对象被构造之前安装?不幸的是,C++ 标准没有规定构建全局对象的顺序。因此,无法绝对保证我们的构造函数会先被调用。但是,我们可以非常接近地保证它。我们可以利用一个特定于编译器的预处理器指令,明确告诉编译器确保我们的全局变量尽快被构造:#pragma init_seg (compiler)
。此指令告诉编译器将我们的全局对象放在“compiler”初始化段中。此段中的对象最先被构造。接下来,构造“library”段中的对象,而“user”段中的对象最后被构造。“user”段是全局对象的默认段。总的来说,没有正常的普通用户对象应该放在“compiler”段中,因此这提供了相当大的确定性,即我们的全局对象将在任何用户对象之前被构造。
检测内存泄漏
由于全局对象的销毁顺序与其构造顺序相反,所以我们的全局对象将在任何用户对象之后被销毁。然后,我们可以像内置检测器一样检查堆。如果我们发现堆上有一个未释放的块,那就是一个泄漏,我们可以使用我们的分配钩子函数记录的唯一 ID 号来查找其调用堆栈。STL map 在此非常适合将 ID 号映射到调用堆栈。我没有使用 STL map,因为我希望我的库能与 Visual C++ 的新旧版本兼容。旧版本的 STL 与新版本不兼容,所以我不能使用 STL 组件。但好消息是,这给了我一个机会创建一个在概念上类似于 STL map 的数据结构,但针对我的内存泄漏检测器进行了特定优化。
您还记得内置泄漏检测器会查看内存块以获取分配该块的文件名和行号吗?嗯,我们对调用堆栈只有一堆程序地址。将所有这些十六进制数字转储到调试器中并没有多大用处。为了使这些地址更有意义,我们需要将它们翻译成人类可读的信息:文件和行号(还有函数名)。再次,微软提供了帮助我们完成这项工作的工具:符号处理程序 API。与 StackWalk64
一样,它们也属于调试帮助库。我不会在此详细说明,因为它们很多而且使用起来相当简单。它们不像 StackWalk64
那样需要太多技巧。我们可以使用两个符号处理程序 API 来获取我们想要的filename、行号和函数名。名为 SymGetLineFromAddr64
的 API 将地址转换为源文件名和行号。它的姊妹 API SymFromAddr
将地址转换为符号名。对于程序地址(也就是我们拥有的),对应的符号名将是包含该程序地址的函数名。
源代码关键部分
如果您对以上部分感到厌倦而跳过,我在这里总结一下。简而言之,这个内存泄漏检测器的工作原理如下:
- 一个全局对象会自动构造。它是第一个被构造的对象。构造函数会注册我们的分配钩子函数。
- 每次分配最终都会调用我们的分配钩子函数。分配钩子函数会获取并记录每次分配的调用堆栈。调用堆栈信息记录在一个特殊的类 STL 的 map 中。
- 程序终止时,全局对象是最后一个被销毁的对象。它会检查堆并识别泄漏。泄漏的块会在 map 中查找并匹配相应的调用堆栈。结果数据被发送到调试器进行显示。
步骤 1:注册分配钩子
这是 VisualLeakDetector
类的构造函数。注意对 _CrtSetAllocHook
的调用。这就是我们的回调函数 allochook
注册到调试堆的地方。对 linkdebughelplibrary
的调用执行了与调试帮助库 (dbghelp.dll) 的显式动态链接。由于 VLD 本身就是一个库,通过导入库 dbghelp.lib 隐式链接到调试帮助库是不理想的;它会在链接时使 VLD 库依赖于 dbghelp.lib。dbghelp.lib 在许多 Windows 计算机上不存在且不可分发,因此我们需要在运行时与 DLL 链接以绕过导入库。这里还有很多其他工作要做,但其中大部分都与 VLD 支持的自定义配置选项有关。
// 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:行走堆栈
这是负责获取调用堆栈的函数。这可能是整个程序中最棘手的部分。为第一次调用 StackWalk64
进行设置是棘手的部分。为了开始堆栈跟踪,StackWalk64
需要确切知道从堆栈的哪个位置开始遍历。它从不假设我们要从当前堆栈帧开始跟踪。这需要我们提供当前帧的地址以及当前程序地址。我见过其他例子试图通过调用 GetThreadContext
来获取此信息,以检索当前线程的上下文,其中将包含两个必需的地址。但是,正如其文档明确说明的那样,不能依赖 GetThreadContext
来获取正在运行线程的有效信息。按定义,这意味着 GetThreadContext
无法获取当前线程的有效上下文。更好的方法是直接获取必需的地址,而唯一的方法是使用内联汇编。
获取当前帧的地址很简单:它存储在一个 CPU 寄存器 (EBP
) 中,我们可以直接读取它。程序地址获取起来要难一些。虽然有一个 CPU 寄存器 (EIP
) 始终包含当前程序地址,但在 Intel x86 CPU 上,它无法通过软件读取。但我们可以通过调用另一个函数并在此函数内部获取返回地址来以一种迂回的方式获得相同的地址。返回地址与调用该函数的程序地址相同。为此,我创建了一个单独的函数 getprogramcounterx86x64
。既然我们已经在使用内联汇编,我们就可以用汇编编写一个简单的函数调用,而不是编写另一个 C++ 函数,但为了保持易于理解,我尽可能使用了 C++。
在以下代码中,pStackWalk64
、pSymFunctionTableAccess64
和 pSymGetModuleBase64
都是指向 dbghelp.dll 导出的函数的指针。
// 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); } }
这是检索 EIP
寄存器的函数。同样,这必须作为单独的函数调用来完成,因为软件无法直接读取 EIP
寄存器。但是,可以通过进行函数调用来获得相同的值,然后从被调用函数内部获取返回地址。返回地址是调用函数的程序地址,在进行函数调用时会被推入堆栈。我们通过从堆栈复制来获取它。
// 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 导出的函数的指针。report
函数只是一个围绕 OutputDebugString
的自定义包装器,它将消息发送到调试器,以便在调试器的输出窗口中显示。
此函数很长。为了减少混乱,我删除了所有不重要和琐碎的部分。要查看此函数的完整内容,请下载源 ZIP 文件。
// 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 ... }
已知错误和限制
最新版本目前没有已知错误,但有一些已知的限制:
- VLD 不检测 COM 泄漏、进程外资源泄漏或与 CRT 堆无关的任何其他类型的内存泄漏。简而言之,VLD 只检测由
new
或malloc
调用引起的内存泄漏。请记住,VLD 是作为内置内存泄漏检测器的替代品而创建的,该检测器也只检测new
和malloc
的泄漏。 - VLD 不兼容调试帮助库 (dbghelp.dll) 的 6.5 版本。建议与 VLD 一起使用的 dbghelp.dll 版本是 6.3。版本 6.3 包含在 VLD 分发版中。
- VLD 分发版中包含的预编译库可能与 Visual Studio 2005 不兼容。如果您需要将 VLD 与 Visual Studio 2005 结合使用,则应在 Visual Studio 2005 中 从源代码构建 VLD 以创建兼容的库。
鸣谢
- 感谢 Alexandre Nikolov 快速发现了 0.9e 中的错误并帮助快速修复。
- 将 VLD 的全局类
VisualLeakDetector
对象构造到比任何其他用户全局对象都靠前的想法归功于 cmk。谢谢 cmk! - 感谢 Nitrogenycs (又名 Matthias) 发现了
VLD_MAX_DATA_DUMP
无限循环错误。 - 感谢 hsvcs (又名 Holger) 发现了
VLD_MAX_DATA_DUMP
编译错误。 - 感谢 Don Clugston 指出有时人们会构建优化过的调试可执行文件,并且我需要注意这一点。
参考文献
这里有一些指向相关文章和资源的链接:
_CrtSetAllocHook
(来自 MSDN Library)。StackWalk64
(来自 MSDN Library)。- 使用符号处理程序(来自 MSDN Library)。
- IA-32 Intel 架构软件开发者手册 - 第 1 卷。第 3.5 节解释了
EIP
寄存器以及软件如何以及如何不能与其交互。
许可证
Visual Leak Detector 在 GNU Lesser General Public License 条款下分发。有关详细的许可信息,请参阅可下载 ZIP 文件中包含的文档。
历史
此列表简要概述了从版本到版本的更改。有关每个版本更改的详细说明,请参阅可下载 ZIP 文件中的更改日志 CHANGES.txt。
- 2006 年 11 月 14 日: 版本 1.9d 测试版 - 修复了在多线程程序中可能发生的死锁错误。还修复了两个断言失败的错误。
- 2006 年 11 月 6 日: 版本 1.9c 测试版 - 新安装程序,使 VLD 入门更容易。已修复与 Visual Studio 2005 的所有已知不兼容问题。
- 2006 年 3 月 9 日: 版本 1.9a 测试版 - 全新的泄漏检测引擎,可检测大多数(如果不是全部)进程内内存泄漏,而不仅仅是
new
或malloc
的泄漏。还添加了许多其他新的便捷功能。 - 2005 年 8 月 5 日: 版本 1.0 - 添加了一些新功能,并修复了一些错误。对本文进行了重大修订,以使其与 1.0 版本保持同步。
- 2005 年 5 月 2 日: 版本 0.9i 测试版 - 此版本修复了多个错误。它还支持 Windows x64。
- 2005 年 4 月 22 日: 版本 0.9h 测试版 - 此版本对 0.9g 版本中的内部逻辑错误进行了关键修复。
- 2005 年 4 月 22 日: 版本 0.9g 测试版 - 此版本包含一个大大改进的内部搜索/排序算法,从而带来了显著的整体性能提升。
- 2005 年 4 月 13 日: 版本 0.9f 测试版 - 为 0.9e 版本中可能导致崩溃的错误提供了快速修复。
- 2005 年 4 月 12 日: 版本 0.9e 测试版 - 通过删除 STL 组件解决了预构建库与 Visual Studio .NET 之间的兼容性问题(预构建库在 Visual Studio 6.0 中构建,STL 在 .NET 和 6.0 之间不兼容)。
- 2005 年 3 月 30 日: 版本 0.9d 测试版 - VLD 作为预构建库集首次发布。
- 2005 年 3 月 17 日: 版本 0.9c 测试版 - 如果定义了
VLD_MAX_DATA_DUMP
,则修复了编译错误。 - 2005 年 3 月 15 日: 版本 0.9b 测试版 - 提高了检测全局 C++ 对象构造函数中泄漏的能力。
- 2005 年 3 月 12 日: 版本 0.9a 测试版 - 首次公开版本。