链接
将编译出来的多个二进制文件合成一个可执行文件。
为什么使用链接?
-
原因一:模块化。
在编写代码时,我们不能把所有的代码都写在同一个源文件中,这样只会写出屎山。
-
原因二:效率。
- 我们可以分别编译。如果我们只是修改了某一个源文件中的一句代码,我们只需要重新编译这一个文件再链接就可以,而不必重新编译整个工程。
- 我们可以实现库。将一些常用的函数的写成了二进制文件,在后期需要使用的时候,只需要将这个二进制文件和我们的程序链接就可以。
- 库的链接有着两种方式:静态链接和动态链接。静态链接会将需要用到的库二进制数据复制到可执行文件中。动态链接会将库二进制数据加载到内存中,其他的程序就可以利用内存地址使用库了。
链接做了什么?
-
符号解析
程序中会定义或者引用需要使用的函数和全局变量,这些符号变量会储存在目标文件的符号表中。
链接器会重新解析这张符号表,将每个符号和函数的定义同唯一的符号和函数实现对应在一起。
-
重定位
- 将分散的代码和数据合并在一起
- 将
.o
文件中代码的相对位置重定向到在可执行文件中的最终内存地址 - 更新文件中对上面这些内容的所有引用
下面我们更详细的介绍这两个步骤。
对象文件的三种种类
-
可重定向对象文件
.o
- 包含可以同其他
.o
文件合并为一个可执行文件的代码和数据 - 每个
.o
是由单个.c
文件产生的
- 包含可以同其他
-
可执行文件
.out
包含可以直接复制到内存中运行的代码和数据
-
共享库文件
- 一种特殊类型的课重定向对象文件,可以直接加载在内存中然后动态链接,加载时链接或者运行时链接均可。
- 在Windows操作系统中后缀为
.dll
可执行文件格式
在不同的系统上采用的可执行文件格式是不相同的,这里主要考虑在现代Unix系统上使用的可执行可链接目标文件(ELF)格式,这是目标文件的标准二进制格式,不论是可重定向文件.o
、可执行文件.out
还是共享库文件.so
都需要遵循这个格式的要求。
ELF目标文件格式
一个典型的ELF文件由三部分组成:ELF文件头、数量不定的节和节头部表。位于文件开头的ELF文件头一般包括这些信息:字长、字节顺序、文件类型(可执行文件、可重定向文件、共享库文件)、机器类型;而位于文件末尾的节头部表描述了文件中不同节的大小和位置。位于文件头和节头部表之间的就是节,节一般有:
-
.text
:已编译程序的机器代码 -
.rodata
:只读数据 -
.data
:已初始化的全局和静态C变量 -
.bss
:未初始化的全局和静态C变量 -
.symtab
:一个符号表,存放程序中定义和引用的函数的全局变量的信息,同编译器中的符号表不同,这里的符号表不会包含局部变量的信息。 -
.rel.text
:一个.text
节中位置的列表,当链接器将这个目标文件同其他文件组合时,需要修改这些位置。 -
.rel.data
:一个.data
节中位置的列表,也就是模块引用或者定义的所有全局变量的重定位信息。 -
.debug
:调试符号表。在gcc
编译中采用-g
选项时,才会生成这张表。
符号和符号表
在链接器的上下文中存在着三种符号:
- 全局符号:由当前模块定义且可以被其他模块所引用的符号,比如C语言中的非静态的函数和变量
- 外部符号:由其他模块定义且被当前模块引用的符号
- 本地符号:由当前模块定义且只能由当前模块引用的符号,比如C语言中静态的函数和全局变量
注意:链接器中符号和本地程序中的符号不能说是一模一样,只能说是毫不相干。
每个符号都会被分配到目标文件的某个节中。在文件中有三个特殊的伪节,在节头部表中没有条目:ABS
表示不该被重定位的符号,UNDEF
表示未定义的符号,COMMON
表示还未被分配位置的未初始化的数据目标。COMMON
和.bss
节之间的区别很细微,一般的链接器遵循如下的规则来将可重定位目标文件中的符号分配到这两个节中:
-
COMMON
:未初始化的全局变量 -
.bss
:未初始化的静态变量,以及初始化为0的全局或者静态变量
符号解析
链接器解析符号引用的方式是将每个引用都和它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。对全局符号的符号解析很棘手。
链接器如何处理多重定义的全局符号
在编译时,编译器想链接器输出每个全局符号,或者是强的或者是弱的。函数和已初始化的全局变量是强的,未初始化的全局变量是弱的。然后,链接器将按照如下的强弱规则来处理多重定义的符号名:
- 不允许有多个同名的强符号
- 如果有一个强符号和多个弱符号重名,那么选择强符号
- 如果有多个弱符号重名,那么选择这其中任意一个
关于全局变量
如果能避免使用全局变量,一定要避免。
如果必须要使用全局变量:
- 使用
static
标记,限制其只能在模块内使用 - 在申明全局变量时初始化变量
- 使用
extern
标记引用的外部全局变量
打包常用的函数
一些像数学、输入输出、内存管理和字符串操作之内的函数是在大多数程序中都会涉及,为了避免重复的实现这些函数,应该将这些函数打包成一个“库”。
过时的解决方式——静态库
将数个可重定位文件连接成一个存档文件(archive files),让链接器遇到没有解决的外部符号时就在多个存档文件中寻找定义,如果存档文件中含有这个符号的定义,就把这个文件链接进可执行文件中。
在C语言编程中通常使用的静态库有:
-
libc.a
C语言标准库,大约4.6M大小,包含1496个对象,含有输入输出、内存管理、信号处理、字符串处理、时间和日期等方面的函数。 -
libm.a
C语言数学库,大约2M大小,含有444个对象,主要是一些浮点运算函数。
在链接器处理的时候,链接器会按照命令行上给出的顺序依次扫描可重定位文件和存档文件,链接器会维护一张当前未找到定义符号的列表,每扫描到一个文件,就会尝试在这个文件中寻找定义,如果在扫描完成之后仍然没有找到定义就给出错误。因此,命令行中指定文件的顺序就变得重要起来,一个常见的技巧是在命令行的最后指定存档文件。
现代的解决方式——动态链接库
将可重定向文件动态的加载和链接进入一个可执行程序中,可以是加载时链接,也可是运行时链接。
- 动态链接可以发生在可执行文件第一次被加载和执行的时候,也就是加载时链接。这种情况在Linux上常见,C标准库
libc.so
就常常是加载时链接的。 - 动态链接也可以发生在可执行文件开始运行之后,也就是运行时链接。
位置无关代码
为了使多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源,需要库代码可以不经过链接的任意加载到内存中的任意位置。这种可以加载而无需重定位的代码被称为位置无关代码,共享库都是这种类型的代码。
PIC数据引用
为了实现对全局变量的PIC引用,编译器利用了这样一个规律:无论我们在内存中的何处加载一个目标模块,数据段和代码段之间的距离总是保持不变的,因此应带段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。于是编译器在数据段开始的地方创建了一个,称作全局偏移量表GOT
,在这张表中,每个被这个目标模块引用的全局数据目标都有一个8字节的条目。编译器还为GOT
中每个条目生成一个重定位记录,在加载时,动态链接器会重定位GOT
中每个条目,使得它包含目标的正确的绝对地址。
PIC函数调用
假设程序调用一个由共享库定义的函数,编译器没有办法预测这个函数的运行时地址,因为定义它的共享库模块可以在运行时加载到任意的地址。编译器采用一种称作“延迟绑定”的技术来处理这种情况,将过程地址的绑定推迟到第一次调用这个过程时。
使用延迟绑定的动机是对于像libc.so
这样的共享库输出的上千个函数中,一个实际的应用程序只会用到其中的少数,让动态链接器在加载的时候进行上千个其实并不需要的重定位相当的浪费资源。市容延迟绑定之后,虽然在第一次调用的过程中开销比较大,在之后的调用都只会花费一条指令和一个间接的内存引用。
延迟绑定是通过两个数据结构之间的交互实现的,这两个数据结构是上文中提到的GOT
和过程链接表PLT
。GOT
是数据段的一部分,PLT
是代码段的一部分。PLT
是一个数组,其中每个条目是16字节的代码。PLT[0]
是一个特殊条目,跳转到动态链接器中,PLT[1]
调用系统启动函数,初始化执行环境,调用main
函数并处理其的返回值,从PLT[2]
开始的条目调用用户代码调用的函数。GOT
是一个数组,其中每个条目是8字节的地址。在和PLT
联合使用的时候,GOT[0]
和GOT[1]
包含动态链接器在解析函数地址会使用的信息,GOT[2]
是动态链接器在ld-linux.so
模块中的入口点,其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。GOT
中的每个条目都有一个相匹配的PLT
条目。
下面两张图展示了第一次调用全局函数和后续调用该函数时的控制流:
在第一次调用时,控制流会给到动态链接器以获取函数的实际地址,这里不同的程序之间通过栈来传递信息。
![Screenshot from 2022-12-23 10-33-41](./assets/Screenshot from 2022-12-23 10-33-41-20221223103401-lmr5a5m.png)
在后续的调用时,已经获得了函数的实际地址了。