构建工具(二):GCC 编译四阶段——你的 .c 文件到底经历了什么

1、一个看似简单的问题

每天你都在敲 gcc -o hello hello.c,回车,然后得到一个可执行文件。一切好像浑然天成。

但如果你打开那个可执行文件,会看到一堆乱码——它已经不是人能读懂的东西了。

所以 .c 文件变成 hello 的过程中,到底发生了什么?

答案比你想象的要复杂:编译器不是一步到位的,它经历了四个阶段

2、四个阶段全景图

hello.c → 预处理 → hello.i → 编译 → hello.s → 汇编 → hello.o → 链接 → hello

我打个比方:

  • 预处理:菜市场买菜,把食材备齐(展开头文件、替换宏)
  • 编译:在厨房里切菜炒菜,把食材变成一道菜(C 代码变成汇编代码)
  • 汇编:把这道菜打包进外卖盒(汇编代码变成机器码)
  • 链接:把所有外卖盒装进一个袋子里,加上筷子纸巾(多个 .o 文件和库合并成可执行文件)

下面我们一个个来看。

3、第一阶段:预处理

预处理做的事情其实很简单:处理所有以 # 开头的指令

比如你的代码里有:

#include <stdio.h>
#define PI 3.14

预处理后:

  • #include <stdio.h> 被替换成了 stdio.h 的全部内容(几千行代码)
  • PI 在所有出现的地方被替换为 3.14
  • 注释被删除

你可以亲自看一下预处理的结果:

gcc -E hello.c -o hello.i

打开 hello.i,你会震惊地发现:一个 5 行的 hello.c,预处理后变成了几百甚至上千行。因为 stdio.h 里又嵌套了别的头文件,层层展开。

这就是预处理阶段的核心任务:把散落在各处的代码片段拼成一份完整的、准备编译的代码

4、第二阶段:编译

这个阶段才真正开始干正经事:把 C 代码翻译成汇编代码

汇编是一种低级语言,每一行几乎对应一条 CPU 指令。比如 a + b 可能变成:

movl    -8(%rbp), %eax
addl    -4(%rbp), %eax

你可以查看编译的结果:

gcc -S hello.c -o hello.s

打开 hello.s,你会看到一堆 movpushcall 开头的指令。这就是你的程序在 CPU 眼里的样子。

这个阶段编译器做了很多优化——它会分析你的代码,做些等价变换,让生成的汇编代码跑得更快。比如:

  • 把循环里不变的运算提到循环外面
  • 把不用的变量直接删掉
  • 用更高效的指令序列替换手写的逻辑

5、第三阶段:汇编

汇编阶段很简单:把汇编代码翻译成机器码

机器码就是 01 组成的二进制指令了,CPU 直接能看懂。

gcc -c hello.c -o hello.o

生成 .o 文件(目标文件)了!但注意,这个 .o 文件还不能执行。如果你强行运行它,系统会报错。

为什么?因为它还缺东西——它调用了 printf,但 printf 的代码不在这个 .o 文件里。它只是留了一个记号说「这里需要 printf」。

这个记号,就是符号表中的未定义引用(undefined reference)。

6、第四阶段:链接

链接器说:你不是缺 printf 吗?来,给你补上。

链接器的任务就是:把多个 .o 文件和库文件拼接成一个完整的可执行文件

gcc -o hello hello.o

这时候 gcc 会自动把 hello.o 和标准 C 库(libc)链接在一起。printf 的代码就在 libc 里,链接器把它找出来,塞到最终的可执行文件里。

链接器还要做一件很重要的事:地址重定位。每个 .o 文件里的地址在链接前是相对的,链接器要把它们调整成最终可执行文件里的绝对地址(或可加载的虚拟地址)。

7、几个经常搞混的编译参数

理解了这四个阶段,下面这些 gcc 参数就很好记了:

  • -I /path/to/include:告诉预处理器去哪里找头文件(I = Include)
  • -L /path/to/lib:告诉链接器去哪里找库文件(L = Library path)
  • -l 库名:告诉链接器链接哪个库(-lm 就是链接 libm,数学库)
  • -D 宏名=值:相当于在代码最前面加了一行 #define,属于预处理阶段
  • -Wall:打开所有警告,属于编译阶段
  • -g:生成调试信息,让 gdb 能定位源码
  • -O2:优化级别,属于编译阶段

一个经典的困惑是:很多人搞不懂 -I-L 的区别。现在是区别它们的好时机——前者管找头文件(预处理),后者管找库文件(链接),完全不是一回事。

8、小结

阶段 输入 输出 做什么
预处理 .c .i 展开 #include、#define,去注释
编译 .i(或 .c .s C → 汇编,做优化
汇编 .s .o 汇编 → 机器码
链接 .o + 库 可执行文件 拼接、地址重定位

这四个阶段是理解 C/C++ 编译过程的基础。以后遇到 undefined reference 错误,你就知道是链接阶段出了问题,而不是代码写错了。


下一篇文章,我们来聊聊库——.a.so 到底是什么,为什么你的程序有时候能跑,有时候跑不起来。

每天前进一小步,就是一个新的高度!