资讯动态

C++编写SysY到RISC-V编译器:编译原理课程实践全解析

发布时间:2026/10/26 7:34:38 来源:尧图企业网站定制
简介一套面向编译原理课程实践的SysY到RISC-V编译器项目基于C实现完整编译链路主要面向计算机相关专业学生、期末大作业与课程设计场景既可作为高分项目模板也能帮助理解词法分析、语法分析、中间代码生成与目标代码转换的落地过程。资源共27个文件以C头文件hpp与实现文件cpp为核心另有词法/语法规则文件l/y、Koopa中间表示、RISC-V汇编及JSON配置等辅助材料压缩包整体仅108KB轻量且便于快速部署。已有136人浏览学习。源代码自带充分注释配合实践报告从SysY语法到RISC-V汇编的映射关系一目了然新手也能看懂并复用项目结构清晰模块划分合理下载后简单配置即可运行适合作为编译原理课程设计或期末大作业的完整参考方案。1. 为什么用C写一个SysY到RISC-V的编译器是编译原理课最具性价比的落地路径编译原理课程实践做到最后很多同学会发现一个分水岭SysY和RISC-V。SysY是一门C语言教学子集语法面窄但五脏俱全RISC-V指令少、手册透明基本没有黑匣子。用C把SysY翻译成RISC-V汇编恰好覆盖词法、语法、语义和代码生成四个环节比照着龙书敲例子更有真实感。这个方向适合两类人一是正在选编译原理实验题目、想在学期内跑通一个完整项目的在校生二是工作后想补编译器后端经验、想用最小成本看清从AST到寄存器分配全链路的一线开发。别急着堆功能和优化先把从SysY到RISC-V的最小闭环跑通。很多初次接触编译原理的读者容易误判工作量以为难点全在词法分析和语法分析上。真做过一遍就知道前端只要按部就班写后端才是一整晚一整晚翻车的地方——指令选择、栈帧布局、寄存器分配、ABI调用约定每一环都能让结果静默出错。所以这篇笔记我会按“语言边界 → 前端 → 后端 → 调试 → 验证”的顺序展开并且把寄存器分配和运行时骨架这种容易被忽略的部分单独拿出来讲因为源码交付和写实践报告时老师的提问基本都集中在这一块。2. 先定SysY语言边界再搭C工程IR选型是三段式编译器的分水岭动手写第一个文件之前我建议你先回答四个问题SysY的语法子集裁到哪、目标RISC-V环境是RV32还是RV64、中间表示用哪一层、实践报告打算怎么组织。这四个问题里最影响后续代码量的不是语言设计而是IR选型——它决定了前端和后端能不能解耦也决定了你调试汇编时是看人话还是看天书。2.1 SysY是什么一张表看清C语言子集的裁剪边界SysY在常见课程实践里是一个“裁剪过的C”保留基本数据类型和流程控制去掉指针、结构体、字符串字面量这些会把一次课程设计拖成三个月的东西。我按自己实现时的边界整理成一张表你在写实践报告时也可以直接复用这个表格。特性类别SysY 常见支持实际裁剪掉的部分类型int、float、void、常量没有 struct、union、typedef、enum变量全局/局部变量、一维/多维数组没有指针类型和指针运算表达式算术、比较、逻辑与/或/非、条件表达式没有位运算、自增自减、逗号表达式控制流if/else、while、for、break、continue、return没有 switch、goto函数定义、调用、递归参数按值传递没有函数指针、默认参数、可变参数下面是一个典型的SysY程序片段包含了数组、函数调用和控制流适合当第一个集成测试用例// tests/fib/main.sy const int N 10; int fib(int n) { if (n 2) return n; return fib(n - 1) fib(n - 2); } int main() { int sum 0; int i 1; while (i N) { sum sum fib(i); i i 1; } return sum % 100; }这段代码可以看出SysY的真实难度语法上没有C那些复杂特性但数组、函数调用、递归和循环一个不少。这意味着你的编译器必须正确生成栈帧、处理函数调用与返回、完成数组寻址前端反而是相对机械的工作。我一般会把“SysY规范没有指针”这一点写进实践报告的第一节作为裁剪边界的立论依据。确定语言边界之后接下来就是工程结构。模块怎么切直接决定你和这段代码相处一个月之后的心情。2.2 C工程骨架CMake、STL与模块划分决定了后期调试难度用C写编译器最舒服的一点是STL容器直接覆盖了符号表、IR指令列表和AST节点池的需求。std::unordered_map做作用域查表std::vector做指令序列std::unique_ptr管理AST节点生命周期基本不需要手写内存池。比C清爽比Python在跑批量测试时快一个数量级这是课程实践选C最实在的理由。我常用的工程布局如下每个目录对应编译过程的一个阶段sysycc/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp # 命令行入口读取 .sy 文件 │ ├── lexer.cpp/.hpp # 词法分析 │ ├── parser.cpp/.hpp # 语法分析产出 AST │ ├── sema.cpp/.hpp # 语义分析与符号表 │ ├── ir.cpp/.hpp # AST 到三地址码 IR │ ├── codegen.cpp/.hpp # IR 到 RISC-V 汇编 │ └── runtime.S # 最小运行时与入口 └── tests/ ├── fib/main.sy expected.out └── ...配套的 CMakeLists 可以这样写cmake_minimum_required(VERSION 3.16) project(sysycc) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(sysycc src/main.cpp src/lexer.cpp src/parser.cpp src/sema.cpp src/ir.cpp src/codegen.cpp ) target_compile_options(sysycc PRIVATE -Wall -Wextra -g)说明CMAKE_CXX_STANDARD 设为 17代码里可以用 std::string_view 和 if constexpr写遍历代码更顺手。-g 必须保留否则编译器自己崩了连 gdb 都追不动。-Wall -Wextra 平时看着烦但能拦下 switch 漏分支这类低级问题。本地我用 VS Code 配好 CMake 插件远程连到 Linux命令行和调试界面两不误Windows 下走 MSVC 也能编但测试汇编需要模拟器体验不如 Linux 顺畅。2.3 中间表示IR选型先做一层三地址码别急着直接吐RISC-V汇编有同学图省事AST 建完直接递归遍历吐汇编结果寄存器分配和指令选择全搅在一起改一个运算符映射就要动好几处。我的做法是先定义一层极简三地址码 IR每一条指令最多三个操作数操作数用虚拟寄存器编号或立即数表示。这样前端输出IR后端消费IR两侧各测各的。IR指令结构的长这样// ir.hpp enum class Op : uint8_t { ADD, SUB, MUL, DIV, REM, LT, LE, GT, GE, EQ, NE, JMP, BEQ, BNE, LOAD, STORE, CALL, RET, ALLOC, SET, GET, CONST }; struct IRInsn { Op op; int dst; // 目的虚拟寄存器或临时变量编号 int lhs; int rhs; int imm; // 立即数CONST 时使用 std::string label; // 跳转目标或函数名 };说明dst/lhs/rhs 都是虚拟寄存器编号不区分物理寄存器这样后端做寄存器分配时有单独的一层可以动手脚。label 字段在 JMP/BEQ/CALL 里复用分支跳转填块标签函数调用填函数名。字段越少IR 越容易在调试时用文本格式打印出来。我没有引入 SSA因为课程实践的关键路径是让生成的汇编能正确运行SSA 需要的 phi 指令和支配树会让工作量翻倍等你想做更多优化时再换 LLVM 才是正路。选择三地址码还有另一个实际收益实践报告里有内容写了。老师在中期检查问“你的中间表示是什么”你直接把 IR 结构和两条示例打印拍进去比空谈“我用了抽象语法树”有说服力得多。我通常会在报告里放一个 IR 文本示例展示 a b * c 变成了哪几条指令这是读者最容易落地的部分。有了IR层后端才可能独立演进。接下来前端只要做好一件事让SysY源码忠实地变成IRT指令序列。3. 前端实现把SysY源码变成IR的Scanner与递归下降Parser前端这一段很多教程会让你先写一个 token 类型表再写状态机最后上分析表。我的建议是全部手写词法器手写语法分析用递归下降语义分析配一张符号表。原因很朴素——SysY 的语法规模根本用不上 flex/bison手写代码可控性好报错信息能自己定制而且这些代码放进实践报告里也最容易展示工作量。3.1 词法分析器手写一个能报行列号的Scanner词法分析最容易犯的错是“只认 token不记位置”。没有行列号后续所有语法报错都是白搭所以我的 Scanner 从头到尾维护 line 和 col 两个状态量每个 token 都携带位置信息。// lexer.cpp 片段 Token Lexer::next() { skipWhitespace(); // 跳过空格、换行、tab int line this-line, col this-col; if (isalpha(ch) || ch _) { std::string id readWhile([](int c) { return isalnum(c) || c _; }); auto it keywords.find(id); // keywords 含 if/while/return/int/const 等 if (it ! keywords.end()) return Token{it-second, id, line, col}; return Token{TokenKind::Ident, id, line, col}; } if (isdigit(ch)) { std::string num readWhile(isdigit); if (ch .) { // 遇到小数点才把整型切为浮点 num.push_back(.); advance(); num readWhile(isdigit); return Token{TokenKind::Float, num, line, col}; } return Token{TokenKind::Int, num, line, col}; } if (startsTwoCharOp()) // 、!、、 这类两字符运算符 return makeTwoCharToken(line, col); static std::string ops -*/%(){}[],;!; if (ops.find(ch) ! std::string::npos) { std::string s(1, ch); advance(); return Token{TokenKind::Op, s, line, col}; } return error(unexpected char: std::string(1, ch)); }逻辑说明readWhile 是核心辅助函数它不断消费满足谓词的字符并推进 line/colskipWhitespace 会处理换行并把 col 重置为 1这样 token 的起始位置永远是准确的。startTwoCharOp 是向后多看一个字符的判断专门处理、!、这类双字符运算符。参数说明Token 的四个字段是 kind、text、line、col错误报告里直接用这三个信息拼“第X行第Y列出现意外字符”。3.2 语法分析器按优先级分层实现表达式解析SysY 的表达式优先级和 C 基本一致递归下降最经典的做法就是“一个优先级一层函数”。parseExpr 是入口往下依次是 parseOr、parseAnd、parseEq、parseRel、parseAdd、parseMul、parseUnary。每一层只处理自己那一个优先级同层运算符用 while 循环迭代避免掉进左递归的坑。// parser.cpp 片段 std::unique_ptrExpr Parser::parseExpr() { return parseAssign(); } std::unique_ptrExpr Parser::parseAdd() { auto lhs parseMul(); // 先解析更高优先级的乘除法 while (match(TokenKind::Plus) || match(TokenKind::Minus)) { auto rhs parseMul(); lhs std::make_uniqueBinaryExpr( lastTokenIsPlus ? BinaryOp::Add : BinaryOp::Sub, std::move(lhs), std::move(rhs)); } return lhs; }逻辑说明每个 parseXxx 函数都遵守“先解析下一优先级再循环处理本层运算符”的模式。while 循环的作用是把a b - c这样的左结合运算链展平而不是一层层嵌套递归这样不仅避免了函数调用栈过深生成的 AST 也更适合后续遍历。match 函数在 token 匹配时消费一个 token 并返回 true不匹配时不消费这是递归下降里最容易写错的地方——任何一个分支在不匹配时也必须至少返回一个节点或直接报错否则就走进了死循环。我在实际实现里给 parseAssign 留了位置因为 SysY 允许a b 1这样的赋值链。赋值语句的右值优先所以 parseAssign 会先 parseExpr 得到右值再向左匹配赋值目标。这一步和表达式解析分开写AST 的结构会更清晰。3.3 语义分析与符号表作用域栈怎么管重复声明和类型语法分析通过后接下来就是语义检查。SysY 虽然没有指针但作用域遮蔽、重复声明、未定义变量、返回值类型错误这些坑还在。符号表我直接用“作用域栈 哈希表”实现每进入一个块压一个 unordered_map每离开一个块弹出去。// symbol.hpp struct Symbol { TypeInfo type; // 是 int 还是 float是变量还是函数 bool isConst; std::vectorint dims; // 数组各维长度不是数组时为空 int slot; // 栈帧偏移代码生成阶段填写 }; class SymbolTable { std::vectorstd::unordered_mapstd::string, Symbol scopes; public: void pushScope() { scopes.emplace_back(); } void popScope() { scopes.pop_back(); } bool declare(const std::string name, const Symbol sym) { if (scopes.back().count(name)) return false; scopes.back().emplace(name, sym); return true; } Symbol* lookup(const std::string name) { for (auto it scopes.rbegin(); it ! scopes.rend(); it) if (it-count(name)) return (*it)[name]; return nullptr; } };逻辑说明declare 返回 false 时上层就可以报“变量重定义”同作用域里重复声明会在这里被拦截。lookup 从最内层向外层查天然支持{ int a; { int a; } }这种遮蔽。slot 字段在语义分析阶段只登记不真正分配——因为栈帧大小要等当前函数所有变量都扫完才能定下来。常见错误是这里直接给 slot 编号导致后续和数组元素偏移量打架我在报告里专门强调了这字段“延后填写”属于血泪经验。一个关键细节函数形参要作为局部变量声明在当前函数的最外层作用域里但类型信息里要标记 isParam否则栈帧布局时形参和普通局部变量用的是完全不同的定位方式后面寄存器分配的坑会在这里埋下。前端三条链路——词法、语法、语义——完整跑通后AST 就是干净的可以放心交给IR生成。4. 后端实现IR到RISC-V汇编的指令选择与寄存器分配后端是 SysY 编译器真正决定成败的地方。很多前端写得漂漂亮亮的项目一到生成 RISC-V 汇编就露怯全局变量放哪、函数栈帧怎么建、比较指令怎么转跳转、float 参数怎么传全是细节。我自己第一次做的时候在寄存器分配上熬到凌晨三点所以这一章我按“指令选择 → 寄存器分配 → 运行时骨架”三步推进每步都给你可以直接抄的代码骨架。4.1 指令选择SysY运算与RISC-V指令的映射关系指令选择本质上是一张映射表。我的 codegen 模块里只有一个大 switch输入 IR 指令的 op输出 RISC-V 指令。先看映射关系IR 运算RISC-V 指令说明ADD / SUBadd / sub三操作数指令MULmul低 32 位结果DIV / REMdiv / rem除数和余数指令分开LT / LE / GT / GEslt / 多条组合用 slt 结果 0/1 表示比较EQ / NEsub slli srli先把差转成 0/1BEQ / BNEbeq / bne配合标签跳转LOAD / STORElw / sw基址寄存器 立即数偏移代码生成 switch 的长这样// codegen.cpp 片段 void CodeGen::emitBinary(const IRInsn insn, std::ostream out) { std::string rd regName(insn.dst); std::string rs1 regName(insn.lhs); std::string rs2 regName(insn.rhs); switch (insn.op) { case Op::ADD: out add rd , rs1 , rs2 \n; break; case Op::SUB: out sub rd , rs1 , rs2 \n; break; case Op::MUL: out mul rd , rs1 , rs2 \n; break; case Op::DIV: out div rd , rs1 , rs2 \n; break; case Op::REM: out rem rd , rs1 , rs2 \n; break; case Op::LT: out slt rd , rs1 , rs2 \n; break; default: reportUnsupported(insn); // 你不可能支持所有Op越早暴露越好 } }逻辑说明regName 是我在寄存器分配层提供的函数输入虚拟寄存器编号返回物理寄存器名字。现在你可能看不出这段的价值等第 4.2 节把朴素栈式分配接进来你就会明白“IR 永远不知道物理寄存器编号”是多清爽的一件事。参数说明RISC-V 的 div 指令除数为 0 时不会像 x86 那样触发异常而是返回 -1这个差异我在实践报告里专门写了一段因为测试用例里很容易用一条x / 0暴露它。比较和分支的转换有一个通用技巧先把比较结果用 slt 算成 0/1再对 0/1 做一元运算得到相等、不等、大于等结果。例如相等判断可以先 sub 两个操作数再sltu rd, x0, rs得到“差值不为0”最后 xori 取反。生成的指令数多点但逻辑完全透明调试时跟着寄存器数值走就能定位。4.2 寄存器分配先跑通朴素栈式分配再谈线性扫描说到寄存器分配我建议你第一步不要做任何花哨算法。最稳妥的路线是朴素栈式分配每个虚拟寄存器固定占一个栈槽使用时临时加载到物理寄存器用完就存回去。这个方法生成的代码慢但正确性极其好保证适合先把编译器全链路跑通。// regalloc.cpp 片段 class NaiveRegAlloc { std::unordered_mapint, int slotOf; int nextOffset 0; int frameSize 0; public: int slot(int vreg) { auto it slotOf.find(vreg); if (it ! slotOf.end()) return it-second; int off nextOffset; nextOffset 4; // 每个虚拟寄存器占 4 字节栈槽 slotOf.emplace(vreg, off); return off; } int finish() { frameSize (nextOffset 15) ~15; // 16 字节对齐 return frameSize; } };逻辑说明slot 函数为每个虚拟寄存器延迟分配栈偏移分配后不会因为作用域变化而释放。这种“只增不减”的做法浪费空间但实现难度几乎为零。finish 在函数体扫描完之后计算最终栈帧大小并且做了 16 字节对齐——RISC-V 栈指针要求始终保持 16 字节对齐否则调用外部函数时崩得很隐蔽。参数说明每槽 4 字节源于 SysY 的 int 和 float 都是 32 位如果你的课程实践扩展到 double这个 4 要换成 8对齐逻辑也要跟着调。访问虚拟寄存器时codegen 会先生成 lw 从栈槽加载到临时物理寄存器运算完成后再 sw 存回去。每一步都对应真实的机器指令所以汇编代码长但每一行你都能解释清楚这是在做什么。课程设计的验收标准首先是“能正确运行”其次才是“代码高效”朴素栈式分配正好卡在这个目标上。等到你所有测试都过了还想在实践报告里多写一节优化再上线性扫描寄存器分配。线性扫描的原理是先给每个虚拟寄存器算活跃区间也就是它被定义到最后一次使用的范围然后按区间顺序分配物理寄存器区间结束时释放。真正的实现还需要处理区间冲突、溢出到栈、函数调用时保存临时寄存器那个复杂度不低。我通常会先在朴素栈式分配上把运行时调用约定跑对再去动线性扫描否则排查问题的时候你会分不清错误是寄存器分配的还是代码生成别的环节的。4.3 运行时骨架入口、数据段和printf的对接RISC-V 汇编生成后还要解决一个问题程序从哪里开始执行以及 printf、exit 这类函数从哪来。课程实践里最常见的是用 RARS 这类模拟器直接运行汇编文件所以我生成的目标汇编会自带一段入口代码并在需要输出时调用外部 printf。# runtime.S —— 最小运行时入口 .section .text .globl _start _start: call main mv a0, a0 li a7, 93 # 退出系统调用号 ecall逻辑说明这里以 main 的返回值作为进程退出码可以方便地在 shell 里用$?验证程序是否返回了正确结果。有的课程环境约定入口是 main 本身那就不需要 _start直接把 main 暴露成全局符号即可两种方案在实践报告开头要写清楚你用的是哪种因为模拟器行为差异很大。printf 的对接是后端最容易翻车的部分。SysY 没有内置输出语句常见做法是允许编译器把 printf 当作外部函数调用。调用前int 实参放进 a0-a7float 实参按 RISC-V ABI 放进 fa0-fa7。但这里有个大坑printf 是可变参数函数标准 psABI 要求可变参数部分的浮点参数按整数寄存器传递并按双精度浮点提升。也就是说你的编译器要么把 float 转成 double 再放寄存器要么按模拟器的具体约定来这必须写进报告里否则老师一问你三不知。数据段也很简单全局变量放进.section .data字符串常量放进.section .rodata。RISC-V 没有mov大立即数指令所以加载一个地址要用la伪指令模拟器会把它展开成auipc addi两条。这段不用写得多复杂但处理不好printf 的格式串地址就全是错的运行结果直接乱码。我在做运行时环境时通常会在 codegen 里维护一个“只输出一次”的字符串池同一个字符串字面量只分配一次避免每个 printf 调用点都生成一份拷贝。5. 避坑手册SysY编译器开发里5个高发问题现象到根因一次说清后端模块多了之后调试就变成了日常。我把这个项目里出现频率最高、也最容易让人盯着屏幕发呆的问题整理成了一份踩坑清单。每一条都是“现象 → 原因 → 解决”的结构你可以直接拿它当排查手册。5.1 链接器报错“未包含main类型”或 undefined reference to main现象自己生成的汇编文件放到模拟器里跑环境直接报找不到入口或者报“编译器未包含main类型”。搜代码发现 main 函数明明在 SysY 源码里写了。原因codegen 生成函数标签时只输出了函数名没有输出.globl main或者因为函数名被内部机制加了下划线之类的修饰导致入口找不到。解决在 codegen 输出函数定义的公共代码里统一检查函数名是否为 main是则额外输出一行.globl main。同时保证函数标签就是干净的main:不要加任何修饰。这个检查建议放在词法分析阶段就标记 isMain而不是在 codegen 里比对字符串因为后者容易漏掉嵌套块里的函数名处理。5.2 表达式优先级翻车ab*c被算成(ab)*c现象测试用例里return 2 3 * 4;预期结果是 14实际运行结果却是 20。原因递归下降解析表达式时没有严格分层。只写了 parseExpr 直接调 parseAdd 和 parseMul 但顺序不对或者 parseMul 没有比 parseAdd 更深一层。解决按优先级严格排调用链 parseAssign → parseOr → parseAnd → parseEq → parseRel → parseAdd → parseMul → parseUnary。并且写一个只包含优先级测试的最小用例放进回归集例如a b * c、a * b c、a b c d。每修好一层语法就跑一遍这条用例优先级错误很容易被这种小用例提前暴露。5.3 数组寻址错乱偏移算错导致运行时崩溃现象数组初始化、赋值正常但访问arr[i]时结果完全不对甚至模拟器直接报段错误或跳转到非法指令。原因数组变量本身占据的栈槽只存了“数组首地址”而代码生成阶段访问元素时没有把基址和下标偏移量正确相加或者偏移量按 int 元素个数算的时候忘了乘 4。解决在 IR 里把数组访问拆成两条指令先算addr base i * 4再LOAD/STORE。乘法用slli完成下标是变量时先加载到寄存器再左移两位。验证方法用一个固定小数组手动算偏移比对汇编代码里的 lw/sw 偏移量是否等于预期值。我后来在符号表里给数组变量额外存了 baseOffset 和 elemSize生成汇编时逻辑才真正清楚。5.4 编译器自己栈溢出深层嵌套表达式把C递归栈打穿现象解析一个括号嵌套 100 层的表达式时编译器进程直接崩溃终端提示 stack overflow 或 “编译器的堆空间不足”。原因递归下降解析天然使用 C 调用栈每层 parsePrimary 调用 parseExpr 都要消耗一大段栈空间。默认栈大小一般是 8MB深度嵌套很容易打穿。解决开发调试的时候在 Linux 下用ulimit -s 65520临时把栈调大能快速验证正确性更彻底的解法是把同层运算符的解析改成 while 循环而不是递归。我之前一直以为 100 层括号属于极端用例直到课程设计助教用生成器批量测试时把它跑炸了才长记性。所以最终方案是在 parsePrimary 里对括号表达式也改成显式栈驱动虽然看着繁琐但彻底根治了崩溃。5.5 float打印全是0可变参数函数的ABI坑现象printf(%f, f);输出 0.000000整数输出正常printf 里第一个参数是字符串第二个参数是 float 变量就是不对。原因RISC-V 的 psABI 规定可变参数函数printf 就是的浮点实参不会放到 fa0 寄存器而是按整数寄存器传递并且 float 要提升成 double。如果 codegen 把 float 的位模式直接放进 a0printf 读取时拿到的其实是浮点数的解释结果自然打印出 0。解决在 codegen 的调用约定模块里判断被调函数是否为可变参数函数。是的话float 实参先把位模式复制到整数寄存器并且按 double 的传递协议占据两个整数寄存器宽度或按模拟器具体规则。这条一定要写进实践报告因为它是“你确实理解了ABI”的强证据。我在这里花了一整晚最后是靠 gdb 查看寄存器值才定位到的后来把 float 传参规则做成了一个独立函数所有调用点统一走它。6. 验证与进阶批量回归测试通过后再动手做常量折叠编译器写完整套流程后最忌讳的是拿一个用例跑通就认为完工。我习惯从项目第一天开始维护一个 tests 目录每个用例是一个子目录里面有 main.sy 和 expected.out。期望输出不光是 stdout也包括退出码因为很多 SysY 程序是靠 main 的返回值表达结果的。#! /usr/bin/env bash # 回归测试脚本编译 运行 比对 for dir in tests/*/; do name$(basename $dir) src$dir/main.sy exp$dir/expected.out out$dir/actual.out ./build/sysycc $src -o $dir/prog.s || { echo COMPILE_FAIL: $name; continue; } rars $dir/prog.s $dir/stdin.txt $out 21 if diff -q $exp $out /dev/null; then echo PASS: $name else echo FAIL: $name fi done逻辑说明这个脚本按目录遍历所有测试用例每个用例包含输入文件 stdin.txt 和期望输出 expected.out。脚本里 rars 的调用参数要以你本机安装的模拟器为准重点是“编译失败 ! 运行失败”两条路径分开报告这样你才知道当前改动影响的是前端还是后端。参数说明stdin.txt 可以为空但保留这个文件能让你以后随手补充键盘输入类用例比如 SysY 读入数据做排序。回归跑通之后如果还有精力我会优先做两个优化性价比最高。第一个是常量折叠在 IR 生成阶段发现2 3这种两个操作数都是立即数的加法直接算成 5不再生成任何汇编指令。实现只需在 emitBinary 前加一个 tryFold 判断十分钟能做出来。第二个是上线性扫描寄存器分配替代朴素栈式分配这一步能让生成的汇编行数减少一半以上但前提是你的活跃区间分析已经通过大量测试验证否则先别动。最后一个建议和实践报告有关报告里不要只贴代码要把“决策在哪里发生”写清楚。比如为什么 IR 用三地址码而不用 SSA为什么寄存器分配先用朴素栈式方案这些决策比代码本身更能体现一个开发者对编译器全链路的理解。这次做下来我最深的教训是“不要在设计IR时反复横跳”——后端代码写到一半想换表示改完前端改后端连续三天都在赶工。如果你也在做类似的编译原理课程实践先把最简单的IR和朴素分配跑通再往上加东西这条路我替你验证过走得稳。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑