news 2026/10/3 2:53:57

从零手写SysY编译器:西工大编译原理试点班大作业实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写SysY编译器:西工大编译原理试点班大作业实战指南

简介:这份资源是西北工业大学编译原理试点班的大作业完整交付物,面向计算机、人工智能、通信工程等专业需要完成课程设计或毕业设计的学生,也适合想深入理解编译器构造的进阶学习者。核心内容是一个能够正常工作的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嵌套。每改一次代码,全量跑一遍差分测试。不要相信“这次只改了一行不会影响别的”,编译器里一行改动可能让整个寄存器分配崩掉。血泪经验:交付前至少留两天做回归,不要等到截止前夜才发现递归用例全挂。

希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 2:53:48

华为云计算HCIE笔试v3.5变题解读与高效备考路线

华为云计算HCIE笔试升级v3.5的消息&#xff0c;这几天在备考群里确实把不少人炸出来了。各机构“变题通知”刷了一波又一波&#xff0c;但真正把“到底变什么、现在怎么备考、题库v3.0到底更新了啥”说清楚的内容并不多。我这段时间把新旧考纲、近期考生回忆、官方材料重新捋了…

作者头像 李华
网站建设 2026/10/3 2:53:27

滑雪场管理系统实战:SpringBoot2+Vue3前后端分离开发全解析

最近刚把手上的滑雪场管理系统从零到一完整落地&#xff0c;整套代码基于 SpringBoot2 Vue3 MyBatis-Plus MySQL8.0&#xff0c;源码和文档一起交付。很多人一看到"管理系统"四个字&#xff0c;脑子里自动浮现"增删改查"——实际做起来真不是那么回事。…

作者头像 李华
网站建设 2026/10/3 2:52:59

金融时序建模实战:从数据清洗到可解释预测

简介&#xff1a;这是一份面向本科毕业设计、课程设计与机器学习初学者的股票价格预测实战项目&#xff0c;基于Python实现LSTM与传统机器学习模型&#xff08;如SKLearn&#xff09;双路建模&#xff0c;解决金融时序数据预测中的特征工程、模型训练与结果可视化等核心问题。资…

作者头像 李华
网站建设 2026/10/3 2:52:58

RDMA无损网络核心:PFC原理、配置实战与排障经验

RDMA网络搞了几年&#xff0c;从最初在测试环境里折腾RoCEv2&#xff0c;到后来在机房顶着新增业务流量做无损网络调优&#xff0c;踩过的坑加起来能写一本“呼叫转移指南”。很多人一提到RDMA就觉得是网卡和驱动的事&#xff0c;装上驱动、设个IP就能跑&#xff0c;结果一到真…

作者头像 李华
网站建设 2026/10/3 2:51:47

制造业国产化替代:十大核心系统分工、难点与实施路径

1. 先别急着谈替代&#xff0c;制造业这十大系统到底是什么分工经常有制造业的朋友问我&#xff1a;"我们公司现在用着 SAP、西门子、达索&#xff0c;到底哪些需要国产化&#xff1f;是不是全换掉才算自主可控&#xff1f;" 问这个问题的人&#xff0c;往往对自家工…

作者头像 李华