1. 概述
链接是将一系列目标文件合并为一个单一文件的过程,该文件可以被加载到内存中执行。链接的核心任务是符号解析和重定位:
- 符号解析:将目标文件中的每个全局符号引用绑定到一个唯一的符号定义上。
- 重定位:确定每个符号和指令的最终内存地址,并修改指令中对这些符号的引用。
根据链接发生的时间,链接主要分为静态链接(构建期)和动态链接(加载期或运行期)。
链接器的处理对象是目标文件,其格式通常为 ELF(Executable and Linkable Format)。目标文件主要分为三种形式:
- 可重定位目标文件:编译器输出,包含二进制代码和数据,尚未链接。
- 可执行目标文件:链接器输出,可直接运行。
- 共享目标文件:动态库,可在加载或运行时被链接。
2. 基础构建块:ELF 格式深度解析
理解链接机制首先需要理解目标文件的结构。ELF 格式提供了两种视角:链接视角(节 Section) 和 运行视角(段 Segment)。
2.1 关键节
.text:已编译程序的机器代码。.rodata:只读数据(如printf的格式化字符串、跳转表)。.data:已初始化的全局变量和静态变量。.bss:未初始化或初始化为 0 的全局变量和静态变量。不占用磁盘空间,仅在运行时占位。.symtab:符号表,记录程序中定义和引用的函数和全局变量信息(位置、大小、类型)。.rel.text/.rel.data:重定位条目,保存了代码段和数据段中需要修正的引用位置信息。
2.2 ELF 头
包含魔数(7f 45 4c 46)、字长(32/64位)、字节序、节头表偏移量等元信息。
3. 静态链接
静态链接由静态链接器(ld)在构建期完成,其核心逻辑是合并与修正。
3.1 符号解析
链接器从左到右扫描输入文件(.o 和 .a),维护“已定义符号集合”和“未定义符号集合”。
-
强符号与弱符号规则:
- 强符号:函数定义、已初始化的全局变量。
- 弱符号:未初始化的全局变量。
- 处理原则:不允许有多个同名的强符号;若有一个强符号和多个弱符号,选强符号;若有多个弱符号,选占用空间最大的。
- 库的链接顺序:如果库引用了某个符号,该符号的定义必须出现在引用它的文件或库之后。否则会报
undefined reference错误。
3.2 重定位
符号解析完成后,链接器将相同属性的节合并(例如所有 .text 合并为一个大的 .text),并为每个符号分配唯一的运行时虚拟地址。
-
重定位条目处理:链接器遍历
.rel.text等节,根据指令类型进行补丁:R_X86_64_PC32(相对地址重定位):计算公式为S + A - P(目标地址 + 附加数 - 当前 PC 值)。R_X86_64_32/64(绝对地址重定位):直接将符号的虚拟地址填入指令中。
4. 动态链接原理
静态链接将代码“拷贝”进可执行文件,而动态链接则将链接过程推迟到程序加载时或运行时。
4.1 两种动态链接模式
| 模式 | 发生时机 | 机制 | 典型应用 |
|---|---|---|---|
| 加载时链接 | 程序启动时(main 执行前) |
操作系统加载器根据可执行文件中的 .dynamic 段,将所需的 .so 映射到内存并进行符号解析。 |
大多数常规 Linux 程序依赖 libc.so。 |
| 运行时链接 | 程序运行过程中 | 程序通过 API 主动加载库,获取符号地址,进行调用。 | 插件系统、浏览器插件、动态配置算法。 |
-
运行时链接 API:
dlopen():加载共享库。dlsym():查找符号地址。dlclose():卸载库。
4.2 关键疑问:为什么 .so 需要作为 ld 的输入?
这是理解动态链接构建流程的关键点。
对于加载时链接: 虽然动态库(.so)的代码和数据不会像静态库那样被复制到可执行文件中,但编译命令中仍然需要指定 .so(或通过 -l 指定)。
- 原因:静态链接器
ld需要在构建期进行符号决议。判断用到的符号是否存在定义?是否存在多个定义?是来自静态库还是动态库?如果没有发现定义,ld会报undefined reference。如果发现多个强符号定义,ld会报multiple definition。如果来自静态库,那么需要将对应模块合并到输出文件中,完成符号解析和重定位;如果是动态库,那么仅进行记录,链接过程推迟到加载时进行。
对于运行时链接: 使用 dlopen 加载的库,不需要作为 ld 的输入。
- 原因:源代码中没有直接引用库中的符号,而是通过函数指针间接调用。静态链接器看不到这些引用,自然也不会去检查它们。这种灵活性也意味着直到
dlopen运行时,程序才能发现库是否存在或符号是否正确。在使用dlopen将动态库手动加载到内存中后,又使用 dlsym 手动获取符号的内存地址,放入变量中,实际调用的时候是通过间接跳转进行调用的。如果dlopen或dlsym失败,都会返回NULL。PS:在Windows系统下,使用LoadLibrary和GetProcAddress进行运行时链接的过程是类似的。
5. 加载器与动态链接器的分工
当用户在 Shell 运行程序时,内核的加载器开始工作,但随后的细节分工如下:
- 内核加载器:读取 ELF 头,将可执行文件映射到内存(特别是加载解释器
/lib64/ld-linux.so),并将控制权转交给动态链接器。 -
动态链接器 (
ld-linux.so):- 这是一个特殊的共享对象,它是自举的。
- 映射依赖库:它通过系统调用
mmap将程序依赖的其他.so文件映射到用户态虚拟内存。 - 符号重定位:读取动态符号表和重定位表,修改程序内存中的 GOT(全局偏移表),填入符号的真实运行时地址。
- 初始化:调用各
.so的初始化函数(.init段)。 - 移交控制:最后将控制权转交给程序的
main函数。
6. 位置无关代码 (PIC) 与 动态链接优化
动态链接的核心目标是让多个进程共享同一份物理内存中的库代码。为了实现这一点,必须保证代码段能在不同的虚拟地址空间中运行,这就是位置无关代码。
6.1 核心思想:分离变化与不变
- 代码段:是只读的,可以被多个进程共享。
- 数据段:是进程私有的,包含需要重定位的绝对地址。
PIC 的核心是将“会变的地址引用”从代码段剥离,存放到数据段中。代码段通过相对寻址来访问数据段。
6.2 数据结构:GOT 和 PLT
-
GOT (Global Offset Table):
- 位于数据段。
- 存放外部全局变量和函数的运行时真实地址。
- 代码段不直接引用函数地址,而是引用 GOT 表中对应的条目。
-
PLT (Procedure Linkage Table):
-
位于代码段(通常
.text附近)。 - 每个外部函数对应一个 PLT 条目(一段小存根代码)。
- 作用:实现延迟绑定,提升程序启动速度。
6.3 延迟绑定流程
当程序第一次调用某个库函数时(如 printf):
- 程序跳转到对应的
PLT条目。 - PLT 条目跳转到对应的
GOT条目。 - 此时 GOT 中还没有真实地址,它指向 PLT 中的第二条指令(即“回跳”指令),并触发动态链接器去解析
printf的真实地址。 - 动态链接器计算出地址后,将其填入 GOT。
- 控制流转回
printf执行。
当程序第二次调用 printf 时:
- 跳转到 PLT。
- PLT 跳转到 GOT。
- 此时 GOT 已保存了真实地址,直接跳转执行,不再经过动态链接器。
这种机制极大地加速了动态链接程序的启动过程,因为只有被实际调用的函数才会进行解析。
