ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理实战:从词法分析到代码生成,LR分析器与AST构建全解析

编译原理实战:从词法分析到代码生成,LR分析器与AST构建全解析

简介:这份《编译原理学习指导》文档面向计算机专业本科生、考研备考者以及希望深入理解编译器工作机制的自学者,系统梳理了编译原理的核心知识脉络与学习路径。内容围绕词法分析、语法分析(LL算法、递归下降算法、LR算法)、语义分析、运行时环境、中间代码生成与代码优化等阶段展开,并重点推荐了《Compilers: Principles, Techniques, and Tools》(龙书)、《Modern Compiler Design》和《编译原理及实践》三本经典教材,对比了它们在理论深度与实践侧重上的差异,帮助读者根据自身需求选择合适的学习材料。资源包为1个doc文档,大小约34KB,结构紧凑,适合作为课程复习提纲或考研知识点速查手册。目前已有153人学习下载。对于正在攻克LR算法理解难关、需要厘清编译各阶段算法本质的读者,这份指导能提供清晰的知识框架与教材选择建议,助力从理论到实践逐步建立对编译器构建的整体认知。

1. 编译原理:为什么 LR 分析器几乎没人手写,却仍是必修课

很多人第一次翻开编译原理教材,翻到 LR 分析那一章就卡住了,然后得出一个结论:这东西工作中根本用不上,学它干嘛。这个结论对了一半——你确实几乎不会手写一个 LR 分析器,但你可能每天都在用它生成的东西。Yacc、Bison 这类工具的核心就是 LR 算法,你写的每一行语法规则,背后都是一张几百个状态的自动机在跑。更反直觉的是:编译原理真正值钱的不是"怎么造编译器",而是它把"如何把一种形式语言机械地翻译成另一种形式"这件事,拆成了一套可证明、可复用的算法流水线。词法分析用正则表达式和有限自动机,语法分析用上下文无关文法和下推自动机,语义分析用语法制导翻译——每一层都有严格的数学对象兜底。理解了这套分层,你再看 JSON 解析、模板引擎、SQL 执行计划、甚至配置文件读取,都会发现它们是同一套骨架的不同变体。这也是为什么它一直是必修课:它训练的是"把模糊问题形式化"的能力,而这种能力,恰恰是区分"会调库"和"能设计系统"的分水岭。

2. 从源码到语法树:词法与语法分析到底在做什么

2.1 词法分析:为什么要把"切词"单独拎出来

一个编译器前端最朴素的做法,是直接拿字符流去匹配语法规则。但这样做会立刻翻车:语法规则描述的是"语句由表达式和分号组成",而字符流里全是i、n、t、空格、换行。如果语法分析器每一步都要先判断"这五个字符是不是关键字 int",规则数量会爆炸,而且完全无法复用。

于是编译器设计者做了一个关键的工程决策:把"识别单词"这件事从语法分析里剥离出来,交给一个独立的词法分析器。它的输入是字符流,输出是 token 流,每个 token 带一个类别标签(关键字、标识符、数字、运算符)和可选的属性值。这个剥离带来的好处是:语法分析器从此只需要面对"标识符""数字"这种抽象符号,规则数量骤降。

词法分析的理论支撑是正则表达式和有限自动机。正则表达式描述 token 的模式,比如标识符是[a-zA-Z_][a-zA-Z0-9_]*,然后通过 Thompson 构造法把正则表达式转成 NFA,再用子集构造法转成 DFA,最后 DFA 可以直接机械地翻译成一张状态转移表加一个循环。这就是为什么词法分析器可以自动生成——它本质上就是一张表在驱动。

# 一个极简的 DFA 驱动词法分析器骨架 # 状态转移表:state -> {char_type: next_state} # 这里只演示标识符和数字的识别逻辑 def lex(char_stream): tokens = [] i = 0 while i < len(char_stream): ch = char_stream[i] if ch.isalpha() or ch == '_': # 进入标识符状态,贪心匹配最长前缀 j = i while j < len(char_stream) and (char_stream[j].isalnum() or char_stream[j] == '_'): j += 1 tokens.append(('ID', char_stream[i:j])) i = j elif ch.isdigit(): j = i while j < len(char_stream) and char_stream[j].isdigit(): j += 1 tokens.append(('NUM', char_stream[i:j])) i = j else: i += 1 # 跳过空白或未识别字符 return tokens

这段代码里最关键的是"贪心匹配最长前缀"——词法分析器永远尝试吃掉尽可能多的字符。intx不会被切成int加x,而是一个完整的标识符。这个规则看起来简单,但它决定了词法分析器必须能"回退":当 DFA 走到一个接受状态后继续读字符,如果读到一个无法转移的字符,就要退回上一个接受状态。真实的词法分析器生成工具(如 Lex/Flex)会把这张转移表编译成 C 代码,运行时就是一个while循环加switch,速度极快。

2.2 语法分析:LL 和 LR 的分水岭在哪

词法分析输出 token 流之后,语法分析器要判断这串 token 是否符合文法,并构建语法树。这里出现了两条技术路线:自顶向下的 LL 和自底向上的 LR。

LL 分析器从文法的开始符号出发,试图推导出输入的 token 串。它维护一个栈,栈顶是非终结符时就查预测分析表,决定用哪条产生式展开。LL(1) 要求文法满足一个条件:对每个非终结符,它的各条产生式的 FIRST 集不相交,且如果某条产生式能推出空串,则 FIRST 集和 FOLLOW 集也不相交。这个条件保证了分析器只看一个 token 就能决定用哪条产生式,不需要回溯。

递归下降是 LL 的一种手工实现形式,每个非终结符写成一个函数,函数体里按顺序匹配产生式右部。它的优点是直观、易调试,缺点是遇到左递归文法会无限递归,需要先消除左递归。对于表达式这种天然左递归的结构,手写递归下降通常要改写成循环加优先级爬升的形式。

LR 分析器走的是另一条路:它从输入串出发,试图归约到开始符号。LR 分析器的核心是一张 ACTION 表和一张 GOTO 表,ACTION 表决定"移进"还是"归约",GOTO 表决定归约后跳到哪个状态。LR 能处理的文法范围比 LL 大得多,几乎所有编程语言的语法都能用 LR 描述,而且不需要消除左递归。

但 LR 的代价是状态数可能很多。一个中等规模语言的 LALR(1) 自动机,状态数轻松上千。这也是为什么没人手写 LR 分析器——状态表太大,手工构造必然出错,必须用工具生成。Yacc/Bison 就是干这个的:你写文法规则和语义动作,它生成 C 代码,里面包含完整的 LR 状态表和驱动逻辑。

2.3 为什么语法树是"树"而不是别的

语法分析的目标是构建抽象语法树(AST)。为什么是树?因为形式语言本身具有递归结构:函数体里可以有函数调用,循环体里可以有循环,条件分支里可以有条件分支。树这种数据结构的定义就是递归的——每个节点的子树仍然是一棵树。这种同构关系让"分析"和"表示"可以一一对应。

更重要的是,树结构让后续阶段可以机械地递归遍历。语义分析在树上做类型检查,中间代码生成在树上做后序遍历,代码优化在树上做模式匹配。如果换成线性结构,每一步都要重新解析上下文,复杂度会失控。

// AST 节点的典型定义(以表达式为例) typedef enum { NODE_NUM, NODE_ID, NODE_BINOP } NodeKind; typedef struct ASTNode { NodeKind kind; union { int num; // 数字字面量 char *name; // 标识符 struct { // 二元运算 struct ASTNode *left; int op; // 运算符 token struct ASTNode *right; } binop; } u; } ASTNode;

这个结构里,binop分支递归地包含左右子节点,天然支持任意深度的表达式嵌套。后续的类型检查函数只需要写一个check(node),根据kind分派,遇到binop就递归检查左右子树——这就是"机械遍历"的含义。

3. 语义分析与运行时环境:类型检查为什么不是"查表"那么简单

3.1 语法制导翻译:把"意义"挂到文法上

语法分析只保证结构正确,不保证意义正确。int x = "hello";在语法上完全合法,但语义上类型不匹配。语义分析的任务就是给语法树补充"意义",而实现这个补充的标准方法是语法制导翻译。

语法制导翻译的核心思想是:给每条产生式附加语义规则,规则里用属性来描述"这个语法结构的意义"。属性分两类:综合属性从子节点往父节点传,继承属性从父节点往子节点传。比如表达式E -> E1 + E2,E.type是一个综合属性,它的值由E1.type和E2.type决定——如果两边都是 int,结果就是 int;如果一边是 float,结果就是 float。

类型检查就是语法制导翻译的一个实例。编译器在语法树上做一遍遍历,每个节点根据子节点的类型计算自己的类型,同时检查是否违反类型规则。C 语言里把浮点数赋给整数变量会报警告,就是因为赋值语句的语义规则要求右部类型能隐式转换到左部类型。

C++ 的多态让这件事复杂了一个量级。虚函数调用在编译期无法确定具体调用哪个函数,只能生成一个虚函数表查找的间接调用。类型检查器需要维护类的继承层次,判断一个指针类型能否转换为另一个指针类型,还要处理模板实例化带来的"延迟类型检查"。这也是为什么 C++ 编译比 C 慢得多——语义分析阶段的工作量大了不止一倍。

3.2 符号表:编译器的"通讯录"

语义分析离不开符号表。符号表记录每个标识符的类型、作用域、存储位置等信息。当编译器看到x时,它需要查符号表知道x是 int 还是 float,是局部变量还是全局变量。

符号表的实现通常用哈希表加作用域链。进入一个作用域时压入一个新表,退出时弹出。查找时从当前作用域往外逐层查,找到第一个匹配就停止——这就是"最近嵌套原则"。C 语言里内层作用域的变量会遮蔽外层同名变量,就是这条规则在起作用。

class SymbolTable: def __init__(self): self.scopes = [{}] # 栈式作用域链 def enter_scope(self): self.scopes.append({}) def exit_scope(self): self.scopes.pop() def declare(self, name, type_info): # 在当前作用域声明,重复声明报错 if name in self.scopes[-1]: raise Exception(f"重复声明: {name}") self.scopes[-1][name] = type_info def lookup(self, name): # 从内到外查找,实现变量遮蔽 for scope in reversed(self.scopes): if name in scope: return scope[name] raise Exception(f"未声明: {name}")

这段代码里reversed(self.scopes)是关键——它保证了内层作用域优先。如果改成顺序遍历,变量遮蔽的语义就反了。真实的编译器还会在符号表里存存储类别(static、extern)、偏移量、对齐信息,供后续代码生成使用。

3.3 运行时环境:为什么代码生成必须先懂内存布局

很多教材把运行时环境放在中间代码生成之前讲,这不是随意安排。代码生成器要决定"这个变量放在栈上还是寄存器里""函数调用时参数怎么传""局部变量什么时候分配",这些决策全部依赖对运行时内存布局的理解。

典型的运行时内存分为代码区、静态数据区、堆、栈。栈从高地址向低地址增长,每个函数调用对应一个栈帧,栈帧里存参数、返回地址、局部变量、保存的寄存器。堆用于动态分配,malloc和new从这里拿内存。

参数传递方式直接影响代码生成。值传递要把实参复制一份压栈,引用传递只传地址。C 语言默认值传递,数组例外——数组名退化为指针,所以传数组实际上是传地址。这个"例外"经常让初学者踩坑:在函数里sizeof(arr)得到的是指针大小而不是数组大小。

非局部名字的访问是另一个难点。嵌套函数里访问外层函数的变量,需要"静态链"或"显示表"来定位外层栈帧。如果语言支持闭包,变量可能需要在堆上分配,因为闭包的生命周期可能超过外层函数。这也是为什么 Java 的匿名内部类访问局部变量时,变量必须声明为 final 或事实 final——编译器实际上是把变量复制到了内部类对象里,如果变量可变,复制就会产生语义歧义。

4. 中间代码与代码生成:为什么堆栈式指令成了通用选择

4.1 中间代码:编译器的"通用语"

从语法树直接生成目标机器代码是可行的,但不可移植。换一个 CPU 架构,整个代码生成器要重写。中间代码的作用是在前端和后端之间加一层抽象:前端负责"源语言到中间代码",后端负责"中间代码到目标代码"。换语言只改前端,换架构只改后端。

中间代码有多种形式:三地址码、四元式、SSA(静态单赋值)、堆栈式指令。三地址码形如t1 = a + b,每条指令最多一个运算符,便于优化。SSA 要求每个变量只赋值一次,这让数据流分析变得极其简洁——每个变量的定义点唯一,使用点可以直接追溯到定义点。

堆栈式指令是另一种流行选择。它的指令不指定操作数,而是隐式地从操作数栈取数据、把结果压回栈。比如push a、push b、add三条指令完成一次加法。这种形式的优点是生成极其简单:后序遍历语法树,遇到叶子就 push,遇到运算符就生成对应指令。不需要考虑寄存器分配,不需要考虑指令调度。

def gen_code(node): """后序遍历 AST 生成堆栈式指令""" if node.kind == 'NUM': emit(f"push {node.value}") elif node.kind == 'ID': emit(f"load {node.name}") # 从变量表加载到栈顶 elif node.kind == 'BINOP': gen_code(node.left) # 左子树结果在栈顶 gen_code(node.right) # 右子树结果在栈顶 op_map = {'+': 'add', '-': 'sub', '*': 'mul', '/': 'div'} emit(op_map[node.op]) # 弹出两个操作数,压回结果

这段代码的优雅之处在于:它完全不需要知道栈的深度,也不需要管理临时变量。左子树生成完,结果自然在栈顶;右子树生成完,两个操作数都在栈上;add指令弹出两个压回一个。整个过程是机械的、无状态的。

4.2 字节码:堆栈式指令的现实落地

Java 的字节码和 .NET 的 IL 都是堆栈式指令。Java 虚拟机执行iadd时,从操作数栈弹出两个 int 相加,结果压回栈。这种设计让 JVM 的实现变得简单:解释器就是一个大循环,每次取一条指令,根据操作码做对应的栈操作。

堆栈式指令的另一个好处是验证简单。JVM 在加载类文件时会做字节码验证,确保操作数栈不会溢出、不会下溢、类型匹配。因为指令的操作数来源是隐式的(栈顶),验证器只需要模拟栈的高度和类型,不需要做复杂的数据流分析。这是 Java 安全模型的重要一环。

代价是性能。堆栈式指令的指令条数比寄存器式多,因为每次操作都要显式地 push 和 pop。寄存器式指令(如 x86 的add eax, ebx)一条指令完成的事,堆栈式需要三条。所以 JVM 有 JIT 编译器,把热点字节码编译成寄存器式的机器码。但 JIT 的存在不否定字节码的价值——它让"一次编译,到处运行"成为可能,而 JIT 只需要在运行时把堆栈式翻译成寄存器式。

4.3 寄存器分配:代码生成里最"玄学"的部分

如果目标代码是寄存器式机器码,代码生成器必须决定哪个变量放哪个寄存器。寄存器数量有限(x86 通用寄存器十几个,ARM 三十几个),变量数量可能成百上千,必然有变量要溢出到栈上。

寄存器分配的标准做法是图着色。把"同时活跃"的变量连一条边,形成一个干涉图,然后用最少的颜色给节点着色,每种颜色对应一个寄存器。如果颜色不够,就选一个节点溢出到内存。图着色是 NP 完全问题,实际编译器用启发式算法求近似解。

// 活跃区间分析的核心逻辑(简化版) // 对每个变量,记录它被定义和被使用的指令位置 // 如果两个变量的活跃区间重叠,它们不能共用寄存器 for (int i = 0; i < num_vars; i++) { for (int j = i + 1; j < num_vars; j++) { if (live_range[i].start < live_range[j].end && live_range[j].start < live_range[i].end) { add_edge(&interference_graph, i, j); // 干涉,加边 } } }

这段代码的复杂度是 O(n²),变量多的时候会慢。真实的编译器用更精细的数据结构,比如按指令位置扫描,维护一个活跃变量集合,只在集合变化时更新干涉图。但核心逻辑不变:活跃区间重叠的变量不能共享寄存器。

寄存器分配之所以"玄学",是因为它和指令调度相互影响。改变指令顺序会改变活跃区间,改变活跃区间又会影响寄存器分配。有些编译器做多轮迭代,先分配再调度再重新分配,直到收敛。这也是为什么同样的源码,不同编译器甚至同一编译器不同优化级别,生成的机器码性能可能差几倍。

5. 避坑:自学编译原理时最容易翻车的几个地方

5.1 坑一:一上来就啃 LR 自动机构造

现象:看到"项目集规范族""闭包""GOTO 函数"就懵了,每个字都认识,连起来不知道在说什么,最后放弃。

原因:LR 自动机的构造需要先理解"项目"这个概念——它表示"产生式右部匹配到哪个位置"。如果跳过这个基础直接看构造算法,就像没学矩阵直接看特征值分解。

解决:先用一个极小的文法(比如只有E -> E + T | T和T -> id)手工推一遍完整的状态转移图,把每个状态里的项目写出来,把移进和归约的决策标出来。推完一遍再回头看算法,会发现它只是在机械地重复你手工做的事。

5.2 坑二:以为词法分析就是写正则

现象:用正则表达式写了一个词法分析器,遇到if和ifx分不清,或者字符串字面量里的转义字符处理不了。

原因:正则表达式匹配的是"模式",但词法分析需要"最长匹配"和"优先级"两个额外规则。if是关键字,ifx是标识符,两者都匹配标识符的正则,但关键字优先。字符串里的\"不能当作字符串结束,需要状态机处理转义。

解决:不要用正则库直接做词法分析,而是手写一个字符级状态机,显式处理"当前在字符串里""当前在注释里"这些状态。或者用 Flex 这类工具,它内置了最长匹配和规则优先级。

5.3 坑三:语法树节点设计得太"通用"

现象:所有节点都用{type, children[]}表示,写语义分析时到处if (node.type == ...),代码又长又容易漏分支。

原因:过度追求"统一"导致类型信息丢失,每个处理阶段都要重新判断节点种类。

解决:用带标签的联合体或类继承体系,让每种节点有自己的字段。二元运算节点直接有left、op、right,函数调用节点直接有name、args。这样语义分析的每个函数只处理一种节点,模式匹配清晰,编译器还能帮你检查遗漏。

5.4 坑四:忽略错误恢复,一个语法错误就崩

现象:编译器遇到第一个语法错误就抛异常退出,用户改完一个错误重新编译,又遇到下一个,来回折腾。

原因:只实现了"正确路径",没有实现"错误恢复路径"。

解决:在语法分析器里加入同步机制。遇到错误时,跳过 token 直到遇到一个"同步点"(比如分号、右花括号),然后从同步点继续分析。这样一次编译能报出多个错误。LL 分析器用 FOLLOW 集做同步,LR 分析器用错误产生式做恢复。

5.5 坑五:代码生成不考虑栈对齐

现象:在自己写的虚拟机上跑生成的代码,简单程序正常,一调用函数就段错误。

原因:很多架构要求栈指针 16 字节对齐(SSE 指令的要求),函数调用时如果栈没对齐,访问局部变量就会崩。

解决:在函数入口处调整栈指针,保证调用子函数前栈是对齐的。具体做法是:进入函数时push rbp后,如果rsp不是 16 的倍数,就sub rsp, 8补齐。这个细节在教材里通常一笔带过,但实际写代码生成器时必踩。

6. 教材选择与学习路径:三本书背后的设计取舍

编译原理的教材选择,本质上是在"理论深度"和"实践可达性"之间做取舍。这个取舍没有标准答案,取决于你的目标是什么。

第一本经典教材以"从例子切入"著称。它开篇就用一个完整的微型编译器例子,把词法、语法、语义、代码生成串一遍,让你先看到全貌再深入细节。这种"先见森林再见树木"的编排,对自学者极其友好——你知道每个理论在整体中的位置,不会迷失在自动机构造里。它的理论部分足够严谨,但不会为了严谨而牺牲可读性。缺点是代码示例偏少,想动手的人需要自己找项目练。

第二本教材的定位是"现代"。传统教材讲的是 80 年代的编译技术,而它把垃圾回收、面向对象语言的编译、即时编译这些较新的主题纳入进来。如果你要处理的语言有闭包、有泛型、有反射,这本书的覆盖更全面。它的实践部分给出了不少可运行的代码片段,但整体难度比第一本高,适合已经入门的读者。

第三本教材的定位是"入门友好"。它用一个完整的 Tiny C 编译器贯穿全书,每讲一个理论就对应实现一个模块,最后拼成一个能跑的编译器。它还详细讲解了 Lex 和 Yacc 的使用,这在很多教材里是缺失的。缺点是理论深度不如前两本,LR 自动机的构造讲得比较简略。但对于第一次学编译原理的人,能跑通一个完整编译器带来的信心,比多学几个定理重要得多。

维度第一本第二本第三本
理论深度高高中
实践代码量少中多
现代主题覆盖少多少
自学者友好度中低高
适合阶段系统学习进阶深入首次入门

我自己的习惯是:先跟第三本走一遍完整流程,把 Tiny C 跑通,建立"编译器长什么样"的直觉;然后回头读第一本,把每个阶段的理论补扎实;最后翻第二本,看看现代语言特性是怎么处理的。这个顺序的关键是"先动手再补理论"——纯理论学编译原理,很容易在 LR 自动机那里卡死,而有了动手经验之后,理论就不再是空中楼阁。

从那以后我每次学一个新的编译相关技术,都强制走一遍"最小可运行例子 → 理论补全 → 边界情况"的流程。没有最小例子,理论就是黑匣子;没有理论,例子就只是复制粘贴。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进