☰
从零手写SysY编译器:西工大编译原理试点班大作业全流程拆解
2026/10/3 2:53:48 网站建设 项目流程

简介:这份资源是西北工业大学编译原理试点班的大作业完整交付物,面向计算机、人工智能、通信工程等专业学生及需要课程设计参考的学习者,核心解决SysY语法编译器从理论到可运行实现的落地问题。压缩包共168个文件,约113KB,以111个sy测试用例、22个in输入数据为主,配合8个cpp与7个h源文件、3个c文件及2个py脚本,另有ll中间表示、md说明文档、makefile构建脚本等,覆盖词法分析、语法分析、AST构建、IR生成与SSA构建等编译全流程模块。已有249人学习下载。资源包含可正常运行的编译器源码、文档说明与实验报告,代码经测试通过,答辩评审平均分达96分,适合作为课设、毕设或项目初期立项的参考模板,也可在现有代码基础上修改扩展功能,帮助读者理解编译器各阶段的实现思路与调试方法。

1. 从零手写 SysY 编译器:西工大编译原理试点班大作业到底在考什么

如果你正在选西北工业大学的编译原理试点班,或者已经拿到了那份“完成一个能够正常工作的 SysY 语法编译器”的大作业题目,大概率第一反应是:词法、语法、语义、中间代码、目标代码,这一整套下来得写多少东西?SysY 是 C 语言的一个精简子集,去掉了指针、结构体、浮点等复杂特性,只保留 int 类型、常量、变量、函数、if/else、while、break/continue 和基本运算。它的语法规则不多,但“能跑通”和“能通过所有测试用例”之间隔着一条很深的沟。这份大作业真正考的不是你会不会写递归下降,而是你能不能把一个完整的编译流水线串起来,让一段 SysY 源码经过你的编译器后,在 RISC-V 或 ARM 模拟器上跑出正确结果。适合谁做?适合已经学过编译原理理论课、想拿一个真实项目把“龙书”里那些抽象概念砸实的人。接下来我会按实际动手顺序,把词法分析、语法分析、语义检查、中间代码生成、目标代码生成和测试验证这条链路拆开讲清楚,每一步都给可复现的命令和参数。

2. 词法分析与语法分析:用 Flex/Bison 还是手写递归下降

2.1 先定工具链:为什么我推荐手写而不是 Flex/Bison

SysY 的语法规则在官方文档里给得很明确,用 Flex 写词法、Bison 写语法是最省事的路径。但试点班大作业通常要求你“理解每一行代码为什么这么写”,而且 Bison 的移进/归约冲突在 SysY 的表达式优先级处理上会频繁出现,调起来非常痛苦。我一般会建议手写递归下降,原因是:SysY 的语法层级清晰,表达式优先级用几个函数层层调用就能表达,不需要引入额外的状态机。更重要的是,手写之后你在语义分析和中间代码生成阶段可以自由地在 AST 节点里挂载符号表指针和类型信息,不用跟 Bison 的语义动作较劲。

先看词法分析的核心结构。SysY 的 token 类型包括关键字(int、void、const、if、else、while、break、continue、return)、标识符、整数字面量(十进制、八进制、十六进制)、运算符和分隔符。下面是一个最小可用的词法分析器骨架:

# lexer.py import re TOKEN_SPEC = [ ('COMMENT', r'//[^\n]*|/\*[\s\S]*?\*/'), ('KEYWORD', r'\b(int|void|const|if|else|while|break|continue|return)\b'), ('IDENT', r'[A-Za-z_][A-Za-z0-9_]*'), ('HEX', r'0[xX][0-9a-fA-F]+'), ('OCT', r'0[0-7]*'), ('DEC', r'[1-9][0-9]*|0'), ('OP', r'==|!=|<=|>=|&&|\|\||[-+*/%<>=!;,()\[\]{}]'), ('WS', r'\s+'), ] def tokenize(src): tokens = [] pos = 0 while pos < len(src): for name, pattern in TOKEN_SPEC: m = re.match(pattern, src[pos:]) if m: text = m.group(0) if name not in ('WS', 'COMMENT'): tokens.append((name, text, pos)) pos += len(text) break else: raise SyntaxError(f"Unexpected char at {pos}: {src[pos]}") return tokens

这段代码的逻辑是按优先级顺序尝试匹配,注释和空白直接跳过。参数上要注意:十六进制和八进制必须放在十进制之前匹配,否则0x10会被拆成0和x10。标识符的正则要放在关键字之后,不然int会被当成普通标识符。实际跑的时候,输入一段 SysY 源码,输出 token 序列,先确认没有漏字符、没有把数字切错。

2.2 递归下降解析器:表达式优先级怎么落到代码里

语法分析阶段要把 token 流变成 AST。SysY 的表达式优先级从低到高是:逻辑或、逻辑与、相等性、关系、加减、乘除模、一元、基本表达式。递归下降的写法就是每个优先级写一个函数,低优先级函数调用高优先级函数。下面给出加减和乘除两层的实现:

# parser.py class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else ('EOF', '', -1) def eat(self, kind=None, text=None): tok = self.peek() if kind and tok[0] != kind: raise SyntaxError(f"Expected {kind}, got {tok}") if text and tok[1] != text: raise SyntaxError(f"Expected {text}, got {tok}") self.pos += 1 return tok def parse_mul(self): node = self.parse_unary() while self.peek()[1] in ('*', '/', '%'): op = self.eat()[1] right = self.parse_unary() node = ('binop', op, node, right) return node def parse_add(self): node = self.parse_mul() while self.peek()[1] in ('+', '-'): op = self.eat()[1] right = self.parse_mul() node = ('binop', op, node, right) return node

逻辑说明:parse_add先调parse_mul拿到一个乘除表达式,然后只要遇到加减号就继续循环,保证左结合。参数上,eat函数负责消费 token 并做类型检查,如果不符合预期就抛异常。这里没有处理一元负号,实际写的时候parse_unary里要判断-和!。跑通的标准是:给一段带括号和混合运算的表达式,能正确还原出 AST 的嵌套结构。

3. 语义分析与符号表:变量作用域和类型检查怎么做才不漏

3.1 符号表的数据结构选型:栈式作用域链

SysY 支持块级作用域,{}里面定义的变量在外面不可见,内层可以遮蔽外层同名变量。符号表最自然的实现是栈式作用域链:进入一个块就压入一个新作用域,退出就弹出。每个作用域是一个字典,查找时从栈顶往下找。下面是一个可直接用的实现:

# symtab.py class SymbolTable: def __init__(self): self.scopes = [{}] # 全局作用域 def enter_scope(self): self.scopes.append({}) def exit_scope(self): self.scopes.pop() def declare(self, name, info): if name in self.scopes[-1]: raise SemanticError(f"Redefinition of {name}") self.scopes[-1][name] = info def lookup(self, name): for scope in reversed(self.scopes): if name in scope: return scope[name] return None

逻辑说明:declare只检查当前作用域是否重名,允许内层遮蔽外层。lookup从最内层往外找,找到就返回。参数上,info里至少存类型(int/void)、是否是常量、常量值(如果是 const)、是否是函数。实际跑的时候,遇到变量引用就调lookup,返回 None 就报“未声明标识符”。

3.2 语义检查的四个必做项:未声明、重定义、类型匹配、返回值

语义分析阶段要遍历 AST,做四类检查。第一,变量引用前必须已声明,函数调用前必须已定义或声明。第二,同一作用域不能重复定义同名变量。第三,赋值号左边必须是左值,且类型要匹配;SysY 只有 int 和 void,所以void函数不能用在表达式里。第四,非 void 函数的所有路径都必须有 return,void 函数不能有返回值。下面是一个遍历函数声明的检查片段:

def check_func(self, node): # node: ('func', ret_type, name, params, body) ret_type, name, params, body = node[1], node[2], node[3], node[4] self.symtab.declare(name, {'type': ret_type, 'params': params}) self.symtab.enter_scope() for ptype, pname in params: self.symtab.declare(pname, {'type': ptype}) self.check_block(body) self.symtab.exit_scope() if ret_type != 'void' and not self.has_return(body): raise SemanticError(f"Function {name} may not return a value")

逻辑说明:先声明函数名,再进入新作用域声明参数,然后检查函数体。has_return需要递归遍历所有分支,确认每条路径都有 return。参数上,params是(类型, 名字)的列表。跑通的标准是:故意写一个缺 return 的函数,编译器要报错;写一个重定义变量,也要报错。

4. 中间代码生成:从 AST 到四元式,怎么保证顺序和临时变量不冲突

4.1 四元式设计:为什么不用三地址码的字符串拼接

中间代码我一般用四元式(op, arg1, arg2, result),比直接拼三地址码字符串好调试。SysY 的中间代码需要支持算术运算、逻辑运算、比较、赋值、跳转、标签、函数调用、返回。下面是一个生成四元式的核心函数:

# irgen.py class IRGen: def __init__(self): self.quads = [] self.temp_count = 0 self.label_count = 0 def new_temp(self): self.temp_count += 1 return f"t{self.temp_count}" def new_label(self): self.label_count += 1 return f"L{self.label_count}" def emit(self, op, arg1=None, arg2=None, result=None): self.quads.append((op, arg1, arg2, result)) def gen_expr(self, node): if node[0] == 'num': return str(node[1]) if node[0] == 'binop': left = self.gen_expr(node[2]) right = self.gen_expr(node[3]) temp = self.new_temp() self.emit(node[1], left, right, temp) return temp if node[0] == 'var': return node[1]

逻辑说明:gen_expr递归处理表达式,遇到二元运算就先算左右子表达式,再生成一条四元式,返回临时变量名。参数上,op是运算符,arg1和arg2是操作数,result是存放结果的临时变量或变量名。跑通的标准是:给一个a = b + c * d,生成的四元式顺序应该是先算c * d到t1,再算b + t1到t2,最后a = t2。

4.2 控制流生成:if/else 和 while 的标签回填

控制流是中间代码生成里最容易翻车的地方。if/else 需要生成条件跳转和标签,while 需要回边跳转。下面是一个 if/else 的生成逻辑:

def gen_if(self, node): # node: ('if', cond, then_body, else_body) cond = self.gen_cond(node[1]) label_else = self.new_label() label_end = self.new_label() self.emit('if_false', cond, None, label_else) self.gen_block(node[2]) self.emit('goto', None, None, label_end) self.emit('label', None, None, label_else) if node[3]: self.gen_block(node[3]) self.emit('label', None, None, label_end)

逻辑说明:先算条件,生成if_false跳转到 else 标签,然后生成 then 块,再无条件跳转到 end 标签,接着放 else 标签和 else 块,最后放 end 标签。参数上,gen_cond负责把比较表达式转成条件跳转或布尔临时变量。跑通的标准是:写一个带嵌套 if/else 的 SysY 程序,生成的四元式标签不重复、跳转目标都存在。

5. 目标代码生成与测试:RISC-V 汇编怎么调,测试用例怎么跑

5.1 从四元式到 RISC-V:寄存器分配和栈帧布局

目标代码生成阶段要把四元式翻译成 RISC-V 汇编。SysY 编译器通常要求生成 RV32IM 汇编,然后在模拟器上跑。寄存器分配最简单的做法是:每个临时变量都分配一个栈槽,运算时加载到t0、t1,算完存回栈。函数调用要遵循 RISC-V 调用约定,参数放a0-a7,返回值放a0。下面是一个四元式到汇编的翻译片段:

def gen_riscv(quads): asm = [] stack_offset = {} current_offset = 0 def get_offset(name): nonlocal current_offset if name not in stack_offset: current_offset += 4 stack_offset[name] = -current_offset return stack_offset[name] for op, arg1, arg2, result in quads: if op == '+': off1 = get_offset(arg1) off2 = get_offset(arg2) offr = get_offset(result) asm.append(f"lw t0, {off1}(sp)") asm.append(f"lw t1, {off2}(sp)") asm.append("add t2, t0, t1") asm.append(f"sw t2, {offr}(sp)") elif op == 'label': asm.append(f"{result}:") elif op == 'goto': asm.append(f"j {result}") elif op == 'if_false': off = get_offset(arg1) asm.append(f"lw t0, {off}(sp)") asm.append(f"beqz t0, {result}") return asm

逻辑说明:每个变量和临时变量都分配一个栈偏移,用sp做基址。加法翻译成两条lw、一条add、一条sw。参数上,stack_offset字典记录每个名字的偏移,current_offset每次减 4。跑通的标准是:生成的汇编能在 RISC-V 模拟器(如 QEMU 或 Venus)上汇编通过,并且跑出正确结果。

5.2 测试用例怎么组织:从官方样例到边界用例

SysY 大作业通常会提供一批测试用例,包括功能测试和性能测试。功能测试覆盖表达式、分支、循环、函数调用、全局变量、常量。性能测试会跑矩阵乘法、斐波那契等。我一般会先跑官方样例,确认基本功能,然后自己补边界用例:空函数、只有 return 的函数、多层嵌套作用域、短路求值、负数除法、取模负数。下面是一个测试脚本的骨架:

#!/bin/bash # run_tests.sh for sysy_file in tests/*.sy; do base=$(basename "$sysy_file" .sy) ./compiler "$sysy_file" > "out/$base.s" if [ $? -ne 0 ]; then echo "COMPILE FAIL: $base" continue fi riscv64-linux-gnu-gcc -static "out/$base.s" -o "out/$base" qemu-riscv32 "out/$base" echo "$base exit code: $?" done

逻辑说明:遍历tests/下的.sy文件,编译成汇编,再用交叉编译器汇编链接,最后用 QEMU 跑。参数上,riscv64-linux-gnu-gcc需要提前装好,qemu-riscv32用于执行。跑通的标准是:所有测试用例的 exit code 和预期一致,性能测试的运行时间在可接受范围内。

6. 避坑与排查:SysY 编译器最容易翻车的五个地方

6.1 短路求值没做,逻辑表达式结果不对

现象:if (a && b)里,即使a为假,b的副作用还是被执行了。原因:中间代码生成时把&&当成普通二元运算,两边都算了。解决:在gen_cond里对&&和||做短路处理,a && b翻译成if_false a goto L; if_false b goto L; ...,||类似。

6.2 全局变量初始化顺序错,常量折叠出问题

现象:全局const int N = 10; int a[N];报数组大小不是常量。原因:符号表里全局常量的值没有在声明时立即求值,或者求值顺序不对。解决:在语义分析阶段遇到全局 const 声明时,立刻计算常量表达式的值并存入符号表,后续数组维度直接用这个值。

6.3 函数调用参数超过 8 个,栈上传参漏了

现象:调用有 10 个参数的函数,第 9、10 个参数值不对。原因:RISC-V 调用约定里,超过 8 个的参数要放在栈上,生成代码时只处理了a0-a7。解决:在函数调用生成时,前 8 个参数放寄存器,后面的参数依次压栈,被调用函数在栈帧里按偏移取。

6.4 临时变量命名冲突,嵌套表达式结果被覆盖

现象:a = b + c + d算出来不对。原因:new_temp生成的临时变量名在递归过程中被复用,或者栈槽分配时同一个名字被分配了不同偏移。解决:确保new_temp全局唯一递增,栈槽分配用字典记录,同一个名字只分配一次。

6.5 性能测试超时,没做基本块优化

现象:矩阵乘法跑了几十秒还没出结果。原因:每个临时变量都走栈,没有做寄存器分配和常量传播。解决:至少做局部常量折叠和死代码消除,把频繁使用的变量尽量留在寄存器里。如果时间紧,先把-O0跑通,再逐步加优化。

7. 进阶技巧:用差分测试和形式化验证思路把编译器逼到墙角

差分测试是验证编译器正确性最有效的手段之一。思路很简单:同一段 SysY 程序,分别用你的编译器和 GCC(把 SysY 当 C 的子集编译)生成可执行文件,跑同样的输入,比较输出。如果输出不一致,说明你的编译器在某处翻译错了。下面是一个差分测试的脚本片段:

#!/bin/bash # diff_test.sh for f in tests/*.sy; do base=$(basename "$f" .sy) # 你的编译器 ./compiler "$f" > "out/$base.my.s" riscv64-linux-gnu-gcc -static "out/$base.my.s" -o "out/$base.my" qemu-riscv32 "out/$base.my" > "out/$base.my.out" # GCC 参考 cp "$f" "out/$base.c" gcc "out/$base.c" -o "out/$base.gcc" ./out/$base.gcc > "out/$base.gcc.out" # 比较 if ! diff -q "out/$base.my.out" "out/$base.gcc.out" > /dev/null; then echo "DIFF FAIL: $base" fi done

逻辑说明:把 SysY 源码分别喂给你的编译器和 GCC,跑出结果后逐字节比较。参数上,qemu-riscv32跑你的 RISC-V 二进制,gcc直接编译 C 版本。注意 SysY 的putint、getint等内置函数在 GCC 版本里需要提供对应的 C 实现,否则链接会失败。

差分测试能抓出很多手工测试漏掉的问题,尤其是表达式求值顺序、整数溢出行为、负数除法和取模。我自己的习惯是:每加一个新特性,先跑一遍差分测试,确认没有回归,再跑官方测试用例。另外,如果时间允许,可以给关键模块写不变量断言,比如“每个临时变量在使用前一定被定义过”“每个标签一定被生成过”,这些断言在调试阶段能省下大量翻车时间。

最后说一个血泪教训:不要等到所有模块写完再联调。词法分析写完就单独测 token 流,语法分析写完就打印 AST 看结构,语义分析写完就故意写错误程序看报错,中间代码写完就手动模拟执行几条四元式。每层都验证过,最后串起来的时候问题会少很多。希望帮到你。

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

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

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

立即咨询