以 Go语言 为例 - 从源码到机器码的完整旅程
编译器(Compiler)是一种将高级编程语言(源语言)编写的源代码,翻译成低级语言(目标语言)的程序。
简单来说:编译器就是把你写的代码(比如 Go、C、Java)转换成计算机能理解的机器码或字节码的工具。
编译器 = 专业翻译官
• 你(程序员)用中文(Go语言)写了一篇文章
• 编译器把它翻译成英文(机器码),让外国人(CPU)能理解
• 翻译过程中会检查语法错误、优化表达、确保意思准确
解释器 = 实时翻译(如 Python、JavaScript)
• 不需要预先翻译,边读边翻译边执行
• 慢一些,但更灵活
1. 读懂源代码:理解你写的代码是什么意思
2. 检查错误:语法错误、类型错误、语义错误
3. 优化代码:让生成的目标代码更快、更小
4. 生成目标代码:输出可执行文件或字节码
编译器的经典工作流程分为 7个阶段,每个阶段都有明确的输入和输出:
任务:理解源代码
• 词法分析
• 语法分析
• 语义分析
• 生成中间代码
特点:
• 与源语言相关
• 与目标机器无关
任务:生成目标代码
• 代码优化
• 目标代码生成
• 寄存器分配
• 指令选择
特点:
• 与源语言无关
• 与目标机器相关
输入:源代码字符串
输出:Token 序列(词法单元)
做什么:
1. 把源代码字符串切分成一个个 Token(词法单元)
2. 删除注释和空白字符
3. 报告词法错误(如非法字符)
// 源代码 package main func add(a int, b int) int { return a + b }
经过词法分析后,变成 Token 序列:
// 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
输入:Token 序列
输出:抽象语法树(AST)
做什么:
1. 根据语法规则,把 Token 序列组织成 语法树
2. 检查语法错误(如缺少括号、语法不正确)
3. 生成 抽象语法树(AST)
// 源代码 func add(a int, b int) int { return a + b }
生成的抽象语法树(AST):
代表算法:递归下降、LL(1)
思路:从根节点开始,逐步展开
优点:简单直观
缺点:不能处理左递归
Go 编译器使用:递归下降法
代表算法: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, } }
输入:抽象语法树(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)
符号表是语义分析的核心数据结构,用来记录:
• 变量名、函数名、类型名
• 它们的类型
• 它们的作用域
• 它们的内存偏移(后续代码生成用)
// 符号表示例 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 编译器的语义分析位于: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 }
问题:不同 CPU 架构的机器码不一样(x86、ARM64、MIPS...)
解决方案:先生成一种 中间表示(IR),再针对不同的目标平台生成机器码
好处:
• 前端复用:多个源语言可以共享同一个 IR
• 后端复用:一个前端可以生成多个目标平台的代码
• 优化方便:在 IR 上进行优化,不依赖具体硬件
源代码 = 设计图纸(人类可读)
中间代码(IR) = 标准化施工图纸(工程师可读,不依赖具体施工队)
目标代码 = 具体施工方案(施工队A用方案A,施工队B用方案B)
SSA(Static Single Assignment):静态单赋值形式
• 每个变量只被赋值一次
• 好处:便于优化(如常量传播、死代码消除)
Go 编译器的 IR:
• 位于 src/cmd/compile/internal/ssagen
• 使用 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
// 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 }
目标:让生成的代码 更快、更小
优化分类:
• 机器无关优化:在 IR 上进行,不依赖具体硬件
• 机器相关优化:在目标代码生成时进行,利用硬件特性
// 优化前 x := 3 + 4 // 优化后 x := 7
// 优化前 x := 10 y := x + 20 // 优化后 x := 10 y := 30 // 因为 x 是常量 10,所以 x+20 = 30
// 优化前 func foo() int { x := 10 y := 20 // y 没有被使用,是死代码 return x } // 优化后 func foo() int { x := 10 return x }
// 优化前:循环不变代码 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 }
// 优化前:函数调用 func double(x int) int { return x * 2 } func foo() int { return double(10) // 函数调用有开销 } // 优化后:内联展开 func foo() int { return 10 * 2 // 直接展开,省去函数调用开销 }
// 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)→ 决定变量栈分配还是堆分配
-gcflags="-l -N" 禁用优化(用于调试)
输入:优化后的 IR(SSA 形式)
输出:目标机器的汇编代码(或机器码)
核心任务:
1. 指令选择:把 IR 指令翻译成目标机器的指令
2. 寄存器分配:决定哪些变量放在寄存器中
3. 指令调度:重新排列指令顺序,提高流水线效率
4. 生成最终的机器码
// 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 中
问题: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 编译器的代码生成位于: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 编译器(gc)是一个 自举(self-bootstrapping) 的编译器:
• 用 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. 查看词法分析后的 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) }
# 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
# 生成汇编代码(不编译成机器码) 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 // 返回
# 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
src/cmd/compile/internal/-gcflags="-S" 查看汇编输出go/ast 和 go/parser 包自己写工具分析 Go 代码1️⃣ 编译器 = 翻译官:把高级语言翻译成机器码
2️⃣ 编译流程:词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 优化 → 目标代码生成
3️⃣ 前端 vs 后端:前端理解源代码,后端生成目标代码
4️⃣ 中间代码(IR):实现跨平台的关键
5️⃣ 代码优化:让程序更快、更小
6️⃣ Go 编译器:用 Go 写的高效编译器,支持多平台
# 推荐书籍 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 文件是如何变成可执行文件的?)
• 自己动手写一个编译器或解释器!