程序员的自我修养--链接、装载与库笔记:Windows下的动态链接

Windows下的PE的动态链接与Linux下的ELF动态链接相比,有很多类似的地方,但也有很多不同的地方。

1. DLL简介

DLL即动态链接库(Dynamic-Link Library)的缩写,它相当于Linux下的共享对象Windows系统中大量采用了这种DLL机制,甚至包括Windows的内核的结构都很大程度依赖于DLL机制Windows下的DLL文件和EXE文件实际上是一个概念,它们都是有PE格式的二进制文件,稍微有些不同的是PE文件头部中有个符号位表示该文件是EXE或是DLL,而DLL文件的扩展名不一定是.dll,也有可能是别的比如.ocx(OCX控件)或是.CPL(控制面板程序)。

DLL的设计目的与共享对象有些出入,DLL更加强调模块化,即微软希望通过DLL机制加强软件的模块化设计,使得各种模块之间能够松散地组合、重用和升级。所以我们在Windows平台上看到大量的大型软件都通过升级DLL的形式进行自我完善,微软经常将这些升级补丁积累到一定程度以后完成一个软件更新包(Service Packs)。比如我们常见的微软Office系列、Visual Studio系列、Internet Explorer甚至Windows本身也通过这种方式升级。另外,ELF的动态链接可以实现运行时加载,使得各种功能模块能以插件的形式存在。在Windows下,也有类似ELF的运行时加载,这种技术在Windows下被应用的更加广泛,比如ActiveX技术就是基于这种运行时加载机制实现的

进程地址空间和内存管理:Windows支持进程拥有独立的地址空间,一个DLL在不同的进程中拥有不同的私有数据副本,就像ELF共享对象一样。在ELF中,由于代码段是地址无关的,所以它可以实现多个进程之间共享一份代码,但是DLL的代码却并不是地址无关的,所以它只是在某些情况下可以被多个进程间共享

基地址和RVA:PE里面有两个很常用的概念就是基地址(Base Address)和相对地址(RVA, Relative Virtual Address)。当一个PE文件被装载时,其进程地址空间中的起始地址就是基地址。对于任何一个PE文件来说,它都有一个优先装载的基地址,这个值就是PE文件头中的Image Base。Windows在装载DLL时,会先尝试把它装载到由Image Base指定的虚拟地址;若该地址区域已被其它模块占用,那PE装载器会选用其它空闲地址。而相对地址就是一个地址相对于基地址的偏移。

DLL共享数据段:Windows系统提供了一系列API可以实现进程间的通信。其中有一种方法是使用DLL来实现进程间通信。正常情况下,每个DLL的数据段在各个进程中都是独立的,每个进程都拥有自己的副本。但是Windows允许将DLL的数据段设置成共享的,即任何进程都可以共享该DLL的同一份数据段。当然很多时候比较常见的做法是将一些需要进程间共享的变量分离出来,放到另外一个数据段中,然后将这个数据段设置成进程间可共享的。也就是说一个DLL中有两个数据段,一个进程间共享,另外一个私有。当然这种进程间共享方式也产生了一定的安全漏洞,因为任意一个进程都可以访问这个共享的数据段,那么只要破坏了该数据段的数据就会导致所有使用该数据段的进程出现问题。

DLL的简单例子:在ELF中,共享库中所有的全局函数和变量在默认情况下都可以被其它模块使用,也就是说ELF默认导出所有的全局符号。但是在DLL中情况有所不同,我们需要显示地”告诉”编译器我们需要导出某个符号,否则编译器默认所有符号都不导出(Export)当我们在程序中使用DLL导出的符号时,这个过程被称为导入(Import)

Microsoft Visual C++(MSVC)编译器提供了一系列C/C++的扩展来指定符号的导入导出,对于一些支持Windows平台的编译器比如Intel C++、GCC Windows版(MinGW GCC, Cygwin GCC)等都支持这种扩展。我们可以通过”__declspec”属性关键字来修饰某个函数或者变量,当我们使用”__declspec(dllexport)”时表示该符号是从本DLL导出的符号,”__declspec(dllimport)”表示该符号是从别的DLL导入的符号。在C++中,如果你希望导入或者导出的符号符合C语言的符号修饰规范,那么必须在这个符号的定义之前加上extern “C”,以防止C++编译器进行符号修饰。

除了使用”__declspec”扩展关键字指定导入导出符号之外,我们也可以使用”.def”文件来声明导入导出符号。”.def”扩展名的文件是类似于ld链接器的链接脚本文件,可以被当作link链接器的输入文件,用于控制链接过程。”.def”文件中的IMPORT或者EXPORTS段可以用来声明导入导出符号,这个方法不仅对C/C++有效,对其它语言也有效。

创建DLL:假设我们的一个DLL提供3个数学运算的函数,分别是加(Add)、减(Sub)、乘(Mul),它的源代码如下(Math.c):

__declspec(dllexport) double Add(double a, double b)
{
	return (a + b);
}

__declspec(dllexport) double Sub(double a, double b)
{
	return (a - b);
}

__declspec(dllexport) double Mul(double a, double b)
{
	return (a * b);
}

使用MSVC(VS2013)的编译器cl.exe进行编译,打开cmd,执行命令及结果结果如下图所示:参数/LDd表示生成Debug版的DLL,不加任何参数则表示生成EXE可执行文件;我们可以使用/LD来编译生成Release版的DLL。编译的结果生成了”Math.dll”、”Math.obj”、”Math.exp”和”Math.lib”这4个文件。

我们可以通过dumpbin工具查看DLL的导出符号,执行命令及结果如下图所示:可以看到DLL有3个导出函数以及它们的相对地址。关于dumpbin工具的方法可参考:https://blog.csdn.net/fengbingchun/article/details/43956673

使用DLL程序使用DLL的过程其实是引用DLL中的导出函数和符号的过程,即导入过程。对于从其它DLL导入的符号,我们需要使用”__declspec(dllimport)”显示地声明某个符号为导入符号。这与ELF中的情况不一样,在ELF中,当我们使用一个外部模块的符号的时候,我们不需要额外声明该变量是从其它共享对象导入的。以下是使用Math.dll的例子,文件TestMath.c的内容如下:

#include <stdio.h>

__declspec(dllimport) double Sub(double a, double b);

int main(int argc, char** argv)
{
	double result = Sub(3.0, 2.0);
	printf("Result = %f\n", result);
	return 0;
}

执行命令及结果如下图所示:使用编译器cl.exe将TestMath.c编译成TestMath.obj,然后使用链接器link.exe将TestMath.obj和Math.lib链接在一起产生一个可执行文件TestMath.exe。在最终链接时,我们必须把与DLL一起产生的Math.lib与TestMath.obj链接起来,形成最终的可执行文件。在静态链接的时候,”.lib”文件是一组目标文件的集合,在动态链接里面这一点仍然没有错,但是Math.lib中并不真正包含Math.c的代码和数据,它用来描述Math.dll的导出符号,它包含了TestMath.obj链接Math.dll时所需要的导入符号以及一部分”桩”代码,又被称作”胶水”代码,以便于将程序与DLL粘在一起。像Math.lib这样的文件又被称为导入库(Import Library)。

使用模块定义文件:声明DLL中的某个函数为导出函数的办法有两种,一种就是”__declspec(dllexport)”扩展;另外一种就是采用模块定义(.def)文件声明。实际上.def文件在MSVC链接过程中的作用与链接脚本文件(Link Script)文件在ld链接过程中的作用类似,它是用于控制链接过程,为链接器提供有关链接程序的导出符号、属性以及其它信息。不过相比于ld的链接脚本文件,.def文件的语法简单的多,而且功能也更少。

将Math.c中的所有”__declspec(dllexport)”去掉改名为Math2.c文件,然后创建一个Math2.def文件,内容如下:

LIBRARY Math
EXPORTS
Add
Sub
Mul

使用以下命令行来编译Math2.c,执行结果如下图所示:这样编译器(更准确地讲是link.exe链接器)就会使用Math2.def文件中的描述产生最终输出文件。使用.def文件来描述DLL文件的导出属性好处:首先,我们可以控制导出符号的符号名。很多时候,编译器会对源程序里面的符号进行修饰,比如C++程序里面的符号经过编译器的修饰以后,都变得面目全非。除了C++程序以外,C语言的符号也有可能被修饰,比如MSVC支持几种函数的调用规范”__cdecl”、”__stdcall”、”__fastcall”,默认情况下MSVC把C语言的函数当作”__cdecl”类型,这种情况下它对该函数不进行任何符号修饰。但是一旦我们使用其它的函数调用规范时,MSVC编译器就会对符号名进行修饰,比如使用”__stdcall”调用规范的函数Add就会被修饰成”_Add@16”,前面以”_”开头,后面以”@n”结尾,n表示函数调用时参数所占堆栈空间的大小。使用.def文件可以将导出函数重新命名。当一个DLL被多个语言编写的模块使用时,采用这种方法导出一个函数往往会很有用。我们经常看到Windows的API都采用”WINAPI”这种方式声明,而”WINAPI”实际上是一个被定义为”__stdcall”的宏。微软以DLL的形式提供Windows的API,而每个DLL中的导出函数又以这种”__stdcall”的方式被声明。与ld的链接控制脚本类似,使用.def文件的另外一个优势是它可以控制一些链接的过程。在.def文件中除了支持”LIBRARY”、”EXPORTS”等关键字以外,还支持诸如”HEAPSIZE”、“NAME”、”SECTIONS”、”STACKSIZE”、”VERSION”等关键字,通过这些关键字可以控制输出文件的默认堆大小、输出文件名、各个段的属性、默认堆栈大小、版本号等。

DLL显示运行时链接:ELF类似,DLL也支持运行时链接,即运行时加载。Windows提供了3个API为:(1). LoadLibrary(或者LoadLibraryEx),这个函数用来装载一个DLL到进程的地址空间,它的功能跟dlopen类似;(2). GetProcAddress,用来查找某个符号的地址,与dlsym类似;(3). FreeLibrary,用来卸载某个已加载的模块,与dlclose类似。

2. 符号导出导入表

导出表:当一个PE需要将一些函数或变量提供给其它PE文件使用时,我们把这种行为叫做符号导出(Symbol Exporting),最典型的情况就是一个DLL将符号导出给EXE文件使用。EFL将导出的符号保存在”.dynsym”段中,供动态链接器查找和使用。Windows PE中,符号导出的概念也是类似,所有导出的符号被集中存放在了被称作导出表(Export Table)的结构中。事实上导出表从最简单的结构上来看,它提供了一个符号名与符号地址的映射关系,即可以通过某个符号查找相应的地址。基本上这些每个符号都是一个ASCII字符串。符号名可能跟相应的函数名或者变量名相同,也可能不同,因为有符号修饰这个机制存在。

PE文件头中有一个叫做DataDirectory的结构数组,这个数组共有16个元素,每个元素中保存的是一个地址和一个长度。其中第一个元素就是导出表结构的地址和长度。导出表是一个IMAGE_EXPROT_DIRECTORY的结构体,它被定义在”winnt.h”中,如下所示:

// Export Format
typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;
    DWORD   Base;
    DWORD   NumberOfFunctions;
    DWORD   NumberOfNames;
    DWORD   AddressOfFunctions;     // RVA from base of image
    DWORD   AddressOfNames;         // RVA from base of image
    DWORD   AddressOfNameOrdinals;  // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

导出表结构中,最后的3个成员指向的是3个数组,这3个数组是导出表中最重要的结构,它们是导出地址表(EAT, Export Address Table)、符号名表(Name Table)和名字序号对应表(Name-Ordinal Table)。EAT存放的是各个导出函数的RVA。符号名表(或函数名表)保存的是导出函数的名字,这个表中,所有的函数名是按照ASCII顺序排序的,以便于动态链接器在查找函数名字时可以速度更快(可以使用二分法查找)。

序号(Ordinals):一个导出函数的序号就是函数在EAT中的地址下标加上一个Base值(也就是IMAGE_EXPORT_DIRECTORY中的Base,默认情况下它的值是1)。比如,Mul的RVA为0x1020,它在EAT中的下标是1,加上一个Base值1,Mul的导出序号为2。如果一个模块A导入了Math.dll中的Add,那么它在导入表中将不保存”Add”这个函数名,而是保存Add函数的序号,即1。当动态链接器进行链接时,它只需要根据模块A的导入表中保存的序号1,减去Math.dll的Base值,得到下标0,然后就可以直接在Math.dll的EAT中找到Add函数的RVA。使用序号导入导出的好处是明显的,那就是省去了函数名查找过程,函数名表也不需要保存在内存中了。使用序号导入导出的最大问题是一个函数的序号可能会变化。假设某一次更新中,Math.dll里面添加了一个函数或者删除了一个函数,那么原先函数的序号可能会因此发生变化,从而导致已有的应用程序运行出现问题。一种解决的方案是,由程序员手工指定每个导出函数的序号,比如我们指定Add的导出序号为1,Mull为2,Sub为3,以后加入函数则指定一个与其它函数不同的唯一的序号,如果删除一个函数,那么保持现有函数的序号不变。这种手工指定函数导出序号的方法可以通过链接器的.def文件实现。由程序员手工维护导出序号的方法在实际操作中颇为麻烦。于是现在的DLL基本都不采用序号作为导入导出的手段,而是直接使用符号名。虽然现在的DLL导出方式基本都是使用符号名,但是实际上序号的导出方式仍然没有被抛弃。为了保持向后兼容性,序号导出方式仍然被保留,相反,符号名作为导出方式是可选的。一个DLL中的每一个导出函数都有一个对应唯一的序号值,而导出函数名却是可选的,也就是说一个导出函数肯定有一个序号值(序号值是肯定有的,因为函数在EAT的下标加上Base就是序号值),但是可以没有函数名。

名字序号对应表拥有与函数名表一样多数目的元素,每个元素就是对应的函数名表中的函数名所对应的序号值。实际上它就是一个函数名与序号的对应关系表。

link.exe链接器提供了一个”/EXPORT”的参数可以指定导出符号,如下图结果所示:表示在产生Math2.dll时导出符号Add。

另外一种导出符号的方法是使用MSVC的__declspec(dllexport)扩展,它实际上是通过目标文件的编译器指示来实现的。对于Math.obj来说,它实际上在”.drectve”段中保存了3个”/EXPORT”参数,用于传递给链接器,告知链接器导出相应的函数,如下图所示:

EXP文件:在创建DLL的同时也会得到一个EXP文件,这个文件实际上是链接器在创建DLL时的临时文件。链接器在创建DLL时与静态链接时一样采用两遍扫描过程,DLL一般都有导出符号,链接器在第一遍时会遍历所有的目标文件并且收集所有导出符号信息并且创建DLL的导出表。为了方便起见,链接器把这个导出表放到一个临时的目标文件叫做”.edata”的段中,这个目标文件就是EXP文件,EXP文件实际上是一个标准的PE/COFF目标文件,只不过它的扩展名不是.obj而是.exp。在第二遍时,链接器就把这个EXP文件当作普通目标文件一样,与其它输入的目标文件链接在一起并且输出DLL。这时候EXP文件中的”.edata”段也就会被输出到DLL文件中并且成为导出表。不过一般现在链接器很少会在DLL中单独保留”.edata”段,而是把它合并到只读数据段”.rdata”中。

导出重定向:DLL有一个很有意思的机制叫做导出重定向(Export Forwarding),就是将某个导出符号重定向到另外一个DLL。比如在Windows XP系统中,KERNEL32.DLL中的HeapAlloc函数被重新定向到了NTDLL.DLL中的RtlAllocHeap函数,调用HeapAlloc函数相当于调用RtlAllocHeap函数。导出重定向的实现机制也很简单,正常情况下,导出表的地址数组中包含的是函数的RVA,但是如果这个RVA指向的位置位于导出表中(我们可以得到导出表的起始RVA和大小),那么表示这个符号被重定向了。被重定向了的符号的RVA并不代表该函数的地址,而是指向一个ASCII的字符串,这个字符串在导出表中,它是符号重定向后的DLL文件名和符号名。

导入表:如果我们在某个程序中使用到了来自DLL的函数或者变量,那么我们就把这种行为叫做符号导入(Symbol Importing)。在ELF中,”.rel.dyn”和”.rel.plt”两个段中分别保存了该模块所需要导入的变量和函数的符号以及所在的模块等信息,而”.got”和”.got.plt”则保存着这些变量和函数的真正地址。Windows中也有类似的机制,它的名称更为直接,叫做导入表(Import Table)。当某个PE文件被加载时,Windows加载器的其中一个任务就是将所有需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接的过程。我们可以使用dumpbin来查看一个模块依赖于哪些DLL,又导入了哪些函数,如下图所示:可以看到Math.dll从Kernel32.dll中导入了诸如GetCurrentThreadId、GetCommandLineA等函数。Math.c里面没有用到这些函数,怎么会出现在导入列表之中?这是由于我们在构建Windows DLL时,还链接了支持DLL运行的基本运行库,这个基本运行库需要用到Kernel32.dll,所以就有了这些导入函数

在Windows中,系统的装载器会确保任何一个模块的依赖条件都得到满足,即每个PE文件所依赖的文件都将被装载。比如一般Windows程序都会依赖于Kernel32.dll,而Kernel32.dll又会导入NTDLL.DLL,即依赖于NTDLL.DLL,那么Windows在加载该程序时确保这两个DLL都被加载。Windows将会保证这些依赖关系的正确,并且保证所有的导入符号都被正确地解析。在这个动态链接过程中,如果某个被依赖的模块无法正确加载,那么系统将会提示错误(我们经常会看到那种”缺少某个DLL”之类的错误),并且终止运行该进程。

在PE文件中,导入表是一个IMAGE_IMPORT_DESCRIPTOR的结构体数组,每一个IMAGE_IMPORT_DESCRIPTOR结构对应一个被导入的DLL。这个结构体被定义在”winnt.h”中,如下所示:

// Import Format
typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;
    CHAR   Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            // 0 for terminating null import descriptor
        DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;                  // 0 if not bound,
                                            // -1 if bound, and real date\time stamp
                                            //     in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
                                            // O.W. date/time stamp of DLL bound to (Old BIND)

    DWORD   ForwarderChain;                 // -1 if no forwarders
    DWORD   Name;
    DWORD   FirstThunk;                     // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;

结构体中的FirstThunk指向一个导入地址数组(Import Address Table),IAT是导入表中最重要的结构,IAT中每个元素对应一个被导入的符号,元素的值在不同的情况下有不同的含义。在动态链接器刚完成映射还没有开始重定位和符号解析时,IAT中的元素值表示相对应的导入符号的序号或者是符号名;当Windows的动态链接器在完成该模块的链接时,元素值会被动态链接器改写成该符号的真正地址,从这一点看,导入地址数组与ELF中的GOT非常类似。

如何判断导入地址数组的元素中包含的是导入符号的序号还是符号的名字?我们可以看这个元素的最高位,对于32为的PE来说,如果最高位被置1,那么低31位值就是导入符号的序号值;如果没有,那么元素的值是指向一个叫做IMAGE_IMPORT_BY_NAME结构的RVA。IMAGE_IMPORT_BY_NAME是由一个WORD和一个字符串组成,那个WORD值表示”Hint”值,即导入符号最有可能的序号值,后面的字符串是符号名。当使用符号名导入时,动态链接器会先使用”Hint“值的提示去定位该符号在目标导出表中的位置,如果刚好是所需要的符号,那么就命中;如果没有命中,那么就按照正常的二分查找方式进行符号查找。

在IMAGE_IMPORT_DESCRIPTOR结构中,还有一个指针OriginalFirstThrunk指向一个数组叫做导入名称表(Import Name Table),简称INT。这个数组跟IAT一模一样,里面的数值也一样。

Windows的动态链接器会在装载一个模块的时候,改写导入表中的IAT,这一点很像ELF中的.got。其区别是,PE的导入表一般是只读的,它往往位于”.rdata”这样的段中。对于一个只读的段,动态链接器是怎么改写它的呢?解决方法是这样的,对于Windows来说,由于它的动态链接器其实是Windows内核的一部分,所以它可以随心所欲地修改PE装载以后的任意一部分内容,包括内容和它的页面属性。Windows的做法是,在装载时,将导入表所在的位置的页面改成可读写的,一旦导入表的IAT被改写完毕,再将这些页面设回至只读属性。从某些角度来看,PE的做法比ELF要更加安全一些,因为ELF运行程序随意修改.got,而PE则不允许。

延迟载入(Delayed Load):Visual C++ 6.0开始引入了一个叫做延迟载入的新功能,这个功能有点类似于隐式装载和显示装载的混合体。当你链接一个支持延迟载入的DLL时,链接器会产生与普通DLL导入非常类似的数据。但是操作系统会忽略这些数据。当延迟载入的API第一次被调用时,由链接器添加的特殊的桩代码就会启动,这个桩代码负责对DLL的装载工作。然后这个桩代码通过调用GetProcAddress来找到被调用API的地址。另外MSVC还做了一些额外的优化,使得接下来的对该DLL的调用速度与普通方式载入的DLL的速度相差无异。

导入函数的调用:如果在PE的模块中需要调用一个导入函数,仿照ELF GOT机制的一个办法就是使用一个简洁调用指令。IAT相当于GOT(不考虑PLT的情况下)。

PE DLL的代码段并不是地址无关的。那么PE是如何解决装载时模块在进程空间中地址冲突的问题的呢?事实上它使用了一种叫做重定基地址的方法。

为了使得编译器能够区分函数是从外部导入的还是模块内部定义的,MSVC引入了扩展属性”__declspec(dllimport)”,一旦一个函数被声明为”__declspec(dllimport)”,那么编译器就知道它是外部导入的,以便于产生相应的指令形式。在”__declspec”关键字引入之前,微软还提供了另外一个方法来解决这个问题。在这种情况下,对于导入函数的调用,编译器并不区分导入函数和导出函数,它统一地产生直接调用的指令。但是链接器在链接时会将导入函数的目标地址导向一小段桩代码(Stub),由这个桩代码再将控制权交给IAT中的真正目标地址。

编译器在产生导入库时,同一个导出函数会产生两个符号的定义,比如对于函数foo来说,它在导入库中有两个符号,一个是foo,另外一个是__imp__foo。这两个符号的区别是,foo这个符号指向foo函数的桩代码,而__imp__foo指向foo函数在IAT中的位置。所以当我们通过”__declspec(dllimport)”来声明foo导入函数时,编译器在编译时会在该导入函数前加上前缀”__imp__”,以确保跟导入库中的”__imp__foo”能够正确链接;如果不使用”__declspec(dllimport)”,那么编译器将产生一个正常的foo符号引用,以便于跟导入库中的foo符号定义相链接。现在的MSVC编译器对于以上两种导入方式都支持,即程序员可以通过”__declspec(dllimport)”来声明导入函数,也可以不使用。

3. DLL优化

DLL的代码段和数据段本身并不是地址无关的,也就是说它默认需要被装载到由ImageBase指定的目标地址中。如果目标地址被占用,那么就需要装载到其它地址,便会引起整个DLL的Rebase。这对于拥有大量DLL的程序来说,频繁的Rebase也会造成程序启动速度减慢。动态链接过程中,导入函数的符号在运行时需要被逐个解析。在这个解析过程中,免不了会涉及到符号字符串的比较和查找过程,这个查找过程中,动态链接器会在目标DLL的导出表中进行符号字符串的二分查找。即使是使用了二分查找法,对于拥有DLL数量很多,并且有大量导入导出符号的程序来说,这个过程仍然是非常耗时的。这两个原因可能会导致应用程序的速度非常慢,因为系统需要在启动程序时进行大量的符号解析和Rebase工作。

重定基地址(Rebasing):PE的DLL中的代码段并不是地址无关的,也就是说它在被装载时有一个固定的目标地址,这个地址也就是PE里面所谓的基地址(Base Address)。默认情况下,PE文件将被装载到这个基地址。一个进程中,多个DLL不可以被装载到同一个虚拟地址,每个DLL所占用的虚拟地址区域之间都不可以重叠。Windows PE采用了一种与ELF不同的办法,它采用的是装载时重定位的方法。在DLL模块装载时,如果目标地址被占用,那么操作系统就会为它分配一块新的空间,并且将DLL装载到该地址。因为DLL的代码段不是地址无关的,DLL中所有涉及到绝对地址的引用该怎么办呢?答案是对于每个绝对地址引用都进行重定位。当然,这个重定位过程有些特殊,因为所有这些需要重定位的地方只需要加上一个固定的差值,也就是说加上一个目标装载地址与实际装载地址的差值。事实上,由于DLL内部的地址都是基于基地址的,或者是相对于基地址的RVA。那么所有需要重定位的地方都需要加上一个固定差值。所以这个重定位的过程相对简单一点,速度也要比一般的重定位要快。PE里面把这种特殊的重定位过程又被叫做重定基地址。PE文件的重定位信息都放在了”.reloc”段,我们可以从PE文件头中的DataDirectory里面得到重定位段的信息。重定位段的结构跟ELF中的重定位结构十分类似。对于EXE文件来说,MSVC编译器默认不会产生重定位段,也就是默认情况下,EXE是不可以重定位的,不过这也没有问题,因为EXE文件是进程运行时第一个装入的虚拟空间的,所以它的地址不会被人抢占。而DLL则没那么幸运了,它们被装载的时间是不确定的,所以一般情况下,编译器都会给DLL文件产生重定位信息。当然也可以使用”/FIXED”参数来禁止DLL产生重定位信息,不过那样可能会造成DLL的装载失败。

改变默认基地址:前面的重定基地址过程实际上是在DLL文件装载时进行的,所以又叫做装载时重定位MSVC的链接器提供了指定输出文件的基地址的功能,可以在链接时使用link.exe命令中的”/BASE”参数指定基地址。这个基地址必须是64K的倍数,如果不是64K的倍数,链接器将发生错误。除了在链接时可以指定DLL的基地址以外,MSVC还提供了一个叫做editbin.exe的工具,这个工具可以用来改变已有的DLL的基地址

系统DLL:由于Windows系统本身自带了很多系统的DLL,比如kernel32.dll、ntdll.dll、shell32.dll等,这些DLL基本上是Windows的应用程序运行时都要用到的。Windows系统就在进程空间中专门划出一块区域,用于映射这些常用的系统DLL。Windows在安装时就把这块地址分配给这些DLL,调整这些DLL的基地址使得它们相互之间不冲突,从而在装载时就不需要进行重定基址了。

序号:一个DLL中每一个导出的函数都有一个对应的序号(Ordinal Number)。一个导出函数甚至可以没有函数名,但它必须有一个唯一的序号。另一方面,当我们从一个DLL导入一个函数时,可以使用函数名,也可以使用序号。序号标示被导出函数地址在DLL导出表中的位置。一般来说,那些仅供内部使用的导出函数,它只有序号没有函数名,这样外部使用者就无法推测它的含义和使用方法,以防止误用。对于大多数Windows API函数来说,它们的函数名在各个Windows版本之间是保持不变的,但是它们的序号是在不停地变化的。所以,如果我们导入Windows API的话,绝对不能使用序号作为导入方法。在产生一个DLL文件时,我们可以在链接器的.def文件中定义导出函数的序号。一般情况下并不推荐使用序号作为导入导出的手段。

导入函数绑定:DLL绑定(DLL Binding)方法可以使用editbin.exe工具对EXE或DLL进行绑定,执行命令及结果如下图所示:editbin.exe对被绑定的程序的导入符号进行遍历查找,找到以后就把符号的运行时的目标地址写入到被绑定程序的导入表内。INT数组就是用来保存绑定符号的地址的。

导致DLL绑定地址失效的情况:一种情况是,被依赖的DLL更新导致DLL的导出函数地址发生变化;另外一种情况是,被依赖的DLL在装载时发生重定基址,导致DLL的装载地址与被绑定时不一致。Windows提供了相应的机制来保证绑定地址失效时,程序还能够正确运行。对于第一种情况的失效,PE的做法是这样的,当对程序进行绑定时,对于每个导入的DLL,链接器把DLL的时间戳(Timestamp)和校验和(Checksum,比如MD5)保存到被绑定的PE文件的导入表中。在运行时,Windows会核对将要被装载的DLL与绑定时的DLL版本是否相同,并且确认该DLL没有发生重定基址,如果一切正常,那么Windows就不需要再进行符号解析过程了,因为被装载的DLL与绑定时一样,没有发生变化;否则Windows就忽略绑定的符号地址,按照正常的符号解析过程对DLL的符号进行解析。

事实上,Windows系统所附带的程序都是与它所在的Windows版本的系统DLL绑定的。除了在编译时可以绑定程序,另外一个绑定程序的很好的机会是在程序安装的时候,这样至少在DLL升级之前,这些”绑定”都是有效的。当然,绑定过程会改变可执行文件本身,从而导致了可执行文件的校验和变化,这对于一些经过加密的,或者是经过数字签名的程序来说可能会有问题。

4. C++与动态链接

5. DLL HELL

DLL跟ELF类似也有版本更新时发生不兼容的问题。三种可能的原因导致了DLL Hell(DLL噩梦)的发生:(1).使用旧版本的DLL替代原来一个新版本的DLL而引起。(2).由新版DLL中的函数无意发生改变而引起。(3).由新版DLL的安装引入一个新BUG。

解决DLL Hell的方法

(1). 静态链接(Static linking):在编译产生应用程序时使用静态链接的方法链接它所需要的运行库,从而避免使用动态链接。这样,在运行应用程序时候就不需要依赖DLL了。然而,它会丧失使用动态链接带来的好处。

(2). 防止DLL覆盖(DLL Stomping):在Windows中,DLL的覆盖问题可以使用Windows文件保护(Windows File Protection,简称WFP)技术来缓解。它能阻止未经授权的应用程序覆盖系统的DLL。第三方应用程序不能覆盖操作系统DLL文件,除非它们的安装程序捆绑了Windows更新包,或者在它们的安装程序运行时禁止了WFP服务(当然这是一件非常危险的事情)。

(3). 避免DLL冲突(Conflicting DLLs):解决不同应用程序依赖相同DLL不同版本的问题一个方案就是,让每个应用程序拥有一份自己依赖的DLL,并且把DLL的不同版本放到该应用程序的文件夹中,而不是系统文件夹中。当应用程序需要装载DLL时候,首先从自己的文件夹下寻找所需要的DLL,然后再到系统文件中寻找。

(4). .NET下DLL Hell的解决方案:在.NET框架中,一个程序集(Assembly)有两种类型:应用程序程序(也就是exe可执行文件)集以及库程序(也就是DLL动态链接库)集。一个程序集包括一个或多个文件,所以需要一个清单文件来描述程序集。这个清单文件叫做Manifest文件。Manifest文件描述了程序集的名字、版本号以及程序集的各种资源,同时也描述了该程序集的运行所依赖的资源,包括DLL以及其它资源文件等。Manifest是一个XML的描述文件。每个DLL有自己的manifest文件,每个应用程序也有自己的Manifest。对于应用程序而言,manifest文件可以和可执行文件在同一目录下,也可以是作为一个资源嵌入到可执行文件的内部(Embed Manifest)。在XP以后的操作系统,在执行可执行文件时则会首先读取程序集的manifest文件,获得该可执行文件需要调用的DLL列表,操作系统再根据DLL的manifest文件去寻找对应的DLL并调用。

GitHubhttps://github.com/fengbingchun/Messy_Test

发布了718 篇原创文章 · 获赞 1131 · 访问量 609万+

猜你喜欢

转载自blog.csdn.net/fengbingchun/article/details/101719347
今日推荐