一段 C 代码是怎么变成可执行文件的:预处理、编译、汇编、链接

平时我们一句 gcc main.c -o main 就拿到了可执行文件,中间发生了什么往往被忽略。但这条命令其实把四个独立的阶段串在了一起。把它们拆开一步步看,对理解编译错误、链接错误、宏展开这些日常问题很有帮助。

以这段最简单的代码为例:

#include <stdio.h>

int main(){
    printf("hello world\n");
    return 0;
}

一个 C 程序从源代码到可执行文件,要依次经过 4 个阶段:

  1. 预处理(Preprocessing)
  2. 编译(Compilation)
  3. 汇编(Assembly)
  4. 链接(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)。这在调试宏展开是否正确、查看编译器生成了什么汇编、定位到底是编译错误还是链接错误时,都非常有用。