编译原理
编译器是把高级语言翻译成机器可执行代码的程序,本质是一条"降级"流水线:把人类友好的文本,逐级翻译成机器友好的指令。它解决的痛点:CPU 只认识 0/1 指令,人直接写机器码几乎不可能——编译器在两者之间架桥,同时承担语法检查、语义校验和性能优化三重职责。理解编译过程,写代码时会知道"语言特性最终变成了什么"。
提示
编译不是一次性的"翻译",而是逐级检查与改写:每一级都验证上一层输出的正确性,再做局部变换。越靠前的阶段越接近"人",越靠后越接近"机器"。本文用一个表达式 result = a + b * 3 走完整个流水线,每个阶段的产物都是下一阶段的输入。
编译流水线
编译器分六个阶段,前三个阶段(分析)读入源码,后三个阶段(综合)产出目标代码:
| 阶段 | 输入 → 输出 | 检查什么 |
|---|---|---|
| 词法分析 | 字符流 → 单词(Token) | 拼写合法性:int、123、+ 是否正确成词 |
| 语法分析 | Token 流 → 语法树 | 结构合法性:if (a) { } 括号是否匹配、语句顺序是否合法 |
| 语义分析 | 语法树 → 带类型语法树 | 含义合法性:变量是否声明、类型是否匹配、作用域是否正确 |
| 中间代码生成 | 语法树 → 三地址码/IR | 生成与机器无关的中间表示 |
| 代码优化 | IR → 优化后 IR | 消除冗余计算、死代码,提升性能 |
| 目标代码生成 | IR → 汇编/机器码 | 寄存器分配、指令选择 |
各阶段出错的表现
语法错误(拼错关键字、缺括号)在语法分析阶段就报错;类型不匹配(把字符串当数字相加)在语义分析阶段报错;而逻辑错误(程序能编译但结果不对)编译器管不了——编译器的检查只到"程序有意义",不到"程序正确"。
前端:从字符到语法树
词法分析
词法分析器(lexer)把字符流切成 Token,像分词器把句子切成词。result = a + b * 3 的切分结果:
| Token | 类型 | 说明 |
|---|---|---|
result | 标识符 | 变量名,长度不限,词法上只看"是不是合法的名字" |
= | 运算符 | 赋值号 |
a b | 标识符 | 变量名 |
+ * | 运算符 | 算术运算符 |
3 | 数字字面量 | 整数常量 |
词法阶段不关心"result 有没有声明"——那是语义阶段的事;它只回答"这串字符能不能拆成合法的词"。判断靠正则表达式:[a-zA-Z_][a-zA-Z0-9_]* 匹配标识符,[0-9]+ 匹配整数。
语法分析
语法分析器(parser)把 Token 组装成语法树,树的嵌套结构直接反映运算优先级——a + b * 3 中乘号节点挂在加号节点的右子树里,对应"先乘后加":
语法树是编译前端的核心产物:一旦建好,后续阶段不再看源码文本。语法分析用文法描述"什么结构是合法的":比如赋值语句的规则是"标识符 = 表达式",表达式的规则是"表达式 + 项 | 项"——递归的规则定义嵌套的结构,这是上下文无关文法的核心思想。
歧义与优先级是怎么定下来的
a + b * 3 也可以画成"加号在乘号上面"或"乘号在加号上面"两棵树,这就是歧义。文法通过分层消除歧义:定义"表达式由项相加组成、项由因子相乘组成",强制乘号的树层级低于加号,优先级就固定在文法里了。
语义分析
语义分析回答"这个程序有没有意义":变量声明了吗?类型匹配吗?作用域对吗?它维护一个符号表(哈希表 实现的变量登记簿),记录每个变量的类型和作用域。分析 result = a + b * 3 时,查表确认 result、a、b 都已声明且类型兼容,否则报"未声明的变量"或"类型不匹配"——这是 Java 报错最密集的阶段。
中端:中间代码与优化
中间代码(IR)是编译器设计的核心决策:前端只依赖语言特性,后端只依赖机器特性,中间层让"新语言"和"新 CPU"可以独立接入——加一种语言只需写前端,加一种架构只需写后端。GCC 支持几十种语言和几十种目标架构,靠的就是统一的中间表示。
三地址码是最常见的 IR 形式,每条指令至多三个操作数:
| 三地址码 | 含义 |
|---|---|
t1 = b * 3 | 乘法的结果存临时变量 t1 |
t2 = a + t1 | 加法 |
result = t2 | 赋值 |
优化阶段在 IR 上做与机器无关的变换:
原始 IR: 优化后:
t1 = 2 * 3 t1 = 6 (常量折叠)
t2 = a + t1 t2 = a + 6 (常量传播)
t3 = a + t1 t3 = t2 (公共子表达式消除:a+t1 算过一次)
result = t3 result = t2 (复制传播 + 死代码删除)优化的两个来源
常量折叠(2 * 3 编译期算成 6)和死代码删除(没用的变量赋值直接去掉)是白赚的性能——编译期做一次,运行期每次执行都受益。这也是"不要手动内联、不要写重复常量"这类代码风格建议的底层原因:编译器比你做得更好。
后端:从 IR 到机器码
目标代码生成做两件事:指令选择(IR 操作映射到具体机器指令)和寄存器分配(有限的寄存器分给无限的临时变量,用不下的变量溢出到内存)。
以 t2 = a + t1 为例,x86 上的指令选择结果是 add eax, ebx(把寄存器 ebx 加到 eax)。寄存器分配决定:a 放 eax、t1 放 ebx,如果寄存器不够(比如同时有 8 个活跃临时变量但只有 6 个寄存器),多余的临时变量"溢出"到内存栈上——多一次内存读写,慢一些,但程序依然正确。后端质量直接决定程序执行速度,这是同样一份 Java 代码,JIT 编译比解释执行快得多的原因。
运行时与解释器
编译器输出可以独立运行的目标码(C/C++、Go),也可以输出字节码交给运行时解释或即时编译:
代表:GCC、Go
- 编译期完成全部翻译,产物是可直接执行的机器码
- 优点:启动快、无运行时编译开销
- 缺点:无法针对运行时的实际数据特征优化
代表:JVM、Node.js
- 先编译成平台无关的字节码,运行时再编译成机器码
- 优点:可跨平台;JIT 能针对热点代码动态优化(如内联频繁调用的小方法)
- 缺点:启动慢(要预热),内存占用更高(字节码 + 机器码两份)
JVM 是字节码模式的最佳案例:javac 把 Java 编译成字节码(见 Java字节码),JVM 加载后先用解释器快速启动,热点方法再编译成本地机器码——把"编译期静态优化"和"运行期动态优化"结合,这是"分层编译"的思路。
与相邻领域的关系
- 计算机组成原理 定义了目标机器能执行什么指令,编译器的后端就是把这些指令拼装起来;寄存器分配的前提是理解 CPU 有多少寄存器
- 数据结构 的栈、哈希表是符号表(记录变量与类型)的底层实现;语法树本身就是二叉树的一种应用
- 操作系统 提供编译产物运行的载体:可执行文件的加载、内存布局由操作系统决定
编译原理是"语言世界"与"机器世界"的交界——理解了这条流水线,语言特性的设计(为什么有类型推导、为什么 lambda 有开销)就有了答案。