☰
C++从零实现SysY到RISC-V编译器:词法分析、语法分析到代码生成
2026/10/12 22:12:30 网站建设 项目流程

简介:面向编译原理课程实践与期末大作业场景,这份资源提供了一套基于C++实现的SysY到RISC-V编译器完整源码,并附带实践报告,覆盖了从词法分析、语法分析、中间代码生成到底层指令输出的完整编译链路。代码包含清晰注释,词法与语法规则文件分工明确,Koopa中间表示与RISC-V后端模块均按功能拆分,新手也可对照报告逐步理解各阶段原理,简单配置部署即可运行并验证生成的汇编结果。压缩包共27个文件,核心实现以10个hpp头文件和6个cpp源文件为主,另有文法定义、工程配置、测试样例及说明文档等辅助材料,总大小约108KB,结构紧凑、便于课设答辩前系统通读。目前已有136人浏览学习,适合正在完成编译原理课程设计或期末大作业、希望参考完整可运行项目以获得高分思路的本科生使用。

1. 为什么我要特意写一个 C++ 版的 SysY→RISC-V 编译器

编译原理课程实践里,用 C++ 从零写一个把 SysY 编译到 RISC-V 的编译器,听起来像「大作业黑话」,但它其实是一条能把词法分析、语法分析、符号表、代码生成全部串起来的完整链路。SysY 是课程常见的 C 语言子集,目标机选 RISC-V 是因为指令规整、工具链成熟,光是这一个项目就能覆盖「从源码到汇编」的每个关键环节。

适合谁?如果你正在做编译原理实验,或者准备课程设计,不想只复制别人的 LLVM 前端,那么这条可复现路径对你有用。下面所有实现都基于 C++17,配合常见的 RISC-V 交叉工具链就能跑通,不需要云端算力,也不绑定某个特定 IDE。它是那种「最终能跑程序、能写报告、能答辩」的完整方案。

2. 先定规矩:SysY 的语言边界与 RISC-V 的最小目标集

2.1 SysY 为什么砍掉 C 的指针和结构体

课程实践不会让你去实现一个完整的 C 编译器,因为 C 的指针别名、结构体布局、位运算、隐式类型转换都会把时间消耗在「与编译原理无关」的细节上。SysY 作为 C 的教学子集,删掉这些之后保留的是:int/float标量、一维与多维数组、函数定义与调用、if-else、while、break/continue、return,以及算术、比较和逻辑运算。有的课程版本还会加上const,但通常不加switch、typedef、struct、指针和位运算。

这个裁剪不是拍脑袋。去掉指针,语义分析阶段就不需要做别名分析和内存逃逸分析,符号表只要记录「变量是全局还是局部、类型是什么、在栈上的偏移是多少」。去掉结构体,就避开了字段偏移计算和内存对齐规则,数组寻址成为唯一的复合类型考点。剩下的int/float足够讲清楚类型检查、类型提升和赋值相容性。于是整个编译器的核心工作量就集中在:语法树怎么建、作用域怎么管、数组地址怎么算、控制流怎么转跳。

选 SysY 还有一个实际好处:它的语法与 C 高度相似,递归下降解析器可以按照常见 C 文法来写,不需要处理 C++ 那种模板或命名空间。而目标机选 RISC-V,同样是为了降低后端难度。

2.2 RISC-V 后端只需要这几类指令和寄存器约定

RISC-V 是一个开放的精简指令集,课程实践通常用 RV32IM 就够了:I 是基础整数指令,M 是乘除法扩展。为什么不用 x86?x86 的指令长度不固定、通用寄存器数量少但别名多、寻址模式复杂,从 AST 直接发射汇编时会被这些历史包袱拖累。RISC-V 的指令定长 32 位,汇编语法干净,而且gcc交叉工具链和qemu用户态模拟器在 Linux 下很容易装。

后端真正要发射的指令类别其实很少:

用途典型指令语法实例
算术加减add reg, reg, immaddi t0, t1, 4
乘除muldivremmul t0, a0, a1
内存读lwlbflwlw t0, 8(sp)
内存写swsbfswsw t0, -4(s0)
分支跳转beqbnebltbgebge t0, t1, label1
函数调用jaljalrjal ra, func
栈操作addi sp, sp, -32addi sp, sp, -32
返回jalr zero, ra, 0jalr x0, ra, 0

寄存器约定沿用标准 ABI:a0-a7传参数与返回值,sp是栈指针,s0/fp是栈帧指针,ra保存返回地址,t0-t6是临时寄存器。注意 RISC-V 汇编里寄存器名是裸的sp而不是$sp,这和 x86 的%rsp、MIPS 的$sp都不一样,很多人在第一次发射汇编时抄错。

后端只需要支持这些指令,就能覆盖 SysY 的控制流和数组访问。剩下的重点是栈帧布局:每个函数在入口处把sp向下调整若干字节,保存ra和旧fp,然后把局部变量按固定偏移放在新栈帧内。这个设计我在第 4 章会展开。

2.3 最小链路:写一个 SysY 小程序并跑通生成汇编

在动手写代码之前,先把目标流程固定下来。通常的做法是:编译器读入.sy文件,输出.s汇编文件;然后用riscv64-unknown-elf-gcc汇编链接,再用qemu-riscv64运行。下面这段 SysY 代码可以作为第一个回归用例:

// SysY 示例:计算 1 到 n 的累加和,期望返回 55 int main() { int n = 10; int i = 1; int sum = 0; while (i <= n) { sum = sum + i; i = i + 1; } return sum; }

这段代码覆盖了int声明、初始化、while、比较跳转、赋值和return,足够最早验证「编译器骨架通没通」。常见 SysY 变体会提供内建的getint()/putint(),如果你的课程版本没有,就不用在测试里依赖它们。

目标链路是:

# 1. 把 SysY 编译成 RISC-V 汇编 ./sysyc test.sy test.s # 2. 用交叉工具链汇编并链接 riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 -static -o test test.s # 3. 用用户态模拟器执行 qemu-riscv32 ./test echo $?

不同课程环境里交叉工具链名称可能不同,有的用riscv64-unknown-elf-,有的用riscv32-linux-gnu-,但思路一样。先把这条链路用任意一个能运行的汇编文件验证一次,之后再开发编译器就有了一台「测量机」:每个新功能做完,就把生成汇编丢进这条流程跑一遍,比对返回值。不要等整个编译器写完才开始测试。

3. 用 C++ 写前端:词法分析、递归下降与符号表的一次完整落地

3.1 Token 定义与词法状态机:手写比正则库更可控

前端第一件事是词法分析。很多同学会条件反射地想到用std::regex写规则,但实际经验是:正则库在 token 数量少时尚且慢,一旦要处理注释、字符串、数字格式和文件结束符,匹配边界会变得很难查。手写一个逐字符状态机,代码量不大,行为完全可预期,调试时打一行日志就能看到当前字符和状态。

常见做法是先定义 Token 类型与结构:

enum class TokenType { IntConst, FloatConst, Identifier, IntKw, FloatKw, IfKw, ElseKw, WhileKw, ReturnKw, BreakKw, ContinueKw, Plus, Minus, Star, Slash, Mod, Assign, Eq, Neq, Lt, Leq, Gt, Geq, AndAnd, OrOr, Not, LParen, RParen, LBrace, RBrace, LBracket, RBracket, Comma, Semicolon, End }; struct Token { TokenType type; std::string text; int line; // 行号,从 1 开始 int col; // 列号,从 1 开始 };

给出std::string text是为了报错时能把原始 token 打印出来,line和col则是后续语法错误和语义错误定位的关键。为什么不用int枚举值直接开搞?因为TokenType用枚举类,switch 分支时不会漏,而且编译器会帮你检查覆盖情况。

词法分析器核心是一个循环:

class Lexer { public: explicit Lexer(std::string_view src) : src_(src) {} std::vector<Token> run() { std::vector<Token> tokens; while (!at_end()) { skip_whitespace_and_comment(); if (at_end()) break; Token t = next_token(); tokens.push_back(t); } tokens.push_back({TokenType::End, "", line_, col_}); return tokens; } private: std::string_view src_; size_t pos_ = 0; int line_ = 1, col_ = 1; // next_token 内部按字符分类进行状态转移 };

这个结构里,run()负责循环,skip_whitespace_and_comment()处理空格、换行、//和/* */注释,next_token()对当前字符做分类。为什么要单独抽一个跳过注释的函数?因为注释在语言里没有语义,但你必须在词法层跳过它,否则注释内容会污染 token 流。我踩过的一个坑是块注释未闭合,导致整个文件被吞掉。处理方式是在skip_whitespace_and_comment()里检测到/*后持续读,直到遇到*/或文件结束,若文件结束则报「未闭合注释」错误。

数字识别需要小心:123是IntConst,1.5是FloatConst,.5在多数 SysY 变体里不合法。常见做法是读入数字部分后用strtof或自带的浮点解析来判断,同时在识别过程中记录当前字符是.还是e/E。我一般用一个小状态机:读到数字后继续读下一位,如果遇到小数点且后面还有数字,则转成浮点 token;如果后面没有数字,则报错「小数点左右必须有数字」。

3.2 递归下降语法分析:优先级和左递归怎么处理

词法分析结束后,语法分析器把 token 流变成抽象语法树。SysY 没有 C 的指针声明优先级问题,递归下降写起来非常顺手。表达式部分要处理「算术 > 比较 > 逻辑」的优先级,每个优先级对应一个函数,从最低优先级往下调。常见写法是:

class Parser { public: explicit Parser(const std::vector<Token>& tokens) : tokens_(tokens) {} std::unique_ptr<ast::Expr> parse_expr() { return parse_or(); } private: std::unique_ptr<ast::Expr> parse_or() { auto lhs = parse_and(); while (match(TokenType::OrOr)) { auto rhs = parse_and(); lhs = make_node<ast::BinaryExpr>(OrOr, std::move(lhs), std::move(rhs)); } return lhs; } std::unique_ptr<ast::Expr> parse_and() { auto lhs = parse_eq(); while (match(TokenType::AndAnd)) { auto rhs = parse_eq(); lhs = make_node<ast::BinaryExpr>(AndAnd, std::move(lhs), std::move(rhs)); } return lhs; } std::unique_ptr<ast::Expr> parse_eq() { auto lhs = parse_rel(); while (match(TokenType::Eq) || match(TokenType::Neq)) { auto rhs = parse_rel(); lhs = make_node<ast::BinaryExpr>(...); } return lhs; } // parse_rel -> parse_add -> parse_mul -> parse_unary -> parse_primary };

这里最关键的是左递归问题。add文法如果写成add: add + mul,直接翻译成parse_add()先递归调用自己,会死循环。解决办法是改成add: mul (+ mul)*,用while循环把左结合的运算收集起来。上面代码已经展示了这种模式:先解析右侧的下一优先级,再循环处理同一优先级的运算符。这种写法生成的 AST 是左结合的,1-2-3会被解析成(1-2)-3,而不是1-(2-3)。课程的语义判断题经常拿这个做文章,所以要格外注意。

比较运算没有链式语法,a < b < c会被解析成(a < b) < c,这在 C 里是合法但通常不符合意图的。SysY 一般也不允许,但语法上你不需要刻意禁止,后面类型检查发现(a < b)是布尔值而Bool不能参与<时就可以报错。实战里,我在parse_eq和parse_rel之间插入一层去处理高低优先级,顺序是or > and > eq > rel > add > mul > unary。

3.3 符号表与作用域:名字重复会坑在哪个环节

语法树构建完成后,接下来是语义分析,核心是符号表。SysY 的作用域规则和 C 类似:块作用域可以隐藏外层同名变量,函数作用域里参数也是局部符号。如果只用一个 unordered_map 保存名字到符号的映射,在进入{}后会忘不了外层符号,导致重复定义的检查失效。

实际项目里我建议用「作用域栈」:

struct Symbol { Type type; // 是 int 还是 float int offset; // 在函数栈帧中的偏移 bool is_global; bool is_const; int array_len; // 数组元素个数,-1 表示非数组 }; class ScopeStack { public: void push_scope() { scopes_.emplace_back(); } void pop_scope() { scopes_.pop_back(); } bool declare(const std::string& name, Symbol symbol) { auto& cur = scopes_.back(); if (cur.count(name)) return false; // 重复定义 cur.emplace(name, std::move(symbol)); return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes_.rbegin(); it != scopes_.rend(); ++it) { auto f = it->find(name); if (f != it->end()) return &f->second; } return nullptr; } private: std::vector<std::unordered_map<std::string, Symbol>> scopes_; };

push_scope在进入函数体或复合语句块时调用,pop_scope在离开时调用。为什么不用单张 map 加计数器?因为lookup需要从内层找到外层,栈结构天然支持:从顶层往下找,找到即返回。重复定义检查只需要看当前作用域,不需要管外层。

参数传递到这里时,一个最常见的出错点是:函数参数应该声明在最内层作用域,并且它们的offset要从sp正偏移开始算,而局部变量从sp负偏移开始算。如果统一用「当前局部变量数量」来算偏移,很容易把参数和局部变量放在同一个偏移上,运行结果就全乱了。我在第 5 章会专门讲这个。

4. 从 AST 到 RISC-V:IR 设计、栈帧布局与汇编发射

4.1 三地址码 IR:指令集设计与临时变量命名

语法树直接翻译成汇编会非常痛苦,因为 AST 的嵌套结构没法直接对应到寄存器分配。中间先翻译成三地址码(一种类似汇编但更抽象的 IR)是常见做法。三地址码每条指令最多一个运算符和三个地址,比如t1 = t2 + t3。

我使用的 IR 结构大致长这样:

enum class IROp { LABEL, // 用于跳转目标 ADD, SUB, MUL, DIV, REM, MOV, // 复制 LOAD, // 从数组元素取值 STORE, // 写入数组元素 JMP, JEQ, JNE, JLT, JLE, JGT, JGE, CALL, PUSH_ARG, RETURN, GLOBAL_LABEL, DECL }; struct IRInst { IROp op; std::string dest; // 目标临时变量或标签名 std::string a, b; // 源操作数,可以是常数、变量或标签 int imm = 0; // 辅助立即数,比如数组大小 };

临时变量命名我用%t0、%t1这样的字符串。为什么不用整数编号?因为后续寄存器分配阶段需要把临时变量以%tN形式输出到汇编注释里,字符串更容易 debug。每个函数体翻译时维护一个计数器,每新建一个临时变量就递增。LABEL则用L0、L1这类名字,避免和源程序里的标签冲突。

三地址码的翻译是递归遍历 AST 实现的。以赋值语句sum = sum + i为例,翻译逻辑是:先递归翻译右表达式的两个变量,得到两个临时变量%t1和%t2,生成ADD %t3, %t1, %t2,再把%t3传给赋值语句生成MOV sum, %t3。所有临时变量都不进符号表,只活在 IR 这一层,因此寄存器分配时可以把它们全塞进临时寄存器里,或者从栈上 load/store。

4.2 栈帧与符号表偏移:函数参数和局部变量怎么放

SysY 是过程式语言,函数调用需要管理栈帧。常用 ABI 里,a0-a7传前 8 个参数,多于 8 个则压栈;但为了简化,很多课程编译器选择把前 8 个参数也先压到栈上,生成统一布局。我建议这样做:进入函数时先保存ra和旧fp,然后把sp减小frame_size,之后所有参数都在fp的正偏移方向,所有局部变量都在fp的负偏移方向。

栈帧布局可以整理成一张表:

内容相对 fp 偏移说明
第 n 个参数fp + 8 + 4*n参数从右往左压栈,但这里按顺序访问即可
返回地址 rafp + 4有些变体也放在负偏移处
旧 fpfp + 0保存调用者帧指针
局部变量 0fp - 4第一个声明的局部变量
局部变量 1fp - 8第二个,依次向下

在符号表里记录offset,就是相对于fp的值。这样访问变量时,如果offset >= 0就用fp + offset,否则用fp - abs(offset)。特别要注意frame_size必须按 16 字节对齐,否则调用约定要求 sp 在函数调用边界保持 16 字节对齐,qemu 模拟时一般不报错,但实际硬件上会出问题。

计算frame_size的方法是:遍历函数的符号表,把所有局部变量占用的字节数加起来,再加上保存ra和fp需要的 8 字节,最后向上对齐到 16。注意数组在这里的计算方式和标量不同,int a[10]占 40 字节,不是 4 字节。

4.3 发射 RISC-V 汇编:一个 return 背后的 5 条指令

IR 翻译到汇编时,核心是把每个 IR 操作映射成 RISC-V 指令序列。最简单的映射是:先把每个变量从栈上加载到临时寄存器,运算后再存回去。这样不会产生复杂的寄存器分配问题,正确性优先,性能后面再优化。

示例:函数int foo(int x) { return x + 1; }的汇编骨架:

.text .globl foo foo: addi sp, sp, -16 # 分配栈帧,16 字节对齐 sw ra, 12(sp) # 保存返回地址 sw s0, 8(sp) # 保存旧帧指针 addi s0, sp, 16 # 建立帧指针 sw a0, 0(s0) # 把第一个参数 x 保存到栈上 lw t0, 0(s0) # 取出 x addi t1, t0, 1 # t1 = x + 1 mv a0, t1 # 返回值放入 a0 lw s0, 8(sp) # 恢复旧帧指针 lw ra, 12(sp) # 恢复返回地址 addi sp, sp, 16 # 释放栈帧 jalr zero, ra, 0 # 返回调用者

这段汇编是整个后端的模板。你可以看到return不是一条指令,而是一条 mov 加一段标准的函数结尾。如果忘记恢复s0或ra,调试时会发现上一个函数的栈帧被破坏,错乱得很难查。我一般把这段 prologue/epilogue 封装成emit_prologue(frame_size)和emit_epilogue(frame_size),每个函数生成时调用,避免在一堆指令里重复粘贴。

将 IR 翻译成这段汇编时,还有一点特别需要注意:IR 里的CALL指令必须区分是内建getint还是普通函数。内建函数可以直接调用链接进来的 sysy 库函数,而普通函数需要在每个调用点之前推送参数到a0-a7。如果你的 SysY 变体允许函数嵌套调用,务必在调用前把ra保存好,否则jal会覆盖返回地址。

5. 避坑与排查:我在这个课程实践中踩过的 5 个坑

5.1 编译器未包含 main 类型:入口函数为什么会被漏掉

现象:编译器生成汇编后,用交叉gcc链接时报错,类似undefined reference to _start或main没有被识别为入口函数。更典型的是报「编译器未包含 main 类型」,其实就是语义分析阶段没有检查到合法的main。

原因:词法或文法层面只处理了普通函数定义,没有对main做特殊约定。有的情况是符号表里把main的返回值类型登记成了void,而 SysY 规定入口函数应该是int main()。还有人在生成汇编时忘了在函数名前面加.globl,导致链接器看不到符号。

解决:在 AST 转 IR 之前增加一层「程序入口检查」:遍历所有函数定义,必须存在名为main且返回类型为int、参数为空的函数。同时编译器要向符号表登记main,并在汇编输出阶段给它的标签加.globl main。链接时还需要提供_start,如果工具链没有自动带,就加一个crt0或者链接-lc -lgloss。最省事的是用riscv64-unknown-elf-gcc -static,它会默认带上标准启动文件。

5.2 栈上偏移算错:寄存器值看起来对,变量值却总被覆盖

现象:程序能运行,但某些全局/局部变量在调试时显示预期值,一旦发生函数调用,回来之后值变成垃圾。典型例子是递归函数,第一次调用正确,第二次开始返回值错乱。

原因:把函数参数和局部变量都按同一个「当前栈顶偏移」来分配,没有给每个函数固定 frame。函数调用时,新函数的栈帧覆盖了旧函数的返回地址或局部变量,导致旧函数的s0被冲掉。

解决:在符号表设计里明确区分参数和局部变量。参数在fp正偏移,局部变量在fp负偏移。每次进入函数时先分配frame_size,而frame_size在函数翻译之前就要计算好。如果你偷懒不想算,先把所有局部变量统一用sp相对负偏移也可以,但必须在每次写变量前把sp调整为函数入口后的固定值。建议在第 4 章栈帧表的基础上,写一个单元测试:一个递归计算阶乘的 SysY 程序,跑一遍,能正确处理 5 的阶乘,栈帧管理基本就对了。

5.3 输出汇编无法被 gcc 编译:格式细节比逻辑更磨人

现象:编译器能吐汇编,但一交到 gcc 就报unexpected token at start of operand,或者relocation truncated,甚至unknown pseudo-op: .globl:. 这也是一部分人最终在课程答辩翻车的地方。

原因:一方面,把 MIPS 或 x86 的$0/%eax带进了 RISC-V 语法;另一方面,标签生成规则不对,比如在普通变量名后面直接加冒号,导致标签名和寄存器名冲突。还有就是.word和.align用错了。

解决:开一个文本调试入口,把你生成的汇编保存下来,用 gcc 的-v或直接查看报错行,逐个对照。RISC-V 汇编标签不能以数字开头,临时标签最好统一叫L1:这种格式。寄存器名用a0、sp、t0,不要带前缀。伪指令.globl main在主函数和全局函数前都要加,.开头一定在行首。最稳妥的方式是先把一个手写的hello.s编译通过,再用你的编译器生成同一逻辑的hello.s,做逐字节 diff。

5.4&&与||:短路求值变成「两边都算」

现象:if (a != 0 && 10 / a > 2)在a == 0时没有报除零错误,但程序竟然崩溃;或者一个函数调用被错误地执行了两次。

原因:没有处理短路语义。直接把&&翻译成「算左边、算右边、再 AND」,这样两个操作数一定都会被求值,和 C/SysY 的语义不一致。正确的做法是:左边为假时,整个表达式为假,不需要求值右边;同理||左边为真时,结果为真,不求值右边。

解决:在 IR 生成阶段,遇到&&时生成两个标签和一个跳转。例如a && b生成:先求 a,如果为假跳转到L_false;再求 b,为假跳转到L_false;否则L_true。||反过来:a 为真跳L_true,b 为真跳L_true,否则L_false。不要把它们当成普通and指令,否则副作用测试和浮点除零测试全部翻车。这个坑很隐蔽,但答辩时考官大概率会拿一个带副作用的样例来问。

5.5 实践报告写成说明书:答辩时最怕被问「怎么验证」

现象:报告用大篇幅贴源代码,把每个类从头介绍到尾,但没有说明设计取舍、没有测试覆盖、没有实验数据。答辩时考官问「你的编译器对浮点常量是个什么处理策略」,你只能说「我实现了」,然后场面会很冷。

原因:把「实践报告」理解成「源码注释汇总」。实际上课程实践报告需要体现的是「你为什么这么做、遇到了什么问题、如何验证」。

解决:报告至少包含三块:设计动机(为什么选三地址码而不是直接汇编)、实验环境与工具链版本、正确性验证(回归测试脚本、测试用例清单、运行结果对比)。我在最后写报告前会做一个测试矩阵:每个语法特性(数组、嵌套函数调用、短路、递归)对应一个.sy文件和期望退出码,把结果做成表格。答辩时直接展示这个表格比念源码有用十倍。

6. 用回归脚本锁死正确性,再把 IR dump 当成后悔药

验证工作不能靠「跑一个例子看结果对不对」,而是要有一个可复用的回归脚本。我习惯在项目根目录放一个run_tests.sh,格式大致是这样:

#!/usr/bin/env bash set -euo pipefail CASES_DIR=tests/cases OUT_DIR=tests/out mkdir -p "$OUT_DIR" for sy in "$CASES_DIR"/*.sy; do name=$(basename "$sy" .sy) ./build/sysyc "$sy" "$OUT_DIR/$name.s" riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 \ -static -o "$OUT_DIR/$name" "$OUT_DIR/$name.s" $SYSLIB qemu-riscv32 "$OUT_DIR/$name" >/dev/null 2>&1 got=$? expected=$(cat "$CASES_DIR/$name.ans" 2>/dev/null || echo "0") if [ "$got" -eq "$expected" ]; then echo "PASS: $name" else echo "FAIL: $name (got $got, want $expected)" exit 1 fi done

这个脚本把「编译、汇编、链接、运行、比对返回值」做成一个动作。每个新功能提交前跑一遍,旧功能没被弄坏,新功能也过了,这是低成本拿高分的习惯。注意脚本里的$SYSLIB一般是课程提供的 sysy 运行时库,如果没有就留空。

比回归更省心的调试技巧是给编译器加一个--dump-ir参数。在 IR 生成后把所有指令打印到标准错误,保留了中间表示就相当于有了后悔药:汇编不对时,先看 IR 对不对。如果 IR 对而汇编错,问题在发射阶段;如果 IR 已经错,问题在 AST 或 IR 翻译阶段。这个二分法能帮你省下一半的调试时间。

后来我给这个项目做了一个小优化:在 IR 层做常数折叠。3 + 4直接变成7,i = i + 0直接变成i = i。代码改动不大,但测试时看到生成的汇编少了几条指令,很有成就感。再往后可以尝试局部寄存器分配,把lw sth; add; sw简化为复用临时寄存器,但这属于锦上添花。我个人的教训是:先把不做任何优化的完整链路跑稳,再把优化当独立功能加。如果一开始就急着写寄存器分配,你会在「不知道正确结果是什么」的情况下调试出一堆看似正确的高性能代码。希望帮到你。

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

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

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

立即咨询