简介:《一个简单文法编译器前端的设计》是一份面向计算机专业学生与编译原理课程设计学习者的docx报告,围绕简单程序设计语言的词法分析、语法语义分析与中间代码生成展开,适合需要完成同类课设或梳理编译前端流程的读者参考。压缩包仅含1个docx文件,大小约381KB,内容以课程设计报告形式组织,涵盖设计任务与要求、算法与数据结构、程序设计与实现、实验结果及结论等模块。报告具体讨论扫描器生成Token序列、以文件保存状态转换关系与关键字/界符表、递归下降子程序法同步完成语法语义分析、四元式中间代码生成,并涉及常量、数组、if-else与while等文法扩展及递归子程序栈观察。读者可借此了解编译器前端从词法到中间代码的完整设计思路,并参考后端目标代码生成与报告撰写结构。目前已有694人学习。
1. 从 Token 到四元式:编译器前端究竟在翻译什么
一个反直觉的事实:扫描器扫到if这两个字符时,它并不认识这是关键字。它只按「字母开头、后接字母或数字」这条规则拼出了一个标识符,真正把编码改成关键字的是后面一次查表——查到了就换成关键字编码,查不到就原样保留成标识符。也就是说,词法分析器眼里没有关键字,只有「形状像标识符的字符串」。
这个细节揭示了编译器设计的整体思路:前端从来不是「理解」源程序,而是一层层地把没有结构的字符流抬升成有结构的表示。字符流 → Token 序列 → 语法树(这里是隐式的递归调用栈)→ 四元式中间代码。每抬升一层,信息就多一层约束,能做的检查也就多一层。搞懂这条抬升链,才能明白为什么左递归必须消除、为什么语义动作要嵌在递归子程序里、为什么中间代码要独立于目标机器存在。
2. 词法分析:为什么状态转换图要搬进文件,而不是写死 if-else
2.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 E',E' → + 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),长表达式不会把递归栈撑爆。
注意:消左递归会改变语法树形状。
a+b+c原来是左倾斜的树,压平之后变成一个循环产生的临时变量链t1=a+b; t2=t1+c。对四元式生成来说没有影响,但如果后面要做结合性检查或者常量折叠,得清楚这一点。
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 * width,width是单个元素的字节数。翻译成四元式:
(*, 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 全挂」的返工。希望帮到你。
本文还有配套的精品资源,点击获取