| ||||
| 显然Visual C++的调试程序是知道这个对应关系的,否则它怎么显示正在执行到源码的什么地方?C++编译器在生成目标代码的时候同时还会生成很多调试信息,这些调试信息是包含在OBJ文件中,然后由连接程序把这些调试信息整合起来成为一个调试文件,最典型的就是PDB文件。在这个文件中包含了和程序相关的所有信息,包括源代码和目标代码的对应行号、类型、变量、函数等等。那太好了,有了这个文件我们就可以知道那个该死的地址对应于什么地方了。 但是,等一下,这个文件是 Microsoft 的专有文件格式,我们对它是如何组织的无从得知,这该如何是好呢?先不要着急,Microsoft也不是这么绝情,它提供了一个动态链接库叫做 DBGHELP.DLL,通过这个库我们就可以访问PDB文件了。然而这个库使用起来并不简单,需要编写一个程序,我们现在是火烧眉毛,来不及做这件事情了,容后续再说。 连接程序另外还会生成一个MAP文件,增加下面的命令行参数可以让LINK产生这个文件: LINK /MAP:filename.map /MAPINFO:LINES … 这个命令行参数告诉LINK,产生一个名字为filename.map的文件,并且这个文件包含行号信息。这两个参数必须在连接你的程序时指定。有了这个文件以后我们可以来查找源文件行号和地址的对应关系了。注意,使用这个选项需要配合 /INCREMENTAL:NO 选项使用。 下面是一个简单的例子,假设有下面这个简单的C++程序: 代码:
Test.exe – Application Error The instruction at “0x00401028” referenced memory at “0x00000000”. The memory could not be “written”. 这里报告了一个错误,地址是0x00401028。接下来让我们来看看MAP文件。文件很大,我们只看其中的一部分。 代码:
代码:
代码:
代码:
代码:
【待续,本文属 Innocentius 原创,转载请注明出处】 |
| ||||
| 代码:
这个公共符号表是按照地址顺序排列的,第一列是Address,是这个符号所在的地址,以 段号:偏移 地址的形式表示,段号根据前面段表确定,偏移地址表示在这个段中的位置。这意味着如果我们要知道这个符号的确切地址,则需要知道段的首地址,然后加上偏移地址,段的首地址根据段表确定。 第二列是 Publics by Value,是公共符号的名称,也就是我们一般意义上的变量名、常量名以及函数名。这里需要注意的是,在这里列出来的名字是经过修饰的名字,例如我们写的func()函数实际上的名字是?func@@YAXXZ,main函数的实际名字是_main。关于这一点我们会在后面再详细讨论的。 第三列是Rva+Base,表示对象的实际地址。对于Visual C++ 6.0以后的LINK会在MAP文件里面列出这个字段,但是较早的版本以及其它软件开发商,比如Borland的连接程序则没有列出这个字段,因此我们需要知道一下这个字段是怎么得到的。RVA是“相对虚拟地址”,前面已经说过,EXE文件就是程序的内存映像,它和在内存中程序的保存形式是完全一样的,因此在程序中的所有使用地址的地方都应该确定下来。然而由于EXE和DLL可能被装入到内存的任意地方,在编译时不会知道最终的地址是什么,因此只能将程序中所有使用地址的地方用一个相对于这个EXE文件的头部的形式表示,这个地址形式称为RVA。实际地址则是由RVA加上装入EXE或DLL文件时的基地址得到的(为了提高装入程序的性能,实际上LINK会把它希望的实际地址保存在EXE和DLL文件中,也就是把RVA加上前面所提到的默认装入地址(Preferred load address),如果实际的装入地址和默认装入地址相同,那么装入程序就可以省去一次重定位的过程,使得装入速度有所提高,这对于EXE来说通常都是可行的,然而对于DLL一般来说做不到。然而你可以在LINK的时候指定DLL的默认装入地址,这样可以提高DLL的装入速度,也可以使用REBASE实用程序改变一个现有DLL的默认装入地址)。 我们来看一下main函数。main函数的公共名字是_main,它所在的地址是0001:00000040,它所在的段是0001,从前面的段表可以查到它是第一个段,起始地址是0x00000000,而我们前面提到过,第一个段的起始地址实际上距离EXE文件的头部是0x1000,因此这个段的实际开始地址是0x1000,加上段内偏移地址0x40,那么可以得到_main的RVA是0x1040,再加上这个模块的默认装入地址0x400000,那么可以得到结果是0x401040,也就是第三列看到的Rva+Base的值。 第四列Lib:Object是这个符号所在的OBJ文件,我们知道OBJ文件和CPP文件基本上是一一对应的,因此通过这个信息可以知道对应的CPP文件是什么。 代码:
好了,有了上面的知识,我们再来看地址0x00401028表示什么信息。 这个地址是一个绝对内存地址,而行号信息中只有段偏移地址,我们需要做一个转换才能完成这件事情。首先把0x00401028减去基地址0x00400000,得到RVA0x1028,然后减去EXE文件头的0x1000,得到0x28,然而我们查段表,发现0001段从0x00000000开始,长度是0xd886,因此0x28肯定就包含在0001段内,这样我们就可以得到绝对地址0x00401028的段偏移地址是0001:00000028。然后我们搜索公共符号表,发现?func@@YAXXZ函数的段地址从0001:00000000到0001:00000040,那么0001:00000028就包含在这个地址范围内,因此我们可以确定,这个错误地址是属于func函数的。进一步的,我们查行号表,看到在test.cpp文件中,第4行的地址是0001:00000025,第5行的地址是0001:0000002e,那么这说明0001:00000028地址应该位于由test.cpp的第4行源代码生成的机器指令之中(我们应该知道一行C++程序通常会生成好几条机器指令,因此错误地址很可能没有和行号对准)。 一般来说到这一步我们就能知道问题出现在什么地方了,如果需要更加详细的信息,那么我们可以继续看C++编译器生成的汇编语言文件。下面是这个文件的片断: 代码:
在这里我们可以找到地址0x0028的指令是 mov DWORD PTR [eax], 0,这条指令表示把数值0写入由eax寄存器所保存的地址中去。而eax寄存器保存的地址在前一条指令中赋值:mov eax, DWORD PTR _p$[ebp],这里ebp是当前函数的栈帧基址寄存器,_p$被定义为-8,表示变量p在堆栈上的相对位置,再前面一条指令是mov DWORD PTR _p$[ebp], 0,表示把0赋值到变量p里面去。这样这三条指令完成了这样一个操作序列:把0赋值给p,把p赋值给eax,把0写入到eax所指定的内存,这里eax就是0,因此实际执行的结果就是把数值0写入地址0。我们知道Win98/2000/XP的进程地址空间中,把从地址0开始的64K(Win98是32K)作为不可写/不可读的内存页保护起来了,所有在这个地址空间中进行的读写操作都会引起操作系统结构化异常,而且如果这个异常没有被处理,则会被Windows捕获,然后就显示了这样一个错误信息。 至此,我们算是彻底把这个错误找到了根源。然而这还不是全部…… 【待续,本文属 Innocentius 原创,转载请注明出处】
__________________ |
| ||||
| 这个步骤有些复杂,如果难得查一次,或许你有这个兴趣,如果需要经常查这些信息,你就会很郁闷,因为其中涉及到很多数据和计算。大家知道计算机科学的发展源于人的惰性,因此我们为了让我们更加舒服一些就需要做进一步的考虑。 如果发生这种关键性错误时我们能够捕获这个错误并且让我们的程序自己来显示出现在什么地方,那有多好呢? 要实现这个技术,我们需要解决下面这些问题: 1、怎么来捕获这个错误? 2、捕获错误以后怎么通过程序来完成上述的动作? 3、获得这些信息以后如何把它记录下来? 这三个问题实际上就覆盖了一个很大范围的知识。让我们来一个个解决。 1、 怎么来捕获这个错误? 从机制上讲,这个错误是一个未处理SEH,也就是所谓的结构化异常。我们知道C++等语言支持异常处理,但是这些异常仅仅限制在这种语言的范围内(.NET 的异常覆盖整个 CLR,但是对我们来说范围还是不够广)。而SEH 是整个操作系统范围内的异常处理,它包括硬件和软件异常两种情况。我们在C++中可以通过下面的语句形式来捕获SEH的异常: 代码:
幸运的是操作系统提供了一个函数:SetUnhandledExceptionFilter,通过这个函数可以给进程安装一个未处理异常过滤器——这个名字是和所谓__except部分的异常过滤器对应的——当一个进程中任何一个线程出现异常并且没有被处理时都会调用这个异常过滤器。这是一个好机会,作为一个异常过滤器,它可以得到很多有用的信息。让我们来看一下能得到些什么: 下面是这个函数的原型: 代码:
代码:
代码:
代码:
第二个字段是ExceptionFlags,如果它是0,那么表示这个异常是可以恢复的,如果是EXCEPTION_NONCONTINUABLE那么表明这个异常是不能恢复的(在C++中,一旦抛出一个异常那么它就不可能再回到抛出异常的地方继续执行,而SEH则可以)。 第三个字段是前一个未处理异常结构。和C++异常不同,SEH异常可以嵌套抛出,可以在异常处理过程中继续抛出异常。从这里可以看到多个异常被组织成了一个链表,因此你在异常过滤器中可以追踪到底发生了多少异常。 第四个字段表明了发生异常的地址,就是Windows显示给你看到的那个地址。 第五个字段表明,对于这个异常,有多少个附加的参数。参数是保存在第六个字段中的。到目前为止,只有EXCEPTION_ACCESS_VIOLATION异常会包含2个参数,第一个参数,也就是 ExceptionInformation[0] 表示对内存读写状态。如果是因为读内存造成异常的,那么这个参数是0,如果是因为写内存造成异常的,那么这个参数就是1。第二个参数是访问违例的内存地址。 上面提到的这个错误实际上就是这个异常结构中内容的一种可视形式,现在我们可以重新回头来看一下造成这个错误的异常信息: ExceptionCode: EXCEPTION_ACCESS_VIOLATION ExceptionFlags: 0 ExceptionRecord: NULL ExceptionAddress: 0x00401028 NumberParameters: 2 ExceptionInformation[0]: 1 ExceptionInformation[1]: 0x00000000 上下文环境记录是用于保存发生异常时的CPU状态,它是在Windows SDK中唯一一个和硬件相关的数据结构,目前我们不需要用它,但是后面会用到。 我们所要做的第一 步 现在已经清楚了,我们只需要安装一个全局的未处理异常过滤器,在这个过滤器中就能得到出现异常的详细信息。下一步就是要把这些信息转换成更加容易理解的形式。 【注意】需要特别注意的一个问题是,如果当前处于调试程序的控制下,例如由Visual C++调试你的程序时,这个全局未处理异常过滤器是不会被调用的,有些资料上说这是一个BUG,然而我觉得不是。不管怎么样这个目前是事实。可以通过调用IsDebuggerPresent函数来判断。这个函数在Windows 95下不存在,因此需要在包含Windows.h前加上下面语句: 代码:
|