Windows系统平台上,你可以将独立的程序模块创建为较小的DLL(Dynamic Linkable Library)文件,并可对它们单独编译和测试。在运行时,只有当EXE程序确实要调用这些DLL模块的情况下,系统才会将它们装载到内存空间中。这种方式不仅减少了EXE文件的大小和对内存空间的需求,而且使这些DLL模块可以同时被多个应用程序使用。Microsoft Windows自己就将一些主要的系统功能以DLL模块的形式实现。例如IE中的一些基本功能就是由DLL文件实现的,它可以被其它应用程序调用和集成。一般来说,DLL是一种磁盘文件(通常带有DLL扩展名,是标准win32可执行文件-“PE”格式),它由全局数据、服务函数和资源组成,在运行时被系统加载到进程的虚拟空间中,成为调用进程的一部分,进程中所有线程都可以调用其中的函数。如果与其它DLL之间没有冲突,该文件通常映射到进程虚拟空间的同一地址上。DLL模块中包含各种导出函数,用于向外界提供服务。Windows在加载DLL模块时将进程函数调用与DLL文件的导出函数相匹配。
在Win32环境中,每个进程都复制了自己的读/写全局变量。如果想要与其它进程共享内存,必须使用内存映射文件或者声明一个共享数据段。DLL模块需要的堆栈内存都是从运行进程的堆栈中分配出来的。
DLL文件中包含一个导出函数表(存在于PE的.edata节中)。这些导出函数由它们的符号名和称为标识号的整数与外界联系起来。函数表中还包含了DLL中函数的地址。当应用程序加载DLL模块时时,它并不知道调用函数的实际地址,但它知道函数的符号名和标识号。动态链接过程在加载的DLL模块时动态建立一个函数调用与函数地址的对应表。如果重新编译和重建DLL文件,并不需要修改应用程序,除非你改变了导出函数的符号名和参数序列。
简单的DLL文件只为应用程序提供导出函数,比较复杂的DLL文件除了提供导出函数以外,还调用其它DLL文件中的函数。
每个DLL都有一个入口函数(DLLMain),系统在特定环境下会调用DLLMain。在下面的事件发生时会调用dll入口函数:1.进程装载DLL。2.进程卸载DLL。3.DLL在被装载之后创建了新线程。4. DLL在被装载之后一个线程被终止了。
应用程序导入函数与DLL文件中的导出函数进行链接有两种方式:隐式链接和显式链接。
隐式链接(load-time dynamic linking)是指在应用程序中不需指明DLL文件的实际存储路径,程序员不需关心DLL文件的实际装载(由编译器自动完成地址分配)。采用隐式链接方式,程序员在建立一个DLL文件时,链接程序会自动生成一个与之对应的LIB导入文件。该文件包含了每一个DLL导出函数的符号名和可选的标识号,但是并不含有实际的代码。LIB文件作为DLL的替代文件被编译到应用程序项目中。当程序员通过静态链接方式编译生成应用程序时,应用程序中的调用函数与LIB文件中导出符号相匹配,这些符号或标识号进入到生成的EXE文件中。LIB文件中也包含了对应的DLL文件名(但不是完全的路径名),链接程序将其存储在EXE文件内部。当应用程序运行过程中需要加载DLL文件时,Windows根据这些信息发现并加载DLL,然后通过符号名或标识号实现对DLL函数的动态链接。我们使用的大部分系统Dll就是通过这样的方式链接的。若找不到需要的Dll则会给出一个Dll缺少的错误消息。
显式链接(run-time dynamic linking)与此相反。用户程序在编译的时候并没有指明需要哪些Dll,而是在运行起来之后调用Win32 的LoadLibary()函数,去装载Dll。若没有找到Dll则这个函数就会返回一个错误。在用LoadLibary()函数装载Dll之后,应用程序还需要用GetProcAdress()函数去获得Dll输出函数的地址。显式链接方式对于集成化的开发语言比较适合。有了显式链接,程序员就不必再使用导入文件,而是直接调用Win32 的LoadLibary()函数,并指定DLL的路径作为参数。还要说明一点的就是Known Dlls就是保证在通过LoadLibary()去装载系统Dll的时候,只从特定的系统目录去装载,防止装载错。装载的时候会去看注册表下是否有一样的注册表键名。如果是装载windows\system32\目录下的对应的Dll。
Dll的搜索顺序,在Windows上有个注册表键值决定了Dll的搜索顺序:HKLM\System\CurrentControlSet\SessionManager\SafeDllSearchMode。在vista,server2003,xp sp2中这个值为1,在xp,2000 sp4中为0。1值时的搜素顺序为:1.可执行文件所在目录,2.系统目录windows\system32\,3. 16位系统目录,4.windows目录,5.当前进程目录。6.环境变量PATH中的目录。0值时的搜素顺序为:1.可执行文件所在目录,2. 当前进程目录。3.系统目录windows\system32\,4. 16位系统目录,5.windows目录,6.环境变量PATH中的目录。
Windows DLL装入(除ntdll.dll外)和连接是通过ntdll.dll中一个函数LdrInitializeThunk实现的。先对LdrInitializeThunk()这个函数名作些解释“Ldr显然是“Loader”的缩写。而“Thunk”意为“翻译”、“转换”、或者某种起着“桥梁”作用的东西。这个词在一般的字典中是查不到的,但却是个常见于微软的资料、文档中术语。这个术语起源于编译技术,表示一小片旨在获取某个地址的代码,最初用于函数调用时“形参”和“实参”结合。后来这个术语有了不少新的特殊含义和使用,但是DLL的动态连接与函数调用时“形实结合”确实有着本质的相似。
由于Windows没有公开这个函数的代码,所以学习起来比较困难,只能通过查阅一些资料来大概猜测这个函数的实现。这个过程中也参看了很多ReactOS(ReactOS是一个免费而且完全兼容 Microsoft Windows XP 的操作系统。ReactOS 旨在通过使用类似构架和提供完整公共接口实现与 NT 操作系统二进制下的应用程序和驱动设备的完全兼容。)的LdrInitializeThunk()函数实现源代码。
在进入这个函数之前,目标 EXE映像已经被映射到当前进程的用户空间,系统DLL ntdll.dll的映像也已经被映射,但是并没有在EXE映像与ntdll.dll映像之间建立连接 (实际上 EXE映像未必就直接调用ntdll.dll中的函数)。LdrInitializeThunk()是ntdll.dll中不经连接就可进入的函数,实质上就是ntdll.dll的入口。除ntdll.dll以外,别的 DLL都还没有被装入(映射)。此外,当前进程(除内核中的“进程控制块”EPROCESS等数据结构外)在用户空间已经有了一个“进程环境块”PEB,以及该进程的第一个“线程环境块”TEB。这就是进入 LdrInitializeThunk()前的“当前形势”。
PEB中有一个字段Ldr是个PEB_LDR_DATA结构指针,所指向的数据结构用来为本进程维持三个“模块”队列、即InLoadOrderModuleList、InMemoryOrderModuleList、和InInitializationOrderModuleList。这里所谓“模块”就是PE格式的可执行映像,包括EXE映像和DLL映像。前两个队列都是模块队列,第三个是初始化队列。两个模块队列的不同之处在于排列的次序,一个是按装入的先后,一个是按装入的位置。每当为本进程装入一个模块、即.exe映像或DLL映像时,就要为其分配,创建一个LDR_DATA_TABLE_ENTRY数据结构,并将其挂入InLoadOrderModuleList。然后,完成对这个模块的动态连接以后,就把它挂入InInitializationOrderModuleList队列,以便依次调用它们的初始化函数。相应地,LDR_DATA_TABLE_ENTRY数据结构中有三个队列头,因而可以同时挂在三个队列中。在我做的小实验当中就是通过查找这三个队列,来将当前进程的Dll加载信息显示出来的。具体的实例请见我的实验说明文档。
在LdrInitializeThunk()中,最开始为做的事情就是将加载的模块信息存放在PEB中的ldr字段,如上面一段文字中所述。之后,LdrInitializeThunk()函数又调用了一个叫LdrPEStartup()的函数。LdrPEStartup()函数首先判断了“期望地址”是否可用,PE映像的NtHeader(peb中有个ImageBaseAddress的地址,代表exe映像在用户空间的位置,在这个地址指向的数据结构中就有NtHeader的结构)中有个指针,指向一个OptionalHeader。在OptionalHeader中有个字段ImageBase,是具体映像建议、或者说希望被装入的地址,我们称之为“愿望地址”。在装入一个映像时,只要相应的区间(取决于它的期望地址和大小)空闲,就总正常装入。但是如果与已经被占用的区间相冲突,就只好利用LdrPerformRelocations()换个地方。
那么映像的愿望地址有着什么物理的或者逻辑的意义呢?我们知道,软件在编译以后有个连接的过程,即为函数的调用者落实被调用函数的入口地址、为全局变量(按绝对地址)的使用者落实变量地址的过程。连接有静态和动态两种,静态连接是在“制造”软件时进行的,而动态连接则是在使用软件时进行的。尽管EXE模块和DLL模块之间的连接是动态连接,但是EXE或DLL模块内部的连接却是静态连接。既是静态连接,就必须为模块的映像提供一个假定的起点地址。如果以此假定地址为基础进行连接以后就不可变更,使用时必须装入到这个地址上,那么这个地址就是固定的“指定地址”了。早期的静态连接往往都是使用指定地址的。但是,如果允许按假定地址连接的映像在实际使用时进行“重定位”,那么这假定地址就是可浮动的“愿望地址”了。可“重定位”的静态连接当然比固定的静态连接灵活。事实上,要是没有可“重定位”的静态连接技术,DLL的使用就无法实现,因为根本就不可能事先为所有可能的DLL划定它们的装入位置和大小。至于可“重定位”静态连接的实现,则一般都是采用间接寻址,通过指针来实现。
所谓重定位,就是计算出实际装入地址与建议装入地址间的位移a,然后调整每个重定位块中的每一个重定位项、即指针,具体就是在指针上加a。而映像中使用的所有绝对地址(包括函数入口、全局量数据的位置)实际上用的都是间接寻址,每个这样的地址都有个指针存在于某个重定位块中。
完成了可能需要的EXE映像重定位以后,下一个主要的操作就是LdrFixupImports()了。实际上这才是关键所在,它所处理的就是当前模块所需DLL模块的装入和连接。各DLL的程序入口记录在它们的LDR_DATA_TABLE_ENTRY数据结构中借助InInitializationOrderModuleList队列就可依次调用所有DLL的初始化函数。
NtHeader的OptionalHeader中有个数组DataDirectory[],其中之一是重定位目录。除此之外,数组中还有“(普通)引入(import)”、“绑定引入(bound import)”以及其它多种目录,但是我们在这里只关心“引入”和“绑定引入”。这两个目录都是用于库函数的引入,但是作用不同,目录项的数据结构也不同。每个引入目录项都代表着一个被引入模块,其模块名、即文件名在dwRVAModuleName(ReactOS中的名字,下同)所指的地方。需要从同一个被引入模块引入的函数通常有很多个,dwRVAFunctionNameList指向一个字符串数组,数组中的每一个字符串都是一个函数名;与此相对应,dwRVAFunctionAddressList则指向一个指针数组。这两个数组是平行的,同一个函数在两个数组中具有相同的下标。从一个被引入模块中引入一个函数的过程大体上就是:根据函数名在被引入模块的引出目录中搜索,找到目标函数以后就把它实际装入后的入口地址填写到指针数组中的相应位置上。但是,这个过程可能是个开销相当大、速度比较慢的过程。为此,又发展起一种称为“绑定”的优化。
所谓绑定,就是在软件的编译,连接过程中先对使用时的动态连接来一次预演,预演时假定所有的DLL都被装入到它们的愿望地址上,然后把预演中得到的被引入函数的地址直接记录在引入者模块中相应引入目录下的指针数组中。这样,使用软件时的动态连接就变得很简单快捷,因为实际上已经事先连接好了。其实“绑定引入”和静态连接并无实质的不同。但是,各模块的版本配套就成为一个问题,因为万一使用的某个DLL不是当初绑定时的版本,而且其引出目录又发生了变化,就有可能引起混乱。为此,PE格式增加了一种“绑定引入”目录,相关的机制会进行判断。但是,“绑定引入”毕竟不是很可靠的,万一发现版本不符就不能使用原先的绑定了。所以“绑定引入”不能单独存在,而必须有普通引入作为后备。如果不符就不能按“绑定引入”目录处理引入,而只好退而求其次,改成按普通“引入”目录处理引入。另一方面,所谓“绑定”是指当被引入模块装入在预定位置上时的地址绑定,如果被引入模块的装入位置变了,就得对原先所绑定的地址作相应的调整、即“重定位”。
LdrFixupImports()函数首先从映像头部获取指向“引入”目录和“绑定引入”目录的指针。若存在“绑定引入”目录,则先通过LdrpGetOrLoadModule()找到或装入(映射)被引入模块的映像。首先当然是在模块队列中寻找,找不到就从被引入模块的磁盘文件装入。之后检查绑定版本是否一致,如果不一致就退而求其次,通过LdrpProcessImportDirectory()处理引入。当然,那样一来效率就要降低了。如果一致,则返回(因为在“预演”中已经连接好,效率当然高了)。而LdrpProcessImportDirectory()才是真正意义上的动态连接!!(说了这么多原来才开始……)。
LdrpProcessImportDirectory()首先根据目录项中的两个位移量取得分别指向函数名字符串数组和函数指针数组的指针。这两个数组是平行的(前面有介绍),然后对字符串数组中的元素计数,得到该数组的大小IATSize。显然,函数指针数组的大小也是IATSize。这里IAT是“引入地址表(Imported Address Table)”的缩写,其实就是函数指针数组。这个数组在映像内部,其所在的页面在装入映像时已被加上写保护,而下面要做的事正是要改变这些指针的值,所以先要通过NtProtectVirtualMemory()把这些页面的访问模式改成可读可写。做完这些准备之后,下面就是连接的过程了,那就是根据需要把被引入模块所引出的函数入口(地址)填写到引入者模块的IAT中。与当前模块中的两个数组相对应,在被引入模块的“引出”目录中也有两个数组,说明本模块引出函数的名称和入口地址(在映像中的位移)。当然,这两个数组也是平行的。要获取被引入模块中的函数入口有两种方法,即按序号(Ordinal)引入和按函数名引入。从而分别调用LdrGetExportByOrdinal()和LdrGetExportByName()。这两个函数都返回目标函数在本进程用户空间中的入口地址,把它填写入当前模块引入目录函数指针数组中的相应元素,就完成了一个函数的连接。当然,同样的操作要循环实施于当前模块需要从给定模块引入的所有函数,并且(在上一层)循环实施于所有的被引入模块。完成了对一个被引入模块的连接之后,又调用NtProtectVirtualMemory()恢复当前模块中给定目录项内函数指针数组所在页面的保护。
到此,我们大概的清楚Windows Dll的加载与连接过程。
以上的Dll加载与连接过程有点抽象与混乱,在这里进行总结,绝体的函数加载关系如下: