尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从零实现SysY到RISC-V编译器:编译原理全流程实践解析

从零实现SysY到RISC-V编译器:编译原理全流程实践解析 简介本资源是面向编译原理课程学习者的C实现SysY到RISC-V编译器完整实践方案专为本科生期末大作业与课程设计打造兼顾理论深度与工程可操作性。资源共33个文件含10个核心hpp头文件如riscv_builder.hpp、symbol_list.hpp、6个cpp源文件含main.cpp、ast.cpp等、5个.zbak备份文件以及README.md、CMakeLists.txt、sysy.y语法定义、sysy.l词法定义、test目录下的样例程序与生成目标文件.s/.koopa/.o整体仅149KB轻量易部署。项目结构清晰划分为前端词法/语法/语义分析、中间表示Koopa IR、后端RISC-V代码生成三大模块源码注释详尽实践报告系统梳理各阶段设计逻辑与关键实现细节。已有51人学习下载适合零基础入门编译流程、理解AST构建、循环优化、寄存器分配及指令选择等核心概念并可直接运行测试用例验证编译正确性。1. 项目概述与核心价值最近在整理过往的项目资料翻到了几年前带学生做的一个课程设计一个基于C实现的SysY语言到RISC-V指令集的编译器。这个项目虽然规模不算庞大但麻雀虽小五脏俱全完整覆盖了从词法分析、语法分析、语义分析、中间代码生成与优化到最终RISC-V目标代码生成的完整编译流程。对于想深入理解编译器工作原理尤其是想亲手实现一个能真正生成可运行代码的编译器的朋友来说这个项目是一个绝佳的练手材料。它不像一些玩具编译器只做到中间表示就结束了而是实实在在地能输出RISC-V汇编经过汇编器和链接器处理后可以在模拟器或真实的RISC-V硬件上执行。今天我就把这个项目的核心实现思路、关键代码解析以及我在实践过程中踩过的坑和积累的经验系统地梳理分享出来。SysY语言是“编译系统”课程中常用的一种简化版C语言子集它保留了C语言的核心语法结构如变量声明、算术运算、控制流if-else, while、函数定义与调用等但去除了一些复杂特性如指针、结构体、联合体使得实现编译器的难度可控。而RISC-V作为一种开源、精简的指令集架构其规整的指令格式和模块化的扩展非常适合作为编译器的后端目标。这个项目的核心价值在于它搭建了一座从高级语言抽象到具体机器指令的桥梁通过实现它你能透彻理解你写的每一行C/C代码最终是如何变成CPU能够识别和执行的一串串0和1的。无论是计算机专业的学生巩固编译原理知识还是对系统软件底层感兴趣的开发者提升功力这个项目都能让你获益匪浅。2. 项目整体架构与设计思路拆解2.1 编译器前端从源代码到抽象语法树编译器的前端负责将源代码字符串转换为结构化的、便于处理的中间表示。我们的实现采用了经典的“词法分析 - 语法分析 - 语义分析”三级流水线。词法分析器Lexer的任务是把字符流转换成有意义的词法单元Token流。我们手工编写了一个状态机来实现而不是使用Flex这样的工具。这样做的原因是为了让学生更深刻地理解正则表达式和有限自动机是如何运作的。词法分析器需要识别SysY语言的关键字如int,if,while,return、标识符、常量整数、十进制浮点数、运算符,-,*,/,,,!,,和分隔符;,,,(,),{,}。每个识别出的Token都附带其类型、在源文件中的位置行号、列号以及具体的字面值。语法分析器Parser在Token流的基础上根据SysY语言的文法规则构建出抽象语法树AST。我们采用了递归下降Recursive Descent的解析方法为每一种语法结构如表达式、语句、函数定义编写一个对应的解析函数。递归下降解析器直观易懂特别适合教学和中等复杂度的语言。文法的设计需要仔细处理运算符优先级和结合性的问题。例如处理表达式a b * c时解析器必须确保乘法节点*成为加法节点的右孩子以体现乘法优先级高于加法。我们通过为不同优先级的运算符设计不同的解析函数层级来解决这个问题。注意在手工编写递归下降解析器时最常遇到的坑是“左递归”文法会导致无限递归。例如表达式规则如果写成Expr - Expr ‘’ Term在解析函数中会立即无休止地调用自身。必须将其改写为等价的右递归形式并在解析过程中通过循环来构建左结合的语法树。这是初学者需要跨过的一道关键门槛。语义分析阶段则是在AST上进行多次遍历完成语法本身无法检查的工作。主要包括符号表管理建立和维护作用域栈记录每个作用域内定义的变量、函数及其类型信息。当遇到一个标识符时需要从当前作用域开始逐级向外查找其定义。类型检查确保运算符两边的操作数类型兼容函数调用的实参与形参类型匹配返回值类型与函数声明一致等。SysY语言类型系统简单主要是int和float如果支持的话以及由它们构成的数组。常量表达式求值对于在编译期就能确定值的表达式如3 5*2直接计算出结果并用一个常量节点替换原来的表达式子树这能为后续的代码优化提供便利。2.2 编译器中端中间表示与优化AST虽然富含语义信息但结构上与机器指令相差甚远。因此我们引入了一种中间表示IR作为前端和后端之间的桥梁。我们选择了一种类似三地址码的线性IR它由一系列简单的指令组成每个指令最多涉及三个操作数或地址。例如将加法a b c转换为t1 b c; a t1。从AST生成IR的过程需要遍历AST为每一种语法结构生成对应的IR指令序列。这个过程相对直接但需要仔细处理控制流。例如一个if-else语句需要被翻译成条件跳转指令和标签Label。生成初始的IR后就可以在其上进行一系列优化。虽然对于一个课程编译器优化的深度和广度有限但实现一些经典的、效果显著的优化能极大提升生成代码的质量并加深对优化原理的理解。我们实现了以下几种优化常量传播将已知的常量值替换到使用该变量的表达式中。公共子表达式消除如果同一个表达式被多次计算且其操作数未改变则保留第一次计算的结果后续直接复用。死代码删除移除计算结果永远不会被使用的指令。强度削弱将代价高的运算替换为代价低的等价运算例如将x * 2替换为x 1。这些优化通常需要基于数据流分析来收集信息比如到达-定值分析、活跃变量分析等。实现一个哪怕简单的数据流分析框架也是这个项目中的一个挑战和亮点。2.3 编译器后端从IR到RISC-V汇编后端是编译器中最贴近机器的一层负责将与机器无关的IR映射到具体的RISC-V指令集上。这个过程主要包括指令选择、寄存器分配和指令调度。指令选择相对简单因为我们的IR指令设计得与RISC-V指令比较接近。例如IR的加法指令可以直接对应RISC-V的add或addi。需要特别处理的是内存访问加载/存储、函数调用call,ret和控制转移条件/无条件跳转。寄存器分配是后端最复杂、最核心的部分。RISC-V有32个通用寄存器x0-x31其中一些是约定俗成的特殊用途寄存器如栈指针sp返回地址ra。我们需要将IR中无限多的虚拟寄存器或临时变量分配到这有限的物理寄存器中。当物理寄存器不足时就必须将某些寄存器的值“溢出”到内存栈帧中。我们实现了一个简单的图着色寄存器分配算法的简化版——线性扫描寄存器分配算法。该算法按指令顺序线性遍历IR为每个变量的生存期分配寄存器并在寄存器冲突时进行溢出处理。虽然不如图着色算法精细但对于SysY这样的语言其效果已经足够好且实现复杂度大大降低。指令调度是为了避免流水线停顿重新排列指令顺序以提高性能。作为教学编译器我们实现了一个非常基础的本地调度主要是在生成加载指令后尽量隔开几条指令再使用加载结果以隐藏内存访问延迟。最终经过寄存器分配和简单调度后我们就可以将每条IR指令“ lowering ”为一条或多条具体的RISC-V汇编指令并按照RISC-V汇编语言的格式输出到.s文件中。3. 核心模块源码解析与实现要点3.1 词法分析器Lexer的实现细节我们的Lexer类核心是一个getNextToken()函数。它从源代码缓冲区中读取字符根据第一个字符判断Token的可能类型进入相应的处理逻辑。class Lexer { std::string sourceCode; size_t position; size_t line; size_t column; char currentChar; void advance(); char peek(); Token getNextToken(); public: Lexer(const std::string code) : sourceCode(code), position(0), line(1), column(1) { if (!sourceCode.empty()) currentChar sourceCode[0]; } Token scan(); // 主扫描函数 };处理标识符和关键字的逻辑是持续读取字母、数字或下划线直到遇到非这类字符。然后将收集到的字符串与关键字表进行比对。我们使用一个std::unordered_mapstd::string, TokenType来存储关键字到Token类型的映射这样查找效率很高。处理数字常量时需要区分整数和浮点数。整数部分相对简单浮点数则需要处理小数点和小数部分以及可选的科学计数法e或E。这里要特别注意数字字面值的解析不能有误因为后续的常量求值依赖于此。实操心得在词法分析阶段就准确记录每个Token的行号和列号至关重要。当后续语法分析或语义分析报错时能精确定位到源代码的错误位置极大提升了调试效率。我们用一个SourceLocation结构体来封装行列信息并附加到每个Token上。3.2 递归下降语法分析器与AST构建语法分析器的入口是parseCompUnit()对应SysY的编译单元即整个程序。它本质上是一系列函数声明的列表。// AST节点基类 class ASTNode { public: virtual ~ASTNode() default; virtual void accept(ASTVisitor visitor) 0; SourceLocation loc; // 源代码位置 }; // 函数定义节点 class FunctionDef : public ASTNode { public: std::string name; std::unique_ptrType returnType; std::vectorstd::unique_ptrVarDecl params; std::unique_ptrBlockStmt body; void accept(ASTVisitor visitor) override { visitor.visit(*this); } };递归下降解析函数的特点是它“预测”当前应该看到什么样的Token然后去消费consume它。如果预测错误就报告语法错误。例如解析if语句的函数std::unique_ptrIfStmt Parser::parseIfStmt() { auto ifToken consume(TokenType::IF); // 消费 ‘if’ expect(TokenType::L_PAREN); // 期待 ‘(’ auto cond parseCond(); // 解析条件表达式 expect(TokenType::R_PAREN); // 期待 ‘)’ auto thenBody parseStmt(); // 解析then分支语句 std::unique_ptrStmt elseBody nullptr; if (currentToken.type TokenType::ELSE) { consume(TokenType::ELSE); elseBody parseStmt(); // 解析else分支语句 } return std::make_uniqueIfStmt(std::move(cond), std::move(thenBody), std::move(elseBody), ifToken.loc); }在解析过程中我们同时创建了对应的AST节点。使用std::unique_ptr来管理节点的生命周期可以避免内存泄漏并清晰地表达节点的所有权关系。AST构建完成后整个程序的结构就以一棵树的形式存储在内存中了。3.3 语义分析符号表与类型检查语义分析器我们实现为多个AST访问者Visitor。我们定义了一个ASTVisitor基类然后派生出SymbolTableBuilder、TypeChecker等。符号表的实现通常是一个栈结构每个栈帧对应一个作用域如全局作用域、函数作用域、块作用域。class SymbolTable { std::vectorScope scopes; // 作用域栈 public: void enterScope() { scopes.push_back(Scope{}); } void exitScope() { scopes.pop_back(); } bool define(const std::string name, Symbol symbol); std::optionalSymbol lookup(const std::string name); // 从内到外查找 };SymbolTableBuilder在遍历AST时遇到变量声明或函数定义就将其加入到当前作用域的符号表中。遇到标识符使用时就调用lookup查找其定义。如果查找失败则报告“未定义的标识符”错误。TypeChecker的访问逻辑则专注于检查类型兼容性。例如访问二元运算符节点时void TypeChecker::visit(BinaryExpr expr) { expr.left-accept(*this); expr.right-accept(*this); Type* leftType expr.left-getType(); Type* rightType expr.right-getType(); // 检查 leftType 和 rightType 是否可以进行 expr.op 运算 if (!isArithmeticOp(expr.op) || !leftType-isArithmetic() || !rightType-isArithmetic()) { reportError(expr.loc, “类型不匹配无法进行算术运算”); } // 推导出表达式的结果类型例如int与float运算结果为float expr.type deduceBinaryOpType(leftType, rightType, expr.op); }3.4 中间代码生成与优化我们的IR指令设计得非常简单每条指令有一个操作码和最多三个操作数。操作数可以是虚拟寄存器、常量或内存地址标签。class IRInstruction { public: enum class Opcode { ADD, SUB, MUL, DIV, // 算术运算 LOAD, STORE, // 内存访问 BRANCH, JUMP, // 控制流 CALL, RET, // 函数调用 // ... 其他指令 }; Opcode opcode; std::vectorOperand operands; std::string label; // 该指令可能关联的标签 };从AST生成IR的访问者IRGenerator其工作模式与TypeChecker类似但输出的是一个指令列表。例如生成赋值语句a b c;的IR为表达式b c生成指令t1 b c假设b和c已存在于某个虚拟寄存器或内存位置。生成将结果t1存储到变量a对应位置的指令store [addr_of_a], t1。对于优化我们实现了几个独立的Pass每个Pass遍历一遍IR指令列表进行特定的变换。例如常量传播Pass的伪代码逻辑for (auto inst : instructions) { if (inst.opcode ADD isConstant(inst.operands[1]) isConstant(inst.operands[2])) { int val getConstValue(inst.operands[1]) getConstValue(inst.operands[2]); // 将这条ADD指令替换为一个将常量val赋给目标操作数的指令 // 同时需要更新所有后续使用该ADD结果的地方直接使用常量val } }优化Pass需要按一定顺序执行并且可能需要多次迭代直到IR不再变化达到不动点。一个常见的顺序是常量传播 - 死代码删除 - 公共子表达式消除。3.5 RISC-V代码生成与寄存器分配这是后端最核心的部分。我们定义了一个TargetCodeGen类它持有IR指令列表、寄存器分配器的结果虚拟寄存器到物理寄存器的映射表、以及当前函数的栈帧信息。class TargetCodeGen { std::vectorIRInstruction irInstructions; RegisterAllocator allocator; FrameInfo frame; std::ostream asmOutput; public: void generateCode(); private: void emitInstruction(const std::string mnemonic, const std::string ops); void emitPrologue(); // 函数序言设置栈帧 void emitEpilogue(); // 函数尾声恢复栈帧并返回 };generateCode()函数遍历IR指令根据其操作码和操作数调用相应的辅助函数生成RISC-V汇编片段。例如对于ADD指令void TargetCodeGen::translateAdd(const IRInstruction inst) { // inst.operands[0] inst.operands[1] inst.operands[2] std::string dest mapToPhysReg(inst.operands[0]); std::string src1 mapToPhysRegOrImm(inst.operands[1]); std::string src2 mapToPhysRegOrImm(inst.operands[2]); if (isImmediate(src2)) { emitInstruction(“addi”, dest “, ” src1 “, ” src2); } else { emitInstruction(“add”, dest “, ” src1 “, ” src2); } }寄存器分配器RegisterAllocator是我们实现的简化版线性扫描算法。它首先需要计算每个虚拟寄存器的活跃区间从定义到最后一次使用。然后按虚拟寄存器定义点的顺序进行处理为当前虚拟寄存器分配一个空闲的物理寄存器。如果没有空闲寄存器则根据某种策略如最远使用选择一个已分配的寄存器将其当前值溢出spill到栈帧中然后回收该物理寄存器用于分配。记录分配/溢出决策到映射表中。这个算法的关键在于高效地维护物理寄存器的空闲状态和虚拟寄存器的活跃区间信息。溢出代码的生成也需要小心处理确保在需要使用溢出变量时能正确地从内存加载回寄存器。4. 构建、测试与调试实战指南4.1 项目构建系统CMakeLists.txt详解一个结构清晰、易于构建的项目离不开好的构建系统。我们使用CMake来管理这个编译器项目。项目的目录结构大致如下sysy-compiler/ ├── CMakeLists.txt ├── include/ # 头文件 │ ├── lexer.h │ ├── parser.h │ ├── ast.h │ ├── ir.h │ └── codegen.h ├── src/ # 源文件 │ ├── lexer.cpp │ ├── parser.cpp │ ├── ast.cpp │ ├── ir.cpp │ ├── codegen.cpp │ └── main.cpp ├── test/ # 测试用例 │ ├── test_lexer.cpp │ └── test_programs/ └── third_party/ # 可能的第三方库如用于RISC-V汇编的库根目录的CMakeLists.txt是构建的入口cmake_minimum_required(VERSION 3.10) project(sysy-compiler LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 添加可执行文件目标 add_executable(sysyc src/main.cpp) # 添加所有源文件避免一个个手动列出 file(GLOB_RECURSE SRC_FILES “src/*.cpp”) target_sources(sysyc PRIVATE ${SRC_FILES}) # 包含头文件目录 target_include_directories(sysyc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) # 设置编译选项开启调试信息和所有警告 target_compile_options(sysyc PRIVATE -g -Wall -Wextra -Werror) # 如果使用了第三方库在这里链接 # target_link_libraries(sysyc PRIVATE some_lib) # 添加测试 enable_testing() add_subdirectory(test)在test/目录下可以有一个独立的CMakeLists.txt来定义如何使用 Google Test 或 Catch2 等测试框架来构建和运行单元测试。踩坑记录使用file(GLOB ...)自动收集源文件在开发初期很方便但当项目变大、文件频繁增删时CMake可能无法自动感知变化导致构建异常。在生产级项目中更推荐显式地列出所有源文件。但对于这种课程项目GLOB的便利性 outweighs 其缺点。4.2 测试策略从单元测试到系统集成编译器的正确性至关重要因此需要一套完善的测试体系。单元测试针对每个独立模块进行测试。Lexer测试输入一段代码字符串验证输出的Token序列是否正确。特别要测试边界情况如最长标识符、最大整数、非法字符等。Parser测试输入合法的/不合法的Token序列验证生成的AST结构是否正确或是否按预期报出语法错误。类型检查测试构造类型正确和错误的AST验证类型检查器能否通过或报出正确的错误信息。 我们使用类似以下的简单测试框架也可以集成gtestvoid testLexer() { Lexer lexer(“int main() { return 0; }”); auto tokens lexer.scanAll(); assert(tokens[0].type TokenType::INT); assert(tokens[1].type TokenType::IDENTIFIER tokens[1].lexeme “main”); // ... 更多断言 std::cout “Lexer test passed!” std::endl; }集成测试测试多个模块协同工作。将一段SysY源代码输入编译器前端LexerParserSemantic检查是否能成功构建出符号表并完成类型检查。测试从AST到IR的生成是否正确。端到端系统测试这是最关键的测试。给定一个SysY源文件运行完整的编译流程生成RISC-V汇编文件.s。然后使用RISC-V的工具链如riscv64-unknown-elf-gcc将其汇编、链接并在RISC-V模拟器如spike或qemu-riscv64中运行。最后验证程序的输出是否符合预期。我们可以编写一系列测试程序覆盖语言的所有特性变量、运算、控制流、函数、数组等并自动运行这个流程。4.3 调试技巧与性能剖析调试编译器是一项富有挑战性的工作因为错误可能出现在任何阶段且现象往往在最后运行阶段才显现。打印中间结果在各个阶段Lexer, Parser, IR生成, 代码生成结束后打印出可读的中间表示。例如打印AST的树形结构、打印IR指令列表、打印生成的汇编代码。这是最直接有效的调试手段。使用GDB/LLDB在关键函数设置断点单步跟踪执行流程观察变量的状态。这对于理解复杂的寄存器分配算法或优化Pass的逻辑流非常有帮助。对比法当对某个SysY程序生成的代码有疑虑时可以先用一个成熟的编译器如gcc -S生成x86汇编或riscv64-unknown-elf-gcc -S生成RISC-V汇编编译一个功能相同的C程序对比两者生成的汇编代码找出自己实现中的逻辑差异。性能剖析当编译器本身运行缓慢时例如处理大型数组初始化可以使用gprof或perf工具进行性能剖析找出热点函数。常见的瓶颈可能在于符号表的查找可考虑使用哈希表优化、AST/IR的频繁拷贝使用移动语义或共享指针等。5. 常见问题排查与进阶优化方向5.1 编译与运行时的典型问题在实现和测试过程中你几乎一定会遇到下面这些问题问题现象可能原因排查思路与解决方案解析if (a b)时报语法错误词法分析器将错误地识别为两个单独的Token。检查Lexer中对于多字符运算符,!,,,, 生成的RISC-V程序在模拟器中运行崩溃如非法指令1. 生成的指令操作数格式错误。2. 函数调用约定Calling Convention未遵守破坏了栈或寄存器状态。1. 仔细检查每条生成的汇编指令确保寄存器编号、立即数范围符合规范。2. 重点检查函数序言保存ra, s0寄存器分配栈空间、尾声恢复寄存器释放栈空间ret以及参数传递a0-a7寄存器、返回值a0, a1寄存器是否符合RISC-V标准ABI。程序计算结果不正确1. 中间代码生成逻辑错误如运算符优先级处理反了。2. 寄存器分配时变量值被错误地覆盖溢出处理不当。3. 常量传播或其它优化Pass引入了错误。1. 编写一个小型测试用例打印出AST和IR人工核对转换逻辑。2. 在寄存器分配后打印出虚拟寄存器到物理寄存器的映射关系以及所有的溢出加载/存储指令检查其正确性。3. 可以逐个关闭优化Pass定位是哪个优化引入了问题。编译器在处理递归函数时栈溢出递归下降解析器或AST访问者对于深层嵌套的语法结构如((((a))))可能导致C调用栈溢出。这通常不是大问题因为正常程序嵌套深度有限。如果为了鲁棒性可以考虑将递归算法改为显式栈管理的迭代算法但这会大大增加复杂度。课程项目中可忽略或设置一个最大递归深度限制。链接时提示未定义符号main编译器没有生成名为main的标签或者生成的main函数不符合汇编器/链接器的预期。确保编译器为输入程序的入口函数在SysY中就是main函数生成正确的全局标签.globl main。检查生成的main函数汇编是否符合平台ABI。5.2 项目扩展与进阶优化思路完成基础版本后这个编译器项目还有巨大的扩展和优化空间支持更多语言特性这是最直接的扩展方向。浮点类型支持float和double类型需要处理浮点算术指令、浮点寄存器分配RISC-V的f扩展以及整型浮点型之间的转换。数组支持一维和多维数组。这涉及到数组声明的存储布局行优先/列优先、数组元素的地址计算、以及作为函数参数传递通常退化为指针。结构体支持自定义结构体类型。需要处理结构体的内存对齐、成员访问、以及作为值传递或返回时的拷贝问题。实现更强大的优化循环优化实现循环不变式外提、归纳变量优化、强度削弱等专门针对循环的优化对性能提升显著。函数内联对于小函数将其代码直接内联到调用处可以减少函数调用的开销。全局值编号比公共子表达式消除更强大的优化可以识别出不同表达式中计算出的相同值。窥孔优化在生成汇编代码后进行小范围的指令替换例如将addi x1, x0, 0替换为mv x1, x0。改进后端实现更优的寄存器分配算法将线性扫描算法替换为图着色算法如Chaitin算法虽然更复杂但能产生更好的分配结果。指令选择与DAG将IR表示为有向无环图DAG然后基于模式匹配进行指令选择可以生成更高效的代码。支持更多目标架构抽象出后端接口可以尝试为x86-64或ARM生成代码加深对不同指令集差异的理解。工具链集成实现简单的链接器理解.o目标文件格式如ELF实现将多个编译单元链接在一起的功能。实现汇编器将自己生成的文本汇编代码直接翻译成机器码生成可执行文件。实现这个编译器的过程就像在亲手搭建一个复杂的、精密的系统。每一个模块的完成每一个Bug的修复都会带来巨大的成就感。它不仅仅是对《编译原理》课本知识的实践更是对工程能力、调试能力和系统思维的一次全面锻炼。当你第一次看到自己编写的SysY程序经过自己的编译器变成RISC-V汇编最终在模拟器上正确输出“Hello, World!”时那种感觉是无与伦比的。希望这份详细的解析和实践报告能为你开启自己的编译器探索之旅提供一份扎实的地图。本文还有配套的精品资源点击获取
返回列表