对话:编译过程 - 从文本到机器码


前置知识: [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. 作用域解析:变量名引用的是哪个声明
  4. 常量折叠:编译期能算出来的就先算(如 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编译器的独特优势——自动决定内存分配位置,程序员不需要手动管理。

小结

⭐ 核心要点:

  1. 编译四步骤:词法分析(切词)→ 语法分析(建树)→ 语义分析(检查含义)→ 代码生成(输出机器码)
  2. IR的精妙:通过中间表示解耦语言和硬件,N+M 而不是 N×M
  3. 目标文件是半成品:有机器码但引用外部符号的地方是”空的”,需要链接器来填

关联阅读