分类: WINDOWS
2010-04-29 15:31:07
在DOS环境下编过程序的读者一定知道静态库的含义——程序员将实现各种功能的代码写成一个个子程序(函数),编译成obj文件后,将多个obj文件组合成一个lib文件,当程序中要用到这些函数的时候,只需要指定函数名称,编译器就可以从库中抽出对应的子程序代码插入到可执行文件中去,这样就可以不必一遍遍地重写相同的功能代码。这种链接方法就是静态链接,
静态链接的缺点显而易见,如果有多个程序用到库中的同样函数,那么所有这些可执行文件中都会包含一份同样的代码,对于每个程序几乎必须使用的一些函数来说,如果硬盘上有一万个程序用到这个函数,那么就存在一万份相同的代码,这显然是很浪费空间的。静态链接的另外一个缺点是:如果某个函数因为发现有错或更新算法等种种原因需要升级版本时,必须把所有用到此函数的可执行文件都找回来重新编译一遍,遗漏的程序中存在的还是旧版本的代码。
DOS操作系统是单任务的操作系统,每时每刻只能有一个程序在运行,所以使用静态链接浪费的空间仅表现在磁盘空间的浪费上;而Windows操作系统是多任务的,内存中会同时装入多个程序的代码,如果使用静态链接的话,意味着有多份相同的代码被装入内存,这种浪费代价将是更昂贵的。
Windows的解决办法就是使用动态链接库,动态链接库从表面上看也是提供了一大堆通用的函数,也可以被多个程序使用,但它和静态库的使用上有很多的不同点。
静态库仅在编译的时候使用,编译完成后,可执行文件就可以脱离库文件单独使用了,而动态链接库中的代码在程序编译的时候并不会被插入到可执行文件中,在程序运行的时候才将整个库的代码调入内存,所以称为“动态链接”。如果有多个程序用到同一个动态链接库,Windows在物理内存中只保留一份库的代码,仅通过分页机制将这份代码映射到不同进程的地址空间中,这样不管有多少程序在使用一个库,库代码实际占用的物理内存永远只有一份。当然,这时候库使用的数据段还是会被映射到不同的物理内存中,多少个程序在使用动态链接库就会有多少份数据段。DLL的工作方式在图1.6中就已经有所演示了。
当应用程序装载动态链接库的时候,程序中仅包括库的名称和函数的名称,这些信息是动态寻找对应函数所必须的,程序在编译和链接的时候必须插入这些定位信息,定位信息取自导入库文件,这一点在前面的编程中已经多次涉及。
动态链接库的缩写为DLL,大部分动态链接库是以扩展名为dll的文件形式存在的,但并不是只有dll扩展名的文件才是动态链接库,系统中的某些exe文件、字体文件(*.fon)、一些驱动程序(*.drv)、各种控件(*.ocx)和输入法模块(*.ime)等都是动态链接库。实际上,系统中大部分包含公用代码的模块——不管扩展名是什么——都有可能是动态链接库。
一个文件是否是动态链接库取决于它的文件结构,动态链接库文件和可执行文件同样使用标准的PE文件格式,仅文件头中的属性位不同而已,所以exe文件的一些特征也存在于动态链接库中,比如在动态链接库中也可以定义并使用各种资源,可以导入并使用其他动态链接库中的函数等。
有一个最重要的概念一定要牢记:动态链接库是被映射到其他应用程序的地址空间中执行的,它和应用程序可以看成是“一体”的,动态链接库可以使用应用程序的资源,它所拥有的资源也可以被应用程序使用,它的任何操作都是代表应用程序进行的,当动态链接库进行打开文件、分配内存和创建窗口等操作后,这些文件、内存和窗口都是为应用程序所拥有的。
1. 入口点和初始化代码
与可执行文件一样,动态链接库需要一个入口点,动态链接库的入口点是一个函数,函数的名称并不重要,例子代码中的入口函数命名为“DllEntry”,读者也可以把它取名为其他任何合法的名字,但入口函数的格式是有规定的。
库的入口函数对调用动态链接库的应用程序来说是不可见的,它仅供操作系统使用。Windows在库装载、卸载、进程中线程的创建和结束等时候调用入口函数,以便动态链接库可以采取相应的动作。
2. 导出函数
与写普通的可执行文件相比,动态链接库的设计流程中多了一个文件,那就是定义文件 *.def,源代码目录中还包括一个Counter.def文件,它的内容是:
EXPORTS _IncCount
_DecCount
文件内容总共只有两行:一个EXPORTS关键字加上两个库中函数的名称,这是用来告诉链接器这两个函数需要导出,也就是说这两个函数可以被其他程序调用。动态链接库的文件格式是PE格式,每个PE格式文件的文件头中都可以有一个导出表,只有导出表中列出的函数才可以被其他程序调用,链接器根据def文件的内容在导出表中加入由EXPORTS关键字指定的函数名。
如果库文件中的函数没有在def文件中指定(如例子中的_SetText函数),那么这个函数就仅能被库文件中的代码调用,而无法在其他应用程序中使用,这是因为库文件的导出表中没有列出它的名称,这样其他程序根本不会知道它的存在。对于这些函数,可以把它们叫做私有函数。
3. 生成库文件
当使用Link.exe链接器完成链接工作后,链接器生成3个文件,它们分别以dll,lib和exp为扩展名。dll文件就是动态链接库,而lib文件是供程序开发用的导入库。
回想一下:当在汇编源程序中用到某个动态链接库中的函数时,在源文件的一开始就要用includelib语句指定动态链接库的导入库,这样链接的时候链接器才知道到哪个库中寻找指定的函数,如果开发的时候没有动态链接库的导入库文件,使用起来就比较麻烦了。
为了在开发其他程序的时候使用自己编写的动态链接库,就必须提供这个动态链接库的导入库文件,Link.exe考虑了这一点,所以在生成dll文件的同时也生成了导入库文件。如果dll文件是当做最终应用程序发布的,可以仅发布dll文件;如果是当做插件供其他人做二次开发用的,那么就要为其他程序员同时提供dll文件和lib文件,并且根据情况提供不同语言使用的头文件(最后,还要为每个导出函数写一个说明,包括参数的个数、类型和定义等)。exp文件是输出库文件,这是链接时的一个副产品,一般没有什么用途,我们可以直接将它删掉。
在Win32编程中常常用到“导入函数”(Import functions),导入函数就是被程序调用但其执行代码又不在程序中的函数,这些函数的代码位于一个或者多个DLL中,在调用者程序中只保留一些函数信息,包括函数名及其驻留的DLL名等。
对于存储在磁盘上的PE文件来说,它无法得知这些导入函数会在内存的哪个地方出现,只有当PE文件被装入内存的时候,Windows装载器才将DLL装入,并将调用导入函数的指令和函数实际所处的地址联系起来,这就是“动态链接”的概念。动态链接是通过PE文件中定义的“导入表”(Import Table)来完成的,导入表中保存的正是函数名和其驻留的DLL名等动态链接所必需的信息。
程序被执行的时候是怎样使用导入函数的呢?先将第3章中那个简单的Hello World程序反汇编一把,看看调用导入函数的指令都是什么样子的,需要反汇编的两句源代码如下:
invoke MessageBox,NULL,offset szText,offset szCaption,MB_OK
invoke ExitProcess,NULL
当使用W32Dasm反汇编以后,这两句代码变成了以下的指令:
:00401000
:00401002 6800304000 push 00403000
:00401007
:
:0040100E E807000000 Call
:00401013
:00401015 E806000000 Call 00401020 ;ExitProcess
:
:00401020 FF2500204000 Jmp dword ptr [00402000]
反汇编后,对MessageBox和ExitProcess函数的调用变成了对
那么在没有装载到内存之前,PE文件中的00402000地址处的内容是什么呢?
首先,使用PEInfo工具去查看一下Hello.exe文件,会得到以下的信息:
文件名:C:\Documents and Settings\Administrator\桌面\Hello.exe
----------------------------------------------------------
运行平台: 0x
节区数量: 3
文件标记: 0x
建议装入地址: 0x00400000
----------------------------------------------------------
节区名称 节区大小 虚拟地址 Raw_尺寸 Raw_偏移 节区属性
----------------------------------------------------------
.text 00000026 00001000 00000200 00000400 60000020
.rdata 00000092 00002000 00000200 00000600 40000040
.data 00000022 00003000 00000200
由于建议装入地址是00400000h,所以00402000h地址实际上处于RVA为2000h的地方,再看看各个节的虚拟地址,可以发现2000h开始的地方位于.rdata节内,而这个节的Raw_偏移项目为600h,也就是说00402000h地址的内容实际上对应PE文件中偏移600h处的数据。
现在随便找一个16进制编辑器来看看文件0600h处的内容是什么:
0600 76 20 00 00 00 00 00 00
0610 54 20 00 00 00 00 00 00-00 00 00 00
0620 08 20 00 00
0630 84 20 00 00 00 20 00 00-00 00 00 00 00 00 00 00 . ... ..........
0640 00 00 00 00 00 00 00 00-00 00 00 00 76 20 00 00 ............v ..
0650 00 00 00 00
0660 73 73 61 67 65 42
0670 2E 64
0680 65 73 73 00 4B 45 52 4E-45
0690 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
查看的结果是00002076h,这显然不会是内存中的ExitProcess函数的地址。将它当作1个RVA看会怎么样呢?查看节表可以发现RVA地址00002076h也处于.rdata节内,减去节的起始地址00002000h后得到这个RVA相对于节首的偏移是76h,也就是说它对应文件0676h开始的地方,接下来可以惊奇地发现,0676h再过去两个字节的内容正是函数名字符串“ExitProcess”!
就是说,Call ExitProcess指令被编译成了Call aaaaaaaa类型的指令,而aaaaaaaa处的指令是Jmp dword ptr [xxxxxxxx],而xxxxxxxx地址的地方只是一个似乎是指向函数名字符串的RVA地址,这一系列的指令显然是无法正确执行的!
但如果告诉你,当PE文件被装载的时候,Windows装载器会根据xxxxxxxx处的RVA得到函数名,再根据函数名在内存中找到函数地址,并且用函数地址将xxxxxxxx处的内容替换成真正的函数地址,那么所有的疑惑就迎刃而解了。
当.exe文件被执行的时候,Windows装载器将可执行文件装入内存并将其导入表中登记的DLL文件也一并装入内存,然后再根据DLL文件中的函数导出信息 对内存中的可执行文件的导入表进行修正。
DLL文件中,函数的导出信息被保存在导出表中,通过导出表,DLL文件向系统提供导出函数的名称、序号和入口地址等信息,以便Windows装载器通过这些信息来完成动态链接的过程。
扩展名为.exe的PE文件中一般不存在导出表,而大部分的.dll文件中都包含导出表,但是这并不是必然的,比如用作纯资源的.dll文件就不提供导出函数,文件中也就不存在导出表;另外,偶尔也可以见到包含导出函数和导出表的.exe文件。
6.重定位表简介
什么是重定位,代码又是在什么情况下才需要重定位呢?这个问题早在
对于操作系统来说,其任务就是在对可执行程序透明的情况下完成重定位操作,在现实中,重定位信息是在编译的时候由编译器生成并被保留在可执行文件中的,在程序被执行前由操作系统根据重定位信息修正代码,这样在开发程序的时候就不用考虑重定位问题了。
重定位信息在PE文件中被存放在重定位表中。
重定位所需的数据
在开始分析重定位表的结构之前需要了解两个问题:第一,对一条指令进行重定位需要哪些信息;第二,这些信息中哪些应该被保存在重定位表中。下面举例来说明这两个问题。
作为例子,现将
:00400FFC 0000 ;dwVar变量
:00401000 55 push ebp
:00401001 8BEC mov ebp, esp
:00401003
:
:0040100B 8B45FC mov eax, dword ptr [ebp-04] ;mov eax,@dwLocal
:0040100E 8B4508 mov eax, dword ptr [ebp+08] ;mov eax,_dwParam
:
:
:00401015 68D2040000 push 000004D2
:
其中地址为00401006h处的mov eax,dword ptr [00400ffc]就是一句需要重定位的指令,当整个程序的起始地址位于00400000h处的时候,这句代码是正确的,假如将它移到00500000h处的时候,这句指令必须变成mov eax,dword ptr [00500ffc]才是正确的。这就意味着它需要重定位。
让我们看看需要改变的是什么,重定位前的指令机器码是A1 FC
所以,重定位的算法可以描述为:将直接寻址指令中的双字地址加上模块实际装入地址与模块建议装入地址之差。为了进行这个运算,需要有3个数据,首先是需要修正的机器码地址;其次是模块的建议装入地址;最后是模块的实际装入地址。这就是第一个问题的答案。
在这3个数据中,模块的建议装入地址已经在PE文件头中定义了,模块的实际装入地址是Windows装载器确定的,到装载文件的时候自然会知道,所以第二个问题也解决了,最后 应该被保存在重定位表中的仅仅是需要修正的代码的地址。
事实上正是如此,PE文件的重定位表中保存的就是一大堆需要修正的代码的地址。