SysY到RISC-V编译器实战:从AST到Koopa IR再到汇编的完整课设解析
简介这是一份面向编译原理课程实践与期末大作业的完整项目基于C实现SysY语言到RISC-V的编译器并配套实践报告。源码覆盖词法/语法分析、AST抽象语法树构建、Koopa中间表示与RISC-V目标代码生成等核心模块前端与后端模块划分清晰代码注释完善即使是初学者也可借助说明文档快速读懂并部署运行。压缩包共27个文件以hpp10个和cpp6个源码为主体辅以词法/语法规则文件、中间表示与汇编输出、测试用例、CMake构建脚本及说明文档整体仅108KB。已有136人学习下载适合需要完成编译原理课程设计或冲击高分大作业的开发者通过分模块目录、VS Code调试配置与报告中的实现解析可以系统性掌握从SysY到RISC-V的完整编译流程并可作为后续扩展实验的起点。1. 这套SysY到RISC-V编译器把编译原理课设从“背理论”变成“能跑完的工程”如果你正在做编译原理期末大作业大概率已经翻过好几篇讲词法分析、语法分析、中间代码生成的文章看完还是不知道从哪里下手。这套基于C实现的SysY到RISC-V编译器项目最大的价值不是“讲解”而是一条完整的、可运行的编译链路从sysy.l词法、sysy.y文法到AST构建、Koopa IR生成再到RISC-V汇编输出每一步都有对应源码和注释还附课程实践报告。它不是玩具Demo而是能真正把SysY代码编成RISC-V汇编、能在模拟器上跑出结果的完整实现。适合两类人一类是时间紧、需要一份能看懂能改的课设基线另一类是已经写完大部分、想对照检查自己架构和边界处理的同学。接下来我会按前端、中间表示、后端三条线拆开讲最后把最容易翻车的几个坑单独拎出来。2. SysY前端与AST从词法规则到语法树的搭建2.1 SysY是什么一个被刻意裁剪的C子集SysY是编译原理课程里常见的一种教学语言它在C语言基础上做了大幅度裁剪只保留int和float两种基本类型支持一维数组函数支持int、float和void返回类型控制流只保留if/else、while、for这三种。裁剪的目的是让课程设计重点落在编译流程本身而不是跟复杂的C语法搏斗——比如预处理指令、结构体、指针、类型隐式转换这些SysY统统不要。好处是你在设计AST节点时可以少考虑好几层继承关系坏处是你在写代码时要自己约束输入不能光靠语法分析器兜底。这个项目的前端分三层。第一层是sysy.l用flex写词法规则负责把源码切成token流第二层是sysy.y用bison写文法做语法分析的同时构建AST第三层是ast相关代码定义AST节点的数据结构后面Koopa IR生成阶段会直接遍历它。这三个文件是编译器的“入口三件套”也是你答辩时最容易讲清楚的部分。2.2 词法规则sysy.l里那些容易被忽略的细节打开sysy.l你会看到几类规则关键字return、if、else、while、for、const、标识符、整数常量、浮点常量、运算符和分隔符。这里有个关键设计SysY要求词法分析器区分int和float常量——比如1是int1.0是float这直接影响后面AST节点的类型推断。flex的规则是“最长匹配优先”所以123abc会被切成整数123和标识符abc而不是报错——这也是SysY本身不做类型绑定检查、留给语法分析阶段去管的原因。const { return CONST; } return { return RETURN; } if { return IF; } else { return ELSE; } [0-9] { yylval.int_val atoi(yytext); return INT_CONST; } [0-9].[0-9] { yylval.float_val atof(yytext); return FLOAT_CONST; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str_val strdup(yytext); return IDENT; }这段规则里所有token的语义值都挂在yylval上由bison文法层取用。注意[a-zA-Z_][a-zA-Z0-9_]*这个标识符规则要放在关键字规则后面因为flex按最长匹配且相同长度时先匹配先声明的那条规则——如果把IDENT放在前面关键字永远匹配不到。这是一个很容易踩的顺序坑后面避坑章会再说实际翻车的场景。2.3 语法分析sysy.y里的优先级与AST构建sysy.y的核心是文法产生式。项目里把表达式分为主表达式、一元表达式、二元表达式、赋值表达式几个层级通过bison的优先级声明解决乘除和加减的先后而不是在文法里硬套多层非终结符。比如%left ADD SUB %left MUL DIV MOD %left LAND LOR %right ASSIGN Exp: PrimaryExp | Exp ADD Exp { $$ createBinaryExprNode(kAdd, $1, $3); } | Exp MUL Exp { $$ createBinaryExprNode(kMul, $1, $3); } | Exp ASSIGN Exp { $$ createBinaryExprNode(kAssign, $1, $3); } ;%left表示左结合越靠后的优先级越高所以MUL DIV MOD声明在ADD SUB下面意味着乘除优先于加减。%right ASSIGN让赋值右结合符合C语言的语义。每个产生式后面的{ $$ ...; }就是在做AST节点的自底向上拼接。这一步完成后main.cpp会拿到一棵完整的AST树接下来就是遍历它生成中间表示。如果你要扩展这个编译器比如加入逻辑非!或者自增改文法时务必同时检查优先级声明和AST节点的构造逻辑这两处是分开的改漏一处就会出现“解析成功但IR生成崩溃”的诡异现象。2.4 AST设计为什么用单一结构体而不是多态继承看了ast相关的代码你会发现项目没有用C的多态来设计AST节点体系而是用了一个带Kind枚举和union/结构体数组的紧凑结构。每个节点统一包含kind、type、children指针数组、int_val、float_val、str_val这几个字段。这种设计看起来“不面向对象”但在编译器前端里很实用遍历时一个switch(kind)就够了不需要accept()虚函数表生成IR和将来做优化遍历时也能直接拿到全部信息。struct AstNode { NodeKind kind; // kIntConst / kFloatConst / kAddExpr / kIfStmt ... NodeType type; // kTypeInt / kTypeFloat / kTypeVoid int int_val; float float_val; const char* str_val; // 标识符名字 vectorAstNode* children; };我一般习惯在AST节点上额外存一个line_no字段用来在语义错误时报出行号。这个项目里没存但你在扩展时强烈建议加——因为Koopa IR生成阶段一旦发现“变量未定义”或“类型不匹配”没有行号的话调试成本直接翻倍。3. 符号表与Koopa IR生成中间表示是怎么一点点拼出来的3.1 Koopa IR是什么为什么选它而不是直接出汇编SysY到RISC-V的直编是可行的但如果你见过直编出来的代码就知道一个赋值语句a b c * 2在RISC-V里要拆成load、li、mul、add、store七八条指令且要手动管理临时寄存器稍不留神就寄存器冲突。项目里引入了Koopa IR作为中间表示它是一个精简的SSA风格IR指令按基本块组织每个基本块必须以jump或branch或ret结尾。之所以选Koopa是因为它比LLVM IR简单得多——只有十几种指令类型没有phi节点Koopa通过开头的全局符号和%开头的局部寄存器区分特别适合课程设计周期。先看一眼项目里hello.koopa是什么样的fun main(): int { %entry: %0 alloc i32 store 0, %0 %1 load %0 ret %1 }这个结构很直观alloc分配局部变量空间store写值load读值ret返回。所有局部“寄存器”统一用%数字命名全局函数用命名。理解Koopa IR的最好方式就是在代码生成时盯着这个文件看边生成边对照源程序哪里多了少了立刻能发现。3.2 符号表的组织作用域嵌套与同名遮蔽项目里symbol_list.hpp维护符号表。它不是一张平铺的map而是按作用域分层的结构每个函数对应一个顶层作用域每进入一个复合语句块Block就压入一层新的作用域退出时弹出。做int a 1; { int a 2; }这种同名遮蔽时查符号表从最内层往外找找到即止。struct SymbolEntry { string name; SymbolType type; // kVar / kFunc / kParam DataType data_type; // kInt / kFloat bool is_const; int offset; // 在当前栈帧里的偏移 AstNode* init_node; // 初始值对应的AST节点 }; class SymbolTable { vectormapstring, SymbolEntry scopes; public: void push_scope() { scopes.emplace_back(); } void pop_scope() { scopes.pop_back(); } void insert(const SymbolEntry entry) { scopes.back()[entry.name] entry; } SymbolEntry* lookup(const string name) { for (auto it scopes.rbegin(); it ! scopes.rend(); it) { auto found it-find(name); if (found ! it-end()) { return found-second; } } return nullptr; } };这段代码把作用域栈做成了一个二维结构外层vector是作用域层级内层map是当前作用域里的符号表。push_scope()进入代码块前调用pop_scope()在代码块结束时调用。查表从最内层往最外层扫描保证同名变量在内层生效、外层不受影响。参数说明rbegin()到rend()是反向迭代器让内层作用域优先命中insert故意不做重复检查——重复声明交给语义分析阶段处理符号表只负责存储和查找职责分离后调试更省心。3.3 builder是核心AST到Koopa IR的递归下降生成builder.cpp是IR生成的主力它的基本逻辑是从AST根节点开始递归遍历每个节点对应一个生成函数。以return语句为例生成过程分为三步先递归生成返回值表达式的IR指令拿到表达式结果所在的临时变量然后生成ret指令。项目里最精妙的是BlockMaintainer和LoopMaintainer这两个类——它们分别记录当前正在生成的基本块编号以及当前循环的continue_bb和break_bb。为什么需要这两个维护器因为Koopa IR不允许无组织的jump。你生成while(1) {}时条件跳转必须有个明确的target块而这个target必须由循环维护器在进入循环时就提前分配。假如你写一个for循环想翻译continue正确顺序应是进入循环前创建cond_bb、body_bb、update_bb和end_bb四个块条件跳转到cond块body块结尾跳回updatecontinue语句跳到update块而不是body块开头——否则更新表达式就被跳过了。LoopMaintainer就是负责记住这些跳转目标让continue和break能取到正确的块号。3.4 一个实际生成例子全局常量与局部变量怎么落看sysy.l和builder的配合你会发现系统对全局常量和局部变量的处理是分开的。全局逻辑先处理顶层const声明生成Koopa IR里的global const和对应的常量数据局部变量则在函数入口统一alloc。常见做法是进入函数体时先扫描一遍所有局部变量声明连续分配多个alloc并把符号表里的offset依次编号——这样做的好处是寄存器分配阶段不需要追溯变量第一次使用的位置。void Builder::build_func(AstNode* func_node) { symbol_table.push_scope(); // 先创建函数入口基本块 int entry_bb current_module-new_block(); block_maintainer.push_block(entry_bb); // 扫描所有局部变量并alloc for (auto child : func_node-children) { if (child-kind kVarDecl) { string var_name child-str_val; KoopaInst alloc_inst new_inst(kAlloc); symbol_table.insert({var_name, kVar, child-data_type}); var_offset_map[var_name] symbol_table.lookup(var_name)-offset; } } // 递归生成函数体 for (auto child : func_node-children) { if (child-kind ! kVarDecl) { build_stmt(child); } } symbol_table.pop_scope(); }这段代码里有个值得注意的点build_func先把所有变量声明扫描完并统一alloc再处理可执行语句。这是SSA风格IR的通行做法让所有局部变量在进入函数时就分配到稳定位置后续生成的指令只需要引用%数字寄存器不用关心变量是否已初始化。如果换成“遇到声明才alloc”那if分支里定义的变量在分支结束后就会失效语义上会出问题。实际生成中你会看到builder大幅使用koopa_util.cpp里的工具函数把kAddExpr映射为add指令、kMulExpr映射为mul指令。映射规则很简单二元运算节点展开成两个操作数的求值再生成一条算术指令引用结果。但要注意kAssignExpr在Koopa里没有对应的“赋值指令”——它是通过store来实现的。也就是说a b 1会被翻译成先算b 1得到%t1然后store %t1, %a。理解这个映射关系后你自己写后端时就不会在“怎么把赋值翻译成IR”上卡住。4. 从Koopa到RISC-V指令选择、栈帧与寄存器分配4.1 指令映射表先建立一张一一对应的翻译表RISC-V后端的基本功是把Koopa IR指令逐条翻译成RISC-V汇编。这张映射表是后端的骨架建议你把它打印出来贴在显示器旁边。alloc对应栈空间的分配在函数栈帧里预留4或8字节store对应sw/sdload对应lw/ldadd对应addmul对应mulsub对应subjump对应jbranch对应beq/bne。看起来很简单真正麻烦的是操作数Koopa里的%0是虚拟寄存器RISC-V里得映射到真实物理寄存器或者栈内存位置。项目采用的做法是临时值直接映射到调用者保存寄存器t0–t3超出范围就压栈。这个策略适合课程设计因为循环深度一般不会超过4层t0–t3基本够用。你要是图省事全部压栈也没错只是生成的汇编性能惨不忍睹答辩时容易被问“你的寄存器分配做了优化吗”——那就尴尬了。所以至少做个简单的临时值到t0–t3的映射性能上好看很多。4.2 函数调用与栈帧ABI约定是绕不过去的硬骨头函数调用是后端里最容易出bug的部分因为涉及栈指针调整和参数传递。RISC-V的调用约定参数通过a0–a7传递返回值放a0返回地址放ra寄存器。函数开头要把ra和旧sp压栈再分配局部变量空间函数结尾恢复栈指针、弹出ra最后ret。看项目里builder对函数调用的处理生成call fun(%arg1, %arg2)时会先把实参按顺序load到a0、a1然后执行call指令。# 函数入口 addi sp, sp, -32 sw ra, 28(sp) sw fp, 24(sp) addi fp, sp, 32 # 分配局部变量空间 addi sp, sp, -16这段汇编的栈帧布局是课程设计里的常见做法栈向下增长sp指向栈顶fp作为栈帧基址。本地变量访问一律用fp偏移寻址比如sw t0, -8(fp)。为什么要用fp而不是sp因为函数体内还可能进一步调整sp比如调用子函数时要保存临时值如果全程拿sp做基址偏移量就会动态变化容易把变量地址算错。用fp固定基址后每个变量在栈帧里的偏移是编译期确定的稳。4.3 分支与循环基本块跳转在汇编里怎么落Koopa IR里的branch cond, bb1, bb2翻译成RISC-V涉及一条比较加跳转。先load条件值到某个寄存器然后beqz跳向false块、bnez跳向true块。课程设计里通常不会对条件做处理器级优化——比如不会用bgt直接实现比较而是先sub再检查符号位。原因是Koopa的branch指令只认i32非零即真翻译时保持简单直接反而更容易验证正确性。# 等价于 if (a b) { ... } else { ... } lw t0, -4(fp) # load a lw t1, -8(fp) # load b slt t2, t0, t1 # t2 (a b) ? 1 : 0 beqz t2, .L_false # 条件为假跳false分支 # true分支代码 j .L_end .L_false: # false分支代码 .L_end:注意Koopa IR对branch的语义是“真值即可”它不关心条件是等式还是不等式。所以算完比较结果之后临时值可能是0或1beqz/bnez判断它是否为0就够了。这比直接在IR层保留lt、gt指令的做法简单得多代价是多了一条slt指令——课程设计完全能接受。4.4 生成代码的组织koopa_util和main.cpp各负责什么main.cpp是总控读SysY源文件调用flex/bison解析得到AST实例化Builder生成Koopa IR再把IR交给后端生成RISC-V汇编。koopa_util.cpp这一层封装了IR指令的构造和输出比如create_kphi()、dump_block()、format_inst()这些函数。因为Koopa IR有两种表达形式——“文本形式”和“内存形式”koopa_util负责把内存形式格式化成文本输出。你在跑通后如果想把IR可视化看结构大概率要改的就是这个文件。后端生成的hello.s是RISC-V汇编可以用riscv64-linux-gnu-gcc编译成目标文件再用qemu-riscv64运行。项目中test目录里自带hello.c、hello.koopa、hello.s三个对照文件强烈建议你按这个顺序依次打开看一遍先看C源码再看IR最后看汇编三步一对照整个编译链路就通了。5. 避坑记录我在课程设计里替你先踩的五个坑5.1 坑一flex规则顺序错误关键字全部变成标识符现象源码里明明写了return但语法分析阶段全部报syntax error而且报错位置指向文件第一行跟实际错误行完全对不上。原因把IDENT的规则写在了关键字规则前面。flex优先匹配最长匹配但长度相同时按声明顺序优先——关键字和标识符模式都能匹配到return时先声明的[a-zA-Z_][a-zA-Z0-9_]*获胜导致关键字被识别成普通标识符。解决把关键字规则全部放在IDENT之前。这条经验我后来在任何用flex的项目里都先检查——先定关键词后写通配标识符。5.2 坑二bison报syntax error却不告诉你错在哪个产生式现象输入一个合法的SysY程序bison却报syntax error没有任何上下文信息项目里找不到任何调试输出。原因bison默认的错误报告只定位token不定位具体栈状态。你需要#define YYDEBUG 1并在main.cpp里调用yydebug 1;才能看到解析器每一步的移进规约栈。解决在main.cpp里加上yydebug 1;然后重新编译跑一次看日志里是规约到哪个产生式时失败的。大部分语法错误是某个表达式缺少;、或者else匹配歧义导致的debug日志一眼就能看出来。5.3 坑三Koopa基本块结尾缺少ret或jump后端生成空汇编现象编译器不报错但生成的.s文件里函数体是空的直接跳到ret运行时什么也不执行返回0。原因Koopa IR要求每个基本块必须显式终结ret或jump。当你处理if分支时如果某个分支块在自己的生成流程里没有在块尾补jump .L_end后端遍历这个块时发现没有终结指令就跳过整块不输出。解决在builder的分支生成逻辑里对每个基本块的最后一条指令做检查——如果不是ret、jump、branch就补一条jump到后继块。我把它做成一个断言函数生成IR时自动检查比事后看空汇编高效得多。5.4 坑四符号表作用域没对齐循环外引用循环内变量现象while循环里定义了一个临时变量循环结束后读写该变量编译器不报错运行时却随机改内存或段错误。原因符号表作用域搬移和基本块维护器没同步。循环体结束触发pop_scope()变量的栈帧偏移被释放但IR里的临时寄存器仍然引用它后端生成了有效的load地址——指向的却是已经失效的栈空间。解决查loop_maintainer.hpp里循环体的作用域边界循环体虽然是新开基本块但它的作用域应该跟函数体保持一致循环变量定义后有效期自然延续到循环结束、而不是循环体块结束。这个坑的根治办法是画一张作用域范围和基本块的对应图再对照代码检查每一处push_scope()和pop_scope()是否配对。5.5 坑五汇编里%号被吞riscv64交叉编译直接报错现象编译器生成汇编成功但用riscv64-linux-gnu-as编译时报错“Illegal instruction”查看.s文件发现寄存器名变成了t0前面多个%或者相反少了一个%。原因Koopa IR里的虚拟寄存器用%0表示后端输出汇编时要把%0映射成物理寄存器但格式字符串写成了%s导致%被原样保留而t0本身是合法的寄存器名汇编器把%%t0当成了非法文件名或非法指令。解决统一封装一个寄存器名转换函数只用它输出汇编里的寄存器名禁止在格式串里直接写%。从那以后我每次在IR到汇编的转换层加了断言凡是输出中仍带%开头且后接数字的token立即中断并提示未映射寄存器。6. 验证方法用交叉编译、模拟器和最小用例给你最后一层后悔药最后的成品如果不验证你永远不知道是“编译器写对了”还是“恰好没触发错误路径”。我这里有一套自己反复用的验证流程从最小用例到完整SysY程序分四步走每一步都能精确定位问题出在哪个阶段。先准备交叉编译工具链。如果你在Ubuntu里安装riscv64-linux-gnu-gcc和qemu-user即可。注意qemu-user是非系统模式的用户态模拟器能直接跑RISC-V可执行文件不需要整块虚拟磁盘。验证一个hello.s的完整命令是先用交叉汇编器生成.o再用交叉gcc链接需要加-static避免动态链接器缺失最后qemu运行。如果只是快速看汇编指令对不对可以跳过链接直接riscv64-linux-gnu-objdump -d hello.o反汇编逐个核对。riscv64-linux-gnu-gcc -O0 -static -o hello hello.s qemu-riscv64 hello echo $?echo $?会打印程序的退出码——对应SysY程序的return值。这一步是终极验证如果return 42;编译出来的程序退出码是42你的后端整体逻辑就通了。如果返回值不对优先怀疑栈帧布局和load/store的偏移量其次怀疑ret指令是否错误地使用了a0之外的值。我的验证顺序永远是先空main只ret 0再return常量再return常量表达式比如2*31再引入变量和赋值最后上if和while。每走一步都用qemu双重验证——编译后运行一次看退出码再读一下生成的汇编手动对照逻辑。这样做的好处是每一步出问题都只有一个变量不会出现“三个功能同时报错但不知道是谁的锅”的绝望场景。最后一层技巧是拿clang当参照物。搞一份同款SysY源码用clang指定--targetriscv64生成RISC-V汇编跟你的编译器输出逐段对比。两边对不齐的地方多半是你没处理清楚的边界——比如函数调用时sp要对齐到16字节、ra保存的时机、以及浮点参数是否错误地走了通用寄存器。clang生成的汇编虽然经过了它的优化但指令序列的结构和栈帧布局是教科书级别的对照一遍胜过读十篇博客。我自己的习惯是每加一个语法特性就把这五步验证走一遍坚持下来之后后端bug的定位时间从半小时缩短到五分钟。希望这套方法也能帮到你少熬几个用printf堆调试信息的夜。本文还有配套的精品资源点击获取