简介:本资源是重庆大学编译原理课程配套的轻量级RISC-V编译器实践项目,面向计算机专业本科生及编译技术初学者,旨在通过从零构建完整编译流程,深化对词法分析、语法解析、语义检查、中间表示、优化策略与RISC-V目标代码生成等核心环节的理解。压缩包共87个文件,含14个CMakeLists.txt(支撑跨平台构建)、12个头文件(封装核心模块接口)、7个CPP源码(实现前端分析与后端代码生成)、8个说明类TXT文档(含实验指导书PDF与README),以及可执行bin、静态库.a、目标文件.o等,整体仅1.56MB,结构清晰、模块解耦,便于分阶段调试与学习。已有56人下载学习,资源以CQU-Stu.zip为参考蓝本,完整覆盖lexer/parser/ir/codegen四大子系统,附带编译原理实验指导书与LICENSE声明,适合课程实验复现、毕业设计参考及RISC-V编译器入门实战。
1. 项目概述与核心目标
最近在整理自己学生时代的项目资料,翻到了当年在重庆大学编译原理课程上做的一个大作业——一个轻量级的RISC-V编译器。这个项目当时花了我整整一个学期的心血,从对编译原理一知半解,到最终能跑通几个简单的测试程序,整个过程踩了无数的坑,也收获了最扎实的成长。现在回头看,虽然它的功能远不如GCC、LLVM那样强大,但作为一个教学实践项目,它完整地走过了从源代码到目标代码的全流程,对于理解编译器这座“冰山”水下90%的部分,有着不可替代的价值。如果你也正在学习编译原理,或者对“如何亲手造一个编译器”感到好奇,甚至是对RISC-V这个开放的指令集架构感兴趣,那么我这次的经验分享或许能给你一些直接的参考和启发。
这个项目的核心目标非常明确:不追求性能与兼容性,而是聚焦于实现一个能正确工作的编译器最小可行产品(MVP)。它需要能够将C语言的一个子集(我们当时限定为不支持指针和复杂数据结构的简单C语法)编译成合法的RISC-V 32位整数指令集(RV32I)汇编代码,然后通过标准的RISC-V工具链进行汇编和链接,最终在模拟器上运行。我们参考了网络上一些优秀的开源学生项目,比如ScienceLi1125/CQU-Stu.zip这个仓库,它提供了一个非常清晰的结构和部分基础模块,让我们能站在前人的肩膀上,专注于核心逻辑的实现而非项目框架的搭建。
2. 整体架构设计与技术选型
2.1 为什么选择C++和CMake?
当时选择C++作为实现语言,是经过一番考量的。编译器的开发本质上是对复杂数据结构的操作(抽象语法树AST、符号表)和字符串处理(词法分析、代码生成)。C++在提供高性能的同时,其面向对象的特性非常适合用来建模编译过程中的各个阶段。例如,我们可以定义一个基类ASTNode,然后派生出BinaryExprNode(二元表达式节点)、VariableNode(变量节点)等,这让代码的组织和后续的遍历操作(如语义分析、代码生成)变得非常清晰。相比之下,如果用C语言,可能需要大量使用联合体(union)和枚举来模拟这种层次结构,代码会显得冗长且容易出错。
构建工具我们选择了CMake,这几乎是现代C/C++项目的标配。一个清晰的CMakeLists.txt文件不仅能管理编译依赖,更重要的是它保证了项目可以在不同平台(Linux、macOS)上被轻松地构建。我们的CMakeLists.txt主要做了以下几件事:
- 设置C++标准为C++11,确保我们使用的智能指针、自动类型推导等特性可用。
- 将源代码分为几个逻辑目标:核心的编译器库(
libcompiler.a)、可执行文件(compiler)以及单元测试(tests)。 - 管理对RISC-V工具链的依赖。我们通过
find_program来查找riscv32-unknown-elf-gcc等外部工具,并在找不到时给出明确的错误提示,这比硬编码路径要友好得多。
注意:在项目初期就搭建好CMake框架和单元测试框架(如Google Test)是至关重要的。编译器开发中,调试一个深层次的逻辑错误往往非常耗时。有了单元测试,你可以在实现每个小模块(如词法分析器、某个语法规则的解析)后立即验证其正确性,将问题隔离在最小范围,这能极大提升开发效率和信心。
2.2 编译器前端:从字符流到抽象语法树
前端的工作是将源代码字符串转化为结构化的中间表示(IR),我们这里就是抽象语法树(AST)。这个过程通常分为词法分析(Lexical Analysis)和语法分析(Syntax Analysis, Parsing)两步。
2.2.1 手写词法分析器
我们没有使用Lex/Flex这样的工具,而是选择手写一个简单的词法分析器。原因很简单:我们处理的语法子集很小,关键字(如int,if,return)和操作符(+,-,==)有限,手写一个状态机反而更直观,也避免了学习另一套工具语法。词法分析器的核心是一个循环,逐个读取字符,根据当前字符判断token的类型。
例如,遇到第一个字符是字母,就进入“标识符/关键字”状态,持续读取后续的字母数字,直到遇到非字母数字字符为止,然后将收集到的字符串与关键字表进行比对,决定是返回关键字token还是标识符token。数字字面量的处理也类似,但需要注意十进制整数的格式。
// 简化的词法分析器核心逻辑示意 Token Lexer::getNextToken() { skipWhitespace(); // 跳过空格、换行、制表符 if (isEnd()) return Token(TokenType::END_OF_FILE, ""); char currentChar = peekChar(); if (isalpha(currentChar)) { // 识别标识符或关键字 std::string identifier; while (isalnum(peekChar())) { identifier += getChar(); } auto it = keywordMap.find(identifier); if (it != keywordMap.end()) { return Token(it->second, identifier); } return Token(TokenType::IDENTIFIER, identifier); } else if (isdigit(currentChar)) { // 识别整数常量 std::string number; while (isdigit(peekChar())) { number += getChar(); } return Token(TokenType::INTEGER_LITERAL, number); } else if (currentChar == '=') { // 可能是‘=’或‘==’ getChar(); if (peekChar() == '=') { getChar(); return Token(TokenType::OPERATOR_EQ, "=="); } return Token(TokenType::OPERATOR_ASSIGN, "="); } // ... 处理其他操作符如 +, -, *, /, (, ), {, } 等 }2.2.2 递归下降语法分析
语法分析我们采用了递归下降法,这是最直观、最适合手写解析器的方法。我们需要根据C语言子集的文法,为每一条语法规则编写一个对应的解析函数。这些函数会调用词法分析器获取token,并根据token预测应该使用哪条语法规则,同时递归地调用其他解析函数来构建AST。
例如,对于简单的表达式文法:Expr -> Term (('+' | '-') Term)*,Term -> Factor (('*' | '/') Factor)*,Factor -> INTEGER | '(' Expr ')' | IDENTIFIER。我们可以编写三个函数:parseExpression(),parseTerm(),parseFactor()。
// 解析表达式的简化示例 std::unique_ptr<ASTNode> Parser::parseExpression() { // 先解析一个Term auto left = parseTerm(); // 循环处理连续的加减法 while (currentToken.type == TokenType::OPERATOR_PLUS || currentToken.type == TokenType::OPERATOR_MINUS) { Token op = currentToken; getNextToken(); // 消费操作符 auto right = parseTerm(); // 构建一个二元表达式节点 left = std::make_unique<BinaryExprNode>(op, std::move(left), std::move(right)); } return left; }递归下降法的优点是与文法规则一一对应,代码可读性极强。缺点是对文法的要求比较严格(需要是LL(1)文法),有时需要重构文法来消除左递归。在我们的子集里,这个问题并不突出。
2.3 编译器中端:语义分析与中间表示
生成AST之后,并不能直接生成代码。我们需要遍历AST进行语义分析,确保程序的语义是正确的。这一步主要包括:
- 符号表管理:我们实现了一个简单的块作用域符号表。当进入一个函数体或复合语句时,会新建一个作用域;退出时则销毁。符号表中记录了变量的名称、类型(我们只有
int)、以及它在栈帧中的位置偏移量。当遇到变量声明时,将其加入当前作用域;当遇到变量使用时,逐级向上查找。 - 类型检查:在我们的简单子集中,类型检查主要是确保表达式中运算符两边的操作数类型兼容(都是
int),以及函数返回值的类型与声明一致。 - 常量折叠:这是一个简单的优化,可以在编译时计算常量表达式的结果,比如将
3 + 5直接替换为8,减少运行时开销。
语义分析的过程通常通过多次遍历AST来完成。第一次遍历可能用于构建顶层的函数符号表,第二次遍历进行类型检查和为局部变量分配栈空间。
2.4 编译器后端:目标代码生成
后端的目标是将带有语义信息的AST(或某种中间表示)翻译成目标平台的汇编代码。这是最贴近机器的一层,需要深刻理解目标指令集架构(ISA)。
2.4.1 RISC-V RV32I指令集简介
我们选择RISC-V RV32I作为目标,因为它设计精简、模块化,非常适合教学。RV32I是32位基础整数指令集,只有40多条指令,涵盖了基本的算术、逻辑、加载/存储、分支跳转等操作。几个关键特点:
- 寄存器:32个通用整数寄存器(x0-x31),其中x0恒为0,x1(ra)用于返回地址,x2(sp)是栈指针,x5-x7和x28-x31是临时寄存器,x8-x9和x18-x27是保存寄存器。
- 加载/存储架构:所有算术运算都在寄存器间进行,内存数据必须通过
lw(加载字)和sw(存储字)指令与寄存器交换。 - 简单的寻址模式:主要是“基址寄存器+立即数偏移”的模式。
2.4.2 从AST到汇编:一个函数的翻译
代码生成的核心思想是递归地遍历AST,为每个节点生成对应的汇编指令序列。我们采用栈帧模型来管理函数内的局部变量和计算过程。
- 函数序言(Prologue):在函数开头,需要分配栈空间并保存必要的寄存器(如返回地址ra、帧指针fp)。例如,一个需要4字节栈空间(存放一个int)的函数序言可能如下:
.globl main main: addi sp, sp, -16 # 在栈上分配16字节空间(按16字节对齐) sw ra, 12(sp) # 保存返回地址 sw fp, 8(sp) # 保存旧的帧指针 addi fp, sp, 16 # 设置新的帧指针 - 变量访问:局部变量被分配在栈帧中。假设变量
a在帧指针fp向下偏移4的位置(即fp-4),那么读取a的值到寄存器t0的指令是:lw t0, -4(fp)。给a赋值则是:sw t1, -4(fp)(假设值在t1中)。 - 表达式求值:表达式求值通常需要使用临时寄存器。编译器需要实现一个简单的寄存器分配策略。在我们的简单实现中,可以采用“遇到表达式就申请新寄存器”的朴素方法,或者使用一个固定的寄存器栈。例如,对于表达式
a + b * 2,生成代码的过程可能是:- 计算
b * 2,结果存入寄存器t0。 - 加载
a的值到寄存器t1。 - 执行
add t2, t1, t0,结果在t2中。
- 计算
- 控制流翻译:
if语句和while循环需要翻译成条件分支和无条件跳转指令。我们需要生成唯一的标签(Label)来标记跳转的目标位置。例如:
可能被翻译成:if (a > 0) { stmt1; } else { stmt2; }lw t0, -4(fp) # 加载a blez t0, .Lelse # 如果 a <= 0,跳转到else分支 # stmt1 的代码 j .Lend # 跳过else分支 .Lelse: # stmt2 的代码 .Lend: - 函数尾声(Epilogue):恢复保存的寄存器,释放栈空间,并跳转回调用者(
ret指令等价于jalr x0, 0(ra))。lw fp, 8(sp) # 恢复帧指针 lw ra, 12(sp) # 恢复返回地址 addi sp, sp, 16 # 释放栈空间 ret # 返回
3. 核心模块实现与关键代码解析
3.1 抽象语法树(AST)节点设计
AST节点的设计是整个编译器数据结构的核心。我们采用继承体系来构建。下面是一个高度简化的节点类定义示例:
// ASTNode.h class ASTNode { public: virtual ~ASTNode() = default; virtual void accept(ASTVisitor& visitor) = 0; // 访问者模式,用于后续的语义分析和代码生成 }; class ExprNode : public ASTNode {}; class BinaryExprNode : public ExprNode { public: Token op; // 操作符,如‘+’,‘-’ std::unique_ptr<ExprNode> left; std::unique_ptr<ExprNode> right; void accept(ASTVisitor& visitor) override { visitor.visit(*this); } }; class IntegerLiteralNode : public ExprNode { public: int value; void accept(ASTVisitor& visitor) override { visitor.visit(*this); } }; class VariableNode : public ExprNode { public: std::string name; void accept(ASTVisitor& visitor) override { visitor.visit(*this); } }; class StatementNode : public ASTNode {}; class AssignmentNode : public StatementNode { public: std::string varName; std::unique_ptr<ExprNode> expr; void accept(ASTVisitor& visitor) override { visitor.visit(*this); } }; class FunctionNode : public ASTNode { public: std::string name; std::vector<std::unique_ptr<StatementNode>> body; void accept(ASTVisitor& visitor) override { visitor.visit(*this); } };使用std::unique_ptr管理节点生命周期,可以避免内存泄漏的麻烦。访问者模式(Visitor Pattern)在这里非常有用,它允许我们创建不同的访问者类(如SemanticAnalyzer,CodeGenerator)来遍历AST并执行不同的操作,而无需修改AST节点类本身。
3.2 符号表与作用域管理
符号表需要支持嵌套作用域。一个简单的实现是使用一个作用域栈(std::vector<Scope*>),每个Scope是一个存储Symbol的映射表(std::unordered_map<std::string, Symbol>)。
class SymbolTable { std::vector<std::unique_ptr<Scope>> scopes; public: void enterScope() { scopes.push_back(std::make_unique<Scope>()); } void exitScope() { if (!scopes.empty()) scopes.pop_back(); } bool addSymbol(const std::string& name, const Symbol& sym) { if (scopes.empty()) return false; return scopes.back()->add(name, sym); } Symbol* lookup(const std::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; } };Symbol结构体至少需要包含变量类型和它在栈帧中的偏移量。在语义分析阶段,当声明一个变量时,我们计算其偏移量(例如,从栈帧底部向上或向下递增),并将其加入符号表。
3.3 汇编代码生成器
代码生成器是访问者模式的一个具体实现。它持有一个对输出流的引用(如std::ostream&),并在遍历AST时向流中输出RISC-V汇编指令。
class CodeGenerator : public ASTVisitor { std::ostream& out; SymbolTable& symTab; int tempRegCounter = 0; // 用于生成临时寄存器名,如t0, t1 int labelCounter = 0; // 用于生成唯一标签,如.L0, .L1 std::string currentFunc; std::string getTempReg() { return "t" + std::to_string(tempRegCounter++); } std::string getNewLabel() { return ".L" + std::to_string(labelCounter++); } public: CodeGenerator(std::ostream& os, SymbolTable& st) : out(os), symTab(st) {} void visit(BinaryExprNode& node) override { node.left->accept(*this); // 生成左子树代码,结果假设在寄存器A中 std::string leftReg = lastUsedReg; // 假设lastUsedReg记录了上一个结果寄存器 node.right->accept(*this); // 生成右子树代码,结果在寄存器B中 std::string rightReg = lastUsedReg; std::string destReg = getTempReg(); if (node.op.value == "+") { out << "add " << destReg << ", " << leftReg << ", " << rightReg << std::endl; } else if (node.op.value == "*") { out << "mul " << destReg << ", " << leftReg << ", " << rightReg << std::endl; } // ... 处理其他操作符 lastUsedReg = destReg; // 记录本次运算结果所在的寄存器 } void visit(AssignmentNode& node) override { node.expr->accept(*this); // 计算表达式,结果在lastUsedReg Symbol* sym = symTab.lookup(node.varName); if (sym) { // 假设sym->offset是相对于帧指针fp的负偏移 out << "sw " << lastUsedReg << ", " << sym->offset << "(fp)" << std::endl; } } void visit(FunctionNode& node) override { currentFunc = node.name; out << ".globl " << node.name << std::endl; out << node.name << ":" << std::endl; // 输出函数序言 out << " addi sp, sp, -16" << std::endl; out << " sw ra, 12(sp)" << std::endl; out << " sw fp, 8(sp)" << std::endl; out << " addi fp, sp, 16" << std::endl; // 为局部变量分配栈空间(偏移量已在语义分析阶段计算好) // 生成函数体 for (auto& stmt : node.body) { stmt->accept(*this); } // 输出函数尾声 out << " lw fp, 8(sp)" << std::endl; out << " lw ra, 12(sp)" << std::endl; out << " addi sp, sp, 16" << std::endl; out << " ret" << std::endl; currentFunc = ""; } };这是一个极度简化的框架,真实的代码生成器需要处理寄存器分配(可能寄存器不够用需要溢出到栈上)、函数调用约定(参数传递、返回值)、以及更复杂的控制流。
4. 构建、测试与调试实战
4.1 项目构建与集成
我们的项目根目录结构大致如下:
compiler-project/ ├── CMakeLists.txt ├── src/ │ ├── lexer/ │ ├── parser/ │ ├── ast/ │ ├── sema/ │ ├── codegen/ │ └── main.cpp ├── include/ (头文件) ├── tests/ (单元测试) └── examples/ (测试用的C源文件)main.cpp是编译器的入口点,它负责读取源文件、协调各个阶段(词法分析->语法分析->语义分析->代码生成)、并输出汇编文件。
// main.cpp 简化版 int main(int argc, char* argv[]) { if (argc != 2) { std::cerr << "Usage: " << argv[0] << " <source.c>" << std::endl; return 1; } std::ifstream sourceFile(argv[1]); std::string sourceCode((std::istreambuf_iterator<char>(sourceFile)), std::istreambuf_iterator<char>()); // 1. 词法分析 Lexer lexer(sourceCode); // 2. 语法分析 Parser parser(lexer); auto ast = parser.parseProgram(); // 解析整个程序,得到AST根节点 if (!ast) { std::cerr << "Parsing failed." << std::endl; return 1; } // 3. 语义分析 SymbolTable symTab; SemanticAnalyzer analyzer(symTab); ast->accept(analyzer); if (analyzer.hasError()) { std::cerr << "Semantic analysis failed." << std::endl; return 1; } // 4. 代码生成 std::string asmFileName = std::string(argv[1]) + ".s"; std::ofstream asmFile(asmFileName); CodeGenerator codeGen(asmFile, symTab); ast->accept(codeGen); asmFile.close(); std::cout << "Assembly generated: " << asmFileName << std::endl; return 0; }编译并运行:
mkdir build && cd build cmake .. make -j4 ./compiler ../examples/test.c这会生成test.c.s汇编文件。
4.2 使用RISC-V工具链进行汇编、链接与运行
生成汇编代码后,我们需要使用RISC-V的工具链将其转换为可执行文件。通常我们需要安装riscv32-unknown-elf-gcc(或riscv64-unknown-elf-gcc)工具链,它包含了汇编器(as)、链接器(ld)等。
# 假设工具链已安装,且前缀是 riscv32-unknown-elf- # 1. 汇编:将.s汇编文件编译成.o目标文件 riscv32-unknown-elf-gcc -c -march=rv32i -mabi=ilp32 test.c.s -o test.o # 2. 链接:将目标文件与运行时库(如crt0)链接成可执行文件 riscv32-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -T link.ld test.o -o test.elf # 3. 在模拟器上运行:使用Spike或QEMU spike pk test.elf # 或者使用qemu-user qemu-riscv32 test.elf这里有几个关键点:
-march=rv32i:指定目标架构为RV32I。-mabi=ilp32:指定应用程序二进制接口(ABI),表示int,long,pointer都是32位。-nostdlib:不链接标准C库,因为我们只实现了最基本的运行时环境。-T link.ld:指定链接脚本,用于控制程序的内存布局(如代码段.text、数据段.data的起始地址)。一个极简的链接脚本link.ld可能如下:
我们还需要提供一个最简单的启动文件OUTPUT_ARCH( "riscv" ) ENTRY( _start ) SECTIONS { . = 0x80000000; /* 模拟器通常从这个地址开始加载 */ .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }crt0.s,里面包含_start标签,负责设置栈指针,然后跳转到我们的main函数。
4.3 调试技巧与常见问题排查
开发编译器时,调试往往比普通程序更困难,因为错误可能发生在编译过程的任何阶段,且现象(如生成错误的汇编指令)与原因(如语义分析错误)相距甚远。
分阶段验证:这是最重要的原则。确保每个阶段(词法、语法、语义、代码生成)都有独立的测试。例如,为词法分析器编写测试,输入字符串
“int a = 42;”,检查它是否能正确输出[KEYWORD_INT, IDENTIFIER("a"), OPERATOR_ASSIGN, INTEGER_LITERAL(42), SEMICOLON]。可视化AST:实现一个打印AST的访问者(
PrintVisitor),将AST以缩进或树形结构打印出来,直观地检查语法分析是否正确。逐条对比汇编:对于简单的输入程序(如
return 3+5;),手动推导出你认为正确的RISC-V汇编指令序列。然后运行你的编译器,将输出与你的推导结果逐条对比。任何差异都可能是bug的线索。使用模拟器的调试功能:Spike和QEMU都支持调试。你可以用
spike -d pk test.elf启动Spike的交互式调试器,然后单步执行(s),查看寄存器(reg),这能帮你确认生成的指令是否按预期改变了机器状态。常见问题速查表:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 汇编器报“非法指令” | 生成了不存在的RISC-V指令 | 检查代码生成器中操作符到指令的映射表;确认目标架构是rv32i。 |
| 程序运行结果错误 | 算术逻辑错误或控制流错误 | 1. 检查表达式求值顺序和寄存器使用是否冲突。 2. 检查 if/while生成的跳转标签是否正确配对,跳转条件是否反了(比如该用bgt用了ble)。3. 使用模拟器单步调试,观察关键分支点的寄存器值。 |
链接器报“未定义引用_start” | 缺少启动文件或入口点不对 | 确保链接时包含了crt0.s或指定了正确的入口点(-e main)。 |
| 访问局部变量导致崩溃 | 栈帧计算错误或访存地址错误 | 1. 检查符号表中为局部变量计算的偏移量是否正确(相对于fp还是sp)。 2. 在函数序言和尾声,确认栈指针 sp和帧指针fp的操作是否正确(分配/释放空间是否匹配)。3. 使用调试器查看发生访存错误时,目标地址是否合理。 |
| 词法分析吞掉字符或卡住 | 状态机逻辑有漏洞 | 在词法分析器中加入详细的日志,打印每个状态转换和识别的token。 |
实操心得:在实现代码生成时,我强烈建议你先实现一个“打印AST”的访问者,再实现一个“模拟执行AST”的解释器。解释器直接在AST上求值,逻辑比生成汇编简单得多。先让解释器能正确运行测试用例,这相当于验证了你的前端和语义分析基本正确。然后再着手实现代码生成,此时你的目标就非常明确:让代码生成器产生的程序,达到和解释器一样的效果。这种“先解释后编译”的策略能有效分解复杂度。
5. 从课程项目到更深入的探索
完成这个基础编译器后,你不仅对编译原理的核心流程有了切身体会,更获得了一个可以持续迭代和扩展的宝贵框架。这里有几个方向值得深入:
支持更丰富的C语言特性:逐步添加对数组、指针、结构体、函数调用的支持。每增加一个特性,都会带来新的挑战,比如指针运算的类型检查、结构体的内存布局、函数调用时参数传递和栈帧管理(调用约定,如RISC-V的ABI)。
实现优化:在生成代码前,可以在AST或新设计的中间表示(IR)上进行优化。比如:
- 常量传播:将变量被赋值为常量的信息传播到使用处。
- 公共子表达式消除:识别并重用重复的计算。
- 死代码删除:移除永远不会被执行到的代码。 实现这些优化能让你更深入地理解编译器的“中端”。
生成更高效的汇编:实现一个简单的寄存器分配算法(如图着色算法或线性扫描算法),来更有效地利用有限的寄存器,减少对内存(栈)的访问,从而提升性能。
转向真正的中间表示(IR):目前我们直接从AST生成汇编,耦合较紧。可以引入一个与机器无关的中间表示(如三地址码、LLVM IR的简化版),将前端和后端解耦。这样,优化可以在IR上进行,并且后端可以更容易地适配新的指令集(比如ARM)。
使用更专业的工具:当你尝试支持更复杂的语法时,手写递归下降解析器会变得繁琐。可以学习使用Parser Generator(如Bison/Yacc)来根据形式文法自动生成解析器。这能让你更专注于语义动作而非解析细节。
这个轻量级RISC-V编译器项目,就像一把钥匙,为你打开了编译器和计算机系统底层世界的大门。它教会你的不仅仅是编译原理的知识点,更是一种系统性的工程思维——如何将复杂问题分解为多个可管理的阶段,如何设计数据结构来承载信息,以及如何严谨地测试和调试。当你看到自己编写的编译器,将几行简单的C代码变成一串串机器指令,并在模拟器上成功运行的那一刻,那种成就感是无与伦比的。希望我的这些经验,能帮助你少走一些弯路,更顺利地开启你自己的“造编译器”之旅。
本文还有配套的精品资源,点击获取