
简介这是一份面向高校计算机专业学生的编译原理课程实践资源围绕基于C实现的SysY到RISC-V编译器展开适合正在准备期末大作业、课程设计或希望深入理解编译流程的学习者。项目从词法分析、语法分析到中间代码生成与目标代码输出均有完整实现并附有实践报告代码注释清晰新手也能逐步看懂。压缩包共27个文件约108KB以hpp头文件与cpp源文件为主体配合sysy.y、sysy.l等语法与词法定义文件以及CMakeLists.txt构建脚本、README说明和测试用例结构紧凑、便于部署运行。目前已有99人学习下载。读者可借此掌握编译器各阶段的衔接方式参考符号表管理、AST构建与Koopa中间表示等模块设计并对照报告梳理实验思路为课程答辩与后续系统能力提升提供扎实参考。1. 从 SysY 到 RISC-V一个能写进简历的编译器项目长什么样很多人学完编译原理词法分析、语法分析、语义分析、中间代码、目标代码这套流程背得滚瓜烂熟但真让他把一段 C 语言子集编译成能在 RISC-V 上跑起来的汇编就卡住了。SysY 到 RISC-V 的编译器项目解决的正是这个断层SysY 是教学用 C 语言子集语法干净、语义明确RISC-V 是开放指令集工具链成熟、模拟器好找两者组合起来刚好够你从零手写一个完整编译器又不至于被工业级语言的复杂度淹没。这个项目适合谁适合已经学过编译原理课程、想找一个能写进简历、能讲清楚每个 pass 在干什么的实践项目的人。它不要求你先成为 C 高手但要求你愿意用 C 把 AST、符号表、IR、寄存器分配这些核心数据结构亲手搭一遍。做完之后你对编译器的理解不再是黑匣子而是一条你能画出来的流水线。2. 动手之前SysY 语言子集与 RISC-V 目标平台的选型理由2.1 SysY 到底砍掉了哪些 C 语言特性SysY 不是完整的 C它砍掉了结构体、联合体、枚举、位运算、浮点部分版本保留、指针算术、动态内存分配、预处理指令、多文件编译。保留的是全局变量、局部变量、常量、一维和二维数组、函数定义与调用、if-else、while、break、continue、return、算术与逻辑表达式、比较运算。砍掉这些不是为了偷懒而是为了让编译器前端足够小小到你能在一周内把词法分析和语法分析写对然后把精力放在中端优化和后端代码生成上。我一般会建议先写一个 SysY 程序作为测试用例比如计算斐波那契数列、矩阵乘法、冒泡排序。这些程序覆盖了数组、循环、函数调用、递归能跑通它们说明你的编译器基本正确。2.2 为什么选 RISC-V 而不是 x86 或 MIPSRISC-V 的指令格式规整寄存器数量多32 个通用寄存器调用约定清晰模拟器如 QEMU、Spike和工具链riscv64-unknown-elf-gcc、riscv64-linux-gnu-gcc在 Linux 上安装方便。x86 指令变长、寻址模式复杂MIPS 有延迟槽这些都会分散你对编译器核心逻辑的注意力。RISC-V 的汇编也容易读一条指令干一件事调试时看汇编就能定位问题。另一个现实原因是RISC-V 生态这几年在课程实践中越来越主流很多高校的编译原理实验已经切换到 RISC-V 目标。你做完这个项目面试时聊起来对方大概率知道 RISC-V 是什么不需要你从头解释指令集。2.3 工具链准备在 Linux 上装好 RISC-V 模拟器与交叉编译器以下命令在 Ubuntu 22.04 上验证过其他发行版包名可能略有差异。# 安装 RISC-V 交叉编译工具链和 QEMU 模拟器 sudo apt update sudo apt install -y gcc-riscv64-linux-gnu qemu-user qemu-user-static # 验证交叉编译器 riscv64-linux-gnu-gcc --version # 验证 QEMU 用户态模拟器 qemu-riscv64 --version逻辑说明gcc-riscv64-linux-gnu提供 RISC-V 的汇编器和链接器用来把你生成的汇编变成可执行文件。qemu-user让你在 x86 主机上直接运行 RISC-V 的 ELF 可执行文件不需要完整系统模拟。参数说明-y表示自动确认安装--version用来确认安装成功。如果你习惯用 VS Code 写代码可以装 C/C 插件和 RISC-V 汇编高亮插件但不要依赖 IDE 的自动补全来写编译器AST 和符号表的结构设计需要你自己想清楚。3. 前端实现词法分析、语法分析与 AST 构建3.1 词法分析用有限状态机把源码切成 Token 流SysY 的 Token 类型不多标识符、整数字面量、关键字int、void、const、if、else、while、break、continue、return、运算符 - * / % ! || !、分隔符( ) { } [ ] ; ,。我一般会手写一个Lexer类内部维护一个pos指针和当前行号每次调用nextToken()返回一个 Token 对象。// lexer.h #pragma once #include string #include unordered_map enum class TokenType { IDENT, INT_LITERAL, KEYWORD, OP, DELIM, END }; struct Token { TokenType type; std::string value; int line; }; class Lexer { public: explicit Lexer(const std::string src); Token nextToken(); private: std::string src_; size_t pos_; int line_; char peek() const; char advance(); void skipWhitespaceAndComments(); };逻辑说明nextToken()是核心方法先跳过空白和注释然后根据当前字符判断是标识符、数字还是符号。关键字用unordered_map查表标识符用正则式[a-zA-Z_][a-zA-Z0-9_]*匹配。参数说明src_保存源码字符串pos_是当前读取位置line_用于报错时定位。踩坑提醒SysY 的注释有//和/* */两种/* */可以跨行跳过注释时别忘了更新行号。另外int后面跟main时main是标识符不是关键字别把main加进关键字表。3.2 语法分析递归下降构建 ASTSysY 的语法可以用递归下降法手写不需要用 yacc/bison。递归下降的好处是错误信息好定位代码结构清晰每个非终结符对应一个解析函数。// parser.h #pragma once #include lexer.h #include memory #include vector struct ASTNode { virtual ~ASTNode() default; }; struct CompUnit : ASTNode { std::vectorstd::unique_ptrASTNode items; }; struct FuncDef : ASTNode { std::string returnType; std::string name; std::vectorstd::unique_ptrASTNode params; std::unique_ptrASTNode body; }; class Parser { public: explicit Parser(Lexer lexer); std::unique_ptrCompUnit parseCompUnit(); private: Lexer lexer_; Token cur_; void eat(TokenType type, const std::string value ); std::unique_ptrASTNode parseFuncDef(); std::unique_ptrASTNode parseBlock(); std::unique_ptrASTNode parseStmt(); std::unique_ptrASTNode parseExpr(); };逻辑说明parseCompUnit()循环调用parseFuncDef()直到文件结束。parseFuncDef()先读返回类型和函数名再读参数列表最后读函数体。parseBlock()处理{ }包裹的语句序列。parseStmt()根据当前 Token 判断是 if、while、return 还是表达式语句。parseExpr()用优先级爬升法处理二元运算符。参数说明cur_是当前 Tokeneat()用来消费预期 Token如果类型或值不匹配就报错。parseExpr()里需要维护一个优先级表||最低次之然后是比较、加减、乘除、一元运算符。3.3 符号表作用域链与变量地址分配符号表是编译器的记忆。SysY 有全局作用域和局部作用域局部作用域可以嵌套。我一般用std::vectorstd::unordered_mapstd::string, Symbol作为作用域栈进入块时push_back一个新 map离开块时pop_back。struct Symbol { std::string name; std::string type; // int 或 int[] bool isConst; int value; // 常量值 int stackOffset; // 局部变量在栈帧中的偏移 bool isGlobal; }; class SymbolTable { public: void enterScope(); void exitScope(); bool insert(const Symbol sym); Symbol* lookup(const std::string name); private: std::vectorstd::unordered_mapstd::string, Symbol scopes_; };逻辑说明enterScope()在进入函数体或块时调用exitScope()在离开时调用。insert()在当前作用域插入符号如果同名符号已存在就报错。lookup()从最内层作用域向外查找找到就返回指针。参数说明stackOffset是局部变量相对于栈帧指针的偏移全局变量不需要这个字段。isConst标记常量常量在编译期求值不分配栈空间。避坑SysY 允许变量声明和函数声明同名吗不允许。函数名和变量名在同一个命名空间里插入符号时要检查冲突。另外数组的符号表项要记录维度大小方便后续计算地址。4. 中端与后端从 AST 到 RISC-V 汇编的落地路径4.1 中间表示为什么我选四元式而不是直接生成汇编直接从 AST 生成汇编不是不行但调试起来很痛苦。AST 是树形结构汇编是线性指令序列中间隔着控制流和寄存器分配。我一般会先降级到四元式Quadruple形式是(op, arg1, arg2, result)比如(ADD, a, b, t1)表示t1 a b。四元式的好处是每条指令只做一件事控制流用标签和跳转表示后续做常量折叠、死代码消除、寄存器分配都方便。struct Quad { std::string op; // ADD, SUB, LOAD, STORE, BR, BEQ, ... std::string arg1; std::string arg2; std::string result; }; class IRBuilder { public: void emit(const std::string op, const std::string arg1, const std::string arg2, const std::string result); std::vectorQuad getQuads() const; private: std::vectorQuad quads_; int tempCounter_ 0; int labelCounter_ 0; std::string newTemp(); std::string newLabel(); };逻辑说明emit()把一条四元式追加到quads_向量。newTemp()生成t0、t1这样的临时变量名newLabel()生成L0、L1这样的标签名。遍历 AST 时每个表达式节点返回一个临时变量名语句节点直接 emit 控制流四元式。参数说明op是操作码arg1和arg2是源操作数result是目标操作数。对于一元操作arg2留空。对于跳转指令result是目标标签。4.2 寄存器分配线性扫描法在 SysY 上的简化实现RISC-V 有 32 个通用寄存器但实际能自由使用的只有t0-t6、a0-a7、s0-s11这些。SysY 程序通常不大我一般用线性扫描法做寄存器分配先计算每个临时变量的活跃区间然后按起始位置排序依次分配寄存器如果寄存器不够就溢出到栈。struct LiveInterval { std::string var; int start; int end; std::string reg; // 分配到的寄存器空表示溢出 }; class RegisterAllocator { public: void allocate(const std::vectorQuad quads); std::string getReg(const std::string var) const; private: std::vectorLiveInterval intervals_; std::vectorstd::string freeRegs_ { t0, t1, t2, t3, t4, t5, t6, a0, a1, a2, a3, a4, a5, a6, a7 }; };逻辑说明allocate()先遍历四元式记录每个变量的定义点和最后使用点形成活跃区间。然后按start排序用freeRegs_里的寄存器依次分配。如果某个区间跨越了函数调用优先分配s系列寄存器被调用者保存否则用t系列。参数说明start和end是四元式的索引。reg为空表示该变量溢出到栈后续生成汇编时用lw/sw访问。避坑函数调用会破坏t系列寄存器调用前要把活跃的t寄存器保存到栈调用后恢复。a0-a7用于传参调用前要设置好调用后a0保存返回值。4.3 代码生成把四元式翻译成 RISC-V 汇编代码生成是最后一步也是最能体现细节的一步。我一般写一个CodeGen类遍历四元式根据op生成对应的汇编指令。class CodeGen { public: std::string generate(const std::vectorQuad quads); private: std::stringstream ss_; void genArithmetic(const Quad q); void genLoadStore(const Quad q); void genBranch(const Quad q); void genCall(const Quad q); };逻辑说明generate()先输出.data段和.text段然后逐条处理四元式。genArithmetic()处理加减乘除genLoadStore()处理内存访问genBranch()处理条件跳转和无条件跳转genCall()处理函数调用。参数说明RISC-V 的算术指令是三操作数格式如add t0, t1, t2。内存访问用lw和sw偏移量是 12 位有符号数超过范围要拆成luiaddi。函数调用用call伪指令返回地址存在ra。一个具体的翻译例子四元式(ADD, a, b, t1)翻译成lw t0, a_offset(sp)、lw t1, b_offset(sp)、add t2, t0, t1、sw t2, t1_offset(sp)。如果a和b已经分配了寄存器就直接用寄存器名。5. 避坑与排查SysY 到 RISC-V 编译器最容易翻车的 5 个地方5.1 现象生成的汇编能编译但运行结果不对且错误随机出现原因寄存器分配时没有正确处理函数调用对寄存器的破坏。t系列寄存器是调用者保存函数调用后原值可能被覆盖但你的代码假设它们不变。解决在函数调用前把当前活跃的t寄存器压栈调用后弹栈恢复。或者更简单函数调用前把所有活跃变量溢出到栈调用后重新加载。线性扫描分配时给跨越函数调用的区间优先分配s系列寄存器。5.2 现象数组访问越界或地址计算错误原因SysY 的二维数组在内存中按行优先存储计算a[i][j]的地址时偏移量是(i * cols j) * 4。如果只算了i * 4 j * 4就会错位。解决在符号表里记录数组的维度大小生成地址计算四元式时先算i * cols再加j最后乘元素大小。对于一维数组偏移量是i * 4。全局数组的基地址用la加载局部数组的基地址用addi从栈指针计算。5.3 现象常量传播后程序行为改变原因SysY 的const变量在编译期求值但如果常量表达式里有未定义行为如除以零编译器可能直接崩溃或生成错误结果。解决常量求值时检查除零、溢出。如果表达式不是编译期可求值的就当作普通变量处理不要强行折叠。const int a b 1;如果b不是常量a也不能当常量用。5.4 现象QEMU 运行时报 illegal instruction原因生成的汇编里用了 RISC-V 不支持的伪指令或指令格式。比如li加载大立即数时如果立即数超过 32 位li会展开成多条指令但某些模拟器对li的支持不完整。解决用riscv64-linux-gnu-as汇编你的.s文件看是否有报错。如果有把伪指令换成真实指令。li可以换成luiaddicall可以换成auipcjalr。另外确认你的目标架构是rv64gc或rv64imac不要用rv32的指令。5.5 现象编译器自身编译时报堆空间不足原因AST 节点用new分配没有释放递归下降解析大文件时内存暴涨。或者四元式向量在循环里不断追加没有预留容量。解决用std::unique_ptr管理 AST 节点函数返回时自动释放。四元式向量用reserve()预留空间。如果还是不够检查是不是在递归下降里写了无限递归比如parseExpr()调parseExpr()没有终止条件。6. 进阶技巧用差分测试和性能计数器验证编译器正确性6.1 差分测试同一段 SysY 程序用 GCC 和你的编译器分别编译对比输出差分测试是验证编译器正确性最有效的方法。你写一个 SysY 程序先用riscv64-linux-gnu-gcc编译成 RISC-V 可执行文件运行得到输出 A再用你的编译器生成汇编汇编链接后运行得到输出 B。如果 A 和 B 不一致说明你的编译器有 bug。# 用 GCC 编译 SysY 程序假设 sysy_prog.c 是合法的 C 子集 riscv64-linux-gnu-gcc -static -o prog_gcc sysy_prog.c qemu-riscv64 ./prog_gcc out_gcc.txt # 用你的编译器生成汇编再汇编链接 ./my_compiler sysy_prog.c prog_my.s riscv64-linux-gnu-gcc -static -o prog_my prog_my.s qemu-riscv64 ./prog_my out_my.txt # 对比输出 diff out_gcc.txt out_my.txt逻辑说明-static避免动态链接库的差异qemu-riscv64运行 RISC-V 可执行文件。diff没有输出表示两者一致。参数说明sysy_prog.c必须是 SysY 和 C 的交集不能用 SysY 不支持的特性。我一般会准备 20 到 30 个测试用例覆盖数组、循环、递归、函数调用、全局变量、常量。每次改编译器后跑一遍差分测试确保没有回归。6.2 性能计数器用 QEMU 的插件统计指令数和分支预测失败QEMU 支持-plugin参数加载性能分析插件可以统计执行的指令数、分支数、缓存命中率。虽然 SysY 程序不大但对比不同寄存器分配策略的指令数能看出优化效果。# 安装 QEMU 插件开发包 sudo apt install -y qemu-plugin-dev # 用指令计数插件运行程序 qemu-riscv64 -plugin /usr/lib/qemu/plugins/libinsn.so ./prog_my逻辑说明libinsn.so是 QEMU 自带的指令计数插件运行后会输出动态指令数。你可以对比线性扫描分配和朴素栈式分配的指令数如果线性扫描的指令数明显更少说明寄存器分配起了作用。参数说明-plugin后面的路径根据系统不同可能变化用find /usr -name libinsn.so找一下。如果没有插件可以用perf stat跑 QEMU但精度不如插件。6.3 一个具体技巧用-S和-o分离汇编生成和链接我习惯把编译器的输出分成两步先生成.s汇编文件再用 GCC 汇编链接。这样调试时可以直接看汇编不用每次重新编译整个编译器。# 生成汇编 ./my_compiler -S sysy_prog.c -o prog.s # 汇编链接 riscv64-linux-gnu-gcc -static -o prog prog.s # 运行 qemu-riscv64 ./prog逻辑说明-S表示只生成汇编-o指定输出文件。如果你的编译器不支持这些选项就在main()里加参数解析。参数说明-static必须加否则 QEMU 找不到动态链接器。我踩过的一个坑生成的汇编里用了printf但没声明链接时报 undefined reference。解决方法是把printf的声明加到 SysY 的运行时库文件里或者用write系统调用直接输出。后来我养成了一个习惯每生成一条函数调用指令先检查被调用函数是否在符号表里不在就报错而不是等到链接时才暴露。希望帮到你。本文还有配套的精品资源点击获取