C-MIPS编译器实现:从递归下降到四元式生成汇编的完整指南
简介面向编译原理课程设计与实验的 C-MIPS 编译器实现资源完整实现了从 C 语言子集到 MIPS 汇编的编译流程包括词法/语法分析、语义分析、中间代码生成与优化、目标代码生成适用于高校编译原理实验、课程设计及相关系统软件研发学习者。资源共 26 个文件压缩包 1.26MB以 C 源码、PDF 实验报告、PNG/JPEG 运行截图及 Markdown/配置文件等为主便于对照代码理解各阶段实现细节。已有 533 人学习浏览。资源不仅包含可编译运行的编译器源码还提供编译原理实验报告与测试截图展示了利用 Flex、Bison 自动生成分析程序、遍历语法树构造符号表、基于 DAG 的中间代码优化以及寄存器分配等关键算法落地过程对理解编译器后端设计和 MIPS 目标代码生成很有帮助。1. 这个 C-MIPS 编译器到底解决什么问题编译原理课设做到最后大多数人的感受是词法分析还能手撕语法分析靠递归下降硬扛一到中间代码生成和 MIPS 目标代码整个流程就变成一个黑匣子。这个基于精简 C 语言的 C-MIPS 编译器输入是我们限定好的一个 C 语言子集没有指针、没有结构体、没有浮点输出是能在 MIPS 模拟器上跑的汇编程序。它解决的核心问题是用最少的语言特性把「源码 → Token → 语法树 → 四元式 → MIPS 汇编」这条编译主链路完整走通一遍。适合正在做编译原理实验、需要交一个能跑的编译器、又不想一开始就被完整 C 语法和寄存器分配算法拖垮的人。别急着写代码先把整体骨架立住后面每一步都能落地。2. 先把编译器骨架立住从 Token 到四元式的数据流设计2.1 三段式流程词法解析、语法分析、目标代码生成很多实验指导书会让你按教科书上的五个阶段来写词法分析、语法分析、语义分析、中间代码生成、目标代码生成。但真正动手做 C-MIPS 实验时我强烈建议把阶段合并成三条清晰的流水线词法分析器产出 Token 流语法分析器在递归下降的同时直接生成四元式最后四元式逐条翻译成 MIPS 汇编。语义分析和中间代码生成在递归下降的过程中同步完成不用单独维护一棵完整的抽象语法树再走一遍遍历。这么做的好处是省掉一层 AST 遍历的代码量坏处是语法分析和语义逻辑耦合在一起一旦语法规则改了四元式生成逻辑也要跟着调。但实验级编译器的语法子集本来就固定权衡下来这种合并是最容易写完的。整体数据流是// token.h —— 词法单元定义 typedef enum { TOK_IDENT, TOK_NUMBER, TOK_CHAR, TOK_INT, TOK_CHAR_KW, TOK_RETURN, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_ASSIGN, TOK_EQ, TOK_NEQ, TOK_LT, TOK_GT, TOK_LE, TOK_GE, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_COMMA, TOK_LBRACKET, TOK_RBRACKET, TOK_EOF, TOK_ERROR } TokenKind; typedef struct { TokenKind kind; char text[64]; // 标识符名字或字面量文本 int intVal; // 数字常量解析后的值 int line; // 出错的第几行排错全靠它 } Token;词法分析器的产出是Token数组而不是一边读一边丢给语法分析器。这也算一个经验提前把整个源文件切完语法分析阶段出错时能直接定位到具体的 Token不用管文件指针的位置。line字段在报错信息里必须保留否则语法错误只能靠猜。四元式结构体是整个编译器的中间枢纽语法分析器产出它MIPS 代码生成器消费它// ir.h —— 四元式定义 typedef enum { IR_ADD, IR_SUB, IR_MUL, IR_DIV, // 算术 IR_ASSIGN, // 赋值 IR_LABEL, // 跳转标签 IR_JMP, // 无条件跳转 IR_JLT, IR_JGT, IR_JLE, IR_JGE, IR_JE, IR_JNE, // 条件跳转 IR_CALL, IR_RET, IR_ARG, IR_PARAM, // 函数调用相关 IR_FUNC_BEGIN, IR_FUNC_END } IrOp; typedef struct { IrOp op; char arg1[32]; // 第一个操作数可以是变量名、常量字符串 char arg2[32]; // 第二个操作数 char result[32]; // 运算结果要写入的变量名 } Quad;四元式的三个槽位都设计成字符串而不是数字索引原因很朴素目标代码生成时不用再查表确认某个临时变量是编号几。字符串比较在实验规模下根本不会成为性能瓶颈但能让调试输出非常直观。把整个中间代码打印出来看printf一下就清楚语法分析对不对。2.2 寄存器与栈帧分配实验级编译器不需要寄存器分配算法这一步就是很多人卡住的点。一看到「目标代码生成」就想上寄存器分配算法其实完全没必要。实验级 C-MIPS 编译器最稳的做法是所有局部变量和临时变量都放到栈上寄存器只用来做单条指令的搬运工用完了立刻存回内存。寄存器实验中的用途说明$sp栈指针始终指向栈顶$fp帧指针当前函数的栈帧基址$t0-$t2临时寄存器算表达式时临时装数$a0传参/系统调用加载 syscall 参数$v0系统调用号/返回值打印或函数返回$ra返回地址jal 自动写入递归时必须自己保存规则只有三条函数开头把$ra和旧$fp压栈函数结尾恢复每个表达式求值时先从栈里把变量加载到$t0算完立即把结果存回栈数组访问一律先算地址偏移放到$t2再用lw/sw带偏移访存。这样每一条四元式都可以翻译成一段固定模板的 MIPS 汇编不用考虑寄存器的生存期和冲突代价只是生成的汇编跑得慢一点。实验交的是正确性不是性能慢一点无所谓。2.3 主控流程源文件进去汇编文件出来整个编译器的主函数只做四件事读文件、词法分析、语法分析生成四元式、把四元式翻译成 MIPS 汇编。命令行参数直接两个输入文件名和输出文件名。实验规定输入输出文件名时一般也是这个套路。// main.c —— 编译器主控 int main(int argc, char *argv[]) { if (argc 3) { fprintf(stderr, usage: cmips input.c output.s\n); return 1; } const char *src read_file(argv[1]); if (!src) { fprintf(stderr, cannot open file: %s\n, argv[1]); return 1; } Token *tokens tokenize(src, src_len); if (parser_errors 0) { dump_errors(); return 1; } Program *prog parse_program(tokens); if (parser_errors 0) { dump_errors(); return 1; } emit_mips(prog, argv[2]); printf(compilation succeeded - %s\n, argv[2]); return 0; }词法分析出错的直接返回、语法分析出错的直接返回编译失败的进程退出码是 1。写脚本批量测试的时候靠退出码就能筛选翻车用例。还有一条经验prog结构体里要挂一张全局符号表和一个四元式数组的指针这样 MIPS 生成器才能拿到完整的函数列表和所有指令——语法分析阶段可以边解析边往四元式数组里追加指令不需要最后再拼接。3. 精简 C 的语法子集文法设计决定后面好不好写3.1 支持的语言特性这几种语法就够撑起完整编译链路C-MIPS 实验里的「精简 C」各学校定义略有出入但主流范围基本一致int和char两种类型、一维数组、全局变量和函数内局部变量、if-else、while、return表达式支持加减乘除、关系比较、逻辑与或、括号改变优先级。函数支持递归调用参数个数一般限制在 4 到 8 个以内。不支持指针、结构体、浮点、位运算、switch、for循环和全局变量初始化表达式。这个规模选得非常刁钻它足够描述「斐波那契递归」「排序」「字符串输出」这类经典验证程序又避开了指针和强制类型转换这两个会让语义分析复杂度翻倍的特性。我建议你的文法只要覆盖以下产生式就够了program - { function_decl } function_decl - type IDENT ( [param_list] ) { { statement } } param_list - type IDENT { , type IDENT } type - int | char statement - { { statement } } | if ( expr ) statement [ else statement ] | while ( expr ) statement | return [ expr ] ; | IDENT expr ; | IDENT [ expr ] expr ; // 数组元素赋值 | IDENT ( [arg_list] ) ; // 函数调用语句 | ; expr - assign_expr assign_expr - logical_or_expr | IDENT assign_expr logical_or_expr- logical_and_expr { || logical_and_expr } logical_and_expr- equality_expr { equality_expr } equality_expr - relational_expr { (|!) relational_expr } relational_expr- additive_expr { (|||) additive_expr } additive_expr - term { (|-) term } term - factor { (*|/) factor } factor - ( expr ) | IDENT | NUMBER | CHAR | IDENT ( [arg_list] ) | IDENT [ expr ]这套文法最大的特点是运算符优先级编码在层级里||最低、乘除最高。递归下降解析器直接按函数层级互相调用不需要额外的算符优先级表。逻辑与或在这里翻译成四元式的条件跳转而不是TRUE/FALSE值这个后面细说。3.2 手写递归下降还是用 YACC实验别用生成器很多人刚上手会纠结要不要用flex/bison。我的建议是实验报告如果要求你必须手写那就老老实实递归下降如果没要求也推荐手写。使用 YACC 的代价是你得先学会 YACC 的文法规则和冲突消解方式调试期出错时它报的是shift/reduce conflict跟你怎么写语义动作没关系。而手写递归下降的规则只有一条——为文法的每个非终结符写一个同名函数遇到终结符就expect()遇到非终结符就调用对应函数。错误定位直观得多语法不对时能直接报「第几行缺什么」。唯一的坑是左递归。上面的乘法表达式用factor { (*|/) factor }这种写法是 EBNF 形式的重复而不是左递归递归下降里用一个while循环就能消化掉。千万别写成expr - expr term这种教科书文法手写解析器直接无限递归栈溢出。3.3 语法分析加四元式生成以赋值语句和 while 为模板一个最实在的经验四元式不要攒到最后一起生成在语法分析的同时就能产出。每个语句对应一段固定的四元式序列。拿赋值语句a b c * 2来说语法分析时依次做的是解析左侧变量名 → 调parse_expr解析右侧表达式 → 表达式内部递归调用时给每个运算分配临时变量 → 最后发出一个IR_ASSIGN四元式把临时变量值赋给a。// expr.c —— 表达式解析并生成四元式 char *parse_expr() { char *left parse_term(); while (peek() TOK_PLUS || peek() TOK_MINUS) { Token op next_token(); char *right parse_term(); char *tmp new_temp(); // 生成临时变量名 t1, t2, ... if (op.kind TOK_PLUS) emit(IR_ADD, left, right, tmp); else emit(IR_SUB, left, right, tmp); left tmp; } return left; }new_temp()和emit()是这个编译器里最重要的两个辅助函数。new_temp()维护一个全局计数器每次调用让它加一产出的名字是t1、t2……直到目标代码生成阶段tN就映射到栈帧里的一个固定槽位。emit()则是把一条四元式追加到全局数组同时在调试模式下打印出来。临时变量命名计数器必须全局唯一这是最多的翻车点后面避坑章详细讲。while语句的四元式生成是一个更好的模板因为它涉及标签和跳转的配合// stmt.c —— while 语句解析与四元式生成 void parse_while() { expect(TOK_WHILE); expect(TOK_LPAREN); char *label_cond new_label(); // L0 char *label_body new_label(); // L1 char *label_end new_label(); // L2 emit(IR_LABEL, , , label_cond); char *cond parse_expr(); // 条件成立跳转到循环体否则跳出循环 emit(IR_JE, cond, 0, label_body); emit(IR_JMP, , , label_end); emit(IR_LABEL, , , label_body); expect(TOK_RPAREN); parse_statement(); emit(IR_JMP, , , label_cond); emit(IR_LABEL, , , label_end); }这段代码的精髓在条件反转。表达式解析出的四元式是「计算出一个值」而循环控制需要「判断这个值是否非零」我们把等于零的情况跳转到结束标签非零的情况跳进循环体。new_label()同样是全局计数器生成形如L0、L1的标签名。跳进循环体之前先无条件跳一次到条件判断的开头保证条件表达式是在每次循环开始前重新计算的——这是while和if最本质的差别。4. 四元式翻译成 MIPS一条一条套模板别想着优化4.1 栈帧布局被调函数怎么对待内存目标代码生成阶段每个函数翻译成 MIPS 汇编时有一个统一的栈帧模板。栈帧从高地址到低地址依次存放返回地址$ra、旧帧指针$fp、局部变量区、数组区、临时变量区。函数入口先执行固定的三条指令出口执行固定的两条指令# 函数入口 subu $sp, $sp, FRAME_SIZE # 栈指针下移FRAME_SIZE 编译期算好 sw $ra, FRAME_SIZE-4($sp) # 保存返回地址 sw $fp, FRAME_SIZE-8($sp) # 保存上一层的帧指针 move $fp, $sp # 帧指针指向当前栈帧底部 # 函数出口 move $sp, $fp lw $ra, FRAME_SIZE-4($sp) lw $fp, FRAME_SIZE-8($sp) addu $sp, $sp, FRAME_SIZE jr $raFRAME_SIZE怎么确定语法分析阶段每遇到一个局部变量声明或临时变量就往函数结构体的frame_size字段累加 4。数组类型按数组元素个数乘 4 计算char数组也按 4 处理省掉对齐逻辑。所有变量在栈帧里的地址偏移是编译期算好的比如第一个局部变量偏移 0第二个偏移 4以此类推。这比每次访问变量时动态计算偏移高效得多也更不容易错。保存返回地址这一步就是递归函数的命门。如果不在入口保存$ra一旦jal进入下一层递归$ra就被覆盖了。很多人的递归代码跑一次正常、跑两层就跳飞就是这里漏了。4.2 算术运算和赋值lw 取数、运算、sw 存回四元式IR_ADD t1, t2, t3的翻译模板是最简单的模式它说明了一个重要的点——编译器生成汇编时要把四元式的字符串操作数换算成栈偏移// mips.c —— 四元式翻译成 MIPS 汇编 void emit_ir_to_mips() { for (int i 0; i quad_count; i) { Quad *q quads[i]; switch (q-op) { case IR_ADD: fprintf(f, lw $t0, %d($fp)\n lw $t1, %d($fp)\n add $t0, $t0, $t1\n sw $t0, %d($fp)\n, var_offset(q-arg1), var_offset(q-arg2), var_offset(q-result)); break; case IR_ASSIGN: fprintf(f, lw $t0, %d($fp)\n sw $t0, %d($fp)\n, var_offset(q-arg1), var_offset(q-result)); break; // ... 其他指令类型类似 } } }var_offset()是符号表和栈帧偏移之间的桥梁查变量名返回该变量在当前函数栈帧中的偏移量。变量名查不到时必须报内部错误这说明语法分析阶段的符号表没插全。IR_ASSIGN翻译过来就是从源地址lw到寄存器再从寄存器sw到目标地址。看起来多此一举但如果你直接从一个栈偏移sw到另一个栈偏移MIPS 指令不支持这种访存形式必须经过寄存器中转。除法指令要特别提醒MIPS 的div指令把商放在$lo、余数放在$hi需要再用mflo把商取出来。我见过太多人以为是div $t0, $t1, $t2三操作数形式结果汇编器直接报错。翻译模板里要写成lw $t0, A($fp) lw $t1, B($fp) div $t0, $t1 mflo $t0 sw $t0, RESULT($fp)4.3 数组访问地址计算比你想的要绕数组是 C-MIPS 实验里最容易写崩的目标代码。四元式IR_ASSIGN的arg1如果是数组元素得先把「数组名 下标表达式」翻译成地址再从这个地址取数。MIPS 不提供寄存器间接寻址时带另一个寄存器偏移的访存指令lw $t0, $t1($t2)在标准 MIPS32 里不存在。所以数组访问的翻译模板是显式计算地址# 数组访存a[i] b; 假设 a 的起始地址在 $t3i 的值在 $t4 lw $t5, 0($t3) # 先取数组基地址栈帧里存的是数组起始地址不这里直接用基址偏移算这里有个设计决策要预先定死实验级编译器推荐把数组空间直接放在栈帧里而不是在堆上动态分配。语法分析阶段声明数组时栈帧里给它预留size * 4字节数组变量本身对应栈帧里的「起始地址偏移」元素a[i]的地址就是「起始偏移 i * 4」。翻译成汇编# 元素地址 数组起始偏移($fp) 下标寄存器值 * 4 lw $t0, i_offset($fp) # 加载下标 i 的值 mul $t0, $t0, 4 # 下标乘 4字节偏移 lw $t1, array_offset($fp) # 取出数组起始位置的值不对这里要分清楚「数组在栈帧里的位置」和「数组的起始地址如何表示」。因为我们把数组空间内嵌在栈帧里数组的起始地址就是$fp array_offset它不是一个存储的值而是一个可以立即算出来的地址。所以正确的翻译是# a[i] 的地址 $fp array_start_offset i * 4 lw $t0, i_offset($fp) # 加载下标 i mul $t0, $t0, 4 # i * 4 addu $t0, $fp, $t0 # $t0 $fp i*4 临时 lw $t1, array_start_offset($t0) # 错误偏移必须是常数这就是刚才说的坑lw指令的偏移只能是立即数。所以把$fp加进去之后就没法用lw constant($t0)了。正确做法是先把数组起始偏移加到$t0lw $t0, i_offset($fp) mul $t0, $t0, 4 addu $t0, $t0, $fp # $t0 $fp i*4 lw $t1, array_start_offset($t0) # array_start_offset 必须是常数此时 $t0 指向数组起点等一下addu $t0, $t0, $fp之后$t0是$fp i*4那么加上常量array_start_offset才是a[i]的地址。指令是lw $t1, array_start_offset($t0)。没错这样写是对的。如果数组下标是常量还能让生成器在编译期直接算出array_start_offset const*4作为立即数字段完全省掉运行时的乘法和加法。实验报告里把这个特判写上是一个很好的加分点。4.4 函数调用参数压栈和返回值传递函数调用四元式IR_CALL的翻译是编译器里逻辑最密的一段涉及调用者保存参数、被调用者保存$ra、返回值传递三件事。实验中我用的简化约定调用者把实参依次压入栈顶然后jal跳转被调用者通过$fp 固定偏移访问参数返回值统一放到$v0调用结束后调用者把返回值从$v0搬到目标变量。// mips.c —— 函数调用翻译 case IR_CALL: { // 先把实参从栈帧加载到参数寄存器 $a0-$a3超过4个参数就直接按栈传 for (int j 0; j q-arg_count j 4; j) { fprintf(f, lw $a%d, %d($fp)\n, j, var_offset(q-args[j])); fprintf(f, subu $sp, $sp, 4\n); fprintf(f, sw $a%d, 0($sp)\n, j); // 压栈传参 } fprintf(f, jal %s\n, q-result); // result 存的是函数名 // 返回值从 $v0 存到目标变量 if (q-arg1[0] ! \0) { fprintf(f, sw $v0, %d($fp)\n, var_offset(q-arg1)); } break; }这套约定不是最优的但它是自洽的。参数压栈、通过$fp访问、返回值固定$v0不给被调用者留下「我的参数在哪」的悬念。IR_RET的翻译则是在函数出口模板的基础上先把返回值表达式加载到$v0再走统一的出口三连。5. 排查与避坑C-MIPS 编译器最容易翻车的 5 个节点5.1 临时变量名重复导致结果互相覆盖现象生成的汇编在模拟器里跑a1、b2、cab结果c永远等于最后一个算出来的操作数。看四元式所有的临时变量都叫t1。原因new_temp()的计数器没有做全局唯一。常见发生在表达式递归解析时每次进入parse_term()都重新初始化临时变量序号或者不同函数里各维护了一个独立计数器。临时变量名在四元式和 MIPS 翻译阶段承担着「这个值暂时放哪个栈槽」的职责同名就意味着栈槽冲突。解决new_temp()里用一个文件作用域的 static 计数器全编译器只有一个从t1开始只增不减。5.2 递归函数一调用就返回乱跳现象单个非递归函数编译出来的程序完全正常一旦函数递归调用自身第二层返回后 PC 直接飞到未知地址或者在函数返回时报栈溢出。原因函数入口没有保存$ra和旧$fp。jal指令会把返回地址写到$ra但递归调用时外层$ra还没被使用就被内层覆盖了。更隐蔽的是$fp内层函数执行move $fp, $sp后外层的帧指针丢了后续所有变量访问偏移全部错位。解决严格按第 4.1 节入口模板操作$ra和$fp的保存必须在任何局部变量分配之前完成。检查汇编代码时直接数一下函数第一条指令是不是subu第二种是不是sw $ra。5.3 标签标号重复或无限增长现象多个if语句嵌套时汇编器报label redefinition或者某个分支跳转到错误的地址。看生成的 MIPS 代码发现每个if-else生成的标签都叫L1、L2。原因标签生成计数器与临时变量同一个毛病——局部计数或重复初始化。有些代码把new_label()实现成每次从 0 开始那第二个if语句生成的标签必然冲突。解决与new_temp()同理全局唯一计数器进if、while时调用的new_label()本质是一次「申请新名字」绝对不能重复使用同一个标签对象。调试时加一个断言emit(IR_LABEL ...)之前检查标签名是否已经出现过。5.4 生成汇编开头缺 main链接时报错或模拟器无法启动现象程序编译成功但模拟器加载运行时报「no main」或直接闪退。出现的原因大多是报告里写的运行方式——用 MARS 加载汇编文件后它从.text段第一条指令开始执行如果全局变量初始化代码在最前面PC 就会执行到数据段。这不是汇编错误是启动代码的边界条件。原因编译器生成的.text段里第一个函数恰好不是main或者没有在开头生成跳转到main的启动指令。解决MIPS 程序约定从main开始执行我们生成的汇编开头必须有且仅有一条跳转指令.text .globl main j main # 程序入口先跳到 main 函数然后函数定义的顺序按源文件顺序排列即可。还有一个相关坑全局变量不能作为「执行语句」出现在.text段必须全部放到.data段并在main入口处把全局变量所在的.data段地址加载到寄存器。做实验时如果没要求支持全局变量的运行期初始化推荐全部用.word 0静态初始化省掉启动代码。5.5 立即数乘除的边界问题不要把 16 位立即数的锅甩给汇编器现象源码里写x 100000;生成的汇编是addi $t0, $zero, 100000MARS 报错或者值被截断成负数。原因MIPS 的addi、ori这些指令的立即数字段只有 16 位取值范围是-32768 到 32767。超过这个范围的常量必须用lui ori组合加载或者直接用伪指令li。实验里一大半人栽在这上面。解决设计目标代码生成器时常量加载单独写一个load_constant(target_var, value)函数超过 16 位时生成两条指令lui $t0, 0x0001 # 高 16 位 ori $t0, $t0, 0x86A0 # 低 16 位 sw $t0, offset($fp)小于 16 位但为负数时也别想当然地用addi $t0, $zero, -5这是合法的。边界条件是-32768只要常量值在这个范围外一律走lui。如果目标是 MARS 模拟器直接输出li $t0, 100000这种伪指令模拟器也会正确展开成两条指令但如果你是严格模式需要生成纯 MIPS32 指令就必须自己处理。6. 验证方法用 MARS 和 syscall 搭一个最小测试闭环编译器写完最迫切的问题不是「能不能编译」而是「编译出来的汇编对不对」。建议流程是先用 MARS 模拟器验证单条指令看寄存器和内存值再写一批边界测试小程序。日常验证一个小程序记得在汇编里加输出逻辑MARS 的 syscall 有两种用法# 打印整数 $v0 1, $a0 要打印的值 lw $a0, result($fp) li $v0, 1 syscall # 打印字符串 $v0 4, $a0 字符串地址 la $a0, msg li $v0, 4 syscall # 程序退出 $v0 10 li $v0, 10 syscall.data段要手动声明字符串并带换行符例如msg: .asciiz hello\n。用 MARS 跑的时候我的习惯是先用「单步执行」跑三遍分别看第一次双击执行、递归调用进入和退出、数组越界附近。单步执行能看到每条指令执行前后寄存器和栈的变化这是定位 5.2 那种递归跳飞最快的办法。确认单点无误后再做全流程自动化测试写个 shell 脚本把每个测试文件编译成.s再用 MARS 命令行模式跑结果比对。命令行下 MARS 支持-nc关闭交互加-p指定程序参数这个比手动点按钮跑测试高效得多。扩展方向优先做两个一是char类型的完整支持包括字符常量和字符串参数传递这涉及内存对齐的细微差别二是和||的短路求值现在的四元式把逻辑运算当成普通算术算遇到a ! 0 b / a 1这种表达式会错误地先执行b / a正确做法是为逻辑表达式生成带标签的跳转序列而不是当成普通二元运算。这两个扩展足够你把它从「实验提交」升级成「可以给别人演示的工具」。我自己的血泪教训就是一开始别急于写代码生成器先把四元式的打印调通每一步语法分析产出的中间代码都能看懂再去做 MIPS 翻译。中间代码一旦正确目标代码只是套模板中间代码错了目标代码的 bug 会把你绕晕最后用 MARS 单步调半天发现是四元式的事。还有一句忠告不要相信自己的记忆每改一次生成器就重新跑一遍之前的所有测试用例保证旧功能没被改坏。这个习惯能让你避开无数个「修好 A 却弄坏 B」的黑匣子。希望帮到你。本文还有配套的精品资源点击获取