能够方便高效地进行动态内存分配,是C++编程语言的重要优点之一;而调试时容易错误使用动态分配的内存也是其最大的缺点之一。Windows程序也可能同样存在与系统资源泄漏或者堆栈相关的内存问题。内存问题是Windows程序错误的常见来源之一、而且如果没有合适的工具进行调试:它们将是最难以追踪到的错误之一。
动态内存分配错误有以下两种基本类型:内存错误和内存泄露。当一个指针或者该指针所指向的内存单元成为无效单元,或者内存中分配的数据结构被破坏时,就会造成内存错误。指针未被初始化、指针被初始化为一个无效地址、指针被不小心错误地修改、在与指针相关联的内存区域被释放以后使用该指针(这种指针被称为虚悬(dangling)指针),这些都会使指针变为无效指针。当通过一个错误指针或者虚悬指针对內存进行写入,或者是将指针强制转换为不匹配的数据结构,又或者是写数据越界的时候,内存本身也会遭到破坏。删除未被初始化的指针,删除非堆指针、多次删除同一指针或者覆盖一个指针的内部数据结构,都会造成内存分配系统错误。总之,C++中的内存错误有无数种可能发生的原因。
内存泄漏在被动态分配的内存没有被释放的时候产生。有很多种情况会导致内存地漏,例如没有在全部的执行路径中释放内存(特别是在那些具有多个返回语句和具有异常抛出的函数中),没有在析构程序中释放所有的内存,或者是忘记将基类析构函数设记为虚函数,还有可能是很简单的情形:忘记释放内存。
这里有一个不幸的消息,那就是在没有帮助的情况下,使用手工方式排除内存错误是极端困难的。内存错误很难被检测到,这是因为这些错误的症状往往十分细小,有时候甚至是不被人注意的。举个例子来说,一个内存泄漏错误可能没有任何症状——除了仅仅是在运行一段时间以后程序会崩溃。而一旦内存错误被找到,追踪得到产生这一错误的原因也十分困难,这是因为导致发生内存错误的原因和可观察到的内存错误产生的结果之间,可能已经相隔了很长的代码和很久的时间。虽然可以使用在第7章中所描述的内存断点的方法——使用Visual C++的调试器进行调试,能够在调试器的帮助下找到特定地址的内存错误,但是仅仅通过使用调试器跟踪检査的方法来确定是否发生了内存错误,可能会是一件困难的事情。使用手工方法将内存分配和回收的信息都记录下来,无疑是不现实的。可以找到这些错误的唯一现实的方法是协调好动态内存分配过程,使得程序能自动检测到自身的内存错误。
这里还有一个好消息,就是Visual C++运行时刻函数库对动态内存分配的管理机制非常好,并且对检测和分析动态内存错误提供了广泛的支持。我将这个被管理的堆称为调试堆。Visual C++编译器自身也提供了相应选项来帮助发现未被初始化的指针变量和这些类型的堆桟破坏。你还可以使用C++的析构函数和智能指针来防止内存泄漏,当对象超出生存范围时会被自动释放。利用好了这些特性,你可以花最小的代价来发现、消除绝大多数的内存错误。
内存错误显然是一个严重的问题,但是内存泄漏的重要性就远远没有那样明显。毕竟Windows会在程序结束的时候将泄漏的内存回收,因此内存泄漏仅仅是一个暂时性的问题。那么只要用户的程序的其他地方可以正确运行,为什么还要关心内存地漏的问题呢?
不考虑回收内存的需要,下面有三个必须消除内存泄漏的原因。首先,内存泄漏往往会导致系统资源的泄露,这会对系统的性能产生直接影响。其次,虽然一旦程序结束,泄漏的内存就会被回收,但是通常的高质量程序和特定的服务器程序必须能够无限地运行。不要设想用户能够喜欢周期性重新启动你的程序。最后,内存泄漏往往是其他程序错误或者不良编程习惯的征兆。因此,对内存泄漏进行追踪,你往往会找到其他的一些文题。下面我们仔细看看前两种原因。
内存泄露往往是其他程序错误或者不良编程习惯的征兆。
在Windows程序中,动态分配内存往往不仅仅代表一块存储区域。很多时候,这些内存代表了某些类型的系统资源。例如文件、进程、线程、信号量、时钟、窗口、设备上下文、字体、画笔或者数据库连接,这些系统资源往往十分紧张,这就使得这种内存泄漏的后果变得比一般普通内存泄漏更加严重。对紧张的系统资源的泄漏会迅速导致系统性能下降,甚至会导致Windows在所有时用内存被耗尽之前彻底崩溃。为了强调这类问题的重要性,这种内存泄漏被称为资源泄漏。
对紧张的系统资源的泄漏会迅速导致系统性能下降,甚至导致Windows崩溃。
Windows 98中图形设备接口(GDI)的资源泄漏是这一类问题中最好的例子。Windows 98使用一个固定64K字节大小的堆来分配画刷、画笔以及其他的图形设备接口数据结构,这个堆被用于整个系统。如果一个程序不能将一个画刷或者画笔正确删除,这个64K字节的堆就会被很快耗尽,导致Widnows 98和在其上运行的所有程序运行性能下降。即使用户拥有上百兆空闲内存,仍然会看到一个如图9.1所示的系统资源短缺对话框。
图9.1 Windows 98系统资源短缺对话框
看到这个消息对话框以后的最佳选择是保存你的工作,退出所有正在运行的程序并且重新启动Windows 98。这当然不太好了。当有了图形设备接口(GDI)以后,用户不再需要对这些资源直接进行动态分配。而是在调用例如CreatePen或者CreateSolidBrush这样的应用程序接口( API)函数时,让Windows在和用户程序行为相关的图形设备接口的堆中进行分配,Windows 2000中并末使用系统范围内的64K字节大小的堆,但是图形设备接口资源泄漏仍然会降低性能——不过不会如同在Windows 98中那样快速地影响性能。
在某些时候,Windows程序中的资源泄漏是可以接受的。举个例子来说,一个用户往往仅仅运行几分钟就退出的应用工具程序所造成的资源泄露可能非常细小,不会造成任何可以观察到的影响。Windows自身也不是足够稳定,以致于每天不得不至少重新启动一次,因此有些Windows程序从来就没有机会能够将细小的资源泄漏积累成严重的大问题。此外,由于计算机的内存容量有限,用户不得不釆用经常性退出当前暂时不使用的程序的方法,以有效利用内存资源。这些程序的周期性重新启动给予了Windows充分机会来同收所有被泄漏的资源。
但是现在时代已经不同了。Windows已经相当稳定,用户可以将Windows程序和Windows自身运行很长时间。现在的计算机拥有相当多的内存,这使得用户即使同时运行相当多的程序也毫无问题。这一切导致用户的期望发生了变化:他们现在期望Windows程序可以无限地运行。经常性地重新启动Windows或者Windows程序已经不再能够被用户接受。
当然,导致程序崩溃的原因有很多种,资源泄漏仅仅是其中的一种。一个程序在崩溃之前可运行的时间越长,则导致崩溃的原因就越可能和资源泄漏有关,原因在于资源泄漏这种错误是和时间相关的(因为资源被耗尽需要花费一些时间),然而其他的错误一般不是时间相关的。举例来说,虽然一个很少被执行的代码中的逻辑错误可能会偶尔导致程序崩溃,但是这种崩溃在程序启动以后立即发生,或者在程序已经运行几天以后发生,其可能件是一样的。而与之产生对比的是,缓慢泄漏资源的程序可能需要几个星期才会逐渐到达崩溃的时刻。一个仅仅在程序已经运行了很长时间以后会发生的崩溃很有可能是资源泄漏的结果。去除程序中的内存泄漏对程序的长期稳定性来说十分重要。
Windows通过它的VirtualAlloc/VirtualFree、HeapAlloc/HeapFree以及GlobalAlloc/GlobalFree应用程序接口函数提供了基本的动态内存分配函数。接着Visual C++运行时刻函数阵在HeapAlloc和HeapFree的上层实现了new/delete和malloc/free。MFC和ATL应用程序基本结构接着也向内存管理中添加了它们自己的部分,使得这问题变得更加复杂。对于大多数新的Windows程序来说,C的运行时刻函数库中已经拥有用户所需要用于内存管理和调试的所有内容,但是为了保持文章的完整性,我还是会讨论所有这些环境下面的内存调试。
Windows中进行动态内存分配的基本方式是VirtualAlloc/VirtualFree应用程序接口函数。Windows为所有的进程提供了4G字节的虚拟地址空间(所有的Windows程序当然都是进程)。这样看起来就像是有很多地址空间,但是要记住这是虚拟内存,因此它仅仅是一段内存区域,除此之外什么都不是。在物理内存同虚拟内存的某一处对应起来以前,用户不可能使用虚拟内存完成任何有用的工作(需要注意的是X86处理器使用的是4K字节的页面大小)。所有被程序使用的内存(确切地说,是主程序可执行代码、动态链接库、拽、堆、内存映射文件和操作系统)都会被映射到虚拟内存中。但是这不一定意味着Windows程序需要直接对虚拟内存应用程序接口函数进行处理,除非该程序需要管理大规模数据(例如二维图形程序)或者完成系统功能(例如Windows自身)。
大多数C++程序使用C运行时刻函数库中提供的new和delete,而不是直接对虚拟内存进行处理:new函数和delete函数工作时,会分配一大块虚拟内存(Windows默认分配1M字节,但是可以通过修改链接器的/HEAP选项来修改分配字节数)并将这块虚拟内存按照程序需求再划分为小块(这被称为再分配)。C运行时刻函数库是使用HeapAlloc和HeapFree实现这种再分配的。当一个进程被装载时,Windows会默认在该进程的虚拟地址空间中创建一个堆,这也就是我们所知道的默认堆空间。大多数程序有一个堆就足够了。但是有时候用户可以通过创建额外的堆来优化内存管理的性能。每一个进程中的C运行时刻函数库的实例都有其自己独立的堆。用户程序在一种情况下会具有多个堆(而用户也许毫不知情),那就是程序使用了动态链接库,而这个动态链接库具有其自己独立的运行时刻函数库的实例。我们常说的本地堆是指由运行时刻函数库的一个特殊实例来进行管理的堆。由此,所有C运行时刻函数库中内存调试函数的行为都将本地堆为基础来进行定义。
GlobalAlloc和GlobalFree函数是从16位Windows中继承下来的。它们基本上是对HeapAlloc和HeapFree函数做了向后兼容的包装,因为它们除了从默认堆中进行再分配以外,几乎没有多做任何其它的事情。在新的程序中唯一会使用到这些函数的地方是将数据传输给Windows,例如将数据拷贝到剪切扳。只有当Windows应用程序接口文档特别推荐使用这些内存函数的时候,才有必要使用它们。
Windows对内存调试提供的支持是十分简单的。Windows提供了IsBadCodePtr、IsBadReadPtr、IsBadStringPtr和lsBadWritePtr应用程序接口函数来帮助用户判断各忡各样的类型指针是否合法。在用户觉得十分艰难的时候,可以使用HeapWalk应用程序接口函数来检查一个堆中的内容。除去这些函数以外,如果要调试从Windows那里直接获取的内存,用户在相当多的时候需要自己独立完成。
虽然Windows没有提供很多应用程序接口函数来帮助用户进行内存调试,但是Windows和处理器一起提供了保护内存,这对跟踪非法指针和内存错误有很大帮助。简单了解一下保护内存在调试内存问题方面的有用之处和局限之处,还是有一些价值的。
首先是保护内存的有用之处,由于32位的Windows提供了4GB的地址空间,对于普通程序来说,一个随机地址指向有效内存的可能性是很小的。因为Windows 2000对最初的64KB进行保护,而Windows 98对最初的4KB进行保护,无论是使用空指针还是使用从空指针开始的即使很小的偏移量,都会导致非法内存访问。在Windows 2000中,所有的操作系统地址空间(地址空间的高端2G字节)也同样被全部保护起来。在Windovvs所有的版本中,属于其他进程的内存是完全不能访问的。Windows还使用保护页对栈的上溢出和下溢出进行了保护,因此对堆栈写操作越界也会导致非法内存访问。另外,一个可被某一进程完全访问的内存页面可能具有只读属性,这样就可以对例如程序代码和只读数据这样的内存内容进行保护。
接下来是保护内存的局限之处,由于内存是按照4KB进行分配的,保护内存对一个可写页面没有提供任何保护。一种常见的内存错误问题是使用堆时写内存越界。由于进程对从内存中再分配出来的堆内存具有全部的访问权限,Windows对于那些仅仅在堆内部发生的内存错误并没有提供保护。但幸运的是,正如你所马上将要读到的Visual C++的C运行时刻函数库在检测堆错误方面做得很好。
Visual C++的C运行时刻函数库提供了广泛的功能,帮助用户检测动态内存分配的内存错误和内存泄漏。这些功能仅仅在调试版本中提供,因此它对发行版本没有影响,这样也就不会造成性能上的损失,这些调试中的大多数支持来源于MFC的一部分,但是从Visual C++4.0开始这些内容被移动到C运行时刻函数库中,这样所有使用Visual C++生成的程序都可以利用这些特性所带来的优点。
当然,在C++中的内存分配是使用new函数,释放是使用delete函数的。在默认情况下。new函数在分配失败的情况下返回一个空指针,但是用户可以使用new而不是使用_set_new_handler加载一个处理程序来抛出一个异常,除非用户在维护一段等待new函数返回为空的代码,否则在使用new函数的时候,应该总是让其在失败时抛出异常,因为这样可以使用户更加容易书写可靠的代码。毕竟空指针很容易被忽视,而一个被抛出的异常确实不可能被忽略的(示例程序请查看第5章)。
Visual C++的C运行时刻函数库可以帮助用户使用许多种方法对内存错误进行调试。最主要的一种帮助就是对己经分配或者释放的的内存写入确定的字节作为标识,以帮助暴露程序中的错误。表9.1列出了所使用的标识。
表9.1 Visual C++的C运行时刻函数库标识模板
字节标识 |
含义 |
0xCD |
已经分配的数据(助记词:alloCatedData) |
0xDD |
已经释放的数据(助记词:DeletedData) |
0xFD |
被保护的数据(助记词:FenceData) |
0xCD标识被用来填充那些最近分配的内存区域,而0xDD被用来填充已经释放的内存,保护字节被写入在被保护内存区域的开始和结束的四个字节,以帮助检测上溢出和下溢出。举例来说,在下面所列出的代码执行以后,pData被设为堆中的一个地址,*pData被设为0xCDCDCDCD,fence1和fence2被设为OxFDFDFDFD。
float* pData = new float;
int fence1 = *((int*)pData) - 1);
int fence2 = *(int*)(((char*)pData) + sizeof(float));
而当下面的代码被执行以后,
delete pData;
*pData接着被设记为0xDDDDDDDD。如果你没有选中调试堆选项中的_CRTDBG_DELAY_FREE_MEM_DF(将会在本章稍后一些的地方讲到),而且打算重新计算fence1和fence2的值,就会发现它们也被设成了0xDDDDDDDD,这是因为在内部数据结构中也使用了释放数据的字节标识。
正如在运行时刻函数库的源代码(Crt\Src\Dbgheap.c)中所描述的,对这些标识的选择要十分仔细,这样才能暴露尽可能多的程序错误。这些标识被特意选择具有以下特征:非零(以和己经被初始化的数据产生对比)、常量(使程序错误可重现)、奇数(在Macintosh上对奇地址的访问会导致自陷(trap),那么为什么不这样做呢)、大数(使其大于一个进程可能使用的地址空间,从而导致非法内存访问)而且不具规则性(因为这些标识不应该频繁位于真实的数据中)。而与之产生对比的是,将内存中的内容初始化为零会掩盖程序中的错误,而将其初始化为一个随机数则会产生随机的程序错误。
这些字节标识还十分便于记忆,这一点可能是其最重要的属性了。如果你发现一个程序尝试对一个内容为0xCDCDCDCD或者0xDDDDDDDD的指针地址解除引用(或者是与之类似的标识,例如0xCDCDCDF0可能就是由某个错误指针加上偏移以后产生的),这时候一定是发现了一个程序错误。我还很喜欢的一点就是这些名字都有相当好的助记词。尽管微软提出的对0xCD的助记词“clear”和0xDD的助记词“dead”对笔者来说并不是很合适。其实微软可以选择更加显著的标识。举例来说,Brian Kemighan和Rob Pike在《The Practice of Programmning》一书中推荐使用0xDEADBEEF作为标识。
另一种帮助调试内存错误的方法就是C运行时刻函数库使用如表9.2所示的内存块类型标识符,将内存划分为五种块类型,这确定了内存信息是如何被跟踪和报告的。字节标识很有用,但是不能以规则的方式对它们进行检查;因此用户需要函数库提供如表9.3中所示的有用函数来帮助用户调试内存错误。
表9.2 Visual C++的C运行时刻函数库内存块类型标识符
内存块类型 |
含义 |
_NORMAL_BLOCK |
由程序直接分配的内存 |
_CLIENT_BLOCK |
由程序直接分配的内存,可以通过内存调试函数对其拥有特殊控制权(用户还可以创建共享块的子类型,以实现高级控制) |
_CRT_BLOCK |
由运行时刻函数库内部分配的内存 |
_FREE_BLOCK |
已经被释放,但是跟踪仍然被保留下来的内存、这在用户选择了调试堆的选项__CRTDBG_DELAY_FREE_MEM_DF以后会出现 |
_IGNORE_BLOCK |
当使用_CrtDbgFlag关闭内存调试操作以后分配的内存(内存调试函数不会对这些内存块进行检查,认为它们没有错误) |
还需要注意的一点是,所有这些函数的使用范围仅仅是在调试版本中,因此不要期望一个像_CrtIsValidPointer这样的函数能够在发行版本中工作。对于发行版本,可以使用应用程序接口函数IsBadReadPtr和IsBadWritePtr作为替代。与之类似,在发行版本中,使用_Heapchk函数代替了_CrtCheckMemory。表9.4列出了一些对调试內存泄漏有用的运行时刻函数库函数。最后,运行时刻函数库还提供了对一般内存调试有用的函数(见表9.5),大多数内存调试函数会在本章稍后一些的地方进行更详细的讨论。
表9.3 Visual C++的C运行时刻函数库提供的帮助调试内存错误的函数
函数 |
用途 |
_CrtChcckMcmory |
检査每一个内存块的内部数据结构和守护(guard)字节,以测试其完整性。对于内存错误来说十分有用,但是当调用次数过多时,可能会严重导致程序运行速度减慢 |
_CtiIsValidHeapPointer |
检验指定指针是否存在于本地堆中 |
_CrtkValidPointer |
检验给定内存范围对读写操作是否合法 |
_Cr!lsMemoryBlock |
检验给定内存范围是否位于本地堆当中,是否拥有例如_NNORMAL_BLOCK这样的有效内存块类型标识符(该函数还可以被用以获得分配数目以及进行内存分配的源文件文件名和行) |
表9.4 用于调试内存泄漏的Visual C++的C运行时刻函数库中的函数
函数 |
用途 |
_CrtSetBreakAlloc _crtBreakalloc=1 |
在给定的分配数目上分配断点,每一块被分配的内存都被指派一个连续的分配号。这对于查找特定的内存泄漏十分有用 |
_CrtDumpMemoryLeaks |
判断一个内存泄漏是否发生。如果发生则将本地堆中所有当前分配的内存按照用户可以阅读的方式进行内存映象转储。这对于在程序结束的时候检测内存泄漏来说十分有用 |
_CrtMemCheckPoint |
在_CrtMemState结构中产生一个本地堆的当前状态的快照 |
_CrtMemDiffercnce |
比较两个堆中的断点,并将不同之处保存在_CrtMemState结构中。如果两个断点不同,则返回真。这对于检测特殊区域代码的内存泄漏十分有用 |
_CrtMemDumpAllObjectsSince |
将从给定堆断点或者从程序头开始分配的内存的所有信息按照用户可以阅读的方式进行内存映象转储 |
_CrtMeniDumpStatistics |
将信息按照用户可以阅读的方式进行内存映象转储到一个_CrtMemState结构中。这一结构中可能包含着一个堆断点或者堆断点之间的差异。这对于得到被使用的动态内存的全面观察信息来说十分有用,而且对检测内存泄漏也是分有用 |
表9.5用于一般内存调试的Visual C++的C运行时刻函数库中的函数
函数 |
用途 |
_CrtSetDbgFlag |
控制内存调试函数的行为 |
_CrtSetAllocHook |
加载一个内存分配过程中的钩子(hook)函数。对于监测内存使用状况或者模拟内存不足情况来说十分有用 |
_CrtSetReportHooK |
加载一个进行定制报告处理的函数。对于过滤报告数据或者将报告数据发送到不同的目的地,例如将数据错误报告送发给一个消息框这样的情形很有帮助 |
_CrtSetDumpClient |
加载一个对用户块进行内存映象转储的函数。对于将数据按照更易阅读的方式进行显示的情形很有帮助 |
_CrtDoForAllClientObject |
对于所有作为用户块进行分配的数据,调用指定的函数 |
正如我在前面所提到的,Visual C++的C运行时刻函数库所支持的内存调试最开始是MFC的一部分。最初的MFC内存调试支持被保留下来,但是现在它大部分仅仅是对运行时刻函数库中函数的简单包装。
由MFC控制的内存有一个不同,那就是new函数被设定为默认在失败时抛出一个异常。在MFC中,用户必须通过使用AfxSetNewHandIer而不是_set_new_handler改变分配失败时候的行为。默认处理异常的函数是AfxNewHandler,它会抛出类型为CMemoryException的异常。在十分必要(我们希望它永远不会发生)的情况下,用户可以通过调用AfxSetNewHandler(0)来使new在失败的时候返回一个空指针,同样,在MFC中,用户可以直接调用AfxThrowMemoryException函数来抛出一个内存异常。读者可以阅读AfxMem.cpp的源代码以了解更多的细节。
由MFC控制的内存还有另外一个不同,那就是MFC重载了CObjett::operator new和CObject::operator delete,这样就给所有由CObject派生的对象加上了一个_CLIENT_BLOCK的内存块类型标识符号。最主要的是,这就可以允许MFC调用_CrtSetDumpClient来如载_AfxCrtDumpClient函数(位于Dumpinit.cpp中),这样使用CObject::Dump虚函数就可以对有效的从CObject派生的对象进行内存映象转储,用户由此可以得到更有用的内存错误分析信息(实现这一函数的技巧在第4章“使用调试语句”中已经介绍过)。
对于那些由CObject派生的类实现内存映象转储虚函数,以得到更加有用的内存借误分析信息。
然而,在默认情况下,_AfxCrtDumpCIient函数并不会调用虚函数CObject::Dump,准确一些说,该函数将一个对象的类名、指针值和大小进行内存映象转储,我猜测之所以采用这样的默认设定,是因为一些例如数组、表和图这样的对象可能具有相当巨大的内存映象转储。为了使用内存映象转储虚函数,用户必须向自己的代码中添加下列代码,而且,最好是添加在初始化的地方。
#ifdef _DEBUG
afxDump.SetDepth(1); // dump CObjects using a deep dump
#endif
为了在内存错误分析中使用内存映象转储虚函数,用户必须调用afxDump.SetDepth(1)函数。
很多内存映象转储函数还使用了深度值来决定是使用浅度策略还是深度策略。举例来说,类C采用下面的方法实现其内存映象转储:
void CObLisL::Dump(CDumpContext &dc) const {
CObjecL::Dump(dc);
dc << "with " << m_nCount << " elements";
if(dc.GetDepth() > 0) {
POSITION pos = GetHeadPosition();
while(pos != NNLL)
dc << "\n\t" << CetNext(pos);
}
dc << "\n";
}
我不知道为什么微软选择了这种极端的处理方法,看上去用户好像应该可以在使用内存映象转储虚函数的时候选择浅度策略进行内存映象转储。
最后要说的是,_AfxCrtDumpClient函数会将所有内容内存映象转储到afxDump以及MFC预定义的全局变量CDumpContext,这表明MFC的调试堆输出由Visual C++跟踪工具进行控制。如果你没有得到预期的内存映象转储信息,那么运行跟踪程序确信打开了Enable tracing选项。
由于MFC对内存的支持大部分仅仅是对C运行时刻函数库中函数的简单包装,因此没有必要再重复我在前面己经介绍过的内容了。作为替代,表9.6给出了MFC到C运行时刻函数库的内存调试函数转换于册:
表9.6 MFC到C运行时刻函数库的内存调试函数转换手册
MFC函数 |
C运行时刻函数库 |
AfxCheckMemory |
_CrtChcckMemory |
AfxDoForAllObjects |
_CrtDoForAllClientObjccts |
AfxDumpMemoryLeaks |
_CrtDumpMemoryLeaks |
AfxIsMemoryBlock |
_CrtIsMemoryBlock |
CMemoryState::Checkpoint |
_CrtMemCheckpoint |
CMemoryState::Difference |
_CrtMemDiffercnce |
CMemoryState::DumpAllObjectsSince |
_CrtMemDumpAllObjectsSince |
CMemoryState::DumpStatistics |
_CrtMemDumpStatistics |
AfxSetAllocStop |
_CrtSetBreakAlloc |
AfxEnableMemoryTracking |
_CrtSetDbgFlag |
AfxMemDF |
_CrtSetDbgFlag |
AfxSetNewHandler |
_set_new_handler |
那么,在MFC程序中,用户究竟应该选择那一组内存调试函数呢?如果用户在维护一个己有的MFC程序,并且这个程序己经使用了MFC函数,那么应该继续使用MFC函数。如果用户将要开发新的程序,那么我会推荐使用C运行时刻函数库的函数。虽然使用MFC函数可能会稍微方便一些,但是如果使用C运行时刻函数库的函数,你的程序会具有更好的可移植性(至少对于那些非MFC的程序来说是这样),而且使用库函数可以给予用户更多的控制能力。
在选择内存调试函数时,C运行时刻函数库要比MFC更好一些。
ATL对内存调试的贡献在于,它维护了一张表,该表由所有用QueryInterface创建的COM接口指针构成,可以通过跟踪AddRef和Release函数调用对其生命周期进行监视。当COM服务器停止运行的时候,任何没有被释放的接口都是一个资源泄露。用户可以在StdAfx.h的头部(在#include
这里介绍了怎样调试组件程序中的接口资源泄漏。在程序结束的时候,在调试语句输出窗口中检查那些标记着“INTERFACE LEAK”的文本,如下所示:
INTERFACE LEAK: RefCount = 7, MaxRefCount = 10, {Allocation = 42}
CMyComClass Leak
接下来在服务器初始化的时候对CComModule对象的m_nIndexBreakAt成员变量进行设置,就可以使用分配数目(allocation number)来帮助査找资源泄漏。例如:
#define _ATL_DEBUG_INTERFACE // define in StdAfx.h
BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID) {
if(dwReason == DLL_PROCESS_ATTACH) {
...
_Module.m_nIndexBreakAt = 42; // set breakpoint to find interface leak
}
return TRUE;
}
在用户下一次运行程序的时候,调试器会在分配数目到达设定值的时候中断程序运行。当然,这一技术要求分配数目在各个程序实例之间保持固定数值,而这样的情况并不是总能被保证。
到现在为止一切都很顺利,但是用户如何在自己的程序中使用调试堆呢?为了使用调试堆,用户必须确定自己使用的是程序的调试版本,并且链接的是C运行时刻函数库的调试版本,另外,也必须定义_DEBUG,这样调试堆版本的new和delete才会被调用。使用了这些设置以后,用户的程序就可以检测内存错误和内存泄漏。但不幸的是,到现在为止工作还没有完全结束:用户必须采用一些额外的步骤,选择所需要的额外测试选项,在程序结束的时候显示内存,正确报告源文件名和行号,并且使用便于阅读的方式显示数据信息。
在说明怎样让用户的程序显示内存泄露之前,我必须首先介绍一些可用的调试堆选项。用户使用_CrtSetDbgFlag函数对调试堆的检查工作进行控制。下面是是这些选项,它们可以一起进行或运算,从而实现多个选项的同时选取。
l _CRTDBG_ALLOC_MEM_DF:启动堆分配检查。当这一选项关闭时,内存分配的处理大部分都相同,但要使用_IGNORE_BLOCK类型的内存块。这个选项最好用在将所有的堆检査都关闭,或者你希望忽略能确信是正确的特定内存分配,或者就是你所希望忽略的内存分配(默认情况下打开)时。
l _CRTDBG_DELAY_FREE_MEM_DF:阻止内存被真正释放。它用来检査访问己被释放内存的错误或者用来模拟底层内存行为。注意,即使该选项没有打开,已经释放的内存也还是用0xDD的字节模式来填充的;但该选项没有打开时,被释放的内存会很快被重用,这使得访问己被释放内存的错误很难被査出。而且,_CrtCheckMemory在该选项关闭时是不检查己被释放内存的(默认情况下关闭)。
l _CRTDBG_CHECK_ALWAYS_DF:使得每次内存分配和内存释放时_CrtCheckMemory都会被调用。这对调试内存破坏很有帮助,但当程序分配了很多内存时,会导致运行速度下降很多(默认情况下关闭)。
l _CRTDBG_CHECK_CRT_DF:使得类型为_CRT_BLOCK的内存块在内存泄漏检查和状态差异检查时会被检查。通常我们不会打开这个选项,因为它会将运行时刻函数库里的某些内存误认成是被泄漏的内存,这些内存直到程序结束时才会被释放。只有当怀疑运行时刻函数库里有内存泄露时才会使用这个选项,通常情况下它不会被使用(默认关闭)。
l _CRTDBG_LEAK_CHECK_DF:使得在程序结束时_CrtDumpMemortLeaks自动被调用(默认情况下关闭)。
我推荐用户总是使用_CRTDBG_ALLOC_MEM_DF和_CRTDBG_LEAK_CHECK_DF内存堆调试选项,而仅仅在帮助调试内存错误的时候才选择_CRTDBG_CHECK_ALWAYS_DF和_CRTDBG_DELAY_FREE_MEM_DF选项。很有可能用户在认为自己的程序中存在内存泄漏的时候,仅仅使用_CRTDBG_CHECK_CRT_DF选项,但是这时候_CrtDumpMemoryLeaks不会报告任何信息。
使用程序的调试版本时,用户的程序能够对内存泄漏进行内存映象转储,但是这一功能并不是默认提供的。用户可以在程序结束的时候显式地调用_CrtDumpMemoryLeaks对内存泄漏进行内存映象转储,但是使用这种方法存在一个问题:用户的程序可能会存在多种不同的退出方式,这就需要用户在多个不同的地方都进行这个调用。因此,最简单的方法是使用_CRTDBG_LEAK_CHECK_DF这个调试堆选项。举例来说,如果用户向自己的程序中添加了下列代码:
#include
int APIENTRT WinMain(...) {
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
...
}
那么内存泄漏会以下面所示的方式报告给用户
Detected memory leaks!
Dumping objects ->
{21} normal block at 0x00780DA0, 8 bytes long.
Data:
...
Object dump complete.
如果用户不能确信内存泄漏是否被报告,可以进行一个小的测试,故意地泄漏一些内存(举个例子来说,向自己的代码中添加int* pLeak = new int;而不加上对应的delete语句),然后看看在程序结束的时候是否报告内存泄漏。
这是一个好的开始,但是如果将源代码文件名和行号信息都显示出来,这些内存泄漏的调试将会变得容易很多。用户可以通过向StdAfx.h的头部添加下面列出的语言以显示源代码信息:
#define _CRTDBG_MAP_ALLOC // define to get line number
#include
#include
#define DEBUG_NEW new (_NORMAL_BLOCK, THIS__FILE, __LINE__)
请确信自己将这些代码放置到了所有头文件的包含部分之前。如果没有得到任何源代码信息,那么是你将这些代码放置得太后的缘故。_CRTDBG_MAP_ALLOC符号使得内存分配函数在CrtDbg.h中被重定义,如下所示:
#ifdef _CRTDBG_MAP_ALLOC
inline void* __cdecl operator new(unsigned int s)
{ return ::operator new(s, _NORMAL_BLOCK, __FILE__, __LINE__); }
#define malloc(s) _malloc_dbg(s, _NORMAL_BLOCK, __FILE__, __LINE__)
#define free(p) _free_dbg(p, _NORMAL_BLOCK)
...
#endif
现在,内存泄漏会以这样的方式报告给用户:
Detected memory leaks!
C:\program files\microsoft visual studio\vc98\include\crtdbg.h(552):
{21} normal block at 0x00780DA0, 8 bytes long.
Data:
C:\Projects\WorldsBuggiesProgram\ WorldsBuggiesProgram.cpp(77):
{18} normal block at 0x00780EC0, 1 bytes long.
Data:
...
Object dump complete
不幸的是,现在工作还没有完全结束。如果深入查看这些源代码信息,就会发现很多内存泄漏来源于CrtDbg.h中被重定义的operator new,而这一信息显然没有什么用处,不管怎样,正确的源代码信息应该是对应malloc函数的。为了得到new函数关于源代码的正确信息,用户必须在每一个cpp文件的开头定义下列代码。
#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] __FILE__;
#endit
现在,内存泄漏会将正确的源代码信息一起报告出来:
Detected memory leaks!
C:\Projects\WorldsBuggiesProgram\ WorldsBuggiesProgram.cpp(77):
{18} normal block at 0x00780EC0, 1 bytes long.
Data:
...
Object dump complete
对于MFC程序来说,由于所有的工作都己经准备好了,所以显示内存泄漏十分容易。用户所需要做的所有工作就是在每一个cpp文件的头部定义前面提到的代码,这些代码在Visual C++中由应用程序向导自动添加。如果内存泄露信息中没有源代码信息,那么很可能是用户忘记在存在内存泄漏的源代码文件中添加这些代码的缘故。
在某些情况下,得到和内存分配有关的源程序的文件名和行号信息并不是十分有用。假设用户拥有一个雇员数据库,所有的雇员数据都使用下面的函数进行分配。
CEmployee* CreateEmployeeRecord(const CString &name, const CString &department) {
return new CEmployee(name, department);
}
现在做更加深入的假设,假设程序从代码的多个不同地方调用CreateEmpbyeeRecord函数,创建成千上万个雇员记录。在这种情况下,如果存在一个和雇员记录有关的内存泄漏,即使用户知道内存泄漏发生在这个函数中,也是无济于事的。用户真正希望得到的信息是调用CreateEmp]oyeeRecord的函数的源代码信息。
用户可以使用下面的技术得到调用函数的源代码信息,在声明CreateEmployeeRecord函数的头文件中创建下面的宏:
#ifdef _DEBUG
#define CreateEmployeeRecord (name, dept) \
CreateEmployeeRecord (name, dept, __FILE__, __LINE__)
#endif
现在修改CreateEmployeeRecord函数,使之在调试版本中将进行调用的函数的文件名和行号信息传递给new函数。
CEmployee* CreateEmployeeRecord(const CString &name, const CString &department
#ifdef _DEBUG
, const char* file, int line
#endif
) {
#ifdef _DEBUG
#undef new //temporarily remove the new macro
return new(file, line) CEmployee(name, department);
#define new DEBUG_NEW //restore the new macro
#else
return new CEmployee(name, department);
#endif
}
虽然现在的这些代码有一些笨拙,但是它可以帮助用户绕最少的弯路发现导致内存泄漏的原因。
当源代码的文件名和行号信息不能告诉你任何导致内存泄漏的原因时,考虑用来报告进行调用的函数的源代码文件名和行号的信息。
顺便说一下,只要有一个已经定义的对operator new的匹配重载,用户可以像对其他函数传递参数一样对new函数传递参数。然而,和其他函数不同的是,operator new的第一个参数总是分配的字节大小,而且这一参数是由编译器隐式传递的。
有时候用户需要知道发生内存错误或者内存泄露的内存块中的内容,来帮助找到程序错误,但是标准内存输出并不是很有用。举个例子来说,如果一个CEmployee对象存在一个内存泄漏,标准内存输出可能会显示成下面的形式。
Detected memory leaks!
Dumping objects ->
C:\Projects\WorldsBuggiesProgram\ WorldsBuggiesProgram.cpp(1075):
{29} normal block at 0x00780BD0, 8 bytes long.
Data: < v v > 8C 04 76 00 DC 04 76 00
strcore.cpp(118): {28} normal block at 0x00780480, 28 bytes long.
Data: < Fred> 01 00 00 00 0F 00 00 00 0F 00 00 00 46 72 65 64
...
Object dump complete
在这种情况下,可以读懂的仅仅只有最后四个字符,因此内存内容的映象转储并不是很有帮助。为了得到更多的有用信息,用户需要将这些对象转换成用户块,然后使用_CrtSetDumpClient建立一个客户内存映象转储函数。一种让CEmployee对象使用客户块的方法就是将_CLIENT_BLOCK传递给new,正如下列所示:
CEmployee* CreateEmployeeRecord(const CString &name, const CString &department
#ifdef _DEBUG
, const char* file, int line
#endif
) {
#ifdef _DEBUG
#undef new //temporarily remove the new macro
return new(_CLIENT_BLOCK, file, line) CEmployee(name, department);
#define new DEBUG_NEW //restore the new macro
#else
return new CEmployee(name, department);
#endif
}
如果因为某些原因,用户使用的是malloc而不是new,那么可以使用下列代码获得同样的结果:
#ifdef _DEBUG
return _malloc_dbg(bufferSize, _CLIENT_BLOCK, file, line);
#else
return malloc(bufferSize);
#endif
这种技术在一般情况下工作得都很好,但是对于类对象来说,最好的方法是对该类中的operator new和operator delete进行重载,以保证所有的堆对象都是一致创建的。
class CEmployee
{
public:
#ifdef _DEBUG
void* operator new(size_t size, LPCTSTR filename, int line);
void operator delete(void *p, LPCTSTR filename, int line);
#endif
...
};
#ifdef _DEBUG
void* CEmployee::operator new(size_t size, LPCTSTR filename, int line) {
return ::operator new(size, _CLIENT_BLOCK, filename, line);
}
void CEmployee::operator delete(void *p, LPCTSTR filename, int line) {
_free_dbg(p, _CLIENT_BLOCK);
}
#endif
最终,用户可能还是希望使用内存块子类型,因为这样可以很容易地区分不同类型的客户块。这种技术在MFC中特别有用,因为MFC中己经使用了客户块。用户可以像下面所显示的代码一样使用子类型。
#define SET_BLOCK_TYPE(subtype) (_CLIENT_BLOCK | (subtype << 16))
#define EMPLOYEE_SUBTYPE 1
#ifdef _DEBUG
void* CEmployee::operator new(size_t size, LPCTSTR filename, int line) {
return ::operator new(size, SET_BLOCK_TYPE(EMPLOYEE_SUBTYPE), filename, line);
}
void CEmployee::operator delete(void *p, LPCTSTR filename, int line) {
_free_dbg(p, SET_BLOCK_TYPE(EMPLOYEE_SUBTYPE));
}
#endif
为了能够按照任何需要的方式对数据进行显示,用户需要创建一个内存映象转储函数,专门对雇员的子类型进行处理,并使用_CrtSetDumpClient函数可以对其进行注册。
#define GET_BLOCK_SUBTYPE(type) ((type >> 16) & 0xFFFF)
#define EMPLYEE_SUBTYPE 1
#ifdef _DEBUG
void __cdecl DumpClientData(void* pData, size_t bytes) {
_CrtMemBlockHead* pHead = pHdr(pData);
if(GET_BLOCK_SUBTYPE(pHead->nBlockUse) == EMPLYEE_SUBTYPE) {
CEmployee* pEmployee = static_cast
_RPT2(_CRT_WARN, "Employee Name: %s Department: %s\n",
pEmployee->GetName(), pEmployee->GetDepartment());
}
else
_printMemBlockData(pHead); // do standard dump, from Dbgheap.c
}
#endif
int APIENTRY WinMain(...) {
_CrtSetDumpClient(DumpClientData);
...
}
需要注意的是,_CrtMemBlockHeader和pHdr会在下一部分中讨论。在DbgHeap.c中有对_printMemBlockData函数的文档说明。
使用内存块子类型可以很容易地区分不同类型的用户块。
最后,雇员数据的内存泄漏以有用源文件名和行号的形式标识出来,而且还是以方便用户阅读的方式进行显示。当然,这仅仅是很少的一点点工作,但是它使得查找内存错误的速度变得很快。
Detected memory leaks!
Dumping objects ->
C:\Projects\WorldsBuggiesProgram\ WorldsBuggiesProgram.cpp(1076):
{29} normal block at 0x00780BD0, subtype 1, 8 bytes long.
Employee Name: Fred Flintstone Department: Accounting
...
Object dump complete
要完全利用好C运行时刻函数库中的调试堆,花时间来弄懂调试堆的具体实现是很有帮助的。当你用new或者malloc的调试版本来分配内存时,这两个函数都会调用_malloc_dbg,而_malloc_dbg又会调用_heap_alloc_dbg进行实际的分配(这两个函数都可以在DbgHeap.c里找到)。实际上,new和malloc底层实现的唯一主要区别在于,new在内存被分配后会调用对象的构造函数,而malloc不会。
调试堆的目的是发现内存破坏和内存泄露,并且向用户报告源代码的哪个地方出了问题。它是通过在内存块里加入额外的内存块头、在实际数据的前面和后面加上保护字节来实现这个目的的。下面的代码显示了它使用的结构类型(在Dbgint.h中定义,该文件没有包含在Visual C++中),图分二采用图示的方法显示了结构类型。
typedef struct _CrtMemBlockHeader
{
struct _CrtMemBlockHeader *pBlockHeaderNext;
struct _CrtMemBlockHeader *pBlockHeaderPrev;
char *szFileName; // source code file name
int nLine; // source code line number
size_t nDataSize; // size of allocation
int nBlockUse; // memory block type
long lRequest; // allocation number
unsigned char gap[nNoMansLandSize]; // 0xFDFDFDFD
// unsigned char data[nDataSize]; // the actual data buffer
// unsigned char gap2[nNoMansLandSize]; // 0xFDFDFDFD
} _CrtMemBlockHeader;
有了这个结构,调试堆的工作细节就再也不神秘了。_Heap_alloc_dbg函数首先检査_CRTDBG_CHECK_ALWAYS_DF调试堆选项是否被设置了。如果己经被设置,函数会调用_CrtCheckMemory来检査堆的完整性。然后检查_CrtSetBreakAlloc是否已经被调用了,如果它正在分配同样的分配号,就产生一个调试中断。如果_CrtSetAllocHook设置了钩子(hook)函数,则调用相应的钩子(hook)函数。然后,它会确定是否该内存块会使用_IGNORE_BLOCK内存块类型,当_CRTDBG_ALLOC_MEM_DF调式堆选项被关闭时,或者当该内存块是_CRT_BLOCK类型而且_CRTDBG_ALLOC_MEM_DF选项没有被打开时,内存块就属于_IGNORE_BLOCK类型。
图9.2调试堆内存块分布图
现在基础知识己经不是问题了,让我们来具体看看,_heap_alloc_dbg函数。它从堆分配内存块,该内存块的大不是用户要求的内存大小再加上内存块头和保护字节的空间,这样,每次内存分配时,都会有36宁节的额外开销。然后就将以下内容保存在内存块里:源代码文件名指针、源代码行号、用户要求的实际大小、内存块类型、己经分配号。函数还会用0xCD的字节模式填充数据区,用0xFD的字节模式填充保护字节。为了跟踪所有不被忽略的内存块,所有不是_IGNORE_BLOCK内存块类型的块都被插入到一个双链表中。然后,函数会更新它的内部记录:总的分配内存大小、当前分配的内存大小以及一次分配的最大内存。最后向用户返回指向实际数据区开始处的指针。
和内存分配相比,内存的回收相对来说简单得多。delete和free函数最终都会调用_free_dbg,_free_dbg首先检查_CRTDBG_CHECK_ALWAYS_DF调试堆选项是否被设置了。如果已经被设置,函数会调用_CrtCheckMemory来检査堆的完整性。如果_CrtSetAllocHook设置了钩子(hook)函数,则调用相应的钩子(hook)函数。函数还会调用_CrtIsValidHeapPointer来检查当前被回收的指针是否有效,并检查块自身的完整件来确定块的内存块类型是有效的。如果_CRTDBG_CHECK_ALWAYS_DF没有被设置(所以就不用检查整个的堆了),函数就会通过检查保护字节检查当前被回收的内存块的完整性。然后,函数就开始更新内部记录了。从当前己分配内存大小里减去当前被回收的内存大小。如果_CRGDBG_DELAY_FREE_MEM_DF调试堆选项没有被设置,函数会将该块从双链中删除,并用0xDD的字节模式填充整个块(包括内存块头和保护字节),然后释放该块,如果_CRGDBG_DELAY_FREE_MEM_DF调试堆选项被设置了,函数就会将块类型设置成_FREE_BLOCK,并用0xDD的字节模式填充实际的数据区。
知道何时检査各种各样的字节模式和内存泄漏以及用户如何才能知道这些问题是对用户是很有用的。已分配数据的字节模式(0xCD)不会被调试堆检查,但这个字节模式有助于用户发现使用未被初始化的数据错误、并在一个未被初始化的指针访问内存时产生非法内存访问异常。当_CRTDBG_DELAY_FREE_MEM_DF调试堆选项被设置时,_CrtCheckMemory会检查已释放的数据的字节模式(0xDD)。当_CRTDBG_CHECK_ALWAYS_DF调试堆选项被设置时,分配和回收函数也会对它进行检查。当犮现写已被回收的_NORMAL_BLOCK和_CLlENT_BLOCK类型的块时,会出现下面的跟踪消息:
memory check error at 0x00772170 = 0x78, should be 0xDD.
DAMAGE: on top of Free block at 0x00772170.
DAMAGED allocated at file C:\PROJECTS\WorldsBuggiestProgram\ WorldsBuggiestProgram.cpp(191).
DAMAGE located at 0x00772170 is 4 bytes long.
保护字节模式(0xFD)会被_CrtCheckMemory检查,并且当_CRTDBG_CHECK_ALWAYS_DF调试堆选项被设置时,所有内存也会被_free_dbg检查,当该选项没有被设置时,当前被回收的块会被检査。当对_NORMAL_BLOCK类型的内存块写上溢出时,你会看到下面的跟踪信息:
memory check error at 0x00772144 = 0x78, should be 0xFD.
DAMAGE: after Normal block(#109) at 0x00772140.
DAMAGED allocated at file C:\PROJECTS\WorldsBuggiestProgram\ WorldsBuggiestProgram.cpp(196).
DAMAGE located at 0x00772140 is 4 bytes long.
当写下溢出时,会看到下面的跟踪信息:
memory check error at 0x0077210C = 0x78, should be 0xFD.
DAMAGE: before Normal block(#110) at 0x00772110.
DAMAGED allocated at file C:\PROJECTS\WorldsBuggiestProgram\ WorldsBuggiestProgram.cpp(200).
DAMAGE located at 0x00772110 is 4 bytes long.
内存泄漏会被_CrtDumpMemoryLeaks函数检查,而且如果_CRTDBG_LEAK_CHECK_DF调试堆选项被设置,在程序结束时内存泄漏也会被检查。如果发现了内存泄漏,你会收到本章前面列出的跟踪信息。
最后,你会发现使用跟踪语句报告出的内存破坏错误很容易被忽视。毕竟,内存破坏是一个错误,所以,使用消息框可能更合适一点。你可以使用_CrtSetReportMode让_CrtCheckMemory用消息框的形式报告内存错误,如下所示:
int oldMode = _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_WNDW);
_CrtCheckMemory();
_CrtSetReportMode(_CRT_WARN, oldMode);
现在,当你的程序写_CLIENT_BLOCK类型的内存块有溢出错误时,你就会收到图9.3所示的消息框。这个方法的缺点是在每一个跟踪语句处你都会收到这样的消息框,所以当程序中有很多内存错误时,你就会收到许多这样的消息框。
使用_CrtSetReportMode和_CrtCheckMemory报告的内存错误更明显。
图9.3 _CrtCheckMemory用消息框报告内存错误时的结果
了解了调试堆的实现情况后,一个隐含的信息应该很明显了:将使用了堆的调试版本模块和发行版本模块混合使用非常不好。_free_dbg和_CttCheckMemory函数都会认为调试堆里的所有内存在分配时都会带有内存块头和保护字节,如果事实上并不如此时,就会出现问题(具体地说,就是断言语句出错,非法内存访问异常)。
调试内存时需要查看许多变量以及包含了指针的数据结构。如果你能通过查看指针的值就能判断出指针是有效的还是非法的,那么你发现内存错误的能力就会很强。由于Windows用了一组固定的范围来分割进程的4GB虚拟地址空间,所有一旦你知道了这些范围时,你就能很容易判断出地址是否有效。你也可以使用另外的技术更精确地了解程序的地址空间是如何分割的,唯一的技巧是,Windows 2000和Windows 98的地址空间分割是不同的,所以,在一个Windows版本里合法的内存地址,在另一个里不一定合法。
如果你知道如何查看指针的值,那么你就能很高效地调试内存错误。如果你知道Windows是如何对虚拟内存空间进行划分的,那么你就可以很容易地判断出指针是否有效。
表9.7列出了Windows 2000和Windows 98对进程的虚拟地址空间的划分,表9.8列出了所有Windows版本中有特殊含义的地址。
在第6章“在Windows中调试”里提到,Windows进程通常被装载在0x00400000的地址处。尽管你可以选择将Windows 2000的一个进程装载到更低的基地址(使用链接器的BaseAddress选项),但是0x00400000是所有版本的Windows能使用的最低基地址。进程的实例句柄的信息是和它的基地址相同,这并不是巧合。
表9.7 Windows虚拟地址空间划分
地址范围 |
用法 |
Windows 2000 |
|
0 - 0XFFFF(64KB) |
不能用来检测空指针赋值(访问冲突) |
0X10000(64KB) - 0X7FFEFFFF(2GB-64KB) |
Win32进程私有的(非保留的), 用于程序代码和数据 |
0X7FFF0000(2GB-64KB) - 0X7FFEFFFF(2GB) |
不能用来防止覆盖OS分区(访问冲突) |
0X80000000(2GB) - 0XFFFFFFFF(4GB) |
为操作系统保留的——不时访问(访问冲突) |
Windows 98 |
|
0 - 0X0FFF(4KB) |
MS-DOS——不可访问(访问冲突) |
0X1000(4KB) - 0x3FFFFF(4MB) |
MS-DOS和16位Windows——除Win32进程外可读/可写 |
0x400000(4MB) - 0x7FFFFFFF(2GB) |
Win32进程私有——非保留的,用于程序代码和进程 |
0X80000000(2GB) - 0XBFFFFFFF(3GB) |
共享的Win32 DLL,内存映射文件,由所有Win32进程共享——可用,可读写 |
0XC0000000(3GB) - 0XFFFFFFFF(4GB) |
虚拟设备驱动系统代码。由所有Win32进程共享——可读/可写,但是写操作可能破坏系统 |
表9.8 Windows地址的特殊含义
地址值 |
意义 |
0 |
空指针——值无效 |
0X00400000 |
程序的基地址 |
0xCCCCCCCC |
未初始化的自动(局部)变量指针 |
0xCCCCCCCC地址的特殊含义是在Visual C++6.0中才引进的,并且只有在程序使用/GZ编译选项进行编译时才会使用它。和我们通常想的相反,未被初始化的自动变量在调试版本里通常没有被初始化,但在打开/GZ选项进行编译时,它们会被初始化为0xCCCCCCCC,这有助于发现未被初始化的指针,注意,所有末被初始化的自动变量都会赋上这个值,并不仅仅是指针(未被初始化的char变量被初始化成0xCC,未被初始化的short变量被初始化成0xCCCC,你只要懂得这个概念就可以了)。在Visual C++ AppWizard中新创建的工程里这个编译选项是自动打开的,但对于早期版本的Visual C++生成的工程,你必须手动地在调试版本里加上这个选项。
表9.9详细介绍了Windows 2000和Windows 98程序将虚拟地址空间分配给可执行文件、DLL、堆栈以及堆的详细情况(在每个范围里列出的项可能会在该范围内找到,但不会占据整个范围的内存空间)。
表9.9 Windows 2000和Windows 98的虚拟地址空间的使用
地址范围 |
用法 |
Windows 2000 |
|
0x00030000 - 0x0012FFFF |
线程栈 |
0x00130000 - 0x003FFFFF |
堆(有时堆位于此处) |
0x00400000 - 0x005FFFFF |
可执行代码 |
0x00600000 - 0x0FFFFFFF |
堆(有时堆位于此处) |
0x10000000 - 0x5FFFFFFF |
AppDLLs、Msvcrt.dll、Mfc42.dll |
0x77000000 - 0xFFFFFFFF |
Advapi32.dll、Comctl32.dll、Gdi32.dll、Kernel32.dll、Ntdll.dll、Rpcrt4.dll、Shell32.dll、User32.dll |
Windows 98 |
|
0x00400000 - 0x005FFFFF |
可执行代码 |
0x00600000 - 0x0FFFFFFF |
线程,栈,然后是堆 |
0x10000000 - 0x7FFFFFFF |
AppDLLs、Msvcrt.dll、Mfc42.dll、Shell32.dll、Shlwapi.dll |
0x80000000 - 0xFFFFFFFF |
Advapi32.dll、Comctl32.dll、Gdi32.dll、Kernel32.dll、User32.dll、Version.dll |
这些值只是一种典型的使用方法,一个特定的程序也许会使用不同的DLL和不同大小的堆栈、堆,从而使得地址的值会不一样。你可以使用Debug菜单単的Modules命令来确定一个Visual C++程序使用的模块地址。如果你想知道堆栈、堆的具体位置,而且你有Jeffrey Richter的《Programming Applications for Microsoft Windows》,你可以使用VMMap工具来得到一个程序的详细虚拟空间映射。不幸的是,VMMap没有对堆作标记,但堆一般就是上述分区中可读/可写的、大小为1 048 576的块。
你可以使用x86的内存对齐规则来确定一个指针是否有效。堆栈、堆的指针都是双字对齐的,所以,它们的最后一位应该是十六进制的0、4、8或者C。但是x86的指令可以是任何大小,所以指令指针的最后一位可以是任何数字。函数是16字节对齐的,所以函数指针的最后一位应该总是零。
在许多调试环境里,要确定一个指针的值是否有效,你不用知道确切的地址空间划分。例如,假设你正在调试Windows 2000的一个程序,你知道0x0046004C对于动态分配的内存来说是合法的地址,而0x5F4CCB14不是。注意,全局变量存储在定义它们的模块地址空间里,所以指向全局变量的指针不会指向堆栈、堆。这些道理看上太很明显,但是,当你发现你没有赋值的指针指向了你意想不到的地址时,不要惊慌。例如,任何空的MFC CString的内部数据成员m_pchData会指向某个地址,例如0x5F4CCB14,这是因为所有空的CString都会指向MFC自身的一个全局变量(具体地说就是_afxDataNil)。
句柄不是指针,它们也不是指向指针的指针。实际上,对它们的解释依赖千具体句柄的类型。通常,它们是一个句柄表的下标。在Windows 2000里,通过观察句柄的值来确定该句柄是否有效一般比较困难。但在Windows 98里,因为句柄都是下标,所以它们的值会比较小,通常小于0x00010000。
到目前为止,有一个比较不好的消息我还没有提到。在Windows 2000里对0xCDCDCDCD、0xCCCCCCCC以及0xDDDDDDDD等地址的读写操作会导致非法内存访问异常,但在Windows 98里却不会这样。使得事情更糟的是,在Windows 98里使用的IsBadReadPlr、IsBadWritePtr等 API函数以及MFC的AfxIsValidAddress函数的断言语句如果对以上地址有操作的话,也不会有任何错误发生。这意味着在Windows 98里使用未被初始化的指针不会很明显,这使得在调试内存破坏时,Windows 2000成为了Windows的各个版本中比较好的一个选择。
不幸的是,操作系统内存没有被保护的事实意味着Windows 98很容易被破坏。例如,执行下面代码的调试版本会导致Windows 98崩溃,并出现蓝屏。
#include
int APIENTRY WinMain(...) {
CHOOSECOLOR cc;
cc.IStructSize = sizeof(cc);
ChooseColor(&cc);
...
}
显然,Windows 98会认为IStructSize域被正确填充的任何通用对话框结构都是有效的。在这个例子里,所有剩余的域被设置成0xCCCCCCCC,这是未被保护的系统内存,访问这些内存会导致Krnl386.exe崩溃。
但是,事情也并不是很糟糕。尽管Windows 98里未被保护的系统内存破坏了系统的完美性,但调试器可以设置基于0xCDCDCDCD、0xCCCCCCCC和0xDDDDDDDD等地址的数据断点,当这些地址被修改时,它就会通知你,如图9.4所示。
图9.4 Windows 98使用数据断点来发现对字节模式地址的写操作
现在,你己经知道了如何组织程序以更好地发现内存破坏,你也己经了解了它是如何工作的,下面让我们看看如何调试一些典型的内存破坏问题。
在任何版本的Windows里,调试版本和发行版本的程序对空指针的读写操作都会导致非法内存访问异常。在你取消异常消息框时,调试器会指出导致错误的代码。
在Windows 2000里,调试版本的程序对堆(地址0xCDCDCDCD)、堆栈(地址0xCCCCCCCC)里未被初始化的指针进行读写操作会导致非法内存访闷,但在Windows 98里就不会这样。在Windows 2000里,你取消异常消息框后,调试器会指出导致错误的代码。在Windows 98里,使用数据断点可以使得这些地址被修改时调试器会提醒你。
在调试版本里,堆里未被初始化的内存被0xCD字节模式填充,堆栈里被初始化的内存被0xCC字节模式填充。调试版本和发行版本里未被初始化的全局内存都被初始化为0。通过调试器的监视,你可以在数据里找到这些字节模式。
在调试版本里,对内存的写越界时,如果是写上溢就会收到“DAMAGE: after block”(内存破坏:在块的后面)的跟踪消息,如果是写下溢就会收到“DAMAGE: before block”(内存破坏:在块的前面)的跟踪消息。但是,只有在内存的破坏发生在数据区的前后四个字节里时,上面的消息才会出现。如果写溢出超过了保护字节,对内存的写越界不会被发现。
因为内存的崩溃通常就发生在内存破坏的后面,所以你可以使用数据断点对导致崩溃的代码进行跟踪。从跟踪消息里得到被破坏的内存地址(在本例中是保护字节地址),再一次运行程序,在断点对话框里设置基于该地址的数据断点(在Edit菜单里执行Breakpoints命令)。
使用数据断点来确定导致内存破坏的原因。
在调试版本里,当堆里的内存被释放后,内存会被0xDD的字节模式填充。然而,如果你继续对已释放的内存进行读写操作时,在任何版本的Windows里都不会导致非法内存访问异常,因为指针指向的地址是合法内存。要发现内存己被释放后的内存访问,一定要在内存被释放后将指针置为空(对句柄作同样处理)。而且,由于被释放的内存在下一次的内存分配时很有可能会被立即重新使用,所以不要依赖于被释放的内存有0xDD的字节模式,除非你设置了_CRTDBG_DELAY_FREE_MEM_DF调试堆选项。
要发现对已被释放的内存的访问操作,一定要将被释放的指针置为空。
在调试版本里试图释放指向不是从调试堆里分配的内存的指针时,会导致图9.5所示的错误断言语句。同样,在调试版本里多次释放指针会导致图9.6所示的错误断言语句。单击Retry按钮,检查堆栈,找出导致错误的代码所在的行。单击Ignore按钮可能会导致非法内存访问异常,这由被释放的指针的值决定。
图9.5释放未被分配的内存导致的消息框
图9.6多次释放被分配的内存导致的消息框
在很多情况下,内存破坏问题很容易发现。如果你知道程序对内存有某种类型的破坏,但找到问题还有一些困难,可以试着让_CRTDGB_CHECK_ALWAYS_DF和_CRTDBG_DELAY_FREE_MEM_DF调试堆选项帮助你找出问题。如果你还没有找到错误,可以考虑在Windows 2000里进行内存破坏调试。
调试比较难的内存破坏问题时,可以试试_CRTDGB_CHECK_ALWAYS_DF和_CRTDBG_DELAY_FREE_MEM_DF调试堆选项。
现在你可以很好地设计程序,在程序结束可能发现任何内存泄漏,而且你也明白了整个过程是怎么样的,下面让我们看看最常见的导致内存泄漏的原因,以及它们的调试方法。
我将从导致内存泄漏的常见原因讲起,例如,根本就没有回收内存,或者回收内存的代码被绕过了。对常见内存泄漏的了解有助于用户通过检查代码就能找出内存泄漏,或者,更好的是,在写程序时就能不犯内存泄漏的错误。
导致内存泄漏最常见的个原因是分配了内存后,忘记了释放内存。例如,最容易忘记释放的一个对象就是MFC的CException对象。在下面的代码里必须释放CFileExeception对象才能消除内存泄漏。
try {
// do something that throws a file exception
}
catch(CFileException*e) {
if(e->m_cause == CFiLeException::fiIeNotFound)
TRACE(_T(''File not found\n"));
e->Delete(); // required to prevent a memory leak
}
你必须使用Delete成员函数来释放MFC异常对象,而不是用delete,因为一些MFC异常是作为静态对象创建的。
在写需要分配内存的构造函数时,一定要记住,析构函数只有在对象被成功创建时才会被调用;所以你不能指望在构造函数抛出异常时析构函数会被调用并释放对象。处理可能会失败的构造函数的技术在第2章“编写便于调试的C++代码”里作了介绍。也可以参看Scott Meyers的《More Effective C++》的Item 10,它介绍了几种不同的方法。
在析构函数里忘了释放对象分配的内存或者根本就忘了写析构函数,都会导致内存的泄漏。在写析构函数时,检査所有分配了资源的数据成员并确定它们已被完全释放是一个很好的习惯。注意,确信析构函数完全释放了构造函数分配的内存还远远不够,因为其他的成员函数也可能分配内存。所以,仅仅将析构函数只是简单地看作构造函数的反函数是不对的。如果析构函数调用的代码可能会抛出异常,在析构函数里处理异常是非常必要的,因为如果不这样,就会导致内存泄漏,甚至导致程序终止。而且要确定基类的析构函数是虚函数(但不是说所有的析构函数都必须是虚函数,因为这样非基类函数不会有任何作用。这样做能保证派生类的析构函数会被调用,即使对象是以指向基类的指针形式存在的。处理有可能失败的析构函数的技术在第2章里有详细的介绍,也可以参看Scott Meyers的《More Effective C++》的Item11。
在写析构函数时,你应该意识到“三大”(Big Three)规律:当类需要析构函数或者需要一个复制构造函数或者需要一个赋值操作符时,就需要同时定义他们三个。否则,可能会导致内存破坏和内存泄漏。要想进一步了解这个问题,可以参看Marshall Cline、Greg Lomow和Mike Girou著的《The Big Three, inC++ FAQs》(第二版)的第30章。
异常处理程序必须执行与正常执行相同的释放语句,否则你的程序就可能存在内存泄漏。考虑一下下面的代码。
BOOL LeakyExceptionHandle(int arg) {
try
{
CMyObject* pObject = new CMyObject(arg);
... // do something that throws an exception
pObject->MemberFunction();
delete pObject;
}
catch (...)
{
return FALSE;
}
return TRUE;
}
在这个例子里,如果函数做了某些导致异常的操作,异常处理函数就不会释放pObject,从而导致了内存泄漏。处理这种问题的标准方法是使用C++的auto_ptr智能指针模版类,这样,C++语言就会负责释放资源,而不是依赖程序员的编程规范了。下面是重写的使用了auto_ptr的代码。
BOOL LeakyExceptionHandle(int arg) {
try
{
auto_ptr
... // do something that throws an exception
pObject->MemberFunction(); // can still call member functions as normal
// no need to delete pObject;
}
catch (...)
{
return FALSE;
}
return TRUE;
}
在这个例子里,你首先创建了一个局部对象pObject,它负责释放CMyObject对象。C++语言保证当pObject超出了生存范围时CMyObject对象会被释放,这样,内存泄漏就不会发生了。这个方法还有另外一个的好处,你不再需要写多余的释放代码了。注意,调用成员函数的语法没有变。
在《The C++ Programming Language))(第三版)里,Bjarne Stroustrup将使用局部对象管理资源的技术称为“resource acquisition is initialization”(资源的获取即初始化)。注意,auto_ptr也解决了构造函数和析构函数里的资源泄漏问题。要想进一步了解这个问题,可以参看《The C++ Programming Language》(第三版)里的第1可章“Exception handling”;也可以参看Scott Meyers的《More Effective C++》的第9章;以及Stanley Lippman和Josee Lajoie的《C++ Primer》(第三版)的8.4节“DynamicalIy allocated Objects”;还有Marshall Cline、Greg Lomow和Mike Girou著的《C++ FAQs》(第二版)的第31章“Using Objects to Prevent Memory Leaks”。
如果在一个函数里有多个释放内存的返回语句,有可能导致内存泄漏。考虑一下下面的代码:
BOOL MultipleReturnStatements(int arg) {
CMyObject* pObject = new CMyObject(arg);
if(pObject->IsEmpty())
return FALSE;
...
delete pObject;
return true;
}
在这个例子中,如果pObject是空的,函数会返回FALSE。而没有首先释放对象,从而导致了内存泄漏。不幸的是,这种内存泄露太普遍了。由于这个原因,一些程序员走到一个极端,完全不使用多个返回语句,这在某些程序里是比较好的选择。我一般比较喜欢使用多个返回语句,因为完全不使用会导致不必要的复杂代码。如果你使用了多个返回语句,在含有内存释放的函数里你一定要非常仔细。再一次提到,使用C++的auto_ptr能完全解决这个问题。
你必须总是使用和new形式相同的delete。更具体地说,就是如果你使用new分配了单独的一个对象,你必须使用普通形式的delete;而如果你使用new分配了一个数组,你必须使用数组形式的delete(使用placement new是一个例外,因为它并不分配内存。要释放placement new创建的对象,你直接调用析构函数就可以了)。例如:
CMyObject* pMyObjectArray = new CMyObject[10];
delete []pMyObjectArray; // this form works
delete pMyObjectArray; //this form doesn't work
这里,new为10个CMyObject对象分配了足够的内存,然后为每个元素调用默认的构造函数。数组形式的delete为每个对象调用了析构函数,然后释放内存,而普通形式的delete仅仅为第一个对象调用析构函数,如果CMyObject的析构函数有内存的释放,就会导致内存泄漏(尽管从技术上讲,结果并没有定义)。如果在调试版本里犯了这个错误,你会收到图9.7所示的断言语句失败消息。
图9.7使用错误形式的delete导致的消息框
要想进一步了解这个问题,可以参看Scott Meyers的《Effective C++》(第二版)的第5章以及《More Effective C++》的第8章。
你可以使用好几种技术来跟踪导致内存泄漏的原因。下面是最常用的几种技术,以用户的喜爱程度顺序排列。
我己经介绍了如何确定程序的内存分配有正确的源代码文件名和行号,这样,_CrtDumpMemoryLeaks就会向你报告这些信息。我还介绍了当调用函数和被调用函数相关性很强时,如何显示调用函数的源代码信息,而不是被调用函数的源代码信息。使用源代码信息通常是找到内存泄漏的最简单方法,如果它可以正常工作的话。
不幸的是,源代码信息对于找到内存泄漏并不是很充足。如果代码经常有内存泄漏,你可以使用源代码信息找到内存泄漏,但如果代码被调用了一千次,而内存泄漏只有一次,这就变得很复杂了。在这种情况下,通过査看转储数据,确定被泄漏的特定数据,你通常都能够找出内存泄漏的位置。如果标准的内存泄漏输出函数对你的帮助不大,你可以使用_CrtSetDumpClient装载用户定义的转储函数,这在前面介绍过。
你可以使用分配号来跟踪特定的内存泄漏,在内存泄漏的输出里,分配号被括号括起来了。首先,注意被泄漏内存的分配号,然后以同样的方式再次运行程序,然后再次检查被泄漏内存的分配号。如果它们是相同的(通常它们是相同的),你可以依次地以这些分配号作为参数调用_CrtSetBreakAlloc逐个调试这些内存泄漏。但是,如果不同的运行导致的内存泄漏的分配号不相同,这个技术就不大可能有效了。
例如,假设一个内存泄漏有固定的分配号27。—种方法是在程序初始化的地方加上下面的代码来设置条件断点。
_CrtSetBreakAlloc(27);
尽管这个技术可以找到内存泄漏,另一个更有效的方法是直接在调试器里设置条件断言点,步骤是在观察窗口里输入下面的语句。
{,, msvcrtd.dll} _CrtSetBreakalloc(27)
另一种方法是,你也可以在观察窗口里设置等价的C运行时刻函数库全局变量来得到同样的效果。
{,,msvcrtd。dll} _crtBreakAlloc = 27
这种交互式的方法通常比较受欢迎,因为用户在一个调试阶段里不必改变代码就可以调试多个内存泄漏。
在调试器里,你可以在观察窗口里交互式地设置内存分配断点。
顺便提一句,如果接收到了这条奇怪的消息“User breakpoint called from code at ...”(在代码...处调用了用户设置的断点),很有可能是你设置了一个分配断点,似又忘了删除它。
正如前面提到的,如果你明白了导致内存泄漏的常见原因,你可以仅仅通过查看源代码就可以很快发现内存泄漏,当前面提到的方法不奏效时,这个技术特别有用。如果你对问题所在大致有个概念,你也许会首先就试试这个方法。
内存检查点(checkpoint)能确定一段代码中是否有内存泄漏,如果有,还会精确地指出代码中哪个地方出现了可存泄漏。在很多情况下,使用内存检查点会比使用前面提到的技术花费更多的精力,所以,不到万不得已用户一般不会使用它。
通过检查点来发现内存泄露的方法是,在你感兴趣的代码前后加上_CrtMemCheckpoint,然后调用_CrtMemDifference检査差异。如果存在差异,则返回TRUE。你可以使用_CrtMemDumpStatistics将差异的记录转储出来,也可以调用_CrtMemDumpAllObjectsSince将从代码段开始的所有内存分配信息转储出来,或者两个函数都调用。_CrtDumpMemoryLeaks尽管从名字上看它可以将内存泄漏信息转储出来,但实际上它会将所有的已分配内存都转储出来,而不仅仅是被泄漏的内存。这个函数仅仅在程序运行结束前调用,这时程序会认为所有剩余的、已分配的内存都是被泄漏的内存。
因为_CrtDumpMemoryLeaks会将所有的已分配内存都转储出来,而不仅仅是被泄漏的内存,所以它仅仅在程序结束时用来检查内存泄露。
例如,你可以使用下面的技术在一个函数里检査内存泄漏:
void LeakyFunction() {
// the beginning checkpoint
_CrtMemState oldState, newState, stateDiff;
_CrtMemCheckpoint(&oldState);
//the actual function
CString notALeak = _T("Not a leak.");
TCHAR* leakingString = new TCHAR[50];
_tcscpy(leakingString, _T("Leaking string."));
// the ending checkpoint
_CrtMemCheckpoint(&newState);
if(_CrtMemDifference(&stateDiff, &oldState, &newState)) {
_CrtMemDumpStatistics(&stateDiff);
_CrtMemDumpAllObjectsSince(oldState);
}
}
程序执行后会输出下面的结果。
0 bytes in 0 Free Blocks.
74 bytes in 2 Normal Blocks.
0 bytes in 0 CRT Blocks.
0 bytes in 0 Ignore Blocks.
0 bytes in 0 Client Blocks.
Largest number used: 0 bytes.
Total allocations: 74 bytes.
Dumping Objects ->
C:\PROJECTS\WorldsBuggiestProgram\WorldsBuggiestProgramView.cpp(192):
{118} normal block at 0x00772190, 50 bytes long.
Data:
strcore.cpp(118): {117} normal block at 0x007721F0, 24 bytes long.
Data:< Not > 01 00 ...
Object dump complete.
有趣的是,输出是错误的,因为它报告了两处内存泄漏,而实际上只有一处。问题在于notALeak字符串从堆里分配的内存只有在函数末尾才会被释放。最简单的解决方法是将原来的函数体全部用括号括起来,这样,在调用CrtMemDifference时,所有的局部变量都已经不在生存范围里了,如下面的代码所示。
在使用内存检查点来发现内存泄漏时,一定要将被检查的代码段括进括号,以确定局部变量不在生存范围里。
void LeakyFunction() {
_CrtMemState oldState, newState, stateDiff;
_CrtMemCheckpoint(&oldState);
{
CString notALeak = _T("Not a leak.");
TCHAR* leakingString = new TCHAR[50];
_tcscpy(leakingString, _T("Leaking string."));
}
_CrtMemCheckpoint(&newState);
if(_CrtMemDifference(&stateDiff, &oldState, &newState)) {
_CrtMemDumpStatistics(&stateDiff);
_CrtMemDumpAllObjectsSince(oldState);
}
}
当然,真正检查内存泄漏的代码会比这个例子复杂得多,但这不会成为问题。假设你有一个很大的很复杂的函数,你怀疑里面有内存泄漏。可以首先在函数的开始和末尾处设置检査点,确定函数确实存在内存泄漏,然后用二分法逐步缩小范围,直至找到导致内存泄漏的代码。
_CrtMemState结构值得看一下,这样,你就能明白它存在的限制了。
typedef struct _CrtMemState
{
struct _CrtMemBlockHeader * pBlockHeader;
size_t lCounts[_MAX_BLOCKS];
size_t lSizes[_MAX_BLOCKS];
size_t lHighWaterCount;
size_t lTotalCount;
} _CrtMemState;
这个结构实际上是内存状态的一个快照,因为它存储的信息级别太高了。当进行快照时,pBlockHeader数据成员指向最后分配的内存块,lCounts和ISizes分别记录对各种类型内存块进行分配的次数和总的内存大小,lHighWaterCount记录了在一次分配中最大内存块的大小,lTotalCount记录了总的分配次数。除了pBlockHeader外,其他的数据成员都会被_CrtMemDumpStatistics使用。值得注意的是,如果pBlockHeader指向的内存块已经被释放了,_CrtMemDumpAllObjectsSince将会很困惑。在这种情况下,它会转储所有已被分配的内存块,因为它不知道应该在哪里停止转储。如果你发现所有的内存都被判定为被泄漏,不要惊慌。打开_CRTDBG_DELAY_FREE_MEM_DF调试堆选项可以得到正确的结果。
使用_CRTDBG_DELAY_FREE_MEM_DF调试堆选项防止_CrtMemDumpAllObjectsSince导出错误的结果。
Windows的资源泄漏是一个很严重的问题,特别是图形设备接口资源的泄漏。Windows会代表用户程序进行资源分配。所以你不可能使用前面介绍的技术检测到Windows的资源泄漏,因为C运行时刻函数库不会对Windows内部分配的内存进行管理。所以,你必须使用其他的技术来发现、消除Windows资源泄露。
要消除资源泄漏,Windows为程序创建的任何资源都要用相应的 API函数来释放。典型的Windows资源释放格式如下所示。
MSOMEOBJEC hObject = CreateSomeObject(...);
... // use hObject
DestroyObject(hObject);
Windows GDI资源又进一步使得事情复杂化了,因为GDI对象当有一个有效的设备上下一文选择了它时,是不能被删除的。对于这个问题有两个解决方法。第一个方法保持原来的GDI对象的一个句柄,在删除新的GDI对象之前,重新选择原来的GDI对象,如下面的代码所示
HPEN hPen, hOldPen;
hPen = CreatePen(PS_SOLID, 1, RGB(0, 0, 0xff));
hOldPen = (HPEN)SelectObject(hDC, hPen);
... // use hPen to do some drawing
SelectObject(hDC, hOldPen); // reselect the original GDI object
DeleteObject(hPen);
第二个方法是重新选择一个库存的对象来代替选择新的图形设备接口对象,如下面的MFC例子所示:
CPen pen(PS_SOLID, 1, RGB(0, 0, 0xff));
dc.SelectObject(&pen);
... // use pen to do some drawing
dc.SelectStockObject(BLACK_PEN);
当在设备上下文里很难跟踪原始的图形设备接口对象时,第二个方法比较受欢迎。
在删除图形设备接口对象之前,一定要确定它们没有被任何有效的设备上下文选中。
正如最后一个例子所示,使用C++类包装(wrap)Windows资源(MFC就是这样做的)的一个重要好处在于一旦资源对象出了生存范围就会自动被释放,从而完全消除了显示调用释放对象的API函数的必要牲。不幸的是,在你释放这些资源之前,必须使得图形设备接口对象不被选中,即时使用的是C++类。
防止Windows资源泄漏的一个严重问题在于Windows资源的类型太多了,而且它们的释放方式还各不一样。很难记得清某些种资源应由哪个函数释放。使事情进一步复杂化的是,有些Windows资源根本就不用释放,例如库存的图形设备接口对象和字符串。表9.10列出了分配资源和释放资源的各种Windows API函数和C运行时刻函数库函数。注意,有一些(不是全部)资源被装载进来后是不用释放的。
正如这张表所示,为某一种Windows资源的构造函数找相应的释放函数时,靠猜测的方法是不可取的。每当你使用没有被包装在C++类里的Windows资源时,都应査看相应的文档,确定使用的是正确的释放函数。
表9.10 Windows API和C运行时刻函数库的资源分配、释放函数
系统资源函数 |
释放函数 |
CreateProcess |
CloseHandle(call twice to close both process and thread handles) |
CreateThread/_beginethreadex |
CloseHandle/_endthreadex |
CreateFile/fopen |
CloseHandle/fclose |
FindFirstFile/_findfirst |
FindClose/_findclose |
LoadLibrary |
FreeLibrary |
GDI资源函数 |
释放函数 |
CreateDC |
DeleteDC |
GetDC |
ReleaseDC |
BeginPaint |
EndPaint |
GetStockObject |
No cleanup required |
CreateSolidBrush |
DeleteObject |
CreatePen |
DeleteObject |
CreateRectRgn |
DeleteObject |
LoadImage |
DeleteObject/DestroyCursor/DestroyIcon |
CreateBitmap |
DeleteObject |
LoadBitmap |
DeleteObject |
CreatePalette |
DeleteObject |
CreateFont |
DeleteObject |
用户资源函数 |
释放函数 |
CreateWindow |
DestroyWindow |
CreateDialog |
DestroyWindow |
CreateMenu |
DestroyMenu |
LoadMenu |
DestroyMenu |
LoadString |
No cleanup required |
CreateCursor |
DestroyCursor |
LoadCursor |
No cleanup required |
CreateIcon |
DestroyIcon |
LoadIcon |
No cleanup required |
SetTimer |
KillTimer |
在Windows 98里检测资源泄漏,特别是检测图形设备接口资源泄漏时,最简单的方法是使用Windows 98自带的资源状况工具(文件名为Rsrcmtr.exe。在Windows目录下可以找到)。这个程序管理Windows系统资源、用户资源以及图形设备接口资源。如图9.8所示。
图9.8 Windows 98资源状况工具
发现资源泄漏的方法如下,首先检查可用资源的百分比,然后运行自己的程序。一旦程序和数据都被装载后,资源应该保持在一个非常稳定的状态。在运行程序时监视资源使用情况,持续的资源百分比下降是资源泄漏的很好的征兆。在显示面板的效果标签里选择拖动窗口时显示其内容选项对发现图形设备接口资源的泄漏非常有效。如果你怀疑程序有资源泄漏,以同样的方式执行程序多次,观察是否有持续的资源下降。一旦你退出程序,可用资源百分比应该恢复到你运行程序之前的状况。如果没有,这又是资源泄漏的一个很好征兆。尽管资源状况工具的粒度不是非常精确(每个百分比代表655字节),但当程序出现资源泄漏时还是非常明显的,所以你不用担心如果资源泄漏非常少时,会检测不到。
在Windows 2000里发现资源泄漏的最简单的方法是运行性能监视工具,并监视程序的私有空间和句柄数随时间的变化,如图9.9所示。私有空间是进程使用的非共亭内存的总量,句柄数是当前被该进程打开的句柄的总数。如果私有空间或者句柄数随着时间持续增长,而且不能达到稳定的状态,你的程序就应该出现了内存泄漏。你还可以使用Windows 2000任务管理器来监视核心内存,这是系统核心分配的内存总数。私有空间和句柄数表明程序自身导致的资源泄漏,而核心内存泄漏表明程序正在泄漏核心对象或者其他的核心资源。
图9.9 Windows 2000性能监视工具
在Windows 2000里使用性能监视工具发现Windows资源泄漏非常有效。
对于释放被选中的图形设备接口对象的问题,非常不幸,还没有一个简单的方法来确定一个非库存的对象是否被设备上下文选中了。从MSDN文档可以知道,DeleteObject API函数在释放一个被设备上下文选中的图形设备接口对象时,会返回零。这表明将DeIeteObject的返回值放入断言语句是发现由于释放被选中对象而造成图形设备接口资源泄漏的一个简单方法。不幸的是,我通过测试发现当释放一个被选中的对象时,DeleteObject有时会返回一个非零值,所以这个方法并不可靠。
另一种判断某个特定图形设备接口对象是否被设备上下文选中的方法是,首先选中该对象,然后将原来选中的对象句柄和被测试的对象进行比较,如下所示。
BOOL IsGIDObjectSelected(HDC hDC, HGIDOBJECT hObj) {
HGIDOBJECT hOldObj = SelectObject(hDC, hObj); // select object
SelectObject(hDC, hOldObj); // restore the original objecct
return hObj == hOldObj; // comare handles
}
尽管在一定的环境下知道某个特定的对象是否被选中可能会有用,例如跟踪一个特定的错误,但要在所有的图形设备接口资源中确定某个资源是否被选中还是不现实的。
一旦发现程序有资源泄漏,通常情况下,最简单的方法是査看源代码来消除资源泄漏。通过对程序进行测试,并査看资源管理工具或者性能监视工具,你可以将资源泄漏隔离到一个特定的任务,接着进一步隔离到一段特定的代码。然后你就可以通过在这些代码里査找遗忘的Windows资源释放语句来发现资源泄露了。
尽管你可以试着使用调试器来发现Windows资源泄漏,但这个方法是最后不得已才选用的,原因很简单,调试器实际上帮不了什么忙。也就是说,使用调试器一步一步地跟踪代码并不比直接查看代码找出泄漏更好。对于这种问题,调试器最好用来发现代码中可能会被遗漏的执行路径,例如存在泄漏的异常处理代码。如果实在没有办法了,你可以采用暂时删除可能有问题的代码来跟踪程序,看看资源泄漏是否不存在了,也许要使用二分法来定位资源泄漏。
你可以通过检査、修复的方法来消除资源泄漏,但更好的方法还是防止泄漏的发生。如果你直接使用Windows API函数进行编程,一个很好的办法是用C++类对Windows资源进行包装,以保证对象在生存范围外时会自动被释放。这样,资源的释放将由C++语言保证而不是编程者的任务了。而且,对于动态分配的资源,这个技术能保证所有的资源泄漏全部是内存泄漏,对于内存泄漏己经有了很好的检测技术。注意,C++的auto_ptrs不能用作这个用途,因为声明为auto_ptrs的对象需要使用delete来释放。当然,如果你使用的是MFC,你就什么也不用管了,因为所有的Windows资源已经都包装在MFC类里了。
防止资源泄漏发生比检查、消除资源泄露好得多。你可以使用C++类保证Windows资源来防止资源泄漏。
如果你使用Windows API或者ATL编程,你可以使用下面的方法对Windows资源(在这个例子里是pen)进行包装。
class CAutoPen {
public:
CAutoPen(HPEN pen = 0) : m_hPen(pen){}
~CAutoPen(){DeleteObject();}
CAutoPen& operator = (const HPEN hPen){
DeleteObject();
m_hPen = hPen;
}
BOOL DeleteObject()
{
if(m_hPen == 0) return FALSE;
BOOL retVal = ::DeleteObject(m_hPen);
m_hPen = 0;
return retVal;
}
operator HPEN () const{return m_hPen;}
private:
HPEN m_hPen;
CAutoPen(const CAutoPen&);
CAutoPen& operator = (const CAutoPen&);
};
你可以以下面的方式使用Pen:
HPEN hOldPen;
CAutoPen pen(CreatePen(PS_SOLID, 1, RGB(0, 0, 0xff)));
hOldPen = (HPEN)SelectObject(hDC, pen);
... // use pen to do some drawing
SelectObject(hDC, hOldPen); // reselect the original GDI object
以类似的方法对所有图形设备接口对象或者其他Windows资源进行包装,可以很容易地防止绝大多数Windows资源泄漏的发生。
剩下的问题就是在释放图形设备接口对象时,一定要确定它们没有被选中。对于这个问题,我发现最有效的解决办法是手工劳动。在我发布使用图形设备接口对象的新代码之前,我会在所有的文件里搜索“SelectObject”,这会将所有相关代码都定位出来,这样做的原因是如果图形设备接口对象没有被设备上下文选中,那么这个图形设备接口对象就什么也做不了(类似地,你可以通过搜索“Create”和“Load”来定位绝大多数非图形设备接口资源)。然后我会确认是否程序创建的每个被选中的图形设备接口对象最终都没被选中,或者被原来的图形设备接口对象代替。或者被库存的对象代替。这需要你仔细检查所有可能的执行路径,特别要注意return、break语句以及异常处理程序。如果我没有使用MFC,也没有将图形设备接口对象包装在C++类里,我还会确认每个图形设备接口对象在所有的执行路经上是否都被删除了,这个工作看起来非常枯燥无味,但我发现我能非常快的完成(大约儿分钟)。实际上,我发现采用査看源代码的方法发现图形设备接口资源泄漏比其他任何方法都要简单。
查看源代码是确定图形设备接口对象在被删除之前没有被选中的最有效的方法。
使用了保护页的Windows内存保护机制能很好地防止堆栈上溢和下溢。这个保护机制使得程序不易崩溃,而且使得堆栈的错误调试相对会比较简单。下面让我们看看Windows堆栈是怎样工作的,堆栈错误有那些类型,以及如何对它们进行调试。
进程里的每个线程都有它自己的堆栈。默认情况下,Windows为每个堆栈保留了1MB的虚拟内存空间,以及两页(8KB)的物理内存空间。堆栈的工作方式是后进先出,所以所有的操作都是在栈顶进行的。堆栈有一点很特殊,它在内存里是向下增长的,所以栈顶的地址会比栈底的地址小。堆栈能持续增长,直到预留的整个内存空间全部用完、栈顶到达堆栈的基地址为止。任何继续增长堆栈的操作都会导致堆栈溢出异常。注意,1MB的默认堆栈大小已经非常大了,比16位的Windows程序的8KB、16KB的堆栈自然也大得多。这么大的空间使得用户很容易忘记堆栈也是会溢出的。
堆栈溢出的异常只能发生一次,所以每个线程最多只能接收到一个堆栈溢出异常。
理解堆栈溢出异常只能发生一次非常重要,每个线程最多只能接收到一个堆栈溢出异常。这意味着如果你以某种方式处理堆栈溢出异常,并期望另一个堆栈溢出异常的发生,你会很失望的。最好的情况下,你会收到内存非法访问异常;最坏的情况下,进程会被终止。原因在于,在Windows 2000里堆栈的栈顶和栈底被访问类型为PAGE_GUARD的内存块保护着。当堆栈的增长超过了预定的范围时,它会试图访问保护页,从而导致异常的抛出。Windows处理这个异常时,首先将保护页转换成普通页,然后生成一个新的保护页。一旦全部保留的堆栈空间使用完时,Windows仍然会试图以将保护页转换成正常页然后生成新的保护页的方式增长堆栈。但这时,保护页的转换会成功,但新的保护页的分配会失败,从而导致堆栈溢出异常。既然已经没有了保护页,Windows也就不会再为这个线程产生另一个堆栈溢出异常了。Windows 98使用的方法和此类似(但它不支持PAGE_GUARD内存类型),所以结果是一样的。
堆栈被用来存储函数返回地址、堆栈基址、函数参数、自动变量、己经被保存的寄存器值。当参数被传递时,它们统一地被转换成32位。函数的返回值不是通过堆栈传递,而是通过EAX寄存器传递。参数被压入栈、推出栈的方式由函数的调用规范决定。表9.11列出了Windows程序常用的调用规范。
表9.11Windows调用规范
调用规范 |
描述 |
__cdecl |
这是C/C++程序的默认调用规范。参数从右到左传递,由调用函数负责将参数从堆栈中移走。这利于传递个数可变的参数 |
__stdcall |
这是Windows API函数使用的调用规范。参数从右到左传递,由被调用函数负责将参数从堆栈中移走。由该规范产生的代码比__cdecl更小,但当函数有可变的参数个数时,仍会使用__cdecl规范。WINAPI、CALLBACK以及APIENTRY宏都被定义为__stdcall规范 |
thiscall (非关键字) |
C++成员函数的默认调用规范,不使用个数可变的参数。除了this指针是保存在ECX寄存器里,而不是保存在堆栈里外,其他的都和__stdcall相同。注意,COM成员函数使用__stdcall规范 |
参考6.6节中的《函数调用规范》
对于调试来说,堆栈是墨菲法则的又一很好的例证:任何可能出现问题的事物都会出现问题。下面列出的问题都有可能在堆栈里发生:
l 如果自动变量太大,以至堆栈装不下时,会发生堆栈溢出。
l 如果递归调用是无穷的或者太深了,也会导致堆栈溢出。
l 如果对局部变量数组的写操作超出于范围,就会破坏堆栈里保存的函数返回地址。
l 在调用函数和被调用函数之间如果出现了函数参数的不匹配或者调用规范的不一致,会导致堆栈破坏。
下面我将一详细介绍这些问题。
如果自动变量太大以至堆栈装不下时,会发生堆栈溢出。依不同的环境而定,或者是因为堆栈的空间太小,或者是因为数据太大不适合放在堆栈里,而应该放在堆里或者直接放在虚拟内存里。下面是一个典型的例子:
void StackGlutton() {
char bigArray[10000000];
...
}
调用StackGlutton会在函数入口处导致堆栈溢出异常。整个堆栈还没有被破坏,所以你可以通过下面的方法调试这个错误:显示调用堆桟窗口,发现导致异常的函数,然后注意到是在函数入口处崩溃的。在这个例子里,问题不在于堆栈太小,而是因为自动变量太大。不要在堆栈里创建1000万字节大小的数组;可以通过在堆里创建数组或者使用VirtualAlloc作业替代。如果大的堆栈非常有必要,你可以在工程设置对话框里改变堆栈大小,在Link标签的Output类里设置Stack Allocations Reserve。
当每个函数调用都被压入堆栈而导致无穷递归或者递归调用太深了时,堆栈会溢出。如下面的例子所示。
int InfiniteRecursion() {
return InfiniteRecursion();
}
在递归调用InfiniteRecursion很多次后,会导致堆栈溢出异常。打开调用堆栈窗口,你会看到有无穷多次递归调用,但它不能显示出初始的调用函数,因为它在堆栈下面很远的地方,以至窗口不能显示它。问题在于调用堆栈窗口只能显示有限数目的调用。显示整个调用堆栈的一个简单的方法是,暂时减少堆栈的大小,使得溢出提前发生。
要调试无穷的递归调用,可以暂时减少堆栈的大小,使得溢出提前发生。
下面是一个稍微不同的例子:
int BigInfiniteRecursion() {
char midSizedArray[1000];
return BigInfiniteRecursion();
}
在这个例子里,每一次函数调用会比以前占用更多的堆栈空间,所以,在堆栈溢出之前。函数调用次数会少一些。所以,在这种情况下显示调用堆栈也有可能显示整个堆栈。
在实际编程中,递归调用的问题通常不会这样明显。下面是一个更为现实的无穷递归调用例子:
int InfiniteRecursion1() {
return InfiniteRecursion2();
}
int InfiniteRecursion2() {
return InfiniteRecursion1();
}
这个例子更为现实是因为,单独地看每个函数你都无法确定是否有无穷递归调用。在Windows程序里导致无穷递归的另一个途径是让两个窗口函数互相给对方发送不相关的消息,如下面的代码所示:
HWND hWIndow1; // has WndProc1 for windows procedure
HWND hWIndow2; // has WndProc2 for windows procedure
LRESULT CALLBACK WndProc1(HWNDhWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
if(msg == WM_USER)
SendMessage(hWnd, msg, wParam, lParam);
return DefWindowProc(hWindow1, wParam, lParam);
}
LRESULT CALLBACK WndProc2(HWNDhWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
if(msg == WM_USER)
SendMessage(hWidnow1, msg, wParam, lParam);
return DefWindowProc(hWnd, wParam, lParam);
}
尽管递归调用非常深的情况和无穷调用不同,它是有穷的,只是调用次数非常大而已,例如遍历一棵可以有任意深度的树,因为你无法确定你的堆栈足够大,所以情况还是有可能会和无穷递归一样糟。在使用递归调用时,一定要证明在所有情况下,函数都能在现有的堆栈空间里完成。如果你不能证明这一点,可以使用迭代方法来替代。
在使用递归调用时,一定要证明在所有情况下,函数都能在现有的堆栈空间里完成。如果你不能证明这一点,可以使用迭代方法来替代。
PS:可以考虑是否能够使用尾递归优化。
如果对局部变量数组的写操作超出于范围,会使得堆栈里保存的函数返回地址被破坏掉。因为堆栈在内存里是向下增长的,写自动变量时如果发生越界错误会导致最后压入堆栈的数据被破坏,例如函数返回地址、堆栈基地址指针甚至是调用函数的自动变量。下面是一个典型的例子:
void StackClobber() {
char array[100];
for(int i=0; i<110; i++)
array[i] = 0;
}
调用StackClobber会在函数返回时导致访问违例异常的发生,因为被破坏的堆栈试图返回到非法地址(在这个例子里是空指针)。因为堆栈已经被严重破坏了,调用堆桟窗口不会显示调用堆栈。不能能显示调用堆栈是函数返回地址和堆栈基地址指针己经被破坏的很确定的征兆。调试这种错误最简单的方法可能是首先确定哪些函数可能会出问题,然后在每个函数的最后一个括号处设置断点。返回时破坏的函数就是自动变量的写操作越界的函数。还有另一个可行的方法,如果你在函数的出口处设置了跟踪语句,通过检查跟踪消息你也可以找到最后退出的函数。然后你就可以监视该函数里的数组,确定对哪个变量的写越界了,并且确定写越界是在哪里发生的。数据断点对这种类型的调试很有效。
不能显示调用堆栈是函数返回地址和堆栈基地址指针已经被破坏的很确定的征兆。
在调用函数和被调用函数之间如果出现了函数参数的不匹配或者调用规范的不一致,会导致堆栈破坏,通常会导致函数返回地址被破坏。类型安全的链接会防止绝大多数函数原型不匹配的发生,但在某些情况下,不匹配总是有可能存在的,例如函数强制类型转换,通用函数指针(例如PVOID、FARPROC或者GetProcAddress API函数)或者声明为extern“C”的函数。例如,在MFC里,你可以使用ON_MESSAGE、ON_REGISTERED_MESSAGE、ON_THREAD_MESSAGE以及ON_REGISTERED_ THREAD _MESSAGE宏来处理用户自定义的消息。ON_MESSAGE和ON_REGISTERED_MESSAGE会期望能得到原型如下的函数:
afx_msg LRESULT OnMyMessage(WPARAM wParam, LPARAM lParam);
但程序员有时会错误地使用下面的函数原型:
afx_msg LRESULT OnMyMessage();
不幸的是,这些宏会将输入的函数进行强制类型转换,所以即使存在不匹配编译器也不会警告你。
问题在于调用者将两个参数压入到栈顶,而被调用函数却认为栈顶没有任何参数。结果导致函数在错误的位置寻找函数返回地址和最新的堆栈基地址指针,从而在函数返回时导致非法内存访问异常。使事情变得复杂的是,该程序的调试版本的执行不会有任何问题,只有发行版本才会崩溃,因为调试版本使用了EBP寄存器来寻找函数返回地址(EBP保存的值是正确的),然而打开了FPO优化的发行版本会将EBP寄存器用在其使用途上。EBP寄存器和FPO的相关内容在第6章里已经讨论过了。
一般来说,调试这类错误的最简单方法是在打开/GZ编译选项的情况下生成调试版本。这个选项会在函数的末尾检査堆栈指针,确定它的值没有被改变。如果它被改变了,程序就会收到图9.10所示的断言失败消息。
图9.10使用/GZ编译选项后发现了堆栈破坏
使用/GZ编译选项来发现会导致堆栈破坏的函数原型不匹配错误。
不幸的是,MFC的调试版本目前还不支持/GZ编译选项。所以,解决ON_MESSAGE问题的最好方法是重新定义ON_MESSAGE宏(在stdafx.h里,在Afxwin.h后面),将C风格的强制类型转换替换成C++的static_cast,如下面代码所示(PS:在较高版本的VC中,如VC10,已作修正)。
#undef ON_MESSAGE
#define ON_MESSAGE(message, memberFxn) \
{ message, 0, 0, 0, AfxSig_lwl, \
(AFX_PMSG)(AFX_PMSGW) \
(static_cast< LRESULT (AFX_MSG_CALL CWnd::*)(WPARAM, LPARAM) > \
(memberFxn)) },
使用了这个宏定义后,错误的函数原型会导致程序在编译时就会报错,这正是大家所希望的。
下面将介绍各种各样的技巧,这些技巧会帮助你更好地进行内存调试。
设计对象的部分工作就是要确定对象如何被创建,如何被释放。设计对象的生存期通常要确定某种所有权,即,某个对象不再有用时,另一对象应该负责将它释放。最简单的所有权关系是对象被创建它的对象释放。你必须非常注意正在使用的对象将如何被释放,因为它通常会导致内存泄漏。
在本章和第7章里已经提到,你可以直接在观察窗口里执行许多调试堆的诊断函数。例如,要检查堆的完整性,可以在观察窗口里输入下面的代码:
{,, msvcrtd.dll} _CrtCheckMemory()
一旦你输入了回车键,这个语句就会被执行。以后,随着程序调试的进行它会不断地被重新调用,所以,这是在调试程序时不停地检查内存完整性的一个比较简单的方法。当然,如果程序分配了许多内存块,这样做会使得程序的调试速度下降很多。要使得这条语句不被执行,要么将它从观察窗口里删除,要么重新选择活动标签,因为只有Active标签里的语句才会被执行。你可以使用相同的方法来执行其他类似的调试函数,例如_CrtSetBreakAlloc、_CrtDumpMemoryLeaks、_CrtMemDumpAlIObjectsSince以及_CrtSetDbgFlag。
你还可以在调试程序时交互地输入观察语句来监视保护字节(guard byte)或者任何其他的诊断数据。例如,你可以输入下面的语句来监视pData变量的保护字节:
*(((int*)pData) - 1),x
*(int*)(((char*)pData) + sizeof(*pData)),x
如果你有一个想要执行的调试语句,在修改代码之前可以先在观察窗口里交互执行这条语句。如果你在观察窗口里输入了任何语句,记住,只要它们还在Active标签下,它们会继续执行。所以当下次调试程序时看到许多意想不到的输出时,不要感到奇怪。
在调试器里你可以从观察窗口里交互执行内存调试语句。
尽管调试堆在发现动态分配的变量的错误时工作得很好,但它对于调试全局变量和自动变量没有任何帮助。对于这些变量令你只能依靠自己了。如果你怀疑某个全局变量或自动变量被破坏了,或者被泄漏了,你可以暂时地将变量替换成动态分配变量,利用调试堆的特性进行调试。例如,可以暂时地将全局变量
CMyObject BuggyGlobal; //a Global variable you suspect has problem
替换成
#define BuggyGlobal(*pBuggyGlobal)
CMyObject*pBuggyGlobal;
int APIENTRY WinMain(...){
pBuggyGlobal=newCMyObject;
delete pBuggyGlobal;
}
C运行时刻函数库的结果输出在输出窗口里,这个默认行为是可以被修改的。你可以使用_CrtSetReportMode使得跟踪消息被输出到消息框里、输出窗口里、文件里或者上述的结合。你可以使用_CrtSetReportFile指出结果应输出到哪个文件里。在某些情况下,改变输出目的地是非常有用的;例如,你可以选择将跟踪消息既输出到输出窗口里,同时为了调试也输出到某个日志文件里。有一个日志文件能帮助你的测试者在错误提交报告里向你提供准确的信息。
你也许希望内存破坏以消息框的形式输出,而其他所有的内存错误以跟踪语句的形式输出。要实现这个目的,你可以使用_CrtSetReportHook安装下面的用户报告过滤器:
#ifdef _DEBUG
_CRT_REPORT_HOOK pfnOldCrtReportHook = 0;
int CustomReportHook(int reportType, char* message, int* pRetVal) {
if(reportType != _CRT_ASSERT && strstr(message, "DAMAGE")!= 0) {
if(_CrtDbgReport(_CRT_ASSERT, __FILE__, __LINE__, NULL, message) == 1)
_CrtDbgBreak();
}
// call the old report hook if there is one
if(pfnOldCrtReportHook && pfnOldCrtReportHook(reportType, message, pRetVal))
return TRUE;
*pRetVal = 0; // don't start the debugger
return FALSE;
}
#endif
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPSTR lpCmdLine, int nShowCmd ) {
DEBUG_ONLY(pfnOldCrtReportHook = _CrtSetReportHook(CustomReportHook));
...
}
CustomReportHook函数会检査不是断言语句的报告(防止无限循环)和消息里有“DAMAGE”的语句。任何这样的报告都会使用断言语句来显示,而不是使用跟踪语句。
尽管_CrtDumpMemoryLeaks从名字上看会转储出被泄漏的内存信息,但实际上它会将所有被分配的内存信息转储出来,所以只有在程序结束时,它才会真正地将所有被泄露的内存信息转储出来。当MFC进行卸载操作时,它会转储内存泄漏信息并自动调用这个函数。然而,在一些情况下,程序会装载多个的MFC DLL,例如当MFC的ANSI和Unicode版本都被使用时。问题在于第一次卸载MFC DLL时报告的内存泄漏信息是错误的,因为程序此时还没有结束,而第二次卸载MFC DLL时报告的内存泄漏信息才是正确的。你可以使用Visual C++的Dependency Viewer(依赖关系查看)工具很快地确定程序使用的DLL,如果可以的话,你应该尽量只使用MFC的一个版本(其他的函数库也一样)。从MSDN的Q167929错误报告里你可以知道更多的信息。顺便提一句,这个问题并不是MFC特有的——调用了_CrtDumpMemoryLeaks的任何DLL都会有这个问题。
...