编译与链接基础:预处理、编译、汇编、链接四阶段详解

各位同学,今天我们来聊聊编译与链接的底层逻辑。说实话,很多C/C++开发者写了几年代码,对IDE按一下F5就完事了,根本不知道中间发生了什么。我个人觉得,不理解这四个阶段,你写出来的库就像在沙滩上盖楼——看着挺高,风一吹就倒。

我在项目中遇到过好几次这样的问题:明明代码逻辑没问题,链接时却报"undefined reference"。新人一脸懵,老手一看就知道是符号没导出。嗯,这就是我们今天要讲的内容。

一、四阶段总览

一个源文件从你写完到变成可执行文件,中间经历了四个阶段。我习惯用一张图来理解:

编译与链接四阶段流程图 预处理 .c → .i 宏展开、头文件包含 编译 .i → .s 语法分析、生成汇编 汇编 .s → .o 生成机器码、目标文件 链接 .o + .a/.so → 可执行文件 符号解析、重定位、地址绑定 源文件 预处理后文件 汇编文件 目标文件 可执行文件 / 库文件

说白了,这四步就是把你写的"人类语言"一步步翻译成"机器语言"。每一步都有它存在的意义,少一步都不行。

二、预处理阶段

预处理是第一步。它处理的是以 # 开头的指令。你想想看,#include#define#ifdef 这些,都是预处理器的活。

核心工作:

  • 头文件展开:把 #include 的内容直接复制进来
  • 宏替换:#define MAX 100 会把所有 MAX 替换成 100
  • 条件编译:#ifdef DEBUG 决定哪些代码保留
  • 删除注释:把 // 和 /* */ 全部去掉

我记得有一次,一个同事在头文件里写了 #define TRUE 1,结果另一个头文件也定义了 #define TRUE 0。两个头文件一包含,整个项目逻辑全反了。嗯,这就是预处理阶段容易踩的坑。

小技巧:gcc -E main.c -o main.i 可以查看预处理后的文件。我经常用这招来排查宏定义的问题。

三、编译阶段

编译阶段才是真正的"翻译"。它把预处理后的 .i 文件转换成汇编代码 .s 文件。这个阶段做的事情可多了:

  • 词法分析:把代码拆成一个个 token
  • 语法分析:检查语法是否正确,生成语法树
  • 语义分析:检查类型是否匹配、变量是否声明
  • 中间代码生成:生成一种中间表示
  • 优化:比如常量折叠、死代码消除
  • 目标代码生成:输出汇编代码

这里有个很多人不知道的点:编译阶段只处理单个源文件。它不知道其他文件里有什么。所以如果你在 a.c 里调用了 b.c 里的函数,编译 a.c 时编译器只知道"这里有个函数调用",但不知道这个函数具体在哪。

注意:编译阶段不会检查函数是否存在。它只检查语法和类型。函数是否存在,那是链接阶段的事。我曾经见过有人把函数名拼错了,编译通过,链接报错,找了半天才发现是拼写问题。

四、汇编阶段

汇编阶段就简单多了。它把汇编代码 .s 转换成机器码,生成目标文件 .o(Linux)或 .obj(Windows)。

目标文件里有什么?我列一下:

段(Section) 内容 说明
.text 代码段 存放可执行的机器指令
.data 数据段 存放已初始化的全局变量和静态变量
.bss 未初始化数据段 存放未初始化的全局变量和静态变量
.rodata 只读数据段 存放字符串常量、const 变量
.symtab 符号表 记录所有符号(函数名、变量名)的信息
.rel.text 代码重定位表 记录需要重定位的位置
.rel.data 数据重定位表 记录数据段中需要重定位的位置

你可以用 objdump -h main.o 查看目标文件的段信息。我习惯用 readelf -a main.o 看更详细的内容。

五、符号表与重定位

这两个概念是链接的核心。说白了,符号表就是"谁定义了什么东西"的清单,重定位就是"把空缺的地址填上"的过程。

5.1 符号表

每个目标文件都有一张符号表。它记录了:

  • 全局符号:在当前文件中定义,可以被其他文件引用
  • 外部符号:在当前文件中引用,但在其他文件中定义
  • 局部符号:只在当前文件中可见(比如 static 函数)

举个例子:

// file1.c
int global_var = 42;          // 全局符号
static int local_var = 10;    // 局部符号
void func1() {                // 全局符号
    // ...
}

// file2.c
extern int global_var;        // 外部符号
void func1();                 // 外部符号
void func2() {
    global_var = 100;
    func1();
}

nm file1.o 可以看到符号表:

0000000000000000 D global_var
0000000000000014 d local_var
0000000000000000 T func1

大写字母表示符号类型:T 是代码段中的全局符号,D 是数据段中的全局符号,d 是数据段中的局部符号。

关键点:链接器在链接时,会检查所有目标文件的符号表。如果某个外部符号在所有目标文件中都找不到定义,就会报"undefined reference"错误。反过来,如果同一个符号被定义了多次(比如两个文件都定义了同名的全局函数),就会报"multiple definition"错误。

5.2 重定位

重定位是链接器最核心的工作。为什么需要重定位?因为编译和汇编阶段,编译器不知道变量和函数最终会放在内存的哪个地址。

举个例子:

// main.c
extern int global_var;
void func();

int main() {
    global_var = 42;
    func();
    return 0;
}

编译成 main.o 后,global_varfunc 的地址都是临时的(通常是 0)。链接器在链接时,需要:

  1. 确定每个目标文件在最终可执行文件中的位置
  2. 计算每个符号的最终地址
  3. 把所有引用这些符号的地方,填上正确的地址

objdump -r main.o 可以查看重定位表:

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000002 R_X86_64_PC32     global_var
000000000000000d R_X86_64_PC32     func

每一行都告诉链接器:在 .text 段的某个偏移位置,有一个地址需要修正。修正的方式由重定位类型决定(比如 R_X86_64_PC32 表示相对寻址)。

实战经验:我调试链接错误时,最常用的命令是 nmobjdump -r。先看符号有没有定义,再看重定位表有没有异常。80%的链接问题都能用这两个命令定位。

六、链接阶段

链接阶段把多个目标文件和库文件合并成一个可执行文件。它做两件事:

  • 符号解析:把每个符号引用和对应的符号定义关联起来
  • 重定位:把所有符号引用替换成实际地址

链接分为两种:

类型 特点 生成文件
静态链接 把库的代码直接复制到可执行文件中 .a(静态库)
动态链接 运行时才加载库的代码 .so(共享库)

静态链接生成的文件大,但部署简单。动态链接生成的文件小,但需要运行时环境支持。我个人的建议是:工具类的小程序用静态链接,大型应用用动态链接。

避坑指南:我曾经在项目里同时链接了两个版本的同一个静态库,结果符号冲突,程序崩溃。后来我养成了习惯:链接前先用 nm 检查库里的符号,确保没有重复定义。

七、总结

好了,今天的内容就到这里。我们走了一遍从源文件到可执行文件的完整流程:

  • 预处理:处理 # 指令,生成 .i 文件
  • 编译:语法分析,生成 .s 汇编文件
  • 汇编:生成机器码,输出 .o 目标文件
  • 链接:符号解析 + 重定位,生成可执行文件

理解这些底层原理,对你后面学习静态库和共享库的制作至关重要。尤其是符号表和重定位,它们是链接器的核心。你想想看,如果你连符号表都看不懂,遇到链接错误就只能瞎猜了。

下一章我们会深入静态库的制作和使用。到时候你会看到,今天讲的这些知识全都能用上。


公众号:蓝海资料掘金营,微信deep3321