简介
在过去的几年中,崩溃转储成为我们调试活动的一个重要部分。当我们的软件在客户的机器出现故障时,创建应用程序状态的快照并使用在开发人员机器上运行的传统调试器对其进行分析的可能性是非常宝贵的。第一代崩溃转储(通常称为“完全用户转储”)捕获了整个进程虚拟内存的内容。尽管对于事后调试毫无疑问是有用的,但这样的转储常常变得如此巨大,以至于不可能或至少不方便将它们以电子方式传输给软件开发人员。此外,没有以编程方式创建此类转储的公共API,我们必须依赖外部工具(如Dr.Watson或Userdump)来创建它们。
一个新的崩溃转储系列,叫做“minidumps”,与Windows XP一起出现在我们面前。minidump是高度可定制的。在最流行的配置中,小型转储包含的信息刚好足以恢复失败进程中所有线程的调用堆栈,并在失败时检查本地变量的值。这种转储很小(通常只有几千字节),因此很容易以电子方式将它们传输给软件开发人员。但如果需要,小型转储可以包含比旧式崩溃转储更多的信息(例如,小型转储可以包含有关进程使用的内核对象的信息)。此外,可再发行的DbgHelp.dll公开了一个用于以编程方式创建小型转储的公共API,我们不再依赖外部工具。
小型转储的可定制性给我们带来了一个问题:我们需要多少关于应用程序状态的信息才能有效地进行调试,同时使小型转储尽可能小?虽然对调用堆栈和局部变量值的了解通常足以调试简单的访问冲突,但更困难的问题将需要额外的信息。例如,我们可能需要查看全局变量的值,检查堆的完整性,或者分析进程虚拟内存的布局。同时,如果可执行文件本身在开发人员的计算机上可用,则可执行模块的代码部分可能是多余的。
幸运的是,DbgHelp函数(MiniDumpWriteDump和MiniDumpCallback)提供了这样的控制级别,甚至更多。在本文中,我们将探讨如何使用这些函数来创建小转储,这些小转储虽然小,但仍然包含足够的信息,可以进行有效的调试。我们还将看到小型转储中可以包含哪些类型的数据,以及如何使用流行的调试器(WinDbg和VS.NET)来查看这些数据。
Minidump Types
让我们从一些代码开始。图1包含MiniDumpWriteDump函数的声明,图2显示了如何使用该函数创建一个简单的minidump。
Figure 1: BOOL MiniDumpWriteDump( HANDLE hProcess, DWORD ProcessId, HANDLE hFile, MINIDUMP_TYPE DumpType, PMINIDUMP_EXCEPTION_INFORMATION ExceptionParam, PMINIDUMP_USER_STREAM_INFORMATION UserStreamParam, PMINIDUMP_CALLBACK_INFORMATION CallbackParam ); Figure 2: void CreateMiniDump( EXCEPTION_POINTERS* pep ) { // Open the file HANDLE hFile = CreateFile( _T("MiniDump.dmp"), GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL ); if( ( hFile != NULL ) && ( hFile != INVALID_HANDLE_VALUE ) ) { // Create the minidump MINIDUMP_EXCEPTION_INFORMATION mdei; mdei.ThreadId = GetCurrentThreadId(); mdei.ExceptionPointers = pep; mdei.ClientPointers = FALSE; MINIDUMP_TYPE mdt = MiniDumpNormal; BOOL rv = MiniDumpWriteDump( GetCurrentProcess(), GetCurrentProcessId(), hFile, mdt, (pep != 0) ? &mdei : 0, 0, 0 ); if( !rv ) _tprintf( _T("MiniDumpWriteDump failed. Error: %u "), GetLastError() ); else _tprintf( _T("Minidump created. ") ); // Close the file CloseHandle( hFile ); } else { _tprintf( _T("CreateFile failed. Error: %u "), GetLastError() ); } }
在本例中,我们如何指定小型转储中应包含哪些类型的数据?答案在MiniDumpWriteDump函数的第四个参数中,它的类型是MINIDUMP_TYPE,其定义如图3所示。
Figure 3: typedef enum _MINIDUMP_TYPE { MiniDumpNormal = 0x00000000, MiniDumpWithDataSegs = 0x00000001, MiniDumpWithFullMemory = 0x00000002, MiniDumpWithHandleData = 0x00000004, MiniDumpFilterMemory = 0x00000008, MiniDumpScanMemory = 0x00000010, MiniDumpWithUnloadedModules = 0x00000020, MiniDumpWithIndirectlyReferencedMemory = 0x00000040, MiniDumpFilterModulePaths = 0x00000080, MiniDumpWithProcessThreadData = 0x00000100, MiniDumpWithPrivateReadWriteMemory = 0x00000200, MiniDumpWithoutOptionalData = 0x00000400, MiniDumpWithFullMemoryInfo = 0x00000800, MiniDumpWithThreadInfo = 0x00001000, MiniDumpWithCodeSegs = 0x00002000, MiniDumpWithoutManagedState = 0x00004000, } MINIDUMP_TYPE;
MINIDUMP_TYPE枚举的有趣之处在于,它实际上是一组标志,允许我们控制MINIDUMP的内容。让我们看看它们的含义以及我们如何使用它们。
- MiniDumpNormal
MiniDumpNormal是一个特殊的标志。其值为0,这意味着此标志始终隐式存在,即使未显式指定它。因此,我们可以假设此标志表示始终存在于minidumps中的基本数据集(除非它被用户定义的回调函数过滤掉,我们将在后面看到)。
下面的表显示了属于这个基本集的数据:
Data kind Description System information Information about the operating system and CPU, including: - Operating system version (including service pack)
- Number of processors and their model
Process information Information about the process, including: - Process ID
- Process times (creation time, and the time spent executing user and kernel code)
Module information For every executable module loaded by the process, the following information is included: - Load address
- Size of the module
- File name (including path)
- Version information (VS_FIXEDFILEINFO structure)
- Module identity information that helps debuggers to locate the matching module and load debug information for it (checksum, timestamp, debug information record)
Thread information For every thread running in the process, the following information is included: - Thread ID
- Priority
- Thread context
- Suspend count
- Address of the thread environment block (TEB) (but the contents of TEB are not included)
Thread stacks For every thread, the contents of its stack memory are included into the minidump. It allows us to obtain call stacks of the threads, inspect the values of function parameters and local variables. Instruction window For every thread, 256 bytes of memory around the current instruction pointer are stored. It allows us to see the disassembly of the code the thread was executing at the moment of failure, even if the executable module itself is not available on the developer’s machine. Exception information Exception information can be included into the minidump via the fifth parameter of MiniDumpWriteDump function (as shown in Figure 2). In this case, the following information about the exception will be available: - Exception record (EXCEPTION_RECORD structure)
- Thread context at the moment of the exception
- Instruction window (256 bytes of memory around the address of the instruction that raised the exception)
是的,即使MiniDumpNormal标志提供的基本信息集也非常有用。我们可以找到导致失败的指令,还可以检查调用堆栈,查看线程是如何进入该状态的。通过检测函数参数和局部变量的值,可以获得更多的信息。此外,这些信息通常足以调试死锁,因为我们可以查看所有线程的调用堆栈并查看它们在等待什么。
所有这些有用的信息都以非常诱人的代价提供——小型转储的大小通常小于20千字节。影响小型转储大小的主要因素是线程堆栈的大小—它们占用的内存越多,小型转储将越大。
但是,如果我们试图使用这种小型转储来调试比简单访问冲突或死锁更复杂的问题,我们很快就会遇到小型转储normal标志收集的信息的限制。我们可能想查看全局变量的值,但它不可用。我们可能希望检查堆上分配的结构的内容,但小型转储中不包含有关堆的信息。以此类推,直到我们意识到需要将其他数据包含到minidump中。 - MiniDumpWithFullMemory
这可能是MiniDumpNormal之后最流行的标志。如果已指定,进程地址空间中每个可读页的内容都将包含在小型转储中。它给了我们巨大的调试能力,因为现在我们可以查看应用程序分配的任何内存。我们可以检查存储在堆栈、堆、模块的数据部分,甚至线程和进程环境块中的数据,这些块的未记录内容有时会为调试提供宝贵的信息。这个标志的唯一问题是小型转储很容易变大(至少有几兆字节)。此外,minidump的大部分内容现在是冗余的。 - MiniDumpWithPrivateReadWriteMemory
如果指定了此标志,则每个可读写专用内存页的内容都将包含在小型转储中。它将允许我们检查堆栈、堆甚至TLS中存储的数据。还包括PEB和TEBs的内容。同时,共享内存页的内容不包含在minidump中(这意味着我们无法检查内存映射文件的内容)。可执行模块的代码和数据部分也不包括在内。这是好的,因为代码段不会在转储中占用不必要的空间,但也不好,因为我们无法检查全局变量的值。不过,MiniDumpWithPrivateReadWriteMemory是一个非常有用的选项,特别是如果我们将它与其他一些选项结合起来。 - MiniDumpWithIndirectlyReferencedMemory
如果指定了此标志,MiniDumpWriteDump函数将扫描每个线程的堆栈内存,查找指向进程地址空间中其他可读内存页的指针。对于找到的每个指针,它指向的位置周围的1024字节内存将存储在minidump中(之前256字节,之后768字节)。
让我们看看下面的例子
#include <stdio.h> struct A { int a; void Print() { printf("a: %d ", a); } }; struct B { A* pA; B(): pA(0) {} }; int main( int argc, char* argv[] ) { B* pB = new B(); pB->pA->Print(); return 0; }
在本例中,main函数试图通过空对象指针(pB->pA)调用A::Print,这将在运行时导致访问冲突。如果我们试图仅使用使用MiniDumpNormal标志创建的minidump调试此问题,我们将无法查看pB指向的B结构的内容(因为它存储在堆中),我们只需要猜测传递给a::Print的对象指针为何为空。但是,如果指定MiniDumpWithIndirectlyReferencedMemory标志,MiniDumpWriteDump将注意到堆栈包含指向堆中某个区域的指针(pB)。存储在pB中的地址周围的1024字节将保存在minidump中,因此可以在调试器中查看B结构的内容,并查看pA是否为空。当然,MiniDumpWriteDump不能访问调试信息,因此它不能区分真正的指针和仅通过巧合指向内存某些区域的值。这种情况如下所示|
#include <stdio.h> void PrintSum( unsigned long sum ) { printf( "sum: %x", sum ); // access violation *(int*)0 = 1; } unsigned long Sum( unsigned long a, unsigned long b ) { unsigned long sum = a + b; PrintSum( sum ); return sum; } int main() { Sum( 0x10000, 0x120 ); return 0; }
当函数PrintSum导致访问冲突时,0x10000和0x120的总和至少存储在堆栈上一次。sum(0x10120)不是指针,但是MiniDumpWriteDump无法知道它。如果0x10120恰好是可读内存页中的有效地址,那么1024字节的内存(0x10020–0x10520)将包含在小型转储中。在扫描堆栈时,MiniDumpWriteDump似乎忽略了指向可执行模块的数据段的指针。因此,MiniDumpWithIndirectlyReferencedMemory标志不允许我们查看全局变量的值,即使它们是从堆栈中引用的。随着MiniDumpWithIndirectlyReferencedMemory标志的添加,minidDump的大小会增加,并且增加的数量取决于堆栈上找到的指针的数量。
- MiniDumpWithDataSegs
如果指定了此标志,则进程加载的所有可执行模块的所有可写数据节的内容都将包含在微型转储中。如果我们想检查全局变量的值,但不想使用MiniDumpWithFullMemory标志,我们必须使用MiniDumpWithDataSegs。此标志对小型转储大小的影响完全取决于相应数据节的大小。即使在简单的应用程序中,它也可以累积到几百千字节,因为系统dll的数据部分也包括在内。例如,DbgHelp.dll的.data节可以贡献超过100K,如果我们仅使用此dll来调用MiniDumpWriteDump,这就太多了。在本文的后面,我将展示如何让MiniDumpWriteDump只包含那些真正需要的数据节。 - MiniDumpWithCodeSegs
如果指定了此标志,则进程加载的所有可执行模块的所有代码部分的内容都将包含在小型转储中。与MiniDumpWithDataSegs标志一样,minidump的大小会显著增加。在本文的后面,我将展示如何定制MiniDumpWriteDump行为,以便只包含必要的代码部分。 - MiniDumpWithHandleData
如果指定了此标志,则微型转储将包含失败时进程句柄表中所有句柄的信息。此信息可以在的帮助下显示!handle在WinDbg调试器中处理命令。此标志对小型转储大小的影响取决于进程句柄表中的句柄数。 - MiniDumpWithThreadInfo
有关进程中线程的其他信息可以通过MiniDumpWithThreadInfo标志收集。对于每个线程,将提供以下信息:Thread times---创建时间,以及线程用于执行用户和内核代码的时间,Start address和Affinity。在WinDbg中,可以使用.ttime命令查看线程时间。 - MiniDumpWithProcessThreadData
有时我们想查看进程和线程环境块(PEB和TEBs)的内容。它可以帮助WinDbg完成!peb和!teb,假设这些块占用的内存包含在小型转储中。这正是提供MiniDumpWithProcessThreadData的目的。如果使用,PEB和TEBs占用的内存页的内容将包括在minidump中,以及它们引用的一些其他内存页(例如存储环境变量和各种过程参数的位置,以及通过TlsAlloc分配的TLS插槽)。不幸的是,PEB和TEBs引用的一些数据似乎被忽略了,分配的TLS数据,如果需要minidump中的数据,我们必须使用MiniDumpWithFullMemory or MiniDumpWithPrivateReadWriteMemory标志。 - MiniDumpWithFullMemoryInfo
如果我们想检查进程的虚拟内存布局,可以使用MiniDumpWithFullMemoryInfo标志。如果已指定,则进程的虚拟内存布局的完整信息将包含在小型转储中,并可以使用WinDbg中的!vadump和 !vprot命令。此标志对小型转储大小的影响取决于虚拟内存布局-具有类似属性的页面的每个区域(有关详细信息,请参阅VirtualQuery函数)将向小型转储添加48个字节。 - MiniDumpWithoutOptionalData
虽然到目前为止我们看到的所有MINIDUMP_TYPE标志都是允许我们向MINIDUMP添加一些数据,但也有一些标志正好相反,它们从MINIDUMP中删除了不必要的信息。其中的MiniDumpWithoutOptionalData允许最小化转储中存储的内存内容量。如果指定了此标志,则仅包含由MiniDumpNormal标志指定的内存,并且即使显式指定了其他内存相关选项(MiniDumpWithFullMemory、MiniDumpWithPrivateReadWriteMemory、MiniDumpWithIndirectyReferencedMemory),也会抑制这些选项。同时,此标志不会更改以下标志的行为:MiniDumpWithProcessThreadData、MiniDumpWithThreadInfo、MiniDumpWithHandleData、MiniDumpWithDataSegs、MiniDumpWithCodeSegs、MiniDumpWithFullMemoryInfo。 - MiniDumpFilterMemory
如果指定了此标志,则在保存到小型转储之前,将筛选堆栈内存的内容,以便只保留重建调用堆栈所需的数据。所有其他数据都用零覆盖。因此,可以重建调用堆栈,但所有局部变量和函数参数的值都设置为0。此标志不会影响小型转储的大小,因为它不会更改存储在其中的内存量—它只会用零覆盖部分数据。此外,此标志只影响线程堆栈占用的内存内容。其他内存(如堆)不会被修改。此外,如果使用MiniDumpWithFullMemory选项,则此标志无效。 - MiniDumpFilterModulePaths
此标志会影响作为模块信息一部分存储的模块路径(请参阅本文中MiniDumpNormal标志的说明)。如果已指定,则从转储中删除模块路径,并且只有模块名称可用。根据文档,这样做是为了从小型转储中排除潜在的私有信息(例如用户名,它有时可能是模块路径的一部分)。此标志仅对小型转储的大小有轻微影响(因为模块路径不是其中最大的实体)。调试体验也不会受到严重影响,因为无论如何,我们必须告诉调试器匹配的可执行文件的存储位置。 - MiniDumpScanMemory
此标志允许我们通过排除调试问题不需要的可执行模块来节省小型转储中的空间。该标志与MiniDumpCallback函数密切合作,因此我们必须先查看该函数,然后在适当的时间返回MiniDumpScanMemory。