「编译器架构与多遍设计」

从整体架构出发,讲解编译器前端/中端/后端的职责分层、多种中间表示(IR)的选择与切换、Pass 管线的组织与调度、调试信息生成,以及可插拔的优化框架设计。

1. 前端、中端与后端

一句话总结: 编译器按前端(源码到 IR)、中端(IR 优化)、后端(IR 到机器码)三层组织,职责分离让每种语言与每种目标机的组合都只需各写一份。

编译器工程最经典的架构决策是把整个翻译过程切成三段。前端(front end)负责词法、语法、语义分析,把源码变成与语言强相关但与机器无关的中间表示;中端(middle end)在 IR 上做机器无关的优化;后端(back end)把优化后的 IR 翻译成目标指令,完成寄存器分配与指令调度。这样 N 种语言 + M 种目标机只需 N 个前端、1 个中端、M 个后端,而不是 N×M 套独立编译器。GCC 与 LLVM 都是这一架构的产物。

# 三阶段编译器骨架: 每条 pipeline 都接收上一个阶段的产品
class FrontEnd:
    def run(self, source):
        return {"ir": "HIR", "symbols": {"x": "int"}}   # 源码 -> IR

class MiddleEnd:
    def run(self, hir):
        return {"ir": hir["ir"].replace("HIR", "OPT")}  # IR 优化

class BackEnd:
    def run(self, opt_ir, target="x86-64"):
        return f"{target} 汇编: {opt_ir['ir']}"

source_code = "int x = 1 + 2;"
hir = FrontEnd().run(source_code)
opt = MiddleEnd().run(hir)
asm = BackEnd().run(opt)
print(asm)
层次输入输出关注点
前端源码IR + 符号表语言语义、语法错误
中端IR优化后 IR机器无关变换
后端IR机器码指令选择、寄存器、调度

分层的核心收益是复用与聚焦:前端团队不需要懂指令调度,后端团队不需要懂类型推断。分层的代价是每一层之间要定义稳定的 IR 契约,而契约一旦定型就难以大改,所以 IR 设计是编译器架构里「牵一发而动全身」的决策。

2. 中间表示的选择

一句话总结: IR 决定编译器能做什么优化,从高抽象 HIR 到低抽象 LIR 逐级下降,每一级都重新定义信息表示方式。

编译器通常不只一种 IR。Rustc 有 HIR(接近源码抽象)、THIR(去语法糖)、MIR(带 borrow 检查的底层抽象)再到 LLVM IR;GCC 有 GENERIC、GIMPLE、RTL 三级。高抽象 IR 保留类型、作用域等语义信息,适合做类型相关变换;低抽象 IR 更接近机器,适合做指令级优化。多级 IR 之间通过 lower(降级)动作衔接,每次降级都丢弃一部分高层信息并暴露低层机会。

class HIR:
    def __init__(self, expr): self.expr = expr   # 树形, 带类型

class MIR:
    def __init__(self): self.statements = []      # 三地址码, 带 CFG

class LIR:
    def __init__(self): self.instructions = []    # 伪汇编, 带寄存器

def lower_hir_to_mir(hir):
    m = MIR()
    m.statements.append(("t1 = " + hir.expr, "typed"))
    return m

def lower_mir_to_lir(mir):
    l = LIR()
    for stmt, _ in mir.statements:
        l.instructions.append(stmt.replace("t1 = ", "mov eax, "))
    return l

print(lower_mir_to_lir(lower_hir_to_mir(HIR("1 + 2"))).instructions)
IR 层级抽象度保留信息典型变换
HIR高类型、作用域内联、去语法糖
MIR中CFG、SSA优化 Pass
LIR低寄存器、指令指令选择、调度

SSA 是多数中端 IR 的事实标准:每个变量只赋值一次,让数据依赖显式化,许多优化(常量传播、死代码消除)在 SSA 上实现得既简单又高效。但 SSA 也有代价——phi 节点要管理、解构要处理循环依赖,所以「用不用 SSA、在哪个层级引入 SSA」是 IR 设计的核心权衡之一。

3. 多遍流水线设计

一句话总结: 编译是「遍(pass)的流水线」,每遍做一类变换,遍与遍之间通过 IR 状态衔接,遍的顺序直接决定优化效果。

「多遍(multi-pass)」指编译器不是一次性把源码翻译成机器码,而是顺序执行许多小变换,每遍只做一件事:常量传播、死代码消除、循环不变量外提、内联……这种设计让每个 Pass 简单、可测试、可复用。LLVM 把 Pass 组织成 pipeline,同一份 IR 按固定顺序流过一串 Pass。Pass 顺序很讲究:内联要放在常量传播之前才能让常量传播看到更大的函数体;向量化之前通常要跑循环简化。

import re

def optimize(ir, passes):
    """按顺序执行一串 pass, 每遍都是 pure 函数."""
    for name, fn in passes:
        ir = fn(ir)
        print(f"  [{name}] 完成, IR 节点数={len(ir['nodes'])}")
    return ir

def const_prop(ir):
    """把已知常量代入使用处 (仅演示, 逐条字符串变换)."""
    out = []
    for n in ir["nodes"]:
        if " = " in n:
            lhs, rhs = n.split(" = ", 1)
            out.append(f"{lhs} = {rhs.replace('x', '3')}")
        else:
            out.append(n)
    return {"nodes": out}

def dead_code(ir):
    """删除写入后从未被读取的赋值."""
    used = set()
    for n in ir["nodes"]:
        rhs = n.split(" = ", 1)[1] if " = " in n else n
        used |= set(re.findall(r"[a-zA-Z_]+", rhs))
    return {"nodes": [n for n in ir["nodes"]
                      if " = " not in n or n.split(" = ", 1)[0] in used]}

ir = {"nodes": ["x = 3", "y = x + 1", "unused = 999", "call(y)"]}
opt = optimize(ir, [("常量传播", const_prop), ("死代码消除", dead_code)])
print("最终:", opt["nodes"])
阶段顺序理由
内联 → 常量传播传播能看到更大函数体
简化循环 → 向量化向量化需要规整循环
优化 → 寄存器分配分配消费优化后的 IR

Pass 之间还会互相拖累:一个 Pass 消掉的代码,另一个 Pass 又生成回来,形成振荡。因此编译器用固定点迭代(重复跑同一组 Pass 直到不再变化)或精心编排的 Pass 列表来收敛。实践上多数编译器有一个默认 pipeline,同时提供 O0/O1/O2/O3 的级别预设,让用户在编译时间与优化质量之间选择。

4. Pass 管理与调度

一句话总结: PassManager 负责 Pass 的注册、依赖分析与结果缓存,避免重复计算分析结果,是现代编译器框架的中枢。

Pass 不能乱跑:循环优化需要先有循环分析,向量化需要别名信息,内联需要调用图。PassManager 用依赖关系把分析结果组织成缓存——「函数内联」依赖「调用图分析」,而「调用图分析」的结果可被后续多个 Pass 复用。如果某 Pass 改变了 IR 结构,它要声明「我改写了 CFG/循环/调用图」,PassManager 据此使对应分析缓存失效。

class AnalysisCache:
    """Pass 之间的分析结果共享: 需要时才计算, 失效即丢弃."""
    def __init__(self):
        self.cache = {}
        self.invalidated = set()
    def get(self, key, compute):
        if key in self.invalidated:
            self.cache.pop(key, None)
            self.invalidated.discard(key)
        if key not in self.cache:
            self.cache[key] = compute()
        return self.cache[key]
    def invalidate(self, key):
        self.invalidated.add(key)

cache = AnalysisCache()
cfganal = cache.get("cfg", lambda: {"blocks": 4})
cache.invalidate("cfg")                       # 某 pass 改了 CFG
cfganal2 = cache.get("cfg", lambda: {"blocks": 7})
print(cfganal, cfganal2)
class Pass:
    def __init__(self, name, requires=(), invalidates=()):
        self.name = name
        self.requires = requires
        self.invalidates = invalidates

# 声明式依赖: 调度器据此排序与失效
PASSES = [
    Pass("内联", requires=["callgraph"], invalidates=["cfg", "loops"]),
    Pass("循环简化", requires=["loops"], invalidates=["cfg"]),
    Pass("寄存器分配", requires=[], invalidates=[]),
]
for p in PASSES:
    print(f"{p.name}: 依赖 {p.requires} 失效 {p.invalidates}")
调度职责说明
依赖分析排序前保证前置分析就绪
缓存管理分析结果跨 Pass 复用
失效传播结构改变后丢弃受影响分析
延迟计算分析按需执行、按需丢弃

LLVM 从顺序 PassManager 演进到 NewPM 的「基于分析结果 + 显式失效」模型,正是因为旧模型的全局缓存难以在模块级并行与增量编译下保持正确。PassManager 的设计直接决定编译器能否扩展、能否并行、能否增量,是架构层面的基础设施决策。

5. 中端优化遍

一句话总结: 中端 Pass 分为局部(函数内)、过程间(跨函数)与循环三类,它们共享数据流与 SSA 基础设施,把 IR 逐步打磨得更省、更快。

中端优化可以按作用域分三类。局部优化在单个基本块内做:指令合并、代数化简、常量折叠。全局优化在整个函数做:数据流分析驱动的常量传播、死代码消除、全局代码移动。过程间优化(IPO)跨函数做:内联、常量传播跨函数、去虚化。循环优化单独成类,因为循环是性能热点所在:不变量外提、强度削减、展开、向量化。

# 一个迷你中端: 折叠 + 死代码 + 循环不变量外提
def fold_constants(stmts):
    """x = 2; y = x + 3  ->  y = 5 (仅演示简单常量折叠)"""
    env = {}
    out = []
    for s in stmts:
        lhs, rhs = s.split(" = ", 1)
        if rhs.lstrip("-").isdigit():      # 整数字面量记录进环境
            env[lhs] = int(rhs)
            out.append(s)
            continue
        folded = rhs
        for k, v in env.items():           # 用已知常量替换右端
            folded = folded.replace(k, str(v))
        try:
            val = eval(folded)             # 仅演示算术折叠
        except Exception:
            val = folded
        out.append(f"{lhs} = {val}")
    return out

print(fold_constants(["x = 2", "y = x + 3", "z = y * 4"]))
def hoist_invariant(loop_body, loop_invariants):
    """把循环体内不变的赋值外提到循环前."""
    preheader = []
    kept = []
    for s in loop_body:
        if all(v not in s.split(" = ")[0] for v in loop_invariants):
            if s in loop_invariants:
                preheader.append(s)
            else:
                kept.append(s)
        else:
            kept.append(s)
    return preheader, kept

pre, body = hoist_invariant(
    ["t = n", "i = i + 1", "a[i] = a[i] + t"],
    ["t = n"],
)
print("外提到 preheader:", pre)
print("循环内剩余:", body)
Pass 类别作用域例子
局部基本块内指令合并、代数化简
全局函数内常量传播、死代码消除
过程间跨函数内联、IPO 常量传播
循环循环结构不变量外提、向量化

中端 Pass 的质量决定「同一份算法跑多快」。一个未经优化的函数可能产生数倍于必要的指令,而一组协调的中端 Pass 能把它压到接近手写汇编。中端的成功标准是:变换正确(不改变程序语义)且单调(在绝大多数程序上变快),这两点正是第 6 篇「正确性验证」要保障的。

6. 后端与指令调度

一句话总结: 后端把优化后 IR 降级为指令,用指令选择匹配目标机器能力,再用指令调度重排以挖掘流水线并行,最后完成寄存器分配与栈帧布局。

后端从 IR 的每条操作里挑选目标指令:t = a + b 在 x86 上可能直接编码成 add(使用内存操作数),在 RISC 上要拆成 load 与 add。指令选择常用树模式匹配(如 LLVM 的 SelectionDAG、ISel)或自底向上的模式覆盖。指令调度则重排指令顺序,让流水线避免停顿:把互相独立的指令交错开,隐藏内存延迟,减少跳转依赖。

# 指令选择: 用目标指令模式覆盖 IR 表达式树
def select(ir_node, arch):
    table = {
        "add": {"x86-64": "add", "riscv": "addi" if ir_node.endswith("const") else "add"},
        "load": {"x86-64": "mov (mem)", "riscv": "lw"},
    }
    op = ir_node.split("(")[0]
    return table.get(op, {}).get(arch, f"unimplemented:{op}")

for node in ["add(a,b)", "load(p)"]:
    print(node, "->", select(node, "x86-64"), "|", select(node, "riscv"))
# 简单指令调度: 把独立的 load 提前, 隐藏访存延迟
def schedule(insts, latency=3):
    loads = [i for i in insts if "load" in i]
    others = [i for i in insts if "load" not in i]
    return loads + others      # 让 load 先发, 后随指令掩盖延迟

insts = ["add eax, 1", "load rbx", "mul eax, ebx"]
print("调度后:", schedule(insts))
后端职责输入输出
指令选择IR 操作目标指令模式
指令调度指令序列重排后序列
寄存器分配伪寄存器物理寄存器/栈
栈帧布局变量/溢出槽帧布局与偏移

后端还要处理目标特有的约束:特殊寄存器、条件码(flag)的隐式副作用、调用约定、页表基址、位置无关代码。指令选择与调度的质量最终决定单条指令的执行效率,而寄存器分配决定访存开销——两者叠加,就是「同一 IR 在不同机器上性能差异」的主要来源。

7. 调试信息与可插拔优化

一句话总结: 调试信息把机器码映射回源码位置,可插拔框架则允许用户按需加入自定义优化,二者共同提升编译器的可用性与扩展性。

优化过的代码几乎不可读,调试器要能单步、看变量,就依赖编译器生成的调试信息。主流格式是 DWARF:记录行号表(每条机器指令对应哪一行源码)、变量作用域与位置表达式(变量此刻在寄存器还是栈上)。优化会移动代码、消除变量,调试信息必须同步描述这些变换,否则断点会落空、变量显示「已优化」。-g 与 -O2 能否共存,取决于后端有多勤快地把优化痕迹写回调试信息。

# 行号表骨架: 每条指令映射回源码行
class LineTable:
    def __init__(self): self.rows = []   # (address, source_line)
    def add(self, addr, line):
        self.rows.append((addr, line))
    def lookup(self, addr):
        row = [r for r in self.rows if r[0] <= addr][-1]
        return f"地址 {addr} 对应源码行 {row[1]}"

lt = LineTable()
lt.add(0x1000, 5); lt.add(0x1004, 6); lt.add(0x1008, 7)
print(lt.lookup(0x1004))

可插拔优化则把编译器从「内建 Pass 集合」变成「可扩展框架」。GCC 允许通过插件注册自定义 GIMPLE Pass,LLVM 提供 NewPassManager 的自定义 Pass 接口,Rustc 更直接支持编译器内部的「driver 定制」与第三方 lint/优化插件。插拔的边界是稳定性:编译器必须定义好 IR 的公开接口与 Pass 契约,第三方 Pass 不能依赖内部实现细节,否则一次上游重构就全线崩溃。

# 插件注册表: 第三方可以注入自定义 pass
class PluginRegistry:
    def __init__(self): self.passes = []
    def register(self, name, fn): self.passes.append((name, fn))
    def run(self, ir):
        for name, fn in self.passes:
            ir = fn(ir)
        return ir

registry = PluginRegistry()
registry.register("我的死代码加强", lambda ir: [s for s in ir if "dead" not in s])
print(registry.run(["x = 1", "dead = 2", "y = 3"]))
扩展机制代表用途
行号表/DWARFGCC/LLVM调试体验
插件 PassGCC自定义优化
自定义 PassLLVM NewPM专业领域优化
driver 定制rustc语言专用流程

调试信息与可插拔框架看似独立,实则共同回答同一问题:编译器如何「既优化又透明」?对用户透明(能看到源码级调试),对开发者透明(能扩展新 Pass)。一个把 IR 契约与调试管线设计良好的编译器,其生命周期与生态活力都会显著优于「只求快、不开放」的实现。

8. 总结

主题核心结论
三层架构前端/中端/后端分离,N+M 而非 N×M
IR 选择HIR→MIR→LIR 逐级降级,各管一段
多遍流水线小 Pass 顺序执行,顺序决定效果
Pass 管理依赖 + 缓存 + 失效,中枢基础设施
中端优化局部/全局/过程间/循环四类协同
后端指令选择、调度、分配、帧布局
调试与扩展DWARF 映射回源码,插件开放边界

编译器架构的本质是「划分边界 + 定义契约」:在语言、优化、机器三者之间划出清晰的层,用稳定且表达力足够的 IR 作为层间契约。多遍设计让复杂度可控、让每个变换可验证,PassManager 让扩展成为可能。无论你在写一门新语言、给现有语言加优化,还是设计一个小型 DSL 编译器,本文的三层骨架、多级 IR 与 Pass 组织方式都是可以照搬的地图。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类