对话:编译过程 - 从文本到机器码
前置知识: [00-overview.md] 难度: ⭐⭐⭐ 预计时间: 45分钟 关键词: 词法分析, 语法分析, AST, 中间表示, 代码生成, 目标文件
引入场景
你写了一行 x := a + b * c,对你来说这是一个简单的数学运算。但CPU不认识字母,只认识0和1。从你写的文本到CPU能执行的二进制指令之间,编译器做了异常精密的工作。
对话探索
问题1:编译器看到 x := a + b * c 时,第一步做什么?它怎么知道这是一个赋值语句?
💭 思考方向:
- 人类阅读文字时,第一步是识别单个词语
- 编译器需要先识别”x”、”:=”、“a”、”+“这些独立的元素
- 这和人类学外语时先学词汇的道理一样
📖 参考答案: 第一步是词法分析(Lexical Analysis)——把字符流切分成有意义的”词”(Token):
输入: "x := a + b * c"
输出(Token序列):
[标识符:x] [赋值::=] [标识符:a] [加号:+] [标识符:b] [乘号:*] [标识符:c]
词法分析器逐字符扫描,根据规则识别:
- 标识符:字母开头的字符串(x, a, b, c)
- 运算符:+, *, :=
- 空白和注释:跳过
类比:就像阅读英语,你先识别每个单词(word),再理解句子的意思。
问题2:有了Token序列后,编译器如何理解 b * c 应该先于 a + 计算?毕竟Token是线性排列的。
💭 思考方向:
- 数学中的运算优先级
- 线性的Token序列无法表达嵌套结构
- 编译器需要把线性结构转换为什么?
📖 参考答案: 第二步是语法分析(Parsing)——将线性Token序列构建为抽象语法树(AST):
:=
/ \
x +
/ \
a *
/ \
b c
AST的结构自然表达了运算优先级:
*在+的下层,意味着先计算- 树的叶子节点是操作数,内部节点是运算符
编译器使用语法规则(如优先级和结合性)来决定树的形状。这就是为什么 a + b * c 和 (a + b) * c 生成不同的AST。
问题3:有了AST,编译器可以直接生成机器码吗?还是中间还有步骤?
💭 思考方向:
- 不同CPU架构(x86、ARM)的指令不同
- 编译器需要做优化(消除冗余、简化表达式)
- 如果每种语言都直接生成每种CPU的代码,需要多少个”翻译器”?
📖 参考答案: 通常不会直接生成机器码,中间还要经过中间表示(IR)和优化:
源代码 → 词法分析 → 语法分析(AST) → 语义分析 → 中间表示(IR) → 优化 → 机器码
前端(语言相关) 后端(硬件相关)
为什么要有IR?
这是一个精妙的架构设计:
- 前端:每种语言(Go/C/Rust)生成统一的IR → N个前端
- 后端:从IR生成不同架构的机器码 → M个后端
- 总共:N + M 个组件,而不是 N × M 个
Go ──→ ──→ x86
C ──→ IR(统一) ──→ ARM
Rust ─→ ──→ RISC-V
这就是抽象层的威力——通过中间层解耦前端和后端。
问题4:语义分析是做什么的?和语法分析有什么区别?
💭 思考方向:
- “我吃了一个星期”语法正确但语义可疑
var x int = "hello"语法正确吗?语义正确吗?- 编译器的类型检查属于哪个阶段?
📖 参考答案: 语法分析检查”结构是否合法”,语义分析检查”含义是否合理”:
var x int = "hello" // 语法正确(赋值语句的结构没问题)
// 语义错误(类型不匹配:int ≠ string)语义分析主要做:
- 类型检查:表达式两边类型是否兼容
- 变量声明检查:变量是否已声明、是否重复声明
- 作用域解析:变量名引用的是哪个声明
- 常量折叠:编译期能算出来的就先算(如
1 + 2→3)
Go的编译器在这一步会特别严格——未使用的变量、未使用的import都会报错。这就是那些编译错误的来源。
问题5:编译完成后输出的目标文件(.o文件)里面是什么?
💭 思考方向:
- 已经是机器码了吗?
- 一个函数调用了另一个文件的函数,此时地址知道吗?
- .o文件能直接运行吗?
📖 参考答案: 目标文件包含机器码,但还不完整:
目标文件(.o)结构:
┌─────────────────────┐
│ 文件头 │ 文件类型、目标架构
├─────────────────────┤
│ .text(代码段) │ 已翻译的机器码
├─────────────────────┤
│ .data(数据段) │ 已初始化的全局变量
├─────────────────────┤
│ .bss(未初始化数据段) │ 未初始化的全局变量(不占文件空间)
├─────────────────────┤
│ 符号表 │ 定义和引用的函数/变量列表
├─────────────────────┤
│ 重定位表 │ 需要在链接时修改的位置
└─────────────────────┘
关键点:
- 机器码已生成,但调用外部函数的地方留着”空位”
- 符号表记录:我定义了哪些函数、我需要哪些外部函数
- 重定位表记录:代码中哪些位置需要在链接时填入正确地址
所以.o文件不能直接运行——它还需要链接器把所有”空位”填上。
Go语言实践
// 创建一个简单的Go文件来观察编译过程
package main
import "fmt"
func add(a, b int) int {
return a + b
}
func main() {
result := add(3, 5)
fmt.Println(result)
}# 查看Go编译器生成的汇编(中间表示的近似)
$ go tool compile -S main.go | head -30
# 你会看到类似这样的输出:
# main.add STEXT nosplit size=15
# 0x0000 TEXT main.add(SB), NOSPLIT, $0-24
# 0x0000 MOVQ "".b+16(SP), AX
# 0x0005 ADDQ "".a+8(SP), AX
# ...
# 查看AST(Go编译器的语法树输出)
$ go tool compile -W main.go 2>&1 | head -50
# 会看到Go编译器内部的AST结构
# 编译为目标文件
$ go tool compile -o main.o main.go
# 查看目标文件的符号表
$ go tool nm main.o | head -20深入细节
Go编译器的完整流程
Go编译器(cmd/compile)的内部流程:
1. 词法分析 + 语法分析(scanner + parser)
→ 生成AST
2. 类型检查(typecheck)
→ 类型推导、接口检查、常量折叠
3. 中间表示生成(SSA - Static Single Assignment)
→ Go使用SSA形式的IR
4. 优化 passes
→ 死代码消除、内联展开、逃逸分析、边界检查消除
5. 机器码生成
→ 根据目标架构(GOARCH)生成指令
6. 输出目标文件
逃逸分析(Go特有)
Go编译器在编译阶段做逃逸分析,决定变量分配在栈上还是堆上:
func escape() *int {
x := 42 // x 逃逸到堆上——因为返回了指针
return &x
}
func noEscape() int {
x := 42 // x 不逃逸——分配在栈上,更快
return x
}# 查看逃逸分析结果
$ go build -gcflags="-m" main.go
# 输出类似:./main.go:3:2: moved to heap: x这是Go编译器的独特优势——自动决定内存分配位置,程序员不需要手动管理。
小结
⭐ 核心要点:
- 编译四步骤:词法分析(切词)→ 语法分析(建树)→ 语义分析(检查含义)→ 代码生成(输出机器码)
- IR的精妙:通过中间表示解耦语言和硬件,N+M 而不是 N×M
- 目标文件是半成品:有机器码但引用外部符号的地方是”空的”,需要链接器来填