符号管理是链接器的关键功能。如果没有某种方法来进行模块之间的引用,那么链接器的其它功能也就没有什么太大的用处了。
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上,可以通过一个链接到兼容库上的动态链接器来支持非本地的可执行程 序。