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

资讯详情

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

从零构建RISC-V编译器:编译原理实战与Mini-C语言实现

从零构建RISC-V编译器:编译原理实战与Mini-C语言实现 简介本资源是重庆大学编译原理课程配套的轻量级RISC-V编译器实践项目面向计算机专业本科生及编译技术初学者旨在通过从零构建完整编译流程深化对词法分析、语法解析、语义检查、中间表示、寄存器分配与RISC-V汇编生成等核心环节的理解。压缩包共87个文件含14个CMake配置脚本支撑跨平台构建、12个头文件与7个CPP源码覆盖前端分析与后端代码生成模块、12个Make相关文件含Makefile及缓存控制、8个说明类TXT文档含实验指导书PDF、README与LICENSE整体体积仅1.56MB结构清晰、模块解耦便于分阶段调试与教学复现。已有56人下载学习资源基于ScienceLi1125开源项目CQU-Stu.zip优化演进包含可运行的main.cpp入口、bin可执行体、build构建目录及nll等内部工具链组件提供真实可用的编译器骨架与典型测试用例是理论落地与工程实践结合的优质教学参考。1. 项目缘起与核心目标最近在整理硬盘翻到了当年在重庆大学读研时做的一个课程实验项目——一个轻量级的RISC-V编译器。说实话当时为了完成这个编译原理大作业熬了不少夜也踩了无数的坑。现在回头看这个项目虽然规模不大但麻雀虽小五脏俱全从词法分析、语法分析到语义分析、中间代码生成再到针对RISC-V指令集的代码生成完整地走了一遍编译器前端到后端的关键流程。当时我们参考了ScienceLi1125同学开源的CQU-Stu.zip项目结构但在此基础上做了大量的重构、优化和功能补充。今天我想把这个项目的完整构建过程、核心设计思路以及那些“教科书上不会写”的实操细节分享出来希望能给正在学习编译原理、尤其是想动手实现一个简易编译器的同学一些实实在在的参考。这不是一个理论教程而是一个从零到一的实战复盘我会重点讲清楚“为什么这么设计”以及“过程中遇到了哪些坑”。这个项目的核心目标非常明确通过亲手构建一个能真正将简化版C语言子集我们称之为Mini-C编译成RISC-V汇编的编译器来深刻理解编译器的各个阶段是如何协同工作的。我们选择RISC-V指令集一方面是因为其指令集架构ISA设计精简、模块化非常适合教学另一方面RISC-V生态正在蓬勃发展掌握其汇编和编译技术也很有实践价值。整个项目用C实现构建工具采用CMake最终产出的编译器能够处理变量声明、算术运算、控制流if-else, while等基本结构并生成可以在RISC-V模拟器如Spike或QEMU上正确运行的汇编代码。2. 项目整体架构与工具链选型在动手写第一行代码之前合理的架构设计和工具选型是项目成功的基石。我们的轻量级编译器采用了经典的多阶段流水线架构但针对教学和实验目的做了适度简化。2.1 编译器核心阶段划分我们的编译器主要分为以下五个阶段每个阶段对应一个或多个独立的模块类词法分析器Lexer将源代码字符流转换为有意义的词法单元Token流如标识符、关键字、运算符、常量等。语法分析器Parser根据预定义的文法规则将Token流组织成一棵抽象语法树AST。我们采用了递归下降Recursive Descent的解析方法因为它直观易懂适合手工编写。语义分析器Semantic Analyzer遍历AST进行类型检查、作用域管理和符号表Symbol Table的填充与查询。确保程序在语义上是正确的例如变量使用前是否已声明、函数调用参数是否匹配等。中间代码生成器IR Generator将经过语义检查的AST转换为一种中间表示IR。我们选择了一种非常简单的三地址码Three-Address Code形式例如t1 a b。IR的存在使得编译器前端与后端解耦便于后续的优化和针对不同目标平台的代码生成。目标代码生成器Code Generator将IR转换为目标RISC-V汇编代码。这是后端的主要工作涉及寄存器分配、指令选择、栈帧管理等内容。由于是教学编译器我们采用了简单的线性扫描寄存器分配和固定的调用约定。注意在真实的工业级编译器中每个阶段都可能非常复杂并且中间会有多次优化Optimization过程。我们的项目为了聚焦核心原理省略了大部分优化环节。2.2 开发环境与工具链编程语言C。选择C是因为其性能高、可控性强能够清晰地实现编译器中的各种数据结构如树、表并且很多经典的编译器如Clang、GCC本身也是用C编写的学习其代码风格有益处。构建系统CMake。这是现代C项目的标配可以跨平台Linux/macOS/Windows生成构建文件如Makefile或Visual Studio项目。参考的CQU-Stu.zip项目提供了基础的CMakeLists.txt我们对其进行了完善增加了更清晰的子目录管理、编译选项配置和测试目标。目标平台RISC-V 32位整数指令集RV32I。这是RISC-V最基本、最核心的指令子集足以支持我们的Mini-C语言功能。测试与运行汇编器与链接器使用GNU工具链中的riscv32-unknown-elf-as和riscv32-unknown-elf-ld。我们需要先交叉编译或直接安装RISC-V GNU工具链。模拟器使用spikeRISC-V ISA模拟器配合pkProxy Kernel来运行生成的RISC-V可执行文件。这是最方便的测试方式无需真实的RISC-V硬件。调试使用gdb调试编译器本身使用 spike 的--gdb选项配合riscv32-unknown-elf-gdb来调试生成的汇编程序。为什么选择这个工具链组合对于学习而言GNU工具链是最成熟、文档最丰富的选择。Spike模拟器由RISC-V官方维护能准确模拟硬件行为。整个工具链在Linux环境下搭建最为顺畅Windows用户可以使用WSL2或MSYS2环境。我们项目中的CMakeLists.txt会预设好编译器的路径但要求使用者提前在系统环境中安装配置好上述工具。3. 从零开始词法分析与语法分析实现编译器的前端始于词法分析和语法分析它们共同负责理解源代码的结构。3.1 手写词法分析器Lexer我们没有使用Lex/Flex这样的自动生成工具而是选择手写词法分析器。这对于理解Token是如何被识别出来的非常有帮助。核心类设计// token.h enum class TokenType { // 关键字 KW_INT, KW_RETURN, KW_IF, KW_ELSE, KW_WHILE, // 标识符 IDENTIFIER, // 字面量 LITERAL_INT, // 运算符 OP_PLUS, OP_MINUS, OP_MUL, OP_DIV, OP_ASSIGN, // - * / OP_EQ, OP_NE, OP_LT, OP_GT, OP_LE, OP_GE, // ! // 分隔符 SEMICOLON, // ; LPAREN, RPAREN, // ( ) LBRACE, RBRACE, // { } // 特殊 END_OF_FILE, // 文件结束 UNKNOWN // 未知字符 }; struct Token { TokenType type; std::string lexeme; // Token对应的原始字符串 int line; // 所在行号用于错误报告 int column; // 所在列号 }; class Lexer { public: Lexer(const std::string sourceCode); Token getNextToken(); private: std::string source; size_t position; size_t currentLine; size_t currentColumn; char peekChar(); char nextChar(); void skipWhitespaceAndComments(); Token scanIdentifierOrKeyword(); Token scanNumber(); Token scanOperatorOrPunctuator(); };实现要点与踩坑记录向前看字符LookaheadpeekChar()和nextChar()是核心。peekChar()只查看下一个字符而不移动指针用于判断nextChar()消费当前字符并移动指针。这对于识别双字符运算符和单字符运算符这类情况至关重要。错误恢复与报告当遇到无法识别的字符时不要直接崩溃。可以将其标记为TokenType::UNKNOWN记录位置然后跳过它继续解析。同时一定要记录Token所在的行号和列号这在语法报错时能提供极其有用的上下文信息。我们最初版本忽略了列号当一行代码很长时定位错误非常痛苦。注释的处理在skipWhitespaceAndComments()函数中需要处理单行注释//和多行注释/* ... */。处理多行注释时要注意嵌套问题虽然C语言不支持嵌套注释但作为健壮的Lexer应该能检测到不匹配的/*和*/并报错。关键字识别scanIdentifierOrKeyword()函数先扫描出一个标识符字符串然后在一个关键字映射表std::unordered_mapstd::string, TokenType中查找。如果找到就是关键字Token否则就是普通标识符Token。这种方法比为每个关键字写独立的判断逻辑更简洁。3.2 递归下降语法分析器Parser与AST构建语法分析器调用Lexer获取Token流并根据文法规则构建AST。我们为Mini-C定义了如下简化文法使用扩展巴科斯范式EBNFprogram :: function* function :: type identifier ( ) compound_statement type :: int compound_statement :: { statement* } statement :: declaration_statement | expression_statement | return_statement | if_statement | while_statement declaration_statement :: type identifier ; expression_statement :: expression ; return_statement :: return expression ; if_statement :: if ( expression ) statement (else statement)? while_statement :: while ( expression ) statement expression :: assignment_expression assignment_expression :: logical_or_expression ( assignment_expression)? logical_or_expression :: logical_and_expression (|| logical_and_expression)* logical_and_expression :: equality_expression ( equality_expression)* equality_expression :: relational_expression ((|!) relational_expression)* relational_expression :: additive_expression ((|||) additive_expression)* additive_expression :: multiplicative_expression ((|-) multiplicative_expression)* multiplicative_expression :: primary_expression ((*|/) primary_expression)* primary_expression :: identifier | integer_literal | ( expression )AST节点类设计我们为每种语法结构设计了一个继承自基类ASTNode的节点类。// ast.h class ASTNode { public: virtual ~ASTNode() default; virtual void accept(ASTVisitor visitor) 0; // 访问者模式用于后续的语义分析和代码生成 }; class ProgramNode : public ASTNode { std::vectorFunctionNode* functions; }; class FunctionNode : public ASTNode { std::string name; CompoundStmtNode* body; }; class CompoundStmtNode : public ASTNode { std::vectorStatementNode* statements; }; class VarDeclarationNode : public ASTNode { std::string type; std::string name; }; class ReturnStmtNode : public ASTNode { ExpressionNode* expr; }; class IfStmtNode : public ASTNode { ExpressionNode* condition; StatementNode* thenStmt; StatementNode* elseStmt; }; class WhileStmtNode : public ASTNode { ExpressionNode* condition; StatementNode* body; }; class AssignmentNode : public ExpressionNode { std::string lhs; ExpressionNode* rhs; }; class BinaryOpNode : public ExpressionNode { TokenType op; ExpressionNode* left; ExpressionNode* right; }; class IdentifierNode : public ExpressionNode { std::string name; }; class IntegerLiteralNode : public ExpressionNode { int value; };递归下降Parser的实现每个非终结符对应一个解析函数。例如解析加法表达式的函数// parser.cpp ExpressionNode* Parser::parseAdditiveExpression() { // 先解析一个乘法表达式作为左操作数 auto left parseMultiplicativeExpression(); while (currentToken.type TokenType::OP_PLUS || currentToken.type TokenType::OP_MINUS) { Token op currentToken; // 记录运算符 getNextToken(); // 消费掉运算符 auto right parseMultiplicativeExpression(); // 解析右操作数 // 创建二元运算节点注意这里左结合性的实现将之前的left作为新节点的左子树 left new BinaryOpNode(op.type, left, right); } return left; // 返回构建好的表达式子树 }关键技巧与常见问题左递归的处理上述文法中additive_expression的定义是左递归的A - A B。直接按照这个写递归函数会导致无限递归。我们通过循环如上代码所示来消除左递归这是标准做法。运算符优先级通过调用链来体现。parseExpression()会调用parseAssignmentExpression()后者调用parseLogicalOrExpression()依次向下直到parsePrimaryExpression()。越底层的函数解析优先级越高的运算符。这样表达式a b * c会被正确解析为a (b * c)。错误处理与同步当Parser遇到不符合文法的Token时例如期待一个;却遇到了一个标识符它需要报告错误并尝试恢复到一个同步点如下一个;或}以便继续解析文件的其他部分。我们实现了一个简单的错误恢复机制在遇到错误时会跳过一些Token直到遇到一个“同步Token集”中的Token。内存管理AST节点使用new在堆上创建。务必在析构函数或专门的清理函数中delete所有节点避免内存泄漏。也可以考虑使用智能指针如std::unique_ptr来简化管理。4. 语义分析符号表、作用域与类型检查语法正确的程序不一定有意义。语义分析器赋予程序意义其核心数据结构是符号表Symbol Table。4.1 符号表的设计与实现符号表用于记录程序中所有标识符变量、函数名的信息包括其类型、作用域、内存位置偏移量等。由于作用域可以嵌套如函数内定义的变量我们通常使用一个符号表栈。// symbol.h enum class SymbolType { VARIABLE, FUNCTION }; struct Symbol { std::string name; SymbolType kind; std::string type; // 如 int int scopeLevel; // 对于变量可能需要记录它在栈帧中的偏移量 int offset; // 对于函数可能需要记录参数列表本项目简化无参数 }; class SymbolTable { std::vectorstd::unordered_mapstd::string, Symbol tables; // 符号表栈 int currentScope 0; public: void enterScope(); void exitScope(); bool insert(const Symbol sym); std::optionalSymbol lookup(const std::string name); std::optionalSymbol lookupCurrentScope(const std::string name); };作用域管理进入一个新的复合语句块{时调用enterScope()压入一个新的空映射。退出一个复合语句块}时调用exitScope()弹出最顶层的映射。lookup()函数从当前作用域开始逐级向外层作用域查找模拟了标识符的遮蔽Shadowing规则。lookupCurrentScope()只查找当前作用域用于检测重复定义例如在同一作用域内定义两个同名变量。4.2 语义检查的访问者模式实现我们采用访问者模式Visitor Pattern来实现对AST的语义检查遍历。这比在AST节点类中嵌入检查逻辑更清晰也便于后续添加新的遍历操作如代码生成。// semantic_analyzer.h class SemanticAnalyzer : public ASTVisitor { SymbolTable symTable; std::string currentFunctionName; bool hasReturnStatement false; public: void visit(ProgramNode node) override; void visit(FunctionNode node) override; void visit(CompoundStmtNode node) override; void visit(VarDeclarationNode node) override; void visit(ReturnStmtNode node) override; void visit(AssignmentNode node) override; void visit(BinaryOpNode node) override; void visit(IdentifierNode node) override; // ... 其他节点的visit方法 };核心检查逻辑示例变量声明在visit(VarDeclarationNode)中将变量符号插入当前作用域的符号表。如果insert失败lookupCurrentScope找到同名符号则报“重复定义”错误。变量使用在visit(IdentifierNode)中调用symTable.lookup()查找该标识符。如果没找到报“未声明的标识符”错误。类型检查在visit(BinaryOpNode)中需要检查左右子表达式的类型是否兼容目前只有int类型所以主要是检查它们是否是合法的表达式。对于赋值语句需要检查左值是否是一个可修改的标识符目前就是标识符。函数返回检查在visit(FunctionNode)中进入函数体前设置currentFunctionName和hasReturnStatementfalse。遍历完函数体后检查hasReturnStatement对于声明返回int的函数我们所有函数都返回int如果没有return语句可以给出警告C语言中这是未定义行为我们按错误处理。控制流语句检查if和while的条件表达式类型是否为布尔值在我们的Mini-C中整数0为假非0为真所以条件表达式就是整型表达式这一步可以简化。踩过的大坑作用域进出时机最初我们在visit(CompoundStmtNode)的一开始就enterScope在结束时exitScope。这对于函数体是没问题的。但对于if或while语句后的那个语句可能是一个复合语句块也可能是一个单条语句我们错误地为单条语句也创建了新作用域导致变量生命周期混乱。正确的做法是只有遇到CompoundStmtNode即{ ... }时才创建新作用域。对于if (cond) statement如果statement不是复合语句则不应创建新作用域。符号表查找的“最近”原则实现lookup时必须从栈顶当前作用域开始向下查找。我们一开始写反了导致总是找到最外层的全局变量遮蔽规则完全失效。5. 中间代码生成三地址码与临时变量管理语义分析通过后我们就得到了一个带有类型和链接信息的“装饰过的”AST。接下来我们需要将其转换为一种更接近机器指令、但又不依赖特定机器的中间表示IR。我们选择三地址码Three-Address Code, TAC。5.1 三地址码指令集设计三地址码的基本形式是x y op z其中x, y, z可以是变量名、临时变量或常量。我们定义了一组简单的TAC指令// tac.h enum class TacOp { ASSIGN, // x y ADD, SUB, MUL, DIV, // 算术运算 x y op z NEG, // 取负 x -y // 比较和跳转 (用于实现if/while) EQ, NE, LT, GT, LE, GE, // 比较: if y op z goto L GOTO, // 无条件跳转 goto L LABEL, // 定义标签 L: // 函数相关 PARAM, CALL, RETURN }; struct TacInstr { TacOp op; std::string result; // 结果操作数可能为空如GOTO std::string arg1; std::string arg2; // 对于跳转指令arg1和arg2是比较的操作数result是目标标签名 };临时变量我们使用t1, t2, t3...这样的名字自动生成。标签用于控制流的跳转目标使用L1, L2, L3...生成。5.2 从AST生成TAC的访问者我们创建另一个访问者IRGenerator来遍历AST并生成TAC指令序列。// ir_generator.h class IRGenerator : public ASTVisitor { std::vectorTacInstr instructions; int tempCounter 0; int labelCounter 0; std::string newTemp() { return t std::to_string(tempCounter); } std::string newLabel() { return L std::to_string(labelCounter); } public: const std::vectorTacInstr getInstructions() const { return instructions; } void visit(BinaryOpNode node) override; void visit(AssignmentNode node) override; void visit(IfStmtNode node) override; void visit(WhileStmtNode node) override; // ... };生成逻辑示例二元运算a b c * d先递归处理c * d生成指令t1 c * d并得到结果临时变量t1。再处理b t1生成指令t2 b t1。最后处理赋值生成指令a t2。 访问BinaryOpNode的函数需要返回一个字符串变量名或临时变量名代表这个子表达式的结果所在的位置。if语句if (x y) { a 1; } else { a 2; }生成比较和条件跳转指令if x y goto L_true。生成goto L_end如果条件为假跳过then分支。生成标签L_true:。生成then分支的指令a 1。生成goto L_next跳过else分支。生成标签L_end:else分支开始处。生成else分支的指令a 2。生成标签L_next:if语句结束处。临时变量管理的经验临时变量在表达式求值时大量产生。我们需要一个机制来分配和管理它们。在简单的单遍编译中我们可以简单地按顺序生成t1, t2, t3...。但要注意在生成跳转指令时临时变量的“生命周期”可能跨越基本块Basic Block。在我们的教学编译器中为了简化我们假设有“无限个”寄存器不进行活跃变量分析和寄存器分配这个工作留到后端代码生成时再映射到有限的RISC-V寄存器上。更高级的实现会在生成TAC后进行一次简单的数据流分析消除一些不必要的临时变量。6. 目标代码生成RISC-V汇编输出这是编译器的最后一步也是最“硬核”的一步需要将TAC指令序列映射到具体的RISC-V汇编指令并处理函数调用约定、栈帧管理等系统性问题。6.1 RISC-V RV32I指令集概要我们需要用到的主要指令类别整数运算add,sub,mul,div注意RV32I没有乘除法指令它们是M扩展的一部分但模拟器通常支持。我们使用mul和div。逻辑运算and,or,xor,sll,srl等。加载/存储lw(load word),sw(store word)。用于在内存和寄存器之间搬运数据。条件分支beq(branch if equal),bne,blt,bge等。无条件跳转j(jump),jal(jump and link, 用于函数调用),jalr(jump and link register, 用于函数返回和间接跳转)。寄存器RISC-V有32个整数寄存器x0-x31。x0硬编码为0x1(ra)是返回地址x2(sp)是栈指针x5-x7,x10-x17,x28-x31是临时寄存器caller-savedx8(s0/fp),x9(s1),x18-x27是保存寄存器callee-saved。6.2 简单的寄存器分配策略工业级编译器使用图着色等复杂算法进行寄存器分配。我们采用一种极其简单的策略将所有的TAC临时变量和用户变量都分配在栈帧上使用sp指针加偏移量的方式来访问。运算时将需要的值从内存加载到临时寄存器如t0,t1计算完成后再存回内存。栈帧布局设计对于一个函数其栈帧从上高地址到下低地址可能包含返回地址由jal指令自动保存通常放在ra寄存器如果函数内调用其他函数则需要保存到栈上。保存的寄存器callee-saved registers如果本函数要用到s0,s1等需要先保存它们。局部变量和临时变量区。参数传递区我们的Mini-C无参数暂不考虑。我们使用一个简单的偏移量计数器来为每个变量分配栈上的位置。例如函数开头addi sp, sp, -framesize # 分配栈帧 sw ra, framesize-4(sp) # 保存返回地址如果需要 # 后续通过 lw t0, offset(sp) 和 sw t0, offset(sp) 来访问变量6.3 TAC到RISC-V汇编的翻译我们编写一个CodeGenerator类它遍历TAC指令列表为每条TAC生成对应的汇编片段。// codegen.h class CodeGenerator { std::ostream out; // 输出流如文件或std::cout std::mapstd::string, int varOffsetMap; // 变量名 - 栈帧偏移量 int currentOffset 0; int getOrCreateOffset(const std::string var); public: void generatePrologue(const std::string funcName); void generateEpilogue(); void generateInstruction(const TacInstr instr); // ... };翻译示例TAC:t1 a b查表或分配a,b,t1在栈上的偏移量off_a,off_b,off_t1。生成汇编lw t0, off_a(sp) # 将变量a加载到寄存器t0 lw t1, off_b(sp) # 将变量b加载到寄存器t1 add t2, t0, t1 # 计算加法结果在t2 sw t2, off_t1(sp) # 将结果存回t1对应的栈位置注意这里用了三个临时寄存器t0,t1,t2。在实际中我们需要一个更精细的寄存器分配器来复用寄存器避免频繁的加载/存储即“溢出”。TAC:if a b goto L1加载a和b到寄存器。生成汇编lw t0, off_a(sp) lw t1, off_b(sp) blt t0, t1, L1 # 如果 t0 t1跳转到标签L1函数调用与返回调用call foo翻译为jal ra, foo将返回地址存入ra并跳转。返回return x翻译为加载x的值到a0寄存器RISC-V的返回值寄存器然后恢复栈帧并跳转回ralw a0, off_x(sp) # 恢复保存的寄存器如果有 lw ra, framesize-4(sp) addi sp, sp, framesize ret # 伪指令等价于 jalr zero, ra, 0最大的挑战寄存器分配与优化我们最初的实现采用了最朴素的“每个变量都在内存”策略生成的汇编代码效率极低加载/存储指令占了绝大部分。作为改进我们实现了一个简单的局部寄存器分配在一个基本块内将最频繁使用的几个变量保留在寄存器中。这需要跟踪变量的“活跃性”当寄存器不够时选择将某个变量“溢出”到内存。即使这样一个简单的优化也能带来显著的性能提升。这部分代码是项目中最复杂也最有趣的部分之一。7. 项目构建、测试与调试实战理论设计最终要落地为可运行的代码。一个清晰的构建脚本和有效的测试方法至关重要。7.1 CMakeLists.txt 的配置艺术参考的CQU-Stu.zip中的CMakeLists.txt比较基础。我们对其进行了增强cmake_minimum_required(VERSION 3.10) project(mini_c_compiler CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 将源代码组织到不同的子目录中更清晰 include_directories(include) file(GLOB_RECURSE SOURCES src/*.cpp) add_executable(minicc ${SOURCES}) # 设置编译选项调试信息、所有警告、将警告视为错误保持代码质量 target_compile_options(minicc PRIVATE -g -Wall -Wextra -Werror) # 如果测试用例和编译器放在一起可以添加一个测试目标 enable_testing() add_test(NAME test_simple COMMAND minicc test/simple.c -o test/simple.s) # 然后可以用脚本自动汇编、链接、用spike运行并比较输出关键点使用GLOB_RECURSE可以自动收集所有源文件但大型项目可能更喜欢显式列出文件以保持确定性。-Werror在开发阶段非常有用强制消除所有警告培养良好的编码习惯。我们创建了test目录里面存放了从简单到复杂的Mini-C测试程序.c文件以及预期的输出。通过add_test集成到CMake/CTest中可以实现一键测试。7.2 测试流程与自动化完整的测试流程是编译./minicc input.c -o output.s汇编riscv32-unknown-elf-as -marchrv32i -o output.o output.s链接riscv32-unknown-elf-ld -o output.elf output.o可能需要链接运行时库我们项目简单没有库运行spike pk output.elf验证检查spike的输出是否与预期一致。我们编写了一个Python脚本run_tests.py来自动化这个过程import subprocess, os test_dir test for test_file in os.listdir(test_dir): if test_file.endswith(.c): base_name os.path.splitext(test_file)[0] # 步骤1-4 # ... # 步骤5捕获spike输出并与预期的.expected文件比较 # ... print(fTest {test_file}: PASSED/FAILED)7.3 调试技巧编译器本身 vs 生成代码调试分为两个层面调试编译器minicc使用gdb。在关键函数如Parser::parseXXXCodeGenerator::generateInstruction设置断点查看AST结构、符号表内容、生成的TAC序列等。std::cout打印日志也是简单有效的方法。调试生成的汇编代码这是更棘手的部分。使用spike的gdb模式spike --gdb-port9824 pk output.elf然后在另一个终端用riscv32-unknown-elf-gdb连接上去可以单步执行汇编指令查看寄存器值和内存内容。插入调试标签在代码生成时可以插入一些特殊的汇编注释或标签帮助定位源代码和生成汇编的对应关系。简化输入当遇到错误时首先创建一个最小的、能复现错误的测试用例。这能极大简化调试过程。一个难忘的Bug我们曾经遇到一个Bug在循环嵌套时生成的汇编会跳转到错误的标签。花了很长时间才发现问题出在IRGenerator为if和while生成标签时标签计数器在嵌套情况下被错误地共享和重复使用了。解决方案是为每个上下文如每个循环、每个条件语句使用独立的标签前缀或生成机制。这个教训是管理好生成式代码如标签、临时变量的“命名空间”至关重要。8. 总结与扩展思考构建这个轻量级RISC-V编译器的过程是一次对编译原理知识体系的深度串联和压力测试。从定义语言文法开始到手动实现词法分析、递归下降解析、构建AST、进行语义检查、生成中间代码最后映射到真实的RISC-V汇编指令每一步都充满了挑战也加深了对“程序如何从文本变成机器指令”这一核心过程的理解。这个项目虽然简单但已经涵盖了现代编译器的主要骨架。如果你有兴趣继续深入以下几个方向是很好的扩展点支持更复杂的类型加入float、char、数组和指针类型。这会让类型系统、中间代码表示和目标代码生成特别是内存布局和访问复杂很多。实现真正的寄存器分配学习并实现图着色Graph Coloring或线性扫描Linear Scan寄存器分配算法这将大幅提升生成代码的质量。添加优化遍Pass在生成TAC后或生成汇编前加入一些优化如常量传播、公共子表达式消除、死代码删除等。你会亲眼看到a 2 * 3;被优化成a 6;非常有成就感。支持函数参数和返回值实现完整的调用约定Calling Convention处理参数在寄存器和栈上的传递。连接标准库实现printf、malloc等基本运行时库函数的调用让你的Mini-C程序能做一些更有趣的IO操作。最后想对打算尝试类似项目的同学说不要被庞大的理论吓倒。从一个小而确定的目标开始比如先能编译int main() { return 42; }然后像搭积木一样一步步添加对、-、变量、if、while等的支持。每完成一个功能就测试一个功能。遇到问题善用调试工具和简化测试用例。这个过程收获的将远不止一个编译器本身更是对计算机系统底层运作的深刻洞察和解决复杂工程问题的系统性能力。本文还有配套的精品资源点击获取
返回列表