文章目录
-
- 先回答一个问题:编译器和链接器到底在忙什么?
- 1. 翻译环境和运行环境
-
- 1.1 翻译环境:把源代码变成可执行文件
- 1.2 先区分两个环境
- 2. 翻译环境的四步
-
- 2.1 预处理(预编译):先把源文件整理好
- 2.2 编译:把 C 语言翻译为汇编
-
- 2.2.1 词法分析:字符组成一个个记号
- 2.2.2 语法分析:记号是否符合 C 的句子结构
- 2.2.3 语义分析:结构合法还要“有意义”
- 2.3 汇编:把汇编文本变成目标文件
- 2.4 链接:解决“这个名字和地址在哪里”
- 3. 运行环境:把可执行文件变成一个进程
- 4. 用命令亲眼观察每个中间产物
- 5. 常见报错应该归到哪个阶段?
- 6. 容易混淆的两件事
-
- 6.1 头文件不是库
- 6.2 .o 文件不是“半个可执行文件”
- 总结
先回答一个问题:编译器和链接器到底在忙什么?
C 源代码是给人看的文本,处理器只能执行特定指令编码。工具链要完成几次“翻译和拼接”:先处理文本指令,再把 C 语句翻译成汇编和机器码,最后把多个文件及库中的代码地址接起来,生成操作系统可以加载的可执行文件。 
预处理处理文本,编译理解 C,汇编生成目标文件,链接解决跨文件关系,运行环境负责加载和执行。
不同平台的文件扩展名可能不同(Windows 常见 .obj/.exe,Linux 常见 .o/无扩展名),但这条主线基本一致。本文用 GCC/Clang 风格命令演示;Visual Studio 的按钮名称不同,背后的阶段类似。
1. 翻译环境和运行环境
1.1 翻译环境:把源代码变成可执行文件
翻译环境包括预处理器、编译器、汇编器和链接器。它们可以由一个编译器驱动程序(如 gcc)按顺序调用,也可以手动拆开观察。
gcc hello.c -o hello
./hello
-o hello 只是指定输出文件名。命令成功只说明翻译阶段没有发现错误,不保证程序逻辑正确,更不保证运行时不会崩溃。
1.2 先区分两个环境
翻译环境解决“能不能生成程序”,运行环境解决“程序启动后怎样获得资源并执行”。
2. 翻译环境的四步
2.1 预处理(预编译):先把源文件整理好
预处理器主要处理以 # 开头的指令:
- include:把头文件内容展开到当前位置;
- define:进行宏替换;
- if、ifdef:根据条件决定保留哪些文本;
- 删除注释,并处理行连接等规则。

#include <stdio.h>
#define MESSAGE "hello"
int main()
{
puts(MESSAGE);
return 0;
}
用 -E 只做预处理:
gcc -E hello.c -o hello.i
打开 hello.i,会看到大量 stdio.h 声明以及 MESSAGE 已经变成字符串字面量。预处理器并不理解变量类型;宏只是文本替换,所以要给复杂宏的参数和整体表达式加括号:
#define SQUARE(x) ((x) * (x))
SQUARE(1 + 2) 如果定义成 x * x,会变成 1 + 2 * 1 + 2,结果不是 9。宏参数还可能被求值多次,SQUARE(i++) 就是带有副作用危险用法。
2.2 编译:把 C 语言翻译为汇编
编译器读取预处理结果,经过词法分析、语法分析和语义分析,再进行优化并生成汇编文本。可以用 -S 停在汇编阶段:

gcc -S hello.c -o hello.s
2.2.1 词法分析:字符组成一个个记号
词法分析器从左到右扫描字符,把字符归类为关键字、标识符、常量、运算符和分隔符。例如:
int total = price + 1;
会被看成 int(关键字)、total(标识符)、=、price、+、1 和 ;。无法识别的字符会在这一阶段附近报错:
int n = 3 @ 2; // @ 不是这里合法的 C 运算符
2.2.2 语法分析:记号是否符合 C 的句子结构
语法分析器根据 C 语言文法建立表达式和语句的结构。括号、分号和大括号缺失,往往属于语法错误:
int main(void) {
puts("hi") // 少了分号
return 0;
}
编译器可能把错误位置标在下一行,因为它直到继续读取后才确定前面的结构无法闭合。看到 expected ‘;’ 时,先检查上一行和配对括号。
2.2.3 语义分析:结构合法还要“有意义”
语义分析会检查类型、声明、作用域和表达式规则。下面代码的结构像赋值语句,但左边不是可修改对象:
3 = value; // 语义错误:常量不能被赋值
再看一个跨阶段的例子:
int add(int, int); // 只有声明
int main() {
return add(2, 3); // 调用形式正确
}
编译器可以据此生成目标代码;但如果工程没有提供 add 的定义,链接器才会报告未定义引用。这说明“编译通过”不等于“链接通过”。
2.3 汇编:把汇编文本变成目标文件
汇编器把 .s 中类似 mov、call 的指令翻译成机器码,并生成目标文件(.o 或 .obj):
gcc -c hello.c -o hello.o
-c 会完成预处理、编译和汇编,但不进行链接。目标文件已经包含机器码,却可能仍有无法确定的地址,例如调用了另一个源文件里的函数。它还包含符号表、重定位信息和调试信息(启用 -g 时)。
2.4 链接:解决“这个名字和地址在哪里”
链接器把多个目标文件和库合并,完成两件事:

准备两个源文件:
// math.c
int add(int a, int b)
{
return a + b;
}
// main.c
#include <stdio.h>
int add(int, int); // 函数接口声明
int main()
{
printf("%d\\n", add(2, 3));
return 0;
}
分别编译再链接:
gcc -c math.c -o math.o
gcc -c main.c -o main.o
gcc main.o math.o -o calc
运行 calc 输出 5。如果漏掉 math.o,通常会出现 undefined reference to add(Windows 链接器有类似提示)。这不是语法错误,而是链接器找不到定义。标准库函数也需要链接;头文件只提供声明,不会把实现本身复制进程序。
3. 运行环境:把可执行文件变成一个进程
操作系统启动程序时,会创建进程、映射代码和数据、准备栈与堆,再调用 C 运行库初始化代码,最后进入 main。main 返回的整数会交给操作系统,通常 0 表示成功,非 0 表示失败。 
#include <stdio.h>
int main()
{
puts("program is running");
return 0; // 返回给操作系统的状态码
}
翻译环境解决“能不能生成程序”,运行环境解决“程序启动后怎样获得资源并执行”。数组越界、空指针和未初始化变量通常不会在链接阶段报错,而是在运行时造成未定义行为。
4. 用命令亲眼观察每个中间产物
以 hello.c 为例,可以依次执行:
gcc -E hello.c -o hello.i # 预处理:文本变大
gcc -S hello.i -o hello.s # 编译:得到汇编
gcc -c hello.s -o hello.o # 汇编:得到目标文件
gcc hello.o -o hello # 链接:得到可执行文件
也可以让 gcc 自动完成前面步骤,只保留目标文件:
gcc -c hello.c -o hello.o
.i 和 .s 通常是文本,.o 和可执行文件是二进制,不能用普通文本编辑器判断它们是否正确。
5. 常见报错应该归到哪个阶段?
| expected ';'、括号不匹配 | 语法分析 | 分号、大括号、上一行 |
| undeclared identifier | 语义分析 | 声明、头文件、作用域 |
| undefined reference / unresolved external | 链接 | 是否加入定义所在的 .o/库 |
| 运行后崩溃、输出错误 | 运行环境/逻辑 | 越界、空指针、未初始化变量 |
分阶段编译的好处,就是可以判断错误发生在翻译的哪一步。不要看到所有红字都统称为“编译错误”。
6. 容易混淆的两件事
6.1 头文件不是库
头文件提供声明、宏和类型定义;库提供已经编译好的函数实现。include 让编译器知道函数怎么调用,链接器才负责把实现接入。
6.2 .o 文件不是“半个可执行文件”
它是一个可以重定位的目标文件,代码和数据已经有机器表示,但地址和跨文件符号可能未最终确定。多个 .o 能被链接成一个程序,也能被打包进静态库。
总结
在最初的学习过程中可以不必要一定了解编译与链接的底层实现,本文只浅谈编译与链接的一些简要大致内容,让人起码能够知晓一段C语言代码是如何一步步变成一个可执行程序,进而了解到从源代码到程序并不是神秘黑盒,而是一条可以拆开的流水线:预处理让文本就位,编译器确认 C 语言结构和含义,汇编器产出机器码目标文件,链接器把符号和地址接好,运行环境再创建进程并调用 main。。
