简介:一套面向编译原理课程实践的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/col;skipWhitespace 会处理换行并把 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_ptr<Expr> Parser::parseExpr() { return parseAssign(); } std::unique_ptr<Expr> Parser::parseAdd() { auto lhs = parseMul(); // 先解析更高优先级的乘除法 while (match(TokenKind::Plus) || match(TokenKind::Minus)) { auto rhs = parseMul(); lhs = std::make_unique<BinaryExpr>( 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::vector<int> dims; // 数组各维长度,不是数组时为空 int slot; // 栈帧偏移,代码生成阶段填写 }; class SymbolTable { std::vector<std::unordered_map<std::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 / SUB | add / sub | 三操作数指令 |
| MUL | mul | 低 32 位结果 |
| DIV / REM | div / rem | 除数和余数指令分开 |
| LT / LE / GT / GE | slt / 多条组合 | 用 slt 结果 0/1 表示比较 |
| EQ / NE | sub + slli + srli | 先把差转成 0/1 |
| BEQ / BNE | beq / bne | 配合标签跳转 |
| LOAD / STORE | lw / 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_map<int, 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-a7,float 实参按 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 表达式优先级翻车:a+b*c被算成(a+b)*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 的位模式直接放进 a0,printf 读取时拿到的其实是浮点数的解释结果,自然打印出 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" 2>&1 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和朴素分配跑通,再往上加东西,这条路我替你验证过,走得稳。希望帮到你。
本文还有配套的精品资源,点击获取