今天我们来聊聊 一件你每天都在干、但大概率从没细想过的事——你按下“运行”那个绿色小按钮之后,你写的.c文件到底经历了什么,才变成黑窗口里那行输出?
很多同学写 C 语言,流程大概是:打开 VS / CLion → 敲代码 → 按 F5 → 哇,出结果了!
然后就完了。
但你要是真以为电脑能直接看懂int main(),那可就太看得起 CPU 了。它只认 0 和 1,你那堆英文单词在它眼里跟天书没两样。
一、先记住两个词:翻译环境 + 运行环境
ANSI C 规定,一个 C 程序一辈子就活在两个环境里 :
- 翻译环境:把你能看懂的
.c源代码,变成电脑能跑的二进制指令(可执行文件)。 - 运行环境:真正把那个可执行文件塞进内存,开始跑。
打个比方:
- 翻译环境 = 工厂加工 ➜ 把原材料(代码)做成成品(
.exe/.out) - 运行环境 = 上台表演 ➜ 成品摆到舞台上动起来
你点一次“运行”,其实是两件事连着发生:先翻译,再执行。
二、翻译环境四步走:预处理 → 编译 → 汇编 → 链接
这才是本文重点。记住这条流水线,面试被问到直接背给你听:
.c 源文件 ↓ 预处理 .i 文件 ↓ 编译 .s 汇编文件 ↓ 汇编 .o / .obj 目标文件 ↓ 链接 可执行程序 ✅咱们一步一步来。
三、第一步:预处理(预编译)—— 给代码“做文本按摩”
处理完出来的是.i文件。
在 gcc 里你可以亲眼看看:
gcc -E test.c -o test.i这阶段不改逻辑,只动文本,主要干这些事 :
- 把
#define删掉,宏原地展开
比如你写了#define N 10,后面用的N全变成10,宏本身消失。 - 把
#include的头文件整段搬进来
而且是递归搬——头文件里再包含头文件,继续搬。
所以有时候你一个test.c才 20 行,预处理完变成 800 行,别慌,正常。 - 处理条件编译
#if/#ifdef/#else/#endif
不满足条件的代码,直接剔掉。 - 注释全删,换成空格
编译器才不看你的“// 这里好难” - 加行号和文件名
为了方便后面报错的时候告诉你“第几行出事了”。 - 保留
#pragma
这是留给编译器后面用的“小纸条”。
小技巧:宏没展开?头文件没进来?别猜,直接去看
.i文件,一目了然。
四、第二步:编译 —— 最烧脑的“审稿三连”
出来的是.s汇编文件:
gcc -S test.i -o test.s这一步才是真正的“翻译”。编译器对着你的代码做了三件事 :
1. 词法分析:拆单词
把一整行代码切成一个个“记号(token)”。
比如这句:
array[index] = (index+4)*(2+6);扫出来就是:
array[index]=(index+4)*(2+6)
——一共 16 个记号,分清楚谁是标识符、谁是运算符、谁是数字。
2. 语法分析:搭语法树
光拆词不够,还得看“句子通不通”。
array[index]是不是合法的下标访问?括号匹不匹配?优先级对不对?
编译器在心里给你画了一棵语法树,哪棵枝桠是乘法,哪棵是加法,清清楚楚。
3. 语义分析:看意思讲不讲得通
语法对 ≠ 逻辑对。
比如你写:
int x = "hello";句子结构没毛病,但类型不对啊!语义分析阶段就给你报错了。
它还会顺手做类型匹配、隐式转换检查、声明检查这些活儿。
大部分你看到的
error: ...编译报错,都是这一步喷出来的。
干完这三件,再优化一下(比如(2+6)直接算成8),最后吐出汇编代码。
五、第三步:汇编 —— 查表翻译,不做思考题
gcc -c test.s -o test.o汇编器干的事特别“机械”:
汇编指令和机器指令有一张对照表,一条对一条,翻译成二进制 。
- 几乎不优化
- 每条汇编对应一条机器指令
- 出来的是
.o(Linux)或.obj(Windows)——目标文件
注意:这时候文件已经变成二进制了,你用记事本打开是乱码。
而且——它还不能直接运行。
为什么?因为printf在哪?你调用的别的文件里的函数在哪?还都不知道。
六、第四步:链接 —— 给所有“空地址”补票
这是最容易被忽略、但最关键的一步。
为什么非链接不可?
真实项目很少只有一个.c。比如你这样写:
// test.c extern int add(int x, int y); extern int g_val; int main() { int sum = add(10, 20); printf("%d\n", sum); return 0; }// add.c int g_val = 2022; int add(int x, int y) { return x + y; }test.c单独编译的时候,编译器根本不知道add和g_val真实藏在内存的哪个位置。
怎么办?——先空着,留个坑。
等链接的时候,链接器出场,干三件事 :
- 地址和空间分配
把各个目标文件的代码段、数据段排排坐,分配地址。 - 符号决议
找清楚:add是谁、g_val在哪、printf在系统库里哪个位置。 - 重定位
把之前空着的那些调用地址,一个一个填成真实地址。
这个“补地址”的动作,就叫重定位(Relocation)。
补完之后,多个.o文件 + 系统运行库(比如 C 标准库)+ 可能的第三方库,拼成一个完整的可执行程序。
—— 到这儿,翻译环境的工作才算结束。
顺带提一嘴:你平时看到的
undefined reference to 'xxx',就是链接阶段在喊:“这符号我找不着爹!”
七、运行环境:程序终于上台了
可执行文件有了,双击或命令行跑起来,进入运行环境 :
- 载入内存
有操作系统?系统帮你搬进内存。
没操作系统(比如裸机单片机)?要么手写加载,要么烧进只读内存。 - 调
main函数
从main开始执行,这个不用多说。 - 跑代码时用两块内存
- 栈(stack):存局部变量、函数返回地址,函数进进出出,自动管理
- 静态内存:存
static和全局变量,整个程序跑着都一直在
- 终止
正常return 0退场,或者……段错误直接被系统干掉 💀
八、一张表收尾
阶段 | 输入 | 输出 | 干的事 |
|---|---|---|---|
预处理 |
|
| 展宏、塞头文件、删注释 |
编译 |
|
| 词法+语法+语义分析,出汇编 |
汇编 |
|
| 查表转机器码 |
链接 | 多个 | 可执行文件 | 分配地址、找符号、重定位 |
运行 | 可执行文件 | 屏幕上的结果 | 载内存、跑 main、用栈和静态区 |
九、总结时间到啦:“保命口诀”
**宏和头文件预处理,词法语法语义编译,
汇编查表出目标,链接补票成 exe,
载进内存跑 main,栈管局部静态跟。**
懂了这套流程,你再看到编译报错就不会只骂“又错了”,而是能大概判断:
- 是宏没展开?→ 看预处理
- 是括号写歪了?→ 编译阶段报错
- 是函数没实现?→ 链接阶段报错
- 是跑起来崩了?→ 运行阶段背锅