☰
编译器前端实战:递归下降、AST构建与错误恢复指南
2026/10/9 10:52:41 网站建设 项目流程

简介:一份面向编译原理课程设计的实现报告,适合计算机专业本科生或需要在课设中完成编译器前端模块的读者参考。内容围绕简单文法编译器前端展开,涵盖词法分析、递归下降语法分析、语义分析、四元式中间代码生成,并扩展了常量、数组、if-else 与 while 语句等文法;同时简述后端目标代码生成流程,可帮助理解从源码到中间代码的完整构造思路。文中包含设计任务、总体流程、数据结构与算法、程序流程图、实验结果及结论等模块,并给出递归子程序调用栈快照与四元式生成实例,能直观看到编译过程的关键处理。资源为 1 个 docx 文档,压缩包约 381KB,便于直接查阅或对照课程设计要求修改复用;该报告已有 693 人学习,适合作为课程设计选题、报告结构或代码实现方面的参考样例。

1. 一个“简单”文法编译器前端,难点到底在哪

很早之前,A同学给我看他自己写的编译器前端,词法分析器用正则硬扫,语法分析器选了经典的递归下降。代码写得很规矩,一跑却出了怪事:解析“1+2*3”这种三行表达式,结果永远是把加法放在根节点,乘法缩在右子树里,优先级整个是反的。再试括号,程序直接栈溢出,评论区一句“这是传统的左递归问题”把他晾在原地。

这类问题在所谓“简单”的文法编译器前端里特别常见。它看起来只有词法、语法、抽象语法树三段,真动手时,坑全在“文法”与代码的缝里:左递归、公共前缀、优先级分层、回溯、错误定位,哪一件不处理都跑不顺。这篇笔记就按我实际搭一个最小前端时的顺序来写:先讲模块怎么切、文法怎么设计,再给一个能运行的递归下降实现,最后盘点那些不看会翻车的边角问题。它不打算做完整语言的编译器,目标是把“简单文法”的前端从能跑变成扛得住用,适合正在学编译原理的同学,也适合要给自己的小语言快速搭前端的从业者。

2. 先把前端流水线画清楚:词法、语法与AST的职责与选型

很多人一上来就写代码,结果词法和语法的边界反复改,今天觉得数字识别该归语法管,明天又觉得括号匹配该在词法里做。其实前端顺序很固定:源码先进词法分析器,产出 token 流;token 流进语法分析器,按文法产生抽象语法树(AST)。中间不需要第二个接口,AST 就是前端交付给后端的唯一产品。把这条线画清,后面的大部分选择都是顺理成章的。

2.1 词法分析器:手写扫描、正则库还是自动生成器

词法分析器的任务不是“看懂代码”,而是把字符串切成有类型的 token。它一般只做四件事:跳过空白和注释、识别一个字面量(数字、标识符)、识别一个符号(+、-、*、/、括号、分号),以及记录这个 token 在源码中的行号和列号。

对于一个 token 类型保持在 20 个以内、不带复杂字符串转义的文法,我通常直接手写一个扫描循环。理由有三:第一,线性扫描一个字符一个字符地推进,逻辑透明,出错时用 debugger 跟一遍就能看出问题;第二,报错信息可以精确到“第几行第几列的某个字符无法识别”,这对后面的语法报错非常关键;第三,不需要维护任何生成配置,改一个 token 就是改几行代码的事。反过来,如果哪天要支持多行字符串、嵌套注释、模板字符串这类状态敏感的语法,手写扫描会迅速变成状态机地狱,那时候用 flex 这类自动生成器或者成熟的词法库更划算,它们在长输入和复杂状态切换上已经被大量验证过。

也有一种中间做法:在 Python、JS 这类语言里用正则库一条一条地匹配。它适合快速验证,但有个硬伤——大部分正则库默认从当前位置开始匹配并返回第一个成功项,顺序写得稍有偏差,if和ifx就会被切错;而且正则引擎的内部回溯在遇到超长输入时很难控制。所以我自己的规则很简单:演示可以,工程上不推荐。

2.2 语法分析器:递归下降、LL(1) 表驱动还是 LR 生成器

语法分析器的选择比词法更影响开发节奏。常见路线四条:手写递归下降、LL(1) 表驱动、LR(1)/LALR 生成器(yacc/bison 这类)、以及 ANTLR 这种基于自适应 LL(*) 的生成器。

我手头这个“简单文法”项目,默认选手写递归下降,因为它和文法结构是一一对应的:文法里的一条产生式,在代码里就是一个函数;产生式右侧的每个符号,就是函数里的一个调用或匹配动作。这种对应关系让排查非常舒服——文法第 3 行有问题,直接去第 3 个函数里找状态。表驱动虽然也适合 LL(1),但那张预测分析表是二维的,每次想加一条产生式,得先重新算 FIRST 和 FOLLOW,再翻表格,心智负担比递归下降大得多。LR 生成器能处理更大的文法集合,可一旦出现冲突,报错信息像天书,对“简单”项目来说属于杀鸡用牛刀还磨刀。

那什么时候该换工具?我的判断标准是文法迭代速度。如果一周要改十几次文法,手写函数跟着改十几次太累,这时用 ANTLR 这类生成器,改完文法重新生成代码,反而效率最高。简单项目里更常见的情形是:文法基本稳定,只需要在函数里不断调整构建 AST 的动作,递归下降依然是最舒服的姿势。

2.3 token 与 AST 的数据契约,先定死再动手

前后端之间必须有一份稳定的数据契约,否则今天给 parser 一个字符串数组,明天改成带位置的字典,接口每动一次,两侧的代码全要跟着改。我一般把契约固定成两个结构。

token 最小结构是四元组:kind(token 类型,如NUM/PLUS/IDENT)、value(字面量原文或解析后的值)、line、col。其中col我习惯记“该 token 起始字符的列号”,而不是结束位置,因为报错要指出的是“从哪里开始出错”。

AST 节点最小结构是三元组:type(节点类型,如binop/number/name)、value(可选,保存运算符或字面量值)、children(有序子节点列表)。注意区分语法树和 AST:语法树会保留每一个产生式的展开痕迹,包括很多冗余节点;AST 则把括号、分隔符这类纯语法信息丢到结构里,比如(1+2)*3的括号在 AST 中根本没有节点,它的层级关系直接把“括号内先算”表达掉了。后端的语义分析和代码生成,拿到的应当只是 AST,而不是 token 流或语法树,这个约定越早定下来越好。

3. 文法设计先行:分层、消左递归与提左因子,决定天花板

我写前端的顺序和大部分人相反:先写文法,再写代码。文法不是写代码之前的文档,它是前端的地基;分层分不好,后面代码怎么写都别扭。上机验证过太多次,文法设计阶段省下的半小时,会在调试阶段用两小时还回去。

3.1 按运算优先级把文法分成三层

以最常用的四则运算表达式为例,最简单的做法是把优先级直接分层嵌入文法。标准 EBNF 写法如下:

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

这里的关键是“层”的顺序:优先级最低的运算符放在最外层,优先级最高的放在最底层解析;factor作为原子单元,要么是数字、变量,要么是用括号包裹的整个表达式。expr处理加减时,它的运算对象是term,而term已经先把乘除算完了,所以1+2*3解析出来一定是1 + (2*3)的结构。

这个分层还被另一个事实反推着:文法中每多一层,递归下降代码里就多一个函数。所以“简单”不是指层数少,而是指每层只解决一件事。等你要往语言里加比较运算、逻辑运算时,照这个模式继续往上叠层:or_expr包and_expr,and_expr包equality_expr,一层一个职责,优先级天然成立。

3.2 左递归和公共前缀:文法的两处必改点

教科书上写表达式文法常写成expr ::= expr '+' term,这叫做直接左递归,因为产生式左侧的非终结符一开头又出现了自己。递归下降函数一旦照这个文法写,解析第一个 token 就会无限调用自身,直到栈溢出。所以实现前必须把它改成右递归或 EBNF 循环式:

expr ::= term expr_tail ; expr_tail ::= ('+' term) expr_tail | ε ;

这个改法保持了“加减是左结合”的语义,同时让递归下降在每一层只消耗一个运算符再递归下降到尾部,不会死循环。实际写代码时,expr_tail很少单独做成函数,而是直接用 while 循环代替,这点下一章会看到。

另一个必改点是公共前缀。比如文法里同时有if '(' expr ')' stmt和if '(' expr ')' stmt 'else' stmt两条产生式,它们都以if '(' expr ')' stmt开头。如果照抄,递归下降解析到if之后必须做出选择,但当下根本没有足够信息判断后面有没有else,只能往两条路都试——这是回溯的根源。解决办法是提取左因子:

stmt ::= if '(' expr ')' stmt else_part ; else_part ::= 'else' stmt | ε ;

把公共前缀提出来,把“有没有 else”这个决策推迟到else_part处处理。这样解析器始终是单路径的,不用试错,也不会有指数级回溯的隐患。

3.3 优先级靠文法分层,结合性靠递归方向,两者别混

这是新手最容易混的地方。优先级解决的是“先算谁”,结合性解决的是“同优先级时从左还是从右算”。expr ::= term (('+'|'-') term)*写成循环,默认是左结合,因为每读到一个运算符,就把左边的结果和右边的term合成新节点,天然形成左深树。如果需要一个右结合运算符,比如赋值=或乘方^,做法不是在这个循环里做特殊判断,而是单写一层右递归文法:

assign ::= IDENT '=' assign | expr ;

这里assign右侧又出现assign,递归下降解析时会一直向右展开,形成右深树。把“结合性”放到文法层,代码层就只管照着递归方向建节点,两者一一对应,调试时不至于为了一个运算符写一堆 if 特例。我见过有人为了省钱,在平铺文法上用代码手动调整左右子树,结果打印 AST 一看,有的节点前序对有的后序对,根节点位置还随输入长度变化,最后整层返工。

4. 手写递归下降落地:可跑通的最小前端与关键参数

到这一步,文法已经定了:表达式语言,支持数字、变量、四则运算、括号。下面给一个能直接运行的最小实现,按照上一章的文法分层来写。语言用 Python,原因是结构表达清晰、跑起来零依赖,核心逻辑可以照搬到任何语言。

4.1 词法部分:Token 定义与线性扫描器

先定义 token 结构和词法分析器,把行号、列号在扫描时记准。

class Token: def __init__(self, kind, value, line, col): self.kind = kind # token 类型:NUM / IDENT / PLUS / MINUS / STAR / SLASH / LPAREN / RPAREN / EOF self.value = value # 字面量原文,例如 '123'、'+' self.line = line # 起始行号,从 1 开始 self.col = col # 起始列号,从 1 开始 class Lexer: def __init__(self, text): self.text = text self.pos = 0 # 当前扫描位置,指向下一个待处理字符 self.line = 1 self.col = 1 def _advance(self): ch = self.text[self.pos] self.pos += 1 if ch == '\n': self.line += 1 self.col = 1 else: self.col += 1 return ch def peek(self, offset=0): idx = self.pos + offset if idx >= len(self.text): return '' return self.text[idx] def next_token(self): while self.peek() and self.peek().isspace(): self._advance() if self.pos >= len(self.text): return Token('EOF', '', self.line, self.col) line, col = self.line, self.col ch = self.peek() if ch.isdigit(): raw = '' while self.peek().isdigit(): raw += self._advance() return Token('NUM', raw, line, col) if ch.isalpha() or ch == '_': raw = '' while self.peek().isalnum() or self.peek() == '_': raw += self._advance() return Token('IDENT', raw, line, col) if ch in '+-*/()': op = self._advance() kind_map = {'+': 'PLUS', '-': 'MINUS', '*': 'STAR', '/': 'SLASH', '(': 'LPAREN', ')': 'RPAREN'} return Token(kind_map[op], op, line, col) raise SyntaxError(f"第 {line} 行第 {col} 列出现无法识别的字符 {ch!r}")

这个扫描器的核心参数有两个:pos标记字符消费进度,line/col随_advance维护。关键技巧是next_token里先把line, col存到局部变量,再开始消费字符——因为_advance会改掉这两个值,不提前保存,所有 token 的位置都会变成结束位置。数字和标识符都采用“读到不再属于本类的字符为止”,这就是最长匹配的朴素实现。注意peek只做预读不消费,所以数字扫描时不会把下一个字符吞掉。

4.2 语法部分:递归下降与 match 模式

parser 层只维护一个前瞻 token,current指向当前正在处理的 token,advance消费它并更新。match是统一入口:类型对得上就推进,对不上就抛出带位置的语法错误。

class Parser: def __init__(self, lexer): self.lexer = lexer self.current = None self.advance() def advance(self): self.current = self.lexer.next_token() return self.current def match(self, kind): if self.current.kind != kind: raise SyntaxError( f"第 {self.current.line} 行第 {self.current.col} 列," f"期望 {kind},实际 {self.current.kind}" ) return self.advance() def parse_expr(self): node = self.parse_term() while self.current.kind in ('PLUS', 'MINUS'): op = self.advance() right = self.parse_term() node = Node('binop', op.value, [node, right]) return node def parse_term(self): node = self.parse_factor() while self.current.kind in ('STAR', 'SLASH'): op = self.advance() right = self.parse_factor() node = Node('binop', op.value, [node, right]) return node def parse_factor(self): if self.current.kind == 'NUM': tok = self.advance() return Node('number', tok.value, []) if self.current.kind == 'IDENT': tok = self.advance() return Node('name', tok.value, []) if self.current.kind == 'LPAREN': self.advance() node = self.parse_expr() self.match('RPAREN') return node raise SyntaxError( f"第 {self.current.line} 行第 {self.current.col} 列," f"意外的 token {self.current.kind}" )

注意parse_expr里 while 循环是如何实现旧文法expr_tail的:每遇到一个加减号,就把已经解析出的左节点和新的右节点合成binop。因为新节点总把旧节点放在children[0],所以左结合语义被完整保留。parse_term同理,只是把优先级更低的运算对象换成factor。这样1+2*3解析时,parse_expr第一次调parse_term,后者先吃掉整个2*3,加法节点自然挂在更外层。

4.3 AST 节点与验证入口

AST 节点仅需要type/value/children三个字段,再加一个递归打印方法就够了。验证入口建议直接打印树形结构,而不是只输出一个对象地址。

class Node: def __init__(self, type, value=None, children=None): self.type = type self.value = value self.children = children if children is not None else [] def dump(self, indent=0): line = ' ' * indent + f"{self.type}" if self.value is not None: line += f" {self.value}" print(line) for child in self.children: child.dump(indent + 1) def parse_source(text): lexer = Lexer(text) parser = Parser(lexer) ast = parser.parse_expr() if parser.current.kind != 'EOF': raise SyntaxError(f"第 {parser.current.line} 行第 {parser.current.col} 列,存在未消费的 token") return ast

代码里最后一步检查EOF经常会被人漏掉。它的作用是保证整个输入都被消费完,否则1+2 3这类多个表达式连写的输入会被静默接受,只解析出前半段。parse_source是外部唯一入口,下游拿到的只应是一个完整 AST。这套最小前端缺了语句层,但如果要扩展,只需仿照expr加一个parse_stmt函数,文法改三行,代码加三五行。

5. 文法编译器前端排查:五个翻车场景与补救办法

前端跑不动、结果不对,绝大多数不是代码写错,而是文法与代码的隐式约定被破坏。下面五条都是我在各种“简单”文法项目里真实踩过的坑,每条按现象、原因、解决的顺序拆开,方便对照排查。

5.1 递归深入后栈溢出或卡死

现象:解析1+2时直接RecursionError,或者程序看起来卡住不动,用调试器一看,栈里全是同一个函数名。这个现象几乎可以断定是直接左递归漏网进了代码。原因:文法写成expr ::= expr '+' term | term,但递归下降的函数parse_expr第一行就调用了parse_expr,token 还没有被消费,每次调用都在原位置重进。解决:改文法,把左递归改成循环式expr ::= term (('+'|'-') term)*,代码里用while而不是首行递归调用。快速排查方法:检查每个 parse 函数的函数体,确认第一个递归调用之前至少消费了一个 token;如果一个函数在没有任何 token 消费的情况下调用自己,基本就是左递归的代码化身。

5.2 优先级错乱,AST 结构不对

现象:解析1+2*3,打印 AST 根节点是binop +没错,但右子树居然也是binop +或者只有2,乘号被挂在更下层;解析(1+2)*3,括号居然不起作用。原因:绝大多数情况是文法分层没做,term和expr共用同一个层级;或者 parse 函数里把+、*放在同一个 while 循环内处理。解决:回到第 3.1 节的文法,逐层确认expr -> term、term -> factor的调用链。AST 验证我用一个土办法:把1*2+3、1+2*3、(1+2)*3三组输入并排打印,肉眼检查根节点和左右子树的运算符;任何一组的根节点运算符与预期优先级不符,就沿着调用的层数往上查。

5.3 语法报错的位置永远指向行首或文件末尾

现象:错误信息写“第 1 行第 1 列”,或者明明在表达式中间出错,位置却指向整个输入的末尾。原因:Token 不带位置信息;或者词法分析器在消费完字符后才记录line/col,导致每个 token 的位置都是结束位置;又或者 parser 报错时拿的是advance之后的current。解决:词法层在next_token开头先把当前line/col存下来,带着它构造 Token;parser 层在做假设时用当前 token 的位置。注意一个隐蔽细节:如果报错逻辑里先advance再取错误位置,位置会顺移到下一个 token,所以匹配失败要先取current.line/col,再决定是否推进。

5.4 输入稍长就慢得不像线性

现象:解析一个几百行的程序还能忍受,到几千行时肉眼可见地越跑越慢,甚至出现指数级膨胀。原因:解析器不是单路径的,它在某些决策点走了一条错路,发现失败再回头;如果公共前缀没有提干净,比如if语句的两个变体共用了大量前缀,每次遇到if都要把整棵子树尝试两遍,复杂度直接炸开。解决:先做第 3.2 节的提取左因子,把所有“先试 A 再试 B”的分支改成“读完公共前缀再决策”。还有一个常见误用:在factor里先试着按数字解析,失败再按变量解析,这种局部回溯其实无害,因为消耗的是不同 token 序列;真正危险的是两个分支共享同一段 token 消耗,那才是指数级的来源。排查时看 parse 函数里有没有“尝试性调用”,有的话重点检查两个分支是否在同一 token 位置开始。

5.5 标识符与关键字互相吞噬,数字边界切错

现象:把ifx识别成关键字,或者123abc没有报错而是拆成123和abc两个 token;1.2.3这种非法数字也可能被部分接受。原因:词法分析器按顺序匹配规则,如果关键字正则排在标识符前面,任何以关键字开头的变量都会被吞;数字扫描循环没有判断终止字符是否为字母或小数点。解决:一类修复路径是“先识别完整标识符,再查关键字表”,因为ifx是完整标识符,不在表里,自然不会被误伤;数字扫描则要求循环只在连续数字内推进,遇到字母或第二个小数点立即结束并报错。边界处理还有个参数可调:是否允许数字后紧跟字母报错。我的习惯是报错,因为123abc几乎一定是用户漏写了运算符,静默拆成两个 token 会把错误埋到语法层,导致报错信息看不懂。

6. 让前端更扛错:错误恢复、同步 token 与 AST 验证

前端的价值不只体现在能解析合法代码,还体现在遇到非法代码时能继续报出更多错误。一个错误就停的解析器,在长文件上体验极差,用户修完第一个错还要再次编译才知道第二个错在哪。所以我会在 parser 里加上最小限度的错误恢复,做法是经典的 panic mode。

所谓 panic mode,就是在语法错误发生后,不停留在原地纠结,而是跳过一批 token,直到遇到一个“同步 token”再继续解析。同步 token 的选择要配合文法边界:语句结束的分号、右括号、EOF都适合。下面这段是expect的恢复版本,可以替换基础版match在语句层使用。

SYNC_KINDS = {'SEMI', 'RPAREN', 'EOF'} def expect(self, kind): if self.current.kind == kind: return self.advance() self.errors.append( f"第 {self.current.line} 行第 {self.current.col} 列," f"期望 {kind},实际 {self.current.kind}" ) while self.current.kind not in SYNC_KINDS: self.advance() return None

注意这个版本只适合语句层的匹配,不能用在表达式内部。表达式里误吃一个右括号,后面整段结构都会乱;而语句层用分号同步,能保证错误后至少能继续识别下一条语句。错误恢复后的 AST 是不完整的,下游做语义分析前必须检查errors列表,非空就只报错不继续。我把错误收集和 AST 构建解耦,就是为了避免“AST 缺了一截还拿去分析”的情况。

验证方面,除了第 4 章的 AST 打印,我还会维护一组“用例对照表”,每个用例保存预期根节点类型和错误数。正常用例检查优先级和结合性,异常用例检查报错位置是否精确。比如1+2*3期待根节点binop +,1+2 3期待错误且错误位置指向3;这类表跑一遍,胜过手动点十次。最值得留意的验证是“改动文法后回归全表”,因为前端改一处文法,往往牵连 parser 函数和错误位置,不回归很难发现某个角落里优先级悄悄变了。

这段路我走得不算顺。最早做前端时,我也是先写代码再补文法,结果一半时间花在追优先级错乱和栈溢出的玄学问题上;后来改成“文法先行、代码对照文法”,同样的功能只用一半时间落地,报错还更准。每当有人拿着调试到崩溃的前端来找我,我第一句问的永远是“你的文法文件在哪”。先让文法立住,代码才有资格谈踩坑。希望这些场景能帮你在自己的前端里少绕几个弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询