☰
编译器前端课程设计:状态表驱动词法分析与递归下降四元式生成
2026/10/31 7:41:46 网站建设 项目流程

简介:一份面向编译原理课程设计与编译器入门实践的课程设计报告,围绕“简单文法编译器前端”的设计与实现展开,适合计算机专业学生、课程设计小组及希望梳理编译流程的自学者参考。压缩包内仅含 1 个 docx 文件,约 381KB,正文按概述、任务要求、算法与数据结构、程序设计与实现、结论等章节组织,便于按模块阅读。报告具体覆盖词法分析中的扫描器与 Token 序列生成、基于递归下降子程序的语法与语义同步分析、四元式中间代码生成,以及常量定义、数组结构、if-else 与 while 语句的文法扩展;还讨论了递归子程序栈的调用跟踪和后端目标代码生成思路。已有 694 人学习,可作为课程设计选题、方案撰写和答辩准备的参考样本,也能帮助读者理解前端各阶段的衔接关系与实现要点。

1. 一段二十行的源程序:这套编译器前端要交付什么

课程设计题目是「一个简单文法编译器前端的设计」,真正动手做才会发现,难的不是把a+b算出来,而是验收时对方随手改一段源程序——加个常量定义、塞个数组、套两层 if-else——递归下降的子程序还能不能对上。编译器设计在课设这个规模上不追求优化深度,追求的是链路闭合:扫描器把字符拼成 Token,递归下降子程序一边查文法一边执行语义动作吐出四元式,后端拿四元式生成目标代码,中间任何一环含糊,现场演示就会卡住。

这套方案把整条链拆成职责清晰的模块,词法表、关键字表、界符表全部外置成文件,文法扩展(常量、数组、if-else、while)有固定的加法,递归子程序栈把每一步的调用状态摊开给人看。它适合要交编译原理课设、需要现场跑演示并回答提问的人,也适合想把前端骨架挪到别的小语言上复用的从业者。

2. 总体架构与技术选型:三段流水线与文件驱动的表结构

2.1 三段流水线的数据契约

整个前端只认两个结构:Token 序列和四元式序列。源程序进来先被扫描成 Token 流,Token 流喂给递归下降子程序,子程序在归约的瞬间生成四元式,四元式序列再交给后端翻译成目标代码。这条链上最忌讳的是"中间塞私货"——比如在 Token 里塞一个语法分析阶段才用得上的字段,或者在四元式里直接存对象指针,打印出来一团地址,答辩时没法讲。

# Token 与四元式的数据契约:前后端只认这两个结构,不要中途加临时字段 from dataclasses import dataclass @dataclass class Token: kind: str # 'kw' 关键字 / 'id' 标识符 / 'cons' 常数 / 'delim' 界符 / 'eof' text: str # 原始词素,报错时原样回显给用户 code: int # 关键字表或界符表里的编码,语法分析靠它做分支 line: int # 行号,错误定位的唯一依据 col: int # 列号,配合行号能直接指到字符 @dataclass class Quad: op: str # '+' '':=' 'j' 'jf' '[]=' '=[]' 等操作符 arg1: str # 操作数1,空串表示该位不用 arg2: str # 操作数2 result: str # 结果或跳转标号

kind用字符串而不是枚举,是为了打印四元式和报错信息时不用再翻译一次;code单独存一份,是因为语法分析里 if、while、begin、end 这些关键字全靠编码比对,比字符串比较快也更不容易写错。四元式的四个字段全用字符串,临时变量名就是t1、t2这种可读名字,好处是打印出来直接能看懂,答辩现场把四元式序列贴屏幕上,讲解成本几乎为零。通常的做法是再加一个next字段把四元式串成链表,回填跳转标号的时候只改这一个字段,数组整体不动。

2.2 递归下降为什么比 LL(1) 表驱动更适合课设

同一个文法,可以走 LL(1) 预测分析表,也可以走 LR 分析表自动生成,还可以用递归下降手写。三者里最"高级"的是 LR,最省事的是递归下降,课设场景下我一般直接选递归下降,理由不是它简单,而是它和语义动作贴得最近。

表驱动分析器里,语义动作只能挂在产生式上,靠一个动作编号去查表执行,中间隔了一层;递归下降里,语义动作就是子程序里的一行代码,写到哪执行到哪,断点打上去就能看到当前栈里是什么、当前单词是什么。这一点对课程设计特别关键,因为验收要演示"某一时刻递归子程序栈的情况",递归下降天然有这个栈,表驱动得自己模拟一个出来。

代价是三条硬约束:文法不能有左递归,不能有回溯,错误恢复要自己写。左递归会让EXPRESSION → EXPRESSION + TERM这样的子程序无限递归,进程栈几十毫秒就爆掉,所以文法进代码之前必须先做等价改造;回溯意味着要保存扫描位置,Token 流得支持回退指针,否则一遇到公共前缀就得重扫;错误恢复不做的话,源程序里一个分号缺失就能让整条链崩到没法看。这三点在后面都会展开,选型阶段先明确:课设规模的文法(十几条产生式、二十来个关键字),递归下降的综合成本最低。

2.3 文件驱动的表结构:把可变部分从代码里赶出去

设计要求里写得很清楚:状态转换关系、关键字表、界符表都以文件形式保存。这条不是形式主义,它直接决定了"扩展文法"这个演示环节能不能做得漂亮。如果关键字写死在if (s == "while")这样的判断里,加一个for关键字就得改代码、重编译;做成文件以后,演示时现场往文件里加一行,程序重跑就认了,这个动作在答辩里说服力很强。

关键字表和界符表用最简单的两列文本,一行一个词条,前面是词素后面是编码:

# keyword.txt 词素 编码 program 0 var 1 integer 2 begin 3 end 4 if 5 else 6 while 7 real 8 bool 9 char 10 arr 11 con 12 of 13

界符表同构,+ 0、* 1、:= 4、>= 19这样排下去。加载时读进两个字典,一个从词素查编码,一个从编码查词素,后者专门给报错信息用——报"第 5 行遇到意外的]",比报"遇到界符编码 7"有用得多。编码顺序不要随意变动,因为语法分析里会拿编码做分支,改了编码等于改了文法,这一点要在文件头写注释提醒。

状态转换表稍微复杂一点,字段是"当前状态、输入类别、后继状态、动作"四列:

# dfa.txt 当前状态 输入类别 后继状态 动作 0 letter 1 keep 0 digit 2 keep 0 quote 5 keep 1 letter 1 keep 1 digit 1 keep 1 other 0 emit_id 2 digit 2 keep 2 dot 3 keep 2 other 0 emit_cons 3 digit 4 keep 4 digit 4 keep 4 other 0 emit_cons

动作字段只有三种取值:keep表示把当前字符吃进缓冲区继续走,emit_id表示缓冲区里攒的是一个标识符,交给关键字表判定,emit_cons表示攒的是一个数字常量。这样扫描器主循环里没有一行 if-else 是跟具体词法规则绑死的,全部逻辑压进这张表。加一条规则——比如支持科学计数法的1.5e-3——只需要在文件里补几行状态转移,代码不动。

3. 词法分析模块:状态转换表驱动的扫描器怎么落地

3.1 状态划分与输入类别的归一

状态机本身不复杂,难点在状态怎么划分才不臃肿。我一般按"最长的公共前缀"来分:状态 0 是初态,读字母进 1(标识符和关键字的公共路径),读数字进 2(整数的路径),读到.从 2 进 3(小数部分),读到E或e再分一支走指数。字符常量和字符串常量各自一条支路,进入后一直吃到对应的闭合引号,中途遇到行尾还没闭合就报错。

输入类别要在扫描之前归一化,别在状态转移里直接比字符。字母、数字、下划线、引号、点、其他,归成letter、digit、under、quote、dot、other六类就够了。归一的函数放在扫描器里,一行三目运算的事:

def classify(ch: str) -> str: # 把字符归成状态表认识的几种输入类别,状态表本身不关心具体字符 if ch.isalpha() or ch == '_': return 'letter' if ch.isdigit(): return 'digit' if ch in '"\'': return 'quote' if ch == '.': return 'dot' return 'other'

归一之后,状态表的每一行就是一个四元组,查表时用(state, cls)做键,命中不到就说明这个组合根本不该出现,直接报词法错误并把当前状态和字符一起打出来。这样做的额外好处是调试:程序跑飞了,把(state, cls)打一遍就能看出卡在哪个状态,不用在几十个分支里靠猜。

需要注意的是引号这条支路,单引号和双引号走不同的结束条件,但状态表里只有一个quote类别。解决办法是在进入引号状态时把当前的引号字符记在扫描器的quote_char变量里,退出条件判断"当前字符是否等于quote_char",而不是判断"当前字符是不是引号"。这个细节不做,遇到'a'和"a"混排就会提前截断,而且报错位置会飘到下一行去。

3.2 扫描主循环:最长匹配与回退

扫描主循环的核心是"吃到一个不能吃的字符为止,然后回退一格"。这个回退动作是词法分析里最容易写错的地方——多退一格会把下一个单词的首字符吞掉,少退一格会把分隔符当成单词的一部分。

class Scanner: def __init__(self, src: str, dfa: dict, kw: dict, delim: dict): self.src, self.pos = src, 0 self.dfa = dfa # (state, cls) -> (next_state, action) self.kw, self.delim = kw, delim self.line, self.col = 1, 1 def next_token(self) -> Token: while self.pos < len(self.src) and self.src[self.pos].isspace(): self._advance() # 跳过空白,行号在这里累加 if self.pos >= len(self.src): return Token('eof', '', -1, self.line, self.col) state, buf, start = 0, [], (self.line, self.col) while self.pos < len(self.src): ch = self.src[self.pos] key = (state, classify(ch)) if key not in self.dfa: break # 状态表里没有这条路,停下来准备归约 state, action = self.dfa[key] if action == 'keep': buf.append(ch) self._advance() else: break # 该归约了,但当前字符不属于这个单词 return self._emit(state, ''.join(buf), start) def _emit(self, state, text, start): # state 决定归约成什么:1 是标识符/关键字,2、4 是常数,其余按界符处理 if state == 1: code = self.kw.get(text) return Token('kw' if code is not None else 'id', text, code or 0, *start) if state in (2, 4): return Token('cons', text, 0, *start) code = self.delim.get(text) if code is None: raise SyntaxError(f'第 {start[0]} 行第 {start[1]} 列无法识别的词素: {text}') return Token('delim', text, code, *start)

_advance里改self.pos,遇到\n时line加一、col归一是常规操作,但一定要在每个吃字符的地方都走这个函数,不能有的地方pos += 1、有的地方调_advance,否则行号迟早出错,而错行号的报错信息比没有报错更误导人。

_emit里的state == 1分支是关键设计点:标识符和关键字在词法阶段不做区分,扫描器只负责切出词素,是不是关键字交给关键字表查一次。这样做的好处是关键字可以随时从文件里加,不需要动状态机;代价是program这样的单词在状态机里和普通标识符走完全相同的路径,状态数少了一大截。

3.3 扩展一个新界符或关键字的完整流程

演示"词法可扩展"的正确姿势,是从改文件开始到跑出新结果结束,中间不碰代码。比如要加一个复合赋值+=,步骤是这样:

第一步,在delim.txt末尾追加一行+= 21,编码接着往下排,不要插在中间。第二步,看状态表:+已经有一条从状态 0 出发的路径了,现在要让它读完+之后再看一眼是不是=,所以要把原来那条0 other '+' ... emit_delim的规则拆成"读+进新状态,新状态遇到=就归约成+=,否则回退一格归约成+"。第三步,在状态表文件里补两行。最后跑一遍,a += 1那边能切出id(a)、delim(+=)、cons(1)。

这个流程里有个坑:拆规则的时候容易把回退逻辑漏掉,导致a + 1里的+也被读成+=的前缀然后报错。所以状态表里必须有一个明确的"归约并回退"动作,跟"归约但已消费"分开。通常的做法是在动作字段里加第四种取值emit_back,表示归约这个单词但把最后读进来那个字符还回去。这个动作一加,状态表的表驱动能力就完整了,后面所有双字符界符(<=、>=、:=)都能用同一套机制覆盖。

关键字扩展更简单,往keyword.txt加一行就行,因为状态机里根本没有关键字专用的路径。但要注意:如果新增的关键字和已有的标识符冲突(比如加了int而程序里本来就有个变量叫int),词法阶段会把所有int都判成关键字,这个行为在多数语言里是对的,但要在文档里写清楚。

4. 语法语义分析与四元式生成:递归下降子程序的实现骨架

4.1 文法改造:消左递归和提左公因子

原始文法里EXPRESSION → EXPRESSION + TERM | TERM是直接左递归,TERM → TERM * FACTOR | FACTOR也是,这两条不改,子程序一进去就自己调自己。改造成右递归的标准做法是引入新非终结符:

EXPRESSION → TERM EXPRESSION' EXPRESSION' → + TERM EXPRESSION' | ε TERM → FACTOR TERM' TERM' → * FACTOR TERM' | ε

改造之后每个非终结符对应一个子程序,EXPRESSION调TERM再调EXPRESSION',EXPRESSION'里看到+就继续,看不到就直接返回,递归深度等于运算符个数,课设规模下完全够用。

除了左递归,还要检查 FIRST 集冲突。VARIABLE那条产生式有var、con、arr、ε四个候选,前三个的首符集互不相交,第四个是空产生式,判断逻辑就是看当前 Token 是不是var/con/arr,都不是就跳过。EVA_SENTENCE只有id := EXPRESSION一条,首符集是id,也没问题。真正要当心的是扩展文法之后引入的新冲突,比如数组定义arr id cons { of cons } ;和变量定义都以id开头,如果实现时把ID_SEQUENCE和数组那条的首符集搞混,就会在解析到一半时走错分支。检查办法很土但有效:把每个非终结符的首符集列在纸上,两两对比,有交集就在注释里写清楚靠什么区分。

4.2 递归下降子程序骨架与语义动作挂载点

每个非终结符一个函数,函数开头记录当前状态(压栈),函数结尾弹栈,中间按产生式右部逐个匹配。语义动作就写在这个函数的相应位置上,比如EVA_SENTENCE解析完右部表达式之后,左部变量名和右部结果都已经在手上,这时候生成一条赋值四元式刚刚好。

def expression(self) -> str: # 返回值为本次表达式的结果位置(临时变量名或已有变量名) left = self.term() return self.expression_tail(left) def expression_tail(self, left: str) -> str: if self.cur.kind == 'delim' and self.cur.code == 0: # 界符编码 0 是 '+' self.match_delim(0) # 吃掉 '+' right = self.term() tmp = self.new_temp() # 先申请临时变量 self.emit('+', left, right, tmp) # 再生成四元式 return self.expression_tail(tmp) # 右递归继续 return left # ε 分支直接返回

这段代码里三个顺序不能乱:先match_delim吃掉运算符,再解析右操作数,最后生成四元式。如果先emit再解析右操作数,right还是空串,四元式里就会出现(+, a, , t1)这种残缺条目,而且错得很隐蔽——程序能跑完,只是结果全错。

term和factor同构,factor的三个分支分别是标识符、常数、括号表达式,前两个直接返回词素本身,第三个递归调expression再吃掉右括号。括号分支里要注意:括号内的表达式结果如果是临时变量,括号结束后这个临时变量不能立刻回收,因为外层还要用。临时变量的生命周期管理放在整个语句解析完之后统一做,比在每个子程序里精细控制要安全得多。

4.3 四元式生成:临时变量编号与跳转回填

四元式的生成策略分两类。算术和赋值是"自底向上"的:子表达式先算完,结果落在临时变量里,父表达式拿临时变量当操作数。控制流是"回填"的:if 和 while 在生成跳转指令时还不知道跳转目标在哪,先把标号空着,等目标位置确定了再回头填。

def if_statement(self): self.match_kw(5) # if cond = self.expression() # 条件表达式,结果在 cond 里 q = self.emit('jf', cond, '', '') # 条件为假就跳走,目标先空着 self.match_kw(3) # begin,注意这里和语句块约定一致 self.sentence_sequence() self.match_kw(4) # end if self.cur.kind == 'kw' and self.cur.code == 6: # 有 else 分支 end_label = self.new_label() self.emit('j', '', '', end_label) # 真分支结束,跳过 else self.patch(q, self.next_label()) # 回填 jf 的目标 self.match_kw(6) self.match_kw(3) self.sentence_sequence() self.match_kw(4) self.place_label(end_label) # 落下终点标号 else: self.patch(q, self.next_label()) # 没有 else,jf 跳到当前末尾

emit返回这条四元式的索引,patch拿索引和标号回填result字段,next_label返回下一个待分配标号,place_label在四元式序列里插入一条纯标号条目。这四个函数组成回填的全部机制,加起来不到三十行,但它是整个语义分析里唯一需要"回头改前面的东西"的地方,也是答辩时最容易被追问的设计点。

临时变量和标号用两套独立的计数器,t1、t2、t3和L1、L2,不要混用同一个前缀,否则打印出来分不清哪个是值哪个是位置。数组元素读写需要专门的四元式形式,[]=表示写入(base、index、value 三位),=[]表示读出(base、index 进前两位,结果在第四位),这两种形式在生成目标代码时对应不同的寻址方式,所以中间代码阶段就要区分开,不能都塞进普通的:=。

4.4 文法扩展四件套与递归子程序栈

设计要求点名的扩展有四个:常量定义、数组、if-else、while。四个扩展里改动最小的是常量,con ID_SEQUENCE : cons ;这一条只是在VARIABLE的候选里多一个分支,语义动作是把名字和值成对写进符号表,标记为只读,赋值语句里如果左部是常量就报"不可赋值"。

数组的扩展最麻烦,因为它同时动词法、语法和符号表三处。词法上arr、of要进关键字表,方括号要进界符表;语法上要加arr id cons { of cons } ;这条产生式,解析完把维数和每维长度记下来;符号表上要知道这个数组占用多少空间,符号表项里得加一个维度字段。三维以上的数组在课设里通常不做,二维足以演示。

if-else 和 while 的扩展主要落在控制流的四元式上,while 比 if 多一个回跳:

def while_statement(self): start = self.place_label(self.new_label()) # 循环头标号先落下 cond = self.expression() q = self.emit('jf', cond, '', '') # 假就跳出循环 self.match_kw(3); self.sentence_sequence(); self.match_kw(4) self.emit('j', '', '', start.label) # 真分支跑完跳回循环头 self.patch(q, self.next_label()) # 回填出口

这里的start必须先place_label再解析条件,因为条件表达式里可能又嵌套了 while 或者 if,标号顺序错了整段跳转全乱。

递归子程序栈是这份设计里最有展示价值的部分。实现上不复杂:每个子程序入口push(name, cur_token, len(quads)),出口pop(),栈里存的每条记录包含子程序名、进入时的当前单词、当时的四元式条数。演示时把栈打出来,看到expression → term → factor → term → expression这样的形态,评审就能直观看出当前在文法的哪一层、已经生成了几条四元式。这个栈只在调试模式下开,正常编译时关掉,避免拖慢扫描。

5. 常见问题排查:递归下降最容易踩的五个坑

5.1 进程栈溢出:左递归没消干净

现象是程序一跑就崩,错误信息指向递归调用过深,或者在浏览器/IDE 里直接闪退,栈帧刷满屏。原因通常是某个非终结符还留着直接左递归,或者间接左递归没被发现——A → B ...、B → A ...这种循环在产生式少的时候容易漏。解决分两步:先把所有产生式按非终结符排序,逐个检查右部第一个符号是不是自己或自己前面的符号,有就改成右递归;改完用最简输入(一个只有begin end的空程序)跑一遍,能正常退出说明改造到位。间接左递归的排查靠人工看产生式组,别指望程序告诉你。

5.2 词法切分错误:最长匹配做漏了

现象是a >= b解析报错,提示意外的=,或者1.5被切成1、.、5三段。原因是双字符界符没有做前瞻,状态机读到>就立刻归约,没看下一个字符是不是=。解决是在状态表里为>、<、:各加一个中间状态,读到第二个字符比对成功就归约成两字符界符,比对失败就回退一格归约成单字符。小数同理,.之后必须再看一位是不是数字,是才进小数分支。判断标准很简单:把所有双字符界符和所有数字形态列成一张表,逐个用例跑一遍,有一个切错就说明回退逻辑有问题。

5.3 错误恢复缺失:一个错带动全篇报错

现象是源程序里少一个分号,程序连续报出二十条错误,第二条开始全是错位的。原因是第一个错误抛出后没有做同步,扫描位置还停在原地或者乱跳。解决是在语句级的子程序外面包一层try,捕获到语法错误时丢弃当前 Token 直到遇到;或end这类同步符号,然后继续解析下一条语句,同时把错误记进错误列表而不是直接抛出。这样最终报出的错误条数就等于真实错误条数。这一步不做,演示时一改源程序就满屏红字,场面很难看。

5.4 四元式临时变量冲突:名字撞车

现象是同一个表达式算出来的结果不对,打印四元式发现两条不同的运算写进了同一个t1。原因是临时变量计数器在某个子程序里被重置了,或者嵌套语句里用了局部计数器。解决是把临时变量和标号的计数器都挂在语法分析器实例上,全局唯一,所有子程序共用同一个new_temp(),绝不允许在子程序内部自己维护计数。另一个隐蔽的变种是while循环体里的临时变量被复用,导致第二轮循环读到上一轮的值——这个问题在临时变量只增不减的策略下不会出现,所以哪怕是课设,也别做临时变量的主动回收。

5.5 表文件与代码不一致:改了文件没生效

现象是往keyword.txt里加了新关键字,程序还是把它当标识符;或者状态表改了规则,扫描行为一点没变。原因通常是加载路径写死成相对路径,程序从别的目录启动时读到了旧文件,或者读文件之后被代码里的硬编码初始化覆盖了。解决是把三张表的加载放在同一处,加载完打一行日志显示每个表加载了多少条、文件绝对路径是什么;另外在代码里彻底禁掉任何硬编码的关键字判断。我一般会在启动时做一次自检:文件里出现的编码如果有重复,直接报错退出,不让它带病运行。

6. 验收与答辩:用一组用例把前端讲透

验收能不能过,很大程度上取决于你准备了几组用例、按什么顺序演示。我的习惯是准备三层用例,从小到大依次上,每一层都对应一个设计点,讲的时候不用背稿,指着输出说就行。

层级输入特征演示目的预期输出
最小program demo. begin end验证整体流程闭合,Token、四元式、目标代码三样都能出Token 序列 5 条,四元式为空,目标代码含起止指令
核心含变量声明、常量定义、四则运算赋值验证符号表、临时变量编号、四元式生成顺序每条赋值对应一条:=,算术自底向上成链
扩展数组读写 + if-else 嵌套 + while验证文法扩展和跳转回填jf/j的标号成对出现,无悬空标号

演示顺序按这个表走,每跑完一层就切到递归子程序栈的窗口看一眼,讲一句"现在栈里是 expression 调 term 调 factor,说明正在解析乘除优先的那一层"。这句话比任何 PPT 都直观,因为它把抽象的文法推导变成了可见的调用链。

提问环节通常会盯三个地方。第一个是文法改造,"你这个文法有左递归吗,怎么改的",回答要能当场把产生式写出来,别只说"改过了"。第二个是四元式的执行顺序,"为什么先算乘法",答案是优先级通过文法层次体现,term在expression里面一层,自然先归约。第三个是扩展性,"现在加一个 for 循环要改哪里",标准答案是三处:关键字表加一行、文法加一条产生式、递归下降加一个子程序,其余不动。能把这三处说清楚,扩展性这个设计点就立住了。

还有一个容易被忽略的准备:故意留一个错误用例,比如少写一个分号,演示错误恢复。多数人只演示正确路径,一遇到错误输入程序就崩,这时候能稳定报出"第 6 行缺少分号,已跳过至下一条语句",比多跑十个正确用例更加分。

从那以后我每次带课设验收,都强制自己先按"最小、核心、扩展、错误"四组用例各跑一遍,跑不通就不约演示时间——这个习惯救过我不止一次,因为问题往往就藏在最小用例里。希望帮到你。

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

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

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

立即咨询