☰
从SLR(1)分析表到中间代码:语法制导翻译与四元式生成实践
2026/10/10 3:19:37 网站建设 项目流程

简介:北京交通大学编译原理课程设计资源包,围绕基于SLR(1)分析法的语法制导翻译与中间代码生成,完整覆盖从文法定义、分析表构造到中间代码输出的编译器构建全过程。面向编译原理初学者及需要完成同类课程设计的高校学生,解决分析表冲突处理、语法制导翻译实现、三地址码生成等实践难点,适合自学也适合作为实验报告与答辩支撑。包内共11个文件,其中9个Java源文件覆盖SLR(1)分析表构建、集合计算、状态管理、语法翻译、主控程序等核心模块,另有1个测试输入文件和1份实验报告,整体仅约345KB。目前已有300人学习/下载。实验报告详述实施步骤与排错思路,源码可独立编译运行,对照源码与报告可直观理解从分析表生成到中间代码生成的完整链路,对掌握编译原理核心技术与完成课程设计均有切实帮助。

1. 这包代码能帮你搞定的核心问题:SLR(1)怎么一步步走到中间代码

如果你学过编译原理,多半卡在过这样一个组合:SLR(1)分析表能构造出来,但分析表和语法制导翻译怎么接,翻译出来的中间代码怎么和语义栈对上号,教材里往往跳过了中间的脏活。而“北交-编译原理-基于SLR(1)分析法的语法制导翻译及中间代码生成程序设计原理与实现”正是把这条线走完的一份典型课程设计:从文法出发,构建SLR(1)分析表,在归约时触发语义动作,最终产出四元式中间代码。适合两类人:一是正在做编译原理实验、被实验报告和分析器调试逼到墙角的同学;二是想自己写一个能跑通C语言子集前端的从业者。这套东西看起来是教学项目,但里面分析表构造、语义栈同步、临时变量管理这些细节,放到真实的小型编译器里也完全用得上。

2. 从文法到SLR(1)分析表:构造步骤、代码骨架与选型依据

2.1 为什么是SLR(1):在LR(0)的简单和LR(1)的复杂之间取一个折中

SLR(1)的本质是在LR(0)项目集规范族的基础上,利用FOLLOW集合在归约时做二次过滤。LR(0)只要某个项目集里出现了归约项目就归约,不管当前输入符号是什么,所以遇到二义性文法或常见表达式文法时冲突频繁。LR(1)把展望符直接做到项目里,冲突消解能力更强,但项目数量膨胀得很厉害,手写和调试的成本都高。SLR(1)的做法是:LR(0)项目照旧构造,但在决定归约前检查当前输入符号在不在这条产生式左部的FOLLOW集合里,不在就不归约。

我一般这样给学生解释:SLR(1)分析表和LR(0)分析表的差别只在“归约”那一列的填法上。LR(0)是动作表里归约项直接填满整行,SLR(1)只在该产生式左部FOLLOW集中的符号对应列填rn。这个改动不大,但表达式文法里的移进-归约冲突基本都能消掉。它是大多数编译原理课程设计里选择SLR(1)而非LR(0)的直接原因——代码量不大,但能处理的范围明显更宽。

2.2 构造SLR(1)分析表的五个关键步骤

SLR(1)分析表的构造,在实际项目里我会按下面五步走。这五步和教材顺序一致,但工程实现上有几个容易忽略的细节,我会在每个步骤后面标明。

第一步,拓广文法。对原始文法G,加一条产生式S'->S,S'是新的开始符号。这一步的目的是让“接受”动作只有一个明确的归约入口,避免分析表里出现两个接受状态。

第二步,构造LR(0)项目集规范族。对每个项目集做闭包运算,然后读入文法符号做GOTO转移,生成识别活前缀的DFA。这是表驱动分析器最核心的数据结构,后面语义动作也挂在归约项目上。

第三步,为每个非终结符计算FIRST集和FOLLOW集。SLR(1)的归约判断依赖FOLLOW集,如果FOLLOW集算错,分析表看着没问题,一跑就翻车。我见过不少项目把$\varepsilon$传播和FOLLOW集循环依赖算串了,结果两个产生式的归约条件重合,冲突消解失败。

第四步,按规则填充分析表。对每个项目集I和每个终结符a,若存在GOTO(I, a),则在ACTION表填移进动作;对每个归约项目A->α·,对a属于FOLLOW(A),在ACTION表填归约动作rn;GOTO表则记录非终结符转移。

第五步,检查冲突。同一个表格单元里同时出现移进和归约,或者两个不同的归约,就是冲突。SLR(1)出现冲突时不要急着改表,先排查这三种情况:FOLLOW集算错、文法本身二义、拓广文法的开始符号FOLLOW集没处理好。北交这个课程设计里最常见的冲突来源,就是if-else悬挂文法和表达式文法混在一起时,归约条件没做边界处理。

2.3 闭包和GOTO的最小可运行实现

只看教材上的集合运算,动手写的时候容易无从下手。我提供一个最小实现思路,用Python的frozenset做项目集的不可变表示,用字典做转移表,这样去重和查找都省事。

# 文法表示:产生式用 (左部, 右部元组) 表示,右部元组里的元素可以是终结符/非终结符/ε # 项目:用 (左部, 右部元组, 圆点位置) 表示 def closure(items, productions, first_sets): """计算LR(0)项目集的闭包""" queue = list(items) result = set(items) while queue: item = queue.pop() # 圆点右边的符号 dot_pos = item[2] if dot_pos >= len(item[1]): continue # 归约项目,没有可展开的符号 symbol = item[1][dot_pos] if symbol in productions: # 是非终结符,需要展开 for prod in productions[symbol]: new_item = (symbol, prod, 0) if new_item not in result: result.add(new_item) queue.append(new_item) return frozenset(result) def goto(item_set, symbol, productions, first_sets): """计算GOTO(I, symbol)""" moved = set() for item in item_set: dot_pos = item[2] if dot_pos < len(item[1]) and item[1][dot_pos] == symbol: moved.add((item[0], item[1], dot_pos + 1)) if not moved: return None return closure(moved, productions, first_sets)

闭包函数里那个queue是关键。每个新加进闭包的项目都可能带出新的非终结符,必须继续展开,直到没有新项目产生为止。我见过很多人只做了一层展开,导致项目集缺失,分析表直接少行,后面跑任何输入都报错。GOTO函数的核心是“圆点跨越一个符号”,注意这里只对集合里的项目逐个移动圆点,不是递归操作,和闭包是两个阶段。

这一段代码里first_sets参数在纯LR(0)闭包里其实用不到,但如果你后续扩展成SLR(1)或LALR(1)的项目传播,就会需要FIRST集参与展望符计算,所以我在函数签名里先留了口子。

2.4 分析表的数据结构设计

分析表的存储方式,决定了语义动作好不好接。我建议ACTION表和GOTO表分开,ACTION表用二维数组,行是状态编号,列是终结符;GOTO表同样二维,行是状态,列是非终结符。课程设计规模下,这种紧凑方式最好调试,打印出来也直观。

# ACTION表结构示例 # action[state][terminal] = ('shift', next_state) 移进 # action[state][terminal] = ('reduce', prod_index) 归约 # action[state][terminal] = ('accept',) 接受 # 终端符号用整数编码,-1 表示非法 action = {} goto_table = {} def fill_entry(action_table, state, symbol, action_entry): """填表并检查冲突:同位置已有不同动作时报错""" key = (state, symbol) if key in action_table and action_table[key] != action_entry: print(f"[冲突] 状态{state} 符号{symbol}: " f"{action_table[key]} vs {action_entry}") return False action_table[key] = action_entry return True

这个fill_entry函数是排查冲突的抓手。遇到冲突时先定位到具体状态和符号,再看是移进-归约冲突还是归约-归约冲突。归约-归约冲突几乎都是FOLLOW集重叠造成的,两个产生式左部的FOLLOW集有交集,在同一个输入符号上都能归约。而移进-归约冲突里,一大半是if-else悬挂文法这种二义性文法,SLR(1)理论上就处理不了,得改文法或升级成LALR(1)。做课程设计时,识别出“这冲突不是实现bug,是文法本质问题”非常关键,能省下好几天的白费功夫。

3. 语法制导翻译的落点:语义动作挂在产生式的哪个位置

3.1 语法制导定义和翻译方案的区别,以及为什么实现时要用后者

语法制导定义(SDD)是对每个产生式附加语义规则,描述属性怎么从子节点算到父节点,它是抽象的,不关心求值顺序。而语法制导翻译方案(SDT)则把语义动作明确地插到产生式右部某个位置,比如产生式E -> E + T里,动作可以放在T后面,表示归约前先执行这个动作。写代码的时候,我们实际用的是SDT,因为语义动作的触发点和分析器的归约时机是绑定的。

在SLR(1)分析器里,归约发生在栈顶形成某个产生式的右部时。归约时弹出右部所有符号,压入左部符号。如果我们的语义动作是“在归约时执行”,那么最自然的做法就是把动作关联到归约动作本身。北交这个课程设计里的常见做法,是把动作编号直接挂在产生式上,分析器在ACTION表里查到reduce动作时,根据产生式编号找到对应的语义动作函数执行。这样分析器主循环和语义动作是解耦的,调试时可以在主循环里加打印,不影响语义逻辑。

3.2 语义栈如何与分析栈保持同步

这是整个项目里最容易写错的地方。分析栈里压的是状态和文法符号,语义栈里压的是属性值。归约发生时,分析栈弹出r个符号,语义栈也弹出r个属性值,然后根据语义动作计算新属性值,压回语义栈。两边的弹出和压入必须严格对应,错一个位置,后面的属性全乱。

def reduce_handler(stack, sem_stack, prod, semantic_actions): # prod: 产生式 (left, right_tuple, action_index) right_len = len(prod[1]) if right_len > 0: # 弹出右部所有符号期对应的语义值 args = sem_stack[-right_len:] del sem_stack[-right_len:] # 语义栈弹出,注意顺序 else: args = [] # 调用对应语义动作 result = semantic_actions[prod[2]](args) # 压入左部非终结符对应的语义值 sem_stack.append(result) return result

注意args的顺序。sem_stack[-right_len:]取的是从左到右的操作数,和产生式右部从左到右的顺序一致。如果你在语义动作里要取右部最后一个符号的属性,那就是args[-1],别和栈顶搞混了。栈顶其实是最右边的符号,但数组切片里最后一个元素反而是oldest值,这是经典的索引逆反坑。我建议在语义动作函数内部再定义一个取数的辅助函数,明确参数含义,别直接用魔法索引。

3.3 综合属性与继承属性的设计边界

SLR(1)分析器自底向上工作,所以天然适合计算综合属性。子节点属性先算出来,归约时计算出父节点的属性。表达式求值、类型检查、中间代码生成,这三种需求都是综合属性为主的。但如果你要处理变量声明的类型上下文,或者需要从父节点向子节点传递信息的场景,综合属性就不够用了。

处理继承属性的常见做法有两种。一种是改造文法,把需要继承的信息作为参数放进产生式右部的语义动作里,比如变成“声明 -> 类型 变量表”,变量表归约时从栈里取类型信息;另一种是维护一个单独的属性上下文栈,和语义栈并行,在需要继承属性时从上下文栈里读取。课程设计规模下,我推荐第一种,因为第二种的栈同步问题更多一个维度。如果你发现代码里到处都在“往回取值”,多半是文法设计把属性依赖做拧了,先把文法改顺,好过在语义动作里打补丁。

3.4 语义动作的编码方式

语义动作在工程上怎么落地,我见过三种常见风格:第一种是每个产生式编号对应一段独立的if-else代码块,工程量小但维护差;第二种是用函数指针或策略模式注册,把动作做成可插拔的;第三种是把语义动作直接写进产生式右部,用中间表示生成器解析。北交这个项目里的做法,流行的是第一种和第二种的混合——产生式编号到动作的映射表,动作函数可以访问语义栈和一个全局的符号表。选哪种取决于你有没有后续扩展的需求。如果只是完成课程设计,第一种够了,逻辑都在一个文件里,打印调试也方便。如果你想把这个编译器前端继续做下去,代码生成器拆分出来会更好维护,为后续做优化和错误恢复留空间。

4. 中间代码生成:四元式设计与临时变量的一生

4.1 为什么选择四元式

中间代码的形态有AST、三地址码、四元式、后缀式四种常见选择。AST最直观,但做控制流分析和优化时还要再遍历一遍;三地址码和四元式等价,表达力基本一样,但四元式在实现上更规整——每个四元式固定四个字段,适合用结构体或元组存,打印对齐也好看。

中间代码生成这一环的核心矛盾是:既要贴近目标机器的操作模型,又要和具体目标机无关。四元式作为三元地址码的标准形态,刚好卡在这个位置上。每个四元式(op, arg1, arg2, result)表达一次运算:arg1和arg2是操作数,result是运算结果。比较运算、赋值、跳转、函数调用都统一成这个格式。后续如果要生成汇编或解释执行,只需按op分派即可。

4.2 临时变量管理

表达式翻译必然会引入大量临时变量。比如a + b * c,翻译成四元式时b * c的结果得先存到一个临时变量t1里,然后a + t1的结果再存到t2里。临时变量的命名规则,直接决定中间代码好不好阅读。

class TempVarManager: """临时变量管理器:负责申请新的临时变量编号""" def __init__(self, prefix="t"): self.prefix = prefix self.count = 0 def new_temp(self): """申请一个新的临时变量""" self.count += 1 return f"{self.prefix}{self.count}" def reset(self): """重置计数,多用于调试时多次运行同一输入""" self.count = 0 # 四元式结构:(op, arg1, arg2, result) # op 取值:'=', '+', '-', '*', '/', 'jmp', 'jlt', 'jgt', 'jeq' 等 quads = [] # 全局四元式序列 def emit(op, arg1, arg2, result): """生成一个四元式并追加到序列""" quads.append((op, arg1, arg2, result))

temp_var_manager这个设计里有个容易被忽视的点:reset方法。课程设计的测试环境里,同一份程序往往要跑多次输入,如果临时变量计数不归零,后一次生成的中间代码里t编号就是接着上次的,导致输出结果不稳定,排查时还以为代码跑错了。调试过程中我习惯在每次语法分析前调用reset,保证测试可复现。

4.3 赋值语句与表达式的翻译模式

赋值语句的翻译是最基础的起点。id = expr的翻译过程是:先递归翻译expr,得到存放expr结果值的变量或常量名,再生成一个赋值四元式。

# 假设我们有一个递归下降的表达式翻译器,这里展示赋值语句的处理逻辑 # 用伪代码形式表达语义动作的核心 def gen_assign(id_name, expr_code_gen): # expr_code_gen 返回存放表达式结果的变量名 expr_result = expr_code_gen() emit('=', expr_result, None, id_name) return id_name

关键点是表达式的翻译要返回一个“名字”,这个名字可以是原变量名、常量、或临时变量。如果一个表达式翻译后直接得到a的名字,就不需要临时变量,直接生成(=, a, None, b)。高层次的表达式化简就体现在这里:有没有省掉无谓的临时变量。北交的课程设计要求里,一般会希望你们展示四元式序列时,看到中间代码没有明显冗余。这个不起眼的地方,反而是课程设计的给分点之一。

控制语句的翻译就比较有意思了。if (E) S1 else S2的翻译逻辑是:先翻译条件E,E的翻译结果是一个布尔变量或一组条件跳转四元式列表;然后生成条件跳转指令,跳过else分支;S1翻译完成后生成一个无条件跳转跳过else体。这里有个导致初学翻车的常见错误:跳转目标(四元式序号)在没有翻译完S1和S2之前是未知的。常用的做法是回填——先emit一个四元式,记下它的位置,等跳转目标算出来后再回填到那个四元式的result字段。回填是课程设计里最经典的“指针操作”,忘了回填,生成的中间代码一跳转就跳到错误位置,程序跑起来完全不可理喻。

4.4 中间代码的可读性:新行维护与注释输出

中间代码本来是为后续优化和目标代码生成准备的,但在课程设计里,可读性直接决定你调试效率和报告展示效果。我强烈建议生成四元式时附带一个可打印的字符串表示,把它做成格式化输出函数。这样调试时可以直接打印四元式序列,肉眼检查翻译结果。

def dump_quads(quads, temp_manager): """打印所有四元式,带编号和临时变量说明""" for i, q in enumerate(quads): op, arg1, arg2, result = q line = f"[{i:3d}] {op:4s}" if arg1 is not None: line += f" {arg1}" else: line += " -" if arg2 is not None: line += f" {arg2}" else: line += " -" line += f" -> {result}" print(line)

打印格式里,编号和arg的缩进对齐要花点心思。调试时你的眼睛要在连续几十行里扫出某条跳转指令指向哪里,对齐不良的打印让你扫一分钟也看不出问题。而本课程设计里“中间代码生成”这一部分的验收标准,往往就是代码要能清晰对齐,读者一眼能读懂跳转关系。这个细节虽然谈不上技术难度,但确实影响你调试速度,也影响老师/阅卷人的第一印象。

5. SLR(1)项目避坑与排查:五个经典翻车现场

5.1 移进-归约冲突的误判:究竟卡在文法还是卡在实现

现象:构造分析表时,某个状态遇到同一个终结符同时出现移进和归约动作,程序直接报冲突退出。你检查文法觉得没问题,FOLLOW集也算了好几遍,完全不知道卡在哪。

原因:九成情况是FOLLOW集计算实现有bug。两个非终结符的FOLLOW集被算成共享集合,或者循环依赖没断开。还有一成情况是文法的设计问题,比如基本的算术表达式文法里,如果产生式写成E -> E + E这种二义形式,SLR(1)无法处理,需要改写成E -> E + T的层次结构。

解决:先把冲突状态对应的一组项目打印出来,看是哪些项目引起的冲突。如果归约项目是E -> E + E,移进项目来自E -> E + . E,那就是文法二义性问题,直接改写文法。如果归约项目和移进项目来源的文法规则看着不相关,那就去单步调试FOLLOW集计算。我在调试时会在闭包函数里加断点,看项目集是不是多算了某个非终结符的展开。

5.2 语义栈同步错位:归约时弹出的值顺序颠倒

现象:输入abc这样的表达式,翻译出的中间代码里操作数顺序反了。比如a+b生成的四元式是(=, b, a, t1),人脑看就是错的。

原因:归约时语义栈弹出顺序处理反了。我们常以为“栈顶是最后压入的值”,但归约前栈顶是最右的符号,弹出后用切片取,索引[-right_len:]得到的是从左到右的顺序。如果代码里直接写成args = sem_stack[-1]再依次往前取,没做反转,操作数就颠倒了。

解决:归约弹出时,先做一个明显标注的切片取数,再del删除。在semantic_actions函数的入口统一解包,不要到处写pop。容易出错的另一个细节是:产生式右部为空时,args=[],千万不要尝试对sem_stack取负索引切片,会取到别的属性。

5.3 终结符与语义动作混在产生式右部

现象:给文法加上语义动作后,原本能识别所有合法句子的分析器,突然对某些合法输入报错,或者生成的中间代码缺一块。排查后发现,问题出在产生式右部里那些条纹状穿插的“语义动作终结符”上。

原因:SDT有语义动作嵌入产生式右部时,会把动作当作一个语义符号出现在项目集里。如果你的实现把语义动作符号当成终结符参与分析表构造,分析器就会期待输入流里出现这个“动作”,输入当然不会匹配,于是报错。

解决:正确做法是语义动作符号在分析表构造阶段不参与其中,只在归约时触发。具体到实现上,我们在产生式编号和动作映射表之间做绑定,归约时根据产生式编号查动作,而不是让动作符号成为语法符号。如果你在代码里看到project集里出现了类似“#action1”这样的元素,说明数据结构设计上把语法符号和语义符号混在一起了,建议分开。

5.4 跳转回填时序号错位

现象:if语句翻译出的跳转四元式,目标序号指向了错误的位置。代码执行后不会跳到else分支,总是执行完then分支后直接跳出整个if。打印四元式发现中间代码顺序完全对,但跳转目标的序号比预期少1或多1。

原因:回填时使用了错误的索引基准。四元式在代码里是列表,每个元素的索引就是它的“地址”,但如果你在emit函数内对列表append之后,又用len(quads)去获取新元素的位置,而emit内部做了一次+1操作,回填就会差1。

解决:统一封装emit函数,让它返回刚生成的四元式的确切索引。回填时直接使用该返回值,不再计算列表长度。调试时注意,如果在四元式里插入或删除任意操作,所有后续跳转位置全部失效。要改结构就提前设计好,不要在调试过程中随手插入打印四元式的新步骤,除非你每次都重新生成整个序列。

5.5 说明书和代码不一致:版本漂移造成的交付翻车

现象:项目源码能跑,但说明书里描述的符号表结构、中间代码格式和实际输出对不上。验收演示时按说明书验证中间代码,跑出来的四元式op命名对不上。

原因:课程设计通常有一个“说明书”作为交付文档,但很多人在实现过程中对设计做了微调——比如把四元式的op从'+'改成了'ADD',或把变量名的输出带上了符号表序号,结果忘了同步说明书。这不是代码bug,是文档和实现漂移。

解决:交付前做一次最终对齐。跑一个短小的样例程序,把输出截图贴进说明书,比说明书里任何手写的四元式样例都准确。北交这类课程设计的说明书一般有固定要求,但不管模板怎么要求,“四元式输出示例”和“实际运行结果”这两块一定要保持一致。我的习惯是:先把代码定稿,最后一天统一改说明书里所有涉及四元式内容的部分。

6. 把中间代码的TRACE调通:三种验证方法与一个调试技巧

6.1 用标准测试集验证分析行为全链路

SLR(1)分析器做完,最怕的不是分析表构造错误,而是构造错误恰好被某个测试侥幸避开。验证套路是准备三组输入:合法短句、非法短句、带优先级的表达式。合法短句验证能归约到底并生成中间代码;非法短句验证分析器报错且不会崩;带优先级的表达式验证运算符优先级和结合性在语义动作里同样生效,比如输入a+b*c,中间代码必须先算乘法再算加法,判断t1=b*c是否出现在a+t1之前。这三组测试消化掉核心逻辑,如果都能过,再丢入长的代码文件去压测。

6.2 快照对比法:把分析栈和语义栈并列打印

调试语义栈不同步问题时,单独看某一时刻的栈没有意义,要并列打印。在分析器主循环的每次执行后,打印当前状态栈、符号栈、语义栈,再配合后续归约动作,一眼就能看出栈的演变是否和对应归约步骤匹配。

def debug_snapshot(step, state_stack, symbol_stack, sem_stack, action_used=None): """分析器调试快照:并列打印三个栈的内容""" print(f"--- 步骤 {step} ---") print(f"状态栈: {' '.join(map(str, state_stack))}") print(f"符号栈: {' '.join(symbol_stack)}") print(f"语义栈: {' '.join(str(s) for s in sem_stack)}") if action_used: print(f"动作: {action_used}") print()

这个函数建议在主循环里每步都调,但只打印最近的几次快照,避免输出爆炸。实现上的做法是维护一个环形缓冲区,只保留最后20步快照,异常时整体dump。这样在分析长输入时不会刷屏,又能保证在出错点有足够上下文。

6.3 一个调参技巧:临时变量编号的调试开关

临时变量编号混乱是最难排查的问题之一。调试时,给TempVarManager加一个flag,当flag打开时,new_temp生成的名字带上来源表达式信息。比如按表达式在源码里的位置编号,t1_3_1表示第3行第1个临时变量。这样打印四元式时,一眼就能看到临时变量来自哪段表达式。这个技巧看起来土,但在做表达式层叠翻译时极其救命。

另一个相关的习惯是给自己的中间代码生成器加一个重启入口:支持把符号表和临时变量计数器强制复位到初始状态。我遇到过一次很奇怪的现象,同一份输入,第一次跑出正确结果,第二次跑出一堆不存在的变量名。排查到最后是临时变量计数没复位,第二次运行所有生成的中间代码都带着第一次的残留编号。从此我就牢记一句话:编译器前端只要涉及全局状态,调试开关就得能把它清零。如果你在组合这些验证方法和调试习惯后,发现自己的SLR(1)分析表依然在某类输入上出错,那就是时候把文法拿回去再检查一遍了,千万别死磕分析表实现。希望这份从分析表到中间代码的完整路径和这些踩坑记录能帮到你。

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

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

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

立即咨询