尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零实现SysY到RISC-V编译器:C++实践与架构解析

从零实现SysY到RISC-V编译器:C++实践与架构解析 简介本资源是面向计算机专业本科生的编译原理课程实践项目聚焦SysY语言到RISC-V指令集的完整编译流程实现适用于期末大作业、课程设计等高阶编程实践场景兼顾理论深度与工程可操作性。压缩包共33个文件149KB含10个hpp头文件与6个cpp源文件构成核心编译器框架覆盖词法分析sysy.l、语法解析sysy.y、AST构建ast.hpp/ast.cpp等、中间表示koopa_util.cpp、RISC-V后端代码生成riscv_builder.hpp、builder_code.cpp及测试用例test/目录下hello.c等另有CMakeLists.txt、README.md和.gitignore等工程配置文件结构清晰、模块职责分明。已有51人学习下载代码注释详实实践报告系统梳理了各编译阶段的设计逻辑与实现细节并提供简易部署指南。读者可直接复现从SysY源码到RISC-V汇编.s及可执行目标.o的全流程深入理解前端语义分析、中间代码优化与目标平台适配等关键环节。1. 项目缘起与核心目标最近在整理过往的项目资料翻到了一个让我印象深刻的“硬骨头”——一个基于C实现的SysY语言到RISC-V指令集的编译器项目。这个项目最初是作为一门高级编译原理课程的期末大作业但它的复杂度和挑战性远超普通课程设计几乎可以看作是一个简化版的工业级编译器前端到中端的实现。SysY是“系统编程语言Y”的简称它是一个教学用的C语言子集语法相对简单但包含了变量、函数、控制流、数组等核心特性非常适合作为编译器开发的练手对象。而RISC-V作为近年来开源指令集架构的明星其简洁、模块化的设计哲学使得为其生成代码成为了理解计算机体系结构与编译器后端设计的绝佳途径。这个项目的核心目标非常明确将一个用SysY语言编写的源程序经过词法分析、语法分析、语义分析、中间代码生成与优化最终翻译成能在RISC-V模拟器如Spike或QEMU上正确运行的汇编代码。听起来像是教科书的标准流程但真正动手做起来你会发现从理论到实践之间隔着无数个“坑”。网上关于LLVM、GCC的讨论很多但完整实现一个从特定高级语言到特定指令集的编译器并附上详尽的实践报告和可构建的源码这样的资源并不多见。很多人卡在如何组织项目结构、如何处理复杂的语义规则、如何设计有效的中间表示IR、以及如何将IR映射到RISC-V那有限的寄存器集合上。我这个项目就是试图填平这些坑的一次实践总结希望能给后来者提供一个清晰的、可复现的路线图。2. 项目整体架构与工具链选型在动手写第一行代码之前合理的架构设计和工具链选择是项目成功的基石。这个项目完全采用C17标准进行开发主要基于以下几点考虑首先C提供了足够的抽象能力如类、模板来构建复杂的编译器数据结构抽象语法树AST、符号表、中间代码等同时又能保持接近C语言的性能这对于需要频繁进行树遍历和符号查找的编译器核心来说至关重要。其次丰富的标准库和第三方库如用于命令行解析的cxxopts用于格式化输出的fmt能极大提升开发效率。最后C是现代工业级编译器如Clang、GCC的C部分的实现语言用C来实现编译器本身也是一个绝佳的学习过程。构建系统我毫不犹豫地选择了CMake。在编译器这种模块化程度高、源文件众多、且有不同构建类型Debug/Release需求的项目中手写Makefile无异于自讨苦吃。CMake的跨平台特性保证了项目在Linux、macOS乃至Windows通过MinGW或MSYS2上都能顺利构建。我的CMakeLists.txt大致分层如下最外层定义项目名称、C标准、编译选项如调试信息-g、优化级别-O2、所有警告-Wall -Wextra然后通过add_subdirectory引入src/、include/、test/等子目录在src/目录下将编译器按阶段划分为多个静态库目标如lexer_lib、parser_lib、semantic_lib、ir_lib、codegen_lib最后再链接成一个可执行文件sysyc。这种结构清晰且便于单独测试某个模块。开发环境我主要使用VSCode配合CMake Tools和C/C插件体验非常流畅。调试则主要依赖GDB。至于RISC-V工具链我使用的是官方提供的riscv64-unknown-elf-gcc交叉编译工具链它包含了汇编器、链接器和模拟器是我们验证生成代码正确性的最终裁判。注意在Ubuntu等系统上安装特定版本的CMake有时会遇到问题。如果系统自带的版本过低不建议直接降级如降到3.16.3这可能破坏其他包的依赖。更稳妥的做法是从Kitware官网下载最新版本的预编译包或通过pip install cmake安装并将其路径加入PATH环境变量最前端。3. 从SysY源码到抽象语法树前端实现详解编译器的前端负责将源代码的字符流转化为结构化的抽象语法树AST并检查基本的语言规则。这部分的工作相对标准化但细节决定成败。3.1 词法分析器的手工实现我没有使用Flex这样的词法分析器生成器而是选择手工编写词法分析器Lexer。原因有二一是SysY的词法规则比较简单手工实现可控性更强便于调试和添加自定义的Token如跟踪源码位置这对后续报错至关重要二是作为学习项目亲手实现一遍能更深刻地理解正则表达式如何转换为状态机。我的Lexer类核心是一个状态机循环每次从输入流中读取字符根据当前字符和状态决定是继续读取、生成Token还是报错。Token类型包括关键字if,while,int,return等、标识符、常量整数、浮点数、运算符和界符。这里的一个关键点是必须处理好“最长匹配”原则例如是一个运算符不能被错误地解析为和。class Token { public: enum class Type { KEYWORD, IDENTIFIER, INTEGER, OPERATOR, DELIMITER, END_OF_FILE }; Type type; std::string value; // 源码位置信息用于错误提示 size_t line, column; };3.2 递归下降语法分析器构建AST语法分析器Parser我采用了递归下降的方法这是实现LL(1)文法最直观的方式。首先需要根据SysY的语法规范通常是EBNF形式消除左递归并提取左公因子使其满足LL(1)文法的要求。然后为每个非终结符编写一个对应的解析函数。例如解析一个if语句的函数大致如下std::unique_ptrIfStmtNode Parser::parseIfStatement() { auto ifNode std::make_uniqueIfStmtNode(currentToken().line); consume(Token::Type::KEYWORD, if); // 消耗掉if consume(Token::Type::DELIMITER, (); // 消耗掉( ifNode-condition parseExpression(); // 解析条件表达式 consume(Token::Type::DELIMITER, )); // 消耗掉) ifNode-thenBody parseStatement(); // 解析then分支语句 if (currentToken().value else) { consume(Token::Type::KEYWORD, else); ifNode-elseBody parseStatement(); // 解析else分支语句 } return ifNode; }在解析过程中同步构建AST节点。我设计了一个继承体系丰富的AST节点类根节点是ASTNode其下衍生出StmtNode语句、ExprNode表达式、DeclNode声明等。每个节点都包含其子节点和源码位置。递归下降解析的过程本质上就是根据文法规则自顶向下地创建并组装这些AST节点的过程。当Parser成功运行完毕我们就得到了一棵完整描述程序结构的AST。3.3 语义分析符号表与类型检查AST只保证了语法的正确性接下来需要通过语义分析来保证程序的含义是合法的。这部分的核心数据结构是符号表Symbol Table。我实现了一个支持作用域嵌套的符号表。当进入一个复合语句由{}包围或函数体时会新建一个作用域Scope退出时则销毁。每个作用域是一个哈希表将标识符名称映射到其符号信息SymbolEntry。SymbolEntry需要记录变量的类型int、int[]等、是否为常量、初始值如果是常量以及函数符号的返回类型、参数列表等。在遍历AST进行语义分析时主要做以下几件事声明收集遇到变量或函数声明时将其信息加入当前作用域的符号表。如果当前作用域已存在同名符号则报“重复定义”错误。引用消解遇到使用标识符如在表达式中使用变量、调用函数时从当前作用域开始逐级向外层作用域查找该符号的定义。如果找不到报“未定义标识符”错误。类型检查检查表达式中运算符两边的操作数类型是否兼容如int int是合法的int int[]则不合法函数调用的实参与形参类型和数量是否匹配return语句的返回值类型是否与函数声明的返回类型一致等。常量传播对于常量表达式如3 5 * 2可以在编译期直接计算出结果并用这个结果替换掉原来的表达式节点这属于一种简单的优化。语义分析是编译器发现程序员逻辑错误的关键阶段一个健壮的语义分析器能提供清晰准确的错误信息极大提升开发体验。4. 中间表示的设计与生成前端完成后我们得到了带有丰富语义信息的AST。但直接基于AST生成目标代码非常困难因为AST的结构与机器指令相差太远。因此我们需要一个介于高级语言和机器语言之间的中间表示IR。我选择实现了一个类似LLVM IR但极度简化的三地址码IR。4.1 三地址码IR设计三地址码的基本形式是result arg1 op arg2。每个指令最多涉及三个地址变量或常量。我的IR指令集主要包括ALLOCA在栈上分配空间给一个变量。LOAD/STORE从内存地址加载值到临时变量或将临时变量的值存储到内存地址。ADD,SUB,MUL,DIV,MOD算术运算。AND,OR,NOT,XOR逻辑运算。CMP后接EQ,NE,LT,GT等条件码比较操作结果通常存入一个条件寄存器虚拟的。BR无条件跳转。BRCOND条件跳转基于条件寄存器的值。CALL函数调用。RET函数返回。PHI用于在SSA形式中合并来自不同前驱块的值在实现SSA时用到。IR是函数级别的。每个函数对应一个Function对象里面包含一个基本块BasicBlock的列表。基本块是只有一个入口和一个出口的指令序列通常以跳转指令或返回指令结束。4.2 从AST到IR的翻译这是一个模式匹配和递归翻译的过程。我为每种AST节点类型编写了对应的codegen方法。例如翻译一个二元运算表达式a b递归翻译左子树a得到它对应的IR值可能是一个临时变量名或常量。递归翻译右子树b得到其IR值。生成一条新的临时变量t1并创建一条三地址码指令t1 a_value b_value。将t1作为整个表达式的结果返回。翻译控制流语句如if、while则更复杂一些需要巧妙地管理基本块和跳转指令。以if语句为例创建三个基本块thenBBthen分支、elseBBelse分支如果有、mergeBB合并点。翻译条件表达式结果存入条件寄存器。生成一条条件跳转指令BRCOND cond, thenBB, elseBB或直接跳转到mergeBB如果只有then分支。设置当前基本块为thenBB翻译then分支的语句。翻译结束后生成一条到mergeBB的无条件跳转。类似地处理elseBB。将当前基本块设置为mergeBB后续的代码将在此继续。这个过程确保了程序的控制流在IR层面被正确地图形化表示出来为后续分析和优化奠定了基础。4.3 静态单赋值形式与优化初探生成的原始IR效率通常不高存在大量冗余计算和无效代码。因此我实现了一个简单的优化管道。第一步是将IR转换为静态单赋值形式。SSA要求每个变量只被赋值一次这极大地简化了数据流分析。对于有多个赋值路径的变量如if语句两侧都对同一个变量赋值需要使用PHI指令在控制流合并点进行“选择”。转换为SSA后我实现了几个经典的本地优化公共子表达式消除如果同一个表达式在同一个基本块内被计算了多次且其操作数在此期间没有改变那么可以复用第一次计算的结果。常量传播与折叠如果一条指令的操作数是常量可以在编译时直接计算出结果。进一步地如果这个结果又被用于其他计算可以继续传播和折叠。死代码消除移除那些计算结果永远不会被用到的指令。这些优化虽然基础但能显著提升生成代码的质量。实现它们需要用到定义-使用链分析等数据流分析技术这是编译器课程中最有趣也最具挑战性的部分之一。5. 目标代码生成映射到RISC-V这是将平台无关的IR落实到具体机器架构的关键一步。RISC-V指令集简洁规整但如何有效利用其有限的寄存器通常是32个通用整数寄存器是最大的挑战。5.1 寄存器分配策略RISC-V的寄存器有约定俗成的用途例如x0零寄存器值恒为0、x1返回地址寄存器ra、x2栈指针sp、x5-x7和x28-x31临时寄存器t0-t6、x8-x9和x18-x27保存寄存器s0-s11等。我的寄存器分配器相对简单采用线性扫描算法的一个变种并辅以活跃变量分析。活跃变量分析遍历每个基本块计算在每条指令处哪些变量是“活跃的”其值在未来会被读取。这是寄存器分配的基础。线性扫描按指令顺序遍历为每个变量分配一个生存期。当需要为一个新变量分配寄存器但所有寄存器都已被占用时就需要选择一个寄存器进行“溢出”即将其当前值保存到栈内存中腾出寄存器给新变量并在后续需要时再加载回来。选择溢出哪个变量有一套启发式策略比如选择生存期结束最晚的变量。由于实现一个完美的寄存器分配器非常复杂我的策略是“尽力而为”。优先使用临时寄存器t0-t6不够用时再考虑使用保存寄存器s0-s11但需要遵循调用约定在函数开头保存、结尾恢复这些寄存器的值。如果寄存器实在不够用就坦然地将变量溢出到栈上。对于访存密集型或变量很多的函数性能会下降但功能是正确的。5.2 指令选择与汇编生成IR指令需要被“降低”为一条或多条RISC-V指令。这是一个模式匹配的过程。我维护了一个指令选择表。例如IR的ADD指令如果操作数是寄存器可以直接对应RISC-V的add rd, rs1, rs2。IR的LOAD指令如果基址是帧指针fp加上一个常量偏移可以对应lw rd, offset(fp)。控制流指令BR对应j labelBRCOND则需要分解为比较指令如slt和条件分支指令如beq,bne。此外还需要处理函数调用的约定调用者负责将参数放入a0-a7寄存器如果参数多于8个剩下的压栈跳转到函数地址jalr调用者保存的寄存器Caller-saved如t0-t6可以被被调函数随意使用而被调函数则需要保存和恢复被调用者保存的寄存器Callee-saved如s0-s11和ra。最终代码生成器会遍历优化后的IR函数为每个基本块生成对应的RISC-V汇编指令字符串并处理好标签Label、函数序言设置栈帧和函数尾声恢复栈帧并返回。5.3 集成、测试与调试将所有的模块链接起来就得到了完整的编译器sysyc。它的使用方式类似./sysyc -S -o output.s input.sy。为了测试我编写了大量的SysY测试用例覆盖了语法、语义、各种语句和表达式。用sysyc编译生成.s文件再用RISC-V工具链汇编和链接最后在Spike模拟器上运行与预期的输出结果进行比对。调试编译器本身是一个“元”调试过程。我总结了几点心得单元测试至关重要为词法分析器、语法分析器、语义分析器分别编写单元测试使用Google Test框架确保每个模块在集成前基本正确。可视化AST和IR我写了一个简单的Dot图形生成器将AST和IR以图形方式输出这对于理解复杂的程序结构和调试代码生成逻辑有奇效。善用GDB和打印日志在关键节点如进入/退出函数、生成特定指令时打印详细的日志信息。当程序行为异常时结合GDB单步调试和日志能快速定位问题。对比参考编译器如果课程提供了官方的SysY编译器可以将自己的编译结果与参考编译器的结果无论是IR还是汇编进行逐行对比这是发现差异的最直接方法。这个项目从零开始构建了一个可工作的编译器虽然功能上距离GCC、Clang这样的工业级产品还有光年之遥但它完整地走完了编译流程的每一个关键步骤。通过这个项目你收获的不仅仅是一份代码和报告而是对“程序如何从高级语言变为机器指令”这一魔法过程的深刻理解以及解决复杂系统工程问题的实战能力。如果你正打算挑战类似的编译器项目希望这份详细的实践报告能为你照亮前路少踩一些我当年踩过的坑。本文还有配套的精品资源点击获取
返回列表