编译器前端实战:词法分析、递归下降与四元式生成
简介《一个简单文法编译器前端的设计》是一份面向计算机专业学生与编译原理课程设计学习者的docx报告围绕简单程序设计语言的词法分析、语法语义分析与中间代码生成展开适合需要完成同类课设或梳理编译前端流程的读者参考。压缩包仅含1个docx文件大小约381KB内容以课程设计报告形式组织涵盖设计任务与要求、算法与数据结构、程序设计与实现、实验结果及结论等模块。报告具体讨论扫描器生成Token序列、以文件保存状态转换关系与关键字/界符表、递归下降子程序法同步完成语法语义分析、四元式中间代码生成并涉及常量、数组、if-else与while等文法扩展及递归子程序栈观察。读者可借此了解编译器前端从词法到中间代码的完整设计思路并参考后端目标代码生成与报告撰写结构。目前已有694人学习。1. 从 Token 到四元式编译器前端究竟在翻译什么一个反直觉的事实扫描器扫到if这两个字符时它并不认识这是关键字。它只按「字母开头、后接字母或数字」这条规则拼出了一个标识符真正把编码改成关键字的是后面一次查表——查到了就换成关键字编码查不到就原样保留成标识符。也就是说词法分析器眼里没有关键字只有「形状像标识符的字符串」。这个细节揭示了编译器设计的整体思路前端从来不是「理解」源程序而是一层层地把没有结构的字符流抬升成有结构的表示。字符流 → Token 序列 → 语法树这里是隐式的递归调用栈→ 四元式中间代码。每抬升一层信息就多一层约束能做的检查也就多一层。搞懂这条抬升链才能明白为什么左递归必须消除、为什么语义动作要嵌在递归子程序里、为什么中间代码要独立于目标机器存在。2. 词法分析为什么状态转换图要搬进文件而不是写死 if-else2.1 最长匹配扫描器真正的难点不是认出单词而是知道什么时候停把切成和把123.45e-6切成123、.、45、e、-、6这是手写词法分析最常见的翻车现场。根源在于扫描器是「边走边看」的读到的时候它已经可以接受一个合法的界符了但如果下一个字符是它就必须把刚才那个结论推翻重新拼成。这就是最长匹配原则——能拼多长就拼多长只有当继续往下走会进入非法状态时才回退到最近一次「可接受状态」。实现上不能一接受就立刻返回必须在状态机里额外记一个变量保存最近一次接受时的位置和当时的状态走到死路再回退。浮点常量那条路径更能说明问题1.、1.5、1.5e、1.5e-全都是中间状态只有落到1.5e-3的数字上才算终态中间任何一个位置断掉都会退回到更早的接受点。2.2 三张表驱动的自动机状态、字符类、动作手写if (ch ) { if (next ) return GE; return GT; }这种代码加一个运算符就要动一次分支关键字多一个就要多一个strcmp扩展到后面基本没法维护。表驱动的思路是把「转换关系」从代码里抽出来变成数据字符先映射到等价类数字 d、字母 l、界符 b、其他再用「当前状态 字符类」去查下一状态。{ charClass: { digit: d, alpha: l, punct: b }, trans: { 0: { l: 8, d: 1, b: 9, : 10, \: 11, other: -1 }, 1: { d: 1, .: 2, E: 4, e: 4, other: accept }, 2: { d: 3, other: -1 }, 3: { d: 3, E: 4, e: 4, other: accept }, 4: { d: 6, : 5, -: 5, other: -1 }, 5: { d: 6, other: -1 }, 6: { d: 6, other: accept }, 8: { l: 8, d: 8, other: accept }, 9: { other: accept } } }这段表就是原文里那张状态图的直接翻译。状态 0 是初态读到字母进 8标识符/关键字路径读到数字进 1数字常量路径读到界符进 9界符路径读到单引号进 10字符常量读到双引号进 11字符串常量。状态 1 读到.进 2、读到E/e进 4这就是1/1.5/1e10三条分支的分岔点状态 4 读到/-进 5 是处理负指数1.5e-3必须整体识别成一个常量不能拆成减号。把这张表放进外部文件后加一个运算符%只需要在界符表和状态 9 的自环上各加一条代码一行不改。这是「数据驱动」相对「硬编码」最直接的收益。提示表驱动不是没有代价。转换表存在文件里出问题时你看不到任何一行「像逻辑的代码」只能靠打印状态轨迹来定位。建议在扫描器里加一个--trace开关把每一步的(状态, 字符, 下一状态)打出来。2.3 关键字与界符的二次判定先当 id再查表关键字表里有program、var、integer、begin、end、if、else、while、real、bool、char、arr、con、of这十几个词界符表里有 * - / : , [ ] { } ; ( ) : . 这一批符号。两张表都是启动时从文件读进内存的哈希表或数组扫描器只负责问一句「这个字符串在表里吗」。关键在于编码设计标识符和常数这类「无穷多个」的单词不能用固定编码一般给一个负数类别码兜住——查到关键字就返回关键字编码查不到就返回标识符类别码id常数返回cons。这样语法分析器拿到if的时候看到的是编码 5拿到abc的时候看到的是id两者的判断路径完全不同但词法层的代码只有一处。2.4 扫描器与语法分析器的接口为什么只要一个 lookahead词法模块对外暴露的通常只有一个next_token()和一个「当前单词」变量。递归下降语法分析器每走一步都要问「现在这个词是什么」问完再决定进哪个子程序。它不需要一次性拿到全部 Token 序列——边扫边用内存里只留一个当前单词这是流式设计。代价是出错时回溯困难所以递归下降本身也不做回溯靠 FIRST 集保证每个分支入口唯一。3. 递归下降不用分析表靠函数调用栈走完整个文法3.1 FIRST 集就是子程序开头的 if 条件递归下降的写法极其朴素文法里每个非终结符写成一个函数函数体就是「看当前单词决定走哪条产生式」。PROGRAM → program id SUB_PROGRAM .这条产生式翻译过来就是先match(program)再match(id)再调sub_program()最后match(.)。每个match内部检查当前单词编码对不对对就前进一个不对就报错。那么函数开头怎么决定走哪条分支靠 FIRST 集。VARIABLE有三条产生式分别以var、con、arr开头这三者的 FIRST 集互不相交所以一个if/elif/elif就够了。如果两条产生式的 FIRST 集有交集就必须提取公共左因子如果某个非终结符能推出空串还要看 FOLLOW 集才能决定是不是该跳过它。这就是 LL(1) 的全部约束——向前看一个 Token足以唯一确定动作。SEN_SEQUENCE → EVA_SENTENCE { ; EVA_SENTENCE }里的花括号是「零次或多次」直接写成while (lookahead ;)循环。原文里EVA_SENTENCE后来又扩展出 if-else 和 while于是这个循环体的入口判断从「是不是 id」变成了「是不是 id / if / while 三者之一」但结构不变。3.2 左递归必须先消掉EXPRESSION → EXPRESSION TERM的直接翻车原文文法里写着EXPRESSION → EXPRESSION TERM | TERM。如果照着这条产生式写函数def expression(self): self.expression() # 直接左递归函数第一句就调自己 self.match() self.term()第一句就会无限自调用直到栈溢出。这不是实现问题是文法问题——左递归在自顶向下的分析方式里天然无解因为最左推导会一直往左展开永远不消费任何输入。标准解法是改写成右递归形式E → T EE → T E | ε。但在代码里还有更省栈的等价写法——用循环把递归压平def expression(self): left self.term() # 先吃一个 TERM拿到它的临时变量 while self.lookahead in (, -): # E 的循环体还能不能继续接 op self.lookahead self.match(op) right self.term() temp self.newtemp() # 每做一次运算开一个新临时变量 self.emit(op, left, right, temp) # 生成四元式 left temp # 结果成为下一轮的左操作数 return leftterm()同理处理*和/factor()处理id、cons和带括号的表达式。三层嵌套刚好对应运算符优先级括号最深、乘法居中、加减最浅。这里的 while 循环和右递归E在语义上完全等价但栈深度从 O(运算符个数) 降到 O(1)长表达式不会把递归栈撑爆。注意消左递归会改变语法树形状。abc原来是左倾斜的树压平之后变成一个循环产生的临时变量链t1ab; t2t1c。对四元式生成来说没有影响但如果后面要做结合性检查或者常量折叠得清楚这一点。3.3 语义动作插在哪归约点而不是推导点语法分析和语义分析之所以能同时做是因为递归下降的每个子程序返回时正好对应一次「归约」——这个非终结符管辖的那段代码已经翻译完了。term()返回时乘除法的四元式已经全部生成返回的是一个代表结果的临时变量名expression()拿到这个临时变量接着做加减。临时变量像一根线把前后生成的四元式串成依赖链。如果把emit放错位置——比如在term()之前就生成减法四元式——那 arg1 和 arg2 还是空的。这种时序错误在四元式序列里看不出来只有到后端生成目标代码时才会发现操作数对不上。所以规则很硬任何一条四元式它的两个操作数必须在生成时刻已经确定。3.4 四元式的结构为什么它比语法树更适合做交接四元式就是(op, arg1, arg2, result)四元组a : b c * d会被翻成三条(*, c, d, t1) (, b, t1, t2) (:, t2, -, a)相比语法树四元式把嵌套结构彻底压平成一维序列每个操作数要么是变量名、要么是临时变量名、要么是常量没有「子树」这个概念。这种扁平化带来两个好处一是后端只需要顺序扫描一遍就能生成目标代码不需要递归遍历树二是优化常量折叠、公共子表达式消除、死代码删除都可以在序列上做局部模式匹配。4. 文法扩展常量、数组、if-else、while 怎么塞进同一套子程序4.1 常量定义符号表里多一个属性位还是一张单独的表原文的常量产生式是con ID_SEQUENCE : cons ;注意它用的是cons而不是TYPE——常量的类型由字面量本身决定不需要声明。语义动作要干两件事把名字和值登记进符号表以及标记这个符号「不可被赋值」。var声明的变量和con声明的常量在语法结构上长得几乎一样区别只在关键字和分号前那一段。实现上通常走同一个declare(name, kind)入口kind字段记VAR / CON / ARR。等到EVA_SENTENCE → id : EXPRESSION做赋值时先查表如果kind CON就报「常量不可赋值」。这个检查放在语义阶段而不是语法阶段是因为语法层面x : 1完全合法只有当x被解析成常量时才有问题——这正是语义分析存在的意义。数组声明arr id cons { of cons } ;里的花括号表示多维方括号里的 cons 是维度上界。声明时要算出总元素个数往符号表里写「基地址 维度信息 元素宽度」。这部分是后端地址计算的输入。4.2 数组a[i]背后的三条四元式数组取值不是一条指令能搞定的。x : a[i]至少要做算偏移、算地址、按地址取值。一维数组且下标从 0 开始的话地址就是base i * widthwidth是单个元素的字节数。翻译成四元式(*, i, 4, t1) # t1 i * 4假设元素宽 4 字节 ([], a, t1, t2) # t2 a[t1]按地址取值 (:, t2, -, x) # x t2[]和[]是专门为数组造的两个四元式算子arg1 放数组基址或者符号表里的数组名arg2 放下标偏移result 放取到的值或要存的值。多维数组要把a[i][j]压成一维行优先展开成i * 列数 j再乘元素宽度。这个过程在语法分析阶段就能算完因为维度信息在声明时已经记进符号表了。变长数组或者下标是表达式的场景要复杂一层——下标本身要先生成四元式算出来再把结果当 arg2 用。递归下降在这里很自然factor()里遇到[就调一次expression()拿下标返回的临时变量直接塞进[]。4.3 if-else 与 while标号和回填那个必须记住的出口链控制流语句的四元式难点不在生成而在「跳转目标暂时不知道」。翻译if E then S1 else S2的时候遇到条件为假要跳到 S2 的入口可 S2 还没翻译入口地址是几根本不知道。标准做法是先填一个占位符比如 0等 S2 翻译完了再回头把那个 0 改成正确的四元式下标——这叫回填。def if_statement(self): self.match(if) cond self.expression() # 条件表达式返回临时变量 self.match(then) jmp_false self.emit(j, cond, 0, 0) # 条件为假则跳目标待回填 self.statement() # S1 的四元式 if self.lookahead else: self.match(else) jmp_end self.emit(j, 0, 0, 0) # S1 执行完跳过 S2目标待回填 self.backpatch(jmp_false, self.next_quad()) # 假出口指向 S2 入口 self.statement() # S2 的四元式 self.backpatch(jmp_end, self.next_quad()) # S1 的出口指向整个语句之后 else: self.backpatch(jmp_false, self.next_quad()) # 没有 else假出口就是语句末尾next_quad()返回下一条将要生成的四元式下标backpatch(idx, target)就是把第idx条四元式的目标字段改成target。嵌套 if 的时候每个if_statement()调用各自持有自己的jmp_false和jmp_end互不干扰——这个「各自持有」的状态正好落在递归调用栈上。while的结构反过来循环入口标号在条件之前就确定了不需要回填只有「条件为假跳出循环」这条需要回填。所以典型写法是先loop_start next_quad()生成条件生成j跳转翻译循环体最后生成无条件跳回loop_start再回填出口。4.4 递归子程序栈把「当前走到哪」变成可观察的现场递归下降最大的认知障碍是你不知道程序此刻在推导树的哪一层。原文专门设计了递归子程序栈来解决这个——每进入一个子程序就压栈退出就弹栈界面上实时显示当前栈的内容。比如解析到a : b c的号时栈大概是program → sub_program → com_sentence → sen_sequence → eva_sentence → expression → term → factor这串名字读下来就是一条从根到叶的推导路径也是「为什么此刻的 lookahead 是」的完整解释。调试左递归、调试 FIRST 集算错、调试语义动作时序靠打印这串栈比靠打断点快得多。编译器本身是个黑匣子把调用栈暴露出来等于给黑匣子开了个观察窗。5. 避坑与排查递归下降前端最容易翻车的几个地方5.1 现象被切成和语法分析报「意外的 」原因在扫描器的接受时机上。状态机读到时已经处于可接受状态如果代码写成一接受就return后面的根本没机会看。这是最长匹配没做全。解决是加一个「最近接受点」记录每次进入可接受状态时保存当前位置继续尝试往下走走到无路可走再回退到那个位置。回退时要把位置指针、单词缓冲区一起恢复两个都得回只回一个就会出现字符吃掉了但 Token 没对上的玄学现象。5.2 现象加了一个新关键字后所有用到这个前缀的标识符全部解析失败原因是词法层把关键字匹配写成了前缀匹配或者关键字表用了有序数组加线性扫描但比较逻辑写成了startswith。integer加进去以后integerValue这种标识符被误判成关键字后面语法分析自然全崩。解决是关键字判定必须整串精确匹配且必须在标识符识别完成之后做——先按最长匹配把整个字母数字串扫完再拿这个完整字符串去查关键字表。顺序反了怎么改都是错的。5.3 现象四元式里出现(, t1, t2, )result 是空的后端生成目标代码时崩这是语义动作时序问题。典型场景是二元运算的两个操作数来自不同的子程序调用代码写成先emit再调第二个子程序result 字段当时还没算出来。也有一种情况是临时变量计数器没递增连续两条四元式用了同一个t1前一条的结果被后一条覆盖。解决分两步一是把emit严格放在所有操作数都已确定之后靠子程序返回值传递依赖二是临时变量名用单调递增的全局计数器生成不要用「复用最近释放的临时变量」这种优化——在前端做这个省不了多少事却极容易翻车。5.4 现象嵌套 if-else 里内层 if 执行完跳到了外层 if 的 else 分支回填链断了。每个if_statement()都有自己的待回填集合但嵌套时如果内层直接把出口链写死成「当前末尾」外层的jmp_end就丢了。更隐蔽的情况是 else 悬挂问题if a then if b then s1 else s2里那个 else 到底归谁文法二义性没消掉的话语法分析器的行为是「就近匹配」但如果你写代码时手动处理错了顺序就会出现语义错位。解决是把每个控制流语句的出口链做成一个显式的待回填列表由statement()这一层统一收集、统一回填内层不碰外层的链。悬挂 else 的问题在文法层面用「匹配语句 / 非匹配语句」两类非终结符消掉不要指望运行时代码去兜。5.5 现象扩展了 while 之后原来能跑的 if 语句突然报错多半是 FIRST 集冲突。if_statement和while_statement的入口判断都在statement()里如果while的判断条件写成「lookahead 不是 id」那if语句也会被吞进去。或者更常见while子程序内部消费 Token 时多吃了一个导致返回后 lookahead 已经指到了下一条语句中间。排查方法是打印每次进入statement()时的 lookahead 编码和递归子程序栈深度对照 FIRST 集表逐个核对。这类问题血泪经验就一条每扩展一条产生式先把它的 FIRST 集算出来跟已有分支比对一遍确认互斥再动手写代码。6. 前端与后端的契约四元式为什么值得单独存在到这里可以讨论一个真正的设计取舍为什么要在语法树和目标代码之间硬插一层四元式直接把语义动作写成目标指令不行吗不行或者说代价很大。四元式是寄存器无关的(, a, b, t1)这条四元式不管目标机有几个寄存器、有哪些寻址方式语义都是确定的。前端只要保证四元式序列正确后端换一台机器只需要重写「四元式 → 指令」这一段映射词法、语法、语义三块一行不动。这是给未来留的后悔药——课程设计里目标机通常只有一种但真实编译器里前端要支撑多个后端这层中间表示就是分界线。代价也很实在。多一层表示就多一次遍历四元式序列里的临时变量数量远多于语法树节点后端做寄存器分配时压力更大而且四元式丢失了一部分结构信息比如「这三个四元式原本属于一个 if 语句」这种边界优化阶段要靠额外的标注才能恢复。方案前端改动成本后端复用性优化友好度调试直观性语法树直接生成目标码低差换机器重写差需遍历中四元式中间代码中多一次生成好只换映射好序列上模式匹配高可打印序列栈式字节码中最好虚拟机承载中高另一处取舍是语法分析和语义分析合体。递归下降天然支持边解析边生成省掉了一棵完整语法树的内存也省掉一次遍历。但代价是错误恢复变难——一旦在中途报错前面已经生成的四元式是半成品要么全部丢弃重来要么带着脏数据继续跑。分两遍走的编译器能在一遍里专心查语法错把所有错误报全了再进语义阶段用户体验更好。词法层把状态转换表放进文件也是同一类取舍换来了「加关键字零代码改动」付出了「调试时看不到逻辑」的代价。这个交换在需要频繁扩展文法的教学场景里划算在关键字固定不变的生产环境里就未必。验证这套东西不需要复杂工具几组边界用例就够浮点常量带负指数、和相邻出现、嵌套三层的 if-else、数组下标是表达式、常量被赋值、while 循环体里再套 while。每跑一个用例把四元式序列和递归子程序栈一起打出来对照哪一层出的问题一目了然。从那以后我每次动文法都强制先把改动后的 FIRST 集写出来、跟已有分支比一遍再开始敲代码——这个习惯帮我省掉了无数次「加完 while 之后 if 全挂」的返工。希望帮到你。本文还有配套的精品资源点击获取