简介:这份资源是西北工业大学编译原理试点班的大作业完整交付物,面向计算机、人工智能、通信工程等专业需要完成课程设计或毕业设计的学生,也适合想深入理解编译器构造的进阶学习者。核心内容是一个能够正常工作的Sysy语法编译器,从词法分析、语法分析到中间代码生成与优化均有覆盖,并附有源代码、文档说明与实验报告,可直接用于课设答辩或作为项目立项演示的基础。压缩包共168个文件,约113KB,以111个sy测试用例、22个in输入样例为主,配合8个cpp与7个h源文件、3个c文件及少量Python脚本、LLVM IR文件、Makefile和Markdown说明,构成完整的编译与测试链路。目前已有249人学习下载。代码经过实际运行验证,答辩评审平均分达到96分,读者可据此掌握编译器各阶段的实现思路、测试用例组织方式与排错方法,也可在现有代码上修改扩展,实现更多语法特性或优化功能。
1. 从零手写 SysY 编译器:西工大编译原理试点班大作业到底在考什么
如果你正在选西北工业大学编译原理试点班的大作业,或者已经拿到题目但对着“SysY 语法编译器”这几个字发懵,那这篇就是写给你的。SysY 是一门精简的 C 语言子集,只有int、const、数组、函数、if/else、while、break/continue这些基本构件,但要求你从词法分析一路做到目标代码生成,中间不能调库、不能偷懒。试点班和普通班最大的区别在于:普通班可能只要求做到语法分析或中间代码,试点班要求你交付一个能跑通、能通过评测、能生成可执行汇编的完整编译器。这意味着你要亲手处理作用域、类型检查、短路求值、数组寻址、函数调用约定这些平时被高级语言封装掉的东西。适合谁做?适合已经学过 C/C++、了解基本数据结构、愿意花 40 到 80 小时啃下来的人。不适合想水学分的人,因为评测用例不会陪你演戏。
2. 编译器骨架怎么搭:从 SysY 源码到 RISC-V 汇编的四段流水线
2.1 为什么选 RISC-V 而不是 x86 或 MIPS
SysY 官方评测通常提供 RISC-V 或 ARM 的目标平台,西工大试点班多数年份用的是 RISC-V 32 位整数指令集。选它的理由很实际:指令格式规整,寄存器多,没有 x86 那些历史包袱,写后端时不用和段寄存器、复杂寻址模式搏斗。MIPS 虽然也规整,但延迟槽会让新手在分支跳转上翻车。RISC-V 的lw/sw、addi、beq/bne、jal/jalr足够覆盖 SysY 全部语义。你不需要实现浮点、不需要实现乘除以外的复杂运算,评测用例基本是整数和数组。
编译器整体分四段:词法分析、语法分析、语义分析与中间代码生成、目标代码生成。每段之间用数据结构衔接,不要想着一步到位。我一般会先把前端跑通,能输出语法树并打印出来,再往后端推。
2.2 词法分析:手写 DFA 比正则库更可控
SysY 的 token 类型不多:标识符、整型常量、关键字、运算符、分隔符。用 Flex 当然可以,但试点班通常要求手写,因为要理解最长匹配和回退。下面是一个最小可用的词法分析核心片段:
# 词法分析器核心:逐字符扫描,维护当前位置和行号 KEYWORDS = {"int", "const", "if", "else", "while", "break", "continue", "return", "void"} def tokenize(src): tokens = [] i, line = 0, 1 while i < len(src): c = src[i] if c in " \t\r": i += 1 continue if c == "\n": line += 1 i += 1 continue # 标识符或关键字:字母或下划线开头 if c.isalpha() or c == "_": j = i while j < len(src) and (src[j].isalnum() or src[j] == "_"): j += 1 word = src[i:j] tokens.append(("KW" if word in KEYWORDS else "ID", word, line)) i = j continue # 整型常量:支持十进制、八进制、十六进制 if c.isdigit(): j = i if src[i] == "0" and i + 1 < len(src) and src[i+1] in "xX": j = i + 2 while j < len(src) and src[j] in "0123456789abcdefABCDEF": j += 1 else: while j < len(src) and src[j].isdigit(): j += 1 tokens.append(("NUM", src[i:j], line)) i = j continue # 运算符和分隔符,双字符优先 for op in ("==", "!=", "<=", ">=", "&&", "||"): if src.startswith(op, i): tokens.append(("OP", op, line)) i += 2 break else: tokens.append(("OP", c, line)) i += 1 return tokens这段代码的关键点:最长匹配——遇到=要看下一个是不是=,遇到&要看下一个是不是&。行号必须记录,否则后面报错时你根本不知道哪一行出了问题。参数上,src是完整源文件字符串,返回的 token 列表里每个元素带类型、原文和行号。常见翻车点是注释处理:SysY 支持//和/* */,后者要跨行,忘了处理会导致整个 token 流错位。
2.3 语法分析:递归下降是唯一推荐路线
SysY 的文法用 LL(1) 就能覆盖,递归下降写起来最直观。每个非终结符对应一个函数,函数返回语法树节点。表达式要处理优先级,从低到高依次是:逻辑或、逻辑与、相等、关系、加减、乘除模、一元、基本表达式。下面给出表达式解析的骨架:
# 递归下降解析表达式,优先级从低到高逐层调用 def parse_expr(self): return self.parse_lor() def parse_lor(self): node = self.parse_land() while self.peek() == "||": self.next() rhs = self.parse_land() node = ("lor", node, rhs) return node def parse_land(self): node = self.parse_eq() while self.peek() == "&&": self.next() rhs = self.parse_eq() node = ("land", node, rhs) return node def parse_eq(self): node = self.parse_rel() while self.peek() in ("==", "!="): op = self.next() rhs = self.parse_rel() node = (op, node, rhs) return node def parse_rel(self): node = self.parse_add() while self.peek() in ("<", ">", "<=", ">="): op = self.next() rhs = self.parse_add() node = (op, node, rhs) return node def parse_add(self): node = self.parse_mul() while self.peek() in ("+", "-"): op = self.next() rhs = self.parse_mul() node = (op, node, rhs) return node def parse_mul(self): node = self.parse_unary() while self.peek() in ("*", "/", "%"): op = self.next() rhs = self.parse_unary() node = (op, node, rhs) return node逻辑与和逻辑或必须单独成层,因为后面要做短路求值:a && b在a为 0 时不能计算b。如果你把它们和加减混在一起,语义就错了。参数说明:self.peek()返回当前 token 类型,self.next()消费并前进。每个函数返回一个元组表示的树节点,后续遍历时按元组第一项分派。
2.4 语义分析:符号表和类型检查不能省
语法树建好后,必须走一遍语义分析。核心是符号表:每个作用域一个表,进入块时压栈,离开时弹栈。变量声明时插入,引用时从内到外查找。SysY 要求const变量在编译期求值,所以符号表里要存常量值。函数要检查参数个数和类型、返回值是否匹配。数组要检查下标是否为整型、维度是否匹配。这一步不做,后面生成代码时会出现“变量未定义却生成了访存指令”的玄学错误。
常见做法是:先建全局符号表,再遍历函数体,每个块维护一个局部表。遇到int a = 1;就插入(a, int, 1);遇到a = 2;就查找并检查是否 const。如果对 const 赋值,直接报错退出。
3. 中间代码与目标代码:从 AST 到 RISC-V 汇编的落地细节
3.1 中间代码选四元式还是栈式虚拟机
中间代码的作用是解耦前端和后端。常见选择有两种:四元式(三地址码)和栈式虚拟机指令。四元式更接近最终汇编,优化空间大;栈式虚拟机写起来快,但生成汇编时要再做一次映射。我一般推荐四元式,因为试点班评测往往看的是最终汇编能不能跑,四元式到 RISC-V 的映射更直接。
四元式格式:(op, arg1, arg2, result)。例如t1 = a + b写成("+", "a", "b", "t1")。控制流用标签和跳转:("label", None, None, "L1")、("jmp", None, None, "L2")、("jz", "t1", None, "L3")。数组访问要拆成地址计算:t1 = i * 4、t2 = base + t1、t3 = *t2。
3.2 短路求值的四元式生成
a && b的语义是:如果a为 0,整个表达式为 0,不计算b。生成四元式时:
# 生成 a && b 的四元式,使用短路跳转 def gen_land(a, b, result): # a 已经计算到临时变量 ta emit(("jz", ta, None, "L_false")) # 计算 b 到 tb emit(("jz", tb, None, "L_false")) emit(("mov", 1, None, result)) emit(("jmp", None, None, "L_end")) emit(("label", None, None, "L_false")) emit(("mov", 0, None, result)) emit(("label", None, None, "L_end"))逻辑或同理,只是jz换成jnz,真假标签互换。这里的关键是标签唯一性,每次生成都要新标签,不能复用,否则嵌套短路会跳错位置。
3.3 数组寻址:行优先与基址偏移
SysY 的数组是行优先存储。一维数组a[i]的地址是base + i * 4。二维数组a[i][j]的地址是base + (i * cols + j) * 4。生成四元式时,先算下标表达式,再乘元素大小,再加基址。注意int占 4 字节,RISC-V 的lw/sw也是 4 字节对齐。如果数组作为函数参数传递,实际传的是首地址,形参要按指针处理。
3.4 函数调用约定:寄存器传参和栈帧布局
RISC-V 调用约定:a0-a7传前 8 个整型参数,返回值放a0。超过 8 个参数用栈传递。调用者负责保存ra和临时寄存器,被调用者负责保存s0-s11和sp。SysY 函数不会超过 8 个参数,所以可以简化:参数全放a0-a7,返回值放a0。栈帧布局:先减sp,再存ra和旧s0,然后分配局部变量空间。函数返回前恢复sp和ra,ret跳回。
下面是一个函数序言和尾声的模板:
# 函数序言:分配栈帧,保存 ra 和 s0 addi sp, sp, -16 sw ra, 12(sp) sw s0, 8(sp) addi s0, sp, 16 # ... 函数体 ... # 函数尾声:恢复并返回 lw ra, 12(sp) lw s0, 8(sp) addi sp, sp, 16 ret参数说明:sp是栈指针,s0是帧指针。局部变量用s0负偏移访问,比如第一个局部变量在-4(s0)。如果函数内还有调用,ra必须保存,否则回不来。
4. 避坑与排查:SysY 编译器最容易翻车的五个地方
4.1 现象:评测报“段错误”,本地却正常
原因:本地测试用例太简单,没有覆盖数组越界或空指针。SysY 评测有严格的边界用例,比如int a[0];或者函数没有return却使用了返回值。解决:在语义分析阶段就检查数组大小是否为正、非 void 函数是否所有路径都有 return。生成汇编时,栈帧大小要按 16 字节对齐,RISC-V 的sp必须保持 16 字节对齐,否则lw/sw可能触发异常。
4.2 现象:短路求值结果反了
原因:a && b生成四元式时,jz跳到了真标签而不是假标签。或者a || b的jnz和jz用混了。解决:画一张真值表,逐条核对跳转方向。&&是“有一个为假就为假”,所以遇到假跳假;||是“有一个为真就为真”,所以遇到真跳真。
4.3 现象:函数递归调用后局部变量被覆盖
原因:没有保存ra或者没有正确分配栈帧。递归时每次调用都要有独立的栈帧,ra必须压栈。如果ra没保存,第一次返回后ra就丢了,第二次返回直接跳到未知地址。解决:只要函数体内有函数调用,序言里必须sw ra, offset(sp),尾声里lw ra, offset(sp)。
4.4 现象:全局变量初始化顺序错乱
原因:SysY 允许全局变量用常量表达式初始化,比如int a = 1 + 2;。如果你在生成汇编时直接输出.word 1+2,汇编器不认。解决:在语义分析阶段就把常量表达式求值,全局变量输出.word 3。如果全局变量引用了另一个全局变量,要按声明顺序求值,不能有循环依赖。
4.5 现象:break和continue跳错位置
原因:循环嵌套时,break应该跳到最内层循环的结束标签,continue跳到最内层循环的条件判断标签。如果你用一个全局栈存循环标签,但没在进入循环时压栈、退出时弹栈,嵌套循环就会跳错。解决:维护一个循环标签栈,break取栈顶的结束标签,continue取栈顶的继续标签。每进入一个while就压栈,退出就弹栈。
5. 进阶技巧:用差分测试和汇编模拟器把编译器逼到极限
5.1 差分测试:拿 GCC 当参照物
你自己写的编译器对不对,不能只靠肉眼。最有效的方法是差分测试:同一段 SysY 代码,分别用你的编译器和 GCC(把 SysY 当 C 的子集)编译运行,比较输出。如果结果不一致,大概率是你的编译器有 bug。具体做法:写一个脚本,遍历测试用例目录,对每个.sy文件,先调你的编译器生成 RISC-V 汇编,用模拟器跑出输出;再用 GCC 编译成可执行文件跑出输出;diff 两个输出。下面是一个简单的 bash 脚本框架:
#!/bin/bash # 差分测试:对比自研编译器和 GCC 的输出 for sy in tests/*.sy; do base=$(basename "$sy" .sy) # 自研编译器生成汇编并模拟运行 ./mycompiler "$sy" > "$base.my.s" spike pk "$base.my.s" > "$base.my.out" 2>&1 # GCC 编译运行 gcc -x c "$sy" -o "$base.gcc" ./"$base.gcc" > "$base.gcc.out" 2>&1 # 比较 if ! diff -q "$base.my.out" "$base.gcc.out" > /dev/null; then echo "DIFF: $base" fi done参数说明:spike是 RISC-V 模拟器,pk是代理内核。如果你的环境没有 spike,可以用qemu-riscv32。这个脚本能帮你快速定位语义错误,比一个个手算快得多。
5.2 用模拟器单步调试汇编
生成汇编后,如果结果不对,不要瞎猜。用模拟器单步执行,看寄存器和内存。spike -d可以进入调试模式,until pc 0x...设断点,reg a0看寄存器,mem 0x...看内存。常见错误是栈偏移算错,比如局部变量在-4(s0),但你写成了4(s0),结果读到了ra。单步走一遍,一目了然。
5.3 性能优化:常量折叠和死代码消除
SysY 评测通常不要求优化,但如果你想让编译器更“像样”,可以加两个简单优化。常量折叠:在语义分析阶段,如果二元运算两边都是常量,直接算出结果,后面就不用生成指令。死代码消除:如果if (0)后面的块永远不执行,直接删掉。这两个优化实现成本低,但能显著减少生成汇编的长度。注意:优化不能改变程序语义,尤其是短路求值和副作用,a && f()不能因为a是常量就跳过f()的调用。
5.4 我踩过的最大的坑:忘了处理void函数
SysY 有void函数,不能返回值,调用时也不能出现在表达式里。我第一版编译器忘了检查,结果void f(); int a = f();这种代码居然通过了语义分析,生成汇编时a0里是垃圾值。后来在语义分析里加了一条:如果函数返回类型是void,调用表达式只能作为语句,不能作为右值。这个检查必须在类型检查阶段做,不能拖到代码生成。
5.5 交付前必做:用评测用例全量回归
西工大试点班通常会给一批公开评测用例,但隐藏用例才是决定分数的关键。我的习惯是:自己构造边界用例,覆盖空语句、嵌套注释、数组越界、递归、全局变量初始化、短路求值副作用、break/continue嵌套。每改一次代码,全量跑一遍差分测试。不要相信“这次只改了一行不会影响别的”,编译器里一行改动可能让整个寄存器分配崩掉。血泪经验:交付前至少留两天做回归,不要等到截止前夜才发现递归用例全挂。
希望帮到你。
本文还有配套的精品资源,点击获取