《程序员的自我修养》(二)——装载与动态链接

装载与动态链接

可执行文件的装载与进程

  • 每个程序都拥有自己独立的虚拟地址空间,这个空间大小由计算机硬件平台决定(理论上的最大上限)。比如,32位硬件平台的虚拟地址空间的地址为0到232-1,即0x00000000~0xFFFFFFFF,总共大概4G;而64位硬件平台的虚拟地址空间地址为0到264-1,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,大概有17179869184G。在32位平台上,Linux操作系统中4G的虚拟地址空间会被划分为两个部分,从0xC0000000到0xFFFFFFFF共1G的地址空间被分配给了操作系统,剩下的从0x00000000到0xBFFFFFFF共3G的地址空间是留给进程的。从原则上讲,我们进程最多能使用3G的虚拟地址空间。对于Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2G,进程只剩下2G。对于一些程序来说2G虚拟空间太小,所以Windows有个启动参数可以将操作系统占用的虚拟地址空间减少到1G。方法如下:修改Windows系统盘根目录下的boot.ini,加上“/3G”参数。

  • 动态装载的两种典型方法是覆盖装入和页映射,覆盖装入在没有发明虚拟存储之前使用比较广泛,现在已经几乎被淘汰了。页映射简单的说就是操作系统将程序需要使用的页按一定的算法动态映射到物理内存中执行。
  • 从操作系统的角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。一个进程的建立有三步:

    • 首先是创建虚拟地址空间。
    • 读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。(可执行文件装载中最重要的一步,也是传统意义上的“装载”)
    • 将CPU指令寄存器设置成可执行文件入口,启动运行。
  • 我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。

  • ELF文件被映射时,是以系统的页长度作为单位的。为避免内存浪费,操作系统在装载可执行文件时主要关心的只是文件中段的权限(可读、可写、可执行)。对于相同权限的段,把它们合并到一起当作一个段进行映射。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA),在Windows中将这个叫做虚拟段(Virtual Section)。很多情况下,一个进程中的堆和栈分别都有一个对应的VMA。操作系统在进程启动前会将系统的环境变量和进程的运行参数提前保存到进程的虚拟空间的栈中(也就是VMA中的stack VMA)。
  • PE文件的装载和ELF有所不同,在PE文件中,所有段的起始地址都是页的倍数,段的长度如果不是页的整数倍,那么在映射时向上补齐到页的整数倍。由于这个特点,PE文件的映射过程比ELF简单得多,因为它无需考虑如ELF里面诸多段地址对齐之类的问题,虽然这样会浪费一些磁盘和内存空间。
  • PE文件中,链接器在生产可执行文件时,往往将所有的段尽可能地合并,所以一般只有代码段、数据段、只读数据段和BSS等为数不多的几个段。
  • 每个PE文件在装载时都会有一个装载目标地址,这个地址就是基地址,基地址不是固定的,每次装载时都可能会变化。所以PE文件中有一个常见术语叫相对虚拟地址(RVA),它是相对于PE文件的装载基地址的一个偏移地址。这样无论基地址怎么变化,PE文件中的各个RVA都保持一致。
  • WIndows PE文件的装载过程:

    • 先读取文件的第一个页(包含DOS头,PE文件头和段表)。
    • 检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。(主要针对DLL装载)
    • 使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。
    • 如果装载地址不是目标地址,则进行Rebasing
    • 装载所有PE文件所需要的DLL文件。
    • 对PE文件中的所有导入符号进行解析。
    • 根据PE头中指定的参数,建立初始化堆和栈。
    • 建立主线程并且启动进程。

动态链接

  • 为什么要动态链接?

    • 静态链接的方式对于计算机内存和磁盘的空间浪费非常严重。
    • 静态链接对于程序的更新、部署和发布也会带来很多麻烦。
  • 在Linux系统中,ELF动态链接文件被成为动态共享对象(DSO),简称共享对象,它们一般都是以“.so”为扩展名的一些文件;而在Windows系统中,动态链接文件被成为动态链接库(DLL),它们通常是以“.dll”为扩展名的文件。
  • 静态链接的重定位叫链接时重定位(Link Time Relocation),而动态链接的重定位为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)。在Linux和GCC中只要使用“-shared”参数,输出的共享对象就是使用的装载时重定位。
  • 把指令中那些需要修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,这种方案就是地址无关代码(PIC)技术。在Linux共享对象中要生成地址无关代码只用在编译是带上参数-fPIC。

  • 上面的情况并没有包括定义在共享模块内部的全局变量。ELF共享库在编译时,默认都把定义在模块内部的全局变量当做定义在其他模块的全局变量,也就是说当做上图中的类型(4),通过GOT来实现变量的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把GOT中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到主模块中的变量副本;如果该全局变量在程序主模块中没有副本,那么GOT中的相应地址就指向模块内部的该变量副本。
  • 对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,这个重定位表里面包含了“R_386_RELATIVE”类型的重定位入口。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。
  • 我们在编译共享对象时如果使用“-fPIC”参数,就表示要产生地址无关的代码段。GCC编译动态链接的可执行文件会默认带上该参数的。如果不使用该参数就会产生一个装载时重定位的共享对象,它的代码段就不是地址无关的,也就不能被多个进程之间共享,于是就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。
  • 动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转,这可能会导致程序启动或者运行速度减慢,所以我们需要优化动态链接性能。
  • ELF采用延迟绑定来优化动态链接性能,基本思想是当函数第一次被用到时才进行绑定(符号查找、重定位等)。具体方法是使用了PLT(Procedure Linkage Table)。PLT为GOT间接跳转又增加了一个中间层,在调用某个外部模块的函数时,并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项。(汇编指令实现)

  • 实际的PLT基本结构代码如下:
PLT0:
push *(GOT +4)
jump *(GOT+8)

...

bar@plt:
jmp *(bar@GOT)
push n
jump PLT0
  • 在动态链接情况下,操作系统在装载完可执行文件之后会先启动一个动态链接器,之后就将控制权交给动态链接器的入口地址。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权交到可执行文件的入口地址,程序开始正式执行。
  • 动态链接相关结构

    • “.interp”段:里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。
    • “.dynamic”段:ELF文件中最重要的结构,保存了依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等信息。
    • “.dynsym”段:动态符号表,表示动态链接模块之间的符号导入导出关系。
    • “.rel.dyn”段:数据引用重定位,修正“.got”以及数据段。
    • “.rel.plt”段:函数引用重定位,修正“.got.plt”。
  • 动态链接基本上分为3步:先是启动动态链接器本身(自举,bootstrap),然后装载所有需要的共享对象,最后是重定位和初始化。(跳转)
  • 完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个全局符号表中。在Linux中,当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略(全局符号介入问题)。
  • 当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将它们的GOT/PLT中的每个需要重定位的位置进行修正。

Windows下的动态链接

  • 在ELF中,由于代码段是地址无关的,所以它可以实现多个进程之间共享一份代码,但是DLL的代码却并不是地址无关的,所以它只是在某些情况下可以被多个进程间共享。
  • PE文件里有两个常用的概念就是基地址(Base Address)相对地址(RVA,Relative Virtual Address)。基地址就是PE头文件中的Image Base,是PE文件被装载进进程地址空间中的起始地址,

对于EXE文件来说,其值一般是0x400000,对于DLL文件来说,其值一般是0x10000000。而相对地址就是一个地址相对于基地址的偏移。

  • ELF默认导出所有的全局符号。但是在DLL中,我们需要显式地“告诉”编译器我们需要导出某个符号,否则编译器默认所有符号都不导出。在VC++中,我们使用“__declspec(dllexport)”表示DLL导出符号,使用“__declspec(dllimport)”表示DLL导入符号。除了使用导出导入符号外,我们也可以使用“.def”文件中的IMPORT或者EXPORTS段来声明导入导出符号。这个方法不仅对C/C++有效,对其他语言也有效。
  • 使用.def文件来描述DLL文件导出属性的优点有两个,一是可以控制导出符号的符号名,而是可以控制一些链接的过程。
  • Windows提供3个API来支持DLL的运行时链接,分别是LoadLibrary(LoadLibraryEx):装载DLL,GetProcAddress:获取某个符号的地址,FreeLibrary:卸载DLL。
  • 在Windows PE中,所有导出的符号被集中存放在导出表(Export Table)的结构中。从最简单的结构上来看,它提供了一个符号名与符号地址的映射关系。导出表是一个IMAGE_EXPORT_DIRECTORY结构体,定义在“Winnt.h”中:
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个数组,分别是导出地址表(EAT,Export Address Table)符号名表(Name Table)名字序号对应表(Name-Ordinal Table)
  • 导出地址表中存放的是各个导出函数的RVA,符号名表中存放的是导出函数的名字。序号表实际是早期16位windows为了应对内存小而使用的机制。使用序号导入导出的好处就是省去了函数名查找过程,函数名表也不需要保存到内存中。但是它最大的问题就是一个函数的序号可能会变化。这就需要程序员手工指定每个导出函数的序号。由于目前硬件性能的提升,这种内存空间的节省和查找速度的提升效果就不明显了。所以现在这种方式基本就不采用了,但是为了保持向后兼容,它还是被保留了下来。
  • 动态链接器如何查找函数RVA呢?假设模块A导入了Math.dll中的Add函数,那么A的导入表中就保存了“Add”这个函数名。当进行动态链接时,动态链接器在Math.dll的函数名表中进行二分查找,找到“Add”函数,然后在名字序号对应表中找到“Add”所对应的序号,即1,减去Math.dll的Base值1,结果为0,然后在EAT中找到下标0的元素,即“Add”的RVA为0x1000。
  • 在ELF中,“.rel.dyn”和“.rel.plt”两个段中分别保存了该模块所需要导入的变量和函数的符号以及所在的模块等信息,而“.got”和“.got.plt”则保存着这些变量和函数的真正地址。Windows中也有类似机制,叫做导入表(Import Table)。当某个PE文件被加载时,Windows加载器的其中一个任务就是将所有需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接的过程。
  • 导入表是一个IMAGE_IMPORT_DESCRIPTOR结构体数组,每一个IMAGE_IMPORT_DESCRIPTOR结构对应一个被导入的DLL。它也被定义在“Winnt.h”中:
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指向一个导入地址数组(IAT,Import Address Table),IAT中每个元素对应一个被导入的符号,元素的值在不同的情况下有不同的含义。在动态链接器刚完成映射还没有开始重定位和符号解析时,IAT中的元素值表示相对应的导入符号的序号或者是符号名;当Windows的动态链接器在完成该模块的链接时,元素值会被动态链接器改写成该符号的真正地址,从这一点看,导入地址数组与ELF中的GOT非常类似。(INT)
  • 为了使得编译器能够区分函数是从外部导入的还是模块内部定义的,MSVC引入了“__declspec(dllimport)”的扩展属性,一旦一个函数被声明为“__declspec(dllimport)”,那么编译器就知道它是外部导入的,以便产生相应的指令形式。比如:CALL DWORD PTR [0x0040D11C]。这里面的IAT表元素地址0x0040D11C也是绝对地址,这也是需要后面修正的。所以可以看到PE结构中,DLL的代码段并非地址无关的,所以Windows系统就是大气,根本不像Linux那么在意代码段指令的重复利用。
  • 因为PE没有类似ELF的全局符号介入问题,所以对于模块内部的全局函数调用,编译器产生的都是直接调用指令CALL XXXXXXXX(不是相对地址偏移,是直接地址调用。这是因为Windows PE下,任何一个PE文件在编译时都会给出自己的一个优先装载位置,然后根据此位置产生一系列的定位,当然这个绝对地址是需要在实际装载运行时再重新修正的,采用了一种重定基地址的方法)。

DLL优化

  • DLL的代码段和数据段本身并不是地址无关的,也就是说它默认需要被装载到由ImageBase指定的目标地址中。如果目标地址被占用,那么就需要装载到其他地址,便会引起整个DLL的Rebase。这对于拥有大量DLL的程序来说,频繁的Rebase也会造成程序启动缓慢。这是影响DLL性能的一个原因
  • 动态链接过程中,导入函数的符号在运行时需要被逐个解析。在这个解析过程中,免不了涉及到符号字符串的比较和查找过程,这个查找过程中,动态链接器会在目标DLL的导出表中进行符号字符串的二分查找。即使是使用了二分查找法,对于拥有DLL数量很多,并且有大量导入导出符号的程序来说,这个过程仍然是非常耗时的。这是影响DLL性能的另一个原因
  • Windows PE采用了装载时重定位来解决共享对象的地址冲突问题。这个重定位过程有些特殊,因为所有这些需要重定位的地方只需要加上一个固定的差值,也就是说加上一个目标装载地址与实际装载地址的差值。这主要得益于DLL内部的地址都是基于基地址的,或者似乎相对于基地址的RVA。所以这种重定位过程比一般的重定位要简单,速度更快一些。PE里把这种特殊的重定位过程叫做重定基地址(Rebasing)
  • MSVC的链接器提供了指定输出文件的基地址的功能。可以在链接时使用link命令中的“/BASE”参数来指定基地址。比如:link /BASE:0x100100000, 0x10000 /DLL bar.obj
  • Windows系统本身自带很多系统的DLL,基本上Windows的应用程序运行时都要用到。Windows系统就在进程空间中专门划出一块0x70000000~0x80000000区域,用于映射这些常用的系统DLL。Windows在安装时就把这块地址分配给这些DLL,调整这些DLL的基地址使得它们互相之间不冲突,从而在装载时就不需要进行重定基址了。
  • 每一次一个程序运行时,所有被依赖的DLL都会被装载,并且一系列的导入导出符号依赖关系都会被重新解析。在大多数情况下,这些DLL都会以同样的顺序被装载到相同的内存地址,所以它们的导出符号的地址应该都是不变的,既然这些符号的地址不变,那程序主模块的导入表应该还是和上次程序运行时相同,故而可以保留下来,这样就可以省去每次启动时符号解析的过程。这种方法称为DLL绑定。
  • 在PE的导入表中有一个和IAT一样的数组叫做INT就是用来保存绑定符号的地址的。一旦检测到INT里面有信息,则不需要再次进行符号重定位了,如果遇到问题(如依赖的DLL更新,DLL装载顺序打乱了和此前装载位置不一致),导致INT中绑定符号信息失效,则也可以依靠IAT的信息再重来一次重定位。Windows系统中很多系统自带程序便采用DLL绑定用以加速程序启动。

参考文章

Windows下动态链接之二:DLL优化加速
如何理解DLL不是地址无关的?DLL与ELF的对比分析

你可能感兴趣的:(windows,linux,链接,操作系统,pe)