计算机那些事(5)——链接、静态链接、动态链接
通过前面对ELF文件结构的详细介绍,我们对ELF目标文件从整体轮廓到局部细节都有了一定的了解。那么接下来,当我们有多个目标文件时,如何将它们链接起来形成一个可执行文件呢?一切都要从链接说起。
链接概述
模块化设计是软件开发中最常用的设计思想。链接(Linking) 本质上就是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确衔接。比如:
我们在模块
main.c
中使用另一个模块func.c
中的foo()
函数。我们在main.c
模块中每一处调用foo
时都必须确切知道foo
函数的地址。但由于每个模块都是单独编译的。编译器在编译main.c
的时候并不知道foo
函数的地址。所以编译器会暂时把这些调用foo
的指令的目标地址搁置,等待最后链接时由链接器将这些指令的目标地址修正。这就是静态链接最基本的过程和作用。
如下图所示为最基本的静态链接过程示意图。每个模块的源代码文件(如.c
)文件经过编译器编译成目标文件(Object
File,一般扩展名为.o
或.obj
)。目标文件和
库(Library) 一起链接形成最终的可执行文件。
其中,最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。库本质上是一组目标文件的包,由一些最常用的代码编译成目标文件后打包而成。
链接过程主要包含了三个步骤:
- 地址与空间分配(Address and Storage Allocation)
- 符号解析(Symbol Resolution)
- 重定位(Relocation)
下面,我们以两个源代码文件a.c
和b.c
为例展开分析。
1
2
3
4
5
6
7// a.c
extern int shared;
int main() {
int a = 100;
swap(&a, &shared);
}1
2
3
4
5
6// b.c
int shared = 1;
void swap(int *a, int *b) {
*a ^= *b ^= *a ^= *b;
}
其中,b.c
中定义了两个全局符号:变量shared
、函数swap
;a.c
中定义了一个全局符号:main
。a.c
引用了b.c
中的swap
和shared
。接下来我们要将两个目标文件链接在一起并最终形成一个执行程文件ab
。
使用gcc -c
命令我们可以分别编译得到a.o
和b.o
两个目标文件。
地址与空间分配
在介绍ELF文件结构关于段与节的区别时,我们就提到过可执行文件中的段是由目标文件中的节合并而来的。那么,我们的第一个问题是:对于多个输入目标文件,链接器如何将它们的各个节合并到输出文件呢?或者说,输出文件中的空间如何分配给输入文件。
按序叠加
一个最简单的方案就是将输入的文件按序叠加,如下图所示。
虽然这种方法非常简单,但是它存在一个问题:在有很多输入文件的情况下,输出文件会有很多零散的节。这种做法非常浪费空间,因为每个节都需要有一定的地址和空间对齐要求。x86硬件的对齐要求是4KB。如果一个节的大小只有1个字节,它也要在内存在重用4KB。这样会造成大量内部碎片。所以不是一个好的方案。
合并相似节
一个更加实际的方法便是合并相同性质的节,比如:将所有输入文件的
.text
节合并到输出文件的
text
段(注意,此时出现了段和节两个概念),如下图所示。
其中.bss
节在目标文件和可执行文件中不占用文件的空间,但是它在装载时占用地址空间。事实上,这里的空间和地址有两层含义:
- 在输出的可执行文件中的空间
- 在装载后的虚拟地址中的空间
对于有实际数据的节,如.text
和.data
,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;对于.bss
来,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。我们在这里谈到的空间分配只关注于虚拟地址空间的分配,因为这关系到链接器后面的关于地址计算的步骤,而可执行文件本身的空间分配与链接的关系并不大。
现在的链接器空间分配的策略基本上都采用“合并相似节”的方法,使用这种方法的链接器一般采用一种叫 两步链接(Two-pass Linking) 的方法。即整个链接过程分为两步:
- 第一步 地址与空间分配
扫描所有的输入目标文件,获得它们的各个节的长度、属性、位置,并将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局的符号表。这一步,链接器能够获得所有输入目标文件的节的长度,并将它们合并,计算出输出文件中各个节合并后的长度与位置,并建立映射关系。 - 第二步 符号解析与重定位
使用前一步中收集到的所有信息,读取输入文件中节的输数据、重定位信息,并且进行符号解析与重定位、调整代码、调整代码中的地址等。事实上,第二步是链接过程的核心,尤其是重定位。
在地址与空间分配步骤完成之后,相似权限的节会被合并成段,并生成了ELF文件结构一文中没有介绍的
程序头表(Program Header Table)
结构。如下右图可执行文件结构所示,主要生成两个段:代码段(
text
段)、数据段( data
段 )。
我们使用ld或gcc将a.o
和b.o
链接起来,然后使用objdump工具来查看链接前后的地址分配情况。
1 | $ objdump -h a.o |
1 | $ objdump -h b.o |
1 | $ objdump -h ab |
可以发现,链接前目标文件中所有节的 VMA(Virtual Memory
Address)
都是0,因为虚拟空间还没有分配。链接后,可执行文件ab
中各个节被分配到了相应的虚拟地址,如.text
节被分配到了地址0x0000000000400450
。
那么,为什么链接器要将可执行文件ab
的.text
节分配到0x0000000000400450
?而不是从虚拟空间的0地址开始分配呢?这涉及到操作系统的进程虚拟地址空间的分配规则。在Linux
x86-64系统中,代码段总是从0x0000000000400000
开始的,另外.text
节之前还有ELF Header
、Program Header Table
、.init
等占用了一定的空间,所以就被分配到了0x0000000000400450
。
符号解析
在两步链接中,这一步和重定位被合并成了一步,这是因为重定位的过程是伴随着符号解析的。这里我们分开介绍。
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对那些和引用定义在相同模块的局部符号的引用,符号解析是非常简单的。编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
然而,对于全局符号的解析要复杂得多。当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条错误信息并终止。
另一方面,对全局符号的解析,经常会面临多个目标文件可能会定义相同名字的全局符号。这种情况下,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。
多重定义的全局符号解析
链接器的输入是一组可重定位目标模块。每个模块定义一组符号,有些是局部符号(只对定义该符号的模块可见),有些是全局符号(对其他模块也可见)。如果多个模块定义同名的全局符号,该如何进行取舍?
Linux编译系统采用如下的方法解决多重定义的全局符号解析:
在编译时,编译器想汇编器输出每个全局符号,或者是强(strong)或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表中。
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
- 规则1:不允许有多个同名的强符号。
- 规则2:如果有一个强符号和多个弱符号同名,则选择强符号。
- 规则3:如果有多个弱符号同名,则从这些弱符号中任意选择一个。
另一方面,由于允许一个符号定义在多个文件中,所以可能会导致一个问题:如果一个弱符号定义在多个目标文件中,而它们的类型不同,怎么办?这种情况主要有三种:
- 情况1:两个或两个以上的强符号类型不一致。
- 情况2:有一个强符号,其他都是弱符号,出现类型不一致。
- 情况3:两个或两个以上弱符号类型不一致。
其中,情况1由于多个强符号定义本身就是非法的,所以链接器就会报错。对于后两种情况,编译器和链接器采用一种叫 COMMON块(Common Block ) 的机制来处理。其过程如下:
首先,编译器将未初始化的全局变量定义为弱符号处理。对于情况3,最终链接时选择最大的类型。对于情况2,最终输出结果中的符号所占空间与强符号相同,如果链接过程中有弱符号大于强符号,链接器会发出警告。
重定位
事实上,重定位过程也伴随着符号的解析过程。链接的前两步完成之后,链接器就已经确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。
那么链接器如何知道哪些指令是要被调整的呢?事实上,我们前面提到的ELF文件中的 重定位表(Relocation Table) 专门用来保存这些与重定位相关的信息。
对于可重定位的ELF文件来说,它必须包含重定位表,用来描述如何修改相应的节的内容。对于每个要被重定位的ELF节都有一个对应的重定位表。如果.text
节需要被重定位,则会有一个相对应叫.rel.text
的节保存了代码节的重定位表;如果.data
节需要被重定位,则会有一个相对应的.rel.tdata
的节保存了数据节的重定位表。
我们可以使用objdump工具来查看目标文件中的重定位表: 1
2
3
4
5
6
7
8
9
10
11
12
13
14$ objdump -r a.o
a.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000023 R_X86_64_32 share
0000000000000030 R_X86_64_PC32 swap-0x0000000000000004
0000000000000049 R_X86_64_PC32 __stack_chk_fail-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
我们可以看到每个要被重定位的地方是一个 重定位入口(Relocation Entry)。利用数据结构成员包含的信息,即可完成重定位。
静态链接
事实上,静态链接的过程就是上文所描述的过程。在Linux中,静态链接器(static
linker)ld
以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。输入的可重定位目标文件由各种不同的节组成,每一节都是一个连续的字节序列。
动态链接
静态链接使得进行模块化开发,大大提供了程序的开发效率。随着,程序规模的扩大,静态链接的诸多缺点也逐渐暴露出来,如:浪费内存和磁盘空间、模块更新困难等。在静态链接中,C语言静态库是很典型的浪费空间的例子。关于模块更新,静态链接的程序有任何更新,都必须重新编译链接,用户则需要重新下载安装该程序。
解决空间浪费和更新困难最简单的方法便是将程序的模块相互分割开来,形成独立文件。简而言之,就是不对那些组成程序的目标文件进行链接,而是等到程序要运行时才进行链接。
动态链接的基本实现
动态链接涉及运行时的链接以及多个文件的装载,必需要有操作系统的支持。因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。
目前,主流操作系统都支持动态链接。在Linux中,ELF动态链接文件被称为
动态共享对象(DSO,Dynamic Shared
Objects),一般以.so
为后缀;在Windows中,动态链接文件被称为
动态链接库(Dynamic Linking
Library),一般以.dll
为后缀。
在Linux中,常用的C语言库的运行库glibc,其动态链接形式的版本保留在
/lib
目录下,文件名为
libc.so
。整个系统只保留一份C语言动态链接文件libc.so
,所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载时,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并将程序中所有未解析的符号绑定到相应的动态链接库中,并进行重定位。
动态链接程序运行时地址空间分布
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,即可执行文件。而对于动态链接,除了可执行文件,还有它所依赖的共享目标文件。
关于共享目标文件在内存中的地址分配,主要有两种解决方案,分别是:
- 静态共享库(Static Shared Library)(地址固定)
- 动态共享库(Dynamic Shared Libary)(地址不固定)
静态共享库
静态共享库的做法是将程序的各个模块统一交给操作系统进行管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间。因为这个地址对于不同的应用程序来说,都是固定的,所以称之为静态。
但是静态共享库的目标地址会导致地址冲突、升级等问题。
动态共享库
采用动态共享库的方式,也称为装载时重定位(Load Time Relocation)。其基本思路是:在链接时,对所有绝对地址的引用都不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。
但是这种方式也存在一些问题。比如,动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来说都是不同的。
虽然,动态链接库中的代码是共享的,但是其中的可修改数据部分对于不同进程来说是由多个副本的。基于此,一种名为地址无关代码的技术被提出以克服这个问题。
地址无关代码
计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
地址无关代码(PIC,Position-independent Code) 技术完美阐释了上面这句名言,其基本原理是:把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。
共享对象模块中的地址引用按照是否为跨模块分为两类:模块内部引用、模块外部引用。按照不用的引用方式又可分为:指令引用、数据引用。以如下代码为例,可得出如下四种类型:
- 类型1:模块内部的函数调用。
- 类型2:模块内部的数据访问,如模块中定义的全局变量、静态变量。
- 类型3:模块外部的函数调用。
- 类型4:模块外部的数据访问,如其他模块中定义的全局变量。
1 | static int a; |
类型1 模块内部函数调用
由于被调用的函数与调用者都处于同一模块,它们之间的相对位置是固定的。对于现代的系统来说,模块内部的调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的。
类型2 模块内部数据访问
一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,即任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,所以只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。
类型3 模块间数据访问
模块间的数据访问比模块内部稍微麻烦一些,因为模块间的数据访问目标地址要等到装载时才决定。此时,动态链接需要使用代码无关地址技术,其基本思想是把地址相关的部分放到数据段。ELF的实现方法是:在数据段中建立一个指向这些变量的指针数组,也称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。过程示意图如下所示:
当指令中需要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。每个变量都对应一个4字节的地址,链接器在装载模块时会查找每个变量所在的地址,然后填充GOT中的各个项,以确保每个指针所指向的地址正确。由于GOT本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。
类型4 模块间函数调用
对于模块间函数调用,同样可以采用类型3的方法来解决。与上面的类型有所不同的是,GOT中响应的项保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。
总结
通过上文的描述,我们基本理清了链接的过程以及静态链接和动态链接的区别。事实上,链接的具体实现细节是非常复杂,本文只是对其进行了概述,更多细节以及优化实现还是需要我们自己进一步去探索。
参考
- Executable and Linkable Format (ELF)
- 《Linux 二进制分析》
- 《程序员的自我修养——链接、装载与库》
- 《深入理解计算机系统》
- Executable and Linkable Format
(完)