一段 C 代码是怎么变成可执行文件的:预处理、编译、汇编、链接
平时我们一句 gcc main.c -o main 就拿到了可执行文件,中间发生了什么往往被忽略。但这条命令其实把四个独立的阶段串在了一起。把它们拆开一步步看,对理解编译错误、链接错误、宏展开这些日常问题很有帮助。
以这段最简单的代码为例:
#include <stdio.h>
int main(){
printf("hello world\n");
return 0;
}
一个 C 程序从源代码到可执行文件,要依次经过 4 个阶段:
- 预处理(Preprocessing)
- 编译(Compilation)
- 汇编(Assembly)
- 链接(Linking)
这四个阶段合起来构成了编译系统。前三步分别由预处理器、编译器、汇编器完成,把单个源文件一步步降级,最后由链接器把多个目标文件拼成一个完整程序。

预处理阶段
预处理器直接对源代码做文本层面的处理,不涉及任何语法分析。它干的事包括:展开宏(#define)、把 #include 的头文件内容原样插入进来、根据 #ifdef 等条件保留或删除代码、去掉注释。
用 -E 让 gcc 只做到这一步,生成 main.i:
gcc -E main.c -o main.i
打开 main.i 会发现它一下子膨胀到了几百上千行——因为 #include <stdio.h> 把整个 stdio 头文件(包含 printf 的声明)都展开进来了。这一步之后,源文件里就不再有任何预处理指令了。
编译阶段
编译器接管预处理后的代码,做词法分析、语法分析、语义检查和优化,最终把 C 代码翻译成对应平台的汇编语言。
gcc -S main.i -o main.s
main.s 是人还能读懂的汇编:
.file "main.c"
.text
.section .rodata
.LC0:
.string "hello world"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
...
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@PLT
movl $0, %eax
popq %rbp
ret
.cfi_endproc
可以注意到一个细节:源码里写的是 printf,编译器却生成了 call puts@PLT——因为编译器发现这次调用没有格式化参数、字符串又以换行结尾,于是把它优化成了更轻量的 puts。我们大部分语法错误,都是在这个阶段被报出来的。
汇编阶段
汇编器把上一步的汇编代码翻译成机器语言,产出目标文件(object file,.o)。目标文件已经是二进制了,但还不能直接运行。
gcc -c main.s -o main.o
链接阶段
我们的代码调用了标准库里的 printf/puts,但这些函数的实现并不在 main.o 里——main.o 里只有一个「待填」的符号引用。链接器(linker)的工作就是把这些缺失的符号解决掉:找到 C 标准库中对应的实现,和 main.o 合并、重定位地址,最终生成一个完整的、可以加载执行的二进制文件。
平时遇到的
undefined reference to 'xxx'报错就发生在这一步:编译都过了,但链接器找不到某个函数/变量的实现(常见于忘了链接对应的库,比如用了数学函数却没加-lm)。这和「编译错误」是两类不同的问题。
链接还分静态链接和动态链接:静态链接把库代码直接拷进可执行文件,动态链接则在程序运行时才去加载共享库(.so)。默认的 printf 走的就是动态链接 libc。
一步到位 vs 分步执行
理解了四个阶段,再回头看那条熟悉的命令——它只是把上面四步一次性做完:
gcc main.c -o main
然后执行:
./main
而我们完全可以用 -E、-S、-c 把每个阶段单独跑出来,逐一查看中间产物(.i / .s / .o)。这在调试宏展开是否正确、查看编译器生成了什么汇编、定位到底是编译错误还是链接错误时,都非常有用。