1. Windows和Linux下动态链接的原则不同
Linux系统以.so共享对象设计共享库,并在设计共享对象的过程,花费很多精力实现.so对象的代码段.text多进程共享,提升空间利用率(如PIC机制、SO-NAME机制、符号版本机制等);Windows系统设计.dll(DLL文件还可以是别的扩展名,如OCX控件.ocx,控制面板程序.cpl)则并不像Linux那么小家子气,并没有花费太多精力设计一些精巧的结构或机制来提升空间利用率,更侧重程序逻辑上的模块化,使得各模块之间能够自由松散地组合、重用和升级。所以Windows系统下可以看到各种各样的软件是通过升级DLL的形式来进行版本迭代,微软本身的系统更新也是将这些升级补丁积累到一定程度后发布一个软件更新包来实现系统升级。
Windows COFF/PE文件结构下的.exe可执行文件或.dll文件都需要被映射到虚拟内存空间中才能得以运行,相比于Linux采用的精打细算的代码地址无关机制,Windows下的文件采用的是一种叫做基地址重载的方式,即并没有采用代码地址无关,所有DLL涉及到的绝对地址的引用在实际装载时都需要重定位。所以要理解PE文件结构下的文件链接和装载过程就不得不谈两个概念:映像基地址(Image-Base-Address)和相对地址(RVA, Relative-Virtual-Address)。
2.映像基地址
在Windows PE文件结构下,当一个PE文件被装载时,其对应的虚拟空间的起始地址便是基地址,而任一PE文件在编译时便存在一个指定(或默认)的优先装载地址,如.exe文件一般的基地址为0x0040 0000,而.dll文件的基地址一般默认是0x1000 0000。系统在装载.exe文件时,因为可执行文件是第一个被加载的文件,显然没人和.exe抢默认的基地址空间,从而.exe文件是不需要基地址重定位的;而DLL文件装载时,则可能遇到默认指定的优先基地址空间被别人抢占了,故而这时就需要重新选择可用的空闲地址,这时整个文件将产生整体位移。
3.RVA
相对地址顾名思义就是在PE文件基地址确认后,一个地址相对于基地址的偏移量,比如一个PE DLL文件默认基地址为0x1000 0000,一个符号存储位置的RVA为0x1000,DLL文件编译时有以下赋值操作:
MOV DWORD PTR [0x1000 1000], 0x20
该文件实际装载时,被基地址重载到0x4000 0000,则符号对应的赋值操作应该变成:
MOV DWORD PTR [0x1000 1000 + 0x2000 0000 - 0x1000 0000], 0x20 //此处减法只是显示重定位的原理,实际这一步计算在链接阶段就已经完成了,此处填入的应该是计算结果
4.DLL文件的符号导出声明
ELF文件.so共享对象,在默认情况下,文件中所有的全局变量和全局函数都是导出的(除非加static修饰符限制方位范围)。
DLL文件在默认情况下,是不导出任何符号的,如果要导出需要手动指明,有两种方法:
1. “__declspec(dllexport)” 修饰符指明该符号导出,对应的,”__delspec(dllimport)”修饰符是指明该符号从外部导入(如果是C++ 文件,但是希望导出函数符号的修饰规则使用C的简洁修饰规则,那么需要再在函数符号前面添加external “C”。实际上,不推荐使用C++编写DLL,因为C++只规定了语言层面的规则,但是ABI二进制层面并没有定义,故而不同编译器甚至同一编译器的不同版本的具体实现都可能不同,故而很容易出现版本不兼容或升级困难等问题。如果一定要用C++编写,需要涉及到COM(Component Object Model)技术);
2. 编写.def链接脚本,批量声明导出符号。如下是某.def链接脚本声明该DLL的导出符号。
LIBRARY Math
EXPORTS
Add @1
Sub @2
Mul @3
Div @4 NONAME
.def文件是在输入编译指令通过/DEF声明传递给link
cl math.c /LD /DEF /math.def
系统级软件开发时,一般推荐使用.def模块脚本批量定义导出符号。一方面,C/C++编译器可能会在编译后将函数修饰的面目全非,如_Add@16之类的,这时在.def文件中可以重命名,这时__declspec()这种方式无法做到的;另一方面是除了LIBRARY/EXPORTS关键字,还可以通过NAME/VERSION/SECTIONS/STACKSIZE/HEAPSIZE等关键字来定义输出文件名/DLL版本/各段的属性/默认堆栈大小/默认堆大小。显然.def模块脚本的操作空间更大,可以封装更多的细节。
EXPORTS
Add = _Add@16
5.PE文件头下的DataDirectory
这里先介绍以下DataDirectory这一关键的结构数组,因为其概念将引出后续的符号导入表和导出表。
首先PE文件中除去sectiontable/.data/.code等段信息外,还存在PE HEADER段,即文件头。这里需要深度解析下PE文件的各段组成和DataDirectory的内容。
typedef struct{
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
}IMAGE_NT_HEADERS;
typedef struct{
WORD Machine;
WORD NumberOfSection;
WORD TimeDataStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
}IMAGE_FILE_HEADER;
typedef struct{
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
...
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
...
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory;
}IMAGE_OPTIONAL_HEADER32;
typedef struct{
DWORD VirtualAddress;
DWORD iSize;
}IMAGE_DATA_DIRECTORY;
/* 16个重要数据结构列举如下
1. IMAGE_DIRECTORY_ENTRY_EXPORT
2. IMAGE_DIRECTORY_ENTRY_IMPORT
3. IMAGE_DIRECTORY_ENTRY_RESOURCE
4. IMAGE_DIRECTORY_ENTRY_EXCEPTION
5. IMAGE_DIRECTORY_ENTRY_SECURITY
6. IMAGE_DIRECTORY_ENTRY_BASERELOC
7. IMAGE_DIRECTORY_ENTRY_DEBUG
8. IMAGE_DIRECTORY_ENTRY_COPYRIGHT
9. IMAGE_DIRECTORY_ENTRY_GLOBALPTR
10. IMAGE_DIRECTORY_ENTRY_TLS
11. IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG
12. IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT
13. IMAGE_DIRECTORY_ENTRY_IAT
14. IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT
15. IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR
16. IMAGE_DIRECTORY_ENTRY_ENTRIES
*/
typedef struct{
DWORD OriginalFirstThunk;
DWORD TimeDataStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
}IMAGE_IMPORT_DESCRIPTORS;
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORYM;
DataDirectory中项目 | 定义 |
---|
IMAGE_DIRECTORY_ENTRY_EXPORT | 指向导出表(一个IMAGE_EXPORT_DIRECTORY结构)。 |
IMAGE_DIRECTORY_ENTRY_IMPORT | 指向导入表(一个IMAGE_IMPORT_DESCRIPTOR结构数组)。 |
IMAGE_DIRECTORY_ENTRY_RESOURCE | 指向资源(一个IMAGE_RESOURCE_DIRECTORY结构。是PE文件结构下最为重要且难懂的地方。 |
IMAGE_DIRECTORY_ENTRY_EXCEPTION | 指向异常处理表(一个IMAGE_RUNTIME_FUNCTION_ENTRY结构数组)。CPU特定的并且基于表的异常处理。用于除x86之外的其它CPU上。 |
IMAGE_DIRECTORY_ENTRY_SECURITY | 指向一个WIN_CERTIFICATE结构的列表,它定义在WinTrust.H中。不会被映射到内存中。因此,VirtualAddress域是一个文件偏移,而不是一个RVA。 |
IMAGE_DIRECTORY_ENTRY_RESOURCE | 指向资源(一个IMAGE_RESOURCE_DIRECTORY结构。是PE文件结构下最为重要且难懂的地方。 |
IMAGE_DIRECTORY_ENTRY_BASERELOC | 指向基址重定位信息。 |
IMAGE_DIRECTORY_ENTRY_DEBUG | 指向一个IMAGE_DEBUG_DIRECTORY结构数组,其中每个结构描述了映像的一些调试信息。早期的Borland链接器设置这个IMAGE_DATA_DIRECTORY结构的Size域为结构的数目,而不是字节大小。要得到IMAGE_DEBUG_DIRECTORY结构的数目,用IMAGE_DEBUG_DIRECTORY 的大小除以这个Size域。 |
IMAGE_DIRECTORY_ENTRY_ARCHITECTURE | 指向特定架构数据,它是一个IMAGE_ARCHITECTURE_HEADER结构数组。不用于x86或IA-64,但看来已用于DEC/Compaq Alpha。 |
IMAGE_DIRECTORY_ENTRY_GLOBALPTR | 在某些架构体系上VirtualAddress域是一个RVA,被用来作为全局指针(gp)。不用于x86,而用于IA-64。Size域没有被使用。参见2000年11月的Under The Hood 专栏可得到关于IA-64 gp的更多信息。 |
IMAGE_DIRECTORY_ENTRY_TLS | 指向线程局部存储初始化段。 |
IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG | 指向一个IMAGE_LOAD_CONFIG_DIRECTORY结构。IMAGE_LOAD_CONFIG_ DIRECTORY中的信息是特定于Windows NT、Windows 2000和 Windows XP的(例如 GlobalFlag 值)。要把这个结构放到你的可执行文件中,你必须用名字__load_config_used 定义一个全局结构,类型是IMAGE_LOAD_CONFIG_ DIRECTORY。 对于非x86的其它体系,符号名是_load_config_used (只有一个下划线)。如果你确实要包含一个IMAGE_LOAD_CONFIG_DIRECTORY,那么在 C++ 中要得到正确的名字比较棘手。链接器看到的符号名必须是__load_config_used (两个下划线)。C++ 编译器会在全局符号前加一个下划线。另外,它还用类型信息修饰全局符号名。因此,要使一切正常,在 C++ 中就必须像下面这样使用: extern “C” IMAGE_LOAD_CONFIG_DIRECTORY _load_config_used = {…} |
IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT | 指向一个 IMAGE_BOUND_IMPORT_DESCRIPTOR结构数组,对应于这个映像绑定的每个DLL。数组元素中的时间戳允许加载器快速判断绑定是否是新的。如果不是,加载器忽略绑定信息并且按正常方式解决导入API。 |
IMAGE_DIRECTORY_ENTRY_IAT | 指向第一个导入地址表(IAT)的开始位置。对应于每个被导入DLL的IAT都连续地排列在内存中。Size域指出了所有IAT的总的大小。在写入导入函数的地址时加载器使用这个地址和Size域指定的大小临时地标记IAT为可读写。 |
IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT | 指向延迟加载信息,它是一个CImgDelayDescr结构数组,定义在Visual C++的头文件DELAYIMP.H中。延迟加载的DLL直到对它们中的API进行第一次调用发生时才会被装入。Windows中并没有关于延迟加载DLL的知识,认识到这一点很重要。延迟加载的特征完全是由链接器和运行时库实现的。 |
IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR | 在最近更新的系统头文件中这个值已被改名为IMAGE_DIRECTORY_ENTRY_ COMHEADER。它指向可执行文件中.NET信息的最高级别信息,包括元数据。这个信息是一个IMAGE_COR20_HEADER结构。 |
6.DLL文件的符号导出表
和ELF文件结构下的.dynsym意义相同,对于DLL文件,其在被加载时,显然需要有个集中存放导出符号的段或数据结构来供链接器快速收集当前DLL的导出信息。COFF PE文件结构中,这些导出符号被放在文件的导出表中。导出表提供一个符号名和符号地址的映射关系,即可以通过符号名查找该符号对应的变量或函数的具体地址。
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORYM;
可以看到对于DLL的导出表中有三个数组是最为核心的结构,分别是导出地址表(EAT,Export Address Table)、符号名表(Name Table)和名字序号对应表(Name-Ordinal Table)。导出地址表EAT对应的是DLL各导出函数的RVA地址,符号名表存储的则是DLL各导出函数名,序号表则是和符号名表一一对应,用以指明相应的函数名的RVA地址在EAT数组中的下标。
7.PE文件的符号导入表
一个DLL文件只有一个导出表(.exe可执行文件不存在导出符号),但是一个PE文件可能依赖多个文件(从多个DLL文件中导入符号),所以要为每个依赖文件单独弄一份导入符号集合,再将所有依赖文件的导入符号集合集中在一起便成了PE文件的导入表。在PE文件中,记录每个依赖文件的导入符号信息的是一个IMAGE_IMPORT_DESCRIPTOR结构体,_IMAGE_IMPORT_DIRECTOTY_ENTRY指向的便是一个该结构体的数组。
typedef struct{
DWORD OriginalFirstThunk;
DWORD TimeDataStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
}IMAGE_IMPORT_DESCRIPTORS;
IAT中每个元素对应一个被导入的符号,在没有重定位或符号解析之前,IAT中的元素值表示相对应的导入符号的序号或符号名,当重定位和符号解析完成后,IAT中元素值将被改写成符号的真正地址,和ELF文件结构下GOT功能相似。
IAT(INT)的元素为IMAGE_THUNK_DATA32结构,而其指向为IMAGE_IMPORT_BY_NAME结构,这两个结构的定义如下。
typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString;
DWORD Function;
DWORD Ordinal;
DWORD AddressOfData;
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
对于32位PE来说,来说如果最高位为1,那么低31位则直接就是导入符号的序号值;如果没有,那么这个IMAGE_THUNK_DATA32启用的是AddressOfData,其指向一个_IMAGE_IMPORT_BY_NAME结构体。使用_IMAGE_IMPORT_BY_NAME结构体时,先根据Hint值去往相应DLL导出表中查找是否对应的符号名是所需的符号,如果不是,则需要按照符号名去符号名数组中二分查找(符号名数组在收集导出符号做了预处理,按照字典序进行排列,所以可以进行二分查找)。
Fig.1 COFF PE文件结构下的符号导入导出表对应关系Q: 在导出表中可以看到函数名序号数组的存在其实蛮多余的,明明可以通过函数符号名表和EAT地址表一一对应来解决问题,却非要通过一个序号表中转下,这是为什么?
A: 有很多说法,但核心还是考虑DLL兼容性。在Windows系统还是16bits的年代,显然保留导出函数的函数名数组是一件极为奢侈的事情,故而出于节省空间的考虑,将DLL导出函数符号分配唯一的序号用以代表,从而在完成重定位的任务下,也可尽可能地节省内存占用。如下面.def模块脚本的内容,为各导出函数手动绑定序号。
LIBRARY Math
EXPORTS
Add @1
Sub @2
Mul @3
Div @4 NONAME
使用序号虽好,可一旦发生函数变更增减,则需要再次手动更新一遍函数序号,但这也会影响到使用老版本DLL的程序的中函数调用,因为这种强绑定关系导致采用序号机制的DLL升级较为繁琐。后来随着计算机内存的增加,自然保留导出函数名成为主流选择。而为了兼容性考虑,序号机制这一历史便依旧被传承下来了。
7.DLL显式运行时加载链接demo
#include <windows.h>
#include <stdio.h>
typedef double (*Func) (double, double);
int main(int argc, char **argv)
{
Func function;
double result;
HINSTANCE hinstLib = LoadLibrary("Math.dll");
if (hinstLib == NULL) {
printf("ERROR: unable to load DLL\n");
return -1;
}
function = (Func) GetProcAddress(hinstLib, "Add");
if(function == NULL) {
printf("ERROR: Unable to find target function\n");
FreeLibrary(hinstLib);
return 1;
}
result = function(1.0, 2.0);
printf("Result = %f\n", result);
FreeLibrary(hinstLib);
return 0;
}