这个过程核心分为预处理、编译、汇编、链接 四个阶段,即使我们平时用gcc test.c -o test一键编译,编译器也会在背后依次执行这四步。下面我来详细讲讲每一个环节。
一、完整流程总览
C 文件 → 预处理(.i 文件) → 编译(.s 文件) → 汇编(.o 文件) → 链接(可执行文件)
下面以 Linux 下的 GCC 编译器为例(Windows 下的 MinGW、Clang 逻辑一致),逐个阶段拆解。
为了方便讲解,我们先创建一个简单的test.c文件,后续所有步骤都基于这个文件:
cpp
// test.c
#include <stdio.h> // 头文件包含
#define PI 3.14 // 宏定义
int main()
{
// 条件编译 + 注释
#ifdef PI
printf("PI = %f\n", PI);
#endif
return 0;
}
环节1:预处理(Preprocessing)
核心作用
处理源代码中 "编译前需要提前处理" 的内容,不做语法检查,只做文本替换 / 清理,最终生成纯 C 代码的预处理文件(.i)。
具体操作
- 展开所有
#define宏(比如把PI替换成3.14); - 处理
#include头文件(把<stdio.h>的内容直接插入到当前文件中); - 处理条件编译指令(
#ifdef/#if/#endif等,保留满足条件的代码); - 删除所有注释(// 和 /* */);
- 添加行号和文件标识(方便后续编译报错时定位)。
bash
# -E:只执行预处理阶段,-o 指定输出文件
gcc -E test.c -o test.i
此时输出
生成test.i文件(文本文件,可直接打开查看),核心内容如下(简化版):
bash
// 省略<stdio.h>展开的上千行代码...
int main()
{
// 注释已被删除,PI被替换成3.14,#ifdef PI条件满足保留代码
printf("PI = %f\n", 3.14);
return 0;
}
环节 2:编译(Compilation)
核心作用
将预处理后的.i文件(纯 C 代码)翻译成汇编语言代码(.s),这是 "高级语言→低级语言" 的核心步骤,会做严格的语法 / 语义检查。
具体操作
- 词法分析 :把代码拆分成一个个 "单词"(比如
int、main、=、3.14),检查拼写错误(如把printf写成printff会在此阶段报错); - 语法分析:验证代码语法是否符合 C 语言规则(比如少分号、括号不匹配会报错),生成抽象语法树(AST);
- 语义分析:检查代码逻辑是否合理(比如变量未定义就使用、类型不匹配);
- 优化:对代码进行优化(比如常量折叠、循环优化);
- 生成汇编:将优化后的 AST 翻译成对应 CPU 架构的汇编语言。
执行命令(GCC)
bash
# -S:只执行到编译阶段,生成汇编文件
gcc -S test.i -o test.s
此时输出
生成test.s文件(汇编代码,文本文件),以 x86_64 架构为例,核心内容如下:
bash
.file "test.c"
.text
.section .rodata
.LC0:
.string "PI = %f\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movsd .LC1(%rip), %xmm0 # 把3.14加载到寄存器
leaq .LC0(%rip), %rdi # 加载字符串地址
movl $1, %eax
call printf@PLT # 调用printf函数
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.section .rodata
.LC1:
.long 1374389535
.long 1074339512
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
环节3:汇编(Assembly)
核心作用
将汇编语言的.s文件翻译成机器码(二进制),生成 "重定位目标文件(.o)",也叫目标文件。
具体操作
- 汇编器(GCC 中是
as工具)逐行解析汇编指令; - 将每个汇编指令转换成对应 CPU 能识别的二进制机器码(比如
movq对应0x48 0x89); - 生成目标文件:包含机器码、符号表(比如
main、printf的引用)、重定位表(记录需要链接时修正的地址),但此时还不能直接执行 (因为外部函数如printf还未解析)。
执行命令(GCC)
bash
# -c:只执行到汇编阶段,生成目标文件
gcc -c test.s -o test.o
# 等价于直接调用汇编器:
# as test.s -o test.o
此时输出
生成test.o文件(二进制文件,无法直接打开查看,可通过objdump查看内容):
bash
# 查看目标文件的反汇编内容
objdump -d test.o
输出片段(可见机器码和对应的汇编):
bash
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: f2 0f 10 05 00 00 00 movsd 0x0(%rip),%xmm0 # c <main+0xc>
b: 00
c: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 13 <main+0x13>
13: b8 01 00 00 00 mov $0x1,%eax
18: e8 00 00 00 00 callq 1d <main+0x1d> # printf的地址暂未解析
1d: b8 00 00 00 00 mov $0x0,%eax
22: 5d pop %rbp
23: c3 retq
环节 4:链接(Linking)
核心作用
将目标文件(.o)、系统库(比如libc.so,包含printf的实现)、其他依赖的目标文件合并,解析所有未定义的符号,最终生成可执行文件。
具体操作
链接是新手最容易忽略但最关键的阶段,分为两步:
1. 符号解析
找到所有未定义的符号(比如printf)的实际实现位置(在系统 C 标准库libc中)。
2. 重定位
修正目标文件中的地址(比如把printf的调用地址从 "占位符" 改成实际的内存地址),合并所有目标文件的代码段、数据段,生成完整的可执行文件。
链接的两种类型(重点)
- 动态链接(默认) :可执行文件不包含库函数的代码,运行时才加载系统中的
libc.so(体积小,节省内存,但依赖系统库); - 静态链接 :将库函数的代码直接嵌入可执行文件(体积大,但不依赖系统库),命令:
gcc test.o -o test_static -static。
执行命令(GCC)
bash
# 链接目标文件,生成可执行文件
gcc test.o -o test
关键输出
生成test可执行文件(二进制文件),可直接运行:
bash
./test
# 输出:PI = 3.140000
补充:一键编译 vs 分步编译
平时我们用的gcc test.c -o test是 "一键编译",GCC 会自动依次执行:预处理→编译→汇编→链接,等价于:
gcc -E test.c -o test.i
gcc -S test.i -o test.s
gcc -c test.s -o test.o
gcc test.o -o test
总结
- C 文件生成可执行文件分为 4 个核心阶段:预处理(展开文本)→ 编译(转汇编)→ 汇编(转机器码)→ 链接(合并解析);
- 预处理输出
.i(文本)、编译输出.s(汇编文本)、汇编输出.o(二进制目标文件)、链接输出可执行文件; - 链接是最终生成可执行文件的关键,核心是解析外部符号(如库函数)并修正内存地址,分为动态链接(默认)和静态链接两种方式。