Appearance
编译四步:预处理、编译、汇编、链接
概念
前面五篇(lang/10~15)讲的是"C 语言的语义":类型、指针、布局、栈、堆、多文件。这一篇换一个视角:
一份
.c文本,究竟怎么变成一颗能被 CPU 执行的二进制?
答案是一条四级的翻译流水线:
四步,四种产物,四种"人还能不能看懂":
| 步 | 输入 | 输出 | 谁在干 | 产物形态 |
|---|---|---|---|---|
| ① 预处理 | .c | .i | 预处理器 | 纯 C 文本(人还能读) |
| ② 编译 | .i | .s | 编译器(前端+后端) | 汇编文本(人勉强能读) |
| ③ 汇编 | .s | .o | 汇编器(as) | 二进制,但地址未定(人读不了) |
| ④ 链接 | .o 们 + 库 | 可执行文件 | 链接器(ld) | 二进制,地址已定 |
先记住这条最重要的分界线:
本篇在主线上的位置:
lang/15-multi-file.md已经点出"编译是各编译各的、再拼起来"。本篇把那句话拆成四个可命名的阶段——这也是【由硅到 C】这个名字的落点:从晶体管一路走到 C,再从这里反向走回机器码。
原理
一、四步的完整链条
hello.c ← 你写的源码(文本)
│
│ ① gcc -E 预处理器:处理 #include / #define / #if
▼
hello.i ← 纯 C 文本(头文件已被"抄"了进来)
│
│ ② gcc -S 编译器:C → 汇编
▼
hello.s ← 汇编文本("机器码的文本形式")
│
│ ③ gcc -c 汇编器:汇编 → 机器码 + 符号表 + 重定位表
▼
hello.o ← 目标文件(二进制;地址还留着空)
│
│ ④ gcc(内部的 ld) 链接器:符号解析 + 重定位
▼
a.out ← 可执行文件(每个地址都填好了)对应的四条命令(gcc 只是驱动,它按参数决定跑到哪一步就停):
bash
gcc -E hello.c -o hello.i # 只做预处理,停
gcc -S hello.i -o hello.s # 预处理 + 编译,停(也可直接 gcc -S hello.c)
gcc -c hello.s -o hello.o # 再做到汇编,停
gcc hello.o -o a.out # 再做到链接(默认全跑完)
# 一行到底(等价于上面四步依次执行)
gcc -o a.out hello.c
-E/-S/-c三个开关的记忆法:大写字母就是"那一步的产物后缀"——-E对应预处理(preprocess Expanded)、-S对应.s、-c对应.o(compile,不链接)。
二、第 ① 步:预处理——"文本处理",不认识 C
lang/15 已经讲过它的本质:预处理器不认识 C 语法,只做文本替换。
它只干四件事:
| 指令 | 动作 |
|---|---|
#include | 把那个文件的全部文本抄到这里 |
#define | 宏替换(对象宏 / 函数宏) |
#if / #ifdef / #else / #endif | 条件编译:这段文本要不要留下 |
#error / #pragma | 报错 / 实现相关 |
它的产物 .i 仍然是"合法的 C 代码"——你可以直接读它(只是长得吓人:一个 #include <stdio.h> 展开后有近千行)。
三个必须记住的副作用:
| 副作用 | 原因 |
|---|---|
.i 体积暴涨 | #include 是抄文件——抄进来的头文件才是大头 |
| 报错行号指向头文件 | .i 里不再有"哪行是你写的"这类信息(#line 指令负责把它带过去) |
| 宏的错误无法调试 | 宏在 .i 里已经展开完了,调试器看不到"宏"这个东西 |
三、第 ② 步:编译——真正的"翻译",内部还有六小步
这是四步里唯一涉及"语义"的一步。 它内部通常再分六小步(分法见各类编译原理教材,名字略有出入):
| 小步 | 输入 → 输出 | 一句话说明 |
|---|---|---|
| ① 词法分析 | 字符流 → 记号(token)流 | 把 int x = 3; 切成 int、x、=、3、; |
| ② 语法分析 | token 流 → 语法树(AST) | 按 C 文法检查"这个句子合不合法"(语法错误在这一步报) |
| ③ 语义分析 | AST → 带类型的 AST + 符号表 | 类型检查、隐式转换插入、作用域解析(类型错误在这一步报) |
| ④ 中间代码生成 | AST → IR(三地址码 / SSA) | 与具体机器无关的"通用指令" |
| ⑤ 优化 | IR → 更优的 IR | 常量折叠、公共子表达式、死代码消除、循环不变量外提 |
| ⑥ 目标代码生成 | IR → 汇编 | 指令选择 + 寄存器分配 + 指令调度 |
三地址码(three-address code)长什么样? ——每个运算最多三个操作数、只做一件事:
C: int d = (a + b) * (a + b);
三地址码(未优化):
t1 = a + b
t2 = a + b ← 算了两次("公共子表达式")
t3 = t1 * t2
d = t3优化之后:
t1 = a + b ← 只算一次
d = t1 * t1这就是为什么"编译能优化",而"写 C 时手写 (a+b)*(a+b) 也没什么大不了"——好的编译器会把它算一次,你不用为这类事操心。
一个反直觉的事:
-O0(不优化)与-O2生成的汇编可能差 3 倍以上。所以"我写 C 不关心性能"只在-O2以上才成立——调试时用-O0(便于单步),发布时用-O2,这是两条不同的路径。
.s 文件长什么样?(MIPS32 的片段,与本课汇编篇口径一致)
asm
.text
.globl add
add:
addu $v0, $a0, $a1 # v0 = a0 + a1
jr $ra # 返回(见 lang/04-stack.md)注意 .s 里还有"标签(label)和名字"(add)——它们还不是地址。 这正是"汇编还没完成"的标志。
四、第 ③ 步:汇编——把助记符翻译成机器码
汇编器(assembler,通常叫 as)是一个"查表翻译器"。它做四件事:
| 动作 | 说明 |
|---|---|
| ① 指令编码 | addu $v0,$a0,$a1 → 一个 32 位机器字(编码规则就是 lang/01-isa.md 讲的 R/I/J 格式) |
| ② 伪指令展开 | li $t0, 0x12345678 变成 lui + ori 两条真指令 |
| ③ 建立符号表 | 记录"本文件定义了什么"(.globl 的才是对外可见) |
| ④ 生成重定位表 | 记录"哪些地方要等链接时才能填"(比如 jal printf 的地址还不知道) |
产物 .o(目标文件)的定性:
"没填完"是必然的:汇编 .c A 时,.c B 里的函数地址根本不存在——汇编器只能在那里留一个"占位",并把"这里要填 printf 的地址"记进重定位表。
.o 里主要有这些节(section):
| 节 | 内容 | 大小 |
|---|---|---|
.text | 机器码(可执行指令) | 有 |
.data | 已初始化的全局/静态变量 | 有 |
.bss | 未初始化的全局/静态变量 | 只记"要多少字节",不占文件空间(下一篇细讲) |
.rodata | 只读数据(字符串字面量、常量表) | 有 |
.symtab | 符号表 | 有(可被 strip 删掉) |
.rel.text | 重定位表:.text 里哪些位置要填 | 有 |
.o是"二进制"没错,但它不是"可执行文件":它没有入口点(entry point)、没有程序头(program header),操作系统不能直接加载它。
五、第 ④ 步:链接——符号解析 + 重定位
链接器(ld)只做两件事,但这件"拼装"工作是整个链条的关键:
| 步骤 | 名字 | 干什么 |
|---|---|---|
| ① | 符号解析(symbol resolution) | 把每个 .o 的"定义"收集起来,去匹配别人的"未定义引用" |
| ② | 重定位(relocation) | 给每个段分配最终地址,再把重定位表里的"占位"填上真实地址 |
链接之后,.o 里那些"簿记"(符号表、重定位表)就被"消耗"掉了——成品是可执行文件。
三类链接输入的"顺序敏感性"(新手最容易踩的坑):
bash
gcc main.o -lm # ✔ 库放后面
gcc -lm main.o # ✘ 可能报 undefined reference原因:链接器按命令行顺序单向扫描——它只把"已经被引用、但还没定义"的符号从后面提供的库里捞出来。库在前时,main.o 的引用还没出现,库就被"看过去"了。
链接器要做的事:这一步的细节(强/弱符号、PC 相对重定位的算数)在 lang/22-link.md 单独讲。
六、四步各自的产物能不能"复用"
为什么要分成四步、而不是"一步到底"? 因为每一步的产物都有自己的复用价值:
| 产物 | 能不能复用 | 复用方式 |
|---|---|---|
.i | 很少 | 调试宏展开时才看 |
.s | 可以 | 有人手工改汇编;交叉编译时 .s 是"可移植的中间物" |
.o | 大量复用 | 这就是"增量编译"的全部基础(lang/15 讲过):改一个 .c,其它 .o 不动,只需重跑「③ + ④」 |
| 可执行文件 | 不 | 最终产物 |
示例
例 1:把"降抽象"的四级亲手走一遍
任务:拿一小段 C,写出它在编译阶段内部的 IR 变化,看清"优化到底改了什么"。
c
/* opt_demo.c */
int f(int a, int b) {
int c = 3 * 4 + 1; /* 常量表达式 */
int d = (a + b) * (a + b);
return c + d * 0; /* 乘 0 恒为 0 */
}用 Python 模拟"IR 生成 + 优化",并数清指令条数:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
print("=== 第 0 级:源代码(人写的 C) ===")
SRC = [
"int f(int a, int b) {",
" int c = 3 * 4 + 1;",
" int d = (a + b) * (a + b);",
" return c + d * 0;",
"}",
]
for i, l in enumerate(SRC, 1):
print(" " + pad(str(i), 3) + "| " + l)
print()
print("=== 第 1 级:中间代码 IR(三地址码,不优化) ===")
IR0 = [
("t1", "3 * 4"),
("t2", "t1 + 1"),
("t3", "a + b"),
("t4", "t3 * t3"),
("t5", "t4 * 0"),
("t6", "t2 + t5"),
("ret", "t6"),
]
for dst, expr in IR0:
sep = " ← " if dst == "ret" else " = "
print(" " + pad(dst, 5) + sep + expr)
print(" 共 %d 条 IR 指令" % len(IR0))
print()
print("=== 第 2 级:优化后的 IR ===")
IR1 = [
("c", "13"),
("t3", "a + b"),
("t4", "t3 * t3"),
("ret", "13 + t4"),
]
for dst, expr in IR1:
sep = " ← " if dst == "ret" else " = "
print(" " + pad(dst, 5) + sep + expr)
print(" 共 %d 条 IR 指令" % len(IR1))
print()
print("=== 优化明细(哪条规则省掉了什么) ===")
print(" " + pad("优化前", 18) + pad("优化后", 14) + "规则")
OPT = [
("3 * 4 + 1", "13", "常量折叠(编译期就算完)"),
("(a+b)*(a+b)", "t3 * t3", "公共子表达式消除(a+b 只算一次)"),
("t4 * 0", "删除", "恒等式 x * 0 = 0"),
("13 + 0", "13", "再次常量折叠"),
]
for a, b, r in OPT:
print(" " + pad(a, 18) + pad(b, 14) + r)
d = 100.0 * (len(IR0) - len(IR1)) / len(IR0)
print(" → IR 条数 %d → %d,减少 %.1f%%" % (len(IR0), len(IR1), d))
print()
print("=== 再往后两级(本轮只看形态,不展开) ===")
print(" 第 3 级 汇编 .s :把 t3/t4 分配成寄存器,例如")
print(" addu $t0, $a0, $a1 # t3 = a + b")
print(" mul $t1, $t0, $t0 # t4 = t3 * t3")
print(" 第 4 级 目标码 .o :上面两条各编成一个 32 位机器字")预期输出:
=== 第 0 级:源代码(人写的 C) ===
1 | int f(int a, int b) {
2 | int c = 3 * 4 + 1;
3 | int d = (a + b) * (a + b);
4 | return c + d * 0;
5 | }
=== 第 1 级:中间代码 IR(三地址码,不优化) ===
t1 = 3 * 4
t2 = t1 + 1
t3 = a + b
t4 = t3 * t3
t5 = t4 * 0
t6 = t2 + t5
ret ← t6
共 7 条 IR 指令
=== 第 2 级:优化后的 IR ===
c = 13
t3 = a + b
t4 = t3 * t3
ret ← 13 + t4
共 4 条 IR 指令
=== 优化明细(哪条规则省掉了什么) ===
优化前 优化后 规则
3 * 4 + 1 13 常量折叠(编译期就算完)
(a+b)*(a+b) t3 * t3 公共子表达式消除(a+b 只算一次)
t4 * 0 删除 恒等式 x * 0 = 0
13 + 0 13 再次常量折叠
→ IR 条数 7 → 4,减少 42.9%
=== 再往后两级(本轮只看形态,不展开) ===
第 3 级 汇编 .s :把 t3/t4 分配成寄存器,例如
addu $t0, $a0, $a1 # t3 = a + b
mul $t1, $t0, $t0 # t4 = t3 * t3
第 4 级 目标码 .o :上面两条各编成一个 32 位机器字三条结论:
| 结论 | 说明 |
|---|---|
| 编译 ≠ 直接生成机器码 | 中间隔着 IR——"生成 IR → 优化 IR → 再落成汇编"才是真实流程 |
| 优化发生在 IR 上 | 常量折叠、公共子表达式、恒等式都在 IR 层完成,与目标机器无关 |
| "人写的 C"与"跑起来的码"不是一对一 | 7 条 IR 变 4 条、3*4+1 根本不会出现在运行期——这就是"编译器比你聪明"的地方 |
例 2:四步的产物各有多大
任务:给"每级产物"算一笔体积账,理解"为什么 .i 会暴涨"。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
print("=== 一条四步流水线的产物体积(典型量级,示意) ===")
STEPS = [
("hello.c", 200, "人写的源码", "—"),
("hello.i", 817000, "预处理后的纯 C 文本", "预处理器 -E"),
("hello.s", 4100, "汇编文本", "编译器 -S"),
("hello.o", 2100, "目标文件(含符号表)", "汇编器 -c"),
("a.out", 16500, "可执行文件", "链接器 ld"),
]
print(" " + pad("产物", 11) + pad("大小", 11) + pad("内容形态", 22) + "产出者")
for name, size, kind, tool in STEPS:
if size >= 1024:
human = "%.1f KB" % (size / 1024)
else:
human = "%d B" % size
print(" " + pad(name, 11) + pad(human, 11) + pad(kind, 22) + tool)
print()
print("=== .i 为什么比 .c 大几百倍:数一数 #include 抄进来多少 ===")
SRC_BYTES = 200
HDRS = [
("stdio.h", 51200, 1200),
("string.h", 20480, 480),
("stdlib.h", 30720, 760),
("其它头文件", 81920, 1900),
]
print(" hello.c 的全部内容就是 4 行,其中 3 行是 #include")
print()
print(" " + pad("头文件", 13) + pad("文件大小", 11) + pad("行数", 8) + "备注")
tot_b, tot_l = 0, 0
for name, size, lines in HDRS:
tot_b += size
tot_l += lines
print(" " + pad(name, 13) + pad("%.0f KB" % (size / 1024), 11) + pad(str(lines), 8)
+ ("被反复引用" if name == "其它头文件" else "直接 #include"))
print(" " + pad("合计", 13) + pad("%.0f KB" % (tot_b / 1024), 11) + pad(str(tot_l), 8) + "全部被“抄”进 hello.i")
self_b = SRC_BYTES
print()
print(" 展开倍数 = %d B ÷ %d B = %.0f 倍" % (tot_b, self_b, tot_b / self_b))
print(" 自己写的代码只占 %.3f%%" % (100.0 * self_b / tot_b))
print(" → #include 不是“导入模块”,是“把文件内容抄到这里”(lang/15)")
print()
print("=== 为什么可执行文件比 .o 大,但没 .i 那么大 ===")
PARTS = [
(".text 机器码", 1200),
(".rodata 只读数据", 400),
(".data + .bss", 300),
("ELF 头 + 程序头/节表", 1500),
("动态链接簿记", 3400),
("调试信息 -g", 9700),
]
tot = 0
for name, size in PARTS:
tot += size
print(" " + pad(name, 24) + pad("%d B" % size, 9) + "%.1f%%" % (100.0 * size / 16500))
print(" " + pad("合计", 24) + pad("%d B" % tot, 9) + "%.1f%%" % (100.0 * tot / 16500))
print()
print(" ① .o 里的符号表、重定位表在链接时被“消耗”掉 → 不在成品里")
print(" ② 但调试信息(-g)会一路带到最后,常占 60% 以上")
print(" ③ 所以 strip 一下能瘦一大圈:只删符号与调试信息,不动 .text")预期输出:
=== 一条四步流水线的产物体积(典型量级,示意) ===
产物 大小 内容形态 产出者
hello.c 200 B 人写的源码 —
hello.i 797.9 KB 预处理后的纯 C 文本 预处理器 -E
hello.s 4.0 KB 汇编文本 编译器 -S
hello.o 2.1 KB 目标文件(含符号表) 汇编器 -c
a.out 16.1 KB 可执行文件 链接器 ld
=== .i 为什么比 .c 大几百倍:数一数 #include 抄进来多少 ===
hello.c 的全部内容就是 4 行,其中 3 行是 #include
头文件 文件大小 行数 备注
stdio.h 50 KB 1200 直接 #include
string.h 20 KB 480 直接 #include
stdlib.h 30 KB 760 直接 #include
其它头文件 80 KB 1900 被反复引用
合计 180 KB 4340 全部被“抄”进 hello.i
展开倍数 = 184320 B ÷ 200 B = 922 倍
自己写的代码只占 0.109%
→ #include 不是“导入模块”,是“把文件内容抄到这里”(lang/15)
=== 为什么可执行文件比 .o 大,但没 .i 那么大 ===
.text 机器码 1200 B 7.3%
.rodata 只读数据 400 B 2.4%
.data + .bss 300 B 1.8%
ELF 头 + 程序头/节表 1500 B 9.1%
动态链接簿记 3400 B 20.6%
调试信息 -g 9700 B 58.8%
合计 16500 B 100.0%
① .o 里的符号表、重定位表在链接时被“消耗”掉 → 不在成品里
② 但调试信息(-g)会一路带到最后,常占 60% 以上
③ 所以 strip 一下能瘦一大圈:只删符号与调试信息,不动 .text三条结论:
| 结论 | 说明 |
|---|---|
.i 的体积由头文件决定 | #include 是抄文件——所以"少包含头文件"真能加快编译(也是"前置声明"这一技巧存在的理由) |
.o 里带着"将来要用的簿记" | 符号表 + 重定位表——它们让链接成为可能,但成品里不需要 |
| 成品的大头往往是"调试信息" | 发布版 strip 或 -s 能大幅瘦身,而 .text(真正的指令)通常只占很小一部分 |
例 3:为什么"分四步"比"一步到底"值钱
任务:把"每级产物能复用"这件事算成一笔账。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
N = 500 # 一个中等项目的 .c 数量
print("=== 场景:%d 个 .c 的项目,只改了 1 个文件 ===" % N)
print()
print(" 方案 A:每次“一步到底”(每步都从 .c 重新开始)")
full = N
print(" 重跑 预处理: %d 次" % full)
print(" 重跑 编译 : %d 次" % full)
print(" 重跑 汇编 : %d 次" % full)
print(" 重跑 链接 : 1 次")
print(" 合计重跑 : %d 个文件的全链路" % full)
print()
print(" 方案 B:保留 .o,只重跑受影响的(增量构建)")
inc = 1
print(" 重跑 预处理: %d 次" % inc)
print(" 重跑 编译 : %d 次" % inc)
print(" 重跑 汇编 : %d 次(只是被改的那个)" % inc)
print(" 重跑 链接 : 1 次(全量,但链接很快)")
print(" 合计重跑 : %d 个文件的全链路" % inc)
print()
print(" 加速比(按“.c 全链路次数”计)= %d ÷ %d = %d 倍" % (full, inc, full // inc))
print()
print("=== 每一步“人还能不能看懂” ===")
STAGES = [
(".c", "源码", "能", "人写的,最清楚"),
(".i", "预处理后", "能", "只是很长;宏已展开"),
(".s", "汇编", "勉强", "有标签和指令名;地址未定"),
(".o", "目标文件", "不能", "二进制;但有符号表可查"),
("a.out","可执行", "不能", "二进制;地址已定;可用反汇编看"),
]
print(" " + pad("阶段", 7) + pad("形态", 10) + pad("人能读", 8) + "说明")
for ext, kind, readable, note in STAGES:
print(" " + pad(ext, 7) + pad(kind, 10) + pad(readable, 8) + note)
print()
print("=== 每级能干什么“别的用法” ===")
USE = [
(".i", "看宏到底展开成什么;排查宏相关的报错"),
(".s", "看编译器生成了什么;-O0 与 -O2 对比;交叉编译的中间物"),
(".o", "增量编译的缓存单位;打包成静态库 .a"),
("a.out", "反汇编(objdump -d)核对指令;readelf 看段与符号"),
]
for a, b in USE:
print(" " + pad(a, 8) + b)预期输出:
=== 场景:500 个 .c 的项目,只改了 1 个文件 ===
方案 A:每次“一步到底”(每步都从 .c 重新开始)
重跑 预处理: 500 次
重跑 编译 : 500 次
重跑 汇编 : 500 次
重跑 链接 : 1 次
合计重跑 : 500 个文件的全链路
方案 B:保留 .o,只重跑受影响的(增量构建)
重跑 预处理: 1 次
重跑 编译 : 1 次
重跑 汇编 : 1 次(只是被改的那个)
重跑 链接 : 1 次(全量,但链接很快)
合计重跑 : 1 个文件的全链路
加速比(按“.c 全链路次数”计)= 500 ÷ 1 = 500 倍
=== 每一步“人还能不能看懂” ===
阶段 形态 人能读 说明
.c 源码 能 人写的,最清楚
.i 预处理后 能 只是很长;宏已展开
.s 汇编 勉强 有标签和指令名;地址未定
.o 目标文件 不能 二进制;但有符号表可查
a.out 可执行 不能 二进制;地址已定;可用反汇编看
=== 每级能干什么“别的用法” ===
.i 看宏到底展开成什么;排查宏相关的报错
.s 看编译器生成了什么;-O0 与 -O2 对比;交叉编译的中间物
.o 增量编译的缓存单位;打包成静态库 .a
a.out 反汇编(objdump -d)核对指令;readelf 看段与符号三条结论:
| 结论 | 说明 |
|---|---|
| 分阶段的核心理由是"产物可复用" | .o 就是天然的缓存单位——500 个文件的项目里,改一个只需重跑 1 条全链路 |
| 每级产物都有一个"别的用法" | .s 用来查编译器、.o 用来做增量、a.out 用来反汇编核对 |
| "看懂程度"随阶段递减 | .c/.i 能读、.s 勉强、.o 与成品读不了——所以每级都留了"往回看的工具"(-S、objdump、readelf) |
考点
考点
1. 四步的名字、开关与产物(必背)
| 步 | 开关 | 产物 | 谁做 | 本质 |
|---|---|---|---|---|
| 预处理 | -E | .i | 预处理器 | 文本插入/替换 |
| 编译 | -S | .s | 编译器 | C → 汇编(语义翻译) |
| 汇编 | -c | .o | 汇编器 as | 汇编 → 机器码 + 符号表 + 重定位表 |
| 链接 | 无(默认跑) | 可执行文件 | 链接器 ld | 符号解析 + 重定位 |
gcc -c a.c只到第 ③ 步(不链接);gcc a.c走完全部四步;gcc -o app a.o b.o是"只做第 ④ 步"。
2. 每步"产出什么、报什么错"
| 阶段 | 典型错误 | 例子 |
|---|---|---|
| 预处理 | #include 找不到文件 / 宏用法错 | fatal error: no such file |
| 编译 | 语法错误、类型错误(语义分析) | expected ';'、assignment to 'int' from 'int *' |
| 汇编 | 伪指令/寄存器名写错(手写 .s 时) | unknown instruction |
| 链接 | undefined reference / multiple definition | 见 lang/15、lang/22 |
- 关键判据:"编译能过、链接不过"必然与"跨文件"有关(名字对不上或没参与链接);
- "编译不过"必然是单文件内的语法/类型/预处理问题。
3. 预处理与编译的分界(高频)
- 预处理器不认识 C 语法——它只做文本处理;
.i是"合法的 C 代码",可以直接编译(gcc -S hello.i);- 宏在
.i里已被展开——所以调试器看不到宏、宏的错误报在展开处; #include是"抄文件"——.i的体积由头文件决定(例 2:200 B → 180 KB)。
4. 编译器内部六小步(考"哪一步报哪种错")
词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 优化 → 目标代码生成
- 语法错误(少分号)→ 语法分析阶段;
- 类型错误 / 未声明(
int *p = 5;、用了没声明的变量)→ 语义分析阶段(符号表在这一步用); missing ';'的报错位置常常"偏一行"——因为语法分析器要看见下一个 token 才能判断。
5. 优化:三件必会认的事
| 优化名 | 例子 | 效果 |
|---|---|---|
| 常量折叠 | 3 * 4 + 1 → 13 | 运行期不算了 |
| 公共子表达式消除 | (a+b)*(a+b) → 算一次 t = a+b | 少一次加法 |
| 强度削弱 | x * 2 → x << 1;x / 4 → x >> 2 | 乘法/除法换移位 |
| 死代码消除 | return c + d * 0; 里 d 整块被删 | 少一堆指令 |
-O0不优化、-O2/-O3优化级别递增;调试用-O0,发布用-O2;- 注意:
-O2下"单步调试"会跳来跳去(语句被重排/合并)——这是"优化与可调试性"的固有矛盾。
6. 目标文件与可执行文件的区别(易错)
.o(可重定位目标文件) | 可执行文件 | |
|---|---|---|
| 地址 | 段内地址从 0 开始,未必是最终地址 | 最终虚拟地址已定 |
| 入口点 | 没有 | 有(ELF 头里的 e_entry) |
| 符号表/重定位表 | 有(链接时要用) | 通常已剥掉 |
| 能否直接运行 | 不能 | 能 |
类型(ELF e_type) | ET_REL | ET_EXEC / ET_DYN |
7. 高频易错点
-c不是"编译"的全部——它只到汇编为止,不链接;.i不是"中间代码",它还是 C 源码(中间代码是编译器内部的 IR,不落盘);gcc a.c b.c不是"一次编译两个文件"——是"分别编译成 a.o、b.o,再链接";- 库要放在引用它的目标文件之后(
main.o -lm而不是-lm main.o); - 重定位在链接期做,不在编译期做——编译器只负责"留占位 + 记账";
.bss在文件里几乎不占空间(下一篇细讲:只记大小,加载时清零)。
小结
- 四步一线:
.c→(预处理-E)→.i→(编译-S)→.s→(汇编-c)→.o→(链接)→ 可执行文件。 - 第 ① 步是文本处理:预处理器不认识 C;
#include是抄文件,#define是文本替换。 - 第 ② 步是语义翻译:词法 → 语法 → 语义 → IR → 优化 → 目标代码;语法/类型错误在这一步报。
- 第 ③ 步是查表翻译:指令编码、伪指令展开、建符号表、生成重定位表——产物是"地址没填完的半成品"。
- 第 ④ 步是拼装:符号解析 + 重定位;"编译过、链接不过"是跨文件问题。
- 分阶段的价值在复用:
.o是增量构建的缓存单位(500 个文件里改一个,只需重跑 1 条全链路)。 - 优化发生在 IR 上:常量折叠、公共子表达式消除、强度削弱——"人写的 C"与"跑起来的码"不是一对一。
- 每级产物都留了"往回看的工具":
-S看汇编、objdump -d反汇编、readelf看段与符号。
回到主线:L2【机器】讲了"指令怎么被执行",本层这一篇回答的是"指令是怎么被生成出来的"——从 C 到机器码的这条翻译链,正是【由硅到 C】站名的落点。
但还有几个问题悬着:
.o这个"半成品"内部到底是什么形状?符号表长什么样、怎么读?链接器凭什么"填地址",那个算数怎么做?为什么有的程序要动态链接、有的是静态?程序被加载进内存时,代码段、数据段、堆、栈各摆在哪里?——接下来四篇(21-elf、22-link、23-dynamic、24-image)逐个拆开。
下一篇:目标文件与 ELF 格式
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。