当前位置: 首页 > Linux

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

时间:2023-04-07 01:34:36 Linux

加载与动态链接可执行文件的加载与处理每个程序都有自己独立的虚拟地址空间,这个空间的大小由计算机硬件平台决定(理论上限)。例如32位硬件平台的虚拟地址空间的地址为0到232-1,即0x00000000~0xFFFFFFFF,共约4G;而64位硬件平台的虚拟地址空间的地址是0到264-1,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,大约有17179869184G。在32位平台上,Linux操作系统中的4G虚拟地址空间会被分成两部分,0xC0000000到0xFFFFFFFF的1G地址空间分配给操作系统,剩下的3G地址0x00000000到0xBFFFFFFF空间被保留对于流程。原则上我们的进程最多可以使用3G的虚拟地址空间。对于Windows操作系统,它的进程虚拟地址空间划分是操作系统占用2G,进程只剩下2G。对于一些程序来说,2G的虚拟空间太小了,所以Windows有一个启动参数,可以将操作系统占用的虚拟地址空间减少到1G。方法如下:修改Windows系统盘根目录下的boot.ini,增加“/3G”参数。动态加载的两种典型方法是覆盖加载和页面映射。Overlay加载在虚拟存储发明之前就被广泛使用,现在几乎已经被淘汰。简单的说,页面映射就是操作系统按照一定的算法,动态地将程序需要使用的页面映射到物理内存中执行。从操作系统的角度来看,一个进程最关键的特征就是拥有一个独立的虚拟地址空间,这使得它有别于其他进程。进程的建立分为三个步骤:首先是创建虚拟地址空间。读取可执行文件头,建立虚拟空间与可执行文件的映射关系。(可执行文件加载最重要的一步,也是传统意义上的“加载”)将CPU指令寄存器设置为可执行文件的入口,开始运行。我们知道,当程序执行过程中出现页面错误时,操作系统会从物理内存中分配一个物理页面,然后从磁盘中将“丢失的页面”读取到内存中,然后设置该页面的虚拟页面和物理页面缺页。映射关系,使程序能够正常运行。但很明显,当操作系统捕获到页面错误时,它应该知道程序当前需要的页面在可执行文件中的位置。这就是虚拟空间和可执行文件的映射关系。ELF文件映射时,以系统的页长为单位。为了避免内存浪费,操作系统在加载可执行文件时主要关心的只是文件中间段的权限(可读、可写、可执行)。对于权限相同的段,将它们合并为一个段进行映射。在Linux中,进程虚拟空间中的一段称为虚拟内存区(VMA),而在Windows中,这称为虚拟段(VirtualSection)。很多情况下,一个进程中的堆和栈都有对应的VMA。在进程启动前,操作系统会预先将系统环境变量和进程的运行参数保存到进程虚拟空间的栈中(即VMA中的栈VMA)。PE文件的加载与ELF不同。在PE文件中,所有段的起始地址都是页的倍数。如果一个segment的长度不是pages的整数倍,映射时会被填充到pages的整数倍。由于这个特性,PE文件的映射过程比ELF简单很多,因为它不需要考虑ELF中很多段地址的对齐等问题,虽然这会浪费一些磁盘和内存空间。在PE文件中,链接器在生成可执行文件时,往往会尽可能的合并所有的段,所以一般只有代码段、数据段、只读数据段等几个段,BSS。每个PE文件在加载时都会有一个加载目标地址。这个地址就是基地址。基地址不固定,每次加载时都可能发生变化。所以在PE文件中有一个常用的名词叫做相对虚拟地址(RVA),它是相对于PE文件加载基地址的一个偏移地址。这样无论基地址如何变化,PE文件中的每个RVA都保持一致。WIndowsPE文件的加载过程:首先读取文件的第一页(包括DOS头、PE文件头和段表)。检查目标地址在进程地址空间中是否可用,如果不可用,则选择其他加载地址。(主要用于DLL加载)利用段表提供的信息,将PE文件中的所有段一一映射到地址空间中的相应位置。如果加载地址不是目标地址,则执行变基。加载所有PE文件所需的DLL文件。解析PE文件中的所有导入符号。根据PE头中指定的参数,建立初始化堆和栈。创建主线程并启动进程。动态链接为什么是动态链接?静态链接方式对计算机内存和磁盘空间的浪费非常严重。静态链接也会给程序的更新、部署和发布带来很多麻烦。在Linux系统中,ELF动态链接文件称为动态共享对象(DSO),简称共享对象,它们一般是一些扩展名为“.so”的文件;在Windows系统中,动态链接文件称为动态链接库(DLL),通常是扩展名为“.dll”的文件。静态链接的重定位称为链接时重定位(LinkTimeRelocation),而动态链接的重定位称为加载时重定位(LoadTimeRelocation)。在Windows中,这种载入时的重定位也称为基地址重置(Rebasing)。只要在Linux和GCC中使用“-shared”参数,输出的共享对象就是使用的加载时重定位。把指令中需要修改的部分分开,和数据部分放在一起,这样指令部分可以保持不变,而数据部分可以在每个进程中都有一份。该方案是地址无关代码(PIC)技术。要在Linux共享对象中生成与地址无关的代码,只在编译时使用参数-fPIC。上述情况不包括在共享模块内定义的全局变量。ELF共享库在编译时,默认将模块内部定义的全局变量视为其他模块定义的全局变量,即上图中的类型(4),通过得了。当加载共享模块时,如果一个全局变量在可执行文件中有一个副本,动态链接器会将GOT中的相应地址指向该副本,这样该变量实际上在运行时最终只有一个实例。如果变量是在共享模块中初始化的,那么动态链接器也需要将初始化值复制到主模块中的变量副本中;如果全局变量在程序的主模块中没有副本,那么GOT中对应的地址指向模块内部那个变量的副本。对于共享对象,如果数据段中存在绝对地址引用,编译器和链接器会生成一个重定位表,其中包含“R_386_RELATIVE”类型的重定位项。当动态链接器加载共享库时,如果发现共享库有这样的重定位入口,那么动态链接器就会对共享库进行重定位。如果我们在编译共享库时使用“-fPIC”参数,则意味着生成一个与地址无关的代码段。GCC默认使用这个参数编译动态链接的可执行文件。如果不使用该参数,会产生一个加载时重定位的共享对象,其代码段不是地址无关的,不能在多个进程之间共享,从而失去节省内存的优势。但是在加载时重定位的共享对象比使用地址无关代码的共享对象运行得更快,因为它省去了在地址无关代码中每次访问全局数据和函数时都需要计算当前地址和间接地址寻址。的过程。动态链接比静态链接慢的主要原因是动态链接下的全局和静态数据访问需要复杂的GOT定位,然后间接寻址;对于模块之间的调用,必须先定位GOT,然后间接跳转,这可能会导致程序启动或变慢,所以我们需要优化动态链接性能。ELF使用延迟绑定来优化动态链接性能。基本思想是在第一次使用函数时进行绑定(符号查找、重定位等)。具体方法是使用PLT(ProcedureLinkageTable)。PLT为GOT间接跳转又增加了一个中间层。调用外部模块的函数时,不是直接通过GOT跳转,而是通过一个叫做PLT项的结构跳转。每个外部函数在PLT中都有对应的条目。(汇编指令实现)实际PLT基本结构代码如下:PLT0:push*(GOT+4)jump*(GOT+8)...bar@plt:jmp*(bar@GOT)pushnjumpPLT0中动态链接的情况在这种情况下,操作系统会在加载可执行文件后首先启动一个动态链接器,然后将控制权交给动态链接器的入口地址。当动态链接器获得控制权后,它开始执行自己的一系列初始化操作,然后开始根据当前的环境参数动态链接可执行文件。当所有的动态链接工作完成后,动态链接器会将控制权转移到可执行文件的入口地址,程序开始正式执行。动态链接相关结构的“.interp”段:里面存放了一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。“.dynamic”段:ELF文件中最重要的结构,存放着依赖于哪些共享对象、动态链接符号表所在位置、动态链接重定位表所在位置、共享对象地址等信息对象初始化代码。“.dynsym”段:动态符号表,表示动态链接模块之间的符号导入导出关系。“.rel.dyn”部分:数据引用重定位,修复“.got”和数据部分。“.rel.plt”部分:函数引用重定位,修复“.got.plt”。动态链接基本上分为3个步骤:首先启动动态链接器本身(bootstrap,bootstrap),然后加载所有需要的共享对象,最后重定位和初始化。(跳转)在基本引导之后,动态链接器将可执行文件和链接器自己的符号表合并到一个全局符号表中。在Linux中,当一个符号需要添加到全局符号表中时,如果相同的符号名已经存在,则添加的符号被忽略(全局符号干预问题)。当以上步骤完成后,链接器开始重新遍历可执行文件和各个共享目标文件的重定位表,修正各自GOT/PLT中每一个需要重定位的位置。Windows下的动态链接在ELF中。由于代码段是地址无关的,所以可以在多个进程之间共享一段代码,但是DLL的代码并不是地址无关的,所以只有在某些情况下才能在多个进程之间共享。PE文件中有两个常用的概念是基址(BaseAddress)和相对地址(RVA,RelativeVirtualAddress)。基地址是PE头文件中的ImageBase,是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。在WindowsPE中,所有导出的符号都集中存储在导出表(ExportTable)结构中。从最简单的结构来看,它提供了符号名和符号地址之间的映射关系。导出表是“Winnt.h”中定义的IMAGE_EXPORT_DIRECTORY结构:typedefstruct_IMAGE_EXPORT_DIRECTORY{DWORDCharacteristics;DWORD时间日期戳;WORD主要版本;WORD次要版本;双字节名称;DWORD基础;DWORDNumberOfNameAddsNumberOf;DWORDNumberOfNameAdd;DWORDRVA来自图像DWORDAddressOfNames的基础;//来自图像基础的RVADWORDAddressOfNameOrdinals;//来自图像底部的RVA}IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;在导出表结构中,最后3个成员实现了3个数组,分别是导出地址表(EAT,ExportAddressTable)、符号名称表(NameTable)和名称-序号表(Name-OrdinalTable)。每个导出函数的RVA存放在导出地址表中,导出函数的名字存放在符号名表中。序号表其实是早期16位windows用来应对小内存的一种机制。使用序号导入导出的好处是省去了函数名查找过程,不需要在内存中保存函数名表。但它最大的问题是一个函数的序号可能会改变。这就需要程序员手动指定每个导出函数的序号。由于目前硬件性能的提升,节省内存空间和提高搜索速度的效果并不明显。所以现在这个方法基本不用了,但是为了保持向后兼容,还是保留了下来。动态链接器如何找到函数RVA?假设模块A在Math.dll中导入了Add函数,那么A的导入表中保存的是函数名“Add”。在进行动态链接时,动态链接器在Math.dll的函数名表中进行二分查找,找到“Add”函数,然后在名称序号对应表中找到“Add”对应的序号,即,1,减去数学。dll的Base值为1,结果为0,然后在EAT中找到下标为0的元素,即“Add”的RVA为0x1000。在ELF中,“.rel.dyn”和“.rel.plt”两个section分别存放模块需要导入的变量和函数的符号以及它们所在的模块,而“.got”和“.rel.plt”“.got.plt”存放的是这些变量和函数的真实地址。Windows中也有类似的机制,叫做导入表(ImportTable)。当一个PE文件被加载时,Windows加载器的任务之一就是确定所有需要导入的函数的地址,并将导入表中的元素调整到正确的地址,从而实现动态链接的过程。导入表是一个IMAGE_IMPORT_DESCRIPTOR结构数组,每个IMAGE_IMPORT_DESCRIPTOR结构对应一个导入的DLL。它也在“Winnt.h”中定义:typedefstruct_IMAGE_IMPORT_DESCRIPTOR{union{DWORDCharacteristics;//0用于终止空导入描述符DWORDOriginalFirstThunk;//RVA到原始未绑定IAT(PIMAGE_THUNK_DATA)}DUMMYUNIONNAME;双字;//TimeDate0如果未绑定,//如果绑定则为-1,并且在IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT(新的BIND)中有真实的日期\时间戳//O.W.绑定到(旧BIND)DWORDForwarderChain的DLL的日期/时间戳;//-1如果没有转发器DWORD名称;DWORDFirstThunk;//RVA到IAT(如果绑定这个IAT有实际地址)}IMAGE_IMPORT_DESCRIPTOR;结构体中的FirstThunk指向一个导入地址数组(IAT,ImportAddressTable),IAT中的每个元素对应一个Importedsymbols,元素值在不同的情况下有不同的含义。当动态链接器刚刚完成映射,还没有开始重定位和符号解析时,IAT中的元素值表示对应导入符号的序号或符号名;当Windows动态链接器完成模块的链接时,element的值将被动态链接器重写为符号的真实地址。从这一点来看,导入地址数组很像ELF中的GOT。(INT)为了让编译器能够区分函数是从外部导入的还是模块内部定义的,MSVC引入了“__declspec(dllimport)”扩展属性。一旦一个函数被声明为“__declspec(dllimport)”,那么编译器就知道它是从外部导入的,以便生成相应的指令形式。例如:CALLDWORDPTR[0x0040D11C]。这里的IAT表元素地址0x0040D11C也是绝对地址,后面也需要修正。所以可以看到在PE结构中,DLL的代码段不是地址无关的,所以Windows系统大方,不像Linux那样关心代码段指令的重用。因为PE不存在类似ELF的全局符号干预的问题,所以编译器会为模块内部的全局函数调用生成直接调用指令CALLXXXXXXXX(不是相对地址偏移,而是直接地址调用)。这是因为在WindowsPE下,任何PE文件在编译时都会给自己一个优先加载的位置,然后根据这个位置生成一系列的定位。当然这个绝对地址在实际加载运行时需要重新修正。使用变基地址。方法)。DLL优化DLL的代码段和数据段不是地址无关的,也就是说默认需要加载到ImageBase指定的目标地址。如果目标地址被占用,那么就需要加载到另一个地址,这会导致整个DLL的rebase。对于一个有大量DLL的程序,频繁的rebase也会导致程序启动缓慢。这是影响DLL性能的原因之一。动态链接时,导入函数的符号需要在运行时一一解析。在这个解析过程中,不可避免地涉及到符号串的比较和查找过程。在此搜索过程中,动态链接器将在目标DLL的导出表中对符号字符串进行二进制搜索。即使采用二分查找法,这个过程对于有大量DLL和大量导入导出符号的程序来说还是非常耗时的。这是影响DLL性能的另一个原因。WindowsPE使用加载时重定位来解决共享对象的地址冲突问题。这个重定位的过程有些特殊,因为所有这些需要重定位的地方只需要加上一个固定的差值,也就是在目标加载地址和实际加载地址之间加上一个差值。这主要是因为DLL内部的地址都是基于基地址,或者看起来是相对于基地址的RVA的。所以这个搬迁过程比一般的搬迁更简单快捷。这种特殊的迁移过程在PE中称为Rebasing。MSVC的链接器提供了指定输出文件基地址的能力。可以在链接命令中使用“/BASE”参数指定链接时的基地址。例如:link/BASE:0x100100000,0x10000/DLLbar.obj。Windows系统本身自带了很多系统DLL,Windows应用程序在运行时基本上都会用到这些DLL。Windows系统在进程空间中专门指定了一块0x70000000~0x80000000区域,用于映射这些常用的系统DLL。Windows在安装的时候就把这个地址分配给了这些DLL,并且调整了这些DLL的基地址,使它们之间不冲突,这样加载的时候就不需要rebase了。每次程序运行时,都会加载所有依赖的DLL,重新解析一系列的导入导出符号依赖。在大多数情况下,这些DLL会以相同的顺序加载到相同的内存地址,因此它们导出的符号的地址应该保持不变。由于这些符号的地址保持不变,所以程序主模块的导入表应该还是和上次程序运行时一样的,所以可以保留,这样每次启动时符号解析的过程可以省略。这种方法称为DLL绑定。在PE的导入表中,有一个和IAT一样的名为INT的数组,用来保存绑定符号的地址。一旦检测到INT中有信息,就不需要再重新定位符号。如果遇到问题(比如依赖的DLL更新,DLL的加载顺序被打乱,与之前的加载位置不一致),INT中的绑定符号信息就会失效,你也可以依靠IAT信息再次搬迁。Windows系统中的许多系统内置程序都使用DLL绑定来加速程序启动。参考文章Windows下DynamicLink2:DLL优化加速如何理解DLL不是地址无关的?DLL与ELF对比分析