☰
SLR(1)语法制导翻译与中间代码生成实战:从文法到四元式完整指南
2026/10/10 5:42:43 网站建设 项目流程

简介:面向编译原理课程学习者,这是一份源自北交大的SLR(1)语法制导翻译与中间代码生成完整课程设计资料包,覆盖理论到实现全流程,可支撑同类实验、课程设计或毕业设计前期的编译器原型验证。压缩包共11个文件,核心为9个Java源文件,从文法定义、FIRST/FOLLOW集合求解、SLR(1)分析表构造、DFA状态管理到翻译主控程序一应俱全;另含1个tys测试输入文件和1份docx实验报告,报告内讲述具体实施步骤、遇到的问题与解决方案,便于对照代码逐行排查。整包仅345KB,轻量易获取,适合快速搭建编译原理实验环境。该资源已有300人学习浏览,具备良好参考价值。读者通过研读源码与实验记录,可理清SLR(1)冲突消解、语法制导翻译规则和中间代码生成的完整实现脉络,并获得一套可直接运行、便于二次开发的课程设计范例,对深入理解编译器前端与中间表示均有切实帮助。

1. 编译原理课设里最难啃的硬骨头:SLR(1)语法制导翻译与中间代码生成

北交的编译原理课程设计经常出现这个题目:基于SLR(1)分析法的语法制导翻译及中间代码生成。它要的不是一个能跑词法分析的 demo,而是把一门小型语言从源码字符流一路处理到四元式中间代码的完整前端。很多同学卡在两步:一是 SLR(1) 分析表的构造原理没吃透,二是分析器出来之后不知道语义动作怎么挂上去——表能建出来,中间代码却全是错的。这篇笔记按我实际做这类课设的经验,把文法设计、分析表构造、语义栈同步、四元式生成和常见坑一次讲完,适合正在写编译原理实验报告、或者想从零复现一个可运行分析器的人。

2. 构造SLR(1)分析器:从文法设计到ACTION/GOTO表的完整流程

2.1 文法设计:为什么第一步直接决定分析器能不能建出来

SLR(1) 能处理的文法集合大于 LL(1),但不等于所有 CFG 都能用。设计文法时第一个要注意的是二义性,最典型的二义性来源是 if-else 悬空。考虑产生式S -> if E then S | if E then S else S | other,这个文法是二义的,因为if E then if E then S else S中的 else 可以匹配内层或外层的 if。构造项目集时会在某个状态出现移进-规约冲突。解决方式是把文法改造成匹配最近的 if:S -> M | I,其中M -> if E then M else M | other,I -> if E then M | if E then I else I。悬挂的 if 被单独拆出来,冲突就消掉了。

另一个常见问题是运算符优先级和结合性。不要写成E -> E + E | E * E | (E) | id这种平铺文法,否则分析表里全是冲突。标准做法是把优先级分层,让+和*的优先级、左结合性都由文法结构直接体现,SLR(1) 分析表构造出来是干净无冲突的:

E -> E + T | T T -> T * F | F F -> (E) | id

注意这里保留了左递归,SLR(1) 处理左递归没有问题,不需要像 LL(1) 那样消除左递归,这是 LR 类分析方法的一个重要优势。设计文法时要顺手考虑语义动作的位置。我一般会把每个产生式右部需要归约的语义动作写在产生式末尾,这样自底向上归约时动作时机最自然,属性栈操作也最简单。如果语义动作必须放在产生式中间,SLR(1) 就要引入特殊标记非终结符或改写文法,课设一般用不到,能避开尽量避开。

2.2 FIRST/FOLLOW集合:手工计算容易错,直接交给程序

分析表构造依赖 FIRST 和 FOLLOW 集合。手工算小文法还行,文法一扩到十几个产生式就很容易漏。我习惯先把这两个集合的计算写成函数,后面的项目集构造和冲突检查都基于它们。计算 FIRST 集合时要特别注意左递归文法会导致朴素递归函数死循环,所以必须用迭代或 visited 标记。

def compute_first(grammar): # grammar: {'E': [['E','+','T'], ['T']], ...},右部为终结符/非终结符列表 # 空串用 '@' 表示 first = {nt: set() for nt in grammar} changed = True while changed: changed = False for A, productions in grammar.items(): for production in productions: for symbol in production: if symbol in grammar: # 非终结符:把它的FIRST集合(不含空串)并进来 old_len = len(first[A]) first[A] |= first[symbol] - {'@'} if len(first[A]) != old_len: changed = True if '@' not in first[symbol]: break # 该符号不能推导出空串,停止向后传播 else: # 终结符直接加入 if symbol not in first[A]: first[A].add(symbol) changed = True break else: # 产生式右部所有符号都可空,则A可推导出空串 if '@' not in first[A]: first[A].add('@') changed = True return first

这个实现的逻辑是不断地把右部首符号的 FIRST 集合向左部传播,直到所有集合不再变化。关键点在break的时机:遇到终结符就直接终止本轮传播;遇到非终结符且其 FIRST 集合不含空串时也终止,因为后续符号不会对 FIRST(A) 有贡献。changed标志保证循环收敛,不会因为左递归陷入死循环。

FOLLOW 集合的计算逻辑类似,但要把空串排除,并且把 A 的 FOLLOW 集合传播到右部末尾的非终结符上:

def compute_follow(grammar, start_symbol, first): follow = {nt: set() for nt in grammar} follow[start_symbol].add('$') # 输入结束符 changed = True while changed: changed = False for A, productions in grammar.items(): for production in productions: for i, B in enumerate(production): if B not in grammar: continue beta = production[i+1:] if beta: # 把 FIRST(beta)(排除空串)加入 FOLLOW(B) before = len(follow[B]) for s in beta: if s in grammar: follow[B] |= first[s] - {'@'} if '@' not in first[s]: break else: follow[B].add(s) break else: # beta 整体可推导出空串,继承 A 的 FOLLOW follow[B] |= follow[A] if len(follow[B]) != before: changed = True else: # B 是产生式末尾,直接继承 A 的 FOLLOW before = len(follow[B]) follow[B] |= follow[A] if len(follow[B]) != before: changed = True return follow

A -> alpha B beta有两种情况:beta 非空时,FOLLOW(B) 包含 FIRST(beta)(空串除外),如果 beta 整体可空,还包含 FOLLOW(A);beta 为空时,FOLLOW(B) 直接包含 FOLLOW(A)。这个迭代算法同样用changed收敛,对课设规模几十个产生式的文法完全没有性能压力。参数调整建议:结束符$一定要加到开始符号的 FOLLOW 集合,否则分析表最后一行接受状态会缺项。

2.3 项目集规范族与分析表构造:核心数据结构和冲突检查

项目集是 LR 分析的核心黑匣子。一个项目是一个带点产生式,比如E -> E . + T表示已经分析了E,期待看到+。项目集由 closure 和 goto 两个函数生成。closure 的规则:如果项目A -> alpha . B beta在集合中,那么对B的每个产生式B -> gamma,项目B -> . gamma也要加入集合。goto 则是在项目集内让点越过符号 X,找到能到达的下一个项目集。

def closure(items, grammar): result = set(items) changed = True while changed: changed = False for item in list(result): # item 形式: (left, right_tuple),right_tuple 中 '.' 表示分析位置 dot_pos = item[1].index('.') if dot_pos == len(item[1]) - 1: continue # 规约项目,点在最右 symbol = item[1][dot_pos + 1] if symbol in grammar: for prod in grammar[symbol]: new_item = (symbol, ('.',) + tuple(prod)) if new_item not in result: result.add(new_item) changed = True return result

closure 的每轮循环都会扫描整个项目集,直到新增项目不再出现为止。实现细节:dot_pos每次用 index 查找,性能不高,但课设文法规模小,不用纠结;如果要做大文法,可以改成在项目里直接存点位置,避免反复 index。

分析表的构造规则有四条:对项目集 I 中形如A -> alpha . a beta的项目(a 是终结符),ACTION[I, a] = shift,状态为 goto(I, a);对项目集 I 中形如A -> alpha .的规约项目,对每个FOLLOW(A)中的终结符 a,ACTION[I, a] = reduce,用产生式A -> alpha;对项目集中的增广产生式S' -> S .,ACTION[I, $] = accept;对非终结符 X,GOTO[I, X] = goto(I, X)。填表时如果发现某个格子已经填了内容又填新内容,就是冲突。冲突分移进-规约冲突和规约-规约冲突。SLR(1) 的规约动作依赖 FOLLOW 集合,FOLLOW 集合过大时容易引入规约-规约冲突,这是 SLR(1) 的先天弱点——解决办法是换 LALR(1) 或 LR(1),但课设一般通过改文法就能消掉冲突。

注意:项目集构造完先写一个函数全表扫描冲突,把冲突的(状态, 终结符)对全部打出来。这一步能省后面大量的调试时间,比对着说明书一行行查表可靠得多。

2.4 驱动分析器的循环:移进、规约、接受、报错

分析表到手后,驱动循环本身很简单,但栈结构要提前设计好。状态栈和符号栈同步压弹,每次根据栈顶状态和当前输入符号查 ACTION 表。下面这段是完整的驱动循环骨架,我在课设里就直接用这个结构:

def parse(tokens, action, goto, grammar, semantic_actions): # tokens 末尾要有 '$' 结束符;状态栈、符号栈、属性栈三栈同步 states = [0] symbols = ['#'] attrs = [None] ip = 0 while True: state = states[-1] token = tokens[ip] act = action[(state, token)] if act[0] == 'shift': # 移进:压入输入符号和新的状态,终结符属性就是其字符串值 states.append(act[1]) symbols.append(token) attrs.append(token) ip += 1 elif act[0] == 'reduce': # 规约:弹出产生式右部对应的栈元素,再压入左部非终结符 left, right = act[1], act[2] # right 是产生式右部符号列表 n = len(right) attr_list = attrs[-n:] if n > 0 else [] if n > 0: del attrs[-n:] del states[-n:] del symbols[-n:] # 空产生式 n=0 时不需要弹栈,但语义动作仍要执行 new_attr = semantic_actions[left](attr_list) g = goto[(states[-1], left)] symbols.append(left) attrs.append(new_attr) states.append(g) elif act[0] == 'accept': return attrs[-1]

states[-1]是栈顶状态,token是当前输入符号,用这两者查 ACTION 表。移进时把输入符号压入符号栈、新状态压入状态栈,attrs.append(token)让终结符的属性直接使用它的字符串值,比如 id 变量名或常量字面量。规约时n = len(right)算产生式右部符号数,attr_list用切片保留右部从左到右的属性顺序;如果n为 0(空产生式),不弹栈,语义动作照常执行,这是最容易漏掉的分支。semantic_actions[left](attr_list)返回左部非终结符的综合属性,最后查 GOTO 表压入新状态。注意accept动作通常落在$符号上,也就是 tokens 的最后一个元素。

3. 语法制导翻译:把语义动作精确挂到归约时机上

3.1 为什么自底向上只能用S属性定义

属性文法分 S 属性和 L 属性。S 属性的语义规则只使用产生式右部符号的属性,不引用左部祖先或兄弟的属性,适合自底向上在归约时求值;L 属性依赖左兄弟和祖先属性,适合自顶向下递归下降。SLR(1) 是自底向上的,归约时能拿到的只有产生式右部各符号的属性,所以只能做 S 属性。这也是为什么课设里几乎都选择在产生式末尾挂动作——你无法在归约过程中访问还没分析出来的祖先信息。

如果确实需要 L 属性逻辑,常见做法是引入标记非终结符(marker),把继承属性的信息通过产生式左部往下传。课设阶段我建议别用,它会让分析表状态数暴涨,而且很容易在 marker 的归约时机上出错。对赋值语句、表达式、if/while 这几类典型结构,S 属性足够了。

3.2 属性栈与符号栈的同步模型

语义动作需要访问右部符号的属性。属性栈和符号栈同步压弹:移进时终结符的属性(比如词法值)压入属性栈;归约时先弹出右部长度个属性,按右部从左到右的顺序整理好,再执行语义动作,最后把左部的综合属性压回属性栈。

def make_semantic_stack(parser): # 属性栈独立于状态栈,但操作时机与符号栈严格同步 parser.attr_stack = [] def get_rhs_attrs(n): # 弹出产生式右部n个符号的属性,返回按右部顺序排列的列表 if n == 0: return [] attrs = parser.attr_stack[-n:] del parser.attr_stack[-n:] return attrs def set_lhs_attr(attr): # 归约完成后压入左部综合属性 parser.attr_stack.append(attr) return get_rhs_attrs, set_lhs_attr

get_rhs_attrs返回的是右部从左到右的属性序列,因为切片[-n:]本身保留了栈底的顺序,不需要反转。这一点和很多教材里写的“弹出再反转”等价,但坑在于写法容易乱:如果你用pop()循环取 n 次,得到的顺序是右部从右到左,必须reverse();用切片则天然是正序。选定一种写法就坚持用下去。这个双栈模型在 C++ 和 Java 课设里结构是一样的,区别只是 Java 的 Stack 里要存对象数组,C++ 用 vector 加 union 或 variant 表示属性。

3.3 表达式与赋值语句的语义动作:四元式在归约中生成

以 2.1 节的表达式文法为例。归约发生时右部属性依次是左部符号序列的属性,其中运算符的属性通常没用。语义动作要做的是:取对应操作数的属性,生成一个临时变量,输出形如(+, E, T, t1)的四元式,临时变量名作为左部非终结符的综合属性压栈。

temp_counter = [0] quad_list = [] def newtemp(): temp_counter[0] += 1 return f't{temp_counter[0]}' def gen(op, arg1, arg2, result): quad_list.append((op, arg1, arg2, result)) def sem_F_id(rhs_attrs): # F -> id,返回变量名作为 F 的属性 return rhs_attrs[0] def sem_T_F(rhs_attrs): # T -> F,属性透传 return rhs_attrs[0] def sem_E_T(rhs_attrs): # E -> T,属性透传 return rhs_attrs[0] def sem_E_plus_T(rhs_attrs): # E -> E + T,rhs_attrs = [E1, '+', T] e1 = rhs_attrs[0] t = rhs_attrs[2] t1 = newtemp() gen('+', e1, t, t1) return t1 # E 的属性是存放结果的临时变量 def sem_T_mul_F(rhs_attrs): # T -> T * F,rhs_attrs = [T1, '*', F] t1 = rhs_attrs[0] f = rhs_attrs[2] t2 = newtemp() gen('*', t1, f, t2) return t2

这个设计里非终结符的属性统一表示“该非终结符对应表达式的计算结果存放位置”:对 id 属性就是变量名本身,对复合表达式属性就是临时变量名。后面生成赋值语句的四元式(:=, t1, , x)时,直接把 E 的属性作为 source 即可。newtemp用计数器生成 t1、t2 这样的名字,课设阶段不用回收临时变量,虽然会浪费一些中间变量,但对验证正确性没有影响。

3.4 if-else和while的回填:控制流四元式要晚一步填地址

表达式翻译是线性生成的,控制流却要回头填坑。以if E then S1 else S2为例,翻译 E 时会生成一条条件跳转四元式,但此时 S1 和 S2 的位置还没开始翻译,跳转目标未知。标准做法是回填(backpatching):先把跳转四元式的目标位置空着,记录它的四元式序号,等 S2 翻译完,再回头把真实序号填进去。

# 以 if E then S1 else S2 为例,这里给的是核心回填逻辑 def sem_if_then_else(rhs_attrs): # rhs_attrs = [if, E, then, S1, else, S2] # E 翻译时生成了 jnz E, L1 和 jmp L2,但 L1/L2 是占位符 jnz_index = rhs_attrs[1]['true_list'][0] # jnz 所在四元式序号 jmp_index = rhs_attrs[1]['false_list'][0] # jmp 所在四元式序号 next_instr = len(quad_list) # S1 翻译完后的下一条四元式序号 # 回填 true_list:把 jnz 的目标改成 S1 之后的位置 op, arg1, arg2, _ = quad_list[jnz_index] quad_list[jnz_index] = (op, arg1, arg2, next_instr) return {'next_list': []}

回填是课设里最容易翻车的地方:漏填一个 list、填了错误的四元式序号、或者回填用的 next_instr 取值时机不对,都会让生成的四元式跳转乱套。我的经验是先把整个控制流翻译的四元式骨架在本子上画出来,标好每个跳转指令的产生时机和回填时机,再动手写语义动作。pending_lists在正式实现里应该作为属性的一部分随综合属性向上传递,而不是用全局变量,否则嵌套 if-else 会串。

4. 中间代码生成:四元式的数据结构、符号表与完整输出

4.1 为什么四元式是课设的主流选择

常见中间表示有三种:逆波兰式、三元式、四元式。逆波兰式没有运算符优先级问题,但不适合做优化,跳转信息也不好表达;三元式的每个运算结果用三元式序号引用,一旦插入或删除一个三元式,所有引用序号都要改,调试很难受。四元式用(op, arg1, arg2, result)四个字段固定描述一条运算,修改某一条不影响其他条,跳转四元式用空字段占位再加回填,是课设和很多编译器前端的主流选择。

中间表示结构优点缺点
逆波兰式操作数栈表达生成最简单跳转/优化困难
三元式(op, arg1, arg2)省一个字段序号引用脆弱
四元式(op, arg1, arg2, result)结构清晰、可回填多一个字段占内存

表里的对比是我实际做完课设后的感受:四元式虽然多一个字段,但这第四个字段在回填跳转目标时极其好用,jmp 指令没有常规 arg2,把目标序号放在 result 字段里即可。内存开销在课设级文法上完全不是问题。

4.2 四元式的结构定义与gen函数

我把四元式定义成 Python 元组,方便调试时直接打印。op 字段是操作码,arg1 和 arg2 是操作数,result 是结果位置。操作数有三种:变量名(来自符号表)、常量(字面量)、临时变量名(t1、t2)。跳转类四元式jmp、jnz用 arg1 放条件,result 放跳转目标;赋值四元式:=用 arg1 放源值,result 放目标变量。

# 四元式:('+', 'a', 'b', 't1') # 赋值:(':=', 't1', '', 'x') # 条件跳转:('jnz', 't0', '', 'L1') # arg1 为真时跳转 # 无条件跳转:('jmp', '', '', 'L2') # result 为目标四元式序号 def print_quads(quads): for i, q in enumerate(quads): op, a1, a2, r = q if op == 'jmp': print(f'{i}: {op} {r}') elif op == 'jnz': print(f'{i}: {op} {a1}, {r}') else: print(f'{i}: ({op}, {a1}, {a2}, {r})')

打印格式是按课设报告常见的“序号+四元式”风格来的。调试时我强烈建议加这个打印函数,并且把每个四元式前的序号看成隐含标号,这样回填的目标一眼就能对得上。如果说明书要求输出三地址码格式,把括号去掉、用逗号分隔打印即可,本质一样。

4.3 符号表:从名字到地址的映射

符号表至少需要记录变量名、类型(如果有类型检查)、分配的临时地址或编号。课设里经常用偏移量表示变量存储位置,比如从 0 开始,每个变量占一个单位:

class SymbolTable: def __init__(self): self.table = {} self.offset = 0 def lookup(self, name): return self.table.get(name) def insert(self, name, type='int'): if name not in self.table: self.table[name] = {'name': name, 'type': type, 'offset': self.offset} self.offset += 1 return self.table[name]

offset每次加 1,代表变量在运行时存储区的相对位置。如果要处理作用域(比如块结构),最简做法是每进入一个{}压一个符号表栈,退出时弹出,查找时从栈顶往下查。但很多课设要求里根本没有作用域,全局单表就够了——不要在不需要的地方堆复杂度。符号表和四元式生成是两个独立模块,语义动作只往符号表插入变量、从符号表查地址,不直接操作表内部结构。

4.4 一个可运行的最小翻译流程:从源码到四元式的完整链路

课设包的源码拿到手,第一件事不是找 main 函数,而是先定位三个模块的分界线:词法分析器、分析表构造、语义动作表。词法分析负责把源码字符串变成 token 序列,语法分析只看 token 类型,语义动作用 token 的值。这样如果说明书要求扩展词法(比如加注释、加关键字),语法部分不用动。

def compile_source(source, scanner, parser): tokens = scanner.scan(source) quad_list.clear() parser.parse(tokens) print('符号表:') for name, entry in symbol_table.table.items(): print(f" {name}: {entry['type']}, offset={entry['offset']}") print('四元式序列:') print_quads(quad_list)

compile_source就是整个课设的入口函数。quad_list、symbol_table在单次编译中可以用模块级单例,因为语义动作函数分散在各处,共用一组表最省事。token 序列末尾一定要放$,这是驱动循环 accept 的触发条件,忘了它分析器会越界读 token。

5. SLR(1)实验避坑记录:冲突、栈顺序与回填时机

这一章写我在复现这类课设时实际踩过的坑,每个都按现象、原因、解决整理。分析表构造的算法本身在教材里写得很清楚,真正耗时间的全是这些边界细节。

5.1 分析表出现移进-规约冲突,集中在if-else

现象:填 ACTION 表时,某个(state, token)格子第一次填了 shift,第二次又填 reduce,程序报警。位置通常在包含 if 的产生式对应的状态里。

原因:文法有二义性,最常见是悬空 else。SLR(1) 用 FOLLOW 集合决定规约动作,而 else 在 FOLLOW 集合里,导致看到 else 时既想移进(匹配内层 if 的 else)又想规约(先完成外层 if 的动作)。

解决:改写文法把悬挂的 if 单独处理(S -> M | I的拆分方式),或者用优先级声明让 else 都归最近的 if。课设里推荐改文法,因为说明书一般要求 SLR(1) 分析表无冲突,光写“用优先级解决”在报告里不好交代。

5.2 归约时属性栈弹出顺序反了导致操作数颠倒

现象:生成的四元式把操作数对调,源程序x = a + b生成了+ b a t1,而不是+ a b t1。

原因:归约时从属性栈弹出的第一个属性是产生式右部最右边的符号,如果直接按弹出顺序当右部从左到右的属性用,就反了。

解决:两种写法选一种并坚持。方法一是attr_list = attrs[-n:]取切片保留原顺序;方法二是循环 pop 之后 reverse。建议所有语义动作统一从attr_list取属性,不要直接碰属性栈。

5.3 空串产生式导致状态栈和属性栈不同步

现象:遇到A -> @这种空产生式归约时,栈没弹出任何元素,但语义动作执行了,之后查 GOTO 表的状态索引对不上。

原因:空串产生式右部长度是 0,驱动循环里如果写死了“弹出 n 个元素再压入左部”,n=0 时没有弹任何状态,但左部还是要压栈、GOTO 还是要查,这个两步逻辑没分开处理。

解决:驱动循环里把“弹出右部符号”和“压入左部符号”写成两个独立步骤,弹出步骤根据 n 是否为 0 跳过,压入步骤无条件执行。空串的语义动作要在压入左部属性之前执行,返回值作为左部属性。

5.4 回填跳转目标时next_instr取错时机

现象:if 语句生成的 jmp 四元式目标指向了 if 自身,死循环;或者跳过了 else 块。

原因:用len(quad_list)取下一指令位置作为回填目标,但这个取值时机如果发生在 S1 翻译之前,取到的是 jnz 自己的位置;必须在 S1 翻译完之后再取。

解决:语义动作层面保证时序:先翻译 E(生成 jnz/jmp),再翻译 S1,S1 翻译完之后取len(quad_list)作为回填值,此时 S1 的四元式都已经入表,值才是正确的。建议把回填目标的取值统一封装成next_instr()函数,所有语义动作都走它,避免裸用len(quad_list)。

5.5 调试手段:把分析栈和四元式表每一步都打出来

现象:程序报错或者生成乱码四元式,但不知道从哪一步开始出错。

原因:分析器是个状态机,中间状态不可见,出错时只能看到最终错误,难以定位。

解决:在驱动循环里加一个调试开关,每处理一个 token 就打印当前状态栈、符号栈、剩余输入串和最近生成的四元式。

debug = True def log_step(states, symbols, ip, tokens, quads, last_n=3): if not debug: return print(f'状态栈: {states}') print(f'符号栈: {symbols}') print(f'剩余输入: {tokens[ip:]}') print(f'最近四元式: {quads[-last_n:]}')

这些日志拨开黑匣子:状态栈让你看到当前在哪个 LR 状态,符号栈告诉你已经分析了哪些文法符号,剩余输入告诉你驱动指针在哪,四元式切片告诉你语义动作到底产出过什么。出错时拿第一次行为异常的日志做对比,通常五分钟就能定位是分析表错、语义动作错还是回填错。

6. 把课设做成能演示的完整体:四元式解释器验证与扩展方向

光生成四元式没法直观证明它正确——你看到的只是一串(+, a, b, t1),但你敢保证a + b * c生成的顺序符合优先级?最有效的验证办法是给四元式写一个极简解释器,直接执行它,拿已知答案的程序跑一遍对结果。解释器只有几十行,但让说明书里多一张“测试用例与运行结果”的表,比干贴四元式有说服力。

def interpret(quads, sym_values): # sym_values: {'a': 1, 'b': 2, 'c': 3, ...} temps = {} pc = 0 while pc < len(quads): op, a1, a2, r = quads[pc] if op == '+': temps[r] = sym_values[a1] + sym_values[a2] elif op == '-': temps[r] = sym_values[a1] - sym_values[a2] elif op == '*': temps[r] = sym_values[a1] * sym_values[a2] elif op == '/': temps[r] = sym_values[a1] // sym_values[a2] elif op == ':=': sym_values[r] = temps[a1] elif op == 'jmp': pc = r - 1 elif op == 'jnz': if temps[a1] != 0: pc = r - 1 pc += 1 return sym_values

解释器按四元式序号顺序执行,临时变量存在 temps 字典里,用户变量在 sym_values 里。pc = r - 1是因为循环末尾会pc += 1,所以跳转目标为 r 时要把 pc 先置成 r-1。这个细节容易错,写完先用一个最简 if-else 程序验证跳转。

测试程序从低到高排:纯表达式求值覆盖优先级,赋值和表达式混合覆盖临时变量链,if-else 和 while 覆盖回填,嵌套控制流覆盖回填嵌套。每组都要预先算好正确答案,解释器跑完直接对比,把四元式序列和输出结果一起截图放进说明书。

还有余力的话建议做常量折叠:在 gen 函数里检查两个操作数是否都是常量,是就直接算出结果,不生成四元式。代码量最小,但四元式数量明显变少,报告里容易写对比。选这条路之前先把测试用例全部跑通,每加一个功能就全量回归一遍。我个人的习惯是把调试开关和解释器放同一个 main 里,改完语义动作立刻跑测试,不用等最后一起验证。这类课设最怕中途推翻重来——分析表、语义动作、回填互相牵连。希望这个从文法到四元式的落地过程能帮到你,少走我走过的弯路。

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

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

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

立即咨询