链接器和加载器09
- 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
- 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
- 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。
第9章 共享库
$Revision: 2.3 $
$Date: 1999/06/15 03:30:36 $
程序库的产生可以追溯到计算技术的最早期,因为程序员很快就意识到通过重用程序
的代码片段可以节省大量的时间和精力。
随着如Fortran and COBOL等语言编译器的发展,程序库成为编程的一部分。
当程序调用一个标准过程时,如sqrt(),编译过的语言显式地使用库,而且它们也隐式地使用用于I/O、转换、排序及很多其它复杂得不能用内联代码解释的函数库。
随着语言变得更为复杂,库也相应地变复杂了。
当我在20年前写一个Fortran 7 7编译器时,运行库就已经比编译器本身的工作要多了,而一个Fortran 77库远比一个C++库要来得简单。
语言库的增加意味着:不但所有的程序包含库代码,而且大部分程序包含许多相同的
库代码。
例如,每个C程序都要使用系统调用库,几乎所有的C程序都使用标准I/O库例程,如printf,而且很多使用了别的通用库,如math,networking,及其它通用函数。
这就意
味着在一个有一千个编译过的程序的UNIX系统中,就有将近一千份printf的拷贝。
如果所有那些程序能共享一份它们用到的库例程的拷贝,对磁盘空间的节省是可观的。
(在一个没有共享库的UNIX系统上,单printf的拷贝就有5到10M。
)更重要的是,运行中的程序如
能共享单个在内存中的库的拷贝,这对主存的节省是相当可观的,不但节省内存,也提高页交换。
所有共享库基本上以相同的方式工作。
在链接时,链接器搜索整个库以找到用于解决
那些未定义的外部符号的模块。
但链接器不把模块内容拷贝到输出文件中,而是标记模块来自的库名,同时在可执行文件中放一个库的列表。
当程序被装载时,启动代码找到那些库,并在程序开始前把它们映射到程序的地址空间,如图1。
标准操作系统的文件映射机制自动共享那些以只读或写时拷贝的映射页。
负责映射的启动代码可能是在操作系统中,或在可执行体,或在已经映射到进程地址空间的特定动态链接器中,或是这三者的某种并集。
---------------------------------------------------------------------------------------------图9-1:带有共享库的程序
可执行程序,共享库的图例
可执行程序main,app库,C库
不同位置来的文件
箭头展示了从main到app,main到C,app到C的引用
---------------------------------------------------------------------------------------------在本章,我们着眼于静态链接库,也就是库中的程序和数据地址在链接时绑定到可执
行体中。
在下一章我们着眼于更复杂的动态链接库。
尽管动态链接更灵活更“现代”,但也比静态链接要慢很多,因为在链接时要做的大量工作在每次启动动态链接的程序时要重新做。
同时,动态链接的程序通常使用额外的“胶合(g lu e)”代码来调用共享库中的例程。
胶合代码通常包含若干个跳转,这会明显地减慢调用速度。
在同时支持静态和动态共享库的系统上,除非程序需要动态链接的额外扩展性,不然使用静态链接库能使它们更快更小巧。
绑定时间
共享库提出的绑定时间问题,是常规链接的程序不会遇到的。
一个用到了共享库的程
序在运行时依赖于这些库的有效性。
当所需的库不存在时,就会发生错误。
在这情况下,除了打印出一个晦涩的错误信息并退出外,不会有更多的事情要做。
当库已经存在,但是自从程序链接以来库已经改变了时,一个更有趣的问题就会发生。
在一个常规链接的程序中,在链接时符号就被绑定到地址上而库代码就已经绑定到可执行体中了,所以程序所链接的库是那个忽略了随后变更的库。
对于静态共享库,符号在链接时被绑定到地址上,而库代码要直到运行时才被绑定到可执行体上。
(对于动态共享库而言,它们都推迟到运行时。
)
一个静态链接共享库不能改变太多,以防破坏它所绑定到的程序。
因为例程的地址和
库中的数据都已经绑定到程序中了,任何对这些地址的改变都将导致灾难。
如果不改变程序所依赖的静态库中的任何地址,那么有时一个共享库就可以在不影响程序对它调用的前提下进行升级。
这就是通常用于小bu g修复的"小更新版"。
更大的改变不可避免地要改变程序地址,这就意味着一个系统要么需要多个版本的库,要么迫使程序员在每次改变库时都重新链接它们所有的程序。
实际中,永远不变的解决办法就是多版本,因为磁盘空间便宜,而要找到每个会用到共享库可执行体几乎是不可能的。
实际的共享库
本章余下的部分将关注于UNIX Sy stem V Re l ease 3.2 (COFF格式),较早的Lin ux系统(a.o u t格式),和4.4B S D的派生系统(a.o u t和E LF格式)这三者提供的静态共享库。
这三者以几近相同的方式工作,但有些不同点具有启发意义。
SV R3.2的实现要求改变链接器以支持共享库搜索,并需要操作系统的强力支持以满足例程在运行时的启动需求。
Lin ux的实现需要对链接器进行一点小的调整并增加一个系统调用以辅助库映射。
B S D/O S的实现不对链接器或操作系统作任何改变,它使用一个脚本为链接器提供必要的参数和一个修改过的标准C 库启动例程以映射到库中。
地址空间管理
共享库中最困难的就是地址空间管理。
每一个共享库在使用它的程序里都占用一段固定的地址空间。
不同的库,如果能够被使用在同一个程序中,它们还必须使用互不重叠的地址空间。
虽然机械的检查库的地址空间是否重叠是可能的,但是给不同的库赋予相应的地址空间仍然是一种“魔法”。
一方面,你还想在它们之间留一些余地,这样当其中某个新版本的库增长了一些时,它不会延伸到下一个库的空间而发生冲突。
另一方面,你还想将你最常用的库尽可能紧密的放在一起以节省需要的页表数量(要知道在x86上,进程地址空间的每一个4MB的块都有一个对应的二级表)。
每个系统的共享库地址空间都必然有一个主表,库从离应用程序很远的地址空间开始。
Lin ux从十六进制的60000000开始,B S D/O S从A0000000开始。
商业厂家将会为厂家提供的库、用户和第三方库进一步细分地址空间,比如对B S D/O S,用户和第三方库开始于地址A08 00000。
通常库的代码和数据地址都会被明确的定义,其中数据区域从代码区域结束地址后的一个或两个页对齐的地方开始。
由于一般都不会更新数据区域的布局,而只是增加或者更改代码区域,所以这样就使小更新版本成为可能。
每一个共享库都会输出符号,包括代码和数据,而且如果这个库依赖于别的库,那么通常也会引入符号。
虽然以某种偶然的顺序将例程链接为一个共享库也能使用,但是真正的库使用一些分配地址的原则而使得链接更容易,或者至少使在更新库的时候不必修改输出符号的地址成为可能。
对于代码地址,库中有一个可以跳转到所有例程的跳转指令表,并将这些跳转的地址作为相应例程的地址输出,而不是输出这些例程的实际地址。
所有跳转指令的大小都是相同的,所以跳转表的地址很容易计算,并且只要表中不在库更新时加入或删除表
项,那么这些地址将不会随版本而改变。
每一个例程多出一条跳转指令不会明显的降低速度,由于实际的例程地址是不可见的,所以即使新版本与旧版本的例程大小和地址都不一样,库的新旧版本仍然是可兼容的。
对于输出数据,情况就要复杂一些,因为没有一种像对代码地址那样的简单方法来增
加一个间接层。
实际中的输出数据一般是很少变动的、尺寸已知的表,例如C标准I/O库中的FIL E结构,或者像errno那样的单字数值(最近一次系统调用返回的错误代码),或者
是t z name(指向当前时区名称的两个字符串的指针)。
建立共享库的程序员可以收集到这些输出数据并放置在数据段的开头,使它们位于每个例程中所使用的匿名数据的前面,这样使得这些输出地址在库更新时不太可能会有变化。
共享库的结构
共享库是一个包含所有准备被映射的库代码和数据的可执行格式文件,见图9-2。
---------------------------------------------------------------------------------------------图9-2: 典型共享库的结构
文件头,a.o u t, COFF或E LF头
(初始化例程,不总存在)
跳转表
代码
全局数据
私有数据
---------------------------------------------------------------------------------------------一些共享库从一个小的自举例程开始,来映射库的剩余部分。
之后是跳转表,如果它
不是库的第一个内容,那么就把它对齐到下一个页的位置。
库中每一个输出的公共例程的地址就是跳转表的表项;跟在跳转表后面的是文本段的剩余部分(由于跳转表是可执行代码,
所以它被认为是文本),然后是输出数据和私有数据。
在逻辑上b ss段应跟在数据的后面,
但是就像在任何别的可执行文件中那样,它并不在于这个文件中。
创建共享库
一个UNIX共享库实际上包含两个相关文件,即共享库本身和给链接器用的空占位库(st ub l i b rar y)。
库创建工具将一个档案格式的普通库和一些包含控制信息的文件作为输入生
成了这两个文件。
空占位库根本不包含任何的代码和数据(可能会包含一个小的自举例程),但是它包含程序链接该库时需要使用的符号定义。
创建一个共享库需要以下几步,我们将在后面更多的讨论它们:
确定库的代码和数据将被定位到什么地址。
彻底扫描输入的库寻找所有输出的代码符号(如果某些符号是用来在库内通信的,那么就会有一个控制文件是这些不对外输出的符号的列表)。
创建一个跳转表,表中的每一项分别对应每个输出的代码符号。
如果在库的开头有一个初始化或加载例程,那么就编译或者汇编它。
创建共享库。
运行链接器把所有内容都链接为一个大的可执行格式文件。
创建空占位库:从刚刚建立的共享库中提取出需要的符号,针对输入库的符号调整这些符号。
为每一个库例程创建一个空占位例程。
在COFF库中,也会有一个小的
初始化代码放在占位库里并被链接到每一个可执行体中。
创建跳转表
最简单的创建一个跳转表的方法就是编写一个全是跳转指令的汇编源代码文件,如图3,并汇编它。
这些跳转指令需要使用一种系统的方法来标记,这样以后空占位库就能够把这些地址提出取来。
对于像x86这样具有多种长度的跳转指令的平台,可能稍微复杂一点。
对于含有小于6 4K代码的库,3个字节的短跳转指令就足够了。
对于较大的库,需要使用更长的5字节的跳转指令。
将不同长度的跳转指令混在一起是不能让人满意的,因为它使得表地址的计算更加困难,同时也更难在以后重建库时确保兼容性。
最简单的解决方法就是都采用最长的跳转指令;或者全部都使用短跳转,对于那些使用短跳转太远的例程,则用一个短跳转指令跳转到放在表尾的匿名长跳转指令。
(通常由此带来的麻烦比它的好处更多,因为第一跳转表很少
会有好几百项。
)
---------------------------------------------------------------------------------------------图9-3:跳转表
... 从一个页边界起始
.a l ign 8; 为了变量长度而对其于8字节边界处
J UM P_read: j mp _read
.a l ign 8
J UM P_write: j mp _write
...
_read: ... c ode for read()
...
_write: ... c ode for write()
---------------------------------------------------------------------------------------------创建共享库
一旦跳转表和加载例程(如果需要的话)建立好之后,创建共享库就很容易了。
只需要
使用合适的参数运行链接器,让代码和数据从正确的地址空间开始,并将自引导例程、跳转表和输入库中的所有例程都链接在一起。
它同时完成了给库中每项分配地址和创建共享库文件两件事。
库之间的引用会稍微复杂一些。
如果你正在创建,例如一个使用标准C库例程的共享数学库,那就要确保引用的正确。
假定当链接器建立新库时需要用到的共享库中的例程已经建好,那么它只需要搜索该共享库的空占位库,就像普通的可执行程序引用共享库那样。
这将让所有的引用都正确。
只留下一个问题,就是需要有某种方法确保任何使用新库的程序也能够链接到旧库上。
对新库的空占位库的适当设计可以确保这一点。
创建空占位库
创建空占位库是创建共享库过程中诡秘的部分之一。
对于库中的每一个例程,空占位库中都要包含一个同时定义了输出和输入的全局符号的对应项。
数据全局符号会被链接器放在共享库中任何地方,获取它们的数值的最合理的办法就是创建一个带有符号表的共享库,并从符号表中提取符号。
对代码全局符号,入口指针都在跳转表中,所以同样很简单,只需要从共享库中提取符号表或者根据跳转表的基地址和每一个符号在表中的位置来计算符号地址。
不同于普通库模块,空占位库模块既不包含代码也不包含数据,只包含符号定义。
这些符号必须定义成绝对数而不是相对,因为共享库已经完成了所有的重定位。
库创建程序从输入库中提取出每一个例程,并从这些例程中得到定义和未定义的全局变量,以及每一个全局变量的类型(文本或数据)。
然后它创建空占位例程,通常都是一个很小的汇编程序,以跳转表中每一项的地址的形式定义每个文本全局变量,以共享库中实际地址的形式定义每个数据或b ss全局变量,并以“未定义”的形式定义没有定义的全局变量。
当它完成所有空占位后,就对其进行汇编并将它们合并到一个普通的库档案文件中。
COFF空占位库使用了一种不同的、更简单的设计。
它们是具有两个命名段的单一目标文件。
“.l i b”段包含了指向共享库的所有重定位信息,“.init”段包含了将会链接到每一个客户程序去的初始化代码,一般是来初始化库中的变量。
Lin ux 共享库更简单,a.o u t 文件中包含了带有设置向量(“set ve c tor”) 的符号定义, 我们将在后面的链接部分详细讨论设置向量。
.
共享库的名称一般是原先的库名加上版本号。
如果原先的库称为/l i b/l i b c.a,这通常是C库的名字,当前的库版本是4.0,空占位库可能是/l i b/l i b c_s.4.0.0.a,共享库就是/s h l i b/l i b c_s.4.0.0(多出来的0可以允许小版本的升级)。
一旦库被放置到合适的目录下面,它们就可以被使用了。
版本命名
任何共享库系统都需要有一种办法处理库的多个版本。
当一个库被更新后,新版本相对于之前版本而言在地址和调用上都有可能兼容或不兼容。
UNIX系统使用前面提到的版本命名序号来解决这个问题。
第一个数字在每次发布一个不兼容的全新的库的时候才被改变。
一个和4.x.x的库链接的程序不能使用3.x.x或5.x.x的库。
第二个数是小版本。
在Su n系统上,每一个可执行程
序所链接的库都至少需要一个尽可能大的小版本号。
例如,如果它链接的是4.2.x,那么它就可以和4.3.x一起运行而4.1.x则不行(译者注:就是说得使用尽可能大的小版本号,确保可执行程序可以运行)。
另一些系统将第二个数字当作第一个数字的扩展,这样的话使用一个4.2.x的库链接的程序就只能和4.2.x的库一起运行。
第三个数字通常都被当作补丁级别。
虽然任何的补丁级别都是可用的,可执行程序最好还是使用最高的有效补丁级别。
不同的系统在运行时查找对应库的方法会略有不同。
Su n系统有一个相当复杂的运行时加载器,在库目录中查看所有的文件名并挑选出最好的那个。
Lin ux系统使用符号链接而避免了搜索过程。
如果库l i b c.so的最新版本是4.2.2,库的名字是l i b c_s.4.2.2,但是这个库也已经被链接到l i b c_s.4.2,那么加载器将仅需打开名字较短的文件,就选好了正确的版本。
多数系统都允许共享库存在于多个目录中。
类似于LD_LIBR A R Y_P A TH的环境变量可以覆盖可执行程序中的路径,以允许开发者使用它们自己的库替代原先的库进行调试或性能测试(使用“set u ser ID”特性替代当前用户运行的程序将忽略LD_LIBR A R Y_P A TH以避免使用恶意用户添加了安全漏洞的“特洛伊木马”库)。
使用共享库链接
使用静态共享库来链接,比创建库要简单得多,因为几乎所有的确保链接器正确解析库中程序地址的困难工作,都在创建空占位库时完成了。
唯一困难的部分就是在程序开始运行时将需要的共享库映射进来。
每一种格式都会提供一个小窍门让链接器创建一个库的列表,以便启动代码把库映射进来。
COFF库使用一种残忍的强制方法;链接器中的特殊代码在COFF文件中创建了一个以库名命名的段。
Lin ux链接器使用一种不那么残忍的方法,即创建一个称为设置向量的特殊符号类型。
设置向量象普通的全局符号一样,但如果它有多个定义,这些定义会被放进一个以该符号命名的数组中。
每个共享库定义一个设置向量符号__S H A R E D_LIBR A RI ES__,它是由库名、版本、加载地址等构成的一个数据结构的地址。
链接器创建一个指向每个这种数据结构的指针的数组,并称之为__S H A R E D_LIBR A RI ES__,好让启动代码可以使用它。
B S D/O S共享库没有使用任何的此类链接器窍门。
它使用she ll脚本建立一个共享的可执行程序,用来搜索作为参数或隐式传入的库列表,提取出这些文件的名字并根据系统文件中的列表来加载这些库的地址,然后编写一个小汇编源文件创建一个带有库名字和加载地址的结构数组,并汇编这个文件,把得到的目标文件加入到链接器的参数列表中。
在每一种情况中,从程序代码到库地址的引用都是通过空占位库中的地址自动解析的。
使用共享库运行
启动一个使用共享库的程序需要三步:加载可执行程序,映射库,进行库特定的初始化操作。
在每一种情况下,可执行程序都被系统按照通常的方法加载到内存中。
之后,处理
方法会有差别。
系统V.3内核具有了处理链接COFF共享库的可执行程序的扩展性能,其内核会查看库列表并在程序运行之前将它们映射进来。
这种方法的不利之处在于 “内核肿胀”,会给不可分页的内核增加更多的代码;并且由于这种方法不允许在未来版本中有灵活性和可升级性,所以它是不灵活的(系统V.4整个抛弃了这种策略,转而采用了我们下章会涉及到的E LF动态共享库)。
Lin ux增加了一个单独的u se l i b()系统调用,以获取一个库的文件名字和地址,并将它映射到程序的地址空间中。
绑定到可执行体中的启动例程搜索库列表,并对每一项执行u se l i b()。
B S D/O S的方法是使用标准的mmap()系统调用将一个文件的多个页映射进地址空间,该方法还使用一个链接到每个共享库起始处的自举例程。
可执行程序中的启动例程遍历共享库表,打开每个对应的文件,将文件的第一页映射到加载地址中,然后调用各自的自举例程,该例程位于可执行文件头之后的起始页附近的某个固定位置。
然后自举例程再映射余下的文本段、数据段,然后为b ss段映射新的地址空间,然后自举例程就返回了。
所有的段被映射了之后,通常还有一些库特定的初始化工作要做,例如,将一个指针指向C标准库中指定的系统环境全局变量environ。
COFF的实现是从程序文件的“.init”段收集初始化代码,然后在程序启动代码中运行它。
根据库的不同,它有时会调用共享库中的例程,有时不会。
Lin ux的实现中没有进行任何的库初始化,并且指出了在程序和库中定义相同的变量将不能很好工作的问题。
在B S D/O S实现中,C库的自举例程会接收到一个指向共享库表的指针,并将所有其它的库都映射进来,减小了需要链接到单独的可执行体中的代码量。
最近版本的B S D使用E LF 格式的可执行体。
E LF头有一个interp段,其中包含一个运行该文件时需要使用的解释器程序的名字。
B S D使用共享的C库作为解释器,这意味着在程序启动之前内核会将共享C库先映射进来,这就节省了一些系统调用的开销。
库自举例程进行的是相同的初始化工作,将库的剩余部分映射进来,并且,通过一个指针,调用程序的main例程。
ma ll o c ha c k和其它共享库问题
虽然静态共享库具有很好的性能,但是它们的长期维护是困难和容易出错的,下面给出一些轶事为例。
在一个静态库中,所有的库内调用都被永久绑定了,所以不可能将某个程序中所使用的库例程通过重新定义替换为私有版本的例程。
多数情况下,由于很少有程序会对标准库中例如read()、str c mp()等例程进行重新定义,所以永久绑定不是什么大问题;并且如果它们自己的程序使用私有版本的str c mp(),但库例程仍调用库中标准版本,那么也没有什么大问题。
但是很多程序定义了它们自己的ma ll o c()和free()版本,这是分配堆存储的例程;如果在一个程序中存在这些例程的多个版本,那么程序将不能正常工作。
例如,标准strd u p()例程,返回一个指向用ma ll o c分配的字符串指针,当程序不再使用它时可以释放它。
如果库使用ma ll o c的某个版本来分配字符串的空间,但是应用程序使用另一个版本的free来释
放这个字符串的空间,那么就会发生混乱。
为了能够允许应用程序提供它们自己版本的ma ll o c和free,Sy stem V.3的共享C库使用了一种“丑陋”的技术,如图4所示。
系统的维护者将ma ll o c和free重新定义为间接调用,这是通过绑定到共享库的数据部分的函数指针实现的,我们将称它们为ma ll o c_ptr和f ree_ptr。
e x tern void *(*ma ll o c_ptr)(si z e_t);
e x tern void (*free_ptr)(void *);
#define ma ll o c(s) (*ma ll o c_ptr)(s)
#define free(s) (*free_ptr)(s)
---------------------------------------------------------------------------------------------图9-4:ma ll o c ha c k
程序,共享C库的图例
ma ll o c指针和初始化代码
从库代码中的间接调用
---------------------------------------------------------------------------------------------然后它们重新编译了整个C库,并将下面的几行内容(或汇编同类内容)加入到占位库的. init段,这样它们就被加入到每个使用该共享库的程序中了。
#u ndef ma ll o c
#u ndef free
ma ll o c_ptr =&ma ll o c;
free_ptr =&free;
由于占位库将被绑定到应用程序中的,而不是共享库,所以它对ma ll o c和free的引用是在链接时解析的。
如果存在一个私有版本的ma ll o c和free,它将指向私有版本函数的指针(译者注:指ma ll o c_ptr和free_ptr),否则它将使用标准库的版本。
不管哪种方法,。