对于每一位 C++ 开发者,尤其是正在构建底层系统(如数据库、搜索引擎、高性能服务器)的工程师而言,透彻理解从源代码到可执行文件,再到运行进程的完整生命周期,不仅是面试的重点,更是排查复杂 Bug 的必备内功。C++ 编译的四个核心阶段为:预处理、编译、汇编、链接。
-
预处理(Preprocessing):处理宏和头文件。
-
编译(Compilation):将 C++ 代码翻译成汇编语言。
-
汇编(Assembling):将汇编语言翻译成机器码,生成目标文件。
-
链接(Linking):合并多个目标文件及库,生成最终的可执行文件。
cpp
g++ -E main.cpp -o main.i # 预处理
g++ -S main.i -o main.s # 编译
g++ -c main.s -o main.o # 汇编
g++ main.o -o main # 链接
第一阶段:预处理
1、展开所有的头文件,将#include <iostream>等头文件的内容原封不动地复制粘贴到当前位置。
2、宏替换,处理 #define MAX(a, b) ((a) > (b) ? (a) : (b)) 等宏定义的字符串替换,宏是单纯的字符串替换,不做类型检查。
3、条件编译,根据 #ifdef、#ifndef、#endif来决定哪些代码块参与后续的编译,条件之外的代码和预编译指令会被裁剪掉。这在跨平台开发和调试中极为常见。
4、将代码中的 //和 /* */注释代码删除,减轻后续编译环节的负担。
预处理后的产物是 .i文件。它仍然是 C++ 源代码,但已经是一个"完全体"。如果在这一阶段报错(如 fatal error: iostream: No such file or directory),通常意味着头文件路径配置错误(GCC 的 include 搜索路径问题)。
第二阶段:编译
把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
1、词法分析:利用类似于"有限状态机"的算法,将源代码程序输入到扫描器中,扫描器将其中的字符序列分割成一系列的记号。例如,将 int a = b + 10;拆解为 int、a、=、b、+、10、;。
2、语法分析:语法分析器对扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。如果代码不符合 C++ 语法规范(如少写了分号),编译器会在此阶段抛出 syntax error。
3、语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,这是 C++ 最复杂的地方。编译器需要检查变量是否声明、类型是否匹配、作用域是否正确。
4、优化:中间代码的一个优化过程,包括常量折叠(编译期算出 1+2=3)、死代码消除、循环展开等。源代码(C/C++)首先会被转换成一个与具体CPU架构无关的中间表示(如 GCC 的 GIMPLE 或 LLVM 的 IR)。
5、目标代码生成:由代码生成器将中间代码转换成汇编代码。
This stage generates an assembly file with the extension .s, containing the corresponding assembly instructions.
第三阶段:汇编
这一阶段汇编器(as)将可读的汇编指令(.s)翻译为机器可执行的(不可读)二进制指令(.o)。生成的 .o文件被称为 可重定位目标文件(Relocatable Object File)。它是 ELF(Executable and Linkable Format)格式的一种。虽然里面已经有了二进制的机器码,但它还不能直接运行。原因有二:
-
地址未定 :.o 文件里面的代码和数据,还没有分配最终的虚拟内存地址,比如 0x400000,它们的地址都是从 0 或相对偏移开始的 。假设我们在
main.o中调用了printf函数,但在编译main.o时,编译器并不知道printf在内存中的具体地址。因此,在.o文件中,调用printf的那条指令会留下一个重定位项。 -
外部依赖未解 :如果
main.o引用了另一个utils.o中的函数,汇编器同样不知道那个函数的地址。
汇编阶段产生的 符号表(Symbol Table) 记录了这些信息:哪些是全局符号(供外界调用),哪些是未定义符号(等待外界提供)。
为什么叫"可重定位"(Relocatable)?
"重定位"就是指 ".o 文件里面的代码和数据,还没有分配最终的虚拟内存地址(比如 0x400000),它们的地址都是从 0 或相对偏移开始的,随时可以被'重新定位'到任何地址。"它是ELF二进制文件类型的一种,具备三个特性,让它区别于最终的可执行文件:
| 特性 | 可重定位文件 (.o) | 可执行文件 (ELF) |
|---|---|---|
| 地址状态 | 地址从 0 开始 (或相对偏移)。例如 main 函数的地址在 .o 里是 0x0。 |
拥有绝对虚拟地址 。例如 main 函数的地址被固定为 0x400560。 |
| 外部依赖 | 有很多"未解决"的符号 。比如你调用了 printf,.o 文件里只写了个"我需要 printf",但不知道它在哪。 |
所有符号都已绑定 。要么找到了 printf 在 libc.so 里的地址,要么记录了动态链接的路径。 |
| 能否独立运行 | 绝对不能。因为地址全是从 0 开始的,加载进内存会覆盖内核或系统区域,操作系统会拒绝执行。 | 可以直接运行。内核根据文件头把代码映射到指定的绝对地址(如果是 PIE,则随机映射)。 |
.o 文件靠什么实现"可重定位"?
秘密在于 .o 文件中除了代码和数据,还附带了一张"重定位表"(Relocation Table)。可以用 readelf -r main.o 看到这张表,内容大概像这样:
| Offset (在文件中的位置) | Info | Type | Symbol Name |
|---|---|---|---|
| 0x000000000000001a | ... | R_X86_64_PC32 | printf |
| 0x0000000000000020 | ... | R_X86_64_32 | global_var |
这张表的作用相当于"施工图纸上的标注":
-
它告诉链接器:"在文件的 0x1a 偏移处,有一条指令调用了
printf,但我不知道它的地址,你(链接器)把printf的真实地址算出来,填到这里",填了相对地址,主要用于函数调用。 -
它告诉链接器:"在文件的 0x20 偏移处,用到了
global_var,等我最终确定把global_var放在内存的哪个位置后,你把这个地址填回来",填了绝对地址,不依赖指令位置,是直接、静态的地址,通常用于全局变量的地址访问。
重定位表就是".o 文件的核心灵魂"。没有它,链接器就无法把多个 .o 拼成一个完整的程序。
第四阶段:链接
链接器(ld)的任务就是将多个 .o文件和所需的库文件(.a或 .so)粘合在一起,修补所有未定义的符号,生成最终的可执行文件。链接过程主要包含两个核心动作:
1. 符号解析(Symbol Resolution)
链接器会扫描所有输入的目标文件,收集所有的全局符号,把所有 .o 文件的 .text(代码)和 .data(数据)按顺序拼成一个大文件,链接器给这个大文件分配虚拟地址(比如 add 函数最终被安排在 0x400100)。当在一个文件中发现未定义符号时,它会在其他文件中寻找该符号的定义。如果在所有文件中都找不到,就会报出经典的 **undefined reference to 'xxx'** 错误。
2. 重定位(Relocation)
链接器拿着 合并后的大文件的重定位表,找到未定义符号 的真实地址,把真实地址填进去合并成一个可执行文件,所有指令/变量都有了 绝对/相对 的运行时地址。
静态库链接
一个值得注意的"例外":归档文件(.a 静态库)
链接器对 .o 文件是**"全盘接收"** ------只要你把它写在命令行里,它就会全部合并进最终可执行文件。对 .a 静态库链接器会采用"懒惰提取"(Lazy Extraction)策略。它只会从 .a 库中提取那些能解决当前未定义符号的 .o 成员,而不会把整个库全部合并。
静态库的链接顺序
链接器按顺序扫描文件,如果 main.o 引用了 add,但你在命令行把 utils.o 放在了 main.o 前面或者 utils.o 放在了 main.o 后面都没有问题,因为.o文件是全部合并,但如果是静态库(.a),顺序就极其重要:
cpp
gcc main.o -lmath -o app # 正确:扫描 main.o 时发现缺 sin,再从 libmath.a 里提取
gcc -lmath main.o -o app # 可能报错:扫描 libmath.a 时未定义符号还是空的,所以什么都没提取;等扫描 main.o 发现缺 sin 时,已经错过了 libmath.a
动态库链接
当链接器处理动态库(.so 文件)时,它并不是像处理 .o 和.a文件那样把代码和数据"拼"进来,而是采用了一套完全不同的"延迟绑定"策略。执行 gcc main.c -lm -o app 时,链接器做的工作很简单:
链接时
链接动态库时先进行符号解析**,** 检查 main.o 里调用了 sin,然后去 libm.so 里确认确实有这个函数。在可执行文件中生成导入表,在最终的可执行文件的 .dynamic 段 和 .got.plt 表 中记录一条信息:"我依赖 libm.so,我需要 sin 函数",可执行文件中的 sin() 调用,替换成一条跳转指令,跳到一个由动态链接器(ld-linux.so)提供的固定入口(zhuangdaima定入口(桩代码)。
运行时
当你执行 ./app 时,内核把控制权交给动态链接器(ld-linux.so.x86-64),由它来做真正的"重定位":动态链接器读取可执行文件中的依赖列表,找到 libm.so,并用 mmap 将其映射到进程的虚拟地址空间 (注意:是映射,不是拷贝)。动态链接器查出 sin 函数在内存中的实际虚拟地址(比如 0x7f123456),然后把这个地址写进之前预留的 .got.plt 表 中。从此以后,程序再调用 sin,就会通过 .got.plt 表跳转到真正的 sin 函数地址。
动态链接经典的性能优化:延迟绑定(Lazy Binding)
如果程序里有 1000 个库函数,启动时全部要查地址并填表,启动速度会变慢。为了解决这个问题,Linux 默认开启了延迟绑定 机制。默认情况下sin 函数地址在 ./app 启动时并不会立刻被填入 .got.plt。当 app第一次执行到 sin() 时,CPU 会跳转到 .got.plt,此时里面存的还是桩代码 。动态链接器趁机查表,把 sin 的真实地址填进去,下次再执行 sin(),直接命中已经填好的地址,不再经过动态链接器。
程序启动时快,运行过程中用到哪个库函数,才去解析哪个**。** 这是空间(启动时间)和时间(首次调用开销)的经典权衡。
编译时"找不到 .so" 和 运行时"找不到 .so" 的区别
| 错误类型 | 发生阶段 | 原因 |
|---|---|---|
编译链接错误 (cannot find -lm) |
gcc 编译时 |
链接器在指定的路径(/lib, /usr/lib)找不到这个 .so 文件。 |
运行时错误 (error while loading shared libraries) |
执行 ./app 时 |
编译时找到了 .so,但运行时 动态链接器在 LD_LIBRARY_PATH 和 /etc/ld.so.cache 里找不到这个 .so。 |
关键认知 :链接时的链接器(ld)和运行时的动态链接器(ld-linux.so)是两个独立程序,它们查找库的路径规则完全不同。
总结
cpp
编译 main.c
│
├── 链接 .o 文件
│ └── 结果:a.out 包含完整代码,地址固定(静态链接)
│
├── 链接 .a 静态库
│ └── 结果:a.out 提取 .a 中需要的 .o,物理复制进去(静态链接)
│
└── 链接 .so 动态库
└── 结果:a.out 只包含 "引用标记" + "GOT/PLT 表"
└── 运行时由 ld-linux.so 将 .so 映射内存,并填充 GOT 表(动态链接)