☰
用C++写SysY到RISC-V编译器:编译原理全流程实践
2026/10/10 1:10:38 网站建设 项目流程

简介:面向编译原理课程实践,以C++实现SysY语言到RISC-V指令集的完整编译器,包含可运行源码与配套实践报告,适用于课程设计、期末大作业或自学深入。项目代码注释丰富,核心模块涵盖词法/语法/语义分析、中间表示及RISC-V代码生成,配合y/l文法定义、Koopa中间表示和测试样例,新手也能较快理解关键流程。资源共33个文件,主体为hpp与cpp源文件,另有文法、JSON配置、编译产物(s/koopa/o)及Markdown说明文档,压缩包约149KB,目录组织清晰。实践报告详述了各阶段设计决策、部署与测试方法,可直接据此搭建编译环境并复现完整构建。已有52人浏览学习,尤其适合希望借完整项目吃透编译原理、提升工程实践能力的学生。

1. 从 SysY 到 RISC-V:用 C++ 写编译器是通往真实编译器工程最短的路

SysY 是 C 语言的一个子集,RISC-V 是开源指令集架构,用 C++ 把前者翻译成后者,几乎是编译原理实验里含金量最高的一条路线。很多课程设计只要求做到词法分析,或者把 AST 翻译成伪汇编,但 SysY 到 RISC-V 逼你走完词法、语法、语义、中间表示、指令选择和栈帧布局的全流程。正在选课程设计题目、准备考研复试项目、或者想补编译器底层功底的从业者,这个方向能一次性把 C++ 的数据结构、内存管理和系统级编程手感练熟。下文按我实际做过的方案,把架构、可复现代码、测试和踩坑一条条铺开讲。

2. SysY 语言子集与三段式架构:词法、语法、语义检查怎么分工

编译器开发的第一步永远是明确输入文法。SysY 不是一门新语言,它是课程设计者从 C 里摘出来的一块:保留 int、float、const、数组、函数、if/else、while、for、break/continue/return,去掉宏、指针、结构体、位运算这些在 C 里高频但在教学上反而干扰主线的东西。正因为语言小,你才有机会把每个环节亲手写一遍。很多人在写词法分析时把「编译器和编辑器的区别」抛在脑后,觉得能高亮就是能编译,实际一跑评测就露馅——编辑器的任务是把源码变成带颜色的文本,编译器的任务是把源码变成有确定语义的机器指令,SysY 实验逼你把后者彻底做一遍。

2.1 SysY 语法子集:动手前先把 AST 节点清单列出来

动手写代码前,我会先把 SysY 的语法映射成一张 AST 节点清单。这一步比想象中重要:C++ 的类层次设计全靠它决定,后续语义分析和代码生成都会在这棵树上来回跑。常见的 SysY 定义里,全局声明只有变量和函数两种,语句只有赋值、if、while、for、break、continue、return 和语句块,表达式则覆盖算术、比较、逻辑运算和函数调用,数组支持一维和多维。SysY 没有指针,没有结构体,也没有逗号表达式,所以左值表达式只可能是变量名或下标表达式,这对后面做赋值节点的代码生成非常友好。

AST 节点对应语法说明
ProgramNode整个源文件保存全局声明列表
GlobalVarNodeint a; const int N = 5;const 标记决定初始化表达式是否必须常量
FuncNodeint f(int x) { ... }形参列表 + 函数体,入口要单独处理
BlockNode{ stmts }引入新作用域,语义检查时 push/pop 符号表
AssignNodea = expr;左值必须是变量或数组元素
IfNode / WhileNode / ForNode分支与循环要预留 break 和 continue 的跳转目标
ReturnNodereturn expr;函数返回类型检查的锚点
BinaryExprNode+ - * / % < == &&op 用枚举,避免字符串分发
CallNodef(1, 2)参数个数与类型在语义阶段检查
UnaryExprNode-x / !xSysY 里一元运算常见形态

把这张表定下来后,Parser 写起来是顺水推舟,因为每个节点类的构造函数参数就是子节点,和语法产生式一一对应。我见过不少同学直接在一个 struct 里塞十几种 tag 硬造一颗「万能节点」,后续每次访问都要 switch,代码生成一节全是 if-else,维护成本直接翻倍。用 C++ 写编译器的好处就是虚函数和 unique_ptr 能把这个结构表达得很干净,别浪费了这套语言特性。

2.2 词法分析:手写扫描器比引入 Flex 更容易掌控

词法分析器我建议手写。SysY 关键字不到十个,运算符就那么几类,手写不会超过两百行,生成的代码还能放进实践报告里讲清楚每个分支的用意。用 Flex 当然更快,但生成的 C 代码可读性差,报告里也不好解释。以下是最小可用的扫描循环,只保留核心分支,完整版无非是多补几个两字符运算符的预读判断:

#include <cctype> #include <string> #include <vector> #include <unordered_map> enum class TokenType { IDENT, INT_LIT, FLOAT_LIT, KW_INT, KW_FLOAT, KW_IF, KW_ELSE, KW_WHILE, KW_FOR, KW_RETURN, KW_CONST, KW_BREAK, KW_CONTINUE, OP_ASSIGN, OP_PLUS, OP_MINUS, OP_MUL, OP_DIV, OP_LT, OP_GT, OP_LE, OP_GE, OP_EQ, OP_NE, OP_AND, OP_OR, OP_NOT, OP_SEMI, OP_COMMA, LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, END }; struct Token { TokenType type; std::string text; int line; // 行号字段,错误定位全靠它 }; std::vector<Token> lex(const std::string& src) { static const std::unordered_map<std::string, TokenType> keywords = { {"int", TokenType::KW_INT}, {"float", TokenType::KW_FLOAT}, {"if", TokenType::KW_IF}, {"else", TokenType::KW_ELSE}, {"while", TokenType::KW_WHILE}, {"for", TokenType::KW_FOR}, {"return", TokenType::KW_RETURN}, {"const", TokenType::KW_CONST}, {"break", TokenType::KW_BREAK}, {"continue", TokenType::KW_CONTINUE} }; std::vector<Token> tokens; size_t i = 0; int line = 1; while (i < src.size()) { char c = src[i]; if (c == ' ' || c == '\t') { ++i; continue; } if (c == '\n') { ++line; ++i; continue; } if (std::isalpha(c) || c == '_') { // 标识符与关键字走同一条路径,最后查表区分 size_t start = i; while (i < src.size() && (std::isalnum(src[i]) || src[i] == '_')) ++i; std::string word = src.substr(start, i - start); auto it = keywords.find(word); tokens.push_back({it != keywords.end() ? it->second : TokenType::IDENT, word, line}); continue; } if (std::isdigit(c)) { // 数字先按整串收敛,int/float 类型留到语义分析再定 size_t start = i; bool isFloat = false; while (i < src.size() && (std::isdigit(src[i]) || src[i] == '.')) { if (src[i] == '.') isFloat = true; ++i; } tokens.push_back({isFloat ? TokenType::FLOAT_LIT : TokenType::INT_LIT, src.substr(start, i - start), line}); continue; } // 两字符运算符预读:== != <= >= && || if (c == '=' && i + 1 < src.size() && src[i + 1] == '=') { tokens.push_back({TokenType::OP_EQ, "==", line}); i += 2; continue; } if (c == '!' && i + 1 < src.size() && src[i + 1] == '=') { tokens.push_back({TokenType::OP_NE, "!=", line}); i += 2; continue; } if (c == '<') { if (i + 1 < src.size() && src[i + 1] == '=') { tokens.push_back({TokenType::OP_LE, "<=", line}); i += 2; continue; } tokens.push_back({TokenType::OP_LT, "<", line}); ++i; continue; } // 单字符运算符分支略:+ - * / % ( ) [ ] { } , ; 同理 ++i; } tokens.push_back({TokenType::END, "", line}); return tokens; }

这里几个设计点是后面排错的命门。Token 里必须带 line 字段,否则报错时只能给出 0 行或干脆不知道错在哪,评测系统对比输出时最恨这种「能跑但报错信息缺失」的半成品。数字字面量在一个分支里统一收敛,等语义分析时再按上下文确定是 int 还是 float,避免词法阶段就被 3.14 这种东西绑架。关键字和标识符共用扫描路径再查表区分,这是 C 系语言的标准做法。这套词法、语法、语义的三段式分工,同时也是 c/c++ 构建流程里最值得手写一遍的部分,理解了它,再看 GCC 的 -E / -S / -c 三个阶段会通透很多。

2.3 递归下降语法分析:运算符优先级靠函数层层嵌套表达

词法分析输出的是扁平的 token 流,语法分析要把它们还原成树。我选递归下降而不是 bison:SysY 语法规模小,递归下降写起来直观,报错位置可控。表达式优先级是递归下降最经典的分层思路:每个优先级对应一个函数,最低优先级的函数在最外层,每层只消化当前优先级的一层运算符,然后递归调用下一层。

// 表达式解析的优先级分层:|| 最低,依次是 &&、比较、加减、乘除、一元 std::unique_ptr<ExprAST> Parser::parseExpr() { return parseOr(); // 最外层是 ||,因为它的优先级最低 } std::unique_ptr<ExprAST> Parser::parseOr() { auto lhs = parseAnd(); while (match(TokenType::OP_OR)) { auto rhs = parseAnd(); lhs = std::make_unique<BinaryExprAST>(BinaryOp::OR, std::move(lhs), std::move(rhs)); } return lhs; } std::unique_ptr<ExprAST> Parser::parseAnd() { auto lhs = parseEquality(); while (match(TokenType::OP_AND)) { auto rhs = parseEquality(); lhs = std::make_unique<BinaryExprAST>(BinaryOp::AND, std::move(lhs), std::move(rhs)); } return lhs; }

match() 检查当前 token 并消费它,这里的逻辑说明就一句话:每一层只处理一种优先级。于是a || b && c会被 parseOr 拆成a || (b && c),因为 parseOr 右边的操作数来自 parseAnd,而 parseAnd 会贪婪地把b && c吃掉。完整层级顺序是 parseExpr → parseOr → parseAnd → parseEquality(== !=)→ parseRelational(< > <= >=)→ parseAdd(+ -)→ parseMul(* / %)→ parseUnary(- !)→ parsePrimary(字面量、标识符、括号表达式)。每一层少一个函数,表达式解析就会错位一种优先级,这种错位在功能测试里很难一眼发现。所以我在写 parser 时会先布置一个1 + 2 * 3 == 7的解析测试,用 AST dump 验证优先级树,再往下走。

3. 从 AST 到 RISC-V 汇编:中间表示、符号表与指令选择的完整链路

有了 AST,下一步是把它变成 RISC-V 汇编。这一步是整条路线里最需要系统设计的部分:直接对着 AST 吐汇编虽然也能跑通,但表达式求值顺序、临时变量存放、数组寻址和函数调用会搅在一起,改一个 bug 往往触发另一个。我建议在 AST 和汇编之间加一层极简三地址码(TAC),把这层做薄,后面加优化和换目标架构都有余量。这也是业界常见做法,LLVM 的 IR 本质上就是为了把前端和后端解耦,课程项目不用做那么重,但思路一样。

3.1 中间表示:为什么用三地址码而不是直接把 AST 翻译成 RISC-V

三地址码的形态是t1 = b * 2; t2 = a + t1;。它有三个好处:第一,每个 IR 指令最多处理一个二元运算,寄存器分配的输入是干净的操作数;第二,控制流结构化,一个函数体里所有基本块可以顺序编号,后续做基本块优化时有现成材料;第三,实践报告里可以贴一节 IR dump,评审一眼就能看到你在 AST 和汇编之间做了一层设计,而不是堆一个 switch 硬翻译。以下是我项目里用过的 IR 指令结构:

// tac.h —— 三地址码指令的极简表示 enum class IRKind { ASSIGN, ADD, SUB, MUL, DIV, REM, NEG, NOT, CMP_EQ, CMP_NE, CMP_LT, CMP_LE, CMP_GT, CMP_GE, AND, OR, JMP, JZ, LABEL, CALL, ARG, RET, LOAD, STORE, GETINT, PUTINT // 数组访问和 I/O }; struct TACInst { IRKind kind; std::string op1; // 操作数或来源变量的名字 std::string op2; std::string dst; // 目的变量或目标标签名 int line; // 源码行号,回填调试信息 TACInst(IRKind k, std::string d, std::string a = "", std::string b = "", int l = 0) : kind(k), op1(std::move(a)), op2(std::move(b)), dst(std::move(d)), line(l) {} };

逻辑说明:op1、op2 指的是 IR 临时变量或源码变量名,而不是寄存器。比如ADD t1, a, b翻译成汇编时,再由寄存器分配器决定 a、b、t1 落在哪个寄存器。这一步把变量和寄存器的映射彻底拆开,让指令选择只关心「这条 IR 对应哪几条指令」,寄存器分配只关心「这个变量放哪里」。另一个细节是 JZ 条件跳转的操作数是跳转标签名,dst 字段存标签;LABEL 指令的 dst 也是标签名。用字符串当操作数没有编译期类型安全,但课程规模下换来的是调试时能直接看 IR dump,值。IRKind 里的 AND、OR 是给短路求值用的,它们不能直接翻译成 RISC-V 的 bitwise and/or,后端要展开成跳转序列,这一点在第 5 章避坑里会专门讲。

3.2 符号表与作用域:C++ 的数据结构怎么设计

符号表的核心数据结构是作用域栈。模块设计上,我采用 vector 内嵌 unordered_map 的实现,进入 BlockNode 时 pushScope,退出时 popScope,函数体天然是一个作用域,函数参数也在这个作用域里登记。Symbol 结构里最重要的不是类型,而是 offset 字段——它是栈帧布局阶段回填的偏移量,全局变量则用 isGlobal 标记,offset 存 0,汇编时通过全局符号名访问。

struct Symbol { std::string name; Type type; // INT, FLOAT, ARRAY, FUNC bool isConst; bool isGlobal; int offset; // 局部变量在栈帧中的偏移量;全局变量标记为 0 int arraySize; // 数组元素个数,非数组为 0 }; class SymbolTable { std::vector<std::unordered_map<std::string, Symbol>> scopes; public: void pushScope() { scopes.emplace_back(); } void popScope() { scopes.pop_back(); } bool insert(const Symbol& sym) { auto& cur = scopes.back(); if (cur.find(sym.name) != cur.end()) return false; // 重复定义 cur[sym.name] = sym; return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes.rbegin(); it != scopes.rend(); ++it) { auto f = it->find(name); if (f != it->end()) return &f->second; } return nullptr; } };

这样for (int i = 0; ...)里的 i(如果 SysY 语法允许在 for 初始化里定义)生命周期就只到循环结束。还有一个容易漏的:函数形参必须插入到函数体作用域里,而不是全局作用域。很多程序在函数内引用形参时查不到符号,就是因为形参登记错了层级。语义分析阶段除了查符号表,还要做类型检查和 const 检查——给 const 变量赋值、调用未声明的函数、return 类型和函数声明不一致,都要在 AST 遍历时一次性拦住,别拖到代码生成才爆。

3.3 RISC-V 指令选择:核心映射表与浮点指令的坑

三地址码生成之后,指令选择就是一张查表的事。SysY 面向的 RISC-V 通常按 RV32IM 加 F 浮点扩展来考虑。下面这张映射表是我每次都会贴在报告里的,也是代码生成器里最核心的 switch 分支依据:

三地址码RISC-V 指令序列说明
ADD t1, a, badd t1, a, b整型加法;浮点用 fadd.s
SUB t1, a, bsub t1, a, b整型减法;浮点用 fsub.s
MUL t1, a, bmul t1, a, b / fmul.s t1, a, b按操作数类型选指令
DIV t1, a, bdiv t1, a, b / fdiv.s t1, a, b整数除法向零取整,浮点是 IEEE 舍入
LOAD t1, base, offlw t1, off(base) / flw t1, off(base)数组取值核心指令
STORE val, base, offsw val, off(base) / fsw val, off(base)数组赋值核心指令
CALL f, a0, a1mv/li 参数到 a0-a7,再 call f返回地址自动进 ra
JZ t1, Lbeqz t1, L条件跳转

容易踩的坑有四个。第一,RISC-V 的div指令向零取整,和 C 标准一致,但%取余指令rem的符号跟着被除数,不能按数学意义取模。第二,浮点比较和整型比较指令不同:整型用blt/beq,浮点要用flt.s把比较结果写进浮点寄存器,再fmv.x.w搬到整数寄存器判断。第三,常数需要li装载,RISC-V 没有 x86 那种「内存-立即数」一条龙。第四,函数调用前要保存调用者保存寄存器;如果你的代码生成器把每个 IR 临时变量都放到栈上,这步反而简单,因为栈上的值不会被 callee 破坏。这也是课程项目里「先全栈分配、后局部优化」的合理性所在。

3.4 栈帧布局与数组寻址:sp 和 fp 的偏移量计算

寄存器分配不做过优化时,我的生成器采用「每个局部变量固定一个栈偏移」的策略:进入函数时,先按所有局部变量加临时变量总大小把 sp 下移,之后每次访问变量用 sp 加偏移量。数组寻址是偏移量计算最集中的翻车点。一维数组a[10]中a[k]的地址偏移是k * 4;多维数组按行优先,a[2][3]中a[i][j]的地址偏移是(i * 3 + j) * 4。这个乘法公式必须在代码生成阶段直接展开成常量乘法,不能等汇编阶段再去算。

// 数组元素地址 = 基址 + 元素下标 * 元素大小 // 一维数组 a[10],int 占 4 字节,元素 a[k] 的地址偏移是 k * 4 int elemOffset = k * 4; // k 是 IR 里已计算好的下标临时变量 TACInst store = { IRKind::STORE, "a", "t0", std::to_string(elemOffset), line };

栈帧布局上,我采用「局部变量区从低到高排,数组紧跟在标量变量后面」的顺序,这样每个数组的基址偏移是确定的。这里有一个血泪教训:RISC-V 调用约定要求栈指针按 16 字节对齐,如果你只给一个 int 分配 4 字节就把 sp 减下去,call 的时候非法地址会随机出现。解法是先给整个帧算总大小,向上取整到 16 的倍数,再一次性 sp 下移。另一个常见做法是让 fp 指向帧底部,所有偏移都基于 fp,这样调试器更容易回溯栈,但课程项目里只用 sp 就够了,少维护一个寄存器。

4. 实践报告怎么写才不白做:测试覆盖、指令统计与性能数据收集

源码写得再漂亮,实践报告写不清楚,项目在评审眼里就打了对折。实践报告不是为了记录「我做了什么」,而是为了证明「我做的东西是对的、有效的、可控的」。SysY 编译器评测一般会拿一批 SysY 程序,丢进你的编译器生成汇编,再用 RISC-V 工具链(gcc 交叉编译或 qemu 模拟器)跑出结果,和参考程序做比对。因此报告里最该有的,是一套能反复执行、输出可对比的测试流程和测量数据。

4.1 测试用例组织:功能、边界、压力三张网

第一种是功能测试,覆盖文法里每一条产生式:整型运算、浮点运算、数组、函数调用、嵌套循环、break/continue。SysY 写个冒泡排序是个好开头,因为它同时用上了数组、for、比较和赋值,冒泡排序在 C 里的写法到 SysY 基本不变,只要把 for 的步进语句调整成 SysY 支持的i = i + 1形态。

// 冒泡排序的 SysY 版本,作为功能测试用例 int a[10]; int main() { int i, j, tmp; for (i = 0; i < 10; i = i + 1) a[i] = 10 - i; for (i = 9; i > 0; i = i - 1) for (j = 0; j < i; j = j + 1) if (a[j] > a[j + 1]) { tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; } for (i = 0; i < 10; i = i + 1) putint(a[i]); return 0; }

这个用例一箭三雕:验证了数组下标寻址和a[j+1]的复合地址计算;验证了>比较和 if 的条件跳转;验证了嵌套 for 的循环体。把这种用例的输出(0 1 2 3 4 5 6 7 8 9)和参考编译器输出对比,就完成了一次最小回归。第二种是边界测试:数组下标 0、数组上限、整数最小/最大值、嵌套函数调用深度、递归深度。第三种是压力测试:生成一个几千行的 SysY 程序,观察编译是否崩、耗时多少、输出汇编多少行,压测时最好加一层-O2和-O0对比,顺便记录 IR 指令数。我一般把这三种测试的目录分开,tests/func、tests/edge、tests/stress,回归脚本直接按目录批量跑。

4.2 指令统计与栈帧参数:报告里能量的东西都量出来

实践报告里的数据要有可复现性。能测的指标至少包括:编译耗时、生成的汇编行数、IR 指令数、栈帧大小、程序运行指令数。运行指令数可以用 qemu 的-d in_asm抓,课程环境里如果有 RISC-V 的 gcc 工具链,直接编一份语义等价的 C 程序对比汇编行数也能说明问题。

# 用 qemu 模拟器运行编译产物,并统计执行指令数(示例) riscv64-unknown-elf-gcc -O0 test.s -o test.elf qemu-riscv64 -d in_asm -D trace.log ./test.elf wc -l trace.log
测试用例-O0 IR 指令数-O2 IR 指令数减少比例汇编行数(-O2)栈帧大小
冒泡排序18714323.5%9648 B
斐波那契递归423126.2%2832 B

这种表跑一遍就有,重点是要在报告里写清楚「怎么测的」:输入用例文件放哪个目录,命令是哪条,统计工具是什么。读者照着你报告里的命令能复现,这份报告就叫及格。我见过很多报告贴一张模糊的截图,连编译命令都没写,这种数据基本没有说服力。实践报告里能量的东西都量化成表格,放代码片段旁边,评审老师一眼就能看到工作量。

4.3 报告结构:从需求分析到测试结论的八页模板

我一般按下面的结构走,每部分都绑定一个证据,而不是空谈设计:

报告章节必须给的内容证据
需求分析SysY 语法子集的边界文法摘录 + 支持/不支持清单
总体设计模块划分、数据流一张架构图(前端→IR→后端)
详细设计词法/语法/语义的类图类关系图 + 关键数据结构
中间表示设计IR 指令集定义IR dump 示例
代码生成指令映射表、栈帧布局一段输入源码对应的 RISC-V 汇编
测试测试用例清单与结果运行输出对比粘贴文本
性能分析指令数/栈帧/耗时表4.2 的统计表
总结与展望已知问题、可扩展点诚实列出 3 个未做的优化

报告里最容易被扣分的是「展望」写得太虚,比如「未来可以支持更多优化」。改成具体一点:「目前未做常量化简,a = 2 * 3 + b会生成乘法指令;下一步可以在 IR 层加 constant folding,预计能把冒泡排序的 IR 指令数再降 10%」。这种话才有分量,因为它意味着你已经知道优化点在哪、影响多大,而不是套话。

5. 避坑清单:SysY 到 RISC-V 编译器开发中的 5 个高频翻车点

这一章每一节都是一条真实踩坑记录,格式是现象 → 原因 → 解决。按我在调试器前耗掉最多时间的顺序排,越靠前越致命。

5.1 全局变量初始化变成运行时赋值

现象:全局数组在 main 里读到的初始值全不对,或者整个评测结果整体错位。 原因:有些实现把全局int a[10];的初始化动作放进了函数入口,结果是每次调用函数都重新初始化一遍;或者把const int N = 5也当成运行时赋值,导致代码生成时数组长度拿不到常量值。 解决:全局变量和 const 在语义分析阶段就求值。const int N = 5直接把 N 登记为常量 5,后续数组定义需要 N 时直接用这个常量;全局数组的初始值在汇编阶段展开成.data段里的.word序列,函数体内不生成任何初始化指令。如果你从 IR dump 里看到全局变量赋值出现在函数体里,基本就是踩了这个坑。

5.2 短路求值没做:&& 和 || 把副作用提前求了

现象:if (x != 0 && 10 / x > 2)在 x 为 0 时不报错却进入了错误分支,评测输出直接错。 原因:实现把 && 翻译成了 bitwise and,两边都求值,所以 x 为 0 时10 / x先被算出来,除零错误没有被短路逻辑掐掉。 解决:&& 和 || 必须生成跳转标签:JZ到短路出口,LABEL继续执行。我一般把这种代码生成单独抽出来测,专门准备一组「除零短路测试」用例,确认 x 为 0 时第二个操作数完全不会被求值。SysY 语义如果不明确要求短路,也要在报告里写明你的实现选择,否则评测程序一旦依赖短路,你就得返工。

5.3 整数除法向零取整:不是 bug 但可能被评测判错

现象:-7 / 2得到 -3,而不是 -4;-7 % 2得到 -1,而不是 1。 原因:RISC-V 的 div/rem 指令向零取整,余数符号跟随被除数,这是 C 标准行为。问题不在于你的实现错,而在于你测试时用了数学直觉而不是 C 语义去验证结果。 解决:先确认课程要求是参照 C 还是参照数学取模。SysY 语义如果参照 C,那这里就是对的,在报告里写明「向零取整」即可;如果评测参照 Python 的取模语义,就得在 IR 层手动修正,生成额外的分支纠正负余数。动手前先看清课程要求,这是唯一一个「不是 bug 但可能被评测判错」的坑。

5.4 编译器未包含 main 类型:链接器报错和入口处理

现象:整个编译流程走到链接那步,报「编译器未包含main类型」,或者模拟器执行时直接跳进非法地址。 原因:SysY 程序里 main 是入口。你的编译器生成了main:标签,但如果在汇编末尾加了多余的返回指令,或者没有把jal main作为程序入口,链接器或模拟器就找不到真正的入口点。还有一种情况是函数声明与定义顺序颠倒,导致 main 被识别成普通函数。 解决:在代码生成入口固定输出一段启动序列:.text段里jal main作为入口,main 返回后li a0, 0再按实验环境要求的系统调用退出。在语义分析阶段强制检查 main 必须存在且返回 int。这个检查要放在符号表填充完成后,mian 拼写错误、返回值类型不对都在这一层拦住。

5.5 数组偏移量算错:二维数组的 stride 忘乘元素大小

现象:二维数组a[2][3]访问a[1][0]时,取到的不是第二行首元素,而是偏了几个字节的位置。 原因:算偏移时只按下标做了加法,没乘基础宽度。int 和 float 都是 4 字节,SysY 子集里通常就这两种类型,所以问题集中在「忘乘 4」。 解决:在语义分析阶段给数组类型维护一个 stride 字段。一维数组 stride 是元素大小,二维数组a[M][N]的 stride 是N * 4,再把地址计算公式集中到一个函数里,所有下标访问都走这个函数,不要散落各处。修完之后用一组三点索引测试用例验证:a[0][0]、a[0][2]、a[1][2],三个偏移量分别是 0、8、20,对不上就是乘法漏了。

6. 把编译器做到能交付:优化开关、回归测试和下一步扩展

做完一个能跑的正确编译器只是起点,真正能交付的项目还要能证明它稳定、可回归、有余量。我的做法是加两个东西:一个-O0/-O2优化开关,一个一键回归脚本。优化开关的意义不只是性能,它还逼你把 IR 设计得更干净——如果这个优化在 IR 层做得很别扭,说明 IR 的抽象层级选错了。

#!/bin/bash # 简易回归测试:遍历 tests/ 下所有 .sy 文件,编译运行并比对输出 for src in tests/*.sy; do name=${src%.sy} ./build/sysycc "$src" -o "$name.s" -O2 riscv64-unknown-elf-gcc "$name.s" -o "$name.elf" actual=$(qemu-riscv64 "$name.elf") expected=$(cat "$name.out") if [ "$actual" == "$expected" ]; then echo "PASS $name" else echo "FAIL $name: expected $expected, got $actual" fi done

这个脚本写起来十分钟,但它把「改一处代码会不会影响旧行为」变成了十秒钟的自动检查。我自己的习惯是每修一个 bug,先往 tests/ 里加一个复现用例,让这个脚本先 FAIL,再改编译器代码,改到 PASS 为止。这就是最朴素的测试驱动开发,对编译器这种黑匣子系统特别有效——你永远不知道某个优化会不会在角落里改变一条指令的语义。

扩展方向上,下一步有两个选择:一是做常数折叠和死代码删除,把t1 = 2 * 3直接折叠成t1 = 6,把从未被读取的 IR 指令删掉;二是把三地址码转成 LLVM IR,后端寄存器分配和指令选择直接白拿,但那样就学不到手写后端的苦和甜了。我建议至少在课程项目里把手写后端做到能处理函数调用和数组寻址,再谈 LLVM。源码层面可以把 IR 指令按基本块组织,为将来的活跃变量分析留好结构。

我自己做完这个项目后最大的教训是:编译器的复杂度是叠加的,词法阶段偷懒,语法阶段就得加倍偿还;语法阶段结构设计偷懒,代码生成阶段就是灾难。先保证 -O0 全绿,再开优化;先保证一维数组正确,再碰多维数组;先保证函数调用正确,再碰浮点。每一个阶段都留好测试用例,别等全部写完再一次性调试,那时你已经分不清 bug 来自词法还是代码生成。希望帮到你。

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

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

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

立即咨询