在多数现代系统中,每一个程序被加载到一个新的地址空间,这就意味着所有的程序
都被加载到一个已知的固定地址,并可以从这个地址被链接。这种情况下,加载是颇为简单
的:
从目标文件中读取足够的头部信息,找出需要多少地址空间。
分配地址空间,如果目标代码的格式具有独立的段,那么就将地址空间按独立的段
划分。
将程序读入地址空间的段中。
将程序末尾的 bss 段空间填充为 0,如果虚拟内存系统不自动这么做得话。
如果体系结构需要的话,创建一个堆栈段(stack segment)。
设置诸如程序参数和环境变量的其他运行时信息。
开始运行程序。
如果程序不是通过虚拟内存系统映射的,读取目标文件就意味着通过普通的 read 系统
调用读取文件。在支持共享只读代码段的系统上,系统检查是否在内存中已经加载了该代码
段的一个拷贝,而不是生成另外一份拷贝。
仅有一小部分系统还仍然为执行程序在加载时进行重定位,大多数都是为共享库在加
载时进行重定位。诸如 MS-DOS 的系统,很少使用硬件的重定位;另外一些如 MVS 的系统,
具有硬件重定位(却是从一个没有硬件重定位的系统继承来的);还有一些系统,具有硬件
重定位,但是却可以将多个可执行程序和共享库加载到相同的地址空间。所以链接器不能指
望某些特定地址是有效的。
如第七章讨论的,加载时重定位要比链接时重定位简单的多,因为整个程序作为一个
单元进行重定位。例如,如果一个程序被链接为从位置 0 开始,但是实际上被加载到位置 15
000,那么需要所有程序中的空间都要被修正为“加上 15000”。在将程序读入主存后,加载
器根据目标文件中的重定位项,并将重定位项指向的内存位置进行修改。
加载时重定位会表现出性能的问题,由于在每一个地址空间内的修正值均不同,所以
被加载到不同虚拟地址的代码通常不能在地址空间之间共享。MVS 使用的,并被 Windows 和
AIX 扩展的一种方法,使创建一个出现在多个地址空间的共享内存区域,并将常用的程序加
载到其中(MVS 将其称为 link pack 区域)。这仍然存在普通进程不能获取可写数据的单独复
本的问题,所以应用程序必须在编写时明确地为它可写区域分配空间。
位置无关代码
对于将相同程序加载到普通地址的问题的一个常用的解决方案就是位置无关代码
(position independent code, PIC)。他的思想很简单,就是将数据和普通代码中那些不
会因为被加载的地址改变而变化的代码分离出来。这种方法中代码可以在所有进程间共享,
只有数据页为各进程自己私有。
这是一个令人吃惊的老想法。TSS/360 在 1966 年就使用它了,并且我相信它也不是最
早采用该方法的(TSS 有很多臭名昭著的 bug,但是从我个人经验而言,他的 PIC 特性的确
可以工作)。
在现代体系结构中,生成 PIC 可执行代码并不困难。跳转和分支代码通常是位置相关的,
或者与某一个运行时设置的基址寄存器相关,所以需要对他们进行非运行时的重定位。问题
在于数据的寻址,代码无法获取任何的直接数据地址。由于代码是可重定位的,而数据不是
位置无关的。普通的解决方案是在数据页中建立一个数据地址的表格,并在一个寄存器中保
存这个表的地址,这样代码可以使用相对于寄存器中地址的被索引地址来获取数据。这种方
式的成本在于对每一个数据引用需要进行一次额外的重定位,但是还存在一个问题就是如何
获取保存到寄存器中去的初始地址。
在许多 UNIX 系统中采用的一种简单修改是将一个过程的数据地址假当作这个过程的地
址,并在这个地址上放置一个指向该过程代码的指针,如图 2。如要调用一个过程,调用者
就将该例程的数据地址加载到约定好的数据指针寄存器,然后从数据指针指向的位置中加载
代码地址到一个寄存器,然后调用这个历程。这很容易实现,而且性能还算不错。
编译器将所有的调用都生成为 call 指令,其后还紧跟一个占位控操作指令 no-op
由于 x86 可以直
接寻址,一个对外部数据的引用在非 PIC 代码下可以是一个简单的 MOV 或 ADD,但在 PIC 代
码下就要变成加载紧跟在 MOV 或 ADD 后面的地址,这既增加了额外的内存引用又占用了宝贵
的寄存器作为临时指针。
特别在 x86 系统上,对于速度要求严格的任务,PIC 代码的性能降低是明显的,以至于
某些系统对于共享库退而采用一种类似 PIC 的方法。
自举加载
在这里讨论加载都有一个前提就是计算机系统中已经存在一个操作系统或至少有一个
程序加载器在运行并负责程序的加载。这些一个被另一个加载的程序链总得有一个开始的地
方吧,所以就有一个显而易见的问题即最初的程序是如何被加载到计算机中去的。
在现代计算机中,计算机在硬件复位后运行的第一个程序总是存储在称为 bootstrap R
OM 的随机只读存储器中。就像自己启动自己一样。当处理器上电或者复位后,它将寄存器
复位为一致的状态。例如在 x86 系统中,复位序列跳转到系统地址空间顶部下面的 16 字节
处。Bootstrap ROM 占用了地址空间顶端的 64K,然后这里的 ROM 代码就来启动计算机。在 I
BM 兼容的 x86 系统上,引导 ROM 代码读取软盘上的第一个块,如果失败的话就读取硬盘上的
第一个块,将它放置在内存位置 0,然后再跳转到位置 0。在第 0 块上的程序然后从磁盘上
一个已知位置上加载另一个稍微大一些的操作系统引导程序到内存中,然后在跳转到这个程
序,加载并运行操作系统(可能存在更多的步骤,例如引导管理器需要决定从那个分区上读
取操作系统的引导程序,但加载器的主要功能是不变的)。
为什么不直接加载操作系统?因为你无法将一个操作系统的引导程序放置在 512 个字节
内。第一级引导程序只能从被引导磁盘的顶级目录中加载一个名字固定且大小不超过一个段
的程序。操作系统引导程序具有更多的复杂代码如读取和解释配置文件,解压缩一个压缩的
操作系统内核,寻址大量内存(在 x86 系统上的引导程序通常运行在实模式下,这意味着寻
址 1MB 以上地址是比较复杂的)。完全的操作系统还要运行在虚拟内存系统上,可以加载需
要的驱动程序,并运行用户级程序。
很多 UNIX 系统使用一个近似的自举进程来运行用户台程序。内核创建一个进程,在其
中装填一个只有几十个字节长度的小程序。然后这个小程序调用一个系统调用运行/etc/ini
t 程序,这个用户模式的初始化程序然后依次运行系统所需要的各种配置文件,启动服务进
程和登录程序。
这些对于应用级程序员没有什么影响,但是如果你想编写运行在机器裸设备上的程序
时就变得有趣多了,因为你需要截取自举过程并运行自己的程序,而不是像通常那样依靠操
作系统。一些系统很容易实现这一点(例如只需要在 AUTOEXEC.BAT 中写入你要运行的程序
名字,再重新启动 Windows 95),另外一些系统则几乎是不可能的。它同样也给定制系统提
供了机会。例如可以通过将应用程序的名字改为/etc/init 基于 UNIX 内核构建单应用程序系
统
所有共享库基本上以相同的方式工作。在链接时,链接器搜索整个库以找到用于解决
那些未定义的外部符号的模块。但链接器不把模块内容拷贝到输出文件中,而是标记模块来
自的库名,同时在可执行文件中放一个库的列表。当程序被装载时,启动代码找到那些库,
并在程序开始前把它们映射到程序的地址空间,如图 1。标准操作系统的文件映射机制自动
共享那些以只读或写时拷贝的映射页。负责映射的启动代码可能是在操作系统中,或在可执
行体,或在已经映射到进程地址空间的特定动态链接器中,或是这三者的某种并集。
静态链接库,也就是库中的程序和数据地址在链接时绑定到可执
行体中。当库已经存在,但是自从程序链接以来库已经改变了时,一个更有趣的问题就会发生。
在一个常规链接的程序中,在链接时符号就被绑定到地址上而库代码就已经绑定到可执行体
中了,所以程序所链接的库是那个忽略了随后变更的库。对于静态共享库,符号在链接时被
绑定到地址上,而库代码要直到运行时才被绑定到可执行体上。
代码地址,库中有一个可以跳转到所有例程的跳转指令表,并将这
些跳转的地址作为相应例程的地址输出,而不是输出这些例程的实际地址。所有跳转指令的
大小都是相同的,所以跳转表的地址很容易计算,并且只要表中不在库更新时加入或删除表
项,那么这些地址将不会随版本而改变。每一个例程多出一条跳转指令不会明显的降低速度,
bbs.theithome.com
由于实际的例程地址是不可见的,所以即使新版本与旧版本的例程大小和地址都不一样,库
的新旧版本仍然是可兼容的。
对于输出数据,情况就要复杂一些,因为没有一种像对代码地址那样的简单方法来增
加一个间接层。实际中的输出数据一般是很少变动的、尺寸已知的表,例如 C 标准 I/O 库中
的 FILE 结构,或者像 errno 那样的单字数值(最近一次系统调用返回的错误代码),或者
是 tzname(指向当前时区名称的两个字符串的指针)。建立共享库的程序员可以收集到这些
输出数据并放置在数据段的开头,使它们位于每个例程中所使用的匿名数据的前面,这样使
得这些输出地址在库更新时不太可能会有变化。
Linux 链接器使用一种不那么残忍的方法,即创建一个称为设置向量的特殊
符号类型。设置向量象普通的全局符号一样,但如果它有多个定义,这些定义会被放进一个
以该符号命名的数组中。每个共享库定义一个设置向量符号__SHARED_LIBRARIES__,它是由
库名、版本、加载地址等构成的一个数据结构的地址。 链接器创建一个指向每个这种数据
结构的指针的数组,并称之为__SHARED_LIBRARIES__,好让启动代码可以使用它。BSD/OS 共
享库没有使用任何的此类链接器窍门。它使用 shell 脚本建立一个共享的可执行程序,用来
搜索作为参数或隐式传入的库列表,提取出这些文件的名字并根据系统文件中的列表来加载
这些库的地址,然后编写一个小汇编源文件创建一个带有库名字和加载地址的结构数组,并
汇编这个文件,把得到的目标文件加入到链接器的参数列表中。
在每一种情况中,从程序代码到库地址的引用都是通过空占位库中的地址自动解析的。
使用共享库运行
启动一个使用共享库的程序需要三步:加载可执行程序,映射库,进行库特定的初始
化操作。在每一种情况下,可执行程序都被系统按照通常的方法加载到内存中。之后,处理
方法会有差别。系统 V.3 内核具有了处理链接 COFF 共享库的可执行程序的扩展性能,其内
核会查看库列表并在程序运行之前将它们映射进来。
BSD/OS 的方法是使用标准的 mmap()系统调用将一个文件的多个页映射进地址空间,该
方法还使用一个链接到每个共享库起始处的自举例程。可执行程序中的启动例程遍历共享库
表,打开每个对应的文件,将文件的第一页映射到加载地址中,然后调用各自的自举例程,
该例程位于可执行文件头之后的起始页附近的某个固定位置。然后自举例程再映射余下的文
本段、数据段,然后为 bss 段映射新的地址空间,然后自举例程就返回了。
在 BSD/OS 实现中,C 库的自举例程会接收到一个指向共享库表的指针,并将所有其它
的库都映射进来,减小了需要链接到单独的可执行体中的代码量。最近版本的 BSD 使用 ELF
格式的可执行体。ELF 头有一个 interp 段,其中包含一个运行该文件时需要使用的解释器程
序的名字。BSD 使用共享的 C 库作为解释器,这意味着在程序启动之前内核会将共享 C 库先
映射进来,这就节省了一些系统调用的开销。库自举例程进行的是相同的初始化工作,将库
的剩余部分映射进来,并且,通过一个指针,调用程序的 main 例程。
indows 也允许使用 LoadLibrary 和 FreeLibrary 函数来明确的加载和卸载 DLL,并使用
GetProcAddress 来查找符号的地址。
DLL 库和线程
Windows 的 DLL 模式不能很好工作的特例之一是线程本地存储。Windows 程序可以在同
一个进程中启用多个线程,它们共享进程的地址空间。每一个线程都有一小块线程本地存储
(Thread Local Storage, TLS)区域来保存和特定线程相关的数据,例如指向当前线程正
在使用的数据结构或资源的指针。TLS 对于程序或每个使用 TLS 的 DLL 中的数据,需要使用
槽位6(slot)。Windows 链接器可以在 PE 可执行程序中创建一个.tls 区段,它定义了可执
行程序及其直接引用的 DLL 中的例程所需的 TLS 的布局。每次在进程创建一个线程时,新的
线程会获得自己的 TLS,该 TLS 是以.tls 区段为模板创建的。
问题是,多数 DLL 既可以从可执行程序中被隐含的链接,也可以通过 LoadLibrary 来显
式的加载。由于 DLL 的作者无法预测该库是隐式还是显式激活的,因此显式加载的 DLL 不能
自动获得.tls 存储区域,它不能够倚赖.tls 区段。
Windows 定义了运行时系统调用,可以在 TLS 的末尾分配槽位。除非 DLL 知道自己只会
被隐含的激活,否则就会使用那些系统调用,而不是.tls 区段。
Microsoft 动态链接库
微软 Windows 系统也提供共享库,称为动态链接库或 DLL,形式上与 ELF 共享库相似但
某种程度上要更简单一些。16 位的 Windows 3.1 和 32 位的 Windows NT 和 Windows 95 上的
DLL 的设计是有本质区别的。这里只讨论更现代的 Win32 库。DLL 通过与 PLT 相似的策略来导
入过程的地址。虽然 DLL 的设计可以实现通过与 GOT 相似的策略来导入数据地址,但实际中
它们使用了一种更简单的方法,即要求明确的程序代码对共享数据的导入指针进行解析。
在 Windows 下,程序和库都是 PE(Portable Excutable)格式文件,可以被内存映射到
bbs.theithome.com
一个进程中。与 Windows 3.1 中所有的应用程序共享单一的地址空间不同,Win32 为每个应
用程序提供了自己独立的地址空间,当可执行程序和库被使用时可以映射到各自的地址空间
中。对于只读代码,这没有任何特殊的区别,但对于数据而言,就意味着每个使用 DLL 的应
用程序对 DLL 的数据都有属于自己的副本(这有点过分简单了,因为可以通过标识 PE 文件
的某些区段为共享数据而在使用这个文件的多个应用程序之间共享一份数据副本,但是大部
分数据都是非共享的)。
加载一个 Windows 可执行程序或者 DLL 与加载一个动态链接的 ELF 程序相似,尽管在
Windows 下动态链接器是操作系统内核的一部分。首先内核根据可执行程序文件的 PE 头部中
的区段表,将可执行程序映射进来。然后它在根据可执行程序所使用到的 DLL 文件的 PE 头
部的信息,将所有这些 DLL 映射进来。
PE 文件可以包含有重定位项。通常一个可执行程序不会包含可重定位项,因此必须将
它们映射到在被链接确定的地址上。DLL 都包含有重定位项,并且在它们被链接进来的地址
空间无效的时候都会被重定位(微软将运行时重定位称为 rebasing)。
所有的 PE 文件,包括可执行程序和 DLL,都有一个入口点,在 DLL 被加载、被卸载,
以及每一次进程的线程 attach 或 deattach 这个 DLL 的时候,加载器都会调用 DLL 的入口点
(每一次加载器都会传递一个参数说明调用原因)。这就可以提供类似 ELF 的.init 和.fini
区段的钩子代码来实现初始化和终结操作。
都被加载到一个已知的固定地址,并可以从这个地址被链接。这种情况下,加载是颇为简单
的:
从目标文件中读取足够的头部信息,找出需要多少地址空间。
分配地址空间,如果目标代码的格式具有独立的段,那么就将地址空间按独立的段
划分。
将程序读入地址空间的段中。
将程序末尾的 bss 段空间填充为 0,如果虚拟内存系统不自动这么做得话。
如果体系结构需要的话,创建一个堆栈段(stack segment)。
设置诸如程序参数和环境变量的其他运行时信息。
开始运行程序。
如果程序不是通过虚拟内存系统映射的,读取目标文件就意味着通过普通的 read 系统
调用读取文件。在支持共享只读代码段的系统上,系统检查是否在内存中已经加载了该代码
段的一个拷贝,而不是生成另外一份拷贝。
仅有一小部分系统还仍然为执行程序在加载时进行重定位,大多数都是为共享库在加
载时进行重定位。诸如 MS-DOS 的系统,很少使用硬件的重定位;另外一些如 MVS 的系统,
具有硬件重定位(却是从一个没有硬件重定位的系统继承来的);还有一些系统,具有硬件
重定位,但是却可以将多个可执行程序和共享库加载到相同的地址空间。所以链接器不能指
望某些特定地址是有效的。
如第七章讨论的,加载时重定位要比链接时重定位简单的多,因为整个程序作为一个
单元进行重定位。例如,如果一个程序被链接为从位置 0 开始,但是实际上被加载到位置 15
000,那么需要所有程序中的空间都要被修正为“加上 15000”。在将程序读入主存后,加载
器根据目标文件中的重定位项,并将重定位项指向的内存位置进行修改。
加载时重定位会表现出性能的问题,由于在每一个地址空间内的修正值均不同,所以
被加载到不同虚拟地址的代码通常不能在地址空间之间共享。MVS 使用的,并被 Windows 和
AIX 扩展的一种方法,使创建一个出现在多个地址空间的共享内存区域,并将常用的程序加
载到其中(MVS 将其称为 link pack 区域)。这仍然存在普通进程不能获取可写数据的单独复
本的问题,所以应用程序必须在编写时明确地为它可写区域分配空间。
位置无关代码
对于将相同程序加载到普通地址的问题的一个常用的解决方案就是位置无关代码
(position independent code, PIC)。他的思想很简单,就是将数据和普通代码中那些不
会因为被加载的地址改变而变化的代码分离出来。这种方法中代码可以在所有进程间共享,
只有数据页为各进程自己私有。
这是一个令人吃惊的老想法。TSS/360 在 1966 年就使用它了,并且我相信它也不是最
早采用该方法的(TSS 有很多臭名昭著的 bug,但是从我个人经验而言,他的 PIC 特性的确
可以工作)。
在现代体系结构中,生成 PIC 可执行代码并不困难。跳转和分支代码通常是位置相关的,
或者与某一个运行时设置的基址寄存器相关,所以需要对他们进行非运行时的重定位。问题
在于数据的寻址,代码无法获取任何的直接数据地址。由于代码是可重定位的,而数据不是
位置无关的。普通的解决方案是在数据页中建立一个数据地址的表格,并在一个寄存器中保
存这个表的地址,这样代码可以使用相对于寄存器中地址的被索引地址来获取数据。这种方
式的成本在于对每一个数据引用需要进行一次额外的重定位,但是还存在一个问题就是如何
获取保存到寄存器中去的初始地址。
在许多 UNIX 系统中采用的一种简单修改是将一个过程的数据地址假当作这个过程的地
址,并在这个地址上放置一个指向该过程代码的指针,如图 2。如要调用一个过程,调用者
就将该例程的数据地址加载到约定好的数据指针寄存器,然后从数据指针指向的位置中加载
代码地址到一个寄存器,然后调用这个历程。这很容易实现,而且性能还算不错。
编译器将所有的调用都生成为 call 指令,其后还紧跟一个占位控操作指令 no-op
由于 x86 可以直
接寻址,一个对外部数据的引用在非 PIC 代码下可以是一个简单的 MOV 或 ADD,但在 PIC 代
码下就要变成加载紧跟在 MOV 或 ADD 后面的地址,这既增加了额外的内存引用又占用了宝贵
的寄存器作为临时指针。
特别在 x86 系统上,对于速度要求严格的任务,PIC 代码的性能降低是明显的,以至于
某些系统对于共享库退而采用一种类似 PIC 的方法。
自举加载
在这里讨论加载都有一个前提就是计算机系统中已经存在一个操作系统或至少有一个
程序加载器在运行并负责程序的加载。这些一个被另一个加载的程序链总得有一个开始的地
方吧,所以就有一个显而易见的问题即最初的程序是如何被加载到计算机中去的。
在现代计算机中,计算机在硬件复位后运行的第一个程序总是存储在称为 bootstrap R
OM 的随机只读存储器中。就像自己启动自己一样。当处理器上电或者复位后,它将寄存器
复位为一致的状态。例如在 x86 系统中,复位序列跳转到系统地址空间顶部下面的 16 字节
处。Bootstrap ROM 占用了地址空间顶端的 64K,然后这里的 ROM 代码就来启动计算机。在 I
BM 兼容的 x86 系统上,引导 ROM 代码读取软盘上的第一个块,如果失败的话就读取硬盘上的
第一个块,将它放置在内存位置 0,然后再跳转到位置 0。在第 0 块上的程序然后从磁盘上
一个已知位置上加载另一个稍微大一些的操作系统引导程序到内存中,然后在跳转到这个程
序,加载并运行操作系统(可能存在更多的步骤,例如引导管理器需要决定从那个分区上读
取操作系统的引导程序,但加载器的主要功能是不变的)。
为什么不直接加载操作系统?因为你无法将一个操作系统的引导程序放置在 512 个字节
内。第一级引导程序只能从被引导磁盘的顶级目录中加载一个名字固定且大小不超过一个段
的程序。操作系统引导程序具有更多的复杂代码如读取和解释配置文件,解压缩一个压缩的
操作系统内核,寻址大量内存(在 x86 系统上的引导程序通常运行在实模式下,这意味着寻
址 1MB 以上地址是比较复杂的)。完全的操作系统还要运行在虚拟内存系统上,可以加载需
要的驱动程序,并运行用户级程序。
很多 UNIX 系统使用一个近似的自举进程来运行用户台程序。内核创建一个进程,在其
中装填一个只有几十个字节长度的小程序。然后这个小程序调用一个系统调用运行/etc/ini
t 程序,这个用户模式的初始化程序然后依次运行系统所需要的各种配置文件,启动服务进
程和登录程序。
这些对于应用级程序员没有什么影响,但是如果你想编写运行在机器裸设备上的程序
时就变得有趣多了,因为你需要截取自举过程并运行自己的程序,而不是像通常那样依靠操
作系统。一些系统很容易实现这一点(例如只需要在 AUTOEXEC.BAT 中写入你要运行的程序
名字,再重新启动 Windows 95),另外一些系统则几乎是不可能的。它同样也给定制系统提
供了机会。例如可以通过将应用程序的名字改为/etc/init 基于 UNIX 内核构建单应用程序系
统
所有共享库基本上以相同的方式工作。在链接时,链接器搜索整个库以找到用于解决
那些未定义的外部符号的模块。但链接器不把模块内容拷贝到输出文件中,而是标记模块来
自的库名,同时在可执行文件中放一个库的列表。当程序被装载时,启动代码找到那些库,
并在程序开始前把它们映射到程序的地址空间,如图 1。标准操作系统的文件映射机制自动
共享那些以只读或写时拷贝的映射页。负责映射的启动代码可能是在操作系统中,或在可执
行体,或在已经映射到进程地址空间的特定动态链接器中,或是这三者的某种并集。
静态链接库,也就是库中的程序和数据地址在链接时绑定到可执
行体中。当库已经存在,但是自从程序链接以来库已经改变了时,一个更有趣的问题就会发生。
在一个常规链接的程序中,在链接时符号就被绑定到地址上而库代码就已经绑定到可执行体
中了,所以程序所链接的库是那个忽略了随后变更的库。对于静态共享库,符号在链接时被
绑定到地址上,而库代码要直到运行时才被绑定到可执行体上。
代码地址,库中有一个可以跳转到所有例程的跳转指令表,并将这
些跳转的地址作为相应例程的地址输出,而不是输出这些例程的实际地址。所有跳转指令的
大小都是相同的,所以跳转表的地址很容易计算,并且只要表中不在库更新时加入或删除表
项,那么这些地址将不会随版本而改变。每一个例程多出一条跳转指令不会明显的降低速度,
bbs.theithome.com
由于实际的例程地址是不可见的,所以即使新版本与旧版本的例程大小和地址都不一样,库
的新旧版本仍然是可兼容的。
对于输出数据,情况就要复杂一些,因为没有一种像对代码地址那样的简单方法来增
加一个间接层。实际中的输出数据一般是很少变动的、尺寸已知的表,例如 C 标准 I/O 库中
的 FILE 结构,或者像 errno 那样的单字数值(最近一次系统调用返回的错误代码),或者
是 tzname(指向当前时区名称的两个字符串的指针)。建立共享库的程序员可以收集到这些
输出数据并放置在数据段的开头,使它们位于每个例程中所使用的匿名数据的前面,这样使
得这些输出地址在库更新时不太可能会有变化。
Linux 链接器使用一种不那么残忍的方法,即创建一个称为设置向量的特殊
符号类型。设置向量象普通的全局符号一样,但如果它有多个定义,这些定义会被放进一个
以该符号命名的数组中。每个共享库定义一个设置向量符号__SHARED_LIBRARIES__,它是由
库名、版本、加载地址等构成的一个数据结构的地址。 链接器创建一个指向每个这种数据
结构的指针的数组,并称之为__SHARED_LIBRARIES__,好让启动代码可以使用它。BSD/OS 共
享库没有使用任何的此类链接器窍门。它使用 shell 脚本建立一个共享的可执行程序,用来
搜索作为参数或隐式传入的库列表,提取出这些文件的名字并根据系统文件中的列表来加载
这些库的地址,然后编写一个小汇编源文件创建一个带有库名字和加载地址的结构数组,并
汇编这个文件,把得到的目标文件加入到链接器的参数列表中。
在每一种情况中,从程序代码到库地址的引用都是通过空占位库中的地址自动解析的。
使用共享库运行
启动一个使用共享库的程序需要三步:加载可执行程序,映射库,进行库特定的初始
化操作。在每一种情况下,可执行程序都被系统按照通常的方法加载到内存中。之后,处理
方法会有差别。系统 V.3 内核具有了处理链接 COFF 共享库的可执行程序的扩展性能,其内
核会查看库列表并在程序运行之前将它们映射进来。
BSD/OS 的方法是使用标准的 mmap()系统调用将一个文件的多个页映射进地址空间,该
方法还使用一个链接到每个共享库起始处的自举例程。可执行程序中的启动例程遍历共享库
表,打开每个对应的文件,将文件的第一页映射到加载地址中,然后调用各自的自举例程,
该例程位于可执行文件头之后的起始页附近的某个固定位置。然后自举例程再映射余下的文
本段、数据段,然后为 bss 段映射新的地址空间,然后自举例程就返回了。
在 BSD/OS 实现中,C 库的自举例程会接收到一个指向共享库表的指针,并将所有其它
的库都映射进来,减小了需要链接到单独的可执行体中的代码量。最近版本的 BSD 使用 ELF
格式的可执行体。ELF 头有一个 interp 段,其中包含一个运行该文件时需要使用的解释器程
序的名字。BSD 使用共享的 C 库作为解释器,这意味着在程序启动之前内核会将共享 C 库先
映射进来,这就节省了一些系统调用的开销。库自举例程进行的是相同的初始化工作,将库
的剩余部分映射进来,并且,通过一个指针,调用程序的 main 例程。
indows 也允许使用 LoadLibrary 和 FreeLibrary 函数来明确的加载和卸载 DLL,并使用
GetProcAddress 来查找符号的地址。
DLL 库和线程
Windows 的 DLL 模式不能很好工作的特例之一是线程本地存储。Windows 程序可以在同
一个进程中启用多个线程,它们共享进程的地址空间。每一个线程都有一小块线程本地存储
(Thread Local Storage, TLS)区域来保存和特定线程相关的数据,例如指向当前线程正
在使用的数据结构或资源的指针。TLS 对于程序或每个使用 TLS 的 DLL 中的数据,需要使用
槽位6(slot)。Windows 链接器可以在 PE 可执行程序中创建一个.tls 区段,它定义了可执
行程序及其直接引用的 DLL 中的例程所需的 TLS 的布局。每次在进程创建一个线程时,新的
线程会获得自己的 TLS,该 TLS 是以.tls 区段为模板创建的。
问题是,多数 DLL 既可以从可执行程序中被隐含的链接,也可以通过 LoadLibrary 来显
式的加载。由于 DLL 的作者无法预测该库是隐式还是显式激活的,因此显式加载的 DLL 不能
自动获得.tls 存储区域,它不能够倚赖.tls 区段。
Windows 定义了运行时系统调用,可以在 TLS 的末尾分配槽位。除非 DLL 知道自己只会
被隐含的激活,否则就会使用那些系统调用,而不是.tls 区段。
Microsoft 动态链接库
微软 Windows 系统也提供共享库,称为动态链接库或 DLL,形式上与 ELF 共享库相似但
某种程度上要更简单一些。16 位的 Windows 3.1 和 32 位的 Windows NT 和 Windows 95 上的
DLL 的设计是有本质区别的。这里只讨论更现代的 Win32 库。DLL 通过与 PLT 相似的策略来导
入过程的地址。虽然 DLL 的设计可以实现通过与 GOT 相似的策略来导入数据地址,但实际中
它们使用了一种更简单的方法,即要求明确的程序代码对共享数据的导入指针进行解析。
在 Windows 下,程序和库都是 PE(Portable Excutable)格式文件,可以被内存映射到
bbs.theithome.com
一个进程中。与 Windows 3.1 中所有的应用程序共享单一的地址空间不同,Win32 为每个应
用程序提供了自己独立的地址空间,当可执行程序和库被使用时可以映射到各自的地址空间
中。对于只读代码,这没有任何特殊的区别,但对于数据而言,就意味着每个使用 DLL 的应
用程序对 DLL 的数据都有属于自己的副本(这有点过分简单了,因为可以通过标识 PE 文件
的某些区段为共享数据而在使用这个文件的多个应用程序之间共享一份数据副本,但是大部
分数据都是非共享的)。
加载一个 Windows 可执行程序或者 DLL 与加载一个动态链接的 ELF 程序相似,尽管在
Windows 下动态链接器是操作系统内核的一部分。首先内核根据可执行程序文件的 PE 头部中
的区段表,将可执行程序映射进来。然后它在根据可执行程序所使用到的 DLL 文件的 PE 头
部的信息,将所有这些 DLL 映射进来。
PE 文件可以包含有重定位项。通常一个可执行程序不会包含可重定位项,因此必须将
它们映射到在被链接确定的地址上。DLL 都包含有重定位项,并且在它们被链接进来的地址
空间无效的时候都会被重定位(微软将运行时重定位称为 rebasing)。
所有的 PE 文件,包括可执行程序和 DLL,都有一个入口点,在 DLL 被加载、被卸载,
以及每一次进程的线程 attach 或 deattach 这个 DLL 的时候,加载器都会调用 DLL 的入口点
(每一次加载器都会传递一个参数说明调用原因)。这就可以提供类似 ELF 的.init 和.fini
区段的钩子代码来实现初始化和终结操作。