计算机体系结构——链接
链接器的存在可以使得编译器可以组合多个代码片段,实现库的引入,实现编译分离等功能。
编译驱动器
多数编译系统都提供了编译驱动器,包括语言预处理器,编译器,汇编器,链接器。
例如使用 GNU GCC 编译一个 C 语言源文件的过程:
- 使用
cpp
命令调用 C 语言预处理器将 ASCII C 语言源文件main.c
转换为 ASCII 中间文件main.i
。 - 使用
cc1
将调用 C 编译器中间文件翻译为 ASCII 汇编文件main.s
。 - 使用
as
命令调用汇编器将汇编文件编译为可重定位目标文件main.o
。 - 使用
ld
调用链接器链接多个可重定位目标文件,编译为可执行目标文件main
。
在 Shell 中执行该文件,操作系统通过加载器将可执行文件加载到内存,然后将控制权交给该文件。
静态链接
像通过 ld
命令通过链接器进行链接可重定位目标文件组,这种链接方式叫做静态链接。
可重定位目标文件将代码分成指令、已初始化全局变量、未初始化全局变量分成三个区域。
链接器主要做下面两个任务:
- 符号重新决定。目标文件定义和引用了一些符号,符号重新决定将符号定义和符号引用进行一一对应。
- 重定位。目标文件包含了许多区域,链接器将这些区间进行重新布局,对引用区域赋予正确的引用内存地址。
链接器通常并不需要了解目标机器的信息,大部分工作汇编器已经完成,链接器只需要符号决定和重定位内存即可。
目标文件
目标文件包含三种类型:
- 可重定位目标文件:包含未被链接的符号代码、数据区域,可以被链接器链接成可执行目标文件。
- 可执行目标文件:可以被加载器直接加载进内存并且执行的文件。
- 共享目标文件:一类特殊的可重定位目标文件,可以被程序在运行时动态的加载进内存。在运行时确定内存符号位置的文件。
编译器和汇编器生成可重定位目标文件和共享目标文件,链接器生成可执行目标文件。
目标文件作为一个文件也具有不同的文件格式,例如第一代 Unix 系统使用 a.out 目标文件格式(至今为止,编译器默认输出的可执行文件名仍叫 a.out ),早期 System V Unix 系统使用 COFF ( Common Object File Format )作为目标文件格式, Windows NT 使用一种 COFF 的变种 PE ( Portable Executable )作为目标文件格式。现在一些 Unix 和 Linux 系统使用 ELF ( Executable and Linkable Format )目标文件格式。
尽管我们主要关系 ELF 目标文件格式,但是对于其他文件格式来说概念都大同小异。
可重定位目标文件
一个 ELF 文件以 16 字节的序列称为 ELF 头的区域开始,这个区域记录了该文件使用的字长和端序信息。剩余的空间记录了能够被链接器识别的信息,包括 ELF 头的大小,目标文件的类型,程序段的数量和段头表的偏移地址和目标机器信息等等。
ELF 文件以段头表作为结束,描述了文件中每个段的大小和偏移。
ELF 头和段头表中间夹的部分由多个程序段组成,一个典型的 ELF 文件包含以下程序段:
.text
编译文件得到的机器码。.rodata
只读代码段,例如包括字符串字面量和用于switch
语句的跳表等等。.data
已初始化的全局变量。.bss
未初始化的全局变量。这部分不占用实际的储存空间,仅仅只是声明变量的存在。.symtab
包含程序的符号表。许多程序员认为只有加上了-g
选项才会生成.symtab
,其实不然,每个 ELF 文件都会包含.symtab
和编译器的符号表相比不保存局部变量信息。.rel.text
一个列表,记录了在.text
所有在链接时需要被修改的信息。总体来说,任何的指令需要调用外部函数或引用外部变量都需要被修改。.rel.data
需要重定位的局部变量列表。总体来说,任何已初始化的全局变量需要被修改。.debug
保存了所有的局部变量符号表用于调试,只有开启-g
选项才会生成该段。.line
保存了在.text
中的指令和源文件行号之间的对应关系用于调试,只有开启-g
选项才会生成该段。.strtab
帮助 ELF 文件记录字符串,例如记录.symtab
和.debug
中的字符串和段头表中每个段名字。通常是一个非终端字符的字符串。
符号和符号表
每个可重定位文件都有自己的符号,通过来说分为下面三个类型:
- 全局定义符号。在全局定义的符号,可以被其他可重定位文件引用。包括非静态函数,定义的全局变量。
- 全局引用符号。引用其他全局定义符号。包含外部函数和全局变量。
- 本地符号。对应具有静态类型的全局变量和函数或者静态类型的局部变量。只在本可重定位文件可以引用,其他可重定位文件不能引用。
通常来说,对应静态局部变量,会在 .data
段定义声明和使用。在 .bss
段为其分配空间,而不是函数栈帧中。
在 .symtab
中,符号表以实体储存符号。一个实体包括:
name
段,指定了符号的名称,只记录在.strtab
中的偏移地址。value
段,段偏移地址,或虚拟内存地址。size
段,指定了对象的大小。type
段,指定了符号的类型,数据、函数、段和源文件名,占 4 位。binding
段,指定是本地类型还是全局类型。reversed
段,不使用。section
段,指定位于具体哪个程序段。或者定义 4 种非程序段类型,ABS
表示不需要重定位,UNDEF
定义该符号没有定义在本文件中,定义在其他文件中,COMMON
指没有初始化的数据,也没有分配空间其value
指定了对齐方式size
指定了最小空间。
符号决议
链接器决议多重定义的全局符号
链接器面对的第一个问题就是,如何处理链接器定位多重定义的全局符号。
在汇编阶段,汇编器将符号分成两种。第一种是强符号,包括函数和已初始化全局变量,第二种是弱符号,包括未初始化全局变量。
链接器通过以下规则确定符号:
- 规则一:多个重复的强符号是不允许的。
- 规则二:给定一个强符号和多个弱符号,选择强符号。
- 规则三:给定多个弱符号,选择任意一个弱符号。
例如,如果在两个文件中同时定义了 main
函数,链接器将会报错,因为这违反了规则一。
注意规则二,因为链接只匹配名称而不匹配类型,因此可以在一个文件中定义 int main = 1;
另一个文件中声明 double x;
也可被正确重定位,但是会引起错误,因为访问了非法的内存。
静态库的链接
如果将所有的库函数都放在一个可重定位目标文件中,将会造成可执行文件体积过大等缺点。
因此引入静态库的概念,静态库是将多个相关的可重定位目标文件放在一个文件中交给链接器链接,链接器在链接的时候会选择使用的可重定位目标文件,而忽略那些没有使用的可重定位目标文件。
在 Unix 系统中,称静态库为归档文件,以 .a
结尾。可以通过 ar
命令创建归档文件。
例如,标准 C 函数在 libc.a
文件中,数学函数在 libm.a
中。
链接器处理静态库的符号决议
一开始,链接器维护三个集合, E E E 集合是要被加入到可执行目标文件中的可重定位目标文件, U U U 是需要重定位的符号集合, D D D 是定义的符号集合。
链接器将从左到右扫描命令行输入的文件列表:
- 如果文件是目标文件,链接器将会将这个文件加入到 E E E 中,不管有没有使用,并更新 U U U 和 D D D 。
- 如果文件是一个归档文件,链接器会循环性的检查 U U U 如果当前可重定位目标文件中包含定义的 U U U ,那么链接器将会加入到 E E E 中,并更新 U U U 和 D D D 。这个过程只会在遍历一遍结束后 U U U 和 D D D 均不发生变换之后才退出循环。否则继续循环。
- 如果 U U U 不是非空的到最后,那么链接器将会报错,否则生成一个可执行文件。
不幸的是,这个算法会造成很多麻烦,因此输入文件的顺序十分重要,有个使用原则是永远将库归档文件放在最后,并决定好输入顺序,另外如果有必要,例如循环依赖,一个归档文件可以多次出现在输入文件中以解决循环依赖。
重定位
当链接器决议完所有的符号之后,只剩下重新定位内存的工作了,这个过程包含下面两项:
- 重定位段和符合定义。链接器将多个文件中的段合并成一个段,然后确定每个定义的真实位置。
- 重定位引用。将每个引用赋予对应定义的正确的内存位置。
重定位实体
汇编器既不知道代码和数据存放的实际地址也不知道外部代码和数据的地址,汇编器将这些待决议的实体放在 .relo.text
和 .relo.data
段中。
一个实体包含三个字段:
offset
字段,指定段内的偏移地址,指定那个位置需要修改。symbol
字段,指向这个引用在符号表的位置。type
字段,实体类型。
ELF 定义了 11 中实体,我们只关系两种:
R_386_PC32
使用一个 PC 32 位相对地址。 PC 相对地址指当前 PC 寄存器加上这个相对地址得到绝对地址。R_386_32
使用 32 位绝对地址。
链接器通过这些实体信息进行修改段内的引用数据和代码地址。
可执行目标文件
可执行目标文件和可重定位目标文件很想,不同的是:
- ELF 还包含程序的入口。
- 包含一个额外的
.init
段,包含了程序初始化的代码。 - 没有
.relo.xxx
段,因为所有的符号均已重定位。 - 为了将可执行目标文件能够被更有效的加载进内存,使用将该文件分成多个内存段,在 ELF 头下面是一个内存段表。
对于内存段,内存段头将整个文件分成两个内存段:
- 代码段:指出应该对齐到什么边界,具有读取和执行限权,起始地址和总大小,加载 ELF 头,内存段头,
.init
和.text
和.rodata
五个区域。 - 数据段:指出应该对齐到什么边界,具有读取和写入限权,起始地址和总大小,加载
.data
段,剩下的区域用 0 填充.bss
区域。
加载可执行目标文件
当一个程序被 Shell 执行的时候,任何 Unix 程序都能调用 execve
函数来调用加载器,执行一个可执行目标文件。
一个 Unix 系统具有如下的内存布局(从低地址到高地址):
- 内核虚拟内存。
- 用户栈空间。
- 共享库空间。
- 堆空间(受
malloc
控制)。 - 读写段(包含
.data .bss
等)。 - 只读段(包含
.init .text .rodata
等)。 - 未使用。
一个加载器将可执行目标文件加载进对应的内存区域。然后将控制权交给 _start
符号指定的内存地址。 _start
定义在 crt1.o
文件中,称作启动代码,这段代码执行下面的过程:
- 执行
__libc_init_first
代码在.text
中,初始化标准库。 - 执行
_init
代码在_init
中,初始化程序数据。 - 执行
atexit
代码在.text
注册一些清理函数。 - 执行
main
函数。 - 执行
_exit
函数,退出程序。
动态链接和共享库
共享库被系统的动态链接器载入进内存,耦合性更小,其中的函数可以被多个进程所共享,具有静态库没有的优点。
在 Unix 等系统中共享库以 .so
结尾,在 Windows NT 等系统中以 .dll
结尾。
通过 gcc -shared -fPIC
命令可以生成一个 .so
文件。
将一个 .so
文件交给静态链接器的时候,静态链接器不会将 .so
文件中的数据和代码复制到可执行文件中,而是生成一个可重定位信息和符号表,动态链接器可以利用这些信息动态的加载共享库。
当加载器执行可执行文件的时候,可执行文件称为部分链接的可执行文件,包含一个 .interp
段包含了动态链接器的目录地址(本身也是一个共享库,例如 ld-linux.so
)。加载器就会执行动态链接过程:
- 重定位
xxx.so
中的代码和数据段加载进内存。 - 重定位可执行文件中的符号通过
xxx.so
。
最终,动态链接器将执行权交给 main
函数。
应用程序加载共享库
Linux 等系统给程序员提供了在代码中加载共享库的接口,在 Linux 中使用 dlfcn.h
库中的函数。
一些工具
GNU 提供了一些链接的工具:
ar
用于查看、修改、创建归档文件即静态库文件。strings
用于打印在目标文件中的字符串。strip
用于删除目标文件中的符号表信息。nm
列出符号表中的定义。size
列出目标文件中的段信息。readelf
读取 elf 文件。objdump
反汇编工具。ldd
查询可执行文件的共享库信息。