
简介面向编译原理课程实践与期末大作业场景这份资源提供了一套基于C实现的SysY到RISC-V编译器完整源码并附带实践报告覆盖了从词法分析、语法分析、中间代码生成到底层指令输出的完整编译链路。代码包含清晰注释词法与语法规则文件分工明确Koopa中间表示与RISC-V后端模块均按功能拆分新手也可对照报告逐步理解各阶段原理简单配置部署即可运行并验证生成的汇编结果。压缩包共27个文件核心实现以10个hpp头文件和6个cpp源文件为主另有文法定义、工程配置、测试样例及说明文档等辅助材料总大小约108KB结构紧凑、便于课设答辩前系统通读。目前已有136人浏览学习适合正在完成编译原理课程设计或期末大作业、希望参考完整可运行项目以获得高分思路的本科生使用。1. 为什么我要特意写一个 C 版的 SysY→RISC-V 编译器编译原理课程实践里用 C 从零写一个把 SysY 编译到 RISC-V 的编译器听起来像「大作业黑话」但它其实是一条能把词法分析、语法分析、符号表、代码生成全部串起来的完整链路。SysY 是课程常见的 C 语言子集目标机选 RISC-V 是因为指令规整、工具链成熟光是这一个项目就能覆盖「从源码到汇编」的每个关键环节。适合谁如果你正在做编译原理实验或者准备课程设计不想只复制别人的 LLVM 前端那么这条可复现路径对你有用。下面所有实现都基于 C17配合常见的 RISC-V 交叉工具链就能跑通不需要云端算力也不绑定某个特定 IDE。它是那种「最终能跑程序、能写报告、能答辩」的完整方案。2. 先定规矩SysY 的语言边界与 RISC-V 的最小目标集2.1 SysY 为什么砍掉 C 的指针和结构体课程实践不会让你去实现一个完整的 C 编译器因为 C 的指针别名、结构体布局、位运算、隐式类型转换都会把时间消耗在「与编译原理无关」的细节上。SysY 作为 C 的教学子集删掉这些之后保留的是int/float标量、一维与多维数组、函数定义与调用、if-else、while、break/continue、return以及算术、比较和逻辑运算。有的课程版本还会加上const但通常不加switch、typedef、struct、指针和位运算。这个裁剪不是拍脑袋。去掉指针语义分析阶段就不需要做别名分析和内存逃逸分析符号表只要记录「变量是全局还是局部、类型是什么、在栈上的偏移是多少」。去掉结构体就避开了字段偏移计算和内存对齐规则数组寻址成为唯一的复合类型考点。剩下的int/float足够讲清楚类型检查、类型提升和赋值相容性。于是整个编译器的核心工作量就集中在语法树怎么建、作用域怎么管、数组地址怎么算、控制流怎么转跳。选 SysY 还有一个实际好处它的语法与 C 高度相似递归下降解析器可以按照常见 C 文法来写不需要处理 C 那种模板或命名空间。而目标机选 RISC-V同样是为了降低后端难度。2.2 RISC-V 后端只需要这几类指令和寄存器约定RISC-V 是一个开放的精简指令集课程实践通常用 RV32IM 就够了I 是基础整数指令M 是乘除法扩展。为什么不用 x86x86 的指令长度不固定、通用寄存器数量少但别名多、寻址模式复杂从 AST 直接发射汇编时会被这些历史包袱拖累。RISC-V 的指令定长 32 位汇编语法干净而且gcc交叉工具链和qemu用户态模拟器在 Linux 下很容易装。后端真正要发射的指令类别其实很少用途典型指令语法实例算术加减add reg, reg, immaddi t0, t1, 4乘除muldivremmul t0, a0, a1内存读lwlbflwlw t0, 8(sp)内存写swsbfswsw t0, -4(s0)分支跳转beqbnebltbgebge t0, t1, label1函数调用jaljalrjal ra, func栈操作addi sp, sp, -32addi sp, sp, -32返回jalr zero, ra, 0jalr x0, ra, 0寄存器约定沿用标准 ABIa0-a7传参数与返回值sp是栈指针s0/fp是栈帧指针ra保存返回地址t0-t6是临时寄存器。注意 RISC-V 汇编里寄存器名是裸的sp而不是$sp这和 x86 的%rsp、MIPS 的$sp都不一样很多人在第一次发射汇编时抄错。后端只需要支持这些指令就能覆盖 SysY 的控制流和数组访问。剩下的重点是栈帧布局每个函数在入口处把sp向下调整若干字节保存ra和旧fp然后把局部变量按固定偏移放在新栈帧内。这个设计我在第 4 章会展开。2.3 最小链路写一个 SysY 小程序并跑通生成汇编在动手写代码之前先把目标流程固定下来。通常的做法是编译器读入.sy文件输出.s汇编文件然后用riscv64-unknown-elf-gcc汇编链接再用qemu-riscv64运行。下面这段 SysY 代码可以作为第一个回归用例// SysY 示例计算 1 到 n 的累加和期望返回 55 int main() { int n 10; int i 1; int sum 0; while (i n) { sum sum i; i i 1; } return sum; }这段代码覆盖了int声明、初始化、while、比较跳转、赋值和return足够最早验证「编译器骨架通没通」。常见 SysY 变体会提供内建的getint()/putint()如果你的课程版本没有就不用在测试里依赖它们。目标链路是# 1. 把 SysY 编译成 RISC-V 汇编 ./sysyc test.sy test.s # 2. 用交叉工具链汇编并链接 riscv64-unknown-elf-gcc -marchrv32im -mabiilp32 -static -o test test.s # 3. 用用户态模拟器执行 qemu-riscv32 ./test echo $?不同课程环境里交叉工具链名称可能不同有的用riscv64-unknown-elf-有的用riscv32-linux-gnu-但思路一样。先把这条链路用任意一个能运行的汇编文件验证一次之后再开发编译器就有了一台「测量机」每个新功能做完就把生成汇编丢进这条流程跑一遍比对返回值。不要等整个编译器写完才开始测试。3. 用 C 写前端词法分析、递归下降与符号表的一次完整落地3.1 Token 定义与词法状态机手写比正则库更可控前端第一件事是词法分析。很多同学会条件反射地想到用std::regex写规则但实际经验是正则库在 token 数量少时尚且慢一旦要处理注释、字符串、数字格式和文件结束符匹配边界会变得很难查。手写一个逐字符状态机代码量不大行为完全可预期调试时打一行日志就能看到当前字符和状态。常见做法是先定义 Token 类型与结构enum class TokenType { IntConst, FloatConst, Identifier, IntKw, FloatKw, IfKw, ElseKw, WhileKw, ReturnKw, BreakKw, ContinueKw, Plus, Minus, Star, Slash, Mod, Assign, Eq, Neq, Lt, Leq, Gt, Geq, AndAnd, OrOr, Not, LParen, RParen, LBrace, RBrace, LBracket, RBracket, Comma, Semicolon, End }; struct Token { TokenType type; std::string text; int line; // 行号从 1 开始 int col; // 列号从 1 开始 };给出std::string text是为了报错时能把原始 token 打印出来line和col则是后续语法错误和语义错误定位的关键。为什么不用int枚举值直接开搞因为TokenType用枚举类switch 分支时不会漏而且编译器会帮你检查覆盖情况。词法分析器核心是一个循环class Lexer { public: explicit Lexer(std::string_view src) : src_(src) {} std::vectorToken run() { std::vectorToken tokens; while (!at_end()) { skip_whitespace_and_comment(); if (at_end()) break; Token t next_token(); tokens.push_back(t); } tokens.push_back({TokenType::End, , line_, col_}); return tokens; } private: std::string_view src_; size_t pos_ 0; int line_ 1, col_ 1; // next_token 内部按字符分类进行状态转移 };这个结构里run()负责循环skip_whitespace_and_comment()处理空格、换行、//和/* */注释next_token()对当前字符做分类。为什么要单独抽一个跳过注释的函数因为注释在语言里没有语义但你必须在词法层跳过它否则注释内容会污染 token 流。我踩过的一个坑是块注释未闭合导致整个文件被吞掉。处理方式是在skip_whitespace_and_comment()里检测到/*后持续读直到遇到*/或文件结束若文件结束则报「未闭合注释」错误。数字识别需要小心123是IntConst1.5是FloatConst.5在多数 SysY 变体里不合法。常见做法是读入数字部分后用strtof或自带的浮点解析来判断同时在识别过程中记录当前字符是.还是e/E。我一般用一个小状态机读到数字后继续读下一位如果遇到小数点且后面还有数字则转成浮点 token如果后面没有数字则报错「小数点左右必须有数字」。3.2 递归下降语法分析优先级和左递归怎么处理词法分析结束后语法分析器把 token 流变成抽象语法树。SysY 没有 C 的指针声明优先级问题递归下降写起来非常顺手。表达式部分要处理「算术 比较 逻辑」的优先级每个优先级对应一个函数从最低优先级往下调。常见写法是class Parser { public: explicit Parser(const std::vectorToken tokens) : tokens_(tokens) {} std::unique_ptrast::Expr parse_expr() { return parse_or(); } private: std::unique_ptrast::Expr parse_or() { auto lhs parse_and(); while (match(TokenType::OrOr)) { auto rhs parse_and(); lhs make_nodeast::BinaryExpr(OrOr, std::move(lhs), std::move(rhs)); } return lhs; } std::unique_ptrast::Expr parse_and() { auto lhs parse_eq(); while (match(TokenType::AndAnd)) { auto rhs parse_eq(); lhs make_nodeast::BinaryExpr(AndAnd, std::move(lhs), std::move(rhs)); } return lhs; } std::unique_ptrast::Expr parse_eq() { auto lhs parse_rel(); while (match(TokenType::Eq) || match(TokenType::Neq)) { auto rhs parse_rel(); lhs make_nodeast::BinaryExpr(...); } return lhs; } // parse_rel - parse_add - parse_mul - parse_unary - parse_primary };这里最关键的是左递归问题。add文法如果写成add: add mul直接翻译成parse_add()先递归调用自己会死循环。解决办法是改成add: mul ( mul)*用while循环把左结合的运算收集起来。上面代码已经展示了这种模式先解析右侧的下一优先级再循环处理同一优先级的运算符。这种写法生成的 AST 是左结合的1-2-3会被解析成(1-2)-3而不是1-(2-3)。课程的语义判断题经常拿这个做文章所以要格外注意。比较运算没有链式语法a b c会被解析成(a b) c这在 C 里是合法但通常不符合意图的。SysY 一般也不允许但语法上你不需要刻意禁止后面类型检查发现(a b)是布尔值而Bool不能参与时就可以报错。实战里我在parse_eq和parse_rel之间插入一层去处理高低优先级顺序是or and eq rel add mul unary。3.3 符号表与作用域名字重复会坑在哪个环节语法树构建完成后接下来是语义分析核心是符号表。SysY 的作用域规则和 C 类似块作用域可以隐藏外层同名变量函数作用域里参数也是局部符号。如果只用一个 unordered_map 保存名字到符号的映射在进入{}后会忘不了外层符号导致重复定义的检查失效。实际项目里我建议用「作用域栈」struct Symbol { Type type; // 是 int 还是 float int offset; // 在函数栈帧中的偏移 bool is_global; bool is_const; int array_len; // 数组元素个数-1 表示非数组 }; class ScopeStack { public: void push_scope() { scopes_.emplace_back(); } void pop_scope() { scopes_.pop_back(); } bool declare(const std::string name, Symbol symbol) { auto cur scopes_.back(); if (cur.count(name)) return false; // 重复定义 cur.emplace(name, std::move(symbol)); 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; } private: std::vectorstd::unordered_mapstd::string, Symbol scopes_; };push_scope在进入函数体或复合语句块时调用pop_scope在离开时调用。为什么不用单张 map 加计数器因为lookup需要从内层找到外层栈结构天然支持从顶层往下找找到即返回。重复定义检查只需要看当前作用域不需要管外层。参数传递到这里时一个最常见的出错点是函数参数应该声明在最内层作用域并且它们的offset要从sp正偏移开始算而局部变量从sp负偏移开始算。如果统一用「当前局部变量数量」来算偏移很容易把参数和局部变量放在同一个偏移上运行结果就全乱了。我在第 5 章会专门讲这个。4. 从 AST 到 RISC-VIR 设计、栈帧布局与汇编发射4.1 三地址码 IR指令集设计与临时变量命名语法树直接翻译成汇编会非常痛苦因为 AST 的嵌套结构没法直接对应到寄存器分配。中间先翻译成三地址码一种类似汇编但更抽象的 IR是常见做法。三地址码每条指令最多一个运算符和三个地址比如t1 t2 t3。我使用的 IR 结构大致长这样enum class IROp { LABEL, // 用于跳转目标 ADD, SUB, MUL, DIV, REM, MOV, // 复制 LOAD, // 从数组元素取值 STORE, // 写入数组元素 JMP, JEQ, JNE, JLT, JLE, JGT, JGE, CALL, PUSH_ARG, RETURN, GLOBAL_LABEL, DECL }; struct IRInst { IROp op; std::string dest; // 目标临时变量或标签名 std::string a, b; // 源操作数可以是常数、变量或标签 int imm 0; // 辅助立即数比如数组大小 };临时变量命名我用%t0、%t1这样的字符串。为什么不用整数编号因为后续寄存器分配阶段需要把临时变量以%tN形式输出到汇编注释里字符串更容易 debug。每个函数体翻译时维护一个计数器每新建一个临时变量就递增。LABEL则用L0、L1这类名字避免和源程序里的标签冲突。三地址码的翻译是递归遍历 AST 实现的。以赋值语句sum sum i为例翻译逻辑是先递归翻译右表达式的两个变量得到两个临时变量%t1和%t2生成ADD %t3, %t1, %t2再把%t3传给赋值语句生成MOV sum, %t3。所有临时变量都不进符号表只活在 IR 这一层因此寄存器分配时可以把它们全塞进临时寄存器里或者从栈上 load/store。4.2 栈帧与符号表偏移函数参数和局部变量怎么放SysY 是过程式语言函数调用需要管理栈帧。常用 ABI 里a0-a7传前 8 个参数多于 8 个则压栈但为了简化很多课程编译器选择把前 8 个参数也先压到栈上生成统一布局。我建议这样做进入函数时先保存ra和旧fp然后把sp减小frame_size之后所有参数都在fp的正偏移方向所有局部变量都在fp的负偏移方向。栈帧布局可以整理成一张表内容相对 fp 偏移说明第 n 个参数fp 8 4*n参数从右往左压栈但这里按顺序访问即可返回地址 rafp 4有些变体也放在负偏移处旧 fpfp 0保存调用者帧指针局部变量 0fp - 4第一个声明的局部变量局部变量 1fp - 8第二个依次向下在符号表里记录offset就是相对于fp的值。这样访问变量时如果offset 0就用fp offset否则用fp - abs(offset)。特别要注意frame_size必须按 16 字节对齐否则调用约定要求 sp 在函数调用边界保持 16 字节对齐qemu 模拟时一般不报错但实际硬件上会出问题。计算frame_size的方法是遍历函数的符号表把所有局部变量占用的字节数加起来再加上保存ra和fp需要的 8 字节最后向上对齐到 16。注意数组在这里的计算方式和标量不同int a[10]占 40 字节不是 4 字节。4.3 发射 RISC-V 汇编一个 return 背后的 5 条指令IR 翻译到汇编时核心是把每个 IR 操作映射成 RISC-V 指令序列。最简单的映射是先把每个变量从栈上加载到临时寄存器运算后再存回去。这样不会产生复杂的寄存器分配问题正确性优先性能后面再优化。示例函数int foo(int x) { return x 1; }的汇编骨架.text .globl foo foo: addi sp, sp, -16 # 分配栈帧16 字节对齐 sw ra, 12(sp) # 保存返回地址 sw s0, 8(sp) # 保存旧帧指针 addi s0, sp, 16 # 建立帧指针 sw a0, 0(s0) # 把第一个参数 x 保存到栈上 lw t0, 0(s0) # 取出 x addi t1, t0, 1 # t1 x 1 mv a0, t1 # 返回值放入 a0 lw s0, 8(sp) # 恢复旧帧指针 lw ra, 12(sp) # 恢复返回地址 addi sp, sp, 16 # 释放栈帧 jalr zero, ra, 0 # 返回调用者这段汇编是整个后端的模板。你可以看到return不是一条指令而是一条 mov 加一段标准的函数结尾。如果忘记恢复s0或ra调试时会发现上一个函数的栈帧被破坏错乱得很难查。我一般把这段 prologue/epilogue 封装成emit_prologue(frame_size)和emit_epilogue(frame_size)每个函数生成时调用避免在一堆指令里重复粘贴。将 IR 翻译成这段汇编时还有一点特别需要注意IR 里的CALL指令必须区分是内建getint还是普通函数。内建函数可以直接调用链接进来的 sysy 库函数而普通函数需要在每个调用点之前推送参数到a0-a7。如果你的 SysY 变体允许函数嵌套调用务必在调用前把ra保存好否则jal会覆盖返回地址。5. 避坑与排查我在这个课程实践中踩过的 5 个坑5.1 编译器未包含 main 类型入口函数为什么会被漏掉现象编译器生成汇编后用交叉gcc链接时报错类似undefined reference to _start或main没有被识别为入口函数。更典型的是报「编译器未包含 main 类型」其实就是语义分析阶段没有检查到合法的main。原因词法或文法层面只处理了普通函数定义没有对main做特殊约定。有的情况是符号表里把main的返回值类型登记成了void而 SysY 规定入口函数应该是int main()。还有人在生成汇编时忘了在函数名前面加.globl导致链接器看不到符号。解决在 AST 转 IR 之前增加一层「程序入口检查」遍历所有函数定义必须存在名为main且返回类型为int、参数为空的函数。同时编译器要向符号表登记main并在汇编输出阶段给它的标签加.globl main。链接时还需要提供_start如果工具链没有自动带就加一个crt0或者链接-lc -lgloss。最省事的是用riscv64-unknown-elf-gcc -static它会默认带上标准启动文件。5.2 栈上偏移算错寄存器值看起来对变量值却总被覆盖现象程序能运行但某些全局/局部变量在调试时显示预期值一旦发生函数调用回来之后值变成垃圾。典型例子是递归函数第一次调用正确第二次开始返回值错乱。原因把函数参数和局部变量都按同一个「当前栈顶偏移」来分配没有给每个函数固定 frame。函数调用时新函数的栈帧覆盖了旧函数的返回地址或局部变量导致旧函数的s0被冲掉。解决在符号表设计里明确区分参数和局部变量。参数在fp正偏移局部变量在fp负偏移。每次进入函数时先分配frame_size而frame_size在函数翻译之前就要计算好。如果你偷懒不想算先把所有局部变量统一用sp相对负偏移也可以但必须在每次写变量前把sp调整为函数入口后的固定值。建议在第 4 章栈帧表的基础上写一个单元测试一个递归计算阶乘的 SysY 程序跑一遍能正确处理 5 的阶乘栈帧管理基本就对了。5.3 输出汇编无法被 gcc 编译格式细节比逻辑更磨人现象编译器能吐汇编但一交到 gcc 就报unexpected token at start of operand或者relocation truncated甚至unknown pseudo-op: .globl:. 这也是一部分人最终在课程答辩翻车的地方。原因一方面把 MIPS 或 x86 的$0/%eax带进了 RISC-V 语法另一方面标签生成规则不对比如在普通变量名后面直接加冒号导致标签名和寄存器名冲突。还有就是.word和.align用错了。解决开一个文本调试入口把你生成的汇编保存下来用 gcc 的-v或直接查看报错行逐个对照。RISC-V 汇编标签不能以数字开头临时标签最好统一叫L1:这种格式。寄存器名用a0、sp、t0不要带前缀。伪指令.globl main在主函数和全局函数前都要加.开头一定在行首。最稳妥的方式是先把一个手写的hello.s编译通过再用你的编译器生成同一逻辑的hello.s做逐字节 diff。5.4与||短路求值变成「两边都算」现象if (a ! 0 10 / a 2)在a 0时没有报除零错误但程序竟然崩溃或者一个函数调用被错误地执行了两次。原因没有处理短路语义。直接把翻译成「算左边、算右边、再 AND」这样两个操作数一定都会被求值和 C/SysY 的语义不一致。正确的做法是左边为假时整个表达式为假不需要求值右边同理||左边为真时结果为真不求值右边。解决在 IR 生成阶段遇到时生成两个标签和一个跳转。例如a b生成先求 a如果为假跳转到L_false再求 b为假跳转到L_false否则L_true。||反过来a 为真跳L_trueb 为真跳L_true否则L_false。不要把它们当成普通and指令否则副作用测试和浮点除零测试全部翻车。这个坑很隐蔽但答辩时考官大概率会拿一个带副作用的样例来问。5.5 实践报告写成说明书答辩时最怕被问「怎么验证」现象报告用大篇幅贴源代码把每个类从头介绍到尾但没有说明设计取舍、没有测试覆盖、没有实验数据。答辩时考官问「你的编译器对浮点常量是个什么处理策略」你只能说「我实现了」然后场面会很冷。原因把「实践报告」理解成「源码注释汇总」。实际上课程实践报告需要体现的是「你为什么这么做、遇到了什么问题、如何验证」。解决报告至少包含三块设计动机为什么选三地址码而不是直接汇编、实验环境与工具链版本、正确性验证回归测试脚本、测试用例清单、运行结果对比。我在最后写报告前会做一个测试矩阵每个语法特性数组、嵌套函数调用、短路、递归对应一个.sy文件和期望退出码把结果做成表格。答辩时直接展示这个表格比念源码有用十倍。6. 用回归脚本锁死正确性再把 IR dump 当成后悔药验证工作不能靠「跑一个例子看结果对不对」而是要有一个可复用的回归脚本。我习惯在项目根目录放一个run_tests.sh格式大致是这样#!/usr/bin/env bash set -euo pipefail CASES_DIRtests/cases OUT_DIRtests/out mkdir -p $OUT_DIR for sy in $CASES_DIR/*.sy; do name$(basename $sy .sy) ./build/sysyc $sy $OUT_DIR/$name.s riscv64-unknown-elf-gcc -marchrv32im -mabiilp32 \ -static -o $OUT_DIR/$name $OUT_DIR/$name.s $SYSLIB qemu-riscv32 $OUT_DIR/$name /dev/null 21 got$? expected$(cat $CASES_DIR/$name.ans 2/dev/null || echo 0) if [ $got -eq $expected ]; then echo PASS: $name else echo FAIL: $name (got $got, want $expected) exit 1 fi done这个脚本把「编译、汇编、链接、运行、比对返回值」做成一个动作。每个新功能提交前跑一遍旧功能没被弄坏新功能也过了这是低成本拿高分的习惯。注意脚本里的$SYSLIB一般是课程提供的 sysy 运行时库如果没有就留空。比回归更省心的调试技巧是给编译器加一个--dump-ir参数。在 IR 生成后把所有指令打印到标准错误保留了中间表示就相当于有了后悔药汇编不对时先看 IR 对不对。如果 IR 对而汇编错问题在发射阶段如果 IR 已经错问题在 AST 或 IR 翻译阶段。这个二分法能帮你省下一半的调试时间。后来我给这个项目做了一个小优化在 IR 层做常数折叠。3 4直接变成7i i 0直接变成i i。代码改动不大但测试时看到生成的汇编少了几条指令很有成就感。再往后可以尝试局部寄存器分配把lw sth; add; sw简化为复用临时寄存器但这属于锦上添花。我个人的教训是先把不做任何优化的完整链路跑稳再把优化当独立功能加。如果一开始就急着写寄存器分配你会在「不知道正确结果是什么」的情况下调试出一堆看似正确的高性能代码。希望帮到你。本文还有配套的精品资源点击获取