程序的链接
在上一章 程序的转换与表示 中,我们已经了解了程序从源代码到机器指令的转换过程,现在我们详细介绍其中的最后一步——链接。链接的任务,是把所有的机器码模块、库函数以及全局变量组合成一个完整的、可以被操作系统加载和执行的可执行文件。
基本概念¶
链接的必要性¶
现代程序通常采用「模块化开发」,即:将程序按功能拆分成多个 .c
文件,各自编译为独立的目标文件。这样做的好处包括:
- 提升可维护性:修改某个功能模块后,只需重新编译对应文件;
- 提高开发效率:多人可并行开发;
- 增强代码复用性:公共模块可被多个项目共用。
但模块化也带来了新问题:不同模块间的符号如何关联?例如,一个模块中定义了变量 int counter;
,另一个模块中使用了 extern int counter;
。编译器在编译每个模块时,只能识别本文件的符号定义。它并不知道外部符号在哪定义,因此必须留下「未解决的引用」,等到链接阶段再统一处理。
目标文件与 ELF 格式¶
在 UNIX/Linux 系统中,汇编得到的目标文件与链接得到的可执行文件的统一格式是 ELF (Executable and Linkable Format)。它是一种结构化的二进制容器,内部由若干段 (Section) 构成,常见段如下:
段名 | 含义 |
---|---|
.text |
存放机器指令(代码段) |
.data |
存放已初始化的全局变量 |
.bss |
存放未初始化的全局变量 |
.rodata |
存放常量字符串和只读数据 |
.symtab |
符号表,记录符号的定义与引用 |
.rel.text 、.rel.data |
重定位信息,说明哪些位置需要调整地址 |
链接器通过读取这些段的信息来进行符号解析和重定位。
链接过程¶
链接操作由链接器 (Linker) 完成,主要有两件事:
- 符号解析 (Symbol Resolution)。解决「某个名字对应哪段内存」的问题;
- 重定位 (Relocation)。解决「这段机器码中引用的地址该改成多少」的问题。
链接过程的总体流程如下:
符号解析¶
符号解析过程。符号解析是链接的第一阶段。每个目标文件都有一个符号表,记录了该文件定义和引用的所有符号。链接器扫描所有 .o
文件,建立「符号与地址」的映射表,从而将每个引用符号对应到唯一的定义位置。
符号的分类与强弱。C 语言中,符号按照位置可分为以下三类:
- 本地符号 (Local Symbol):仅在本文件可见,如
static
函数或变量; - 全局符号 (Global Symbol):在本文件定义,可被其他文件引用;
- 外部符号 (External Symbol):在本文件引用,但定义在别的文件中。
同时,C 语言还存在「符号强弱」之分:
- 强符号:函数定义、已初始化的全局变量。例如:
int counter = 1;
; - 弱符号:未初始化的全局变量或
extern
声明。例如:int counter;
或extern int counter;
。
链接器在解析符号时,有如下规则:
- 不能存在多个强符号同名;
- 若同时存在强符号与弱符号,采用强符号;
- 若全为弱符号,任选其一(通常按出现顺序)。
这套规则确保了程序中每个符号最终都能有且仅有一个实体定义。
重定位¶
符号解析完成后,链接器知道了每个符号的最终地址。接着,它需要将各个 .o
文件中代码和数据段的地址从「相对偏移」调整为「绝对地址」,这一步称为重定位。
重定位信息结构。在 ELF 文件的 .rel.text
或 .rel.data
段中,记录了哪些位置需要调整,通常包含以下信息:
- 偏移量:在段内需要修改的字节位置;
- 符号:要绑定的目标符号;
- 类型:重定位方式。
重定位的两种方式。以 x86 为例:
-
绝对重定位
R_386_32
。将目标地址直接写入指令或数据中。适用于访问全局变量的情况; -
相对重定位
R_386_PC32
。按当前位置计算目标的偏移量。常见于call
、jmp
指令。
链接示例¶
给定 main.c
文件:
给定 foo.c
文件:
编译与链接:
运行结果:
分析过程:
main.o
定义了符号counter
、main
,引用foo
;foo.o
定义了符号foo
,引用counter
;- 链接器解析后为引用找到定义,并进行重定位;
- 最终生成一个可执行文件
app
。
静态链接¶
静态库¶
静态库 .a
是若干目标文件的集合,本质上就是把一堆 .o
文件打包成一个归档文件。它通过 ar
工具创建,例如:
静态链接过程¶
当我们在链接时指定静态库:
链接器会执行以下逻辑:
- 扫描主程序中未解析的符号;
- 在库文件中查找对应的定义;
- 找到后,将相应的
.o
文件解包并合并进最终可执行文件; - 若库中未使用到的模块则被跳过。
静态库按需加载,这也是为什么库文件必须写在命令行的「引用者之后」的原因。例如 gcc main.o -lmylib
正确,而 gcc -lmylib main.o
可能失败。
静态链接示例¶
假设我们要构建一个小型数学库,提供加减法函数,然后在主程序中调用它。
编写 add.c
文件:
编写 sub.c
文件:
编写主程序 main.c
:
将源文件分别编译为 .o
可重定位目标文件:
此时目录下包含 add.o
、sub.o
和 main.o
三个文件。每个 .o
文件都包含各自的符号表,add.o
定义 add
,sub.o
定义 sub
,main.o
引用 add
、sub
。
将 add.o
和 sub.o
打包为一个静态库:
libmath.a
实际上是一个归档文件 (Archive),内部类似于 ZIP,只是存放 .o
文件,并带有符号索引表,方便链接器查找。
可以用 ar t libmath.a
查看内容,输出:
使用 gcc
将主程序与静态库链接:
-L.
告诉链接器在当前目录查找库,-lmath
表示链接 libmath.a
。
链接器的工作过程是:
- 扫描
main.o
的未解析符号:发现add
和sub
; - 打开
libmath.a
,从索引表中找到定义这些符号的目标文件; - 解包相应的
add.o
与sub.o
,合并到最终的输出; - 执行符号解析与地址重定位,生成
app
。
未被引用的对象文件不会被加入,因此静态库体积较大但最终可执行文件只包含所需部分。
运行结果:
用 ldd
查看依赖:
输出中不会出现 libmath.a
或任何自定义库,因为静态链接已经把库内容复制进了可执行文件中。若换成动态库 .so
,则 ldd
会显示该库的路径。
动态链接¶
静态链接虽然简单高效,但存在以下问题:
- 磁盘浪费:多个可执行文件若都静态链接相同库(如
libc
),会产生大量重复拷贝; - 内存浪费:多个程序同时运行时,各自都有一份库代码副本;
- 维护困难:库文件更新后,所有依赖程序都需重新链接。
为了解决这些问题,动态链接出现了,它让库文件在程序运行时按需加载。
动态库¶
动态链接将库打包为共享对象 (Shared Object),通常扩展名为 .so
。
动态链接过程¶
链接器在生成可执行文件时,并不复制库的代码,而是:
- 在可执行文件中记录对共享库的「引用信息」;
- 由操作系统在「程序加载时」将共享库映射到内存;
- 所有引用同一库的程序共享同一份库代码。
动态链接示例¶
给定 foo.c
文件:
编译生成共享库:
主程序:
编译链接:
运行:
输出:
本章小结¶
链接是程序世界的最后拼图,本质上这个过程就做了一件事:完成了从「符号名」到「内存地址」的映射。
我们重点介绍了静态链接和动态链接,前者注重独立完整,后者追求共享与灵活。这两种链接方式的对比如下表所示:
特性 | 静态链接 | 动态链接 |
---|---|---|
链接时机 | 编译期完成 | 程序加载或运行时 |
文件体积 | 较大(包含库代码) | 较小(仅保存引用信息) |
内存占用 | 每个进程独立一份 | 多个进程共享 |
更新维护 | 需重新链接 | 更新库文件即可 |
启动速度 | 略快 | 需加载库文件略慢 |
典型应用 | 嵌入式系统、单文件发布 | 桌面程序、系统库 |