对于每一位 C 开发者尤其是正在构建底层系统如数据库、搜索引擎、高性能服务器的工程师而言透彻理解从源代码到可执行文件再到运行进程的完整生命周期不仅是面试的重点更是排查复杂 Bug 的必备内功。C 编译的四个核心阶段为预处理、编译、汇编、链接。预处理Preprocessing处理宏和头文件。编译Compilation将 C 代码翻译成汇编语言。汇编Assembling将汇编语言翻译成机器码生成目标文件。链接Linking合并多个目标文件及库生成最终的可执行文件。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、优化中间代码的一个优化过程包括常量折叠编译期算出123、死代码消除、循环展开等。源代码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。它是 ELFExecutable 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 (在文件中的位置)InfoTypeSymbol Name0x000000000000001a...R_X86_64_PC32printf0x0000000000000020...R_X86_64_32global_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顺序就极其重要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 -lmgcc编译时链接器在指定的路径/lib,/usr/lib找不到这个.so文件。运行时错误error while loading shared libraries执行./app时编译时找到了.so但运行时动态链接器在LD_LIBRARY_PATH和/etc/ld.so.cache里找不到这个.so。关键认知链接时的链接器ld和运行时的动态链接器ld-linux.so是两个独立程序它们查找库的路径规则完全不同。总结编译 main.c │ ├── 链接 .o 文件 │ └── 结果a.out 包含完整代码地址固定静态链接 │ ├── 链接 .a 静态库 │ └── 结果a.out 提取 .a 中需要的 .o物理复制进去静态链接 │ └── 链接 .so 动态库 └── 结果a.out 只包含 引用标记 GOT/PLT 表 └── 运行时由 ld-linux.so 将 .so 映射内存并填充 GOT 表动态链接