从零实现SysY到RISC-V编译器:编译原理全流程实践解析
2026/8/30 7:42:47 网站建设 项目流程

简介:本资源是面向编译原理课程学习者的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上进行多次遍历,完成语法本身无法检查的工作。主要包括:

  1. 符号表管理:建立和维护作用域栈,记录每个作用域内定义的变量、函数及其类型信息。当遇到一个标识符时,需要从当前作用域开始逐级向外查找其定义。
  2. 类型检查:确保运算符两边的操作数类型兼容,函数调用的实参与形参类型匹配,返回值类型与函数声明一致等。SysY语言类型系统简单,主要是intfloat(如果支持的话)以及由它们构成的数组。
  3. 常量表达式求值:对于在编译期就能确定值的表达式(如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的addaddi。需要特别处理的是内存访问(加载/存储)、函数调用(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_map<std::string, TokenType>来存储关键字到Token类型的映射,这样查找效率很高。

处理数字常量时,需要区分整数和浮点数。整数部分相对简单,浮点数则需要处理小数点和小数部分,以及可选的科学计数法(eE)。这里要特别注意数字字面值的解析不能有误,因为后续的常量求值依赖于此。

实操心得:在词法分析阶段就准确记录每个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_ptr<Type> returnType; std::vector<std::unique_ptr<VarDecl>> params; std::unique_ptr<BlockStmt> body; void accept(ASTVisitor &visitor) override { visitor.visit(*this); } };

递归下降解析函数的特点是:它“预测”当前应该看到什么样的Token,然后去消费(consume)它。如果预测错误,就报告语法错误。例如,解析if语句的函数:

std::unique_ptr<IfStmt> 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_ptr<Stmt> elseBody = nullptr; if (currentToken.type == TokenType::ELSE) { consume(TokenType::ELSE); elseBody = parseStmt(); // 解析else分支语句 } return std::make_unique<IfStmt>(std::move(cond), std::move(thenBody), std::move(elseBody), ifToken.loc); }

在解析过程中,我们同时创建了对应的AST节点。使用std::unique_ptr来管理节点的生命周期,可以避免内存泄漏,并清晰地表达节点的所有权关系。AST构建完成后,整个程序的结构就以一棵树的形式存储在内存中了。

3.3 语义分析:符号表与类型检查

语义分析器我们实现为多个AST访问者(Visitor)。我们定义了一个ASTVisitor基类,然后派生出SymbolTableBuilderTypeChecker等。

符号表的实现通常是一个栈结构,每个栈帧对应一个作用域(如全局作用域、函数作用域、块作用域)。

class SymbolTable { std::vector<Scope> scopes; // 作用域栈 public: void enterScope() { scopes.push_back(Scope{}); } void exitScope() { scopes.pop_back(); } bool define(const std::string &name, Symbol symbol); std::optional<Symbol> 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::vector<Operand> operands; std::string label; // 该指令可能关联的标签 };

从AST生成IR的访问者IRGenerator,其工作模式与TypeChecker类似,但输出的是一个指令列表。例如,生成赋值语句a = b + c;的IR:

  1. 为表达式b + c生成指令:t1 = b + c(假设bc已存在于某个虚拟寄存器或内存位置)。
  2. 生成将结果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::vector<IRInstruction> &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是我们实现的简化版线性扫描算法。它首先需要计算每个虚拟寄存器的活跃区间(从定义到最后一次使用)。然后按虚拟寄存器定义点的顺序进行处理:

  1. 为当前虚拟寄存器分配一个空闲的物理寄存器。
  2. 如果没有空闲寄存器,则根据某种策略(如最远使用)选择一个已分配的寄存器,将其当前值溢出(spill)到栈帧中,然后回收该物理寄存器用于分配。
  3. 记录分配/溢出决策到映射表中。

这个算法的关键在于高效地维护物理寄存器的空闲状态和虚拟寄存器的活跃区间信息。溢出代码的生成也需要小心处理,确保在需要使用溢出变量时,能正确地从内存加载回寄存器。

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 测试策略:从单元测试到系统集成

编译器的正确性至关重要,因此需要一套完善的测试体系。

  1. 单元测试:针对每个独立模块进行测试。

    • Lexer测试:输入一段代码字符串,验证输出的Token序列是否正确。特别要测试边界情况,如最长标识符、最大整数、非法字符等。
    • Parser测试:输入合法的/不合法的Token序列,验证生成的AST结构是否正确,或是否按预期报出语法错误。
    • 类型检查测试:构造类型正确和错误的AST,验证类型检查器能否通过或报出正确的错误信息。 我们使用类似以下的简单测试框架(也可以集成gtest):
    void 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; }
  2. 集成测试:测试多个模块协同工作。

    • 将一段SysY源代码输入编译器前端(Lexer+Parser+Semantic),检查是否能成功构建出符号表并完成类型检查。
    • 测试从AST到IR的生成是否正确。
  3. 端到端(系统)测试:这是最关键的测试。给定一个SysY源文件,运行完整的编译流程,生成RISC-V汇编文件(.s)。然后使用RISC-V的工具链(如riscv64-unknown-elf-gcc)将其汇编、链接,并在RISC-V模拟器(如spikeqemu-riscv64)中运行。最后验证程序的输出是否符合预期。我们可以编写一系列测试程序,覆盖语言的所有特性(变量、运算、控制流、函数、数组等),并自动运行这个流程。

4.3 调试技巧与性能剖析

调试编译器是一项富有挑战性的工作,因为错误可能出现在任何阶段,且现象往往在最后运行阶段才显现。

  • 打印中间结果:在各个阶段(Lexer, Parser, IR生成, 代码生成)结束后,打印出可读的中间表示。例如,打印AST的树形结构、打印IR指令列表、打印生成的汇编代码。这是最直接有效的调试手段。
  • 使用GDB/LLDB:在关键函数设置断点,单步跟踪执行流程,观察变量的状态。这对于理解复杂的寄存器分配算法或优化Pass的逻辑流非常有帮助。
  • 对比法:当对某个SysY程序生成的代码有疑虑时,可以先用一个成熟的编译器(如gcc -S生成x86汇编,或riscv64-unknown-elf-gcc -S生成RISC-V汇编)编译一个功能相同的C程序,对比两者生成的汇编代码,找出自己实现中的逻辑差异。
  • 性能剖析:当编译器本身运行缓慢时(例如处理大型数组初始化),可以使用gprofperf工具进行性能剖析,找出热点函数。常见的瓶颈可能在于符号表的查找(可考虑使用哈希表优化)、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 项目扩展与进阶优化思路

完成基础版本后,这个编译器项目还有巨大的扩展和优化空间:

  1. 支持更多语言特性:这是最直接的扩展方向。

    • 浮点类型:支持floatdouble类型,需要处理浮点算术指令、浮点寄存器分配(RISC-V的f扩展),以及整型浮点型之间的转换。
    • 数组:支持一维和多维数组。这涉及到数组声明的存储布局(行优先/列优先)、数组元素的地址计算、以及作为函数参数传递(通常退化为指针)。
    • 结构体:支持自定义结构体类型。需要处理结构体的内存对齐、成员访问、以及作为值传递或返回时的拷贝问题。
  2. 实现更强大的优化

    • 循环优化:实现循环不变式外提、归纳变量优化、强度削弱等专门针对循环的优化,对性能提升显著。
    • 函数内联:对于小函数,将其代码直接内联到调用处,可以减少函数调用的开销。
    • 全局值编号:比公共子表达式消除更强大的优化,可以识别出不同表达式中计算出的相同值。
    • 窥孔优化:在生成汇编代码后,进行小范围的指令替换,例如将addi x1, x0, 0替换为mv x1, x0
  3. 改进后端

    • 实现更优的寄存器分配算法:将线性扫描算法替换为图着色算法(如Chaitin算法),虽然更复杂,但能产生更好的分配结果。
    • 指令选择与DAG:将IR表示为有向无环图(DAG),然后基于模式匹配进行指令选择,可以生成更高效的代码。
    • 支持更多目标架构:抽象出后端接口,可以尝试为x86-64或ARM生成代码,加深对不同指令集差异的理解。
  4. 工具链集成

    • 实现简单的链接器:理解.o目标文件格式(如ELF),实现将多个编译单元链接在一起的功能。
    • 实现汇编器:将自己生成的文本汇编代码直接翻译成机器码,生成可执行文件。

实现这个编译器的过程,就像在亲手搭建一个复杂的、精密的系统。每一个模块的完成,每一个Bug的修复,都会带来巨大的成就感。它不仅仅是对《编译原理》课本知识的实践,更是对工程能力、调试能力和系统思维的一次全面锻炼。当你第一次看到自己编写的SysY程序,经过自己的编译器,变成RISC-V汇编,最终在模拟器上正确输出“Hello, World!”时,那种感觉是无与伦比的。希望这份详细的解析和实践报告,能为你开启自己的编译器探索之旅提供一份扎实的地图。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询