简介:面向编译原理课程设计与实验的 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 的起始地址在 $t3,i 的值在 $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)了。正确做法是先把数组起始偏移加到$t0:
lw $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 临时变量名重复导致结果互相覆盖
现象:生成的汇编在模拟器里跑,a=1、b=2、c=a+b,结果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, 100000,MARS 报错或者值被截断成负数。
原因: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」的黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取