1. 语义分析的作用
一句话总结: 语义分析把语法结构绑定到符号与类型信息,拒绝编译违反语言规则的代码。
语法分析只保证结构合法,不保证意义正确。a + b 在语法上永远成立,但若 a 是字符串而 b 是数组,语义检查应当报错。语义分析遍历 AST,为每个名字建立绑定、为每个表达式推导类型、为每个语句验证控制流约束,并在合适时机输出带标注的语法树与中间表示。
| 检查内容 | 例子 | 违反后果 |
|---|---|---|
| 名字绑定 | 使用未声明变量 | 编译错误 |
| 类型匹配 | 字符串与整数相加 | 编译错误 |
| 参数个数 | 函数实参比形参少 | 编译错误 |
| 可见性 | 访问私有成员 | 编译错误 |
| 常量性 | 给只读变量赋值 | 编译错误 |
AST(含语义标注):
Assign(id:a:var_int,
Add(id:b:var_int,
Lit(10:int)))
IR:
t1 = b
t2 = 10
t3 = t1 + t2
a = t3
语义分析的输出既可以直接进入解释器,也可以进入中间表示生成。对于静态类型语言,语义分析集中在编译早期完成;对于脚本语言,类型信息往往推迟到运行时。中间表示则把机器无关的分析固化下来,成为优化与代码生成的公共底座。
2. 符号表设计
一句话总结: 符号表是名字到属性的映射,结构设计决定作用域查找与增量更新的效率与正确性。
符号表保存每个名字的类型、作用域、存储位置与常量性。最简单的实现是哈希表,但块级作用域要求进出块时保存与恢复状态。常见做法是用作用域栈:每进入一个块压入一层表,离开时弹出。查找时从栈顶向下逐层搜索,保证内层名字遮蔽外层同名名字。
class Scope:
def __init__(self, parent=None):
self.parent = parent
self.symbols = {}
def define(self, name, info):
self.symbols[name] = info
def lookup(self, name):
if name in self.symbols:
return self.symbols[name]
if self.parent:
return self.parent.lookup(name)
return None
class SymbolTable:
def __init__(self):
self.stack = [Scope()]
def enter(self):
self.stack.append(Scope(self.stack[-1]))
def leave(self):
self.stack.pop()
def lookup(self, name):
return self.stack[-1].lookup(name)
| 实现方案 | 插入 | 查找 | 适用场景 |
|---|---|---|---|
| 哈希表 | O(1) | O(1) | 全局层 |
| 作用域栈 | O(1) | O(深度) | 块级语言 |
| 持久化树 | O(log n) | O(log n) | 增量编译 |
| 符号串接 | O(1) | O(名字) | Lisp 风格 |
带函数嵌套的语言如 Pascal 需要在符号表中记录活动记录链,支持闭包的语言则要区分定义时环境与调用时环境。多线程编译器还会在符号表上做并发访问控制,或用只读不可变表配合增量编译。符号表不仅是查名字,还要能回答方法重载、泛型实例化等高级查询。
3. 作用域与名字解析
一句话总结: 作用域规则决定名字绑定的可见窗口,词法作用域以静态文本结构为界,动态作用域以调用链为界。
大多数现代语言采用词法(静态)作用域:一个名字的绑定由它在源码中的嵌套位置决定,与调用路径无关。名字解析从最内层作用域向外层逐级查找,找到最近的一个绑定即停止。若解析发生在声明之前,语言需要规定前向引用的规则,例如函数声明可后置、变量声明不可后置。
def resolve_names(ast_root):
env = Scope(global_scope)
def walk(node, env):
if node.kind == "Decl":
env.define(node.name, node)
for child in node.body:
walk(child, env)
elif node.kind == "Ref":
sym = env.lookup(node.name)
if sym is None:
raise SemanticError(
f"undefined name {node.name} at {node.loc}")
node.symbol = sym
else:
for child in node.children:
walk(child, env)
walk(ast_root, env)
| 作用域类型 | 解析依据 | 代表语言 | 示例 |
|---|---|---|---|
| 词法作用域 | 静态嵌套 | C、Java、Rust | 块内遮蔽块外 |
| 动态作用域 | 调用链 | 早期 Lisp | 按当前调用者解析 |
| 模块作用域 | 文件边界 | Python、Go | 跨文件 import |
| 隐式作用域 | 表达式中临时 | SQL | 相关名称解析 |
名字遮蔽是常见语义缺陷来源:内层声明意外遮蔽外层变量导致读到的值不对。编译器的做法是给出 warning 或 require explicit qualification。还有一类问题是名字查找跨越了不该跨越的边界,例如误入全局命名空间,此时模块系统的作用域隔离设计就显得关键。语义分析结束后,每个引用节点都应指向唯一的符号定义。
4. 类型检查与类型推断
一句话总结: 类型系统用静态规则约束值的使用方式,类型检查在编译期验证这些约束。
类型检查遍历 AST,为每个表达式推导类型并验证运算合法性。整数与浮点相加是否需要隐式转换、数组下标是否必须是整数、函数返回值是否与声明一致,都是类型规则要回答的问题。类型推断允许省略部分类型标注,由编译器根据上下文推导,Hindley-Milner 类型推断是函数式语言的典型方案。
def infer(node, env):
if node.kind == "Lit":
node.type = literal_type(node.value)
elif node.kind == "Ref":
node.type = env.lookup(node.name).type
elif node.kind == "BinOp":
left = infer(node.left, env)
right = infer(node.right, env)
if not compatible(left, right):
raise TypeError(
f"type mismatch {left} vs {right} at {node.loc}")
node.type = promote(left, right)
elif node.kind == "Call":
fn = infer(node.callee, env)
node.type = fn.return_type
return node.type
| 类型系统特征 | 说明 | 代表语言 |
|---|---|---|
| 静态类型 | 编译期验证 | C、Java、Rust |
| 动态类型 | 运行时验证 | Python、JS |
| 强类型 | 禁止隐式危险转换 | Rust、OCaml |
| 弱类型 | 允许隐式转换 | C、JS |
| 结构类型 | 按形状兼容 | Go、TypeScript |
| 名义类型 | 按声明名兼容 | Java、C# |
类型检查的难点在用户定义类型与泛型。泛型函数的类型参数在调用时被实例化,编译器要维护类型变量的替换环境。联合类型与代数数据类型要求模式匹配穷尽性检查。类型推断的实现核心是约束收集与合一(unification),把待定类型变量与已知类型做一致化替换。
4.1 合一与泛型实例化
合一算法把两个类型表达式通过替换变成相同形式:对待定类型变量尝试绑定为具体类型,冲突则失败。泛型函数 T -> T 的调用 f(3) 令 T 与 int 合一,返回类型也随之实例化为 int。实现上类型变量用不可变替换环境记录,嵌套推导需要沿替换链解析到最终类型。
def unify(t1, t2, subst):
t1 = resolve(t1, subst)
t2 = resolve(t2, subst)
if isinstance(t1, TypeVar):
subst[t1.name] = t2
return True
if isinstance(t2, TypeVar):
subst[t2.name] = t1
return True
if isinstance(t1, Arrow) and isinstance(t2, Arrow):
return unify(t1.arg, t2.arg, subst) and \
unify(t1.ret, t2.ret, subst)
return t1 == t2
# 实例化: id(3) 令 T = int
# id: forall T. T -> T
# 应用 id 于 int: T 替换为 int → int -> int
5. 中间表示的选择
一句话总结: 中间表示位于源码与目标机之间,设计权衡决定优化能力与移植性。
中间表示(IR)架起前端与后端。高层 IR 贴近 AST,便于做类型与别名相关的分析;低层 IR 贴近目标机,便于做寄存器分配与指令选择。三地址码是经典线性 IR,每条指令形如 x = y op z,最多一个运算符。SSA 是带上唯一赋值性质的 IR,每个变量只被赋值一次,简化数据流分析。
| IR 形态 | 贴近 | 优点 | 缺点 |
|---|---|---|---|
| 抽象语法树 | 源码 | 结构直观 | 难做线性优化 |
| 三地址码 | 无类型机器 | 简单通用 | 数据流不显式 |
| SSA | 数据流 | 优化友好 | 破坏点管理 |
| 栈机字节码 | 虚拟机 | 紧凑易解释 | 偏移计算多 |
| RTL | 目标机 | 精确 | 移植成本高 |
三地址码:
t1 = a
t2 = t1 + 1
a = t2
SSA 形式:
a_1 = a_0
a_2 = a_1 + 1
a_3 = phi(a_0, a_2)
LLVM 的 IR 采用静态单赋值与显式控制流图,兼顾分析与后端复用;GCC 历史上用 GIMPLE 类似机制。解释器常用字节码直接执行,JIT 编译器常把字节码转成低层 IR 再优化。中间表示的层次选择是架构决策:单层 IR 实现简单,多层 IR 优化空间大但工程成本成倍增加。
6. 三地址码生成
一句话总结: 三地址码把表达式拍平为带临时变量的指令序列,是后续控制流图与优化的基础。
三地址码生成自语义标注的 AST。每个二元运算分配一个临时变量保存结果,运算的嵌套被展开成直线指令序列。函数调用、数组访问、指针解引用等在内存或寄存器层面都有专门的三地址指令形式。标签用于标记跳转目标,条件跳转配合比较指令表达控制流。
def gen_expr(node, temps):
if node.kind == "Lit":
t = temps.new()
emit(f"{t} = {node.value}")
return t
if node.kind == "BinOp":
l = gen_expr(node.left, temps)
r = gen_expr(node.right, temps)
t = temps.new()
emit(f"{t} = {l} {node.op} {r}")
return t
if node.kind == "Ref":
return node.name
# 生成的指令示例
# t1 = 10
# t2 = b
# t3 = t1 + t2
# a = t3
| 指令类型 | 形式 | 用途 |
|---|---|---|
| 算术 | x = y op z | 加减乘除 |
| 赋值 | x = y | 数据搬移 |
| 拷贝 | x = *p | 内存读 |
| 跳转 | goto L | 无条件转移 |
| 条件跳转 | if x < y goto L | 分支 |
| 调用 | param p; call f | 过程调用 |
临时变量分配要注意生命周期:用完即可复用,避免无谓膨胀。三地址码生成过程中的临时变量数量会影响后续优化,但现代编译器通常让临时变量命名唯一,交由优化阶段再做命名消解与活跃分析。标签编号与基本块划分也在生成时同步记录。
6.1 语句的翻译
语句翻译负责把条件、循环与过程调用展开为带标签的控制流。if 翻译为比较与条件跳转加两个标签;while 翻译为条件测试、循环体与回跳标签;函数调用翻译为参数放置、调用指令与结果接收。翻译过程的标签表需要统一分配,避免不同语句的标签冲突。
if a > 0 then x = 1 else x = 2
t1 = a
if t1 > 0 goto L1
x = 2
goto L2
L1:
x = 1
L2:
7. 控制流图与 SSA
一句话总结: 控制流图显式表达基本块之间的跳转关系,SSA 让每个变量只有一个定义点。
控制流图(CFG)把三地址码划分为基本块:块内指令顺序执行、块间以跳转相连。基本块划分的依据是跳转目标与跳转源。CFG 是数据流分析、循环识别与优化的骨架。SSA 形式要求每个变量恰有一个定义点,程序点上的变量用版本号区分,控制流汇合处用 φ 函数合并多个来源的值。
基本块 B1: t1 = a
if t1 > 0 goto B3
基本块 B2: t2 = 0
goto B4
基本块 B3: t2 = 1
基本块 B4: x = phi(t2_B2, t2_B3)
SSA 下每个 t2 都有唯一版本,phi 合并两路值。
| SSA 性质 | 含义 | 优化收益 |
|---|---|---|
| 唯一定义 | 每变量一个 def | 简化 use-def 链 |
| φ 函数 | 汇合点合并 | 精确到达定义 |
| 支配树 | 定义支配使用 | 循环分析 |
| 版本化 | 冲突即改名 | 减少伪依赖 |
把普通三地址码转成 SSA 需要做支配树计算与 φ 函数插入。反向转化(恢复非 SSA)发生在寄存器分配前,因为物理寄存器没有版本概念。SSA 上实现的常量传播、全局值编号与死代码消除都比非 SSA 形式简单且精确,这也是 LLVM 与 GCC 把 SSA 作为主要分析 IR 的原因。
8. 总结
| 环节 | 要点 |
|---|---|
| 语义作用 | 绑定名字、验证类型、拒绝非法程序 |
| 符号表 | 作用域栈实现块级遮蔽与查找 |
| 名字解析 | 词法作用域逐层查找最近绑定 |
| 类型检查 | 静态验证与 Hindley-Milner 推断 |
| IR 选择 | 高层到低层、单层到多层的取舍 |
| 三地址码 | 表达式拍平为临时变量指令 |
| 控制流图 | 基本块与跳转显式化 |
| SSA | 唯一定义与 φ 函数简化分析 |
语义分析与中间表示把源码从结构提升到意义,让机器可理解的数据结构取代人写文本。中间表示的质量直接决定后续优化的天花板。下一站将在这层 IR 上施展各种优化手段。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。