现在的位置: 首页 > 综合 > 正文

通过地址获取对应的源代码信息

2013年10月13日 ⁄ 综合 ⁄ 共 22554字 ⁄ 字号 评论关闭

 转自http://www.cpper.com/innocentius/comments/crash_dump_info/

 

 

你写了一个程序,很开心地把它发布给用户。用户满心欢喜地运行它,突然Windows弹出了一个熟悉的窗口:

Application Error:
The instruction at “0x00411a28” referenced memory at “0x12345678”. The memory could not be “written”.
Click on OK to terminate the program.
Click CANCEL to debug program.

顿时用户的心中升起对你的无比仇恨,然而你就会收到用户愤怒的电话,并且知道了Windows的这个几乎没用的信息。

在这个信息中,你知道出现了一个内存访问错误,而且Windows也告诉你了这个错误发生在0x00411a28这个地方……但是,但是,等一下,即使你精通C++,即使你精通汇编语言,即使你精通计算机原理,你还是不知道这个0x00411a28表示什么,不是吗?在客户的计算机上没有调试器,即使有调试器也没有调试信息,即使有调试信息你也不可能到客户的机器上去调试,这该怎么办呢?

这是一种很常见并且很尴尬的情况。那怎么办呢?我们的第一反应通常就是在我们的开发机上设法重现这个错误,然而实际情况并不是很理想,因为客户机的软硬件环境和开发机的软硬件环境可能有很大的差别,客户机的运行数据和开发机的数据也有很大差别,这些都导致了错误很难重现。

为了避免这种情况,你需要学会事后调试,进一步的,如果能够让你的程序自己能够提供一些调试所必须的信息则更好。

让我们首先来看一下我们实际上需要的是什么:

Windows告诉了你一个地址:0x00411a28,你最想知道的是这个地址对应的是哪一句源代码,然后想知道在这个时刻的计算机运行的上下文信息,包括当时的堆栈、变量以及寄存器的值。让我们一个一个来解决:

第一个是源代码,我们希望能够知道的是问题发生在哪一个源码文件的第几行,退而求其次的是希望知道问题发生在哪个函数里。这就需要有一个源代码和目标代码对应的关系表,但是谁知道这个关系表呢?显然,对这件事情最清楚的就是编译程序了,毕竟是它把源代码翻译成机器指令的。

显然Visual C++的调试程序是知道这个对应关系的,否则它怎么显示正在执行到源码的什么地方?C++编译器在生成目标代码的时候同时还会生成很多调试信息,这些调试信息是包含在OBJ文件中,然后由连接程序把这些调试信息整合起来成为一个调试文件,最典型的就是PDB文件。在这个文件中包含了和程序相关的所有信息,包括源代码和目标代码的对应行号、类型、变量、函数等等。那太好了,有了这个文件我们就可以知道那个该死的地址对应于什么地方了。

但是,等一下,这个文件是 Microsoft 的专有文件格式,我们对它是如何组织的无从得知,这该如何是好呢?先不要着急,Microsoft也不是这么绝情,它提供了一个动态链接库叫做 DBGHELP.DLL,通过这个库我们就可以访问PDB文件了。然而这个库使用起来并不简单,需要编写一个程序,我们现在是火烧眉毛,来不及做这件事情了,容后续再说。

连接程序另外还会生成一个MAP文件,增加下面的命令行参数可以让LINK产生这个文件:

LINK /MAP:filename.map /MAPINFO:LINES …

这个命令行参数告诉LINK,产生一个名字为filename.map的文件,并且这个文件包含行号信息。这两个参数必须在连接你的程序时指定。有了这个文件以后我们可以来查找源文件行号和地址的对应关系了。注意,使用这个选项需要配合 /INCREMENTAL:NO 选项使用。

下面是一个简单的例子,假设有下面这个简单的C++程序:

1 void func()
2 {
3 int *p=0;
4 *p=0;
5 }
6
7 int main()
8 {
9 func();
10 return 0;
11 }
12

显然,在第4行应该出现一个内存访问错误,运行这个程序,出现了下面这个信息:

Test.exe – Application Error
The instruction at “0x00401028” referenced memory at “0x00000000”. The memory could not be “written”.

这里报告了一个错误,地址是0x00401028。接下来让我们来看看MAP文件。文件很大,我们只看其中的一部分。

test

Timestamp is 42257ef9 (Wed Mar 02 16:53:13 2005)

Preferred load address is 00400000

Start Length Name Class
0001:00000000 0000d886H .text CODE
0002:00000000 000000fcH .idata$5 DATA
0002:00000100 00001f6bH .rdata DATA
0002:0000206c 00000040H .rdata$debug DATA
...

Address Publics by Value Rva+Base Lib:Object

0000:00000000 ___safe_se_handler_table 00000000
0000:00000000 __except_list 00000000
0000:00000000 ___safe_se_handler_count 00000000
0001:00000000 ?func@@YAXXZ 00401000 f test.obj
0001:00000040 _main 00401040 f test.obj
0001:00000080 __RTC_InitBase 00401080 f LIBCD:init.obj
0001:000000b0 __RTC_Shutdown 004010b0 f LIBCD:init.obj
0001:000000d0 __RTC_CheckEsp 004010d0 f LIBCD:stack.obj
...
Line numbers for ./debug/test.obj(d:/projects/private/test/test.cpp) segment .text

2 0001:00000000 3 0001:0000001e 4 0001:00000025 5 0001:0000002e
8 0001:00000040 9 0001:0000005e 10 0001:00000063 11 0001:00000065

一个MAP文件被分成这么几个部分:

test

这表示这个MAP文件的模块名称,虽然这里看不出什么用途,但是这一点实际上很重要,我们后面就会看到。

Timestamp is 42257ef9 (Wed Mar 02 16:53:13 2005)

这是文件的时间戳,这个时间戳并不是文件日期,而是保存在 EXE 文件内部的一个时间戳,通过这个时间戳可以用于确定MAP文件和EXE是对应的。

Preferred load address is 00400000

这是最佳载入地址,对于EXE来说通常都是 0x00400000,但是对于DLL来说可能实际的载入地址是不同的。这个基地址对于后面的计算很重要。

Start Length Name Class
0001:00000000 0000d886H .text CODE
0002:00000000 000000fcH .idata$5 DATA
0002:00000100 00001f6bH .rdata DATA
0002:0000206c 00000040H .rdata$debug DATA

这里是段表,我们目前关心的是 .text 段。这一段中包含了程序的实际代码。上面的数据表示,第一个段就是.text段(0001段),它的起始地址是0x00000000,长度是 0xd886。按照x86的规定,一个段地址乘上0x10才是实际地址,因此实际地址是从0到0xd8860。由于Windows的PE文件就是内存映像,而PE文件有0x1000字节的头部,因此第一段的起始地址是在PE文件的0x1000处。如果PE文件被装入到0x400000地址处,那么第一段的实际地址应该在0x401000处。

Address Publics by Value Rva+Base Lib:Object

0000:00000000 ___safe_se_handler_table 00000000
0000:00000000 __except_list 00000000
0000:00000000 ___safe_se_handler_count 00000000
0001:00000000 ?func@@YAXXZ 00401000 f test.obj
0001:00000040 _main 00401040 f test.obj
0001:00000080 __RTC_InitBase 00401080 f LIBCD:init.obj
0001:000000b0 __RTC_Shutdown 004010b0 f LIBCD:init.obj
0001:000000d0 __RTC_CheckEsp 004010d0 f LIBCD:stack.obj

这是公共符号表,在这个表中将列出所有公共符号的地址和名称,所谓公共符号就是在汇编语言中声明为PUBLIC的符号,也就是在其它汇编文件中可以通过 EXTERN得到的符号名称。对于C++来说,如果一个全局变量、常量和函数没有被声明为static,那么它就自动声明为公共符号。

这个公共符号表是按照地址顺序排列的,第一列是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文件是什么。

Line numbers for ./debug/test.obj(d:/projects/private/test/test.cpp) segment .text

2 0001:00000000 3 0001:0000001e 4 0001:00000025 5 0001:0000002e
8 0001:00000040 9 0001:0000005e 10 0001:00000063 11 0001:00000065

最后一部分是行号信息。第一句话表示源文件名,以及这个文件中哪个段的行号信息是包含在下面的列表中的。上面的例子可以看到文件名是 ./debug/test.obj 和 d:/projects/private/test/test.cpp,段是 .text。如果一个文件有好几个段,那么它可能被分布在不同的行号信息列表中。接下来的部分就是行号信息,第一个数字是行号,第二个地址是对应的段地址。这里就表示第8行对应于地址0001:00000040,就是刚才我们看到的main函数的地址,也就是源代码中main函数的开始地方。

好了,有了上面的知识,我们再来看地址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++编译器生成的汇编语言文件。下面是这个文件的片断:

_TEXT SEGMENT
_p$ = -8 ; size = 4
?func@@YAXXZ PROC NEAR ; func, COMDAT

; 2 : {

00000 55 push ebp
00001 8b ec mov ebp, esp
00003 81 ec cc 00 00
00 sub esp, 204 ; 000000ccH
00009 53 push ebx
0000a 56 push esi
0000b 57 push edi
0000c 8d bd 34 ff ff
ff lea edi, DWORD PTR [ebp-204]
00012 b9 33 00 00 00 mov ecx, 51 ; 00000033H
00017 b8 cc cc cc cc mov eax, -858993460 ; ccccccccH
0001c f3 ab rep stosd

; 3 : int *p=0;

0001e c7 45 f8 00 00
00 00 mov DWORD PTR _p$[ebp], 0

; 4 : *p=0;

00025 8b 45 f8 mov eax, DWORD PTR _p$[ebp]
00028 c7 00 00 00 00
00 mov DWORD PTR [eax], 0

; 5 : }

0002e 5f pop edi
0002f 5e pop esi
00030 5b pop ebx
00031 8b e5 mov esp, ebp
00033 5d pop ebp
00034 c3 ret 0
?func@@YAXXZ ENDP ; func
_TEXT ENDS

在这个文件中我们可以找到具体错误是发生在哪一条指令中的。需要注意的是C++生成的汇编语言文件中都以函数开始作为偏移基准,因此还需要把一个段偏移地址转换为相对于函数开始的偏移地址,方法是在公共符号表中找到这个函数,然后把段偏移地址减去这个函数的开始段偏移地址就可以了。在我们的这个例子中函数的段偏移地址是0001:00000000,因此函数内的偏移地址和它的段偏移地址是一样的。

在这里我们可以找到地址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捕获,然后就显示了这样一个错误信息。

至此,我们算是彻底把这个错误找到了根源。然而这还不是全部……

这个步骤有些复杂,如果难得查一次,或许你有这个兴趣,如果需要经常查这些信息,你就会很郁闷,因为其中涉及到很多数据和计算。大家知道计算机科学的发展源于人的惰性,因此我们为了让我们更加舒服一些就需要做进一步的考虑。

如果发生这种关键性错误时我们能够捕获这个错误并且让我们的程序自己来显示出现在什么地方,那有多好呢?

要实现这个技术,我们需要解决下面这些问题:

1、怎么来捕获这个错误?
2、捕获错误以后怎么通过程序来完成上述的动作?
3、获得这些信息以后如何把它记录下来?

这三个问题实际上就覆盖了一个很大范围的知识。让我们来一个个解决。

1、 怎么来捕获这个错误?

从机制上讲,这个错误是一个未处理SEH,也就是所谓的结构化异常。我们知道C++等语言支持异常处理,但是这些异常仅仅限制在这种语言的范围内(.NET 的异常覆盖整个 CLR,但是对我们来说范围还是不够广)。而SEH 是整个操作系统范围内的异常处理,它包括硬件和软件异常两种情况。我们在C++中可以通过下面的语句形式来捕获SEH的异常:

__try
{
...
}
__except(...)
{
}

一种可行的处理方法是在 main 函数或者 WinMain 函数中增加一个最顶层的__try和__except,然而这种情况仅仅对单线程程序有效,而且更大的问题是在某些情况下main和WinMain函数不是我们写的(例如MFC),这时这个方法就没有办法了。

幸运的是操作系统提供了一个函数: SetUnhandledExceptionFilter,通过这个函数可以给进程安装一个未处理异常过滤器—— 这个名字是和所谓__except部分的异常过滤器对应的——当一个进程中任何一个线程出现异常并且没有被处理时都会调用这个异常过滤器。这是一个好机会,作为一个异常过滤器,它可以得到很多有用的信息。让我们来看一下能得到些什么:
下面是这个函数的原型:

LPTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter(
LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter
);

其中lpTopLevelExceptionFilter是一个函数指针,应该具有下面的原型:

LONG WINAPI UnhandledExceptionFilter(
STRUCT _EXCEPTION_POINTERS* ExceptionInfo
);

它有一个参数,指向_EXCEPTION_POINTERS结构,这个结构包含下面内容:

typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;

在这个结构中包含了两个指针,一个指向异常记录,另外一个指向上下文环境记录。异常记录包含了发生异常的详细信息,上下文环境记录包含了发生异常时的CPU状态信息。

typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD* ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;

在异常记录结构中,最重要的信息是ExceptionCode,它包含了异常代码。对于内存访问违例来说,它的异常代码就是EXCEPTION_ACCESS_VIOLATION。当然还包括其它的异常代码。

第二个字段是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前加上下面语句:

#define _WIN32_WINNT 0x0400

2 捕获到这个错误以后如何通过程序来处理它

前面我们已经说到可以通过异常来捕获这个错误(当然,需要声明的是这是对于未处理异常来说通过这个方式来捕获),一旦捕获这个异常后我们就需要通过程序来完成前面手工完成的操作,也就是把一个地址对应到一个源代码行去。

手工对应是通过阅读map文件进行的,那么程序该如何做呢?同样的,当发生一个异常时我们已经知道了对应的地址,然后我们查阅map文件。然而我们直接处理map文件是不方便的,因此需要有一种内部格式,也就是和map具有类似信息,但是处理起来更加方法的格式。

我把这种格式称为SDB(Symbol Database,符号数据库),与map文件对应的里面包含了:段表、符号表和行号表。差别在于我们用对于程序来说更加方便理解的二进制形式来保存,而不是使用对人来说更加容易理解的文本形式保存。这个文件的具体格式按照如下定义:

文件首部,是一个SDBHeader结构,定义为:

struct SDBHeader
{
DWORD mMagic; // “SDB0”
DWORD mSizeOfHeader; // 首部长度,包括 mModuleName 的所有字符。
DWORD mTimeStamp; // 时间戳
DWORD mBase; // 参考基准地址
DWORD mFileNum; // 文件表表项数
DWORD mFileTableOffset; // 文件表偏移地址,相对与文件首部的起始字节
DWORD mSegmentNum; // 段表表项数
DWORD mSegmentTableOffset; // 段表偏移地址,相对与文件首部的起始字节
DWORD mSymbolNum; // 符号表表项数
DWORD mSymbolTableOffset; // 符号表偏移地址,相对与文件首部的起始字节
DWORD mLineNum; // 行号表表项数
DWORD mLineTableOffset; // 行号表偏移地址,相对与文件首部的起始字节
DWORD mStringTableSize; // 字符串表长度
DWORD mStringTableOffset; // 字符串表偏移地址,相对与文件首部的起始字节
DWORD mReserved0; // 保留(0)
DWORD mReserved1; // 保留(0)
DWORD mReserved2; // 保留(0)
DWORD mReserved3; // 保留(0)
DWORD mModuleNameSize; // 模块名长度
CHAR mModuleName[1]; // 模块名
};

这里mMagic是一个标识符,表示这是一个SDBHeader,值为SDB_HEADER_MAGIC(0x30424453)。在这个结构中包含了五个表的开始地址:

文件表是用来记录在这个符号文件中出现的源代码文件名;
段表是用来记录map文件中的段信息;
符号表用来记录map文件中的公共符号信息;
行号表用来记录map文件中的行号信息;
字符串表用来保存SDB文件中用到的所有字符串。为了节省空间,每个字符串在SDB文件中只出现一次,并且需要使用字符串的地方都用一个指针去引用而不是包含字符串本身。

需要注意的是这个结构是变长的,因为模块名长度可变。

文件表,是多个FileIndexItem字段,其数量由文件首部的mFileNum确定,这个结构定义为:

struct FileIndexItem
{
DWORD mFileNameLength; // 文件名长度
DWORD mFileNameOffset; // 文件名在字符串表中的偏移地址,相对与字符串表的起始字节
};

段表,是多个SegmentIndexItem字段,其数量由文件首部的mSegmentNum确定,这个结构定义为:

struct SegmentIndexItem
{
DWORD mAddress; // 段起始地址
DWORD mSegmentLength; // 段长度
DWORD mSegmentNameLength; // 段名长度
DWORD mSegmentNameOffset; // 段名在字符串表中的偏移地址,相对于字符串表的起始字节
DWORD mClassNameLength; // 段类名长度
DWORD mClassNameOffset; // 段类名在字符串表中的偏移地址,相对与字符串表的起始字节
};

在上表中我们在创建SDB文件的时候就把段起始地址转换为RVA的形式。

符号表,是多个SymbolIndexItem字段,其数量由文件首部的mSymbolNum确定,这个结构定义为:

struct SymbolIndexItem
{
DWORD mAddress; // 符号所在地址
DWORD mSymbolNameLength; // 符号名长度
DWORD mSymbolNameOffset; // 符号名在字符串表中的偏移地址,相对于于字符串表的起始字节
DWORD mSymbolFlag; // 符号类型(目前未用)
};

上表中,我们在创建SDB文件的时候就把符号地址转换为RVA形式。

行号表,是多个LineIndexItem字段,其数量由文件首部的mLineNum确定,这个结构定义为:

struct LineIndexItem
{
DWORD mAddress; // 行号所在地址
DWORD mLineNum; // 行号
DWORD mFileIndex; // 文件表索引
};

同样,上表中把mAddress转换成RVA形式。

字符串表,就是多个以0结尾的字符串序列,其总长度(字节数)由文件首部mStringTableSize确定。

最后还有一个文件尾部结构SDBTail,定义如下:

struct SDBTail
{
DWORD mMagic; // “SDBT”
DWORD mHeaderOffset; // 首部偏移地址,计算方法为 SDBTail 的偏移地址减去 mHeaderOffset
// 即为首部的偏移地址
};

其中mMagic是一个标识符,表示这是一个SDBHeader,值为SDB_TAIL_MAGIC(0x54424453)。

为了确保EXE文件和SDB文件能够一一对应,我决定把SDB文件附加在EXE文件的后面,因此需要有一个文件尾部结构来标记一下,并且可以通过它找到文件首部在什么地方。

我编写了一个工具程序,mapcvt.exe,可以通过它把VC6/7生成的map文件转换成sdb文件并且绑定到一个exe文件去。

定义了这样一个文件格式,并且有了一个可以把map文件转换成sdb文件的工具,那么我们就可以把这些操作自动化了:在VC的Post-Build事件中添加下列命令:

mapcvt aaa.map aaa.exe

这里aaa.map就是你的项目生成的map文件名,aaa.exe就是你最终生成的exe文件名。VC会在每次成功编译后调用mapcvt来完成这个转换。

接下来一步,就是我们如何来读取这个文件,有了这个文件格式,那么读取已经不是很复杂的事情了。我编写了另外一个库,sdbhelp.dll,可以协助你完成这个操作。在这个库中导出了下面这些函数:

// SDB Image API
SHAPI HSHIMG WINAPI SDBInitializeImage(HANDLE hProcess, BOOL fInvadeProcess);
SHAPI BOOL WINAPI SDBGetSymbolFromAddress(HSHIMG hImg, DWORD dwAddress,
LPSTR lpszSymbolName, DWORD dwSymbolNameLength,
LPDWORD lpdwDisplacement);
SHAPI BOOL WINAPI SDBGetLineFromAddress(HSHIMG hImg, DWORD dwAddress,
LPDWORD lpdwLine, LPSTR lpszFileName,
DWORD dwFileNameLength, LPDWORD lpdwDisplacement);
SHAPI BOOL WINAPI SDBDestroyImage(HSHIMG hImg);

SDBInitializeImage用于初始化,提供给它一个进程句柄,然后它会搜索这个进程中所有模块是否具备SDB符号信息。如果 fInvadeProcess为TRUE,则它试图装入所有可能找到的符号信息,否则将在用到的时候才装入符号信息。在这种情况下,它会自动确定每个符号文件的基地址。这个函数返回一个HSHIMG句柄,用来表示一个符号处理器。

SDBGetSymbolFromAddress函数将一个物理地址转换成对应的符号名,如果转换成功则返回TRUE,否则返回FALSE。符号名保存在lpszSymbolName所指向的缓冲区,这个缓冲区的长度由输入参数dwSymbolNameLength指定,另外一个参数 lpdwDisplacement用于保存这个地址相对于符号开始地址的偏移量。

SDBGetLineFromAddress函数将一个物理地址转换成对应的源文件名和行号,如果转换成功则返回TRUE,否则返回FALSE。行号保存在lpdwLine所指的空间中,文件名保存在lpszFileName所指向的缓冲区中,这个缓冲区的长度由输入参数 dwFileNameLength指定,lpdwDisplacement用于保存这个地址相对于行开始地址的偏移量。

SDBDestroyImage用于关闭符号表。

有了这样一个dll后,我们就可以编写一些函数来使用它了。下面我对这个dll进行了一些封装。

在封装过程中我们需要考虑另外一件事情。前面我们说过,VC本身其实也是具备调试信息的,就是PDB文件,然而这个文件格式没有公开,因此我们没法直接调用它。但是微软提供了一个DBGHELP.DLL文件,它能够满足我们的要求,关于DBGHELP.DLL的详细信息在MSDN的下列位置(这是最新MSDN位置,如果你使用的MSDN较早,可能有所不同):

MSDN Library-January 2005
Win32 and COM Development
System Services
Debugging and Error Handling
Debug Help Library

在 DBGHELP.DLL中有一个函数叫做SymGetLineFromAddr64,另外还有一个SymGetSymbolFromAddr64(这个函数已经过时,应该使用SymFromAddr),这两个函数的作用和SDBHELP中提供的两个函数作用差不多。

我们为什么不直接使用DBGHELP,而要自己写一个SDBHELP呢?最主要的是基于移植的原因。DBGHELP只能解析Visual C++的专有PDB格式,而其它编译器并不生成这个文件。但是几乎所有的编译器都生成非常类似的map文件,我们只需要把mapcvt程序做一点修改,就能使SDBHELP支持Borland C++ Builder,支持 Delphi甚至gcc。另外一个原因是PDB格式文件很大,远比SDB要大,并且不能和EXE结合在一个文件内,导致分发不方便。但是如果能够支持 PDB,那么也是一件很好的事情。

class DebugSymbolHandler: public DebugObject
{
public:
virtual bool Open(const DebugString & search_path="”, HANDLE process=GetCurrentProcess()) =0;
virtual void Close() =0;
virtual bool GetSymbolFromAddress(ADDRESS addr, DebugSymbol & symbol,
ADDRESS & displacement) const =0;
virtual bool GetLineFromAddress(ADDRESS addr, DebugLine & line,
ADDRESS & displacement) const =0;
static bool GetModuleFromAddress(HANDLE process, ADDRESS addr, DebugModule & module,
ADDRESS & displacement);
};

上面定义了一个基本的DebugSymbolHandler类,用于完成从地址到符号、到行号的转换,这里使用DebugSymbol、DebugLine和DebugModule表示符号、行号和模块名。

DebugObject是所有调试对象的基类,它重载了所有内存分配函数,下面是一个简单的实现:

class DebugObject
{
public:
virtual ~DebugObject()
{
}
static void * operator new (size_t size)
{
return malloc(size);
}
static void * operator new (size_t size, void * ptr)
{
return ptr;
}
static void * operator new [] (size_t size)
{
return malloc(size);
}
static void * operator new [] (size_t size, void * ptr)
{
return ptr;
}
static void operator delete (void * ptr)
{
free(ptr);
}
static void operator delete (void *, void *)
{
}
static void operator delete [] (void * ptr)
{
free(ptr);
}
static void operator delete [] (void *, void *)
{
}
};

【注】上面的类中没有实现 nothrow 的重载版本。

由于我们还将把这些调试对象类用于内存泄漏分析,我们不能让它们使用任何标准的new和delete,需要非常小心地进行内存管理,因此我们重载了这些函数。

由于这个原因,我们如果要使用字符串也需要小心。虽然可以通过自定义分配器来实现一个自定义内存管理的std::string,但是一来这是我另外一个更大的但是尚未完全实现的一个框架中的东西,二来对于调试这么基本的代码来说,使用std::string有些不受控制。因此我自己写了一个简单但是不使用标准new和delete的字符串类DebugString。

class DebugString: public DebugObject{
private:
static char * mNullBuffer;
private:
char * mBuffer;
DWORD mSize;
public:
DebugString(const char * str=0);
DebugString(const DebugString & rhs);
virtual ~DebugString();
operator const char * () const;
DWORD Length() const;
DebugString & operator = (const char * str);
DebugString & operator = (const DebugString & rhs);
static const char * GetNullBuffer();
};

调试符号名、行号和模块由下面几个类定义:

typedef ULONG64 ADDRESS;

struct DebugLine: public DebugObject
{
DebugString mFileName;
DWORD mLineNumber;
ADDRESS mAddress;

DebugLine(const DebugString & filename="”, DWORD linenum=0, ADDRESS addr=0)
:mFileName(filename),
mLineNumber(linenum),
mAddress(addr)
{
}
};

struct DebugSymbol: public DebugObject
{
ADDRESS mModuleBase;
ADDRESS mAddress;
DebugString mSymbolName;

DebugSymbol(ADDRESS base=0, ADDRESS address=0, const DebugString & symbol_name="")
:mModuleBase(base),
mAddress(address),
mSymbolName(symbol_name)
{
}
};

struct DebugModule: public DebugObject
{
ADDRESS mImageBase;
DWORD mImageSize;
DebugString mModuleName;

DebugModule(ADDRESS base=0, DWORD size=0, const DebugString & module_name="")
:mImageBase(base),
mImageSize(size),
mModuleName(module_name)
{
}
};

这几个都是很简单的结构,仅仅用于表示这些信息而已。

最后我们需要实现自己的SDBSymbolHandler:

struct SDBSymbolHandlerImpl;

class SDBSymbolHandler: public DebugSymbolHandler
{
private:
SDBSymbolHandlerImpl * mImpl;

public:
SDBSymbolHandler();
~SDBSymbolHandler();

virtual bool Open(const DebugString & search_path="”, HANDLE process=GetCurrentProcess());
virtual void Close();
virtual bool GetSymbolFromAddress(ADDRESS addr, DebugSymbol & symbol,
ADDRESS & displacement) const;
virtual bool GetLineFromAddress(ADDRESS addr, DebugLine & line,
ADDRESS & displacement) const;
};

这个类的实现相当简单,只需要直接调用SDBHELP中的函数即可:

struct SDBSymbolHandlerImpl: public DebugObject
{
HANDLE mProcessHandle;
HSHIMG mImage;
};

SDBSymbolHandler::SDBSymbolHandler()
:mImpl(0)
{
}

SDBSymbolHandler::~SDBSymbolHandler()
{
Close();
}

bool SDBSymbolHandler::Open(const DebugString & search_path/* ="” */, HANDLE process/* =GetCurrentProcess( */)
{
if(mImpl==0)
{
if(!LoadSdbHelp())
{
return false;
}

mImpl=new SDBSymbolHandlerImpl;

mImpl->mImage=PSDBInitializeImage(process,FALSE);
if(mImpl->mImage!=0)
{
return true;
}
else
{
Close();
return false;
}
}
else
return true;
}

void SDBSymbolHandler::Close()
{
if(mImpl!=0)
{
PSDBDestroyImage(mImpl->mImage);
delete mImpl;
mImpl=0;
}
}

bool SDBSymbolHandler::GetSymbolFromAddress(ADDRESS addr, DebugSymbol & symbol, ADDRESS & displacement) const
{
if(mImpl!=0)
{
DWORD disp;
CHAR buf[1024+1];
if(PSDBGetSymbolFromAddress(mImpl->mImage,static_cast(addr),buf,1024,&disp))
{
symbol.mAddress=addr;
symbol.mSymbolName=buf;
displacement=disp;
return true;
}
else
{
return false;
}
}
else
{
return false;
}
}

bool SDBSymbolHandler::GetLineFromAddress(ADDRESS addr, DebugLine & line_info, ADDRESS & displacement) const
{
if(mImpl!=0)
{
DWORD line, disp;
CHAR buf[1024+1];
if(PSDBGetLineFromAddress(mImpl->mImage,static_cast(addr),&line,buf,1024,&disp))
{
line_info.mAddress=addr;
line_info.mLineNumber=line;
line_info.mFileName=buf;
displacement=disp;
return true;
}
else
{
return false;
}
}
else
{
return false;
}
}

LoadSdbHelp函数用于装入SDBHELP.DLL。我们使用动态的方式装入这个程序库,是因为如果不存在这个文件虽然使调试信息无法读取,但是不应该导致程序不能正常运行。

typedef HSHIMG(WINAPI*TSDBInitializeImage)(HANDLE hProcess, BOOL fInvadeProcess);
typedef BOOL(WINAPI*TSDBGetSymbolFromAddress)(HSHIMG hImg, DWORD dwAddress,
LPSTR lpszSymbolName, DWORD dwSymbolNameLength, LPDWORD lpdwDisplacement);
typedef BOOL(WINAPI*TSDBGetLineFromAddress)(HSHIMG hImg, DWORD dwAddress, LPDWORD lpdwLine,
LPSTR lpszFileName, DWORD dwFileNameLength, LPDWORD lpdwDisplacement);
typedef BOOL(WINAPI*TSDBDestroyImage)(HSHIMG hImg);

static TSDBInitializeConverter PSDBInitializeConverter;
static TSDBSetModuleInfo PSDBSetModuleInfo;
static TSDBAddSegment PSDBAddSegment;
static TSDBAddSymbol PSDBAddSymbol;
static TSDBAddLine PSDBAddLine;
static TSDBWriteImage PSDBWriteImage;
static TSDBDestroyConverter PSDBDestroyConverter;
static TSDBInitializeImage PSDBInitializeImage;
static TSDBGetSymbolFromAddress PSDBGetSymbolFromAddress;
static TSDBGetLineFromAddress PSDBGetLineFromAddress;
static TSDBDestroyImage PSDBDestroyImage;

static bool LoadSdbHelp()
{
static HINSTANCE lib=0;

if(lib!=0)
{
return true;
}

lib=LoadLibrary(TEXT("sdbhelp.dll"));

if(lib)
PSDBInitializeImage=(TSDBInitializeImage)GetProcAddress(lib,"SDBInitializeImage");
if(PSDBInitializeImage)
PSDBGetSymbolFromAddress=(TSDBGetSymbolFromAddress)GetProcAddress
(lib,"SDBGetSymbolFromAddress");
if(PSDBGetSymbolFromAddress)
PSDBGetLineFromAddress =(TSDBGetLineFromAddress)GetProcAddress
(lib,"SDBGetLineFromAddress");
if(PSDBGetLineFromAddress)
PSDBDestroyImage =(TSDBDestroyImage)GetProcAddress(lib,"SDBDestroyImage");
if(PSDBDestroyImage)
turn true;

FreeLibrary(lib);
lib=0;
return false;
}

有了这几个类,我们就可以在自己的程序中根据出错的地址查找对应的符号。具体的可以参见相应的源代码。

不过,如果仅仅知道当时的行号和文件还不是完全有用,比如说,如果错误出现在一个很常见的实用函数中,而这个函数会在各个地方被调用,如果我们仅仅知道问题出现在这个函数中,那么还是无法找到问题的根源。此时我们需要知道的是当时的执行上下文,这包括两个方面的内容:CPU状态和堆栈状态。

CPU状态就是在EXCEPTION_POINTERS中的ContextRecord,这里面包含了CPU的所有寄存器值。也可以通过GetThreadContext函数来获取。

在DBGHELP.DLL中存在一个函数称为StackWalk64,如果知道了栈顶地址,那么我们可以使用这个函数来搜索整个堆栈中的函数调用情况。在此我们需要先了解一下系统的堆栈组织结构。

每个线程具有一个运行栈,这个运行栈是一段内存,其栈顶地址由寄存器ESP指出。每当调用了一个函数,通常会生成下列标准调用代码(C调用规范):

Push arg1
Push arg2
Call func
Add esp, 4

这段代码将在堆栈中形成2个参数和一个函数返回地址,当函数返回时,函数返回地址由ret指令弹出,所以随后的add指令可以用于平衡压入参数后的堆栈。

进入函数后,一般会生成下列标准前序代码:

Push ebp
Mov ebp, esp
Sub esp, xxxx

该代码会先把ebp压入堆栈,然后把esp赋值给ebp,此时ebp的值就称为堆栈帧的基址,以此作为当前函数在堆栈上所有对象的访问基准。例如,函数的参数就是ebp+8(需要注意的是,堆栈是向低地址增长;ebp本身指向的是压入堆栈的前一个ebp值,ebp+4是函数返回地址,因此第一个参数就是 ebp+8,第二个参数就是ebp+12(设参数为4字节),以此类推。Sub esp, xxxx指令把esp寄存器减去一个数值,这个数值就是这个函数所有局部变量的总大小。如果函数只有一个局部变量,且其大小为4字节,那么这里就应该是 sub esp, 4。同样可以通过ebp寄存器来访问局部变量,不同的是,访问参数时使用正偏移量,而使用局部变量时使用负偏移量,因此第一个局部变量就应该是:ebp- 8,第二个局部变量就是ebp-12(假设变量为4个字节),以次类推。

任意一个程序总是由操作系统来调用它的启动代码,而它的启动代码再依次调用其各个函数。因此ebp寄存器就始终被用作堆栈帧的基址,而且由于存在 push ebp指令,使当前函数在堆栈中总能够找到它的调用者的ebp值。这就形成了一个链表,通过遍历这个链表就能追踪整个堆栈直到第一个启动当前线程的地方了(这个地方在Windows内部)。

函数在返回前,会生成下列标准后序代码,用于平衡堆栈:

Add esp, xxxx
Pop ebp
Ret

所有上面所说的这些代码形成所谓的标准堆栈帧。

前面我们说到可以以访问ebp寄存器以及堆栈内存的方式遍历整个堆栈帧链表,从而知道到底是谁调用了谁。这个过程完全可以这样做,但是也可以通过StackWalk64函数来完成这个操作。

使用这个函数之前必须要准备好CPU上下文,如果你已经知道了EBP、ESP等寄存器的值,那么没什么问题,否则就需要使用 GetThreadContext函数来获取这些信息。需要注意的是,如果当前正在执行的线程用于获取当前线程的CPU上下文,则此时得到的EIP、 EBP以及ESP寄存器是不正确的(实际上这些寄存器的值表示的是GetThreadContext内部执行时的值,而不是我们所期望的调用 GetThreadContext之前的值),因此对于这种情况需要对这三个寄存器值做一个修正,在Visual C++下可以通过下面指令进行:

DWORD regEIP, regEBP, regESP;
__asm
{
call l1 // call 指令将当前 EIP 压入堆栈
l1: pop edx // 弹出 EIP
mov dword ptr [regEIP], edx // 设置 EIP
mov dword ptr [regEBP], ebp // 设置 EBP
mov dword ptr [regESP], esp // 设置 ESP
}
mImpl->mContext.Eip=regEIP;
mImpl->mContext.Ebp=regEBP;
mImpl->mContext.Esp=regESP;

由于我们无法直接获取EIP寄存器的值,因此执行一个call指令,它会把EIP寄存器的值保存到堆栈,下一条指令就直接把它从堆栈中弹出,保存在edx中。

有了当前CPU上下文以后就可以构建起堆栈帧的参数结构了:

mImpl->mStackFrame.AddrPC.Mode =AddrModeFlat;
mImpl->mStackFrame.AddrPC.Offset =mImpl->mContext.Eip;
mImpl->mStackFrame.AddrFrame.Mode =AddrModeFlat;
mImpl->mStackFrame.AddrFrame.Offset =mImpl->mContext.Ebp;
mImpl->mStackFrame.AddrStack.Mode =AddrModeFlat;
mImpl->mStackFrame.AddrStack.Offset =mImpl->mContext.Esp;

AddrModeFlat表明使用的平面地址模式。

有了这些结构后就可以通过调用StackWalk64来遍历整个堆栈帧了。DebugStackFrame类实现了对这个过程的封装。

需要注意的是,某些编译器可以有一个“省略堆栈帧”的优化选项,该选项针对没有参数和没有局部变量的函数有效,可以减少生成的代码。但是由于没有堆栈帧的构建代码,因此如果遇到这样的函数就无法继续遍历堆栈了。

本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/gezy_1981/archive/2007/10/28/1852187.aspx

抱歉!评论已关闭.