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,你会看到一堆 mov、push、call 开头的指令。这就是你的程序在 CPU 眼里的样子。
这个阶段编译器做了很多优化——它会分析你的代码,做些等价变换,让生成的汇编代码跑得更快。比如:
- 把循环里不变的运算提到循环外面
- 把不用的变量直接删掉
- 用更高效的指令序列替换手写的逻辑
5、第三阶段:汇编
汇编阶段很简单:把汇编代码翻译成机器码。
机器码就是 0 和 1 组成的二进制指令了,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 到底是什么,为什么你的程序有时候能跑,有时候跑不起来。
每天前进一小步,就是一个新的高度!