读书摘要——Linkers and Loaders


引子


   几乎从有计算机以来,链接器和加载器就是软件开发工具包中的一部分,因为它们是允许使用模块而不是一个单独的大文件来构建程序的关键工具。由于链接是编译 过程中将整个程序的代码放在一起处理、并对程序作为一个整体施加影响的唯一阶段,因此链接器还将被加入更多的全局程序优化功能。

    上世纪60年代早期,连接器就已经发展的相当完善了。
    上世纪70到80年代,链接技术几乎没有什么进展。链接器趋向于更加简单,虚拟内存技术将应用程序和覆盖机制中的大多数存储管理工作都转移给了操作系统,越来越快的计算机和越来越大的磁盘也使得重新链接一个程序或替换个别模块比仅仅链接改变过的地方更加容易了。
    从上世纪90年代起,由于增加了诸如动态链接共享库和C++的诸多现代特性,链接器又开始变得复杂起来。


第一章 链接和加载


1.1     链接器和加载器完成什么工作?

    任何一个链接器和加载器的基本工作都非常简单: 将更抽象的名字与更具体的名字绑定起来,好让程序员使用更抽象的名字编写代码。也就是说,它可以将程序员写的一个诸如getline的名字绑定到“iosys模块内可执行代码的612字节处”这样的数字地址上。

1.2    地址绑定的历史视角

    一个有助于深入理解链接器和加载器做了什么的方法就是看看它们在计算机编程系统的发展中承担了什么角色。

    最早的计算机完全是用机器语言进行编程的。这里最大的问题就在于名字和地址绑定的过早了。汇编器通过让程序员使用符号化名字编写程序,然后由程序将名字绑定到机器地址的方法解决了这个问题,地址分配的工作已经从程序员推给计算机了。

    库函数的出现使得地址分配工作更加复杂。在汇编语言出现之前,链接器的两个基本功能“重定位和库查找”就已经出现了。可重定位的加载器器允许库函 数的作者或用户在编写库函数时认为它们都起始于地址0,并将实际的地址绑定延迟到这些函数被链接到某个特定程序时。

    随着操作系统的出现,有必要将可重定位的加载器从链接器和库中分离出来。有了操作系统以后,程序就必须和操作系统甚至其它程序共享计算机的内存。这意味着在操作系统将程序加载到内存之前是无法确定程序运行的确切地址的,并将最终的地址绑定从链接时推延到了加载时。链接器和加载器对要完成的任务进行了划分,链接器对每一个程序的地址进行部分绑定并分配相对地址,加载器完成最后的重定位步骤并赋予的实际地址

    随着硬件重定位和虚拟内存的出现,每一个程序可以再次拥有整个地址空间,因此链接器和加载器变得不那么复杂了。程序可以按照被加载到固定地址的方式来被链接,之后基于硬件而不是软件来解决重定位的问题。

    在较简单的静态共享库中,每个库在创建时会被绑定到特定的地址,链接器在链接时将程序中引用的库函数绑定到这些特定的地址。由于当静态库中的任何部分变化时程序都需要被重新链接,而且创建静态链接库的细节也是非常冗长乏味的,因此静态链接库实际上很麻烦死板。

    使用动态链接库的程序在开始运行之前不会将所用库中的段和符号绑定到确切的地址上。有时这种绑定还会更为延迟:在完全的动态链接中,被调用函数的地址在第一次调用前都不会被绑定。此外在程序运行过程中也可以加载动态库并进行绑定,这提供了一种强大且高性能的扩展程序功能的方法。


1.3    链接与加载

    链接器和加载器完成几个
概念上 相关但不同的动作。

    a. 程序加载:将程序从辅助存储设备(自1968年后这就意味着磁盘)拷贝到主内存中准备运行。

    b.定位: 编译器和汇编器通常为每个文件创建程序地址从0开始的目标代码,但是几乎没有计算机会允许从地址0加载你的程序。如果一个程序是由多个子程序组成的,那么 所有的子程序必须被加载到互不重叠的地址上。重定位就是为程序不同部分分配加载地址,调整程序中的数据和代码以反映所分配地址的过程。
    在很多系统中,重定位不止进行一次。对于链接器的一种普遍情景是由多个子程序来构建一个程序,并生成一个链接好的起始地址为0的输出程序,各个子程序通过重定位在大程序中确定位置。当这个程序被加载时,系统会选择一个加载地址,而链接好的程序会作为整体被重定位到加载地址

    c. 符号解析:当通过多个子程序来构建一个程序时,子程序间的相互引用是通过符号进行的;主程序可能会调用一个名为sqrt的计算平方根函数,并且数学库中定义了sqrt函数。链接器通过标明分配给sqrt的地址在库中来解析这个符号,并通过修改目标代码使得call指令引用该地址。

    尽管有相当一部分功能在链接器和加载器之间重叠,定义一个仅完成程序加载的程序为加载器,一个仅完成符号解析的程序为链接器是合理的。它们都可以完成重定位工作,而且曾经也出现过集三种功能为一体的链接加载器。

    重定位和符号解析的划分界线是模糊的。由于链接器已经可以解析符号的引用,一种进行代码重定位的方法就是为程序的每一部分分配一个指向基址的符号,然后将重定位地址认为是对该基址符号的引用。

    链接器和加载器共有的一个重要特性就是都会修改目标代码,它们也许是唯一比调试程序在这方面应用更为广泛的程序。


1.3.1    两遍链接

    就象编译或汇编一样,链接基本上也是一个两遍的过程。链接器将一系列的目标文件、库及可能的命令文件作为它的输入,然后将输出的目标文件作为产品结果,此外也可能有诸如加载映射信息或调试器符号文件的副产品。

    每个输入目标文件都包含若干个段(segment),即连续存储的数据或代码块。每个输入目标文件至少还包含一个符号表(symbol
table ),表中主要包含以下两类信息:

    导出符号:在当前文件中定义并在其它文件中使用的名字。
    导入符号:在当前文件中使用但不在当前文件中定义的名字。

    第一遍扫描是
链接器 会对输入文件进行扫描,得到各个段的大小,并收集所有符号的定义和引用。它会创建一个列出输入文件中所有段的段表,和包含所有导出、导入符号的符号表。

    第二遍扫描会利用第一遍扫描中收集的信息来控制实际的链接过程。它会读取并重定位目标代码,将符号引用替换为数字地址,调整代码和数据的内存地址以反映重 定位后的段地址,并将重定位后的代码写入到输出文件中。通常还会再向输出文件中写入文件头部信息、重定位的段和符号表信息。如果使用了动态链接,那么输出 文件的符号表中还要包含运行时链接器解析动态符号时所需的信息

    有些目标文件格式是可以重链接的,也就是一次链接器运行的输出文件可以作为下次链接器运行的输入。这要求输出文件要包含一个像输入文件中那样的符号表,以及其它会出现在输入文件中的辅助信息。

1.3.2     目标代码库

   

    当链接器处理完所有常规输入文件后,如果还存在未解析的导入符号(imported name),它就会在函数库中进行查找,将 库中 导出所需符号的的任何文件链接进来。

    对于动态库,由于部分工作从链接时推迟到了加载时,导致链接器的工作变得稍微复杂了一些。在链接器运行时,链接器会识别出能够解析未定义符号的共享 库,但是它会在输出文件中标明用来解析这些符号的库的名称,而不是在此时将它们链入程序;这样可以在程序被加载时执行共享库的(动态)绑定。

1.3.3    重定位和代码修改

    链接器和加载器的核心动作是重定位和代码修改。

    当编译器或汇编器生成一个目标文件时,对于在本TU中定义的符号将使用以0为基准的未重定位地址,而对于引用的未在本TU内定义的外部符号,则通常用0来表示其地址。


1.4    编译器驱动

1.4     链接器命令语言

    每个链接器都有某种形式的命令语言来控制链接过程,最起码链接器需要记录要进行链接的目标文件和库的列表。多数链接器都允许某些方法来指定被链接代码将要绑定的地址,这在链接一个系统内核或其它没有操作系统控制的程序时就会用到。

    有四种常见技术向链接器传送指令:

    a.命令行

    b.与目标文件相混咱

    c.嵌入在目标文件中

    d.专用的配置语言——GNU链接器支持一套复杂的控制语言

1.5    一个链接实例

   
1.6 练习

    将链接器和加载器分成独立的程序有什么好处?在哪些情况下一个整合的链接加载器有用的?



第二章    体系结构问题


    硬件体系结构的两个方面影响到链接器:程序寻址和指令格式。链接器需要做的事情之一就是对数据和指令中的地址及偏移量都要进行修改。两种情况下链接器都必须确保所做的修改符合计算机使用的寻址方式;当修改指令时还需要进一步确保修改结果不能是无效指令。

2.1    应用程序二进制接口

    ABI(Application Binary Interfaces):ABI包含了应用程序在这个系统下运行时必须遵守的编程约定。ABI总是包含一系列的系统调用和使用这些系统调用的方法,以及程序可以使用的内存地址和使用机器寄存器的相关规定。
    从应用程序的角度看,ABI既是系统架构的一部分也是硬件体系结构的重点,只要违反二者之一的条件约束就会导致程序出现严重错误。

    在很多情况下,链接器为了遵守ABI的约定需要进行一些重要的工作。例如,ABI要求每个应用程序包含一个该程序中各函数使用的所有静态数据的地址表。
    ABI经常会影响链接器的是对其函数调用风格(参数压桟的顺序等)的定义。

2.2    内存地址

    很多处理器同样要求程序指令的对齐。多数RISC芯片要求指令必须对齐在4字节的边界上。


2.3    地址构成

    尽管计算机设计者们在这些年中提出了无数不同而复杂的寻址策略,但现在大多数产品化的计算机都使用一套类似的简单寻址策略,以三种架构为例:
    a.IBM 360/370/390。尽管这是仍在使用中的最古老的架构之一,并在过去的35年中不断增加新特性,但其相对简洁的设计仍然能够很好的工作,并且能够实现在可以与现代RISC性能相当的芯片中。
    b.SPARC V8和V9。一个流行的RISC架构,具有相当简单的寻址策略。V8使用32位的寄存器和地址,V9扩充了64位的寄存器和地址。SPARC的设计与其它诸如MIPS和Alpha这样的RISC架构相似。
    c.Intel 386/486/Pentium。仍在使用的最无规律和无法理解的架构之一,但不可否认它是最流行的。

2.4    指令格式

    370在数据引用和跳转中使用相同的指令格式,SPARC使用不同的指令格式,而Intel的有些格式相同,有些格式不同。

    立即操作数、直接寻址、寄存器间接寻址


    如果寄存器中的值是一个存储区域的首地址,而指令中的常量是
想要访问的数据在 存储区域中的偏移量,这种策略称为基址寻址
    如果二者调换过来,寄存器中保存的是偏移量,那这种策略就是索引寻址

    基址寻址与索引寻址之间的区别不那么好定义,而且很多体系结构都将它们混在一起了。

2.5    过程调用和可寻址性

    在最早的计算机中,内存很小,指令中的地址域足够容纳计算机任何一个内存位置的地址,现在我们称这种策略为直接寻址。在上世纪60年代早期,可寻址内存已 经变得相当大使得如果指令集中每个指令都包含整个地址将占用太多仍然宝贵的内存。为了解决这个问题,计算机的架构师们在地址引用指令中部分或彻底的放弃了 直接寻址,使用索引和基址寄存器来提供寻址所需的大部分或全部地址位。这可以让指令短一些,但与之而来的代价是编程更复杂了。

    在没有采用直接寻址的体系结构中,包括IBM 370和SPARC,程序在进行数据寻址时存在一个“自举”的问题:一个函数要使用寄存器中的基地址来计算数据地址,但是将基址从内存中加载到寄存器中的标准方法是从存有另一个基址的寄存器中寻址。
    自举问题就是如何在程序开始时将第一个基地址载入到寄存器中,随后再确保每一个函数都拥有它需要的基地址来寻址它要使用的数据。


2.5.1    过程调用

    在诸如x86这样具有硬件栈的体系结构中返回地址被压入栈中,而在其它体系结构中它会被保存在一个寄存器里,在必要的情况下软件要负责将寄存器中的值保存 在内存中。具有栈的体系结构通常都会有一个硬件的返回指令将返回地址推出栈并跳转到该地址,而其它体系结构则使用一个“跳转到寄存器中地址”的指令来返 回。

2.6    数据和指令引用

2.7    分页和虚拟内存

    如果页可以被标注为只读,那么可以提升性能。由于只读页可以重新加载因此它们不需要调出页的操作。如果某个页逻辑上出现在多个地址空间中(这通常会发生在运行相同程序的多个实例时),一个单独的物理页就可以满足所有的地址空间。

2.7.2    映射文件

    虚拟内存系统在真实内存和硬盘之间来回移动数据,当数据无法保存在内存中时就会将它交换到磁盘上。最初,交换出来的页面都是保存在独立于文件系统名字空间的单独匿名磁盘空间上的。换页发明之后不久,设计者们发现通过让换页系统读写命名的磁盘文件可以将换页系统和文件系统统一起来。

    处理对映射文件的写操作有三种不同的方法:
    最简单的办法是将文件以只读方式(RO)映射,任何对映射文件存储数据的操作都会失败,这通常会导致程序终止。
    第二种方法是将文件以可读写方式(RW)映射,这样对映射文件在内存中副本的修改会在取消映射的时候写回磁盘上。
    第三种方法是将文件以写时复制方式(COW)映射。

2.7.3    共享库和程序

    如果程序或库函数在多于一个的地址空间中被使用,若能够在多个地址空间中共享这个程序或程序库的单一副本,那将节省大量的内存。对于操作系统实现这个功能是相当简捷的——只需要将可执行程序文件映射到每一个程序的地址空间即可。

    a.不可重定位的代码和只读的数据以RO方式映射
    b.可写的数据以COW方式映射
    c.操作系统还可以在所有映射到该文件的进程之间共享RO和尚未被写的COW数据对应的物理页框(如果代码在加载时需要重定位,重定位过程会修改代码页,那它们就必须被当作COW对待,而不是RO)。


2.7.4    位置无关代码

    当一个程序在多个不同的地址空间运行时,操作系统通常可以将程序加载到各地址空间的相同位置。这样可以让链接器将程序中所有的地址绑定到固定的位置且在程序加载时不需要进行重定位,因此链接器的工作简单了很多。

    然而,共享库使情况变得相当复杂。在一些简单的共享库设计中,每一个库会在系统引导时或库被建立时分配一个全局唯一的内存地址,这可以让每一个库放置在固 定的位置上。但由于库内存地址的全局列表需要由系统管理员维护,这就给共享库的管理带来了严重的瓶颈。再进一步,如果一个库的新版本比之前的版本尺寸大且 无法保存在先前分配的位置,那么整个共享库,以及引用这些库的程序都需要被重新链接。

    另一种方案就是允许不同的程序将库映射到各自地址空间的不同位置。这会使库的管理容易一些,但是这需要编译器、链接器和程序加载器的配置,好让库可以在工作的时候忽略掉它被加载到地址空间的什么位置。

    简单的实现方法是在库中包含标准的重定位信息,在库被映射到各个地址空间时,加载器可以修改程序中的任何重定位地址以反映库被加载的位置。不幸的是,修改 的过程会导致对库的代码和数据的修改,这意味着若它是按照COW方式映射的则对应的页不能再被共享,或它是按照RO方式映射的则会导致程序的崩溃。

    为了避免这种情况,共享库可以选择生成位置无关代码(PIC:Position Independnet Code),这使得其无论被加载到内存中的任何位置都可以正常工作的代码。共享库中的代码通常都是位置无关代码,这样代码可以以RO方式映射。数据页仍然带有需要被重定位的指针,但由于数据页将以COW方式映射,因此这里对共享不会有什么损失。

    对于大部分计算机系统,位置无关代码是非常容易创建的。

2.8    Intel 386分段

    虽然386同时支持32位段和286的16位段的所有特性,但多数32位程序根本就不使用段。386中也加入了分页机制,可以提供分段机制的多数实用优点,并且没有性能损失,也没有编写额外段操作代码的麻烦。多数386操作系统在”微小(tiny)模式“下运行应用程序,即将四个段寄存器设置成相同的值


第三章    目标文件

3.1    目标文件中都有什么?

    一个目标文件包含五类信息:

    a.头信息:关于文件的整体信息,诸如代码大小,翻译成该目标文件的源文件名称,和创建日期等。

    b.目标代码:由编译器或汇编器产生的二进制指令和数据。

    c.重定位信息:一个列表,记录了链接器在修改目标代码的内存地址时需要进行调整的位置。

    d.符号信息:该模块中定义的全局符号,以及从其它模块导入的或者由链接器定义的符号。

    e.调试信息:目标代码中与链接任务无关但会被调试器使用到的其它信息。包括源代码文件和行号信息、本地符号、被目标代码使用的数据结构描述信息(如C语言数据结构定义)等。

3.1.1    设计一个目标文件格式


    对一个目标文件格式的设计实际上是对目标文件所处的各种用途导致的折衷方案。

    一个目标文件可能是可链接的,能够作为链接编辑器或链接加载器的输入;它也可能是可执行的,可以加载到内存中作为一个程序运行;或者是可加载的,作为库同程序一起被加载到内存中;或者它是以上几种情况的组合。某些格式只支持上面的一到两种用法,而另一些格式则支持所有的用法。

3.2    空目标文件格式: MS-DOS的.COM文件

    碰到一个仅有可运行二进制代码而没有其它信息的能够使用的目标代码文件是可能的,MS-DOS的.COM就是最有名的例子。x86的分段架构使得这种文件格式可以工作。

   
3.3    Unix a.out文件

    具有硬件内存重定位部件的计算机系统(今天几乎所有的计算机都有)通常都会为新运行的程序创建一个具有独立地址空间的新进程,这种情况下程序就可以按照从 某个固定地址开始的方式被链接,而不需要加载时的重定位。UNIX的a.out目标文件格式就是针对这种情况的。

3.3.1    a.out头部

    由于UNIX会自动将新分配的内存清零,因此初值无关紧要或者为0的数据不必在a.out文件中存储。未初始化数据大小a_bss说明了在a.out文件中的可读写数据段后面逻辑上存在多少未初始化的数据(实际上是被初始化为0)。

    已经被链接好可以运行的程序中既不需要符号表也不需要重定位信息,所以除非链接器为了调试器加入符号信息,否则在可运行文件中这些域都是0。
   

3.4    可重定位:MS-DOS EXE文件

    对于那些可以为每一个进程分配新的地址空间让每个程序都可以加载到相同逻辑地址的系统而言,a.out格式是足够了。但是很多系统就没有那么幸运了。有一 些系统会将所有的程序加载到同一地址空间。还有一些系统虽然会为程序分配自己的地址空间,但是并不总是将程序加载到相同的地址(32位Windows系 统就属于这最后一类)。在这些情况下,可执行程序会包含多个(通常被称为fixups的)重定位项,它们指明了程序中需要在被加载时进行修改的地址位置。具有fixups的最简单的格式之一就是MS-DOS EXE格式


3.5    符号和重定位

    
多数目标文件并不是可加载的,而由编译器或汇编器生成并传递给链接器的中间文件。这些可链接文件比起那些可运行文件来说,要复杂的多。可运行文件要运行在计算机的底层硬件上因此必须要足够简单,但可链接文件的处理属于软件层面,因此可以做很多非常高级的事情

    处于效率的考虑加载器通常都尽可能的简单,以提高程序启动的速度。动态链接将很多工作由链接器转移到加载器,由此在性能上有一些损失,但由于现代计算机的速度足够快了,所以采用动态链接的利大于弊。


3.6    可重定位的a.out格式

    UNIX系统对于可运行文件和可链接文件都使用同一种目标文件格式,其中可运行文件省略掉了那些仅对链接器有意义的段(segment)。

    对于相对简单的分页系统,a.out格式是简单而有效的。之所以被淘汰出主流,主要是因为它不能很容易的支持动态链接。并且,a.out格式不支持C++语言,因为C++语言对所有的初始化和终结代码都需要特殊的处理。

3.7    Unix ELF格式

    ELF格式有三个略有不同的类型:可重定位的(relocatable),可执行的(executable),和共享目标(shared objects)

    可重定位文件由编译器和汇编器创建,但在需要经链接器处理后才能运行。
    可执行文件已完成了所有的重定位工作和符号解析(除了那些可能需要在 运行时被解析的共享库符号)。
    共享目标就是共享库,既包括链接器所需的符号信息,也包括运行时可以直接执行的代码。

    ELF格式具有不寻常的双重特性。链接器将这个文件看作是由section header table 描述的逻辑section的集合,而系统加载器将文件看成是由program header table描述的的segment的集合。一个segment通常会由多个section组成。section是用于链接器后续处理的,而segment 会被映射到内存中。


    ELF头部被设计为即使在那些字节序与文件的目标架构不同的机器上也可以被正确的解码。

3.7.1    ELF可重定位文件

    可重定位或共享目标文件可以看成是在section header中被定义的若干section的集合。每个section只包含一种类型的信息,可以是程序代码、只读数据或可读写数据、重定位项或符号。模 块中定义的符号,其地址都是相对某个section的,因此一个函数入口点是由包含该函数代码的code section的相对地址来定义的。

    section type

        1).PROGBITS:程序内容,包括代码,数据和调试器信息

        2).NOBITS:类似于PROGBITS,但在文件本身中并没有分配空间,用于BSS数据

        3).SYMTAB和DYNSYM:符号表

        4).STRTAB:字符串表

        5).REL和RELA:重定位信息

        6).DYNAMIC和HASH:动态链接信息和运行时符号hash表

    常见section
           
        1).text: 具有ALLOC和EXECINSTR属性的PROGBITS类型的section,相当于a.out中的文本段。

        2).data:具有ALLOC和WRITE属性的PROGBITS类型的section,对应于a.out中的数据段。

        3).rodata: 具有ALLOC属性的PROGBITS类型的section。由于是只读数据,因此没有WRITE属性。

        4).bss:具有ALLOC和WRITE属性的NOBITS类型的section。由于没有在文件中分配空间,因此是NOBITS类型,但由于会在运行时分配空间,所以具有ALLOC属性。

        5).rel.text(.rel.data):每个都是REL或RELA类型的section,包含对应文本或数据section的重定位信息。

        6).init:具有ALLOC和EXECINSTR属性的PROGBITS类型的section。与.text section相似,但分别为程序启动和终结时执行的代码。C和Fortran不需要这个,但是对于具有初始和终结函数的全局数据的C++语言来说是必须 的。

        7)..symtab和.dynsym:都是STRTAB类型的section

        8).一些特殊的section,例如全局偏移量表.got和动态链接时需要的过程链接表.plt

        还有一个特殊的section .interp,它包含解释器程序的名字。如果这个section存在,系统不会直接运行这个程序,而是会运行对应的解释器程序并将该ELF文件作为参数传递给解释器。

3.7.2    ELF可执行文件

    一个ELF可执行文件具有与可重定位ELF文件相同的通用格式,但其中数据的布局能够使得文件被映射到内存中并运行。这样的文件中会在ELF头部后面存在一个programm header,在其中定义了要被映射的segment。

    一个可执行程序通常只有少数几种段,如代码和数据的只读段,可读写数据的可读写段。所有的可加载section都归并到适当类型的段中,以便系统可以通过少数的一两个操作就可以完成文件映射。

3.7.3    ELF共享目标(share object)    

    ELF共享目标包含了可重定位和可执行文件中的所有结构。它在文件中的ELF头部后面具有jprogram header,随后是可加载段所包含的section(包括动态链接信息)。在构成可加载段的各section之后的,是重定位符号表和链接器在根据共享 目标创建可执行程序时需要的其它信息,最后是section table。
   
3.7.4     ELF总结

    ELF是一种较为复杂的格式,但它的表现和预期的一样好。它既是一个足够灵活的格式(可以支持C++),又是一种高效的可执行格式(对于支持动态链接的虚 拟内存系统),同时也可以很方便的将可执行程序的页直接映射到程序的地址空间。它还允许从一个平台到另一个平台的交叉编译和交叉链接,并在ELF文件内包 含了足以识别目标体系结构和字节序的信息。


3.9    PE格式

    PE可执行文件是专为分页系统设计的,因此PE文件中的页通常可以直接被映射到内存中并运行,这与ELF可执行文件很相似。PE格式文件要么是EXE程 序,要么是DLL共享库(即动态链接库)。这两种类型的格式是相同的,通过一个状态位来标识这个PE文件属于哪一种。

    COFF头部描述了文件的内容,其中最重要的内容是section table中的表项数目。

    链接器会为每个PE文件创建一个文件将被映射的特定目标地址(imagebase)。如果该地址所在的地址空间区域有效(几乎总是有效的),就不需要进行加载时的调整了。

3.9.1    PE特有区段

    Pe文件除了像UNIX可执行程序那样包含.text、.data和.bss(有些时候)外,还包含有很多Windows特有的section。

    1).导出section:当前模块中定义并对其它模块可见的符号列表。可执行程序通常不导出符号,或为调试导出少数符号。DLL会为它们所提供的函数和 数据导出符号。为了保持Windows节省空间的传统,被导出的符号也可以通过称为导出序号的小整数来引用,就像引用符号名字那样。

    2).导入section:列出了所有需要在加载时从DLL中进行解析的符号。

    3).资源section:资源表以树的结构来组织。与那些ASCII名称的符号不同,资源使用Unicode名称以支持英语外的语言

    4).线程本地存储(TLS)section:该section通常出现在EXE文件中而在DLL中没有,这是因为在程序动态链接到某个DLL时Windows不会为TLS 分配存储。


3.9.2    运行PE可执行文件

    启动一个PE可执行程序的过程是相对简单的。

    A.读入文件的第一页,其中有DOS头部,PE头部和section table等。
    B.确定地址空间的目标区域是否有效,如果不可用则另分配一块区域。
    C.根据各区段头部的信息,将文件中的所有区段映射到地址空间的适当位置上。
    D.如果文件并没有被加载到它的目标地址中,则进行重定位。
    E.遍历导入区段中的DLL列表,将任何未加载的库都加载(该过程可以是递归的)。
    F.解析所有在导入区段中的导入符号。
    G.根据PE头部的值创建初始的栈和堆。
    H.创建初始线程并启动该进程。

3.9.4    PE文件小结

    对于一个支持虚拟内存的线性寻址操作系统而言,PE文件格式是相当不错格式(还具有少量从DOS继承而来的历史包袱)。它还包括了一些额外特性,诸如专为提高小型系统上程序加载速度的序号式的导入、导出(但其在当代32位系统上的效率还是有待商榷的)。

3.10    比较
   
    诸如ELF这样的现代目标格式都倾向于将相同类型的数据聚集在一起,以方便链接器的处理。它们在安排文件布局时还考虑到了虚拟内存的因素,这样系统的加载器就可以使用尽可能少的额外工作来将文件映射到程序的地址空间中去。




第四章    存储空间分配

    链接器或加载器必须完成的第一个任务是存储分配。分配了存储空间后,链接器才可以继而进行符号绑定和代码调整。在一个可链接目标文件中,多数符号都是相对于文件内的存储区域定义的,所以只有存储区域确定了才能够进行符号解析。

    与链接的其它方面情况相似,存储分配的基本问题是很简单的,但处理计算机体系结构和编程语言语义特性的细节让问题复杂起来。


4.1    段(segment)和地址

    重定位链接器或加载器中的基本问题是要确保程序中的所有段(segment)都被定义且具有地址,并且这些地址之间不能发生重叠。

    通常,连接过程中所有输入目标文件中的相同类型的段诸如可执行代码段,经过链接器的处理,会在输出目标文件中被合并为一个段。

    存储布局是一个“两遍”的过程,这是因为输出目标文件中每个段的基址在所有输入目标文件中段的大小全部已知前是无法确定的。

   
4.4    段与页面的对齐

    如果文本和数据被加载到独立的内存页中,文本段的大小必须扩充为一个整页,这导致数据和BSS段的位置也要进行调整。很多UNIX系统都使用一种技巧来节 省文件空间,即在目标文件中数据紧跟在文本的后面,并将那个文本和数据共存的页在虚拟内存中映射两次,一次映射至RO的文本段,另一次映射至COW的数据段。这种情况下,数据段在逻辑上起始于文本段末尾紧接着的下一页,这样就不需扩充文本段,数据段也可对齐于紧接着文 本段后的4K(或者其它的页尺寸)页边界。


4.6.2    C++重复代码消除


    在某些编译系统中,C++编译器会由于虚函数表、模板和外部inline函数而产生大量的重复代码。

    在每个源代码文件都被单独编译的环境中,最简单的实现机制就是将所有的vtbl都放入到每一个目标文件中,扩展所有该文件用到的模板函数和外部inline函数,这样做的结果就是产生大量的冗余代码。

    想要从正面解决这个问题,要么是让链接器更聪明一些,要么就是将链接器同程序开发环境的其它部分整合在一起。前一种方法是让编译器在每个目标文件中生成所有可能的重复代码,然后让链接器来识别和消除重复的代码。


4.9    实际中的存储分配

4.9.2    ELF格式的链接器存储分配策略

    ELF链接要比a.out复杂一些,因为输入文件中的各个段可以是任意大小的,链接器必须将输入的section转换为可加载的segment。

    


    与a.out不同,ELF格式不会从0位置加载任何东西,而是从地址空间的中间部位来加载,这样栈可以在文本段以下向下增长,堆可以在数据段末尾以上向上增长,以更加紧凑的利用地址空间。

4.9.3    PE格式的链接器存储分配策略

    PE文件的存储分配策略比ELF文件还要简单一点,这是因为PE的动态链接模式需要
较少 链接器支持,代价是编译器承担了更多的工作 。



第五章    符号管理


    符号管理是链接器的关键功能。如果没有某种方法来进行模块之间的引用,那么链接器的其它功能也就没有什么太大的用处了。

5.1    绑定和名称解析


    链接器读入输入文件中所有的符号表,并提取出有用的信息,通常都是关于哪些东西
需要链接 的。然后它会建立链接时符号表并使用该表来指导链接过程。根据输出文件格式的不同,链接器会将表中部分或全部的符号信息写入输出文件中。

    第一遍扫描中,链接器从每一个输入文件中读入符号表,通常是将它们一字不差的复制到内存中。
   

    某些格式会在一个文件中存在多个符号表。例如ELF共享库会有一个动态链接所需信息的符号表,和一个单独的更大的用来调试和重链接的符号表。这个设计不见 得糟糕。动态链接器所需的表比全部的表通常要小得多,将它独立出来可以加快动态链接的速度,毕竟调试或重链接一个库的机会(相比运行这个库)还是很少的。

5.2    符号表格式


    链接器中的符号表同编译器中的相似,通常会更简单一些,因为链接器需要维护的符号信心没那么复杂。

    链接器通常维护三个符号表。第一个列出输入输入文件和库模块,记录每个文件的信息。第二个记录全局符号。第三个记录模块内的调试符号,通常链接器只是简单的将输入文件的调试符号收集后写入输出文件。

5.2.1   模块表


    链接器需要跟踪整个链接过程中出现的每一个输入模块,既包括明确指明的模块,也包括从库中提取出来的模块。

5.2.2    全局符号表


    当第一遍扫描完成后,每一个全局符号应当仅有一个定义,0或多个引用。

    随着输入文件中的全局符号被加入到链接器的全局符号表中,链接器会将文件中每一个符号表项链接到它们在全局符号表中对应的表项中,如下图所示。重定位项一般通过本模块中的符号表的
索引 来引用符号,因此对于每一个外部引用,链接器必须要分辨出符号之间的关系;例如模块A中的符号15名为fruit,模块B中的符号12同样名为fruit,也就是说,它们是同一个符号。每一个模块都有自己的索引集,相应也要用自己的指针向量。



5.2.3    符号解析

    在链接的第二遍扫描过程中,链接器在创建输出文件时会完成对符号引用的
解析 。解析的细节与重定位是相互影响的,这是因为在多数目标格式中,重定位项中使用了符号引用。
     在最简单的情况下,即链接器使用绝对地址来创建输出文件,解析仅仅是用符号地址来替换符号的引用。如果符号被解析到地址0X20486处,则链接器会将相 应的引用替换为0X20486。实际情况要复杂得多。诸如,引用一个符号就有很多种方法,通过数据指针,嵌入到指令中,甚至通过多条指令组合而成。此外, 链接器生成的输出文件本身经常还是可以再次链接的。这就是说,如果一个符号被解析为数据区段中的偏移量426,那么在输出中引用该符号的地方要被替换为可 重定位引用的[数据段基址+426]。


5.2.4    特殊符号

    很多系统还会使用少量链接器自定义的特殊符号。例如所有的UNIX系统都要求链接器定义etext、edata和end这三个符号,依次表示文本、数据和 BSS段的结尾。系统调用sbrk()会以end的地址作为运行时内存堆的起始地址,所以堆可以连续的分配在已经存在的数据和BSS的后面。
   
5.3    名称修改


    在目标文件符号表和链接中使用的名称,与编译出目标文件的源代码程序中使用的名称往往是有差别的。主要原因有三:

    1.    避免名称冲突
    2.    名称重载
    3.    类型检查


    将源代码中的名称转换为目标文件中名称的过程称为名称修改(name
memangling)


5.3.1    解决名称冲突

    UNIX系统采取的办法是修改C和Fortran函数的名称这样就不会因为疏忽而与库和其它函数的名称冲突了。C函数的名称通过在前面增加下划线来修饰,。Fortran函数名称进一步被修改首尾各有一个下划线。

    在其它系统上,编译器设计者们采取了截然相反的方法。多数汇编器和链接器允许在符号中使用C和C++标识符中禁用的字符,如.或者$。运行库会使用带有禁用字符的名称来避免与应用程序的名称冲突,而不再是修改C或fortran程序中的名称。

5.3.2    C++类型编码:类型和范围


    修改名称的另一个用处是将范围和类型信息编码,这样就可以用现存的链接器来链接使用C++、Ada和其它比C、Cobol和Fortran具有更复杂命名规则的语言编写的程序了。

    C++最初是通过名为cfont的翻译器来实现的——其生成C代码并使用已有的链接器,因此它的作者对函数名称进行名称修改以在C编译器不察觉的情况下由 链接器来处理。后面几乎所有的C++编译器都实现了直接生成目标代码或至少是汇编代码,但名称修改仍然被保留下来做为处理名称冲突的标准方法。虽然现代链 接器已经能获取足够信息来进行反修改,但它们还是显示修改过的名称。

    主流的C++手册都描述了cfront使用过的这种名称修改策略,其中的一些微小变化现在已经成为了事实标准。

    C++类之外的数据变量名称不会进行任何的修改。一个名为foo的数组修改后的名称仍为foo。

    与类无关的函数名称修改后增加了参数类型的编码,通过前缀__F后面跟表示参数类型的字母串来实现。
    例如函数func(float, int, unsigned char)变成了func__FfiUc。

    类还可以包含内部多级子类的名称,这种限定性(qualified)名称被编码为Q,还有一个数字标明该成员的级别,然后是编码后的类名称。因此 First::Second::Third就变成了Q35First6Second5Third。这意味着采用两个类做为参数的函数f(Pair, First::Second::Third)就变成了f__F4PairQ35First6Second5Third。

    类的成员函数编码:先是函数名,然后是两个下划线,接着是编码后的类名称,最后是F和参数,所以cl::fn(void)就变成了
f n__2clFv。

    名称修改可以为每一个可能的C++类提供唯一的名称,相应的代价就是在错误信息和列表中会出现惊人长度和(在缺乏链接器和调试器支持时)难以理解的名称。 尽管如此,C++还有一个本质上的问题就是名字空间相当巨大。任何表示C++对象名称的策略都会具有和名称修改相近的冗余,而名称修改的优势在于至少还有 一些人可以读懂它

5.3.3    链接时类型检查

    虽然名称修改在C++出现后才流行起来,但链接器类型检查的思想已经由来已久。

    链接器类型检查的想法非常简单:多数语言都有声明了参数类型的函数,如果调用者没有将被调用函数期望的参数个数或类型传递给被调用函数,那就是错误;如果调用者和被调用者在不同的文件中被编译,那这种错误是非常难以察觉的。

    对于链接器类型检查,每一个定义和未定义的全局符号都会有一个用字串表示的参数和返回值类型,形式与名称修改中的C++参数类型相近。在链接器解析一个符 号时,它将引用处的类型串与符号定义处的类型串进行比较,如果不匹配则报错。这个策略的好处之一就是链接器根本不需要理解类型编码的含义,仅仅比较字串是 否相同就可以了。


5.4    弱外部符号

    很多目标格式都会将符号引用区分为弱或是强。强引用必须被解析,而弱引用存在定义则解析,如果不存在定义也不认为是错误。

    通常链接器会将未定义的弱符号定义为0,这是一个应用程序代码可以检查的数值。

    弱符号在链接函数库的时候是非常有用的。

5.5    维护调试信息

    现代编译器都支持源代码级的调试。即程序员可以基于源代码的函数和变量来调试目标代码,设置断点和单步跟踪。编译器通过将调试信息插入目标文件来实现的,调试信息包括源代码行号到目标代码地址的映射,并描述了程序中用到的所有函数、变量、类型和数据结构。

5.5.1    行号信息

    所有基于符号的调试器都必须将程序地址和源代码行号对应起来。这样就可以通过调试器将断点放入代码的适当位置来实现用户基于源代码行号的断点设置,并可以让调试器将调用堆栈中的程序地址和错误报告中的源代码行号关联起来。

5.5.2    符号和变量信息

    编译器还要为每一个程序变量生成名称、类型和位置。调试符号信息某种程度上要比名称修改更为复杂,因为它不仅要对类型名称编码,还有定义类型时的数据结构类型,这样才能保证调试器能够正确处理一个数据结构中的所有子域的格式。

5.5.3    实际问题

    多数情况下,链接器仅仅传递调试信息而不对其进行解释,也可能在这个过程中会重定位和段相关的地址。

    链接器正在逐渐加入新的功能特性:探测和去除不同模块中的冗余调试信息——例如C/C++中多个TU包含相同头文件而引入的冗余的调试信息。

    UNIX系统有一个strip命令,可以将调试符号信息从一个目标文件中去除而不改变任何其它代码。



第六章    库

    本章讨论传统的静态链接库(statically linked libraries),更复杂的共享库(shared libraries)在第九章和第十章讨论。

6.1    库的目的


    从本质上说,库文件是由多个目标文件的聚合体,通常还会加入一些有助于快速查找的目录信息。

6.2    库的格式


    最简单的库格式就是仅仅将目标模块顺序排列。目录信息可以相当显著的提高库中搜索速度,现在已经成为了库格式的标准组件。

6.2.2    UNIX和Windows的Archive文件

    UNIX中函数库使用一种称为“archive”的格式,它实际上可以用于任何类型文件的聚合,但实践中很少用于其它地方。

    最早的archive没有符号目录,只有一系列的目标文件,但后续版本就出现了多种类型的目录。虽然各种archive中符号目录的格式多少有些不同,但 功能上都是相似的,即在各种名称与成员位置之间建立映射,以便链接器可以直接移动到它所需要的成员处并进行读取。

6.4    扫描库文件

    对库的扫描通常发生在链接器第一遍扫描中所有单独的输入文件都被读入之后。

    如果库具有符号目录,那么链接器就将其目录读入,然后将目录中的符号与链接器符号表进行比照。如果某符号被使用但是未定义,链接器就会将该符号所属的目标文件从库中提出并包含进来。

    库的符号解析是一个迭代的(闭包)过程。链接器对符号目录中的符号完成一遍扫描后,如果在这遍扫描中它又从该库中包括进来了任何目标文件,那么就还需要再进行一次扫描来对新包括进来的目标文件中的符号进行解析,直到对整个目录彻底扫描后不再需要括入新的文件为止。

    当程序员们使用多个库的时候,如果库之间存在循环依赖的时候经常需要将库列出多次。就是说,如果一个库A中的函数依赖一个库B中的函数,但是另一个库B中的函数又依赖了库A中的另一个函数,那么从A扫描到B或从B扫描到A都无法找到所有需要的函数。

6.5    性能问题


    和库相关的主要性能问题是花费在顺序扫描上的时间。一旦符号目录成为标准之后,从一个库中读取输入文件的速度就和读取单独的输入文件没有什么明显差别了,而且只要库是拓扑排序的,那链接器在基于符号目录进行扫描时很少会超过一遍。

6.6    弱外部符号


    符号解析和库成员选择中所采用的简单的定义引用模式对很多应用而言显得灵活有余效率不足。例如,大多数C程序会调用printf函数族中的函数来格式化输 出数据。printf可以格式化各种类型的数据,包括浮点类型。这就意味着任何使用pringf的程序都会将浮点库链接进来,即便它根本不使用浮点数。

    弱外部符号,就是不会导致库成员被加载的外部符号。


第七章    重定位


    为了确定段大小、符号定义和符号引用,并指出该包含那些库模块以及将这些段放置在地址空间的什么位置,链接器会对所有的输入文件进行扫描。扫描完成后的下一步就是链接过程的核心:重定位

    重定位这个概念包含调整段基址和解析对外部符号的引用这两个过程,因为这两个过程通常是一起进行的。

    第一遍扫描也会建立第五章中所讲的全局符号表。链接器还会将输入文件中对全局符号的引用解析为全局符号表的地址。

7.1    硬件和软件重定位

 
    由于几乎所有的现代计算机都具有硬件重定位,可能会有人疑问为什么链接器或加载器还需要进行软件重定位。答案部分在于性能的考虑,部分在于绑定时间。

     硬件重定位允许操作系统为每个进程分配一个独立的从固定位置开始的地址空间,这就使程序容易加载,并且可以避免在一个地址空间中的程序错误破坏其它地址空间 中的程序。软件链接器或加载器重定位将输入文件合并为一个大文件以加载到硬件重定位提供的地址空间中,然后就根本不需要任何加载时的地址修改了。

7.2    链接时重定位和加载时重定位

    很多系统既执行链接时重定位,也执行加载时重定位。链接器将一系列的输入文件合并成一个准备加载到特定地址的单一输出文件。当这个程序被加载后,若所存储的那个地址是无效的,加载器必须重新定位被加载得程序以反应实际的加载地址。

    在包括MS-DOS在内的一些系统上,每一个程序都以被加载到地址0的为假想进行链接处理。而程序的实际加载地址是由具体的有效地址空间决定的,因此程序在被加载时总是会被重定位。

    在其它的一些系统上,尤其是MS Windows,程序按照被加载到一个固定有效地址的方式来链接,并且一般不会进行加载时重定位,除非出现该地址已被别的模块占用之类的异常情况(当前 版本的Windows实际上从不对可执行程序进行加载时重定位,但是对DLL共享库会进行重定位。类似的,UNIX系统从不对ELF格式的可执行程序进行加载时重定位,虽然 它们对ELF共享库会进行加载时重定位)。

    加载时重定位和链接时重定位相比要简单许多。在链接时重定位中,不同地址的调整量随段的大小和位置的不同而变化。而在加载时重定位中,整个程序被视为
单一 整体,加载器只需要判断假想加载地址和实际加载地址的差异即可。

7.3    符号和段重定位

    链接器的第一遍扫描将各个段的位置和大小列出,并收集程序中所有全局符号在其所属段中的相对位置。一旦链接器确定好了每个段的最终位置,它就需要调整存储地址。

    重定位和符号解析的需求有些许不同。对于重定位,基址的数量相当小,也就是一个输入文件中的段的个数,不过目标文件格式必须允许对任何段中的任何地址引 用进行重定位。对于符号解析,符号的数量远远大的多,但是大多数情况下链接器只需要对符号进行一种处理,即将符号的值插入到程序的一个字大小的空间中。

    很多链接器将段重定位和符号重定位统一对待,实现方法将段视为值为段基址的“伪符号”。这样段相关的重定位就成了符号相关的重定位的一个特 例。即使这样的链接器中,这二者之间仍有一个重要区别:符号引用涉及两个值,即符号所属段的段基址和符号在段内的偏移量(译者注: 这里作者少说了半句话:而将段作为符号处理时,这个特殊符号只有段基址,没有段内偏移量)。

7.4    基本的重定位技术

    通常情况下,重定位是一次性操作,即执行重定位处理生成的输出文件无法进行二次重定位。某些目标文件格式(例如IBM 360)是可重链接的,并且输出文件中仍包含用于重定位的信息。Unix中的链接器可以通过命令行选项生成可重链接的输出文件,最常见的使用场合是共享 库,这种情况下的输出文件总是包含重定位信息,因为其在加载时必须进行重定位操作。

    由于在机器指令层次可能存在多种指令格式的缘由,对指令中包含地址的重定位要比
数据指针的 重定位麻烦一些。

7.5    可重链接和重定位的输出格式

    有一小部分格式是可以重链接的,即输出文件带有符号表和重定位信息,这样可以作为下一次链接操作中的输入文件。
    很多格式是可重定位的,这意味着输出文件保存有供加载时重定位使用的重定位信息。

    对于可重链接文件,链接器基于输入文件重定位项生成输出文件的重定位项。在这个过程中,某些重定位项被原样输出,某些被修改了,某些会被丢弃。

    对于可以重定位但不能重链接的输出格式,链接器将丢去不是与段基址相关的重定位项。

7.6    其它重定位格式


    虽然多数重定位项的普遍实现形式是数组,但也有别的实现可能,例如链表和位图。

7.6.1    特殊段


    多数目标格式中存在需要被链接器特殊对待的段。

7.7    特殊情况的重定位

    很多目标文件格式都有弱”部符号:如果输入文件碰巧定义了它的话,那么它就会被当作是普通的全局符号,否则就为空(细节请参看第5章)。无论是哪种情况,都会像其它符号那样进行引用解析。


第8章    加载和重叠

    加载是将一个程序放到内存里使其能运行的过程。本章的注意力集中在加载那些已经链接好的程序上。

    很多系统曾经都有过将链接和加载合为一体的链接加载器,但是现在除了运行MVS的硬件和第十章将会讨论的动态链接器外,其它的实际上已经基本消失了。

    链接加载器和单纯的加载器没有太大的区别,主要区别在于前者的输出放在内存而不是在文件。

8.1    基本加载

    依赖于程序是通过虚拟内存系统被映射到进程地址空间,还是通过普通的I/O调用读入,加载会有一点小小的差别。

    如果程序不是通过虚拟内存系统映射的,读入目标文件意味着通过普通的read系统调用来进行。在支持共享只读代码段的系统中,系统会检查是否在内存中已经加载了该代码段的一个副本,如果是的话就复用之而不是创建一份新的副本。

    在进行内存映射的系统上,这个过程会稍稍复杂一些。系统加载器需要创建段,然后以页对齐的方式将文件页映射到段中,并赋予适当的权限,如RO或COW。在 某些情况下,同一个页会被映射两次,一个在一个段的末尾,另一个在下一个段的开头,分别被赋予RO和COW权限,类似于紧凑的 a.out格式。

8.2    带重定位的基本加载

    目前仅有一小部分系统还仍然对可执行程序在加载时执行重定位操作,大多数都是只对共享库的加载进行重定位操作。

    如第七章所指出的,加载时重定位要比链接时重定位简单的多,因为整个程序是作为一个单元进行重定位的。

    共享库的加载时重定位可能导致性能问题——由于共享库在不同程序的地址空间内被加载时产生的地址修正值可能不同,所以被加载到不同虚拟地址的代码通常不能在不同地址空间之间共享。


8.3    位置无关代码

    解决同一模块被加载到不同地址这个问题的常用的解决方案是位置无关代码(position independent code, PIC)。它的思想很简单,就是将数据和代码分离,并生成不受加载地址影响的代码。使用这种方案,代码页可以在所有进程间共享,只有数据页为各进程自己私 有。

    这是一个令人吃惊的老想法。TSS/360在1966年就使用它了。

    在现代体系结构中,生成PIC可执行代码并不困难。跳转和分支代码通常与指令计数器相关的,或是与某个
运行时 基址寄存器相关的,所以不无需这些代码执行加载时重定位。问题在于对数据的寻址,代码中不能包含任何直接数据地址,否则代码就是需要重定位的,而不是位置无关的。通常的解决方案是在数据页中建立一个存放数据地址的表格,并在 一个寄存器中保存这个表的基址,这样代码可以使用相对于寄存器中基址的索引值来获取数据地址。这种方案的成本在于对每一个数据引用需要进行一次额外的间接 访问,另一个问题是如何正确将该表格的基址存放如寄存器中。

8.3.3   ELF格式中的位置无关代码

    
    ELF的设计者注意到一个ELF格式的模块(与本章背景更贴切的话应该是ELF共享库)中的代码页组跟在数据页组后面,不论其被加载到地址空间的什么位 置,代码段与数据段之间的偏移量是不变的。可以将代码段的基址加载到某个寄存器中,所有数据相对于该基址的偏移量是个确定值,因此程序可以有效的使用基址寻址方式来对该模块数据段中的数据进行访问。

    链接器将为采用PIC方案的ELF模块创建
全局偏移量表 GOT,其中包含了对该模块中所有全局变量的寻址信息。 通常这是针对共享库的,然而如果生成采用PIC方案的主程序的话,其同样具备GOT。由于链接器负责创建GOT,所以对于每个ELF模块中的全局数据只对应一个GOT表项(指针),不论该模块中有多少个函数引用了该数据。

    PIC模块加载示意图



8.3.5    位置无关代码的开销和收益

    PIC的优势是明显的:它使得共享库在加载时无需重定位成为可能,这样可以在不同进程间共享代码的内存页面,即使该模块在不同地址空间中并未加载到相同的位置。

    PIC的劣势就是在加载时、在过程调用中以及在函数开始和结束时会降低速度,并使全部代码变得更慢。在
PIC模块 加载时,虽然其代码段不需要被重定位,但是其数据段却需要。在一个规模较大的库中,TOC或GOT可能会非常大,以至于要花费很长的时间去解析其中的所有项。

    PIC代码要比非PIC代码更大、更慢。到底会有多慢很大程度上依赖于体系结构。对于拥有大量寄存器且无法直接寻址的RISC系统来说,少一个用作TOC或GOT指针的寄存器影响并不明显,并且缺少直接寻址而需要的一些排序时间是不变的。最坏的情况是在x86下。它只有6个寄存器,所以用一个寄存器当作GOT指针对代码的影响非常大。由于x86可以直接寻址,一个对外部数据的引用在非 PIC代码下可以是一个简单的MOV或ADD,但在PIC代码下就要变成加载紧跟在MOV或ADD后面的地址,这既增加了额外的内存引用又占用了宝贵的寄 存器作为临时指针。
 

第九章    共享库


    所有共享库基本上以相同的方式工作。在链接时,链接器搜索整个库以找到用于解决那些未定义的外部符号的模块。但链接器不把模块内容拷贝到输出文件中,而是 标记模块所属的库名,同时在可执行文件中放一个库的列表。当程序被装载时,启动代码找到那些库,并在程序开始前把它们映射到程序的地址空间。标准操作系统 的文件映射机制自动共享那些以只读或写时拷贝的映射页。

    在本章,我们讨论static linked shared libraries,即库中的程序和数据地址在链接时被绑定到可执行体中。在下一章我们讨论更复杂的dynamic linked libraries.

    尽管动态链接更灵活更现代,但也比静态链接要慢很多,因为原本只需在链接时执行一次的工作现在每次启动时都要重复执行。同时,使用动态链接的程序通常使用 额外的glue code来调用共享库中的函数,而glue cold中通常包含若干跳转指令,这会显著地降低调用速度。在同时支持静态共享库和动态共享库的系统中,除非程序需要动态链接的额外灵活性,不然使用静态 链接库能使它们更快更小巧。


9.1    绑定时间

    使用共享库时面临的绑定时间问题,在常规链接的程序中是不会遇到的。一个使用了共享库的程序在运行时依赖于库的有效性。当所依赖的库不存在时,就会发生错误。这种情况下除了打印出一个晦涩的错误信息并退出外,不会有更多的事情要做。

   
更有趣的问题发生在 库虽然存在,但是 库在 程序链接之后发生变更的情况下。在常规链接的程序中, 符号 在链接时被绑定到地址上,而库代码也在链接时被绑定( 复制 )到可执行体了,所以这样的程序会忽略库的后续变更。对于静态共享库,符号在链接时被绑定到地址上,不过库代码要直到运行时才被绑定到可执行体上。对于动态共享库,这二者的绑定都推迟到运行时。

    静态链接共享库的更改很容易破坏依赖于其的可执行程序的有效性。因为库中函数和数据都已经绑定(固化到)到可执行程序中了,这些地址的任何改变都将导致灾难。


9.3    地址空间管理


    共享库中最困难的就是地址空间管理。每一个共享库在使用它的程序里都占用一段固定的地址空间。不同的库如果要在一个程序同时被使用,它们必须占用互不重叠的地址空间。

    虽然理论上可以将若干函数杂乱无规则的链接(聚合)成共享库供第三方程序调用,但实际使用的共享库在创建时都采用了如下原则,以更好的保证库的更新不会破坏对其存在依赖关系的程序。

    1).对于代码地址:通常不选导出函数的实际地址,而是加入一个中间层,在共享库创建一个跳转指令表,每个表项对应一个导出函数,实际对外导出的是这些跳转表项的地址。每个函数多出一 条跳转指令不会明显的降低性能;另一方面由于函数的实际地址是对外不可见的,所以即使共享库的新版本与旧版本中函数大小和入口地址都发生了变化,新旧版本之间仍然兼容的。

    2).对于导出的数据:情况要复杂一些,因为没有一种类似对代码地址那样的简单方法来增加一个间接层。实践表明,共享库的导出数据通常是
尺寸已知且 很少变动的。创建共 享库的程序员可以手动收集导出数据并放置在数据段的开头,使它们位于匿名(非导出)数据之前,这样使得这些输出地址在库更新时不太可能发生相对位置变 化。


9.5    创建共享库

    UNIX下的共享库实际上包含两个相关文件,即共享库本身和供链接器使用的空占位库(stub library)。共享库创建工具以archive格式的普通库和一些包含控制信息的文件作为输入,而以这两个相关文件作为输出。产生的空占位库不包含任何有用的代码和数据,但包含依赖于该共享库的程序在进行链接时所需的符号定义信息。

     创建一个共享库涉及以下几步:

    1).确定库的代码和数据将被加载到什么地址。
    2).对作为输入的普通库进行
彻底扫描, 寻找所有导出代码符号
    3).创建一个跳转表,每个表象对应一个导出的代码符号(即函数地址)。
    4).如果在库的开头有一个初始化或加载函数,那么就编译或者汇编它。
    5).创建共享库——运行链接器把所有输入内容链接为一个大的可执行格式文件。
    6).创建空占位库:从上一步创建的共享库中提取出需要的符号,为每一个导出的库函数创建一个空占位函数,并将这些空占位函数聚合为空占位库。

9.5.1    创建跳转表

    创建跳转表的最简单方法就是编写一个全部由跳转指令组成汇编源文件,并汇编它。

    对于像x86这样具有多种长度的跳转指令的平台,将不同长度的跳转指令混在一起是不能让人满意的,因为它使得各个表项的地址间距变得不一致,同时也更难在以后重建库时确保兼容性。

9.5.2    创建共享库

    跳转表建立好之后,创建共享库就很容易了。只需要使用合适的参数运行链接器,为代码和数据分配合适的起止加载地址,并将跳转表和作为输入的普通库中的所有函数都链接在一起。在创建共享库文件的同时也完成了对库中所有地址的赋值。


9.5.3    创建空占位库

    创建空占位库是创建共享库过程中最诡秘的部分之一。对应真实共享库中的每一个函数,空占位库中都要包含一个同时定义了导入和导出符号符号条目。

    不同于普通库模块,空占位库模块中既不包含代码也不包含数据,只包含符号定义。这些符号必须定义为绝对地址而不是可重定位相对,因为共享库已经完成了所有的重定位工作。


9.6    同共享库进行链接

    链接静态共享库的过程,比起创建静态共享库要简单得多,因为几乎所有确保链接器能够正确解析库中地址的困难工作都在创建空占位库时完成了。唯一困难的部分就是在程序开始运行时如何将其依赖的共享库映射进来。

    每一种可执行文件格式都会提供一个trick供链接器来创建一个库列表,以便程序的启动代码能利用该表将把库映射到程序的地址空间中。

    无论采用了那种实现方案,程序代码中对共享库中符号的引用都是基于空占位库包含的地址完成自动解析的。

9.7    运行使用共享库的程序


    启动一个使用共享库的程序需要三步:加载可执行程序、完成共享库的映射以及执行与各共享库相关的初始化操作。

    Linux增加了一个uselib()系统调用,以共享库的完整路径为参数,负责将其映射到程序的地址空间中。在使用了静态共享库的可执行文件中,可执行文件中包含的启动代码会对库列表中的共享库逐一调用哪个uselib()。

    BSD/OS的方法是使用标准的mmap()系统调用以及每个共享库中的bootstrap函数。可执行文件中的启动代码会针对库列表中的每个库进行相同 的操作:打开库文件,使用mmap()系统调用将库文件的第一页映射进来,然后调用位于该页中固定位置的bootstrap函数,由其完成剩余的映射工 作。


9.8    malloc hack和其它共享库问题

    虽然静态共享库具有很好的性能,但是它们的长期维护是困难和容易出错的。

    首先,在一个静态共享库中,所有的库内调用都被永久绑定了,所以不可能将某个程序中所使用的库函数通过重新定义替换为私有版本的函数。这通常不是什么大问 题,然而某些情况下确实会导致严重问题。例如很多程序中定义了私有版本的malloc()和free()函数——如果库使用malloc的标准版本来分配 空间,但是应用程序使用私有版本的free来释放空间,那么就会发生内存混乱。

    其次,全局数据的名字冲突仍然是遗留在共享库中的一个问题。

    最后,即使UNIX共享库中的跳转表也可能引起兼容性的问题。


 

 
第十章    动态链接和加载


    动态链接将很多链接工作推迟到了程序启动的时候。它提供了一系列其它方法无法获得的优点:

    1).动态链接共享库要比静态链接共享库更容易创建。
    2).动态链接共享库要比静态链接共享库更容易升级。
    3).动态链接共享库的语义更接近于那些非共享库。
    4).动态链接允许程序在运行时加载和卸载函数,这是其它途径所难以提供的。

    当然其也有些许不利。由于
大量的链接工作需要在 程序每次运行时重复执行,动态链接的运行时性能要比静态链接的低不少程序中所使用的每一个 动态链接的符号都必须在符号表中进行查找和解析(Windows的DLL某种程度上有所改善,下面将会讲到)。 动态链接库 比静 态库要大, 因为它还要包括符号表。

    除了调用兼容性问题之外,另外一个顽固的问题根源是库的语义变化。与非共享库或静态链接共享库相比,动态链接库的变更要容易很多,因而也就容易出现正被现有程序 使用的动态链接库的语义变更。这意味着即使程序本身没有任何改变,程序表现出行为也可能会改变。这在indows系统下是一个常见的问题,即著名的"DLL hell"。

10.1    ELF格式的动态链接

    八十年代晚期SUN首次在UNIX系统中引入了动态共享库技术。与SUN合作开发的UNIX系统V版本4,引入了ELF目标格式,并采用 了SUN的ELF方案。很明显ELF是对之前目标格式的改进,在九十年代末它成为UNIX、诸如Linux的类UNIX系统和BSD这样的衍生版本的标 准。

10.2    ELF格式的文件内容

    一个ELF文件可以看成是由链接器解释的一系列区section,或由加载器解释的一系列segment。

    ELF共享库可被加载到任何地址,因此它们总是使用位置无关代码(PIC)的形式,这样库的代码部分无须重定位即可在多个进程之间共享。像在第八章描述的 那样,ELF链接器通过全局偏移量表GOT来支持PIC代码,每个包含静态数据的共享库都有GOT,包含着程序所引用的静态数据的指针。如果一个共享库没有使用任何的静态数据那么可以不需GOT,不过实际中所有的共享库都含有GOT。

    为了支持动态链接,每个ELF共享库和每个使用了共享库的可执行程序都包含一个过程链接表PLT。PLT就像GOT之于数据引用那样,为函数调用加入了一个中间层。PLT还允许“懒惰绑定”,即只有共享库中的函数在第一次被实际调用时,才对其地址进行地 址。考虑到PLT的规模要比GOT多很多,而共享库中大多数函数在给定的程序中都不会被调用,因此“懒惰绑定”既可以提高程序启动的速度,也可以整体上节省相 当可观的时间。

    
(注:区分概念,GOT的存在是为了支持PIC,而PLT的存在是为了支持动态链接)

    由于ELF程序文件不需要在运行时被重定位,因此其不包含GOT。

    一个完整的ELF共享库看起来会像下图一样。首先是只读部分,包括符号表、PLT、文本和只读数据。然后是可读写部分,包括常规数据、GOT和动态区段。BSS在逻辑上跟在最后一个可读写区段后面,但通常都不会出现在文件中。



10.3    加载一个采用动态链接的程序

    要加载一个采用动态链接的程序,其过程冗长却简单。

10.3.1    启动动态链接器

    在操作系统运行程序时,它会像通常那样将文件的页映射进来,但注意在可执行程序中存在一个INTERPRETER区段。这里特定的解释器是动态链接器ld.so,其本身也是ELF共享库。操作系统并非直接启动程序,而是将动态链接器映射到地址空间的一个合适的位置后将控制权交给ld.so。

    动态链接器然后基于程序符号表和其自身的符号表的来初始化一个符号表链。从概念上讲,程序文件和所有加载到进程中的库会共享一个符号表。但实际中动态链接器并不在运行时创建一个合并后的符号表,而是将不同模块中的符号表组成一个符号表链。

10.3.2.    库的查找


    动态链接器自身初始化完成后,它就会去寻找程序所需要的各个库。一旦找到该库文件,动态链接器会打开该文件,读取其program header,它记录了包括dynamic段在内的众多段。此后为库的文本和数据段分配空间,并将它们映射进来,对于BSS分配初始化为0的页。从库的 dynamic段中,动态链接器将库的符号表加入到符号表链中,如果该库还进一步需要其它尚未加载的库,则将那些新库置入将要加载的库链表中。

    在该过程结束时,所有的库都被映射进来了,加载器拥有了一个由程序和所有映射进来的库的符号表联合构成的逻辑上的全局符号表。

10.3.3    动态共享库的初始化


    如果一个库具有.init区段,加载器会调用它来进行库特定的初始化工作,诸如C++的静态构造函数。库中的.fini区段会在程序退出的时候被执行。它 不会对主程序进行初始化,因为主程序的初始化是有自己的启动代码完成的。

    当这个过程完成后,所有的库就都被完全加载并可以被执行了,此时跳转至程序的 入口点开始执行程序。

10.4    基于PLT的懒惰绑定

    使用共享库的程序通常都会有对大量函数的调用。在这个程序的一次运行中,共享库中绝大多数函数永远都不会被调用到。为了加快程序启动的速度,基于动态链接 的ELF可执行程序采用了对函数地址的懒惰绑定(lazy binding)。即一个函数的地址被推迟到其第一次被调用时才会被绑定,而不是在加载时就绑定。

    ELF通过过程链接表来PLT支持懒惰绑定。每一个动态绑定的程序和共享库都包含一个PLT。PLT中的每个表项对应一个程序或库中被调用的非本地函数。注意,位置无关代码中的PLT其自身也是位置无关的,因此它可以成为只读文本段的一部分。

    在程序和库第一次调用某个函数时,PLT项会调用运行时链接器来解析该函数的实际地址。然后,PLT项会直接跳转到函数的实际地址,因此在第一次调用之后,使用PLT的代价就是在函数调用时有一个额外的间接跳转,在调用返回时没有额外的代价。

    PLT中的第一项,我们称之为PLT0,是一段会调用动态链接器的特殊代码。动态链接器在合成的符号表链中查找待解析的符号,并用其解析得到的地址更新GOT中对应的表项。


10.5    运行时的动态加载

    虽然动态链接器通常是在程序启动或访问PLT的时候被隐式调用,程序也可以通过使用dlopen函数(加载一个共享库)及通过dlsym函数(查找一个符号的地址)来显式调用动态链接器。

    这两个函数通常只是简单的wrapper函数——实际工作通过调用动态链接器中的函数来完成。


10.6    Microsoft动态链接库

    在Windows下,可执行程序和库都是PE(Portable Excutable)格式文件,可以被内存映射到一个进程中。

    加载一个Windows可执行程序或DLL与加载一个动态链接的ELF程序相似,尽管在Windows下动态链接器是OS内核的一部分。

    PE文件可以包含有重定位项。通常一个可执行程序不会包含可重定位项,因此必须将它们映射到在链接时确定的地址上。DLL都包含有重定位项,并且在它们被链接进来的地址空间无效的时候都会被重定位(微软将运行时重定位称为rebasing)。

10.6.3    DLL库和线程

    Windows的DLL模式不能很好工作的特例之一是线程本地存储TLS。问题在于多数DLL既可以从可执行程序中被隐含的链接,也可以通过LoadLibrary来显式的加载。
显式加载的DLL不能自动获得.tls存储区域; 由于DLL的编写者无法预测该其将被隐式还是显式激活的,因此,它不能够完全的依靠与.tls section。

10.8    让共享库快一些

    共享库,尤其是ELF共享库,有时会非常缓慢。造成这个情况的原因有多种,有一些在第八章中曾提到过:

    1).加载时库的重定位
    2).加载时库和可执行程序中的符号解析
    3).位置无关代码(PIC)函数初始代码带来的开销
    4).PIC间接数据引用带来的开销


10.9    动态链接实现机制的比较

    在某些有趣的方面,UNIX/ELF和Windows/PE动态链接是存在差异的。

    ELF格式为每个程序使用独立的名字空间,而PE格式为每个库使用独立的名字空间。

    一个ELF可执行程序会列出它所需要的符号的列表和库的列表,但它不记录哪个符号在哪个库中。一个PE文件,会列出从每一个库中输入的所有符号。PE的策略虽然稍微不那么灵活,但对无意欺诈的抵抗性更好一点。

    ELF策略比PE策略更努力的尝试去维护静态链接程序的语义。在一个ELF程序中,对于从另一个库中输入数据的引用,会自动被解析,而PE程序需要对输入数据进行专门处理。

    在运行时,Windows动态链接器
几乎 都在OS内核中运行,而ELF的动态链接器则完全作为应用程序的一部分在用户空间运行,而内核只负责将初始文件映射进来。Windows的策略更快一些,因为它避免了对动态链接器的映射和解析等操作。ELF的策略则是灵活的多。因为每一个可执行 程序都指明了要使用的“解释器”(现在总是名为ld.so的动态链接器),不同的可执行程序可以使用不同的解释器而无须要求操作系统进行任何修改。在 实际中,这就更容易让可执行程序支持多种版本的UNIX,尤其在Linux和BSD上,可以通过一个链接到兼容库上的动态链接器来支持非本地的可执行程 序。



 

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Linkers and Loaders是指链接器和装载器,是计算机领域中的一种重要工具,主要用于将程序的各个部分合并在一起并最终加载到内存中运行。 链接器的主要工作是将多个目标文件合并成一个可执行文件。目标文件包含机器语言指令和符号表信息,链接器将各个目标文件的符号表信息进行合并,并解决模块之间的引用问题,使得程序能正确地执行。 装载器的主要工作是将可执行文件加载到计算机的内存中运行。装载器将可执行文件中的各个段按照一定的地址空间进行装载,并进行重定位等操作,确保程序能够在内存中正确执行。 总之,链接器和装载器是构建可执行程序需要用到的重要工具,对于程序的可靠性、效率和安全性都具有重要作用。 ### 回答2: linkers and loaders是计算机科学中用于程序编译的工具。Linkers在编译过程中将多个目标文件合并成一个可执行文件,同时也会解决外部和内部符号的引用问题。Loaders则是将可执行文件加载到内存中,并把符号解析成运行时地址,最终使程序在计算机上运行。 linkers在编译过程中实现了模块化编程,将单独编写的多个目标文件链接在一起。linkers将程序中各个部分之间的符号和引用关系整合在一起,实现一个完整的可执行程序。linkers主要有静态链接和动态链接两种形式,静态链接将库文件直接合并到可执行文件中,而动态链接则是在程序运行时动态加载库文件。 loaders则负责将可执行文件从磁盘加载到内存中去。借助加载器,操作系统可以根据可执行文件中文件头和段表信息将程序映射到内存中的相应地址,方便操作系统进行命令的调用和运行。 总之,linkers和loaders是编译过程中非常重要的组成部分。它们不仅使编译工作更加高效和灵活,同时也是计算机开发中程序正确性和性能的关键。 ### 回答3: linker是编译后的程序中用来处理符号链接的工具。它的作用是将不同程序模块(source file, object file)之间的符号联系起来。在链接过程中,linker会分析程序中使用到的符号,并将这些符号与其对应的地址进行关联,以便程序中的不同模块能够相互调用。同时,linker也会将所有的模块组合成一个可执行文件,方便程序的执行。 loader是将程序加载入内存并实现分页与映射的软件。loader的主要作用是将程序文件从磁盘上读入内存,并根据程序的逻辑要求,将其按照合适的方式映射到内存中的合适位置。同时,loader还负责分配与管理程序所需要的内存空间,维护程序在内存中的状态,以及实现程序的执行。 在计算机科学领域中,linkers和loaders是非常重要的工具。它们帮助编程人员将不同的程序模块链接起来,并在不同的系统中进行适当的加载。对于任何一个开发者而言,了解和掌握linkers和loaders都是非常重要的基本技能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值