为什么你会觉得学链接没用?教育阶段你确实会觉得没必要学,只要知道编译的过程在干嘛就行了,真的上课给你讲了也是一笔带过,不少人自己用 VS 写个程序只懂得摁个运行键,treat compiler as a black magic box 然后等他自己把 .exe 生出来。但是企业里维护的大项目会接入一堆第三方库,必然会产生一些可以避免或者很难避免的问题,反正等你痛苦得不知道为什么的时候你就该学了...
编译总过程#

| 步骤 | 命令 | 等价命令 | 输出文件 |
|---|---|---|---|
| 预处理 | cpp | gcc -E | .i, .ii |
| 编译 | cc1, cc1plus | gcc -S | .s |
| 汇编 | as | gcc -c | .o, .obj |
| 链接 | ld | gcc | 可执行文件 |
- Preprocess: 源代码进行预处理,主要处理
#开头的代码,进行文本替换和修改,可以处理的行为主要如下。预处理器会展开头文件,展开宏,保留条件编译触发的部分,向编译器发送特定的配置指令,得到的文件为.i和.ii
// 头文件
#include <iostream>
// 宏
#define PI 3.14
// 条件编译
#ifdef 0
...
#endif
// 配置指令
#pragma oncecpp- Compile: 生成汇编语言文件
- Assemble: 生成二进制机器码
- Link: 将多个目标文件以及所需的库文件(.so等)链接成最终的可执行文件
每个步骤详细过程和命令参照gcc 和 g++ ↗,平常开发过程中一般直接调用编译器即可完成整个处理过程,但要定位编译的哪个过程出了问题还是有必要了解。
由一个问题来看编译成功,链接错误#
在编译构建大型项目时出现了以下问题:宏观上来看是编译通过,链接不通过。实际上项目依赖的静态库有一个处理水深的函数,在源代码中已经从 5 个更新成 6 个参数,但是编译的库是按照 5 个参数的版本编译的,链接的时候报错找不到该符号。解决这个问题很简单,直接重新编译就行了,但是该项目依赖库的编译不是我负责的内容,我也没权限对依赖库进行管理,编译命令都不知道(我也想知道为什么我有权限维护源码但不能编译,有可能我搞错了),那么解决方案就是往源码里补上 5 个参数的重载函数。缺失的参数用 static_cast<uint64_t> 0 填充,然后这事就完了...
静态链接#
静态链接可以分为符号解析和重定位两个步骤。
- 符号解析(symbol resolution):目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即 C 语言中任何以 static 属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
- 重定位(relocation):编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。
目标文件#
目标文件本质是字节块,包含程序代码,程序数据和引导链接器和加载器的数据结构等。链接器将这些字节块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。
目标文件的三种形式:可重定位目标文件 / 可执行目标文件 / 共享目标文件
可重定位目标文件#
包含二进制代码和数据,可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。

| 条目 | 描述 |
|---|---|
.text | 已编译程序的机器代码 |
.rodata | 只读数据 |
.data | 已初始化的全局和静态 C 变量(局部 C 变量在栈上) |
.bss | 未初始化的全局和静态 C 变量,以及所有被初始化为 0 的全局或静态变量。 |
.symtab | 符号表,存放在程序中定义和引用的函数和全局变量的信息 |
.rel.text | .text 中位置的列表 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 调试符号表 |
.line | C 源程序中的行号和 .text 节中机器指令之间的映射 |
.strtab | 字符串表(以 null 结尾的字符串的序列) |
详细参照CSAPP ↗
可执行目标文件#
包含二进制代码和数据,可以被直接复制到内存并执行。
共享目标文件#
一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
链接的两个核心阶段#
链接可以分为符号解析和重定位两个核心阶段。
符号解析#
符号#
每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 全局链接器符号:由模块 m 定义并能被其他模块引用的符号,对应于非静态的 C 函数和全局变量。
- 全局符号:由其他模块定义并被模块 m 引用的符号。对应于在其他模块中定义的非静态 C 函数和全局变量。
- 局部符号:只被模块 m 定义和引用,对应于带 static 属性的 C 函数和全局变量。这些符号在模块 m 中任何位置都可见,但是不能被其他模块引用。
静态变量和函数属于局部符号,也就是在内存上既不属于栈,也不属于堆,属于静态存储区,对应了目标文件的 .data 和 .bss 段,并在符号表中创建一个有唯一名字的本地链接器符号。
符号表#
符号表是一种数据结构,使用编译器输出到汇编语言 .s 文件中的符号,包含了源代码的变量名,函数名,类名等符号及其属性。
可重定位目标文件的 .symtab 里的符号表:
typedef struct {
int name; /* String table offset */
char type:4, /* Function or data (4 bits) */
binding:4; /* Local or global (4 bits) */
char reserved; /* Unused */
short section; /* Section header index */
long value; /* Section offset or absolute address */
long size; /* Object size in bytes */
} Elf64_Symbol;c解析过程#
解析过程:将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。也就是说,在符号表中找不到 / 找错(多重定义,类型冲突等)就会导致链接失败。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。
编译器只允许每个模块中每个局部符号有一个定义和一个名字。因此对于全局符号,因为容易重名冲突,全局可见,导致很难被链接器解析。
具体而言,重名符号冲突表现为项目中引入第三方库 A 和 B,但是没使用 namespace 隔离出不同命名空间,就会产生相同的全局符号,如果主程序同时依赖了 A 和 B,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。解析多重定义全局符号的过程参见 CSAPP 7.6.1 ↗,平时开发尽量避免这种情况。
重定位#
符号解析完之后进入重定位:合并输入模块,并为每个符号分配运行时地址。
重定位由两步组成:
- 重定位节和符号定义。 这一步的目的是让程序中的每条指令和全局变量都有唯一的运行时内存地址。
- 重定位节中的符号引用。 在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。这一步,链接器依赖于重定位条目。
重定位条目#
汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。到了链接阶段,这时候就需要重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
ELF 重定位条目的格式如下:
typedef struct {
long offset; /* Offset of the reference to relocate */
long type:32, /* Relocation type */
symbol:32; /* Symbol table index */
long addend; /* Constant part of relocation expression */
} Elf64_Rela;coffset 是需要被修改的引用的节偏移。symbol 标识被修改引用应该指向的符号。type 告知链接器如何修改新的引用。addend 是一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整。
重定位符号引用#
链接器在编译和链接过程中,将代码和数据中对函数或变量的符号引用修改为指向正确运行时绝对或相对地址的步骤,包括 PC 相对引用和绝对引用。
加载#
经过符号解析和重定位生成了目标文件,现在可以加载可执行目标文件了。
加载器是一套操作系统代码。对于一个程序,将它的可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。