🔧 编译器工作原理详解

为例 - 从源码到机器码的完整旅程

📚 第一节:什么是编译器?

🔍 编译器的定义

编译器(Compiler)是一种将高级编程语言(源语言)编写的源代码,翻译成低级语言(目标语言)的程序。


简单来说:编译器就是把你写的代码(比如 Go、C、Java)转换成计算机能理解的机器码或字节码的工具。

📖 生活化类比:翻译官

编译器 = 专业翻译官

• 你(程序员)用中文(Go语言)写了一篇文章

• 编译器把它翻译成英文(机器码),让外国人(CPU)能理解

• 翻译过程中会检查语法错误、优化表达、确保意思准确


解释器 = 实时翻译(如 Python、JavaScript)

• 不需要预先翻译,边读边翻译边执行

• 慢一些,但更灵活

🏗️ 编译器的核心任务

1. 读懂源代码:理解你写的代码是什么意思

2. 检查错误:语法错误、类型错误、语义错误

3. 优化代码:让生成的目标代码更快、更小

4. 生成目标代码:输出可执行文件或字节码

💡 重要概念:
编译(Compile):提前翻译好,生成独立的可执行文件
解释(Interpret):边翻译边执行,不生成可执行文件
混合模式:Java、Python 先编译成字节码,再用虚拟机解释执行

🔄 第二节:编译流程概览

编译器的经典工作流程分为 7个阶段,每个阶段都有明确的输入和输出:

📝 源代码
1️⃣ 词法分析
Lexical Analysis
2️⃣ 语法分析
Syntax Analysis
3️⃣ 语义分析
Semantic Analysis
4️⃣ 中间代码生成
IR Generation
5️⃣ 代码优化
Optimization
6️⃣ 目标代码生成
Code Generation
⚙️ 目标代码

🔴 前端(Front-end)

任务:理解源代码

• 词法分析

• 语法分析

• 语义分析

• 生成中间代码


特点:

• 与源语言相关

• 与目标机器无关

🔵 后端(Back-end)

任务:生成目标代码

• 代码优化

• 目标代码生成

• 寄存器分配

• 指令选择


特点:

• 与源语言无关

• 与目标机器相关

🎯 为什么要分前端和后端?
为了实现 跨平台
• 前端只负责理解源代码(如 Go 语法)
• 后端负责生成不同平台的机器码(x86、ARM64、MIPS 等)
• 这样可以用同一个前端 + 多个后端,支持多种平台

🔤 第三节:词法分析(Lexical Analysis)

📖 词法分析的任务

输入:源代码字符串

输出:Token 序列(词法单元)


做什么:

1. 把源代码字符串切分成一个个 Token(词法单元)

2. 删除注释和空白字符

3. 报告词法错误(如非法字符)

🎯 示例:Token 化过程

// 源代码
package main

func add(a int, b int) int {
    return a + b
}
                

经过词法分析后,变成 Token 序列:

Token 序列:
PACKAGE(package)
IDENT(main)
DELIMITER(;)
FUNC(func)
IDENT(add)
DELIMITER(()
IDENT(a)
TYPE(int)
DELIMITER(,)
IDENT(b)
TYPE(int)
DELIMITER())
TYPE(int)
DELIMITER({)
RETURN(return)
IDENT(a)
OPERATOR(+)
IDENT(b)
DELIMITER(})

🛠️ Go 语言的词法分析器

// Go 编译器的词法分析器位于:src/cmd/compile/internal/scanner

// Token 类型定义(简化版)
type Token int

const (
    ILLEGAL Token = iota
    EOF
    COMMENT
    
    // 标识符和字面量
    IDENT   // main, add, x, ...
    INT     // 123
    FLOAT   // 3.14
    STRING  // "hello"
    
    // 运算符
    ADD // +
    SUB // -
    MUL // *
    DIV // /
    
    // 关键字
    PACKAGE // package
    FUNC    // func
    VAR     // var
    type    // type
    return  // return
    // ... 更多关键字
)
                

🔬 词法分析的实现原理

方法1:手写词法分析器

• 用一组状态机(DFA)识别 Token

• Go 编译器的 scanner 就是手写的


方法2:用工具生成(Lex/Flex)

• 写正则表达式规则,自动生成词法分析器

• 适合快速原型开发


有限状态机(DFA)示例:识别标识符

开始状态 → 读到字母 → 标识符状态 → 读到字母/数字 → 继续 → 读到其他 → 返回 IDENT

🌳 第四节:语法分析(Syntax Analysis)

📖 语法分析的任务

输入:Token 序列

输出:抽象语法树(AST)


做什么:

1. 根据语法规则,把 Token 序列组织成 语法树

2. 检查语法错误(如缺少括号、语法不正确)

3. 生成 抽象语法树(AST)

🎯 示例:从 Token 到 AST

// 源代码
func add(a int, b int) int {
    return a + b
}
                

生成的抽象语法树(AST):

📄 FuncDecl (函数声明)
🏷️ Name: "add"
📥 Params (参数列表)
Param: a (int)
Param: b (int)
📤 Return Type
Type: int
📴 Body (函数体)
return Statement
➕ BinaryOp: +
Ident: a
Ident: b

🛠️ 语法分析方法

📈 自顶向下分析(Top-Down)

代表算法:递归下降、LL(1)

思路:从根节点开始,逐步展开

优点:简单直观

缺点:不能处理左递归


Go 编译器使用:递归下降法

📉 自底向上分析(Bottom-Up)

代表算法:LR(1)、LALR

思路:从叶子节点开始,逐步归约

优点:能处理更多语法

缺点:实现复杂


工具:Yacc/Bison

// Go 编译器的语法分析器位于:src/cmd/compile/internal/syntax

// 简化的语法分析函数(递归下降法)
func (p *parser) parseFuncDecl() *FuncDecl {
    // 期望读到 "func" 关键字
    if !p.expect(FUNC) {
        return nil
    }
    
    // 读取函数名
    name := p.parseIdent()
    
    // 读取参数列表
    params := p.parseParamList()
    
    // 读取返回值类型(可选)
    var result *FieldList
    if p.tok == IDENT {
        result = p.parseResult()
    }
    
    // 读取函数体
    body := p.parseBlock()
    
    return &FuncDecl{
        Name:   name,
        Type:   &FuncType{Params: params, Results: result},
        Body:   body,
    }
}
                

🔍 第五节:语义分析(Semantic Analysis)

📖 语义分析的任务

输入:抽象语法树(AST)

输出:带类型信息的 AST(类型检查后的)


做什么:

1. 类型检查:确保类型使用正确(如不能把 string 赋值给 int)

2. 作用域分析:变量在哪里定义?在哪里可见?

3. 符号表管理:记录所有标识符的信息

4. 检查语义错误(如未定义变量、类型不匹配)

🎯 语义检查示例

// 这段代码的语法是对的,但语义有问题
package main

func main() {
    var x int = "hello"  // ❌ 语义错误:不能把 string 赋值给 int
    
    y = 10                      // ❌ 语义错误:y 未定义
    
    var z int = x + 10           // ❌ 语义错误:x 是 string,不能参与加法
}
                

编译器会报告这些错误:

./main.go:5:14: cannot use "hello" (type string) as type int in assignment

./main.go:7:5: undefined: y

./main.go:9:22: invalid operation: x + 10 (mismatched types string and int)
                

🛠️ 符号表(Symbol Table)

符号表是语义分析的核心数据结构,用来记录:

• 变量名、函数名、类型名

• 它们的类型

• 它们的作用域

• 它们的内存偏移(后续代码生成用)

// 符号表示例
type Symbol struct {
    Name  string      // 符号名称
    Kind  SymbolKind   // 种类:变量、函数、类型...
    Type  *Type        // 类型信息
    Scope *Scope       // 所属作用域
    Offset int         // 栈帧偏移(用于代码生成)
}

// 作用域示例
type Scope struct {
    Outer   *Scope              // 外层作用域(实现作用域嵌套)
    Symbols map[string]*Symbol // 当前作用域的符号
}

// 示例:这段代码的作用域结构
// package main
// func foo() {          ← Scope 1 (函数作用域)
//     x := 10           ← x 在 Scope 1
//     if true {         ← Scope 2 (块作用域,嵌套在 Scope 1 内)
//         y := 20       ← y 在 Scope 2
//     }
// }
                

🛠️ Go 编译器的语义分析

// Go 编译器的语义分析位于:src/cmd/compile/internal/types

// 类型检查示例(简化版)
func (tc *typeChecker) checkBinaryExpr(expr *BinaryExpr) {
    // 检查左右操作数的类型
    leftType := tc.checkExpr(expr.Left)
    rightType := tc.checkExpr(expr.Right)
    
    // 检查类型是否匹配
    if !tc.isCompatible(leftType, rightType) {
        tc.reportError(expr.Pos, 
            "invalid operation: %s (mismatched types %s and %s)",
            expr.Op, leftType, rightType)
        return
    }
    
    // 设置表达式的类型
    expr.Type = leftType
}
                

🔗 第六节:中间代码生成(IR Generation)

📖 为什么要中间代码(IR)?

问题:不同 CPU 架构的机器码不一样(x86、ARM64、MIPS...)

解决方案:先生成一种 中间表示(IR),再针对不同的目标平台生成机器码


好处:

前端复用:多个源语言可以共享同一个 IR

后端复用:一个前端可以生成多个目标平台的代码

优化方便:在 IR 上进行优化,不依赖具体硬件

🏗️ 建筑类比

源代码 = 设计图纸(人类可读)

中间代码(IR) = 标准化施工图纸(工程师可读,不依赖具体施工队)

目标代码 = 具体施工方案(施工队A用方案A,施工队B用方案B)

🎯 Go 语言的中间表示(SSA)

SSA(Static Single Assignment):静态单赋值形式

• 每个变量只被赋值一次

• 好处:便于优化(如常量传播、死代码消除)


Go 编译器的 IR:

• 位于 src/cmd/compile/internal/ssagen

• 使用 SSA 形式的 IR

📝 示例:从 AST 到 SSA IR

// 源代码
func add(a int, b int) int {
    return a + b
}
                

生成的 SSA IR(简化版):

// SSA IR 表示
func add [a: int, b: int] int
  b1:
    v1 = Param "a" [int]
    v2 = Param "b" [int]
    v3 = AddInt v1 v2 [int]
    Ret v3
                
💡 SSA 的关键特点:
• 每个变量(v1, v2, v3)只被赋值一次
• 这样编译器可以很容易地追踪每个值是怎么来的
• 便于进行各种优化(常量传播、死代码消除、循环优化等)

🛠️ Go 编译器的 SSA 生成

// Go 编译器的 SSA 生成位于:src/cmd/compile/internal/ssagen

// 生成 SSA IR 的简化流程
func genSSA(fn *Func) {
    // 1. 创建 SSA 函数
    f := NewFunc()
    
    // 2. 为参数创建 SSA 值
    for _, param := range fn.Params {
        v := f.NewValue(OpParam, param.Type)
        param.SetSSAValue(v)
    }
    
    // 3. 遍历 AST,生成 SSA 指令
    walkAST(fn.Body, func(node Node) {
        switch n := node.(type) {
        case *BinaryExpr:
            // 生成 Add 指令
            v := f.NewValue(OpAdd, n.Type)
            v.AddArg(n.Left.SSAValue())
            v.AddArg(n.Right.SSAValue())
            n.SetSSAValue(v)
        }
    })
    
    // 4. 返回 SSA 函数
    return f
}
                

⚡ 第七节:代码优化(Optimization)

📖 代码优化的目标

目标:让生成的代码 更快更小


优化分类:

机器无关优化:在 IR 上进行,不依赖具体硬件

机器相关优化:在目标代码生成时进行,利用硬件特性

🎯 常见优化技术

1️⃣ 常量折叠(Constant Folding)

// 优化前
x := 3 + 4

// 优化后
x := 7
                    

2️⃣ 常量传播(Constant Propagation)

// 优化前
x := 10
y := x + 20

// 优化后
x := 10
y := 30  // 因为 x 是常量 10,所以 x+20 = 30
                    

3️⃣ 死代码消除(Dead Code Elimination)

// 优化前
func foo() int {
    x := 10
    y := 20  // y 没有被使用,是死代码
    return x
}

// 优化后
func foo() int {
    x := 10
    return x
}
                    

4️⃣ 循环优化(Loop Optimization)

// 优化前:循环不变代码
for i := 0; i < n; i++ {
    x := a + b  // a+b 在循环里不变,每次都计算是浪费
    arr[i] = x
}

// 优化后:代码外提(Code Motion)
x := a + b  // 提到循环外面,只计算一次
for i := 0; i < n; i++ {
    arr[i] = x
}
                    

5️⃣ 内联(Inlining)

// 优化前:函数调用
func double(x int) int {
    return x * 2
}

func foo() int {
    return double(10)  // 函数调用有开销
}

// 优化后:内联展开
func foo() int {
    return 10 * 2  // 直接展开,省去函数调用开销
}
                    

🛠️ Go 编译器的优化

// Go 编译器的优化位于:src/cmd/compile/internal/ssa

// Go 编译器会进行多种优化(在 SSA IR 上)
// 可以通过 -gcflags="-d=ssa/check_bce/debug" 查看优化过程

// 示例:查看 Go 编译器的优化
// 命令:go build -gcflags="-d=ssa/check_bce/debug" main.go

// 常见优化(Go 编译器会做):
// 1. 边界检查消除(Bounds Check Elimination)
// 2. 死代码消除(Dead Code Elimination)
// 3. 常量传播(Constant Propagation)
// 4. 循环不变代码外提(Loop Invariant Code Motion)
// 5. 函数内联(Function Inlining)
// 6. 逃逸分析(Escape Analysis)→ 决定变量栈分配还是堆分配
                
⚡ Go 编译器的特点:
• Go 编译器的优化 相对保守(相比 GCC、LLVM)
• 目的:编译速度快,而不是极致运行速度
• 但足够聪明:逃逸分析、内联、边界检查消除等都做了
• 可以通过 -gcflags="-l -N" 禁用优化(用于调试)

⚙️ 第八节:目标代码生成(Code Generation)

📖 目标代码生成的任务

输入:优化后的 IR(SSA 形式)

输出:目标机器的汇编代码(或机器码)


核心任务:

1. 指令选择:把 IR 指令翻译成目标机器的指令

2. 寄存器分配:决定哪些变量放在寄存器中

3. 指令调度:重新排列指令顺序,提高流水线效率

4. 生成最终的机器码

🎯 示例:从 SSA IR 到 x86-64 汇编

// SSA IR
func add [a: int, b: int] int
  b1:
    v1 = Param "a" [int]
    v2 = Param "b" [int]
    v3 = AddInt v1 v2 [int]
    Ret v3
                

生成的 x86-64 汇编:

; x86-64 汇编(Linux/AMD64 调用约定)
add:
    ; 参数:a 在 EDI,b 在 ESI
    MOV EAX, EDI    ; EAX = a
    ADD EAX, ESI    ; EAX = EAX + b
    RET                  ; 返回,结果在 EAX 中
                

生成的 ARM64 汇编:

; ARM64 汇编(AAPCS64 调用约定)
add:
    ; 参数:a 在 W0,b 在 W1
    ADD W0, W0, W1   ; W0 = W0 + W1
    RET                   ; 返回,结果在 W0 中
                

🛠️ 寄存器分配(Register Allocation)

问题:CPU 的寄存器数量有限(x86-64 有 16 个,ARM64 有 31 个),但变量可能很多。

目标:决定哪些变量放在寄存器中,哪些需要 spill 到内存(栈)中。


常用算法:

图着色算法:把寄存器分配问题转化成图着色问题

线性扫描算法:更简单、更快,Go 编译器使用

// Go 编译器的寄存器分配位于:src/cmd/compile/internal/ssa/regalloc.go

// 简化的寄存器分配逻辑
func allocRegs(f *Func) {
    // 1. 构建变量生存期(Live Range)
    liveRanges := buildLiveRanges(f)
    
    // 2. 按照生存期排序
    sortByLiveRange(liveRanges)
    
    // 3. 线性扫描分配寄存器
    for _, val := range liveRanges {
        // 找一个空闲的寄存器
        reg := findFreeReg(val.Type)
        if reg != nil {
            val.SetReg(reg)
        } else {
            // 没有空闲寄存器,需要 spill 到栈
            spillToStack(val)
        }
    }
}
                

🛠️ Go 编译器的代码生成

// Go 编译器的代码生成位于:src/cmd/compile/internal/ssa

// 不同架构的代码生成
// src/cmd/compile/internal/amd64  - x86-64 代码生成
// src/cmd/compile/internal/arm64  - ARM64 代码生成
// src/cmd/compile/internal/mips64 - MIPS64 代码生成
// ... 其他架构

// 示例:生成 ADD 指令(x86-64)
func genAdd(v *Value, s *State) {
    switch v.Type.Size() {
    case 4:
        // 32位加法:ADD Lr, Rl
        s.ADD(4, v.Args[0].Reg(), v.Args[1].Reg(), v.Reg())
    case 8:
        // 64位加法:ADD Lr, Rl
        s.ADD(8, v.Args[0].Reg(), v.Args[1].Reg(), v.Reg())
    }
}
                

🚀 第九节:Go 编译器实战

🏗️ Go 编译器的架构

Go 编译器(gc)是一个 自举(self-bootstrapping) 的编译器:

• 用 Go 语言编写

• 编译 Go 源代码

• 输出目标机器的机器码

📂 Go 编译器的源码结构

# Go 编译器的源代码位于 Go 源码的 src/cmd/compile/internal/

src/cmd/compile/internal/
├── syntax/          # 词法分析 + 语法分析(Parser)
├── types/           # 类型检查(Type Checker)
├── ssagen/          # SSA IR 生成
├── ssa/             # SSA 优化 + 寄存器分配
├── amd64/           # x86-64 代码生成
├── arm64/           # ARM64 代码生成
├── mips64/          # MIPS64 代码生成
├── wasm/            # WebAssembly 代码生成
└── ...              # 其他架构

# 编译命令
go build            # 编译当前目录的 Go 程序
go tool compile     # 直接调用编译器(生成 .o 文件)
go tool link        # 链接器(生成可执行文件)
                

🔬 实战1:查看 Go 编译过程

# 1. 查看词法分析后的 Token(不支持,但可以用 go/parser 包自己写工具)

# 2. 查看 AST(抽象语法树)
go tool compile -W main.go   # 打印 AST(旧版本)

# 更好的方式:用 go/ast 包写程序查看 AST
# 示例:
package main

import (
    "go/ast"
    "go/parser"
    "go/token"
    "fmt"
)

func main() {
    fset := token.NewFileSet()
    node, _ := parser.ParseFile(fset, "main.go", nil, 0)
    
    // 打印 AST
    ast.Print(fset, node)
}
                

🔬 实战2:查看 SSA IR

# Go 编译器可以在编译时打印 SSA IR

# 1. 查看 SSA 生成过程(非常详细)
go build -gcflags="-d=ssa/check_bce/debug" main.go

# 2. 查看优化前的 SSA IR
go build -gcflags="-d=ssa/gen/debug" main.go

# 3. 查看优化后的 SSA IR
go build -gcflags="-d=ssa/check_bce/debug" main.go

# 4. 查看最终生成的汇编代码
go build -gcflags="-S" main.go

# 示例输出(SSA IR):
func add [a: int, b: int] int
  b1:
    v1 = Param "a" [int]
    v2 = Param "b" [int]
    v3 = AddInt v1 v2 [int]
    Ret v3
                

🔬 实战3:查看最终汇编代码

# 生成汇编代码(不编译成机器码)
go build -gcflags="-S" main.go

# 示例输出(x86-64 架构):
"".add STEXT noseal
  TEXT "".add(SB), NOSPLIT, $0-24
    MOVQ  AX, BX        // 把参数 a 移到 BX
    ADDQ  BX, AX        // AX = AX + BX
    RET                      // 返回
                

🔬 实战4:Go 编译器的优化选项

# Go 编译器的优化选项

# 1. 禁用优化(用于调试)
go build -gcflags="-N -l" main.go
# -N: 禁用优化
# -l: 禁用内联

# 2. 开启所有优化(默认)
go build -gcflags="-O" main.go

# 3. 查看优化决策
go build -gcflags="-m" main.go
# 会打印:内联决策、逃逸分析决策等

# 示例输出(-gcflags="-m"):
# ./main.go:10:6: can inline add
# ./main.go:15:8: inlining call to add
# ./main.go:20:10: leaking param: x
# ./main.go:25:12: moved to heap: y
                
🎯 如何深入学习 Go 编译器?
1. 阅读 Go 源码:src/cmd/compile/internal/
2. 写测试程序,用 -gcflags="-S" 查看汇编输出
3. 用 go/astgo/parser 包自己写工具分析 Go 代码
4. 参考《Go 编译器源码剖析》等相关资料

📝 第十节:总结

🎓 核心要点回顾

1️⃣ 编译器 = 翻译官:把高级语言翻译成机器码

2️⃣ 编译流程:词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 优化 → 目标代码生成

3️⃣ 前端 vs 后端:前端理解源代码,后端生成目标代码

4️⃣ 中间代码(IR):实现跨平台的关键

5️⃣ 代码优化:让程序更快、更小

6️⃣ Go 编译器:用 Go 写的高效编译器,支持多平台

🗺️ 编译流程完整图示

完整的编译流程
源代码
.go 文件
词法分析
scanner
语法分析
parser
语义分析
type checker
SSA IR 生成
ssagen
优化
ssa pass
代码生成
amd64/arm64/...
目标代码
.o 文件
链接
linker
可执行文件
a.out / main

📚 进一步学习资源

# 推荐书籍
1. 《编译原理》(龙书) - Alfred V. Aho 等
   # 编译器领域的圣经
   
2. 《Modern Compiler Implementation in {C, Java, ML}》 - Andrew Appel
   # 更现代的编译器教材
   
3. 《Advanced Compiler Design and Implementation》 - Steven Muchnick
   # 深入代码优化

# 在线资源
- Go 编译器源码: https://github.com/golang/go/tree/master/src/cmd/compile/internal
- LLVM 项目: https://llvm.org/  # 现代编译器基础设施
- Crafting Interpreters: https://craftinginterpreters.com/  # 写解释器的好书

# 实战项目
1. 写一个简单的计算器(词法分析 + 语法分析)
2. 写一个简易的编程语言(实现完整编译器)
3. 阅读 Go / Rust 编译器的源码
4. 贡献到 LLVM 或 GCC 项目
                

🎉 恭喜你完成了编译器原理的学习!

现在你应该理解了:

• 编译器是如何把源代码转换成机器码的

• Go 编译器的内部工作原理

• 如何查看和分析 Go 编译过程


下一步:

• 深入学习操作系统(编译器生成的代码是如何被 CPU 执行的?)

• 学习链接器(编译器生成的 .o 文件是如何变成可执行文件的?)

• 自己动手写一个编译器或解释器!