20. 编译原理基础

系统理解编译原理核心流程:词法分析(正则/DFA)、语法分析(递归下降/LL/LR)、AST、中间表示(三地址码/SSA)、代码优化与解释器对比,掌握把高级语言翻译为机器码的完整链路。

1. 编译流程总览

1.1 前端与后端

编译器把一个高级语言程序翻译成等价的目标代码。整个流程可分为前端(与语言相关:词法/语法/语义)与后端(与机器相关:优化/指令选择/寄存器分配),中间的桥梁是中间表示 IR。

源代码
  │  1. 词法分析(Lexing)   字符流 → Token 流
  │  2. 语法分析(Parsing)  Token 流 → AST
  │  3. 语义分析(Semantic) AST + 类型检查 → 带标注 AST
  │  4. 中间代码生成           → 三地址码 / SSA IR
  │  5. 优化(Optimization)  优化后的 IR
  │  6. 目标代码生成           → 汇编 / 机器码
  ↓
目标程序
阶段输入输出典型工具/概念
词法分析字符流Token 流Lexer、正则、DFA
语法分析Token 流ASTParser、LL/LR、递归下降
语义分析AST带类型标注 AST符号表、类型检查
中间代码生成标注 AST三地址码/SSAIR、基本块
优化IR优化后 IR常量传播、死代码消除
目标代码生成IR汇编/机器码指令选择、寄存器分配

理解编译流程的价值不止于写编译器:正则/DFA 是文本处理核心,递归下降是手写解析器的标准做法,SSA 是 LLVM/GCC 的公共优化基础——这些知识直接迁移到解释器、DSL、SQL 解析器、模板引擎等场景。


2. 词法分析:正则与 DFA

2.1 Token 与正则

词法分析把字符流切分为 Token(类型 + 值 + 位置)。每种 Token 用正则表达式描述,全部正则合并为一个 NFA,再确定化为 DFA,即可线性扫描识别所有 Token。

# 简单词法分析器:识别标识符、数字、运算符、关键字
import re

TOKEN_RE = [
    ("NUMBER",  r"\d+"),
    ("ID",      r"[A-Za-z_]\w*"),
    ("OP",      r"[+\-*/=<>!]"),
    ("WS",      r"\s+"),
]

KEYWORDS = {"if", "else", "while", "return"}

def lex(code):
    tokens = []
    pos = 0
    while pos < len(code):
        for kind, pattern in TOKEN_RE:
            m = re.match(pattern, code[pos:])
            if m:
                text = m.group()
                if kind == "ID" and text in KEYWORDS:
                    kind = "KEYWORD"
                if kind != "WS":            # 跳过空白
                    tokens.append((kind, text))
                pos += len(text)
                break
        else:
            raise SyntaxError(f"无法识别: {code[pos:]}")
    tokens.append(("EOF", ""))
    return tokens

print(lex("x = 42 + y"))   # [('ID','x'),('OP','='),('NUMBER','42'),('OP','+'),('ID','y')]

2.2 正则 → NFA → DFA

正则运算NFA 构造说明
连接 ab两状态串行ε 连接
并 `ab`新起点分支
闭包 a*回边可重复 0 次以上
字符类 [a-z]多输入边等价并

DFA 状态最小化:合并等价状态后得到最小 DFA。像 lex/flex、Go 的 regexp、Python 的 re 内部都基于 NFA/DFA(或 Thompson 模拟)。正则能力边界是正则语言;需要括号配对、递归结构时必须上升到语法分析。

自动机状态转换复杂度
NFA一个输入可走多条 ε/字符边模拟 O(n·m)
DFA每个输入唯一确定下一状态匹配 O(n),状态可能指数膨胀

3. 语法分析:递归下降

3.1 文法与产生式

语法分析根据文法把 Token 流组合成 AST。经典算术表达式文法(含优先级与结合性):

expr    := term (('+' | '-') term)*
term    := factor (('*' | '/') factor)*
factor  := NUMBER | '(' expr ')'

3.2 递归下降实现

递归下降是最容易手写实现的解析方法:每个非终结符对应一个递归函数,下降过程即推导过程。上面的文法消除了左递归与歧义,可直接翻译为代码。

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0

    def peek(self):
        return self.tokens[self.pos]

    def consume(self, expect=None):
        tok = self.tokens[self.pos]
        if expect and tok[1] != expect:
            raise SyntaxError(f"期望 {expect}, 实际 {tok}")
        self.pos += 1
        return tok

    def parse_expr(self):
        node = self.parse_term()
        while self.peek()[1] in ("+", "-"):
            op = self.consume()[1]
            rhs = self.parse_term()
            node = (op, node, rhs)          # 左结合:((a+b)-c)
        return node

    def parse_term(self):
        node = self.parse_factor()
        while self.peek()[1] in ("*", "/"):
            op = self.consume()[1]
            rhs = self.parse_factor()
            node = (op, node, rhs)
        return node

    def parse_factor(self):
        kind, val = self.consume()
        if kind == "NUMBER":
            return ("num", int(val))
        if val == "(":
            inner = self.parse_expr()
            self.consume(")")
            return inner
        raise SyntaxError("非法因子")

# tokens = lex("3 + 4 * (2 - 1)")
# ast = Parser(tokens).parse_expr()   → ('+', ('num',3), ('*',('num',4), ('-',('num',2),('num',1))))

4. 语法分析:LL 与 LR

4.1 自顶向下 vs 自底向上

维度LL(自顶向下)LR(自底向上)
方向从开始符号向下推导从终结符向上归约
表驱动LL(1) 预测分析表LR(0)/SLR/LR(1)/LALR
文法要求无左递归、无二义适用文法更广(几乎所有编程语言)
代表工具手写递归下降、ANTLR(LL*)Yacc、Bison、LALR
优点直观、错误信息好文法表达力强、无回溯

LL(1) 预测分析需要 FIRST/FOLLOW 集:看到当前终结符即可唯一决定采用哪条产生式,故为"1 个前瞻符号"的 LL。手写解析器通常就是 LL 思想(递归下降)。

# LL(1) 预测分析表驱动的核心循环(伪代码)
def predictive_parse(table, start_symbol, tokens):
    stack = [start_symbol]
    a = next_token(tokens)
    while stack:
        X = stack.pop()
        if X.is_terminal():
            if X == a:
                a = next_token(tokens)
            else:
                raise SyntaxError("终结符不匹配")
        else:
            production = table[X][a]       # 由前瞻符号查表决定产生式
            stack.push(reversed(production))  # 右端逆序入栈
文法类别能否被 LL 处理能否被 LR 处理例子
左递归文法否(需改写)是E → E + T
二义文法否通过优先级声明E → E + E
完全上下文无关否否aⁿbⁿcⁿ(需要上下文)

5. 抽象语法树 AST

5.1 AST 与语法树区别

语法树(Parse Tree / CST)保留文法全部细节(含无意义的括号、分隔符);AST 只保留对后续阶段有意义的结构,剔除括号与冗余节点。AST 是编译前端与各工具(Lint、格式化、代码生成)共享的核心数据结构。

// 表达式 3 + 4 * (2 - 1) 的 AST
{
  "type": "BinaryExpr",
  "op": "+",
  "left": { "type": "Number", "value": 3 },
  "right": {
    "type": "BinaryExpr",
    "op": "*",
    "left": { "type": "Number", "value": 4 },
    "right": {
      "type": "BinaryExpr",
      "op": "-",
      "left": { "type": "Number", "value": 2 },
      "right": { "type": "Number", "value": 1 }
    }
  }
}
# AST 求值:递归遍历节点
def evaluate(node):
    kind = node[0]
    if kind == "num":
        return node[1]
    op, left, right = node
    l, r = evaluate(left), evaluate(right)
    if op == "+": return l + r
    if op == "-": return l - r
    if op == "*": return l * r
    if op == "/": return l // r

5.2 语义分析

AST 构建后进入语义分析:建立符号表、做类型检查、解析作用域与名称绑定。

检查项说明出错示例
未定义变量符号表查找失败use x; 未声明
类型不匹配操作数类型与运算符不符"a" + 1(强类型语言)
作用域冲突重复定义int x; int x;
返回类型检查函数返回值与声明不符int f(){ return "x"; }

6. 中间表示:三地址码与 SSA

6.1 三地址码

每条指令至多三个地址(变量/常量),把复杂表达式拆成基本运算,便于后续优化与代码生成。真实中间表示如 LLVM IR、Java Bytecode、WebAssembly 都类似三地址码。

源代码:  result = a + b * c
三地址码:
  t1 = b * c
  t2 = a + t1
  result = t2
指令类型三地址形式例子
赋值x = y op zt1 = b * c
拷贝x = yt2 = a
跳转goto L / if x relop y goto Lif t1 > 0 goto L1
调用call fcall print
返回return xreturn t2

6.2 SSA(静态单赋值)

SSA 要求每个变量只赋值一次,合并控制流时用 φ(phi)函数选择不同分支的值。SSA 让数据流信息显式化,是 LLVM/GCC 优化器的基础。

转换前:                       转换后(SSA):
a = 1                        a1 = 1
if (cond) {                  if (cond) {
  a = a + 2;        →          a2 = a1 + 2;
} else {                       } else {
  a = a * 3;                     a3 = a1 * 3;
}                             }
                              a4 = φ(a2, a3)     ← phi 合并两个分支
优点说明
定义即使用链变量只定义一次,定义-使用关系清晰
简化优化常量传播、公共子表达式消除在 SSA 上实现更简单
精确的活变量分析φ 节点显式表达数据流汇合点

7. 代码优化

7.1 优化分类

优化按阶段分:局部优化(基本块内)、全局优化(跨基本块)、机器相关优化(寄存器分配、指令调度)。目标是减少指令数、消除冗余、利用硬件特性。

优化技术原理示例
常量折叠编译期计算常量表达式2 * 3 → 6
常量传播把常量代入使用处x = 42; y = x + 1 → y = 43
死代码消除删除结果未被使用的指令删掉从未读的赋值
公共子表达式消除复用已算过的相同子表达式a*b + a*b 只算一次
循环不变式外提把循环内不变计算移到循环外for: x = i * const 提外
强度削减昂贵运算换廉价运算i * 2 → i + i 或 i << 1

7.2 窥孔优化与寄存器分配

# 窥孔优化示例:合并相邻指令(模式匹配滑动窗口)
def peephole(ir):
    optimized = []
    for insn in ir:
        # 模式 1: x = y; z = x  →  z = y
        # 模式 2: if x == 0 goto L; goto L2 → if x == 0 goto L; L2: ...
        optimized.append(insn)
    return optimized

寄存器分配(如图着色算法)决定哪些变量驻留寄存器、哪些溢出到内存,对最终性能影响巨大。现代编译器(LLVM)还执行指令调度、内联、向量化(SIMD)与自动并行化。


8. 解释器 vs 编译器

8.1 两种执行模型

维度编译器解释器
工作方式一次性翻译为机器码逐语句/逐字节码执行
启动速度慢(编译时间)快
执行速度快(原生机器码)慢(解释开销)
错误发现运行前发现语法/类型错误运行到才暴露
代表C、Go、RustPython、Ruby、旧 JS 引擎

8.2 混合模型与 JIT

现代语言普遍采用混合策略:先编译到字节码(跨平台 IR),再在运行时用 JIT(即时编译) 热点编译为机器码。V8 的 Ignition + TurboFan、JVM 的 C1/C2、CPython 的 JIT(3.13+ 实验性)都是如此。

源代码 → 字节码(IR)→ [解释器执行] → 热点检测 → [JIT 编译为机器码] → 缓存执行
                                                    └→ 反优化(去优化)退回解释器
策略延迟吞吐可移植性
纯解释最低最低最高
字节码 + JIT中高(热点后)高
直接编译为机器码最高(编译开销)最高低(需平台适配)

9. 综合案例与工具链

9.1 一条语句的完整旅程

以 result = a + b * 2; 为例,走完 9 个阶段:

1. 词法:  ID(result) OP(=) ID(a) OP(+) ID(b) OP(*) NUM(2)
2. 语法:  Assignment(result, Binary(+, a, Binary(*, b, 2)))
3. 语义:  符号表绑定 a:float, b:float → 类型检查通过
4. IR:    t1 = b * 2; t2 = a + t1; result = t2
5. 优化:  常量折叠 t1 = b * 2(2 为立即数); 死代码消除(若 result 未用)
6. 代码生成: mov r1, [b]; imul r1, 2; add r1, [a]; mov [result], r1

9.2 工具链与学习资源

工具类型用途
flex / lex词法生成器从正则生成 C 词法器
yacc / bisonLALR 语法生成器生成 C 语法分析器
ANTLRLL(*) 解析器生成器Java/Python/Go 多语言目标,DSL 开发
LLVM编译器基础设施Clang 后端、优化、JIT
Tree-sitter增量增量解析器编辑器语法高亮、代码分析

入门路径建议:用递归下降手写一个小语言(如计算器 → 简单脚本)理解 AST 与求值;再借助 LLVM IR 生成器把 AST 编译为真实机器码,即可贯通编译原理全流程。


参考文章

继续阅读

探索更多技术文章

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

全部文章 返回首页

「计算机基础」更多文章

  1. 22. CPU 缓存与一致性
  2. 21. 传输层与 TCP 深入
  3. 19. 数据库原理基础