主要参考《程序员的自我修养--链接装载与库》书中的内容,书中的代码参考GitHub
简介
DLL是动态链接库(Dynamic Library)的缩写,相当于Linux下的共享对象。Windows下的DLL和EXE实际上是一个概念,他们都有PE格式的二进制文件,不同点在于他们通过PE文件头部的一个符号来标识。DLL的后缀不一定是.dll
,也有可能是.ocx
或者.cpl
。
进程地址空间和内存管理
在早起的windows版本中,所有进程共享地址空间,所有被加载到内存的DLL都可以被其他的应用程序访问。
现在的Windows版本已经支持进程拥有独立的内存空间,一个DLL在不同的进程中拥有不同的私有数据副本。DLL代码是地址相关的,所以他只能在某些情况下被多个进程间共享。
基地址和相对地址
PE里面有两个基础的概念:基地址(Base Address)和相对地址(RVA,Relative Virtual Address)。当一个PE文件被装载时,其进程地址空间中的起始地址就是基地址。对任何一个PE文件来说他都有一个优先装载的基地址,这个值就是PE文件头中的Image Base。若该地址被其他模块占用了,那么PE装载器会选用其他的空闲地址。相对地址就相当于PE内的偏移量了。
DLL共享数据段
Windows也支持将DLL的数据段设置为共享的,任何进程都可以共享该DLL的同一份数据段。更为常见的做法是既有共享数据段也有进程私有数据段。
这不是进程之间通讯的好方式,难以保证安全性。
DLL的简单例子
ELF会默认导出所有的全局符号,但是DLL默认所有符号都不会被导出,需要显式告诉编译器去导出我们需要导出的符号。
可以通过__declspec(dllexport)
修饰某个函数或者变量,表示该符号是从本DLL中导出的。除此之外还可以使用def文件的形式。
创建DLL
假设这个DLL为我们提供三个函数,创建Math.c
文件
1 | /* Math.c */ |
编译源文件
1 | cl /LDd Math.c # 生成Debug版本的 |
编译之后会生成Math.dll
、Math.obj
、Math.exp
、Math.lib
4个文件
- dll是我们需要的DLL文件
- obj是编译的目标文件
- exp文件见符号导出导入表-EXP文件
- lib文件见符号导出导入表-导入函数的调用
1 | dumpbin /EXPORTS Math.dll |
1 | ... |
可以看到DLL的三个导出函数以及他们的RVA(相对地址)
使用DLL(静态链接)
我们需要使用__desclspec(dllimport)
显式声明某个符号为外部导入,相对应的ELF就不需要显式声明
1 | /* TestMath.c */ |
编译并链接
1 | # 编译生成目标文件 |
lib文件是一组目标文件的集合,以Math.lib
为例,它并不包含Math.c
的代码和数据,他用来描述Math.dll
的导出符号,它包含了TestMath.o
链接Math.dll
时所需要的导入符号以及一部分桩代码(又被称为胶水代码),以便于将程序与DLL粘在一起,又可将.lib
文件称为导入库。
整个流程的关系如下图所示:
模块定义文件(def)
声明DLL中某个函数为导出函数的办法有两种,一种是之前演示过的使用__declspec(dllexport)
扩展,另一种是采用模块定义(.def)文件声明,其作用在MSVC链接过程中的作用与链接脚本(Link Script)文件在ld链接过程中的作用类似,它是用于控制链接过程的,为链接器提供有关链接程序的导出符号、属性以及其他信息。相比于ld的链接脚本文件,.def文件更简单,功能也更少。
在上面的例子中,可以将所有的__declspec(dllexport)
声明去掉,然后创建一个.def文件,例如如下内容
1 | LIBRARY Math |
然后使用下面的命令进行编译
1 | cl Math.c /LD /DEF Math.def |
使用.def相比于在函数前声明的方式,可以更灵活的控制导出符号的符号名,例如当函数名Add称被编译器修饰成_Add@16
时,我们仍可以通过def添加一个别名,导入一个ADD的别名函数
1 | LIBRARY Math |
当使用这个文件生成的Math.dll中,就会包含一个Add的别名,其RVA地址和_ADD@16
一致。
关于编译器会给函数改名的问题,可以搜索如下关键字,查找相关资料
MSVC 函数调用规范:
__cdecl
,__stdcall
,__fastcall
此外.def文件还可以控制一些链接过程,除了LIBRARY和EXPORTS之外,还支持HEAPSIZE、NAME、SECTIONS、STACKSIZE、VERSION等关键字,控制输出文件的默认堆大小、输出文件名、各个段的属性,默认堆栈大小、版本号等信息。
显式运行时链接
与ELF类似,DLL也支持运行时链接,即运行时加载。Windows提供了三个API:
- LoadLibrary,装载DLL到进程的地址空间
- GetProcAddress,查找某个符号的地址
- FreeLibrary,用来卸载某个已加载的模块
如下面的例子
1 | /* LoadLibrary.c */ |
编译运行
1 | cl /c LoadLibrary.c |
符号导出导入表
导出表
在windows PE(DLL的文件格式)中,所有导出的符号被集中存放到了导出表(Export Table)结构体中,简单理解,其中存放了符号名称和符号地址之间的映射关系。每个符号都是ASCII字符串,因为符号修饰机制的存在,其可能与变量名或函数名相同,也有可能不同。
导出表是一个IMAGE_EXPORT_DIRECTORY
的结构体,其中包含了非常重要的
- 导出地址表(EAT,Export Address Table)
- 符号名表(Name Table)
- 名字序号对应表(Name-Ordinal Table)
上文提到的Math.dll,如下图所示。
序号
上文中提到的名字序号对应表的提出,是由于历史原因,windows的设备的内存空间不大,存储函数名称是十分奢侈的事情,因此提出了使用序号代替名称的方式,来查找函数的RVA。这会有一个问题,函数的序号的如何维护内,其中一种解决方案就是通过人工指定函数的序号的方式(和系统调用号类似)。
如今内存已经很大,为了节省内存空间,而使用浪费人力的序号方式已经得不偿失。因此如今的程序也大都使用符号名称索引RVA,但仍保留了向后的兼容性,仅此就能见到名字序号对应表了。
举例查找Add函数的RVA的索引过程:
- 动态链接器在函数名表中进行二分查找,找到Add函数
- 在名字符号对应表中,找到Add对应的序号
- 使用找到的序号减一个Base值得到一个编号,找到导出地址表下标为该编号的RVA
EXP文件
EXP文件是链接器在创建DLL时的临时文件。链接器在创建DLL的时候和静态链接一样采用两遍扫描的过程
- 第一遍会遍历所有的目标文件,并且收集所有的导出符号信息并创建DLL的导出表,会将其放在EXP文件的
.edata
的段中。它的本质是一个标准的PE/COFF目标文件,只不过名字不是.obj
而是.exp
- 第二遍,链接器会把这个EXP文件当做普通文件一样,与其他输入的目标文件链接在一起输出DLL
导出重定向
将导出符号重定向到另外一个DLL中,实现的机制是RVA指向的位置位于导出表中,那么表示这个符号被重定向了。被重定向的RVA指向一个DLL文件名和符号名,例如NTDLL.RtlAllocHeap
。
导入表
导入表中记录了该模块所需要导入的变量和函数的符号以及所在的模块等信息。当某个PE文件被加载时,Windows加载器的其中一个任务是将所有需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接。可以使用如下的命令查看一个模块依赖于哪些DLL。
1 | dumpbin /IMPORTS Math.dll |
内容如下
虽然这些函数我们没有使用,但是DLL运行本来就需要这些基本运行库,KERNEL32就是几乎所有DLL都会依赖的一个库。Windows将会保证所有的这些依赖的DLL都被正确的加载。
导入表是一个IMAGE_IMPORT_DESCRIPTOR
的结构体数组,每一个IMAGE_IMPORT_DESCRIPTOR
都对应一个被导入的DLL。该结构体包含了
- OriginalFirstThunk:指向一个导入名称表(Import Name Table,简称INT),与IAT数组一模一样,具体作用参考DLL优化--导入函数绑定。
- TimeDateStamp
- ForwarderChain:
- Name:DLL的名称
- FirstThunk:指向导入地址数组(Import Address Table,简称IAT)
IAT中每个元素代表一个被导入的符号,在动态链接器刚完成映射还没有开始重定位和符号解析时,IAT中元素的值表示相对应的导入符号的序号或者符号名。当windows动态链接器在完成该模块的链接时,元素值会被动态链接器改写成该符号的真正地址。
IAT通过最高位来标记其值表示的是序号还是符号名。但表示符号名时,实际存储的是IMAGE_IMPORT_BY_NAME的RVA,其元素由一个Hint(可能的序号值)和一个符号名组成。
延迟载入
当链接一个支持延迟载入的DLL时,链接器会产生与普通DLL导入非常类似的数据。但操作系统会忽略这些数据。当延迟载入的API第一次被调用时,由链接器添加特殊的桩代码,这个桩代码负责对DLL的装载工作。然后这个桩代码通过调用GetPROCAddress来找到被调用API地址
导入函数的调用
通过间接调用指令
1 | CALL DWORD PTR [0x0040D11C] |
0x0040D11C,刚好是IAT中的某一项,也就是要调用的导入函数在TestMath.exe的IAT中的位置。
在__declspec(dllimport)
之前,对于调用函数来说,他只产生一般形式的指令“CALL XXXXXXXX”,在链接时链接器会把XXXXXXXX重定向到一段桩代码(Stub),桩代码形如
1 | CALL 0x0040D11C # 默认生成的,会被链接器替换成桩代码的位置 |
链接器一般情况下不会生成指令,所以这段JMP代码,来自产生DLL文件时伴随的那个LIB文件,即导入库。
编译器在产生导入库时,一个函数会产生两个符号定义,比如函数foo来说,他在导入库中有两个符号:
foo
指向foo函数的桩代码,为了给链接器提供桩代码__imp__foo
指向foo函数在IAT中的位置,当我们通过__declspec(dllimport)
来声明函数时,编译器会在编译时给函数添加上__imp__
前缀,以确保跟导入库正确链接。
现在的MSVC编译器支持以上两种导入方式,即加不加__declspec(dllimport)
都可以。但添加之后,因为省略了一条跳转指令,性能更高。
DLL优化
重定基地址
DLL内部的地址都是基于基地址的,通过修改基地址的方式,可以快速修改所有绝对地址的位置。当然也可以禁止DLL产生重定位信息,不过DLL的装载可能会因为地址冲突而失败。
因为重定基地址将导致DLL在进程之间不能被共享,每个进程都需要一份单独的DLL代码段的副本。
改变默认基地址
可以在链接时指定DLL的基地址,也可修改已有DLL的基地址。
系统DLL
windows系统本身自带了很多系统DLL,例如kernel32.dll、ntdll.dll,shell32.dll等。这些DLL太常见了,系统在进程空间中专门划出了一块0x70000000~0x80000000区域,用于映射常见的系统DLL。Windows在安装时就把这块地址分配给这些DLL,调整这些DLL基地址是他们相互不冲突,从而在装载时就不需要进行重定基址了。
序号
使用序号相比于使用符号名,会有更快的速度,那些仅供内部使用的符号,他们只有序号而没有名称。但是对外可能会造成乱用的情况发生,特别是大多数的windows API,他们的符号名在各个windows版本之间没有差异,但是序号会不停发生变化,所以我们应该使用符号名的方式进行导入。
在产生一个DLL的时候,我们可以在链接器的def文件中定义导出函数的序号,例如上文的Math.dll的例子,可以进行如下修改,从而指定序号值,或者声明符号仅以序号的方式进行导出
1 | LIBRARY Math |
目前符号名导出的方式已经不是系统瓶颈,特别是对符号名进行排序之后,可以采用二分查找(最早DLL导出的符号名没有进行排序)
导入函数绑定
大多数情况下,每次程序运行都会按照固定的顺序加载DLL,但是每次程序运行都需要重新解析一系列的导入导出符号的依赖关系,这是重复没有意义的浪费工作。因此提出了DLL绑定,可以使用editbin
对EXE或DLL进行绑定。
提前绑定的目标地址就存在于上文提到的INT中