(转)通过地址获取对应的源代码信息
你写了⼀个程序,很开⼼地把它发布给⽤户。⽤户满⼼欢喜地运⾏它,突然Windows弹出了⼀个熟悉的窗⼝:
Application Error:
The instruction at “0x00411a28” referenced memory at “0x12345678”. The memory could not be “written”.
Click _disibledevent="left">顿时⽤户的⼼中升起对你的⽆⽐仇恨,然⽽你就会收到⽤户愤怒的电话,并且知道了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⾏应该出现⼀个内存访问错误,运⾏这个程序,出现了下⾯这个信息:
< – 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:0000002
5 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
源代码1080p在线这是最佳载⼊地址,对于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:0000002
5 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,那么这说明00
01: 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(...)
{
}
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论