2. 单文件编译回顾
说实话,很多初学者一上来就敲 gcc hello.c -o hello,然后看到可执行文件出来了,就觉得完事了。但你真的知道这背后发生了什么吗?
我记得刚入行那会儿,带我的老工程师让我手动走一遍编译流程。我当时心想:这不是多此一举吗?直到后来遇到一个链接错误,折腾了两天才发现是符号表的问题。嗯,从那以后,我再也不敢跳过这些基础了。
2.1 编译的四个阶段
一个C文件从源码变成可执行文件,要经历四个阶段:预处理、编译、汇编、链接。说白了,就是一步步把人类能读懂的代码,翻译成机器能执行的指令。
我习惯把这四个阶段比作做菜:预处理是洗菜切菜,编译是把食材炒熟,汇编是装盘,链接是把所有菜摆成一桌宴席。有点意思吧?
核心要点:gcc 其实是一个驱动程序,它背后调用了 cpp(预处理器)、cc1(编译器)、as(汇编器)、ld(链接器)来完成工作。你敲一个 gcc 命令,它帮你把脏活累活全干了。
2.2 手动走一遍流程
我们来写一个最简单的 C 文件,叫 hello.c:
#include <stdio.h>
#define GREETING "Hello, Makefile World!"
int main() {
printf("%s\n", GREETING);
return 0;
}
现在,我们一步步手动执行,看看每个阶段都干了什么。
第一步:预处理(Preprocessing)
预处理阶段,gcc 会处理所有以 # 开头的指令。比如 #include 会把头文件内容复制进来,#define 会做宏替换。
gcc -E hello.c -o hello.i
你打开 hello.i 看看,会发现文件变得巨大。为什么?因为 stdio.h 里几百行代码全被塞进来了。我刚开始看到这个文件时吓了一跳,心想这玩意儿也太膨胀了。
小技巧:用 -E 选项只做预处理,不继续编译。这在调试宏定义时特别有用。我曾经排查过一个宏展开错误,就是靠看 .i 文件找到的问题。
第二步:编译(Compilation)
编译阶段把预处理后的代码翻译成汇编语言。这是最核心的一步,语法检查、语义分析、优化都在这里完成。
gcc -S hello.i -o hello.s
打开 hello.s,你会看到一堆汇编指令。比如 movl、call 这些。看不懂没关系,你只需要知道:这时候代码已经从 C 语言变成了汇编语言。
我个人习惯在优化代码时,看看生成的汇编代码。比如你写了个循环,编译器有没有帮你展开?看看 .s 文件就一目了然。
第三步:汇编(Assembly)
汇编阶段把汇编代码转换成机器码,生成目标文件(.o 文件)。目标文件里是二进制数据,但还不能直接运行。
gcc -c hello.s -o hello.o
这时候你 cat hello.o 会看到一堆乱码。别慌,这是正常的。你可以用 file hello.o 看看它的类型:
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
注意看 relocatable 这个词,意思是「可重定位的」。说白了,这个文件里的地址还是占位符,需要链接器来填上真正的地址。
第四步:链接(Linking)
链接阶段把目标文件和库文件合并,生成最终的可执行文件。这里要解决符号解析和重定位的问题。
gcc hello.o -o hello
现在你可以运行了:
$ ./hello
Hello, Makefile World!
注意:链接阶段最常见的错误就是「未定义的引用」。比如你声明了一个函数但没实现,链接器就会报错。我曾经有一次忘记链接数学库 -lm,结果 sqrt() 函数死活找不到,排查了半天。
2.3 一张图看懂整个流程
下面这张 SVG 图,把整个编译流程串起来了。你想想看,是不是一目了然?
2.4 常用选项速查
这里整理了一份常用选项表,我平时写 Makefile 时经常用到:
| 选项 | 作用 | 输出文件 |
|---|---|---|
-E |
只做预处理 | .i 文件 |
-S |
编译到汇编代码 | .s 文件 |
-c |
编译、汇编,但不链接 | .o 文件 |
-o |
指定输出文件名 | 自定义 |
-Wall |
开启所有常见警告 | — |
-g |
生成调试信息 | — |
-O2 |
二级优化 | — |
我的习惯:开发阶段用 -g -Wall,发布前用 -O2。别问我为什么,问就是吃过亏。有一次忘了加 -Wall,一个未初始化变量的 bug 在线上跑了三个月才被发现。
2.5 避坑指南
最后分享几个我踩过的坑:
- 头文件路径问题:如果
#include的头文件不在标准路径下,用-I指定。比如gcc -I./include hello.c。 - 链接顺序:链接时,库文件要放在目标文件后面。比如
gcc main.o -lm是对的,gcc -lm main.o可能会报错。为什么?因为链接器是从左到右解析符号的。 - 多文件编译:如果你有多个
.c文件,可以一次编译:gcc file1.c file2.c -o program。但这样每次都要重新编译所有文件,效率很低。下一章我们会讲怎么用 Makefile 解决这个问题。
好了,单文件编译的流程就讲到这里。你可以在终端里亲手试一遍,看看每个阶段生成的文件长什么样。相信我,亲手走一遍比看十遍文章都管用。