本书由铁文手打整理,仅为方便个人查阅摘录
如喜欢本书,请购买正版
第二部分调试工具
第6章在Windows中调试
也许你以前还没有遇到过下面所述的事情,但迟早有一天它会发生在你身上。你把你的程序给某个重要的人物使用,比如你最重要的客户或者是你公司的老板,不幸的是,程序在他们那里运行时崩溃了。他们记不起他们执行了些什么操作,但有一点是肯定的,这个错误很严重,而且,程序的崩溃导致了很大的麻烦。
在这种情况下,你不大可能重现错误。仅有的信息就是程序崩溃,并且有一个Windows 98的崩溃对话框(有时称为“死亡框”),如图6.1所示。当你单击Detail按钮时,便会显示出来下面的详细信息。
图6.1Windows 98崩溃对话框
你还知道另外一件事:你的老板希望你将错误改正过来,并且认为你知道怎么做。
在本章里,我们将从Windows自身的角度来考虑调试,这在很大程度上将独立于Visual C++以及任何其他的编程工具。我将依次对以下内容作详细介绍:Windows API错误码、Windows异常、可移植的可执行(PE)文件格式、部分汇编语言基础知识、Windows堆栈以及如何阅读MAP文件。我还将演示如何在Windows 98和Windows 2000下用崩溃对话框和Dr. Watson日志文件调试错误。由于调试发生在程序己经完全停止了之后,所以这些调试技术通常被称为事后调试。你很快就会发现,只要你懂得如何阅读崩溃对话框里的内容,那些看起来一点用也没有的十六进制数堆会给你提供很多非常有价值的信息。最重要的是,即使读者不做事后调试,掌握这些技术对于熟练使用Visual C++调试器也会有很大的帮助。
正如以上所说的,当一个非程序员发现了错误时,事后调试是非常有用的。如果你已经安装了Visual C++,并且即时(Just-in-ttime,JIT)调试功能也已经打开,那么当一个Windows崩溃对话框出现时,你应该单击Debug按钮,在程序仍处于活动状态时开始调试。很明显,如果程序的崩溃出现在客户、老板或者测试人员那里,以上的调试就无法进行了。关于调试活动程序的详细内容,凊参看笫7草“使用Visual C++调试器调试”和第8章“基本调试技术”。
6.1事后调试
事后调试有两个最基本的目标:①发现程序是在哪里崩溃的;②找出导致程序崩溃的原因。简单的说,就是要找到程序中导致崩溃的指令地址,在程序对应的MAP文件里。你能找到错误出现的源码文件名以及行号。要找到导致崩溃的原因,你可以通过线程的堆栈跟踪找到出错函数是在哪里被调用的,通过堆栈内容还可以知道相关变量的值。
事后调试是一件很麻烦的事情,而且,程序错误类型也很多,所以并不能保证你能得到足够的信息进行调试。以下是按优先选择顺序排列的调试技术:
•使用调试版本进行本地调试。
•使用带有调试符号的发行版本进行本地调试。
•使用调试版本进行远程调试。
•使用带有调试符号的发行版本进行远程调试。
•使用Dr. Watson日志文件进行事后调试。
•使用Windows 98崩溃对话框的信息进行事后调试。
在以上调试中,事后调试是最后的选择。但事实上,有时你并没有其他选择:你不能重现错误,你没有正确的源码,你没有匹配的调试符号,用户不可能让你进行远积调试等等。在这些情况下,使用Dr. Watson日志文件进行调试却成了非常好的选择,它能让你以最少的工作量找到问题所在。得到一些有用的信息总比什么都没有强。所以,你总会希望你能尽量好地利用你所能得到的任何信息。
6.2 Windows API错误码
调试Windows程序你需要懂得一个最基本的知识:Windows是如何处理错误的。由于Windows API函数从来不抛出异常,所以几乎所有的Windows错误都是通过函数返回值进行处理的(有少数的几个Windows API函数允许用户通过传递适当的标记值使得函数可以抛出异常,例如HeapCreate、HeapAlloc以及HeapRealloc。InitializeCriticalSection也可以抛出异常)。Windows API的错误处理过程分为两步:首先,API函数返回一个特殊的值,指明某个错误的发生,然后,用户可以通过调用API函数GetLastError得到这个特殊的错误码。
这些用来指明错误的特殊返回值依赖于具体的API函数,所以,当你对错误不确信时,最好查查文档。一般来说,返回类型为BOOL型的API函数,如果有错误发生,则返回值为0,否则返回值为非零数。正如我在第2章提到的,由于返回类型为BOOL型的Windows API函数返回的值不一定是0或1,所以在编写带有调试功能的C++程序时,将返回值与TRUE进行比较是一件很冒险的事情,应该避免这样做。返回类型为HANDLE的Windws API函数在有错误发生时,通常返回一个空的句柄,或者返回INVALID_HANDLE_VALUE(值为-1)。返回类型为LONG或DWORD的Windows API函数则通常返回0或-1。最后要说明的是,如果一个 API函数不可能有错误发生,它就返回VOID,这作的函数只有少数的几个。
一旦有错误发生时,你可以通过调用GetLastError得到相应的错误码。最新错误码是基于每个线程来设置的,它存储在线程的局部空间中,所以一个线程不会破坏另一线程的错误数值。由于几乎所有的Wimiows API函数都会修改最新错误数值(即使有些函数正确执行了,它也有可能设置错误数值),所以如果你要用到最新错误数值,最好尽怏地将它保存下来。不幸的是,Windows 98是基于Windows 3.1代码的,所以对GetLastError的支持很有限。这意味着,Windows 98 API函数总能返回正确的错误码,但GetLastError有可能不会返回正确的错误码。
错误码的位域有固定的映射格式(见表6.1),在Winerror.h中有详细的说明。错误码的映射格式看起来很熟悉,这是因为Windows的异常码用的是同样的映射格式。而且COM的HRESULT码也有与此很相似的映射格式(Winerror.h对此也有详细的说明)。
表6.1错误码映射格式
位 | 意义 |
Bits 30-31 | 安全代码:0=安全,1=信息,2=瞀告,3=错误 |
Bits 29 | 客户代奶:0=Microsoft定义的,1=客户定义的 |
Bits 28 | 保留位:必须是0 |
Bits 16-27 | 工具:Microsoft定义的(在Winerror.h中) |
Bits 0-15 | 工具的状态代码:Microsoft或客户定义的 |
顺便提一下,如果你愿意,你可以让你自己的函数也实现对GetLastError的支持,方法是:调用SetLastError API函数设置错误码,然后返回一个特殊值说明某个错误的发生。你可以使用已有的错误码,也可以自己构造一个。需要注意的是,如果是自己构造错误码,一定要确信构造的错误码没有与Windows定义的错误码相冲突。
得到最新错误码后,下面要做的事就是决定如何处理它。你可以在Winerror.h里手工査找错误码,但实际上,还有其他更好的査看错误码的方法。一种方法是使用Visual C++错误查找(ErrorLookup)工具,见图6。注意,对于任何拥有消息表资源的模块,你可以使用Modules命令查找它所定义的错误码,例如NetMsg.dll。
在Visual C++调试器里,可以通过在观察窗口中输入“@ERR”来监视GetLastError的返回值。ERR是调试器用来显示最新错误码的一个虚拟寄存器。你还可以使用“,hr”格式化符号将错误码转换为文本格式,见图6.3。
图6.2 Visual C++错误査找实用程序
图6.3在观察窗口中使用@ERR虚拟寄存器进行调试
通过在观察窗口中输入“@ERR,hr”可以监控GetLastError的返回值。
最后,如果你希望在一条错误信息中显示错误码,你可以通过调用FormatMessage API函数将错误码转换到文本格式。该函数会基于用户的默认本地配置或用户指定的特殊本地配置进行转换。以下代码是FormatMessage的一个应用示例。
......
6.3Windows异常基础知识
Windows程序的崩溃是由一个没被处理的异常引起的。实际上,只要你处理了所有的异常,而且不破坏Windows,不破坏堆栈,不在一个异常正在被处理时将异常抛出(此时堆栈正在作展开),你可以在Windows程序中做任何事情,即使有些看起来会比较可怕。在你做出可以将所有的警告抛至脑后的结论之前,要注意的是,Windows 98很容易崩溃。这就是第9章要介绍“内存调试”的原因。
程序运行的环境不同,报告“异常没有被处理”信息的崩溃对话框也不一样。如果你的程序在Visual C++里运行,当有异常没有被处理时,你所看到的消息框如图6.4所示。如果你没有运行Windows 98调试器,你会看到图6.5所示的Windows 98崩溃对话框的一种变体。变体的形式依Dr. Watson或者即时调试器是否打开而定。在这个例子中,Dr. Watson功能被打开了。如果你没有运行Windows 2000调试器,并且将即时调试器打开了,你会看到图6.6所示的很原始的Windows 2000崩溃对话框。Windows异常码釆用了同Windows错误码一样的位映射模式。由于异常也是错误,而且是由Microsoft定义的,所以任何异常码的最高四位总是1100(二进制),也就是十六进制中的0xC。
图6.4 Visual C++中的异常未被处理消息对话框
图6.5 Windows 98中Dr. Watson被打开后的崩溃对话框
图6.6 Windows 2000中JIT调试器被打开后的崩溃对话框
Winnt.h中列举了所有的Windows异常,表6.2列出了最常见的几种异常。
表6.2常见的Windows异常码
异常 | 含义 |
STATUS_ACCESS_VIOLATlON | 线程试图读写它不能访问的内存 |
STATUS_STACK_OVERFLOW | 线程已经用完了保留的堆栈空间 |
STATUS_FLOAT_DIVIDE_BY_ZERO | 线程试图用0除一个浮点数 |
STATUS_FLOAT_OVERFLOW | 浮点操作的幕指数超过了允许的最大值 |
STATUS_FLOAT_UNDERFLOW | 浮点操作的幕指数小于允许的最小值 |
STATUS_INTEGER_DIVIDE_BY_ZERO | 线程试图用0除一个整数 |
默认情况下,浮点不是作为异常处理的,下面的代码可以使浮点错误以异常的形式出现:
#include <float.h>
int cw = controlfp(0, 0);
cw &= ~(EM_OVERFLOW | EM_UNDERFLOW | EM_INEXACT | EM_ZERODIVIDE |
EM_DENORMAL | EM_INVALID);
_controltp(cw, MCW_EM);
浮点异常处理程序必须以_clearfp作为它的第一条执行指令,以清除浮点异常。
6.4 可移植的可执行文件基础知识
要进行事后调试,你必须懂得PE文件格式的一些基本知识,所有的32位Windows执行文件都釆用此格式,它是以通用对象文件格式(COFF)为基础的。我将要讲述的仅仅是本书中涉及到的一些知识,如果想作进一歩的了解,可以参看Matt Pietrek的《Peering Inside the PE:A Tour of the Win32 Portable Executable File Format》。
PE文件格式中最重要的概念是,它充分利用了内存映射文件。PE文件里代码段和数据段的组织基本上与它们在虚拟内存中的组织一致,而不是在装载时才建立可执行文件映射。Pietrek将PE文件比作预先制作好的房子,只需将它放到真正的空地上,装上电线(与动态链接库相链接),然后就可以工作了。在作内存映射时,可执行文件映像的某些部分只在需要时才真正装载进内存。而且,当内存页被换出时,也不需要额外的硬盘交换空间,因为映像已经保存在硬盘上的PE可执行文件里了。你不认为这很酷吗?
当然,天下没有免费的午餐,这里的花费便是程序所用到的每种可执行文件都有自己的最佳虚拟基址。在下一节我们将看到,如果模块不能从最佳虚拟基址开始装载,整个装载过程将变得非常复杂。Windows进程(.exxe文件也是)的最佳虚拟基址是0x00400000。尽管你可以选择将Windows 2000进程装载在更低的基址空间(使用链接器的Base address选项),0x00400000是所有Windows版本都兼容的最低基址。由于EXE文件是最先被装载的,所以它们总能被装载到自己的最佳虚拟基址。
相反,DLL文件就有可能不能被装载到最佳虚拟基址,而且需要重新定位。为了支持重定位,代码内部的地址均采用相对虚拟地址(RVA),它是实际虚拟地址与基址的差值。但是,指向全局数据和静态数据的指针是从最佳虚拟基址开始编址的,所以,如果DLL需要被重定位,这些指针必须重新计算。为了支持对DLL的动态链接,外部虚拟地址(即,引入的DLL函数的地址)不能直接被访问,而只能通过引入出thunk被间接访问。有趣的是,如果DLL必须被重定位,最终的程序就会需要更多的交换空间,因为重新计算的地址已经和PE文件里的内容不一致了,这导致内存映射文件的优势在一定程度上被削弱了。
下面仔细阅读一下PE文件的内部结构(见图6.7),你看到的第一部分将是MS-DOS头(header)和存根(stub)。如果你在一个不支持Win32程序的MS-DOS下运行PE文件,程序不会崩溃,但你会接收到来自存根的“该程序不能在DOS模式下运行”的消息。接下来的便是映射文件头(image file header),包括可执行文件的一些常用信息,比如目标平台、各种大小以及偏移量。再接下来的是映射文件的可选文件头(imagine option header),包括各种大小、偏移量以及版本号。然后便是段表了(section table),它相当于可执行文件中程序代码段和数据段的一个目录索引。下面就是可执行文件本身的内容了,包括实际的程序代码段和数据段。最后,在文件尾可能还有COFF和CodeView调试信息。从文件开始到代码段,中间信息(包括MS-DOS存根、映射文件头、映射文件的可选文件头、段表)的长度总为4,096个字节(即十六进制里的0x1000),可由可选文件头里的“代码基址”(Base of Code)记录推导出。
图6.7 可移植的可执行文件格式
具体的代码段和数据段因特定的可执行文件和所使用的编译器而定。由Visual C++生成的一种典型的可执行文件里的段见表6.3。
表6.3 一种典型可执行文件的数据段
块 | 用途 |
.text | 模块代码 |
.data | 初始化的(全局、静态和字符串)数据 |
.bss | 未初始化的(全局静态)数据,bss代表块存储空间(block storage space) |
.CRT | 由C运行时刻函数库使用的初始化数据 |
.tls | 线程本地存储空间 |
.rsrc | 模块使用的资源 |
.idata | 输入的函数和数据的信息,含有输入转换程序要用的地址 |
.edata | 输出的函数和数据的信息 |
.rdata | 调试目录和DEF文件中定义的程序描述字符串(如果存在) |
.reloc | 映像在模块重定位时,必须调整的所有地址的列表 |
査看PE文件内部细节有两种常用的方法。一个是使用Visual C++里的DumpBin工具(在Vc/bin里),并且需要打开相应的命令行开关。另外一个更方便的方法是使用Windows 98的快速查看工具,能从可执行文件里抽取出PE文件信息。在Windows浏览器中通过QuickView命令可以打开快速查看工具。
6.5 DLL重定位
虽然EXE文件总能被装载到最佳基址空间,DLL就没有那么幸运了,如果它的最佳甚址空间己经被其他模块占用了,它就不能被装载到最佳基址空间了。例如,Visual C++给出的默认DLL基址是0x10000000,所以,如果你有两个DLL,它们都是甚于默认基址生成的,这时,就会发生地址冲突了。这样的冲突要求一个DLL必须被重定位到另一个地址空间,而且所有的内部数据引用必须全部用新的地址替换。任何DLL被重定位时,你都会收到这样的消息:LDR: DLL example.dll base 0x10000000 relocated due to collisions with C:\project\bogus.dll”(LDR:基址为0x10000000的动态链接库example.dll由于和C:\project\bogus.dll相冲突,需要进行重定位),你可以在输出窗口的Debug标签里看到这条消息(Windows NT4.0和98显示该消息,但2000不显示)。如果想进一步学习DLL的重定位对性能的影响,可以参看Ruediger Asche的MSDN文章“Rebasing Win32 DLLs: The Whole Story”。
地址空间冲突使得依赖地址来识别模块变得很困难。
DLL的重定位不仅增加了程序装载时间,而且使得依赖地址来识别模块变得很困难。装载时,Windows会选择新的基址来重定位DLL。由于不同的系统版本以及不同设置的系统会选择不同的基址,这使得产生的基址与系统相关。一般来说,没有简单的办法能找到重定位DLL的基址。
有两种方法可以保证你所用到的任何DLL都有不同的地址空间。第一种方法是使用Visual C++中的重定位工具(在Vc\Bin下),在编连(build)完成后使用该工具。举例来说,假设你的程序包括MyApp.exe、MyDll1.dll、MyDll2.dll和MyDll3.dll。首先,在你的工程中新建一个文本文件,内容如下、
MyApp.exe
MyDll1.dll
MyDll2.dll
MyDll3.dll
然后,使用下述的重定位命令为调试版本重定位dll。
Rebase -b 400000 -F C:\Projects\ProjcctRoot\Debug -G Rebase.txt
你也可以在工程设置对话框里进行这样的设置,选择Post_build Step标签上的Post_build Command选项。如果你使用的是Windows 2000(因此就不会接收到DLL重定位消息了),你应该总是假设冲突存在,并且使用重定位工具以防止基址冲突。
另一个方法是显式地为你的每个DLL设置一个唯一的基址。你可以在工程设置对话框里改变这些值,在Link标签的Ouput类中对Base address进行设置,为了避免冲突,注意,应该避免使用DLL默认的基址0x10000000。
选取基址的具体方法视你的系统环境而定。对于使用了很多DLL的较复杂的程序,使用重定位工具是一个比较好的选择,另一方面,对于较简单的程序,选取唯一的基址并不是一件困难的事情。介于0x20000000和0x50000000之间的虚拟地址空问总是可以让用户自由使用,所以,如果你有很多的DLL,你可以选取以下的一串基址:0x20000000、0x21000000和0x22000000等等。如果手工选取的基址仍会有冲突,你最好还是使用重定位工具。
在重定位自己的DLL时,你可以确信你是相当自由的,但是,一般来说,不要重定位第三方的DLL,而且永远不要重定位Windows的系统DLL。这会导致与其他部分不一致。但我认为,只要你能够保证这些DLL不会被其他程序使用,你还是可以重定位第三方的DLL的。重定位其他程序使用的DLL显然不是一件好事情。只要你将第三方的DLL装载到自己程序的目录下,并且不在Windows或者System目录下(与微软的Windows徽标设计要求一致),就不会有什么问题。如果实在有必要跟踪一个错误,你也可以暂时性地将第三方的DLL重定位。
永远不要重定位其他程序会使用的第三方DLL。
一旦你已经正确将程序用到的DLL重定位了,你还有一个工作要做,使用Visual C++的绑定(Bind)工具(在Common\Tools目录下)处理你的程序。绑定工具会处理对引入函数的引用。使用绑定工具绝对不会产生任何副作用,因为如果某个DLL被错误的绑定了(或者因为现有的DLL和被绑定的DLL版本不致,或者因为DLL在装载时又被重定位了),这个DLL被装载时,就会和没有被绑定过一样处理。尽管执行重定位和绑定对你的调试不会有任何帮助,但它会使程序装载快很多。如果想对这个方面作进一步的了解,可以参看Matt Pietrek的《Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools》。
如果你使用重定位,那么你也应该使用绑定工具。
6.6 汇编语言基础知识
使用Dr. Watson和Windows 98崩溃对话框进行事后调试,你需要知道一些汇编语言的基础知识。这里,我的目标并不是将你训练成一个汇编语言的编程者,而只是让你掌握足够的Intel x86汇编代码的知识,能很从容地查看Dr. Watson日志文件。Matt Pietrek的《Under the Hood》的两个专栏(1998年2月;1998年6月)针对调试对汇编语言的实质做了很好的介绍,但本书中,我仅会针对调试简单错误介绍少数的汇编知识。如果掌握了更高级的汇编语言技巧,你就可以调试更复杂的错误了,例如由编译器生成的错误代码。
为了使事情变得简单,本书中的汇编代码例子没有进行优化处理。尽管优化后的代码和没有优化的代码在功能上是相同的,但优化后的代码非常难理解,就好像是经过搅拌机处理过一样。也就是说,所有的基本行为都还有,但它们组织在一起的方式和你从源程序所想到的方式完全不同。在调试发行版本时要记住这一点。
调试优化后的汇编代码比没有优化的汇编代码难得多。
汇编指令与寻址方式
为了将读者引进汇编语言的大门,首先看看各种汇编语言的一般组成部分,以及如何将它们组织起来。第一个组成部分是汇编代码指令,这是CPU的指令集。X86处理器使用的汇编指令格式如下所示:
instruction [换作数1], [操作数2]
这里,操作数1和操作数2是指令所使用的数据,操作数1是指令的目标操作数。结果是,如果有一个操作数会被指令修改,那么,它往往就是操作数1。操作数可以是常数、CPU寄存器以及内存单元,到底使用哪种数据决定于寻址方式。下面是一些常用的寻址方式,以move指令为例(使用通用寄存器EAX和EBX)。
move eax, 42
move eax, dword ptr[00420000]
move eax, ebx
move eax, dword ptr[ebx]
move eax, dword ptr[ebx + 42]
move dword ptr[eax], ebx
move dword ptr[eax + 42], ebx
在这些例子中,dword ptr指的是执行一个双字的指针,等价于C++里的*(DWORD*)。一个值得注意的寻址约定是,将数直接从一个内存单元移到另一内存单元,在x86的微处理器中是不允许的。使用move时,至少要使用一个寄存器,或者作为源寄存器,或者作为目标寄存器。另一个值得注意的是,所有的数值都是十六进制的,即使它们没有“0x”前缀和“h”后缀。
常用指令与CPU寄存器
表6.4列举了最常用的汇编指令,以及它们的含义。表6.5列举了最常用的CPU寄存器,并对它们的使用作了一个简单的介绍。
表6.4 常用汇编指令
指令 | 含义 |
mov | 将右操作数复制到左操作数 |
lea | 装入有效地址。用来得到局部变最和函数参数的指针 |
push | 将操作数压到堆栈的栈顶 |
pop | 将堆栈的栈顶数弹出到操作数中 |
pushad | 将所有的通用寄存器压到堆栈的栈顶 |
popad | 将堆栈的栈顶数弹出到所有的通用寄存器中 |
call | 调用一个函数。以寄存器和偏移量(例如call [eax + 32])来调用函数,和C++里的虚函数调用很类似 |
ret | 从一个函数返回。__stdcall调用规范要求如果有返回值,就要将返回值从堆找的栈顶弹出 |
leave | 是move ESP、EBP/pop EBP的简写,用来退出函数 |
add | 算术加 |
sub | 算术减 |
inc | 递增操作 |
dec | 递减操作 |
mul | 无符号整数乘法。操作数必须是寄存器或内存单元 |
imul | 有符号整数乘法 |
div | 无符号整数除法。操作数必须是寄存器或内存单元 |
idiv | 有符号整数除法。操作数必须是寄存器或内存单元 |
and | 逻辑与 |
or | 逻辑或 |
not | 逻辑非,使用反码非。与将所有的位单独取反效果相同 |
neg | 非操作,使用补码非。与乘以-1效果相同 |
xor | 异或。一个寄存器和它自身进行异或通常被用来将该寄存器置为0 |
cmp | 比较操作数(使用减法),并置上标志寄存器的相应位。操作数不会被修改 |
test | 比较操作数的位(使用逻辑与),并置上标志寄存器的相应位。操作数不会被修改 |
jmp | 无备件跳转 |
je | 如果相等则跳转 |
jne | 如果不等则跳转 |
loop | 返回到循环的入口,视具体条件而定 |
nop | 空操作,用来填充 |
int | 引发中断。3号中断调用调试器,它经常被当作不会被调用的指令填充到程序中,从而成为一个特殊的空操作 |
表6.5常用CPU寄存器
寄存器 | 用法 |
EAX | 通用寄存器。记录函数返回值 |
EBX | 通用寄存器 |
ECX | 通用寄存器,记录指向对象的this指钋 |
EDX | 通用寄存器,记录64位函数返回值的高端字 |
ESI | 内存移动和比较操作的源地址寄存器 |
EDI | 内存移动和比较操作的口标地址寄存器 |
EIP | 指令指针(当前执行代码的位置) |
ESP | 栈指针(当前栈顶的位置) |
EBP | 栈基址指针(当前栈顶帧的基址> |
EFLAGS | 记录比较、算术操作的标志位;有时简记为EFL或EFLGS |
除了这些寄存器,还有CS(代码段)、SS(堆栈段)、DS(数据段)、ES(附加段)、FS(另一附加段)、GS(另一附加段);但除了FS寄存器外,其他段寄存器在32位Windows调试中不会有什么用。FS寄存器用来指向线程信息块(TIB),这在以后的章节中会介绍。
线程堆栈
你也许己经注意到能使用的CPU寄存器真是出人意料的少。堆栈用来暂时存储当前没有被处理的数据,保存函数返回地址、堆栈基址、函数参数以及自动(局部)变量。堆栈是以后进先出的方式工作的,所以所有的操作都是在堆栈顶进行。堆栈与众不同的是,在内存中它是向下增长的,所以堆栈顶的地址比堆栈底的地址要小。进程中的每个线程都付它自己的堆栈。
ESP和EBP寄存器是堆找专用的。堆栈基址指针(EBP)寄存器确定堆栈帧的起始位置,而堆栈指针(ESP)寄存器执行当前堆栈顶。在一个函数被调用之前,函数参数和函数返回地址(当前的EIP)被压到堆栈顶。所有的参数在传递时被提升到32位,但函数的返回值不是通过堆栈传递的,它被放在EAX寄存器中。在函数入口处,当前堆栈基址指针被压到堆栈中,并且当前堆栈指针称为新的堆栈基址指针。局部变量的存储空间、函数使用的各种需要保存的寄存器的存储空间在函数入口处也被预留出来。典型的函数入口处的堆栈帧结构如图6.8所示。
图6.8线程的堆栈帧
堆栈基址指针与帧指针省略
堆栈基址指针的值在整个函数中是常数,所以它常被用来寻找堆栈里的内容。既然堆栈在内存中是向下增长的,函数参数相对堆栈基址的偏移量是正值,而局部变量相对堆栈基址的偏移量是负值。
使事情变得更复杂的是,尽管我刚才介绍的对于调试版本是正确的,但对于发行版本却不一定正确,因为堆栈基址指针也许己经被优化掉了。这种优化类型通常称为帧指针省略(FPO)。说“也许”会发生,是因为这种优化要求打开/Oy、/Ox、/O1或者/O2编译器选项,而且只有Visual C++的专家版本和企业版本才会对此有支持。使用了FPO的优化器将堆栈基址寄存器用作具有多种用途的寄存器,并且在函数调用时也不用将EBP寄存器压入堆栈、推出堆栈。这种优化也意味着所有的参数和局部变量相对堆栈指针的偏移量都是正值,而且当堆栈顶变化时,堆栈中的特定参数或局部变量的偏移量也会变化。在汇编代码优化中,性能是真正关心的,可读性没有任何价谊。
优化版本通常不将EBP作为堆栈基址指针使用。这种优化类型称为帧指针省略(FPO)。
FPO优化导致了一个有趣的错误,调试版本可以正确运行,但发行版本却有可能崩溃。当函数的调用者和函数本身的函数原型不匹配时,这个错误会发生。例如,假设调用者将四个参数压入堆栈顶,但函数本身却只是弹出了三个。在调试版本中,这不会有任何影响,因为堆栈基址指针正确地记录了堆栈帧的基址,因此函数在返回调用者时可以得到正确的返回地址。而在使用了FPO的发行版本中,函数返回时堆栈指针不再指向堆栈帧,从而导致程序使用了错误的函数返回地址。为了防止这类错误的发生,一定要在头文件中声明所有的函数原型,而不是仅仅在.cpp文件中声明,如果你使用的是MFC,注意,用户自定义的ON_MESSAGE宏的消息句柄的签名是
afx_msg LRESULT OnMyMessage(WPARAM wParam, LPARAM lParam);
如果你使用了错误的签名,你的程序在发行版本下就会崩溃,但你不会接收到任何编译警告和链接警告。如果你怀疑是优化的问题,你应该做的第一件事就是仔细检査你的所有函数原型。对于调试版本,你可以使用/GZ编译选项,它会在函数末尾检查堆栈指针,以确信它没有被修改。对于发行版本,你可以显式地使用/Oy-编译选顼将FPO关掉,看看问题是否可以解决。
因为优化版本可能不使用堆栈基址指针,如果函数原型声明不一致,就很可能会在函数返回时导致崩溃。
函数调用规范
参数压入堆栈、退出堆栈的方式是由函数调用规范决定的。表6.6列举了Windows程序常用的调用规范。
表6.6常用调用规范
调用规范 | 描述 |
__cdecl | 这是C/C++程序的默认调用规范。参数从右到左传递,由调用函数负责将参数从堆栈中移走。这利于传递个数可变的参数 |
__stdcall | 这是Windows API函数使用的调用规范。参数从右到左传递,由被调用函数负责将参数从堆栈中移走。由该规范产生的代码比__cdecl更小,但当函数有可变的参数个数时,仍会使用__cdecl规范。WINAPI、CALLBACK以及APIENTRY宏都被定义为__stdcall规范 |
thiscall (非关键字) | C++成员函数的默认调用规范,不使用个数可变的参数。除了this指针是保存在ECX寄存器里,而不是保存在堆栈里外,其他的都和__stdcall相同。注意,COM成员函数使用__stdcall规范 |
使用了错误的调用规范会导致难以调试的错误,所以,花一定的时间弄懂如何识别不同的调用规范是很值得的。类型安全的链接会阻止绝大多数的函数原型不匹配,但不匹配总是存在的,例如函数类型强制转换、通用函数指针(PVOID、FARPROC或者GetProcAddress API)或者用extem "C"声明的函数等存在时。在源代码一级,你需要查找调用规范关键字或者与它功能相等的宏定义。
考虑下面的类的例子。
class CMangledNames {
public:
CMangledNames() { m_Value = 0; }
int Norma(int param) { m_Value += param; return m_Value; }
int __cdecl Cdecl() { m_Value += param; return m_Value; }
int __stdcall Stdcall() { m_Value += param; return m_Value; }
private:
int m_Value;
};
在这个类里,Normal函数使用了thiscall调用规范,而其他函数则使用了指定的调用规范。
你可以通过查看混合后的符号确定调用规范:
?Normal@CMangledNames@@QAEHH@Z
?Cdecl@CMangledNames@@QAAHH@Z
?Stdcall@CMangledNames@@QAGHH@Z
这里,如果混合后缀的第三个字符是“E”,则表示thiscall,如果是“A”,则表示__cdecl,如果是“G”,则表示__stdcall。如果你对C++的名字混合很熟悉,你应该知道使用__stdcall混合的Windows API函数名通常是_GetMessageA@16。你也许认为__stdcall符号看上去应该是_Stdcall@CManagledNames@4,实际上这是不正确的。这个符号格式仅仅用作简化了的C符号,例如声明为extern "C"的函数名。
在汇编代码一级,thiscall将ECX寄存器分配给this指针,并且由被调用函数在函数返回时负责将参数弹出堆栈。这是调用Normal函数的汇编代码:
// example.Normal(0x42):
push 42 ;push parameter
lea ecx,[ebp-14h] ;set ECX to this pointer
call CMangleNames::Normal(004032b0)
这是从Normal返回的汇编代码:
ret 4 ; pop one parameter off stack
尽管Normall有两个参数(param和this),但只有一个参数通过堆栈传递。
__cdecl调用规范要求调用者将this指针和其他参数压入堆栈顶,并且在函数返回时将参数和this指针从堆栈顶弹出,这是调用Cdecl函数的汇编代码:
// example.Cdecl(0x42):
push 42 ;push parameter
lea eax,[ebp-14h] ;obtain this pointer
push eax ; push this pointer
call CMangleNames::Cdecl(004032f0)
add esp, 8 ; pop two parameters off stack
注意,紧跟在call指令之后,通过在ESP寄存器上加上参数的大小将参数弹出堆栈顶。这是从Cdecl返回的汇编代码:
ret ;just return
_stdcall调用规范将this指针和其他参数压入堆栈项,并且由被调用函数在函数返回时负责将参数和this指针弹出堆栈顶。这是调用Stdcall函数的汇编代码:
// example.Stdcall(0x42):
push 42 ; push parameter
lea ecx,[ebp-14h] ; obtain this pointer
push ecx ; push this pointer
call CMangleNames::Stdcall(00403330)
这是从Stdcall函数返回的汇编代码:
ret 8 ; pop two parameters off stack
变量访问
变量可以通过不同的方式被访问,这取决于它们的作用域。这里是一些例子。
add dword ptr[0040c842], 42 ; add 0x42 to a global or static variable
add dword ptr[ebp-10], 42 ; add 0x42 to a local variable
add dword ptr[ebp+10], 42 ; add 0x42 to a parameter
在这些例子(假设没有FPO优化)中,通过实际地址直接访问全局变量或者静态变量,通过堆栈指针加上一个负的偏移量访问局部变量,通过堆栈指针加上一个正的偏移量访问参数。
要访问一个结构里的数据成员,只要将指向结构的指针移到一个寄存器里,就可以访问数据成员了。例如,下面的代码将POINT结构指针的x和y成员作了初始化,并以局部变量的形式存放指针:
mov esi, dword ptr[ebp-8] ; move Point structure pointer to ESI
mov dword ptr[esi], 0 ; set X member to 0
mov dword ptr[esi+4], 0 ; set Y member to 0
读取十六进制转储信息
字节存到内存中的方式有两种。第一个方式称为Big Endian,首先存储高字节,所以0x12345678被存为0x12 0x34 0x56 0x78。另一种方式称为Little Endian,首先存储低字节,所以0x12345678被存为0x78 0x56 0x34 0x12。Intel处理器使用Little Endian。无论何时你观察反汇编代码里的十六进制数,由于反汇编器总是将字节以正确的顺序组织在一起,所以在反汇编代码里你总可以看到0x12345678。但是,当你在读取一个字节流时,例如以字节显示的十六进制转储信息,你必须重新组织字节顺序以得到正确的数值。
使用反汇编窗口
现在,你已经掌握了足以开展工作的汇编语言知识。我过去对汇编语言掌握得非常好,但那已经是很久以前的事了,好像是进入了另一个时代似的。尽管我并不是一个汇编高手,但我发现通过查看调试器反汇编窗口可以很容易地推断出事情进行得怎么样了,因为它允许我查看源代码如何被转换到汇编代码。注意,即使你在做事后调试,你也可以使用反汇编窗口。只要它不是优化后的代码,通常都非常简单易懂。
使用调试反汇编窗口查看你的源代码如何被转换成汇编代码。
6.7使用映射文件调试
在进行事后调试时,要找到正确的解决办法,你需要一个映射文件。准确地说,你需要一个对应于程序创建的所有模块的映射文件。映射文件包含对应模块的最佳装载地址、段表、输出符号地址、静态符号地址以及程序代码地址和源程序行号的映射。
映射文件记录的信息比较原始,读懂它需要花费一定的精力,但相对其他类型的调试文件(例如PDB文件)来说,它有两个非常重要的优势:它是可读性很好的文本文件;它不依赖于任何版本的Visual C++。
映射文件的创建与归档
为你的程序创建映射文件,你必须对Visual C++工程中的相应选项作适当的设置,第一步是打开映射文件生成功能。你可以在工程设置对话框里进行设置,在Link标签里的Debug类里打开Generate mapfile选项。由于一些奇怪的原因,Visual C++没有把输出程序代码地址和源代码行号的映射作为默认设置。要得到这些信息,还需要在Project Options对话框里键入“/MAPINFO:LINES”。得到导出序号(export ordinal)也许很有用,所以在Project Options对话框末尾里也要键入“/MAPINFO:EXPORTS”。
对于给其他人使用的程序,你很可能要进行事后调试。这意味着你应该培养出为发布程序的所有模块创建和归档映射文件的习惯。我想,为一个早期版本的程序重新创建映射文件是可能的,但那要求你重建程序时使用同样的源文件、同样版本的编译器、同样的工程设置。相对来说,将映射文件归档会容易得多。
查看映射文件
查看映射文件其实是一件非常简单的事。第一步,当然是要找到与你感兴趣的模块匹配的映射文件,并用某个文本编辑器打开它。顶端是模块名和模块的创建时间。如果列出的创建时间和模块的文件创建时间不匹配,那么有可能映射文件和模块不匹配,接下来的是,检查最佳装载基地址,这是映射文件会假定的装载模块的虚地址。如果你不将你的程序重定位,并且模块产生了虚地址空间冲突,所有的虚地址(准确的说是Rva+Base值)必须要调整成使用实际基地址。
接下来的一步是找到与崩溃地址匹配得最好的函数。公共函数在静态函数的前面列举,所以要找到匹配函数,两个列表你都需要检査。这两个函数列表中,第三列包含Rva+Base的值(例如,0x00401044),这是相对虚地址(例如,0x00000044),加上假定的基地址(例如,0x00400000),再加上PE文件头大小(例如,0x00001000)。匹配得最好的函数在崩溃地址上,或者在更低的地址上。
最后一步是找到匹配得最好的源文件行号。映射文件按照相对虚地址列举了行号,所以你不得不做反向工作。即,从崩溃地址开始(例如,0x00401044),减去实际基地址(例如,0x00400000)和PE文件头(例如,0x00001000),使用剩下的值(例如,0x00000044)。现在检査行号RVA,直到你找到了最好的匹配,它恰好是RVA或者更低,
一个例子
让我们先看看一个特殊的例子吧。为了演示事后调试,我创建了一个名为KillerApp的程序,它有一个会因为种种常见错误导致崩溃的DLL。KillerApp的主函数很快会调用RandomCrash函数,这是一个静态函数,有一些很容易辨认的参数。然后,RandomCrash调用RandomException,第一个参数是随机数,还有一些其他很容易辨认的参数。RandomException函数在KillerDLL.cpp里。随着它的第一个参数的不同,RandomException会以非法内存访问、堆栈溢出或者整数被0除等异常崩溃。Switch语句里的第四种情况比较特殊的是,崩溃发生在Windows自身内部。
#include "stdafx.h"
#include <stdlib.h>
#include <time.h>
#include ".\KillerDLL\KillerDLL.h"
static void WINAPI RandomCrash(int arg1, int arg2, int arg3, int arg4) {
int i= rand();
RandomException(i, 0xDEADEEEF, 0xBADBAD00, 0xDECAF000);
}
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPCTSTR lpCmdLine, int nCmdShow) {
srand(time(0)); // seed random number generator whith current time
RandomCrash(0xAAAAAAAA, 0xBBBBBBBB, 0xCCCCCCCC, 0xDDDDDDDD);
return 0;
}
接下来是KillerDLL.cpp的RandomException函数。
#include "stdafx.h"
#include <Limits.h>
#include "KillerDLL.h"
BOOL APIENTRY dllMain(HANDLE hModule, DWORD reason, LPVOID lpReserved) {
return TRUE;
}
void WINAPI StackGlutton(int arg1, int arg2, int arg3, int arg4) {
char bigArrag[10000000];
}
void KILLERAPI WINAPI RandomException(int arg1, int arg2, int arg3, int arg4) {
int i= 15, j = 0xEEEEEEEE;
switch(arg1 % 6) {
case 0://STATUS_ACCESS_VIOLATION
j = *(int*)i;
break;
case 1://STATUS_STACK_OVERFLOW
StackGlutton(arg1, arg2, arg3, arg4);
break;
case 2://STATUS_SINTEGER_DIVIDE_BY_ZERO
j = 0;
i /= j;
break;
case 3: { // crash in Windows
CHOOSECOLOR cc;
ZeroMemory(&cc, sizeof(cc)); // will crash Win98 if you don't zero
cc.lStructSize = sizeof(cc);
ChooseColor(&cc); // crashes since lpCustColors isn't set
}
break;
case 4: { // crash in structure access
CHOOSECOLOR cc, *pcc = &cc;
COLORREF rgb;
ZeroMemory(&cc, sizeof(cc));
rgb = pcc->lpCustColors[0]; // crash since lpCustColors is invalid
}
break;
case 5: { // crash in structure access
CHOOSECOLOR cc, *pcc = 0;
COLORREF rgb;
ZeroMemory(&cc, sizeof(cc));
rgb = pcc->lpCustColors[0]; // crash since pcc is invalid
}
break;
}
}
下面是KillerApp.exe的映射文件。
KillerApp
Timestamp is 384855c7 (Sat Jan 01 12:00:00 2000)
Preferred load address is 00400000
Start Length Name Class
0001:00000000 0000c2acH .text CODE
0002:00000000 0000c0e8H .idata$5 DATA
0002:000000e8 00001047H .rdata DATA
0002:00001130 00000028H .idata$2 DATA
0002:00001158 00000014H .idata$3 DATA
0002:0000116c 0000c0e8H .idata$4 DATA
0002:00001254 00000412H .idata$6 DATA
0002:00001666 00000000H .edata DATA
0003:00000030 000009f0H .data DATA
0003:00000a20 00001640H .bss DATA
Address Publics by Value Rva+Base
Lib:Object
0001:00000000 _WinMain@16 00401000 f
KillerApp.obj
0001:000000b6 ?RandomException@@YCXHEHH@Z 004010b6 f
KillerDll:KillerDLL.dll
entry point at 0001:000002d0
Static symbols
0001:00000060 ?RandomCrash@@YCXHHHH@Z 00401060 f
KillerApp.obj
Line numbers for .\Debug\KillerApp.obj
(C:\PROJECTS\KillerApp\KillerApp.cpp) segment .text
12 0001:00000000 13 0001:00000018 14 0001:0000002b
15 0001:00000044 16 0001:00000046 06 0001:00000060
07 0001:00000078 08 0001:00000080 09 0001:000000a2
下面是KillerDLL.dll的映射文件。
KillerDLL
Timestamp is 38484c3d (Sat Jan 01 12:00:00 2000)
Preferred load address is 10000000
Start Length Name Class
0001:00000000 00011c5cH .text CODE
0002:00000000 0000012cH .idata$5 DATA
0002:00000130 000018d9H .rdata DATA
0002:00001a0c 00000028H .idata$2 DATA
0002:00001a34 00000014H .idata$3 DATA
0002:00001a48 0000012cH .idata$4 DATA
0002:00001b74 0000053aH .idata$6 DATA
0002:000020b0 0000005cH .edata DATA
0003:00000030 00001480H .data DATA
0003:000014b0 00001734H .bss DATA
Address Publics by Value Rva+Base
Lib:Object
0001:00000000 _DllMain@12 10001000 f
KillerDLL.obj
0001:00000030 ?StackGlutton@@YGXHHHH@Z 10001030 f
KillerDLL.obj
0001:00000060 ?RandomException@@YGXHHHH@Z 10001060 f
KillerDLL.obj
0001:00000182 _ChooseColorA@4 10001182 f
comdlg32:comdlg32.dll
Line numbers for .\Debug\KillerDLL.obj
(C:\PROJECTS\KillerApp\KillerDLL.cpp) segment .text
05 0001:00000000 06 0001:00000018 07 0001:0000001d
09 0001:00000030 11 0001:00000052 13 0001:00000060
14 0001:0000007e 15 0001:0000008c 17 0001:000000b7
18 0001:000000bf 20 0001:000000c4 21 0001:000000d9
24 0001:000000db 25 0001:000000e2 26 0001:000000ec
29 0001:000000ee 30 0001:000000fe 31 0001:00000105
34 0001:0000010e 36 0001:00000114 37 0001:00000124
40 0001:0000012f 42 0001:00000136 43 0001:00000146
46 0001:00000154
Exports
ordinal name
1 ?RandormException@@YGXHHHH@Z (void __stdcall
RandomException(int, int, int, int))
为了节省版面的空间,我将段、符号、以及C运行时刻函数库的行号信息略去了。运行时刻函数库的行号信息没有什么用,除非你有源代码。另一方面,这些信息能帮你确定是哪个运行时刻函数库函数崩溃了,从而可以从反汇编代码中找出到底出了什么问题,
现在让我们假设KillerDLL.dll在虚地址0x100010BF崩溃了。检查KillerDLL.MAP,你可以看到最佳装载基地址是0x10000000。因为KillerApp仅使用了一个DLL,你不用担心会产生虚地址冲突,最佳装载基地址和它是一致的。这意味着你可以不作任何调整使用映射文件的虚地址。要找到匹配得最好的函数,通过崩溃地址可以找到Rva+基地址上的或更低地址上的公共函数或静态函数。我们可以找到RandomException函数。为了找到匹配得最好的源代码行号,首先得到崩溃地址(0x100010BF),减去基地址(0x10000000)和PE头大小(0x00001000),得到0x00000BF的RVA值。在行号段査找该地址,我们得到第18行。
在査找行号时,记住,编译后的代码可能以一种和源代码完全不同的顺序组织在一起。在列出的行号里,RVA值是逐渐增长的,但源代码行号可能经常跳来跳去。在这个例子中,KillerDLL.cpp的行号是有顺序的,但KillerApp.cpp的行号没有顺序。
6.8使用PDB文件调试
为了作个比较,我们用Visual C++进行事后调试,使用调试器找到源代码中的崩溃地址。这个方法要求你有相应的源文件和崩溃程序的PDB文件。此外,你还必须使用和PDB文件格式版本相容的Visual C++,这一点你不能总假设它理所当然地成立。如果崩溃地址所在的模块己经在虚存中作了重定位,这个技术可能并不能解决问题。你可以执行以下的步骤来找到崩溃地址所在的源代码文件和行号。
1.将程序工程装载进Visual C++;
2.在调试器里执行你的程序。使用Debug菜单上的Step Into命令或者F11快捷键;
3.显示反汇编窗口;
4.在Edit菜单中选择Go To命令。在Go To对话框的Go to what框里选择Address选项;
5.在Enter address expression对话框里输入导致崩溃的地址,如果反汇编窗口不是活动窗口,这个对话框是不可用的。确定地址以0x开始;
6.在反汇编窗口里找到崩溃地址,单击Go TO按钮:
7.要从反汇编窗口里找到源代码的位置,在上下文菜单里Go To Source命令。
显然,Visual C++使得寻找崩溃位置比使用映射文件简单得多,所以你应该尽量使用
你可以直接在Visual C++里使用PDB文件进行事后调试。
6.9使用Windows 98崩溃对话框调试
现在,基本知识已经不是问题了,让我们静下心来开始做应该做的事情吧。使用Windows 98崩溃对话框调试仅仅需要你将这章中到目前为止涉及到的所有知识结合到一起。让我们从Wintbws98崩溃对话框中得到的以下崩溃转储信息继续KillerApp的调试,
首先査看程序的名字、崩溃的模块以及异常的类型。你要知道的是,你调试的是你自己的程序,不是其他的什么东西。例如,你程序里的一个错误可能会导致Windows浏览器的崩溃,但你根本不用去调试Windows浏览器。崩溃的模块能告诉你应该在哪个映射文件中查找崩溃地址,在这个例子中,你应该在KillerDLL.map查看崩溃地址。异常类型能告诉你应该查找哪类错误。
接下来的是,确定源代码文件和导致崩溃的行号。使用前面阅读映射文件的方法,你能确定地址0x10001189是KillerDLL.cpp的第43行,以下是导致错误的代码:
rgb = pcc->lpCustColors[0];
现在你可以确定到底是什么问题了。假设内存非法访问异常发生了,你可以推断出或者pcc或者lpCustColors指向了非法内存。那么,到底是哪个呢?你有足够的信息做出这个判断。让我们在Visual C++调试器里在KillerDLL.cpp的第43行设上一个断点,启动程序,然后在反汇编窗口中查看反汇编代码,并使用Code Annotation和Code Bytes选项。
43: rgb = pcc->lpCustColors[0];
10001186 8B 55 80 mov edx, dword ptr[pcc]
10001189 8B 42 10 mov eax, dword ptr[edx+10h]
1000118C 8B 08 mov ecx, dword ptr[eax]
1000118E 89 8D 7C FF FF FF mov dword ptr[rgb], ecx
査看汇编代码能知道,0x10001186处的指令将pcc移到EDX寄存器。然而,崩溃堆显示EDX为0。在0x10001189处的指令接着从EDX偏移0x10个字节寻址,并将内存单元内容移到EAX寄存器。崩溃发生了!地址0x00000010是不可访问内存。显然,pcc是非法的,因为它是一个空指针。具体的崩溃地址(0x10001189)、EDX的内容以及CS:ElP处的字节转储信息(显示导致崩溃的特定代码)一起推导得出了这个结论。现在你要做的便是解决pcc为什么是非法的问题了,而且针对这个问题你己经调试成功了。
联合使用具体的崩溃地址、寄存器的内容以及反汇编代码来推导导致崩溃的原因。
尽管这个例子中没有用到另外一个崩溃转储信息,我们还是快速地回颐一下在其他情况下这些信息有些什么用途。从EAX到EDX、ESI以及EDI等多用途寄存器是绝大部分程序操作发生的地方,所以导致崩溃的指令很有可能是在处理其中的一个寄存器。而且EAX被用来保存函数返回值,ECX经常用来记录C++的this指针。检查EFLGS的值来判断数学操作的结果有时是有用的。你可以放心的跳过CS、SS、DS和ES段寄存器,因为在32位Windows里,它们并不重要。
ESP寄存器在堆栈里,EBP记录的是函数的基址指针(假设没有作FPO优化)。这些地址值能帮你解释堆栈转储信息(stack dump)内容的具体含义。函数在入口处会马上将EBP寄存器压入堆栈顶,所以在堆栈转储信息中寻找这些堆栈基址指针可以帮助你确定堆栈帧以及函数返回地址。回顾一下调用函数时数据被压入堆栈的顺序:函数参数,返回地址,EBP寄存器,局部变量,以及保存了的寄存器。在这个特殊的崩溃中,我们对堆栈转储信息没有什么兴趣。堆栈转储信息仅仅显示了EDl、ESI、EBX寄存器的保存值,还有许多没有被初始化的局部变量。注意,未被初始化的局部变量被置为0xCCCCCCCC,因为这是打开/GZ编译选项的一个调试版本。如果/GZ编译选项没有被打开,这些值将是乱七八槽的。记住在堆栈里存在没有被初始化的变量并不代表有错误发生。在这个例子中,没有被初始化的数据是在没有被执行到的switch语句中定义的CHOOSECOLOR结构变量。
使用EBP寄存器的值确定堆栈转储信息里的堆栈帧。
下面让我们把事情变得更有趣些。假设你的程序是在你的代码外某个地方崩溃的。如果程序在MFC里崩溃了,你非常幸运,因为,Visual C++为所有的MFC动态链接库都创建了映射文件(在Visual C++的光盘里的\VC\Debug目录下可以找到)。如果程序在一个C运行时刻函数库函数中崩溃了,你还是非常幸运,因为映射文件包含了所有的公共函数地址。对于KillerApp,0x00404030的崩溃地址说明程序是在strcpy里崩溃的。可是调用的到底是哪一个strcpy呢?如果程序在Windows里崩溃了又怎么办呢?你回到自己的程序的唯一希望便是查看堆栈的跟踪。既然在函数被调用之前,返回地址就己经压入了堆栈顶,那么堆栈中就可能有返回你的代码的地址。对于KillerApp,可执行主函数的地址范围是0x0040OO00----0x00429FFF,KiIIerDLL的地址范围是0x10000000----0x10031FFFF。在堆栈里,如果你在看起来像EBP的内存后面发现了上述地址范围内的值(实际上,如果你是从左往右读,它在EBP的前面),那么它可能就是返回到你程序代码的返回地址。然后你就可以在映射文件里查找调用函数的源文件信息了。如果你想进一步了解虚地址值的解析,可以参看第9章的“查看Windows内存地址”。
如果崩溃不是发生在你的代码内部,可以通过堆栈转储信息得到返回到你代码的地址。
如果程序试图执行一个随机地址上的指令,那又怎么办呢?例如,假设程序在一个非常低的地址上崩溃了,例如0x00000008,或者是一个很奇怪的地址,例如0x73677562,显然那里没有任何合法的指令。下面是导致试图执行非法代码的一些原因。
•代码试图用一个非法函数指针调用函数。
•在调用者和实际函数之间存在原型不匹配。
•堆栈已经被破坏了,通常是局部变量的溢出导致函数返回地址被破坏掉了。
在前两种情况中,既然函数调用已经失败了,被调用函数也就没有机会将堆栈基址指针、局部变量以及保存的寄存器压入堆栈。这意味着函数返回地址刚好就在堆栈顶,这使得很容易退回到你自己的源代码。在最后那种情况下,返回地址本身被覆盖的数据修改了,可能会给你一点关于问题的提示。例如,如果指针地址看起来像四个ANSI字符(例如地址0x73677562,代表ANSI的“bugs”),这就可能是被一个局部字符串给覆盖了,例如下面的代码。
void StackAttack() {
TCHAR bugsText[16], * bugs = _T("This function has bugs!");
_tcscpy(bugsText, bugs);
}
在这个例子中,bugs字符串长为23个字节(如果使用ANSI文本),对于bugsText来说太长了,所以字符串拷贝函数就用文本把返回地址给覆盖了。
对出现在Windows里的崩溃地址的理解非常重要,这肯定是你程序的某个错误导致的,而并不是Wifldows的错误。很可能是你将一个错误的参数或一些已被破坏的数据传给了Windows API函数。相反,如果产生崩溃的地址在设备的驱动程序里,这就很可能是驱动程序自身出于问题,或者是硬件没有正确配置。原因是Windows程序很少直接和设备驱动程序打交道,而是通过Windows间接地和驱动程序交互。如果你向Windows传递错误的数据从而导致崩溃,Windows自身就会发生崩溃,并不是驱动程序。
在Windows里发生崩溃几乎可以肯定是你代码中的错误导致的,然而,驱动程序中的崩溃极有可能是驱动程序自身的错误。
在这些情况下真要进行事后调试,你需要的是Dr. Watson日志文件。
6.10使用Dr. Watson调试
进行事后调试时,Dr. Watson才是真正实用的工具。只要有一个没有崩溃的堆栈、一个Dr. Watson日志文件(如果是Windows 2000还要有User.dmp文件),你就可以得到从错误跟踪到出错源程序的任何信息。它给了你一个非常详细的堆栈跟踪、对应于每个堆栈帧的汇编代码以及系统状态的一个概要记录。如第1章讨论的,你需要给检测器发出指令,来设置Dr. Watson把日志文件粘贴到bug报告上,以确保能够得到调试崩溃必需的所有信息。
调试符号
如果你使用的是Windows 2000或Windows NT,要真正使用Dr. Watson日志文件,你(以及你的测试者)首先要确信已经安装了系统调试符号(DBG文件)。有了调试符号, Dr. Watson就可以在堆栈跟踪中给出系统函数名。注意,Windows 98没有系统调试符号,但Visual C++提供了MFC和C运行时刻动态链接库的符号。要安装Windows 2000的调试符号,使用客户诊断支持光盘,当你把光盘插进光驱时,它会自动启动安装程序。要为Windows NT4.0安装调试符号,在Visual C++程序组中运行Windows NT符号安装程序。这两个安装程序都会将系统DBG文件从光盘上拷到\WinNT\Symbols\Dlls目录。
一定要为Windows 2000和Windows NT安装系统调试符号。
唯一的技巧是符号文件要恰恰与已安装了的可执行文件匹配,Dr. Watson才会使用它们。在你安装了服务组件后,很有可能就会出现不匹配。因此,每当你安装了服务组件后,你就应该拷贝服务组件光盘上的\Support\Debug子目录下的文件来更新系统符号。如果你对符号的状态不确定,从Visual C++的调试器运行程序。每当没有找到匹配的符号时,在输出窗口的Debug标签上就会有“Loaded 'EXAMPLE.DLL', no matching symbol information found”(EXAMPLE.DLL已装载,但没找到匹配的符号信息)。
每当你安装了一个服务组件时,记住要更新系统符号。
Windows 98与Windows 2000的Dr. Watson
Windows 98与Windows 2000的Dr. Watson的实现不一样,尽管Dr. Watson日志文件里的信息基本相同,信息的表示方式却很不一样。首先,我将通过一个例子说明如何使用Windows 98版的Dr. Watson,然后介绍Windows 2000的版本有哪些不一样。
在Windows 98里使用Dr. Watson
要得到日志文件,Dr. Watson必须处于正在运行的状态,这在Windows 98里并不是设置。要想在Windows 98里让Dr. Watson自动启动,可以在你的启动组里加入Dr. Watson可执行文件(Windows\Dr. Watson.exe)的快捷方式。
要査看Dr. Watson日志文件,首先你必须在Windows浏览器里双击WLG文件(在\Windows\Drwatson里可以找到),将文件装载入Dr. Watson,与前面讲的不一样的是,Windows 98里的Dr. Watson日志文件是二进制的,不能在文术编辑器里查看。默认情况下,Dr. Watson只显小Diagnoise标签,它并不是特别有用。要査看如图6.9所示的信息,在View菜单里选择Advanced View选项。尽管大多数标签显示的是系统崩溃时的信息,Modules标签显示的是程序的可执行文件名、版本号、描述、路径、文件创建日期以及虚地址空间范围。要调试错误,最有用的信息在Details标签里,它显示了寄存器的值、异常记录以及完全的堆栈跟踪。不幸的是,你不能直接将任何信息拷贝到剪贴版里,所以,如果你希望摘录文字,你必须在File菜单里使用Save As命令将日志文件保存为文本文件。然后,你就可以在一个文本编辑器里查看文本日志文件了。
图6.9 Windows 98里的Dr. Watson用户界面
下面让我们开始调试KillerApp在Windows里的崩溃,在我们学习Windows 98的Dr. Watson日志文件以及对它的信息进行解释之前,我们不可能进行这样的调试。下面是KillerApp的Dr. Watson的日志文件,我己经修改了很多,删除了一些冗余信息。
Summary/Overview(概述/综述)部分记录了崩溃的原因、崩溃的模块、该模块的版本、对该模块的描述、崩溃所在的程序以及用户的评语。System Information(系统信息)部分给出Windows和IE的版本号、硬件资源与空闲资源的一个汇总。Details(细节)部分首先给出了命令行、硬件异常号(这是CPU定义的,不是Windows)、寄存器值、导致异常的程序指令、堆栈基址与线程信息块(TIB)的地址范围。TIB包含了潜在的有用信息,例如异常记录、结构化的异常处理程序链(SEH)、堆栈顶与堆栈基址、线程局部存储(TLS)数组。要想进一步了解TIB,可以参看Matt Pietrek的《Under the Hood》(1996年5月)。接下来的信息是异常记录。它给出了Windows异常代码、异常发生时的代码地址以及崩溃地址上的汇编指令。它还给出了崩溃地址的RVA,在本例中,值是0x00003530。
现在是最有意思的部分了。Details部分的其他信息给出了堆栈的概要信息以及详细的堆栈跟踪。注意,堆栈跟踪里的堆栈帧个数是由Dr. Watson设置对话框里的Number of Stack的设置决定的。第一个堆栈帧显示了崩溃出现的地方,并且崩溃的位置用一个星号标出。下面的每个堆栈帧也用一个星号标出了崩溃出现的位置,你必须对这些星号很小心。这些星号确定了堆栈里的返回地址,所以导致崩溃的指令实际上在被标号的指令的前面。
第一个堆栈帧用星号标出了崩溃位置,但其他的堆栈帧标出了紧跟在导致崩溃的指令后的那条指令。
要进行事后调试,第一步是査看日志文件里的Summary/Overview(概述/综述)信息,看看发生了什么事情,找找可能发生意外的任何事物。例如,你应该检查所有的版本信息,以确认你确实是在调试你所想的东西,如果是Windows 98崩溃对话框,检查程序的名字,确认你正在调试你自己的程序,而不是其他的什么。
下一步便是查看堆栈跟踪了。假设,在这个例子中,崩溃没有出现在你的代码中,那么向后跟踪直到你找到了你程序的第一个堆栈帧。因为我已经对Dr. Watson日志文件作了很多修改,在这里很容易做到,但在实际真正的日志文件里,崩溃发生在14个堆栈帧后。下面是导致崩溃的程序代码。
017f:100010fe c745d424000000 mov dword ptr[ebp-2c], 00000024
017f:10001105 8d4dd4 lea ecx, [ebp-2c]
017f:10001108 51 push ecx
017f:10001109 e874000000 call 10001182 = COMDLG32.DLL::ChooseColorA
KILLERDLL.DLL:.text,0x10e:
*017f:1000110e 8d55b0 lea cdx,[ebp-50]
还要注意,星号标注的是返回地址处的指令,所以导致崩溃的指令调用了Comdlg32.dll里的ChooseColor API函数。在映射文件里査找源程序的行号,你能确定源代码的行号是KillerDLL.dll里的31行,它是下面代码的最后一行。
CHOOSECOLOR cc;
ZeroMemory(&cc, sizeof(cc)); // will crash Win98 if you don't zero
cc.lStructSize = sizeof(cc);
ChooseColor(&cc); // crashes since lpCustColors isn't set
(现在,不要超过我的进度——你要假装你还不知道为什么程序会崩溃)正如我前面提到的,Windows里的崩溃说明程序向一个API函数传递了错误的参数。既然ChooseColor只有一个参数,接下来的一歩便是在堆栈顶里査找对应的堆栈帧,检査实际的传递值。
0064fc04 0064fcb0 -> 24 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
从对应着该堆栈帧的堆栈转储信息的顶部可以看到,栈顶的地址是0x0064FC04,内存值是0x0064FCB0。既然0x0064FCB0是该进程的一个合法地址,Dr. Watson会比较宽容。它会认为这个值是一个指针,并将该地址开始的16个字节打印出来。从这些数据堆里,你会看到第一个字节是0x24,其余的字节是0,这表明它是一个未被初始化的参数。
现在,错误已经被很好地解决了。如果错误不是这么明显,那么,接下来的一步是返回到堆栈顶,尽量确定参数到底出了什么问题。下面是堆栈顶的代码。
017f:7fe1452d 884010 mov eax, dword ptr[eax + 10]
COMDLG32.DLL::text+0x3530
017f:7fe14530 8b10 mov edx, dword ptr[eax]
在代码中,EAX寄存器中的地址被偏移了16个(0x10)字节后得到新的指针,新的指针然后被移到EAX寄存器。然后代码试图引用EAX寄存器指向的内存单元,并将值移到EDX,但是这条指令崩溃了。从日志文件给出的寄存器值可以看到,EAX值为0,所以程序是因为试图引用一个空的指针而导致了崩溃。注意,分析出来的结果与日志文件开始说明的错误是吻合的。因为代码是在Windows里面的,所以你不能很确信到底发生了什么。然而,如果你假设崩溃的程序直接使用了传递给ChooseColor的错误参数,看看CHOOSECOLOR结构还是有点意义的。
重要的是,lpCustColors数据成员在这个结构中的偏移量是16个字节,它是一个空指针,并且这就是问题所在。当然,结构的其他数据成员也没有被初始化,但lpCustColors是一个空指针就是导致本次崩溃的原因。
这并不是很糟糕,不是吗?
在Windows 2000中使用Dr. Watson
在Windows 2000中,默认条件下Dr. Watson是在运行的。但你首先必须确定即时(JIT)调试器被设置为不能使用Dr. Watson (通常情况下这并不是你所想做的,因为在Visual C++中调试正在运行的程序总比用Dr. Watson调试已经死掉了的程序好得多)。你可以通过以下方法禁止即时调试器使用Dr. Watson。在Visual C++的Tools菜单里执行Options命令,选择Debug标签,清除Just_in_time Debugging选项。在这些完成后,一旦你的程序崩溃了,你将会看到如图6.10所示的Windows 2000崩溃对话框。Windows 2000里的Dr. Watson有好几个选项,你可以从Windows浏览器运行Dr. Watson(\WinNT\System32\Drwtsn32.exe)来修改它们。很可能你会希望选择图6.11所示的选项。清除DumpSymbol Table选项和Append To Existing Log File选项可以防止日志文件变得太大,选择Visual Notification选项可以在程序崩溃时,弹出前面提到的崩溃对话框。选择Create Crash Dump FiIe选项会创建一个User.dmp文件,这个会在这章的后面详述。
图6.10 Dr. Watson打开后。Windows 2000的崩溃消息对话框
图6.11 Windows 2000里的Dr. Watson选项对话框
要查有Dr. Watson日志文件,双击Drwtsn32.log文件(\Documents and Settings\All Users\Documents\DrWatson目录下可以找到),用记事本来查看日志文件。下面列出的是Windows 2000中对应于KillerApp在Windows里崩溃的Dr. Watson日志文件。
可以看到,Windows 2000的Dr. Watson日志文件是Windows 98的Dr. Watson和Windows 98崩溃对话框的一个混合物。这意味着你可以联合使用前面介绍的技术来分析日志文件。日志文件给出了崩溃的程序、异常号、一些常用的系统信息、堆栈列表、程序模块的虚地址空间(如果你已经装了系统调试符号,这些将有注释)、寄存器值、崩溃发生时附近的反汇编代码、堆栈跟踪以及堆栈转储信息的内容。
在Windows 2000的Dr. Watson日志文件中最值得注意的是没有给出的那些东西。具体地说,日志文件只是给出了崩溃的程序名,而不是特定的模块名,这使得只要有任何程序模块被重定位了,确定导致崩溃的特定模块将是一件很困难的事情。更糟糕的是,日志文件只给出了堆栈跟踪最顶部的反汇编代码,这使得在本例中向后追踪到导致错误的程序源代码是不可能的事。
Windows 2000的Dr. Watson为了弥补日志文件的这些不足,同时还创建了User.dmp文件。它记录了崩溃发生时导致错误的虚地址空间、线程的状态以及寄存器值。你可以使用WinDbg系统调试器和User.dmp进行事后调试,査看堆栈跟踪、寄存器值以及内存内容等信息。
6.11各种技巧
下面的章节介绍了各种各样的技巧,这些技巧能帮助你在Windows里更好地进行调试。
在微软系统信息工具里使用Windows 98的Dr. Watson日志文件
你可以在微软系统信息工具里装载Windows 98的Dr. Watson日志文件。运行系统信息工具,在文件菜单里单击打开,选择Dr. Watson日志文件(*.WLG)文件类型,然后选择要打开的文件。注意,Windows 98的Dr. Watson日志文件是二进制的,所以你不能在文本编辑器里查看它们。
尽管我偏向于Windows 98的Dr. Watson用户界面,但是,有一些事情你可以使用系统信息工具办到,而Dr. Watson却不能。例如,你可以用系统信息工具选择特定的一项或几项并将它们拷贝到剪贴板里,但你不能从Dr. Watson的用户界面进行拷贝。你可以让这两者都导出到文本文件。虽然Dr. Watson导出的文本文件包含了所有的与崩溃有关的信息,但是系统信息工具做得更好一些,它导出了所有的模块信息,并以表格的形式显示这些信息。相反,Dr. Watson仅仅导出被选择的模块域(模块名、版本、作者以及对模块的描述),每个一行。
转换混合符号
映射文件里列出的所有公共符号都是使用的混合名字。你可以使用Visual C++的名字解析(Undname)工具将混合名字转换到原始名字。例如,给出下面的命令行
Undname ?RandomException@@YGXHHHH@Z
它将输出
>> ?RandomException@@YGXHHHH@Z == RandomException
结果很让人吃惊,但你还可以使用-f选项显示出整个函数原型,例如,给出下面的命令行
Undname -f ?RandomException@@YGXHHHH@Z
它将输出
>> ?RandomException@@YGXHHHH@Z == void __stdcall RandomException(int, int, int, int)
在査看映射文件时,如果出现重载(OverIoad)函数,名字解析工具是很有用的。
使用依赖关系浏览工具
在调试Windows程序时,通过査看PE文件得到的一些信息是很有用的,例如可执行文件依赖于哪些DLL、引出函数和引出数据、混合函数名、引出函数顺序、文件路径、文件版本以及基地址。Visual C++的依赖关系浏览工具(Common\Tools\Depends.exe)能给出这些信息,并且还能给出其他更多的信息。
微软已经发布了依赖关系浏览工具的版本2.0--www.dependencywaIker.com。这个版本有很多高级特性,包括一个系统信息对话框、一个决定显示混合函数名还是显示函数原型的选项,它还能发现动态装载的模块和动态调用的函数。模块列表视图显示了一些新的有用信息,例如实际使用的虚基地址空间(如果你忘记了将程序重定位)、模块的实际大小,调试符号类型(PDB、COFF、DBG或者没有)以及模块装载顺序。
使用MFC和ATLDEF文件
谈到引出函数顺序,你应该知道任何版本的MFC和ATL的DEF文件都在Vc\Mfc\Src\Intel和Vc\Atl\Src目录下。DEF文件给出了函数名和顺序间的映射关系。例如,如果用户运行自己程序时,收到“The ordinal 6880 Could not be located in the dynamic link Library MFC42.DLL”(6880号无法在动态链接库MFC42.dll里定位)的错误信息,査看Mfc42.def里对应6880号的函数,你会看到对应的是CWnd::ScreenToClient函数。因为许多MFC函数都被重载了,你可以使用Visual C++的名字解析工具将混合名字转换到函数原型。
调试Windows 2000的死亡蓝屏
如果你使用过Windows 2000,你也许己经见识到了声名狼藉的死亡蓝屏(BSOD)。在死亡蓝屏发生时,屏幕会切换到到文本模式,并伴随着蓝色的背景和一个非常不友好的十六进制转储信息。这时,你唯一可做的事是重启Windows并希望它不要再崩溃了。你应该明白是什么导致了屏幕会这样,并且应该知道你可以做些什么,如果有什么可以做的话。
Windows 2000程序运行在两个不同的模式下:核心模式(对于Intel处理器就是优先级为0的程序),驱动程序和操作系统的一部分就是处在这个模式下;用户模式(对于Intel处理器就是优先级为3的程序〉,应用程序和操作系统的另一部分就是处在这个模式下,在这两个模式下,异常的处理非常相似。首先,异常被抛出,然后,异常将被处理。如果异常没有被处理,抛出异常的进程必须被终止。在用户模式下,未被处理的异常会导致的结果在本章里已经介绍了。不幸的是,核心模式下,异常所对应的被终止进程就是操作系统自身,所以操作系统必须将自己终止,以防止进一步的崩溃。系统必须以一种不需要任何系统服务的方式显示这些信息,所以使用对话框是不可能的。这使得文本模式是最好的选择。微软之所以选用蓝色背景,是因为蓝色是一种比较让人平静的颜色(只是开个玩笑)。
除非你的程序直接与驱动程序打交道,或者你的程序包含了驱动程序,死亡蓝屏不大可能是你程序的错误。如果是你的程序导致了崩溃。调试死亡蓝屏和你调试Windows 98崩溃对话框的方法非常类似。蓝屏包含了导致崩溃的模块名、崩溃地址、未被处理的异常、正在运行的驱动程序列表以及堆栈转储信息。如果想进一步了解如何解决蓝屏问题,可以参看“Microsoft Windows 2000 Server Operations Guide”。
如果你遇到了死亡蓝屏,并且想将这些信息保存下来,你不必将它们写下来。你可以运行系统面板,选择高级标签,单击重启和故障恢复,或者选择小内存转储(64KB)、或者选择全部内存转储,这样,就可以将蓝屏上的信息保存到Minidump或Memory.dmp文件里了。在你因为死亡蓝屏重启机器后,你就会得到一个转储文件。一般借况下,你应该选择小内存转储选项,全部内存转储开销太大,因为从字面上可以看出它们是整个内存的转储(尽管它们被压缩得很好)。另外,你还可以使用System Internals(www.sysinteminternals.com)的蓝屏保存(BlueSave)工具仅仅将蓝色屏幕上的信息保存下来。
6.12推荐阅读
Asche, Ruediger "Rebasing Win32DLLs: The Whole Story", MSDN, September 18, 1995.
对在建立时将DLL重定位而不是在装载时重定位的性能优势作了详细的分析,还讲述了提高DLL装载性能的一些技巧。
......
Pietrek, Matt. "Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools"。Microsoft System Journal, October 1996.
介绍了如何使用Visual C++的重定位工具去避免地址空间冲突,以及如何使用Visual C++的连接工具来解析对引入函数的调用,如果将这两少加入到你的建立过程,将大大减少可执行文件的装载时间,而且使用重定位还能帮助你调试DLL。
Pietrek, Matt. "Under the Hoods"。Microsoft System Journal, June 1998.
介绍了“Matt's Just Enough Assembly to Get By, Part II”。重点点介绍了几条汇编指令,并针对程序因为非法的指令指针地址而导致崩溃的错误,给出了非常好的关于调试的建议。特别地,它还给出了关于调试堆栈溢出的很好的忠告。
......