当前位置:文档之家› 链接器和加载器09

链接器和加载器09

第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字节的跳转指令。

将不同长度的跳转指令混在一起是不能让人满意的,因为它使得表地址的计算更加困难,同时也更难在以后重建库时确保兼容性。

相关主题