基于C++的SysY到RISC-V编译器:从词法分析到代码生成全解析

发布时间:2026/10/26 15:56:12
基于C++的SysY到RISC-V编译器:从词法分析到代码生成全解析 简介基于C实现的SysY到RISC-V编译器项目面向编译原理课程设计与期末大作业场景适合需要完成同类课题的本科生及入门学习者。资源含完整工程源码与实践报告代码附带注释词法分析、语法分析、AST构建、符号表管理、中间代码生成与RISC-V目标代码输出等编译流程环节均有清晰实现新手也能较快上手复现。压缩包共27个文件以hpp/cpp源码文件为主辅以yacc/flex语法词法定义、koopa中间表示、RISC-V汇编样例、CMake构建配置及README说明文档整体仅108KB结构紧凑便于部署调试。已有136人学习下载是期末大作业与课程设计的高分参考范例。项目目录按src、ast、builder等模块划分结构清晰下载后可获得可编译运行的编译器前端与后端实现配合实践报告与测试示例能直观理解SysY语言到RISC-V的完整编译流程也便于后续扩展与二次开发。1. 编译原理课程实践不是天书这个SysY到RISC-V的C编译器项目到底在做什么编译原理课程实践最难的不是某个算法而是把词法、语法、语义再到RISC-V代码生成这条流水线一口气串起来。这个基于C实现的SysY到RISC-V编译器项目恰好把一台真实编译器最核心的部分压缩到一个学期能做透的范围内。SysY是精简过的类C教学语言RISC-V则是最容易手写代码生成的现代指令集。这篇笔记写给两类人正在为编译原理实验选题的学生和想补编译器开发基本功、却被LLVM源码劝退的工程师。我会按工程顺序讲清SysY语言边界、C模块怎么切、递归下降怎么写、寄存器分配的课设级做法以及那些查半天才发现是玄学的坑。2. 动手前先搭好地基SysY的语言边界与C工程模块怎么切2.1 SysY不是C语言全家桶一张表划清能写什么不能写什么第一次接触SysY的人最常犯的错是把它当成C语言来写测试程序。SysY是C的一个子集支持范围是经过设计的类型系统只保留int、float、void复合类型只有数组控制流只保留if-else、while、do-while、for外加break、continue、return。函数可以定义和调用IO通过内建的putint、getint这类形式提供不需要包含头文件。支持不支持int / float / voidstruct / union / enum一维及多维数组指针数组参数退化为地址传递if-else / while / do-while / forswitch-case / goto算术、关系、逻辑 || !位运算部分定义支持以课程文法为准函数定义与传参、返回函数重载、默认参数、模板putint / getint / putch 等内建IOfopen / printf 等标准库这张表最大的价值是帮你控制测试范围。很多人花大量时间去适配不存在的特性比如试图让编译器通过一个用了结构体的测试——根本不在SysY定义里。做课程实践前先把老师给的SysY文法精读一遍哪些终结符、哪些优先级、哪些保留字直接决定了词法和语法分析器的工作量。另一个容易混淆的点编译器和编辑器是两回事。编辑器帮你写代码编译器把你写的代码变成目标机器码SysY编译器没有交互界面它读一个.sysy文件吐出一个.riscv汇编文本。别想着在IDE里直接“运行”它它的产物是汇编不是可执行文件。2.2 六模块加ASTC工程头文件划分与AST节点设计把编译器拆成六个模块是课程实践最稳的做法词法分析器Lexer、语法分析器Parser、抽象语法树AST、符号表SymbolTable、中间代码IR生成、目标代码CodeGen生成。IR这一层可以根据课设要求取舍很多SysY课设允许直接由AST生成RISC-V汇编跳过三地址码但我建议保留一个极简IR哪怕只是线性指令序列也能让代码生成和优化好写很多。// include/lexer.h #ifndef SYSYCC_LEXER_H #define SYSYCC_LEXER_H #include string #include vector #include cstdint enum class TokenKind { Ident, // 标识符 IntConst, // 整型常量 FloatConst, // 浮点常量 Int, Float, Void, Return, If, Else, While, Do, For, Break, Continue, // 关键字 Plus, Minus, Star, Slash, Percent, // 算术 - * / % Assign, Equal, NotEqual, Less, LessEq, Greater, GreaterEq, // 比较 ! And, Or, Not, // 逻辑 || ! Semicolon, Comma, LParen, RParen, LBracket, RBracket, LBrace, RBrace, // ; , ( ) [ ] { } End // EOF }; struct Token { TokenKind kind; std::string text; int line, col; bool isFloat; // IntConst / FloatConst 区分标记 union { int ival; float fval; } value; }; class Lexer { public: explicit Lexer(const std::string src) : src_(src) {} std::vectorToken run(); // 扫描出全部Token private: const std::string src_; size_t pos_ 0; int line_ 1, col_ 1; }; #endifToken结构里用union装整型或浮点值用isFloat标记避免词法阶段就把类型推断做死。int和float都是32位恰好占同一个union的大小。这里有个C的新手坑union里的非平凡类型不能随便放但int和float都是平凡类型放心用。enum class是C11引入的强类型枚举不会像C enum那样隐式转int。写完词法后建议单独写一个小程序把Token序列打出来验证常见实现是加一个dump函数。SysY没有字符串类型所以这里不需要处理字符串字面量——不少同学一上来就写字符串识别逻辑其实完全用不上。AST节点可以直接用带虚函数的class层级比如Stmt为基类派生出AssignStmt、IfStmt、WhileStmt、ReturnStmt、BlockStmt表达式同理派生BinaryExpr、IntExpr、FloatExpr、VarExpr、CallExpr。每个节点重载一个dump()用来打印AST这是语法分析阶段最有效的调试手段。实践报告不是博客最好按课程要求的章节来写需求分析、总体设计、关键模块实现、测试结果、问题与思考。最容易出彩的是问题与思考把第4章里那些翻车现场如实写进去比堆原理更有说服力。报告里的模块图要和你实际的代码结构一致别拿网上别人的图充数。2.3 构建工程gcc加CMake的最小配置工程骨架建议用CMake而不是手写Makefile。课程实践一般不大CMake三五行就能描述清楚而且跨平台一致。我的最小CMakeLists.txt长这样cmake_minimum_required(VERSION 3.16) project(sysycc LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(sysycc src/main.cpp src/lexer.cpp src/parser.cpp src/ast.cpp src/symbol.cpp src/codegen.cpp ) target_compile_options(sysycc PRIVATE -Wall -Wextra)构建命令mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPEDebug make -j$(nproc) ./sysycc ../test.sysy参数说明CMAKE_BUILD_TYPEDebug会带上-O0和-g方便后续在gdb里查段错误-j$(nproc)在macOS上要换成sysctl -n hw.ncpu。如果你刚开始配置环境先把gcc编译器下载安装到本机——Debian/Ubuntu用apt install gcc gmacOS用xcode-select --installWindows推荐MSYS2的ucrt64工具链。习惯在VSCode里写C的话把C/C插件配置好tasks.json里填上build目录下的make命令即可。注意target_compile_options里的-Wall -Wextra在MinGW/MSYS2环境下也适用。编译器的warning要在早期当成error处理我见过最典型的翻车是-和.混用导致AST访问越界开-Wall后能少走很多弯路。2.4 手写词法分析器为什么别急着上Flex课程实践里用Flex生成词法分析器当然快但那是“使用工具”不是“理解原理”。SysY的token种类撑死三十多个用手写扫描器半天就能写完而且每一步都在你的掌控里。// src/lexer.cpp #include lexer.h #include cctype #include unordered_map static bool isIdentStart(char c) { return std::isalpha(c) || c _; } static bool isIdentPart(char c) { return std::isalnum(c) || c _; } static const std::unordered_mapstd::string, TokenKind kKeywords { {int, TokenKind::Int}, {float, TokenKind::Float}, {void, TokenKind::Void}, {return, TokenKind::Return}, {if, TokenKind::If}, {else, TokenKind::Else}, {while, TokenKind::While}, {do, TokenKind::Do}, {for, TokenKind::For}, {break, TokenKind::Break}, {continue, TokenKind::Continue} }; std::vectorToken Lexer::run() { std::vectorToken out; while (pos_ src_.size()) { char c src_[pos_]; if (std::isspace(c)) { pos_; col_; continue; } if (isIdentStart(c)) { size_t start pos_; while (pos_ src_.size() isIdentPart(src_[pos_])) { pos_; col_; } std::string text src_.substr(start, pos_ - start); auto it kKeywords.find(text); out.push_back({it ! kKeywords.end() ? it-second : TokenKind::Ident, text, line_, col_}); continue; } if (std::isdigit(c)) { size_t start pos_; bool isFloat false; while (pos_ src_.size() std::isdigit(src_[pos_])) { pos_; col_; } if (pos_ src_.size() src_[pos_] .) { isFloat true; pos_; col_; while (pos_ src_.size() std::isdigit(src_[pos_])) { pos_; col_; } } Token tok{TokenKind::IntConst, src_.substr(start, pos_ - start), line_, col_, isFloat, {}}; if (isFloat) tok.value.fval std::stof(tok.text); else tok.value.ival std::stoi(tok.text); out.push_back(tok); continue; } // 运算符与分隔符的分发此处省略具体字符匹配 pos_; col_; } out.push_back({TokenKind::End, , line_, col_, false, {}}); return out; }这段代码做了三件事跳过空白、识别标识符与关键字、识别整数和浮点常量。读整数和小数按最长串读把‘.并入浮点常量这样1.5是一个FloatConst而不是IntConst加一个点号。SysY不允许.5这种写法所以小数部分缺失时直接抛错更安全。关键字表用unordered_map做字符串到TokenKind的映射本质上是C字符串数组初始化的一个变体但比逐字符strcmp干净得多。std::stoi和std::stof遇到非法文本会抛std::invalid_argument注意在词法里先保证text已经校验过是数字串。3. 把SysY源码变成RISC-V汇编递归下降与代码生成的落地3.1 递归下降语法分析每产生式就是一个C函数语法分析器的选型上SysY这种规模的语言用递归下降最合适文法的每个非终结符对应一个成员函数读Token、匹配、递归。手写LR分析器需要处理状态转移表和冲突消解对课设来说投入产出比较低。// src/parser.cpp std::unique_ptrStmt Parser::parseStmt() { if (match(TokenKind::If)) return parseIfStmt(); if (match(TokenKind::While)) return parseWhileStmt(); if (match(TokenKind::Return)) return parseReturnStmt(); if (match(TokenKind::LBrace)) return parseBlock(); return parseExprStmt(); // 赋值或函数调用后加分号 }注意两个关键封口函数match()是“当前token等于某类型就吃掉并返回true”不失败expect()会吃掉期望的token否则抛出带行号的ParseError。SysY表达式优先级从高到低是一元负和!、乘除模、加减、关系、相等性、逻辑与、逻辑或。parseExpr用经典的分层写法每个优先级一层千万别写成一个大函数里手动管优先级。3.2 符号表与作用域用unordered_map加链表的做法SysY有块级作用域同一个变量名在内层可以遮蔽外层。最直接的数据结构不是单个map而是一个作用域链每进入一个花括号就push一个新map退出时pop。查表时从最内层往外找。// src/symbol.h #include string #include vector #include unordered_map struct Symbol { std::string name; Type type; // Int / Float / Array / Func bool isConst; int offset; // 栈上偏移负数表示在fp之下 }; class ScopeTable { public: void push() { scopes_.push_back({}); } void pop() { scopes_.pop_back(); } void add(const Symbol s) { scopes_.back()[s.name] s; } Symbol* find(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_; };这里用vector套unordered_map做作用域链push/pop对应开闭花括号。find从rbegin反向遍历天然实现内层遮蔽外层。Symbol里的offset字段是后端用的进入函数时先统一把所有局部变量压栈偏移在符号声明时就分配好。有些实现会在AST节点里存一个Symbol*指针语义检查后就不再查表。这也行但注意AST的dump、深拷贝、序列化时指针会失效。课设里直接查表更省事。3.3 生成RISC-V汇编一条赋值语句的完整旅程以SysY语句a b c * 2;为例AST长这样赋值节点的左边是变量a右边是二元运算根节点Plus左子是变量b右子是另一个二元节点Mul乘数是IntConst(2)。代码生成阶段遍历AST为每个变量分配栈偏移为每个临时结果分配栈槽。// src/codegen.cpp void CodeGen::visitExpr(Expr* e) { if (auto* bi dynamic_castBinaryExpr*(e)) { visitExpr(bi-lhs); // 左子树结果压入临时栈 int lhs tmpTop_ - 1; visitExpr(bi-rhs); // 右子树结果压入临时栈 int rhs tmpTop_ - 1; std::string instr; switch (bi-op) { case TokenKind::Plus: instr add; break; case TokenKind::Minus: instr sub; break; case TokenKind::Star: instr mul; break; case TokenKind::Slash: instr div; break; default: throw CodeGenError(不支持的运算); } emit(lw a0, slotAddr(lhs)); emit(lw a1, slotAddr(rhs)); emit(instr a0, a0, a1); tmpTop_ - 1; // 两个操作数合并成一个结果 emit(sw a0, slotAddr(tmpTop_ - 1)); } else if (auto* iv dynamic_castIntExpr*(e)) { emit(li a0, std::to_string(iv-val)); tmpTop_ 1; emit(sw a0, slotAddr(tmpTop_ - 1)); } else if (auto* v dynamic_castVarExpr*(e)) { emit(lw a0, slotAddr(v-sym-offset)); tmpTop_ 1; emit(sw a0, slotAddr(tmpTop_ - 1)); } }这段代码把表达式的每个中间结果都放在栈式临时区tmpTop_是栈顶指针slotAddr(n)生成-4*(n1)(s0)这样的寻址。注意到访问完一个表达式节点后临时区里恰好保留一个结果供父节点使用。代码只用了a0、a1做中转寄存器永不冲突逻辑非常容易验证。代价是多了一堆冗余的load/store但课设阶段正确性优先。这里示意性省略了浮点分支实际做float时要在指令上加.s后缀并且常量要放到.rodata段用flw加载不能直接li。3.4 寄存器分配为什么课设阶段用栈式分配最稳等课程实践做到代码生成很多人会问不上寄存器分配算法吗课设答案明确不上。线性扫描或者图染色要在活跃区间上做数据流分析对第一次写编译器的人来说寄存在栈上没有正确性问题只损失效率。SysY课设的验收标准是语义正确不是生成代码堪比gcc -O2。方案优点缺点课设推荐度纯栈式临时区实现简单、完全正确代码膨胀、有大量冗余存取强烈推荐简单线性扫描能减少一半load/store要写活跃性分析有时间再上图染色接近真实编译器周期长、冲突图构建复杂不建议课设选函数入口的栈帧布局我习惯这样入参从高地址往低地址排局部变量从fp往下排表达式临时区在最底部。所有访问都用(framePointer offset)的寻址统一负偏移。如果目标平台是RV32I栈指针sp需要始终16字节对齐这个不变量在函数入口和出口都要维护。还有一个容易漏的点SysY函数调用时参数要放到a0-a7超过8个参数的部分要压栈。课设里最简单的方法是让每个函数最多8个参数词法和语法检查时直接报错——这比实现溢出参数传递省力得多。3.5 分支和循环的翻译标签与回边别标错if-else和while的代码生成是课设里另一个大坑。要点是每层控制流生成唯一的标签条件跳转的方向别搞反。最可靠的模式是把条件编译成“条件为假跳转”。// src/codegen.cpp 中 if-else 的标签布局 int elseLabel labelCnt_; int endLabel labelCnt_; visitCond(cond, elseLabel); // 条件为假时跳到 else // then 分支 visitStmt(thenStmt); emit(j .L std::to_string(endLabel)); emit(.L std::to_string(elseLabel) :); // else 分支 if (elseStmt) visitStmt(elseStmt); emit(.L std::to_string(endLabel) :);visitCond在生成条件跳转时要把比较指令的方向处理好。比如cond是a b对应指令是bge a, b, elseLabel也就是“如果a b就跳else”。把不等式倒过来是高频翻车点。所有标签用一个全局计数器生成不要用函数名拼接否则递归函数里会重复。while循环的关键是回边条件检查在循环体后面重新执行时要无条件跳回条件标签。do-while的continue要跳到条件检查处break要跳到循环结束标签。这些标签一旦错位表现往往是死循环或者少执行一次非常难查。4. 一个SysY编译器最常见的5个翻车现场避坑与排查4.1 链接器报错“编译器未包含main类型”现象拿到生成的riscv汇编用工具链生成可执行文件链接阶段报错“编译器未包含main类型”、undefined reference to main。原因代码生成器在输出函数时没有为入口函数main生成.globl main指令或者函数名拼写成了Main、main_。汇编器不负责找入口链接器按globl符号解析缺了它自然找不到入口。解决代码生成时对函数名做一次特判。if (func-name main) { emit(.globl main); emit(.type main, function); }同时检查SysY文法的入口是main还是_main不同课程略有差异。这里最容易翻车的点是SysY允许声明void main()所以返回值类型不一定是int别在生成器里写死“入口函数返回类型必须是int”。4.2 栈帧没对齐导致模拟器上段错误现象在spike或qemu-user上跑编译产物一个简单的递归函数在第二层就段错误或者返回地址被冲掉。原因RV32I调用约定对栈指针有16字节对齐要求。有的实现在函数入口做sp减小之前忘了把大小调整到16的倍数导致后续保存ra和s0写到了错误位置。另一种常见原因是非叶函数没有保存ra到栈帧就继续调用别的函数返回地址在嵌套调用里被覆盖。解决函数入口统一按这个模板生成# 函数序言 addi sp, sp, -frameSize # frameSize 是 16 的倍数 sw ra, 12(sp) # 保存返回地址 sw s0, 8(sp) addi s0, sp, frameSize # 帧指针指向原 sp # 函数尾声 lw ra, 12(sp) lw s0, 8(sp) addi sp, sp, frameSize ret调试小技巧不要只盯崩溃点生成汇编后用riscv64-linux-gnu-objdump反汇编检查每个函数的序言和尾声是否对称、frameSize是否一致。注意RV32I的addi立即数只有12位栈帧超过4096字节会溢出。课设里的函数一般很小但测试里如果出现超大的局部数组记得在栈帧分配前做溢出检查。4.3 递归调用把返回地址弄丢遇到过最玄学的崩溃现象递归函数在第一次递归调用后一切正常第二层返回时直接跳到非法地址。原因代码生成器把返回地址当成普通临时量函数体里调用了其他函数但ra没有在自己的栈帧里保存计算表达式时对a0、a1的写入冲掉了ra。RISC-V里ra不是只读的jal指令会写入ra所以非叶函数必须保存它。解决在语法分析或语义分析阶段给每个函数标记叶函数/非叶函数。如果函数体里出现过函数调用序言必须保存ra否则可以不保存。保存的位置固定放在栈帧顶部这样调试时一眼能看出来。4.4 全局变量和局部变量同名符号表作用域没做对现象函数里定义了和全局变量同名的局部变量赋值后读取却发现还是全局的值或者反过来全局变量被局部污染。原因符号表用单个map存所有名字声明时直接覆盖了祖先作用域的同名项。SysY允许内层遮蔽外层这个遮蔽信息在查表时必须保留。解决用第3章的作用域链实现find从当前层往祖先层找。更隐蔽的坑函数参数在符号表里属于最内层作用域进入函数体后push新作用域时参数已经在上一层。要保证内层能访问到参数find会往上找但如果在当前层声明同名变量则会遮蔽参数——这是对的恰好实现C语义。4.5 浮点常量编译成整数指令语义上的静默错误现象对含浮点运算的程序生成的汇编能链接能运行但结果错得离谱。比如float x 1.5; 得到的x是1。原因词法阶段把1.5解析成了FloatConst但语法分析器在表达式节点上没有区分IntExpr和FloatExpr代码生成统一走了li路径。li伪指令只会生成整数立即数浮点值被截断成整数。SysY里float和int都是32位寄存器表现一样所以错误不崩溃最难察觉。解决浮点常量统一放到.rodata段.section .rodata .LC0: .float 1.5 # 使用 lui a0, %hi(.LC0) flw ft0, %lo(.LC0)(a0) fsw ft0, -4(s0)注意RV32F里flw/ft0这类指令只有支持F扩展的汇编器才认识。如果课设目标只是RV32IM我建议直接把float从支持的子集里砍掉在语义检查时报“不支持浮点”比输出错误汇编好得多。这是课设边界上的主动取舍不算偷工减料。5. 从「能跑」到「可信」对拍验证法与三个值得动手的优化点课设验收前我建议你做一件看起来慢、其实回报最高的事搭一个对拍环境。把同一段SysY源码分别用你的编译器和课程的参考编译器如果有编译比较两者输出汇编的语义没有参考编译器就手写几十条关键指令的期望汇编或者把冒泡排序算法用SysY写一遍作为端到端回归测试每次改完代码生成器就重跑一遍。对拍能让隐藏的语义错误快速暴露尤其是指令选择时把/和%搞反、比较方向写反这类问题。验证之外三个值得动手的扩展点按性价比排序常量折叠、死代码消除、把乘2除2替换成移位。常量折叠最简单在AST构建时如果子节点全是字面量直接算出结果能消掉一大半冗余指令死代码消除更适合放在IR层移位替换则是后端的小心思只对乘除2的幂生效属于常见的编译器优化练习。我当年做完这个编译原理实验最大的感受是它逼着你同时面对系统设计和细节正确性。不怕你卡住就怕你卡在某个跟原理无关的小坑里出不来——比如一次栈帧对齐、一个.globl缺失。把这些坑写进你自己的checklist比任何经验贴都有用。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询