写 C 语言代码时,我们按下“编译运行”,程序就跑起来了。但在这“一下”背后,源代码其实经历了一段很长的旅程:预处理 → 编译 → 汇编 → 链接。本文把这条链路一次讲清楚,并配上能直接对照的示例和速记卡。
一、两个环境:翻译环境与运行环境
C 标准规定,程序的整个生命周期里存在两个环境:
- 翻译环境:把源代码翻译成可执行的机器指令(二进制)。
- 运行环境:实际执行代码的环境。
翻译环境又由编译和链接两大部分组成,其中编译可细分为预处理、编译、汇编三个过程。先把总流程立起来:
阶段 主要工作
预处理 宏展开、头文件插入、删注释、条件编译
编译 词法 → 语法 → 语义 → 优化
汇编 汇编指令逐条翻译成机器指令
链接 地址分配、符号决议、重定位
记忆锚点:.c → .i → .s → .o → .exe,每一跳对应的命令是 -E / -S / -c / link。
一个项目有多个 .c 时,每个源文件独立编译成目标文件,再由链接器把它们和链接库(运行时库或第三方库)合并成最终的可执行程序。
二、预处理:把“准备工作”全部干完
预处理只处理以 # 开头的指令,产出 .i 文件。它主要做六件事:
- 删除 #define,展开所有宏;
- 处理条件编译:#if / #ifdef / #elif / #else / #endif;
- 处理 #include,把头文件内容插入到指令位置,且递归进行;
- 删除所有注释;
- 添加行号和文件名标识,便于生成调试信息;
- 保留 #pragma,交给后续编译器处理。
示例一:宏展开
#define N 10
#define SQUARE(x) ((x) * (x))
int a = N; // 预处理后 -> int a = 10;
int b = SQUARE(3); // 预处理后 -> int b = ((3) * (3));
预处理完成后,.i 文件里已经看不到宏名了,因为全被替换成了宏定义的内容。
示例二:头文件插入是递归的
#include <stdio.h>
stdio.h 可能又包含了别的头文件,预处理器会把它们一层层展开,全部摊进 .i 文件里。所以:
怀疑“宏没生效”“头文件没包含对”时,别猜,直接看预处理后的 .i 文件——它是唯一能还原预处理结果的“照妖镜”。
三、编译:从字符到记号,再到语法树
编译阶段对 .i 文件做词法分析、语法分析、语义分析和优化,最终生成汇编代码 .s。
示例:一句话被切成 16 个记号
array[index] = (index + 4) * (2 + 6)
- 词法分析:把字符流切成最小、有意义的单元,称为记号(token)。上句正好切成 16 个:
array [ index ] = ( index + 4 ) * ( 2 + 6 )
注意:空格不是记号,只作分隔;若末尾写分号,分号自身也是第 17 个记号。 - 语法分析:把记号按语法规则拼成语法树(以表达式为节点的树),供后续阶段使用。
- 语义分析:做静态语义检查,主要包括声明与类型的匹配、类型转换,并在这一步报出错误。
示例:语义错误
int x = “hello”; // 语法结构没问题,但类型不匹配
词法、语法都可能“过”,但语义分析会发现 int 变量不能接收字符串字面量,从而报错。
一句话概括三步:词法管“切块”,语法管“搭树”,语义管“对类型”。
四、汇编:只是"查表翻译"
汇编器把 .s 里的汇编语句,对照"汇编指令 ↔ 机器指令"的映射表,逐条翻译成二进制机器指令,生成 .o。
这个阶段不做指令优化——优化在编译阶段已经完成。所以:
汇编负责"翻译",不负责"变聪明"。
五、链接:符号决议 + 重定位
这是整条链路里最容易被"大概懂了"糊弄过去的一段。先看一个经典的双文件示例:
// test.c
#include <stdio.h>
extern int Add(int x, int y); // 声明外部函数
extern int g_val; // 声明外部全局变量
int main(void)
{
int a = 10, b = 20;
int sum = Add(a, b);
printf("%d\n", sum);
return 0;
}
// add.c
int g_val = 2022;
int Add(int x, int y)
{
return x + y;
}
关键问题:编译 test.c 时,编译器根本没见过 add.c,它凭什么能生成 test.o?
答案是:它能,而且必须能。
- extern 只是声明——“我保证外面有这么个东西”,编译器无需知道它的地址;
- 于是 test.o 正常生成,但把 Add、g_val 记为未定义的引用,地址先留空占位;
- 而 add.o 里,Add、g_val 是已定义的,带有自己的地址。
链接器上场后,主要做三件事: - 地址和空间分配:把各目标文件的段拼起来、安排位置;
- 符号决议(符号解析):拿着 test.o 里"未定义的 Add",到所有目标文件里找到那个"已定义的 Add",确认二者是同一个符号;
- 重定位:找到后,把 Add 的真实地址回填到 test.o 中所有引用它的地方。
核心动作八个字:先占位,后回填。
示例:三种常见链接错误
报错
unresolved external symbol Add(Linux:undefined reference to ‘Add’)
multiple definition of ‘g_val’
找不到库路径 / 库版本不匹配
更底层的目标文件格式(如 elf)、符号表结构、重定位表,这里不展开,可参阅《程序员的自我修养》。
六、运行环境:编译完,不等于能跑
可执行文件生成后,真正运行还要经历: - 载入内存:有操作系统时由系统完成,裸机环境则需手工安排;
- 开始执行,调用 main;
- 使用运行时堆栈:存放函数的局部变量和返回地址;同时可使用静态内存,其中的变量在整个运行期间保留;
- 终止:正常从 main 返回,或意外终止。
示例:栈与静态区的区别
void f(void)
{
int a = 1; // 栈:函数返回即回收
static int b = 1; // 静态区:程序运行期间一直存在
}
一句话:栈是"临时工",静态区是"长期工"。
七、速记卡
.c --gcc -E–> .i --gcc -S–> .s --gcc -c–> .o --link–> .exe
(Windows 目标文件叫 .obj)
预处理:宏展开 / 条件编译 / 头文件插入(递归) / 删注释 / 加行号 / 保留 pragma
编译 :词法(切记号) → 语法(语法树) → 语义(类型检查) → 优化
汇编 :查表翻译成机器指令,不优化
链接 :地址空间分配 → 符号决议(认人) → 重定位(填地址)
运行 :载入内存 → 调用 main → 栈(局部变量/返回地址)+静态区 → 终止
八、总结
编译与链接回答的是一个比语法更根本的问题:你写下的几行字符,凭什么能变成机器听得懂的东西?
把这条链路记成"一条线",比记住零散名词有用得多:
- 用产物后缀反推阶段(.i 预处理完、.s 编译完、.o 汇编完);
- 用具体例子锚定抽象概念(16 个记号、Add 的先占位后回填);
- 用一句话概括每段职责(切块 / 搭树 / 对类型 / 翻译 / 认人 + 填地址)。
愿你在 .c 到 .exe 的路上,少一点"大概",多一点"准确"。
379



被折叠的 条评论
为什么被折叠?



