1.1 编译器到底做什么
编译器并不只是“把代码变成机器码”的程序。这个说法没有错,但它把真正的工程结构隐藏起来了。更准确地说,编译器是一组分析和变换:它不断改变程序的表示形式,同时尽力保持程序的语义不变。最开始,程序是人写出来的文本;最后,它可能变成机器码、字节码、目标文件、另一个编译器的中间产物,甚至另一种高级语言。中间每一步都在问几个问题:这里有什么结构?哪些规则必须成立?哪些事实可以证明?下一阶段最适合处理哪种表示?
本课程会把编译器看成一个分层系统。每一层都有契约。词法分析器(lexer)不需要理解函数调用;它负责把字符分组成词法单元(token)。语法分析器(parser)不需要知道目标机器寄存器;它负责识别语法结构。名字解析器(resolver)把变量使用连接到声明。类型检查器(type checker)证明操作符合语言类型规则。中间表示(IR)生成器把丰富语法降低成更简单的表示。优化器(optimizer)在保持可观察行为的前提下重写表示。后端(backend)把表示映射到某种执行模型。
这个分层视角非常重要,因为编译器失败通常不是随机的。语法错误和未声明变量不是同一层的问题。类型不匹配和链接阶段的未定义外部符号也不是同一层的问题。错误优化比拒绝程序更危险,因为它可能悄悄改变程序行为。因此,真实编译器不是一个神秘的大翻译函数,而是一组小而严肃的证明义务。
核心流水线
经典教学流水线通常写成:
source text
-> lexer
-> token stream
-> parser
-> syntax tree / AST
-> resolver and type checker
-> checked AST
-> IR generation
-> intermediate representation
-> optimization
-> optimized IR
-> code generation
-> target code真实编译器可能拆分、合并、重复或跳过某些阶段。C 和 C++ 在解析前有预处理。Java 把源代码编译成 JVM 字节码。JavaScript 引擎可能先解析、解释执行、收集性能数据(profile)、编译热点代码、假设失效后去优化(deopt),然后再重新编译。Rust 和 Swift 在代码生成前做大量语义检查。基于 LLVM 的编译器常把语言专属 AST 降低成 LLVM IR,然后复用中端和后端。阶段名字会变,但核心思想反复出现:每个阶段都把一种表示变成另一种更结构化、更显式或更适合下一阶段的表示。
可以把每一层看成一个检查点。如果词法分析器静默接受非法字符,后面所有阶段都会继承混乱。如果语法分析器构造了错误的树,类型检查器可能报告误导性的错误。如果类型检查器放过了不可能的操作,后端可能被迫为运行时无法支持的情况生成代码。健壮编译器会让非法状态很难被表示出来。
表示形式决定工程边界
编译器设计中最重要的问题经常不是“该用哪个算法”,而是“这个阶段应该消费和产生什么表示”。词法单元流(token stream)适合语法识别,但不适合类型检查。抽象语法树(Abstract Syntax Tree, AST)适合用户诊断,因为它仍然接近源程序。控制流图适合优化,因为分支和循环变得显式。静态单赋值(SSA)适合数据流推理,因为每个变量定义都有清晰身份。机器指令适合调度、寄存器分配和 ABI,但它对于源语言类型规则来说太低级。
这就是编译器充满中间表示的原因。初学者可能尝试从语法分析器直接生成汇编,但这个设计很快会不可维护。假设语言后来加入 if、函数调用、局部变量、闭包或对象,直接“语法树到汇编”会让每个语法特性知道太多目标机器细节。更好的设计是让前端描述程序的含义,再让后续阶段决定如何实现这个含义。
表示形式也决定诊断质量。如果语法分析器丢掉源码范围,类型检查器就无法精确指向代码。如果名字解析器不记录符号声明位置,错误消息就无法说“上一次声明在这里”。如果优化器丢失映射信息,调试器就无法把优化后的代码解释回源码变量。编译器架构同时也是用户体验架构。
前端、中端与后端
编译器工程师常把系统分成三个区域:
- 前端理解源语言。
- 中端分析并优化相对语言无关的表示。
- 后端理解目标执行环境。
前端处理语法、声明、模块、重载解析、类型检查、借用检查、模式检查和大多数用户诊断。它的职责是拒绝源语言中不合法的程序,并为合法程序产生经过检查的表示。
中端处理大量优化:常量折叠、死代码消除、公共子表达式消除、内联、循环优化、逃逸分析和数据流分析。中端必须保守。它可以把 2 + 3 替换成 5,但不能在无法证明函数无相关副作用且总返回同一值时,把函数调用替换成常量。
后端处理目标相关细节:指令选择、寄存器分配、栈帧布局、调用约定、重定位、调试信息和目标文件生成。x86-64 后端和 ARM64 或 WebAssembly 后端的约束不同。即使两个目标支持类似操作,它们的寄存器、指令编码、ABI 和内存模型也会不同。
诊断也是编译器的一部分
高级编译器工作不只是接受正确程序,还要以有帮助的方式拒绝错误程序。一个只输出 error 的编译器虽然拒绝了程序,但工程上远远不够。好的诊断通常应该包含稳定错误码、严重级别、主源码范围、简洁消息、辅助标签,以及可能的修复建议。
例如:
let ok: bool = 42;弱诊断只会说:
type error有用诊断会说:
error[E0301]: cannot assign int to bool
--> main.mini:1:16
|
1 | let ok: bool = 42;
| ---- ^^ this expression has type int
| |
| variable declared as bool here
help: change the variable type to int, or compare the number to produce bool这样的消息依赖前面的架构决策。词法分析器和语法分析器必须保留源码位置。类型检查器必须知道期望类型和实际类型。诊断层必须支持标签、相关范围和建议。编译器内部表示和用户界面是连在一起的。
让一行程序穿过整条流水线
以 let total: int = price * count + 3; 为例。词法分析器先产生 LET IDENT COLON IDENT EQUAL IDENT STAR IDENT PLUS INT SEMICOLON,并保留每个词法单元的拼写与源码区间;语法分析器再把右侧组织为 +( *(price, count), 3 );名字解析器将 price、count 绑定到声明;类型检查器证明乘法和赋值合法;降低(lowering)可生成 %0=load price、%1=load count、%2=mul %0,%1、%3=add %2,3。寄存器、栈槽和指令选择是更后面的工作。
这给出调试原则:检查最早可能携带错误的表示。缺分号不是类型问题,错误括号结构不是寄存器分配问题;只在优化后出错时,应先检查中间表示。成熟编译器会提供词法单元、抽象语法树、绑定、类型、中间表示与汇编的转储。
工程检查表
- 为跨阶段数据写出不变量:词法单元有半开区间、名字要么已绑定要么已有诊断、表达式要么有类型要么有错误类型。
- 把“发现错误”和“渲染诊断”分开;同一结构化错误才能服务终端、IDE、JSON 与修复建议。
- 保留可观测性。无法打印抽象语法树、中间表示或解释优化的编译器会非常难维护。