ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

编译原理核心不是编译器:从字符流到语法树的算法与避坑

编译原理核心不是编译器:从字符流到语法树的算法与避坑

简介:《编译原理学习指导》是一份面向计算机专业本科生、考研复习者及自学编译原理读者的doc文档,聚焦词法分析、语法分析、语义分析、运行时环境、中间代码生成与代码优化等核心环节,帮助读者梳理课程脉络并建立算法视角。压缩包内仅含1个doc文件,大小约34KB,篇幅精炼,适合作为课堂笔记补充或考前快速回顾材料。目前已有153人学习,说明内容在编译原理入门与复习场景中具有一定参考价值。文档围绕LL、递归下降、LR等分析算法展开,并谈及龙书、《现代编译程序设计》《编译原理及实践》等经典教材的学习侧重,可帮助读者理解词法分析与语法分析如何把源程序转换为便于处理的数据结构,也能为后续实践小型编译器Tiny C提供概念铺垫。

1. 编译原理:真正值钱的不是编译器,是那套把字符流变成结构的算法

很多人学完编译原理的第一感受是“课听懂了,编译器还是写不出来”。这个“失败”其实是课程设计好的:本科阶段的编译原理,主要成果从来不是一台能跑的编译器,而是词法、语法、语义、运行时、代码生成这几段算法各自的理论骨架,编译器只是把这些骨架串起来的副产品。20 世纪 50 年代,第一台高级语言编译器的实现据说磨了十几年,真正沉淀下来的不是那个程序,而是正则表达式、有限自动机、上下文无关文法、语法制导翻译这些能反复复用的东西。

所以把编译原理当成“算法设计的一个专精分支”来读,比当成“软件工程课”来读要顺得多。它教的是怎么把一段不规则的字符流,一步步塌缩成机器能执行的结构;而这套转换能力,今天几乎在所有需要解析输入的地方都在被复用——命令行参数、配置文件、模板引擎、查询语句,甚至一个自定义的领域特定语言。

2. 前端两级流水线:为什么非要把“扫字符”拆成词法分析和语法分析

初学者最容易产生的疑问是:既然最后都要得到一棵语法树,为什么不让语法分析器直接从字符开始扫?这个问题的答案藏在文法的表达力和自动机的复杂度里。

2.1 词法分析为什么要把正则表达式和有限自动机塞进来

如果把标识符、数字、空白、注释这些字符级规则全部塞进上下文无关文法,会产生大量形如“单个字母推导出单个字母”的产生式,文法规模成倍膨胀,语法分析器的状态数跟着爆炸。分层之后,词法层只处理正则语言(乔姆斯基谱系里的 3 型),用有限自动机做线性扫描;语法层处理上下文无关语言(2 型),用下推自动机。这是把一个问题按“需要多少记忆”切开:词法不需要记忆嵌套深度,一个固定状态就够;语法需要记住“现在还在几层括号里”,必须上栈。

教科书里那条标准路径是:正则表达式 → Thompson 构造 NFA → 子集构造法确定化为 DFA → 状态最小化 → 生成表驱动扫描器。这套流程的意义在于,它把“写一个词法分析器”从手艺活变成了机械推导,只要正则写对,扫描器就必然正确。

# 一个极简的表驱动词法扫描器骨架:把 DFA 的转移关系当数据用 # 状态 0 为初态;2 为整数接受态;4 为标识符接受态 TRANS = { (0, 'digit'): 2, (2, 'digit'): 2, # 0->2 之后可以一直吃数字 (0, 'alpha'): 4, (4, 'alpha'): 4, (4, 'digit'): 4, } ACCEPT = {2: 'INT', 4: 'ID'} # 终态到 token 类型的映射 def classify(ch): if ch.isdigit(): return 'digit' if ch.isalpha() or ch == '_': return 'alpha' return 'other' def scan(src): state, lexeme, i = 0, '', 0 while i < len(src): ch = src[i] nxt = TRANS.get((state, classify(ch))) if nxt is None: # 转移不存在:回退到最近一次接受点 if state in ACCEPT: yield ACCEPT[state], lexeme state, lexeme = 0, '' if classify(ch) == 'other': i += 1 # 空白等分隔符直接跳过 continue state, lexeme = nxt, lexeme + ch i += 1 if state in ACCEPT: yield ACCEPT[state], lexeme

这段代码里真正值得看的是两个参数:TRANS是 DFA 的转移函数,ACCEPT是终态集合。TRANS决定了能识别什么语言,ACCEPT决定了识别出来的东西叫什么名字;把ACCEPT换成一张“拼写 → 关键字 token”的哈希表,就能在同一个 DFA 上把关键字从标识符里分出来(业界常见的做法是先用统一规则识别成标识符,再查保留字表回填类型,比在自动机里枚举关键字省事得多)。nxt is None这条分支承担的是最长匹配语义:只要没有后继转移,就退回上一个接受位置,这也是为什么扫描器通常要记住“最近一次接受点”而不是简单前瞻一个字符。

2.2 LL 自顶向下与 LR 自底向上:两种完全不同的思考方向

语法分析的教材几乎都会同时讲 LL 和 LR,但两者的难度曲线根本不在一个量级上。

LL 是自顶向下:从开始符号出发,看下一个 token 该走哪条产生式。判断依据是 FIRST 集(某非终结符能推导出的首终结符集合)和 FOLLOW 集(某非终结符后面可能跟什么终结符)。把这两张表算出来,就能填一张预测分析表,表驱动地做推导。递归下降是 LL 的手写版本,每个非终结符对应一个函数,函数体里按当前 token 分派到子函数;写起来和文法几乎一一对应,调试时断点打在哪里、栈走到哪一层,都看得清清楚楚。

LR 是自底向上:从 token 流开始,不断把栈顶的若干符号归约成一个非终结符,直到归约到开始符号。它的核心概念是“句柄”(某条产生式的右部,且被归约后能继续正确推导)和“项目集规范族”(把“我现在推导到产生式的第几个位置”编码成状态)。难点在于这套状态机是机械推导出来的,几十个状态、上百条转移,靠人脑手推基本不现实,很多自学者卡在这里放弃。

# 递归下降的典型写法:每个非终结符一个函数,按 token 分派 # 文法: E -> T ('+' T)* T -> F ('*' F)* F -> INT | '(' E ')' def parse_E(): node = parse_T() # 先解析左子树 while peek() == '+': # 循环处理左递归,避免无限递归 take('+') right = parse_T() node = ('+', node, right) # 边解析边构造 AST,不留中间产物 return node def parse_F(): if peek_type() == 'INT': return ('int', take_value()) if peek() == '(': take('('); node = parse_E(); take(')') return node raise SyntaxError(f'期望表达式,实际遇到 {peek()}')

parse_E里那个while是消除左递归的标准手法:原本E -> E + T | T是左递归的,直接翻译会无限递归,改写成循环后语义等价但栈深度有界。peek()只读不前进,take()消费当前 token,这两个原语把“向前看一个符号”这件事封装起来,是递归下降能保持可读性的关键。参数上唯一要调的是前瞻数量:LL(1) 只要一个 token,遇到需要两个 token 才能区分的情况就要做提取左公因子。

2.3 递归下降必须能手写,LR 更适合交给生成器

这就解释了一个看起来矛盾的现象:教材对 LR 讲得很深,却很少要求学生手写 LR 分析器;对递归下降讲得不多,却要求学生必须能写出来。

原因在调试成本和收益比。递归下降的错误恢复非常自然:发现某个 token 不对时,直接跳到同步集合(比如跳到下一个分号或右括号)继续往下走,能一次报出多个语法错误,这对实际编译器至关重要。LR 分析器由工具生成,状态表是黑匣子,出错时给的是“在状态 37 上遇到 token X,期望集合是 Y”,用户看到这种信息会直接懵掉。但 LR 的表达力更强,天然支持左递归,能处理的文法范围比 LL 大,而且状态转移是查表,速度稳定。

实践中的分工很明确:递归下降自己写,因为简单且错误信息友好;LR 交给生成器,因为那套表本来就该由程序算。这也顺带回答了另一个问题——为什么在某些不提供经典生成器工具链的平台上,人们仍然倾向于手写解析器:不是 LR 不好,而是把状态机搬过去的成本太高。

3. 抽象语法树:为什么编译器最后都收敛到“树”这一种结构

前端做完,产物几乎必然是一棵树。这件事看起来是惯例,其实是被形式语言本身的性质逼出来的。

3.1 从栈、链表到树:递归性决定的形状

数据结构课里讲的栈、线性表、队列,都有各自擅长的场景,但它们对“嵌套”这件事的表达能力很弱。栈能记住嵌套深度,却记不住嵌套内部的具体结构;链表能表达顺序,表达不了包含关系。而程序语言的核心特征恰好就是嵌套:函数里调用函数,循环里套循环,条件分支里再嵌条件。

树的结构是递归定义的——把任意一个节点摘下来,它连同子树仍然是一棵完整的树。这跟“表达式里套表达式、语句块里套语句块”是同构的。所以你只要在解析过程中每次成功归约一条产生式就新建一个节点,把子结果挂上去,最后一棵抽象语法树自然就长成了,不需要额外设计。

这也解释了为什么先做词法、再做语法是合理的顺序:词法层把字符流压成 token 序列(线性),语法层再把线性序列折成树(层级)。两级各自处理一种复杂度,谁都不越界。

3.2 递归遍历怎么机械地长出堆栈式中间代码

树一旦建好,从树到中间代码的过程就退化成一次遍历,而且是完全机械的——后序遍历(先处理子节点,再处理父节点)。因为父节点的代码必须等子节点的值算完才能生成,这个求值顺序天然就是后序。

# AST 节点形如 ('+', 左, 右) 或 ('int', 42) # 后序遍历生成栈式指令:每个子表达式算完,结果就留在栈顶 def gen(node, out): op = node[0] if op == 'int': out.append(f'push {node[1]}') # 叶子:把常量压栈 elif op in ('+', '-', '*', '/'): gen(node[1], out) # 先算左子树 gen(node[2], out) # 再算右子树 out.append({' +':'add', '-':'sub', '*':'mul', '/':'div'}[op]) # 二元运算:弹出栈顶两个值,运算结果压回栈顶 return out code = gen(('+', ('int', 2), ('*', ('int', 3), ('int', 4))), []) # 结果:push 2 / push 3 / push 4 / mul / add

这段代码里没有任何寄存器、没有任何临时变量名,因为栈本身就是“隐式的临时变量容器”:push 3和push 4之后,两个值的位置关系由栈顶指针自动维护,mul只需弹出两个值、压回一个。参数上唯一需要约定的是运算结果放哪——栈式指令集的答案永远是“栈顶”,这也是它能被机械生成的根本原因。

一个容易被忽略的延伸是:这种基于栈的指令序列不只出现在编译器后端,今天很多解释型语言的字节码就是它的直系后代。字节码解释器本质上就是一个巨大的switch,每条指令对应一次栈操作,这比直接生成机器码简单一个数量级,也让跨平台成为可能——同一份字节码在任何有解释器的地方都能跑。

3.3 语义分析:本质上是给树打补丁

语法树只保证“形状对”,不保证“意思对”。把一个浮点数直接赋给整型变量,语法上完全合法,编译器凭什么报错?答案在语义分析这一步,它的工作方式是遍历这棵树,在节点上查表、算类型、贴标签。

具体机制是语法制导翻译:给每条产生式挂上语义动作,动作里读写两类属性——综合属性从子节点往上传(比如表达式的类型),继承属性从父节点往下传(比如期望的目标类型)。符号表在这个过程中被建起来,作用域通过栈式结构管理:进入一个块就压一层,离开就弹一层。

类型检查之后还有更麻烦的事。支持函数重载的语言,同一个名字对应多个候选函数,调用点必须根据实参类型筛出唯一匹配,筛不出来就报歧义;支持泛型的语言,还要在类型变量上做合一。教材在这部分往往只讲策略不讲实现,原因很实在:新语言特性层出不穷,旧的规则集覆盖不了新问题,能给出的只有方法论。

4. 运行时环境:不懂机器怎么装载和分配,代码生成就是瞎猜

一个反直觉的事实是:讲代码生成的章节,前面往往要先花大量篇幅讲内存布局。因为如果你不知道一个函数调用发生时栈上发生了什么,你就不知道该怎么生成调用指令。

4.1 存储组织与分配策略:静态、栈式、堆式

程序运行时的内存通常被切成几块:存放全局变量和常量的静态区、管理函数调用的栈区、处理动态申请的堆区。三者对应三种分配策略,区别在于“生命周期由谁决定”。

静态分配最简单,编译期就能定下地址,代价是不支持递归,也不支持动态大小的对象。栈式分配利用函数调用的后进先出性质,天然匹配递归,代价是变量不能活过函数返回。堆式分配最灵活,代价是要处理碎片和回收。

函数调用在栈上留下的那块区域叫活动记录,典型布局从上到下是:实参、返回地址、控制链(指向调用者的活动记录)、访问链(指向词法外层的作用域)、局部变量、临时值。理解这张图,后面所有关于“为什么递归不会覆盖变量”“为什么局部变量返回后是垃圾值”的问题都能自己推出来。

4.2 非局部名字访问与参数传递:运行栈上的约定

嵌套函数要访问外层函数的变量时,编译器必须提供一个通道。常见方案有两种:静态链在活动记录里存一个指向词法外层活动记录的指针,访问时沿链跳 N 跳;显示表则维护一个按嵌套深度索引的指针数组,跳转是 O(1),代价是要在调用时维护这张表。选哪种是典型的时间换空间。

参数传递的语义差异更是踩坑重灾区。值传递传的是副本,函数内改不动实参;引用传递传的是地址,能改;值-结果传递先复制进去,返回时再复制回来。很多语言表面上只有一种传递方式,实际行为却另有约定——数组作为参数时会退化成指针,看着像引用传递,本质还是值传递了一个地址。这类细节不看运行时约定是推不出来的。

4.3 从堆栈式指令到寄存器分配:为什么“能跑”和“跑得快”是两门课

前面生成的栈式代码有个巨大优点:不需要考虑寄存器。机器有几个寄存器、哪个正被占用、哪个该溢出到内存,全都不用管。这也是为什么教学编译器几乎都选栈式中间代码——它让“正确性”和“性能”解耦,先把正确性拿到手。

一旦目标换成真实机器码,问题立刻变成图论问题:把变量的活跃区间画成干涉图,然后给这张图着色,颜色数等于可用寄存器数,着色失败就溢出到内存。这是一整套独立技术,复杂度足以单独开课。所以本科教学普遍把代码优化放在边缘位置,不是不重要,而是投入产出比在这一阶段不划算——一个能正确生成可执行代码的编译器,已经是相当扎实的成果。

5. 避坑:亲手做一个编译器最容易翻车的几个地方

5.1 从零手写词法分析器,把时间烧在没有收益的地方

现象:花两周手写了一个字符级扫描器,各种边界情况(注释嵌套、字符串转义、数字后缀)反复出 bug,语法分析一行没写。

原因:词法规则本质是正则语言,手写等价于手工模拟一个 DFA,而这类东西机器生成比人写可靠得多。历史上 Unix 环境里那对经典的词法/语法分析器生成工具,就是专门解决这个问题的,输入规则文件,输出扫描器代码。

解决:优先用生成器把词法层跑通,把精力留给语法和语义。真要手写,也先用状态转移表把规则列清楚再写代码,别边写边想。如果所在平台没有这套工具链,退而求其次的做法是写一个表驱动扫描器,把规则放进数据而不是代码里。

5.2 语法定义写歪了,后面全是白工

现象:解析器能跑通简单例子,一遇到稍微复杂的输入就报莫名其妙的错,改一处崩三处。

原因:文法本身有冲突或歧义。二义性文法在 LR 里表现为移进-归约冲突,在 LL 里表现为同一非终结符的两条产生式 FIRST 集相交。这类问题在解析器代码层面是修不掉的,必须回到文法层。

解决:先用工具检查文法冲突,把二义性消掉再动手。业界的血泪经验是——写出一个干净的程序语言文法,几乎等于完成了编译器的一半。另外,网上能找到现成的语法定义文件,拿来改比从零写省太多时间,尤其是那些已经被大量输入验证过的。

5.3 一上来就冲机器码和寄存器分配

现象:前端刚通,就想着直接生成汇编,结果卡在寄存器不够用、调用约定对不上、栈帧布局算错,进度彻底停住。

原因:机器码生成需要同时处理指令选择、寄存器分配、调用约定、栈对齐四件事,任何一件错了都表现为“程序跑飞”,而且极难定位。

解决:先把中间代码跑通,写一个解释器执行栈式指令,确认语义全对,再考虑往下翻译。这条路径的好处是阶段可分、错误可归因——如果解释器跑对了,后面出错就一定是后端的事。生成机器码时也优先考虑简单方案,寄存器不够就全溢出到内存,慢但正确,等正确性稳了再优化。

5.4 把语义分析当成可选步骤

现象:语法树建好了就直接生成代码,结果某些输入生成的代码行为诡异,比如整型和浮点混算结果不对。

原因:语法树只承载结构,不承载类型。缺了类型检查和类型转换插入,后端拿到的树是“语义不明”的,它只能按自己的默认假设翻译。

解决:哪怕只实现最小子集,也要有符号表和类型标注这一层。做法可以很轻:遍历树时维护一张作用域栈,遇到变量引用就查表拿类型,遇到二元运算就做类型提升并显式插入转换节点。这一步做了,后面调试会轻松很多,因为错误在语义层就被拦住,不会污染到代码生成。

6. 三本教材的分工:为什么“读哪本”比“读多少”更关键

编译原理的教材有个特点:内容框架高度趋同,几乎都是从早期那本经典著作定下的顺序——词法、语法、语义、运行时、中间代码、代码生成、优化。框架一样,差别全在“切入口”和“实践比重”上,而这两点恰好决定了一本书适不适合你当下的阶段。

教材类型切入口实践比重适合阶段读完的效果
红封面的经典教材用一个完整小例子先跑一遍全流程中,原理为主入门到打基础知道每个阶段为什么存在、互相怎么衔接
强调“现代”的那本直接讲技术细节和实现代码高,含新技术话题已经懂原理、准备动手能把先进特性(如自动内存管理)纳进自己的实现
边讲边做小语言的那本每讲一块就实现一块,最后拼成完整编译器高,原理型实践零基础且想一次做完手上有能跑的成品,理解不悬空

第一本的价值在于“先见森林”:它不急着让你写代码,而是用一个具体例子把整条流水线走一遍,让你心里先有个全局图。初学者最怕的不是难,是不知道学这个有什么用,而这本书恰好先回答了“有什么用”。

第二本的价值在“现代”二字。传统教材里看不到自动内存管理、即时编译这类话题,因为它们是在那些书成稿之后才流行起来的。如果你的目标是做一个不那么玩具的编译器,这本补的是断层。

第三本的定位最明确:它把原理和实践绑在一起推进,讲一部分就实现一部分,最后你手上会有一个完整的小型编译器。它对入门者极其友好,配套工具的使用说明也讲得很细,这在同类教材里并不常见。缺点是深度不如第一本,很多地方点到为止。

关于读原版还是译本,我的看法偏实用:这三类书的翻译质量都过得去,理解理论的实质比抠字眼重要。真正卡住人的从来不是英文,是某个算法没想通。

选择上的取舍其实只有一句话:如果只想搞懂原理,读第一本;如果想动手做一个像样的编译器,第一本打底、第二本补现代技术;如果完全零基础又想要一个能跑的成果,直接从第三本开始,边做边补理论。三条路都通向同一个地方,区别只在于你在哪个阶段会感到“我好像懂了但写不出来”。

我自己的教训是:早年间贪多,三本书同时开,结果每本都停在语法分析那一章,脑子里全是半截的 LR 状态机。后来改成一次只推进一本,每读完一章就强制把那一章的算法用代码实现一遍,哪怕只是最小可运行版本,进度反而快得多。编译原理这门课,看十遍不如把词法分析器真正跑通一次——那种“原来这一层是这样接上去的”的感觉,只有代码能给你。希望帮到你。

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

返回列表