简介:基于精简C语言实现C-MIPS编译器的完整实验资源包,紧扣编译原理课程分阶段实验要求,适用于需要完成词法语法分析、静态语义、中间代码生成与优化、目标代码生成四部分任务的高校学生。资源共26个文件,包括Flex词法文件lex.l、Bison语法文件parser.y、Analysis.c、TargetCode.c等核心C源码,附实验报告PDF与README说明,另有17张运行截图和过程图片便于核对结果,压缩包仅1.26MB,结构紧凑。已有533人学习,资源清晰呈现了从抽象语法树打印、符号表构造到DAG优化与寄存器分配的完整链路,可帮助读者快速建立对编译器整体工作流程的工程化认知,也适合作为计组CPU联动项目的前期参考。
1. 这真的是在写编译器吗:C-MIPS 实验的本质与工作量评估
先把话撂在这:基于精简 C 语言的 C-MIPS 编译器不是说让你去写一个能编译全部 C99 的怪物,也不是让你输出一个能在真机上跑出可执行文件的完整工具链。绝大多数编译原理实验的设定是——你拿到一个 C 语言的子集(去掉指针运算、结构体、递归、甚至数组下标越界检查),把它翻译成 MIPS 汇编,最后在 SPIM 或 MARS 这类教学模拟器上跑出正确结果。目标很朴素:让课堂上的词法分析、语法分析、语义分析、中间代码生成、目标代码生成这些概念,变成一个按一下回车就能看到输出的工具链。这个东西能解决的问题只有一个——证明你真听懂了编译器的流水线,而不是只会背 Chomsky 四型文法。适合谁来上手?两类人:一类是正在做课程设计、被代码框架逼到墙角的本科生;另一类是工作后想回头补编译原理内功、但对 Lex/Yacc 和 LLVM 都心存敬畏的开发者。说句实话,这个实验的代码量通常在 2000 到 5000 行之间,如果选 C 语言手写递归下降,工作量集中在语法分析和目标代码生成这两块,前面乐意花时间把 Token 流设计好,后面生成 MIPS 时能少掉一半头发。后面我会把你从「这是什么」一路带到「怎么把 3000 行代码写出来不翻车」,每步都给出可抄的框架和参数,还会把最容易让实验分崩离析的坑提前标出来。
如果你还在犹豫值不值得做,我可以给个参考视角:这门实验的性价比极高。它不像做操作系统那样需要面对中断和内存管理的大量玄学,也不像做数据库那样绕不开磁盘 IO。编译器实验的整个流程是线性推进的——词法分析结束后语法分析,语法分析结束后生成中间表示,最后一把梭到 MIPS。只要每个阶段老老实实把中间结果打印出来做断言,Bug 的可定位性很强,血泪经验是:超过 80% 的编译错误不是算法问题,而是你没在一个阶段结束时检查输出。这个特性决定了它是所有体系结构实验里最不容易彻底失败的,但也是最容易因前期设计潦草而在后期推到重写的。所以,先别急着写代码,把我们要做的这几层想明白,顶过第一周的纠结,后面就是捋顺的活。
2. 整体架构选型:先把分层定下来,再谈怎么写代码
2.1 C 语言子集的定义:决定你工作量上限的边界
动手之前必须先跟实验文档做一次「讨价还价」——拿笔把文档里允许出现的语法逐个列出来。常见的 C-MIPS 编译器实验允许的子集大概是:全局变量和局部变量(int、char),一维数组,算术表达式(加减乘除取模),关系运算和逻辑运算,if-else、while、for,break、continue,无返回值函数和有返回值函数,参数支持值传递,不支持指针、不支持结构体、不支持switch、不支持递归。为什么要把边界画得这么清楚?因为递归意味着你需要维护动态栈帧,指针意味着你要在 MIPS 里做地址运算和类型推导,这两件事会瞬间让你的工作量翻倍。如果你拿到的是一个能自由选做加分项的题目,我的建议是:先做基准子集,跑通全流程后再考虑要不要加递归。把基准跑通了,哪怕一分附加题不做,及格以上是稳的。
定了子集之后,还要定一个东西:语法中括号的匹配规则和运算符的结合性。比如a = b = c这种连续赋值是否支持?-2 * 3里的减法是一元还是二元?这些细节在课本上没有统一规定,但你的语法分析器里必须有明确回答。我的处理方式是给出一份「白纸黑字的子集参考表」,放在实验报告的开头部分,既是文档也是实现时的 Checklist。以下是我常用的一份:
| 语言特性 | 是否支持 | 说明 |
|---|---|---|
| 数据类型 | int, char | char 按 8 位存储,参与运算时按 int 处理 |
| 一维数组 | 支持 | 拿 int a[16]; 数组下标只允许整型表达式 |
| 指针 | 不支持 | 不涉及 &、* 运算 |
| 结构体/联合体 | 不支持 | 这块直接砍掉 |
| 控制流 | if-else, while, for, break, continue | 支持嵌套 |
| 函数 | 支持 | 最多支持 8 个参数;不支持递归 |
| 运算符 | + - * / % < > <= >= == != && || ! | 支持括号改变优先级 |
| 全局变量 | 支持 | 初始化为 0 |
2.2 编译器整体流程与各阶段职责划分
这一节的目的是让你在写第一行代码之前,脑子里有一张完整的「数据流图」。常见的实现方案是把编译器拆成四个独立 Pass,每个 Pass 读入上一个 Pass 的输出。词法分析器读入源码字符流,产出 Token 流;语法分析器基于上下文无关文法做递归下降或 LR 分析,产出抽象语法树;语义分析器遍历 AST,维护符号表,检查类型匹配和变量未声明;最后一步是目标代码生成,遍历 AST 并把每个节点映射到一段 MIPS 指令。实验代码自己维护这四个 Pass,中间产物可以用文件导出,方便你排查错位。
这里有个非常关键的经验:不要一上来就想着跳过 AST、直接在语法分析同时生成目标代码。很多实验指导书为了降低难度会建议在语法分析时就输出四元式,但这会把语义检查搞得一团糟。例如a[i + 1] = 2 * b的数组下标类型检查,发生在语法分析阶段还是语义分析阶段?如果在语法分析时就要确保 i 是整数、i+1 不能越界,你的递归下降函数会掺杂大量与语法无关的代码。我的惯用做法是:语法分析只负责「能不能被文法接受」,所有「合不合法」的检查全部沉淀到语义分析里。这样各个 Stage 解耦,出问题时你能直接说出「是语法错还是语义错」。
说到这必须提一下中间表示(IR)的取舍。C-MIPS 编译器可以选 AST 直出 MIPS,也可以先输出三地址码或四元式再翻译成 MIPS。我的判断是:如果实验要求你必须现场讲解代码,直接 AST 到 MIPS 最省事,因为少了中间表示的生成和优化环节,你面对的问题更少。但如果你打算做加分项比如常量传播、公共子表达式消除,那就必须有三地址码。两害相权取其一,如果你不想把战线拖太长,AST 就是你的 IR。只是要留意:AST 上做代码生成时,你怎么确定一个表达式该用哪一块寄存器?这个问题的答案就落在下一节的寄存器分配策略。
2.3 寄存器分配策略:临时变量和局部变量的栖身之所
MIPS 有 32 个通用寄存器,但实验里你能用的其实没那么多:$zero、$at、$v0-$v1、$a0-$a3、$t0-$t9、$s0-$s7、$k0-$k1、$gp、$sp、$fp、$ra。新手最容易踩的第一坑就是——所有变量都放内存,然后每次运算都 lw 出来、算完 sw 回去,最后代码会膨胀到 2000 行但性能奇差。我的推荐是「寄存器池 + 溢出」策略:为每一个临时变量分配一个虚拟寄存器编号,再维护一个「虚拟寄存器到物理寄存器」的映射表。当物理寄存器不够用(超过 10 个活动临时变量)时,把最久没用的那个虚拟寄存器溢出到栈上,之后要用再从栈里加载回来。
这种策略的参数设定参考如下:保留$t0-$t9共 10 个寄存器给临时变量,$s0-$s7共 8 个寄存器给局部变量或全局变量缓存,$a0-$a3给函数传参,$v0-$v1给返回值或系统调用。寄存器分配算法用朴素 LRU 的思想:每次分配新的虚拟寄存器时,优先从空闲池里取;如果没有空闲,找最近最少使用的那个,先把它 sw 到栈里腾位置。作为给新手的复现路径,你不需要做图着色,LRU 足够了。代码看起来大概是这样的:
typedef struct { int is_used; // 当前虚拟寄存器是否被占用 int phy_reg; // 映射到的物理寄存器编号,-1 表示尚未映射 int spill_offset; // 被溢出到栈后的偏移量,-1 表示未溢出 } VirtReg; VirtReg reg_pool[MAX_VIRT_REG]; // 分配一个虚拟寄存器,返回编号;必要时挤掉 LRU int alloc_virt_reg(FILE *mips_file) { for (int i = 0; i < MAX_VIRT_REG; i++) { if (!reg_pool[i].is_used) { reg_pool[i].is_used = 1; reg_pool[i].phy_reg = get_free_phy_reg(); return i; } } // 所有虚拟寄存器都活着,只好挤掉 LRU 并生成 spill 代码 int victim = find_lru_virt_reg(); fprintf(mips_file, "sw $%d, %d($sp)\n", reg_pool[victim].phy_reg, reg_pool[victim].spill_offset); reg_pool[victim].phy_reg = get_free_phy_reg(); return victim; }这段代码的逻辑说明:alloc_virt_reg先扫描整个虚拟寄存器表,只要有空闲项就直接占用并分配一个物理寄存器;如果全被占用,只能挤出最久没用的虚拟寄存器,并在栈上保存它的值,这就是溢出(spill)。两个参数需要你特别留意:MAX_VIRT_REG的定义不是越大越好,虚拟寄存器池过大会导致 LRU 链变长、挤占符号表内存,通常设 32 就够了;spill_offset需要在函数入口统一分配栈空间时计算好,不能每次溢出都用固定偏移,否则两个变量会挤在同一块栈空间。这个设计的价值在于:你把「寄存器不够用」这个难题,转化成了一个 LRU 数组的维护问题,调试思路瞬间就清晰了。
3. 词法与语法分析实现:从字符流到语法树的跳跃
3.1 Token 流定义成固定表,性价比远超手写哈希
词法分析器要面对的核心问题不是「怎么读字符」,而是「怎么把读到的字符归类成关键字、标识符、数字、运算符」。很多教材会建议你用哈希表来存关键字,但在一个不超过 30 个关键字的实验里,哈希的收益微乎其微,一个strcmp线性查表就够了。我一般会先定义一个大枚举,把所有的 Token 类型排好,再维护一个关键字查找表:
typedef enum { TOKEN_INT, TOKEN_CHAR, TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE, TOKEN_FOR, TOKEN_RETURN, TOKEN_BREAK, TOKEN_CONTINUE, TOKEN_IDENT, TOKEN_NUMBER, TOKEN_PLUS, TOKEN_MINUS, TOKEN_STAR, TOKEN_SLASH, TOKEN_PERCENT, TOKEN_ASSIGN, TOKEN_EQ, TOKEN_NE, TOKEN_LT, TOKEN_GT, TOKEN_LE, TOKEN_GE, TOKEN_AND, TOKEN_OR, TOKEN_NOT, TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_LBRACE, TOKEN_RBRACE, TOKEN_LBRACKET, TOKEN_RBRACKET, TOKEN_SEMI, TOKEN_COMMA, TOKEN_EOF, TOKEN_ERROR } TokenType; typedef struct { TokenType type; char lexeme[64]; // 标识符或数字的字符串原文 int line; } Token;这段代码里有一个容易被忽略的点:lexeme数组的长度设成 64 字节,这是给标识符长度设的上限。如果你的实验允许更长的变量名,就同步放大这个值,但要注意后续所有拷贝操作都检查长度,以免栈溢出。line字段必须记录行号,否则语法报错时你连「在哪一行出错」都说不出来,那调试体验会非常难受。关于数字的处理:TOKEN_NUMBER的lexeme存的是数字字符串,等你真正生成 MIPS 时再用atoi转成整型即可,不要在词法阶段就做进制转换,免得遇到0123这类八进制写法时产生歧义。
词法分析器的主循环其实就是一个switch或if-else链,注意两个边界坑:换行符要递增行号;/* ... */块注释如果实验支持的话,要小心「直到 EOF 都没遇到结束符」的文件。我见过太多人在注释处理上翻车,最后压栈溢出。稳妥的做法是读到*/才退出注释状态,如果一直读到 EOF 就直接报「unterminated comment」并返回错误 Token。
3.2 递归下降:手写语法分析的确定性选型
语法分析的策略选择上,我坚持用递归下降而非 Lex/Yacc。理由很实际:递归下降的语法规则和 CFG 产生式几乎一一对应,出错信息可以写得非常具体,而且不需要额外学习工具链。实验场景下的 C 子集是确定性的文法,没有左递归,写下去不会有回溯爆炸的问题。你只需要为每个非终结符写一个函数:parse_program、parse_declaration、parse_statement、parse_expression、parse_assignment、parse_factor等。每个函数遵循同一套路——根据当前 Token 决定调用哪个子函数,然后消费 Token,返回 AST 节点。
这里举个赋值语句的解析例子:
AstNode *parse_assignment() { AstNode *node = (AstNode *)malloc(sizeof(AstNode)); node->type = AST_ASSIGN; // 左值一定是一个标识符或数组元素 if (current_token.type == TOKEN_IDENT) { node->left = create_leaf_node(current_token); advance_token(); } else { error_report("expect identifier before assignment", current_token.line); return NULL; } // 等号 if (current_token.type == TOKEN_ASSIGN) { advance_token(); } else { error_report("expect '=' after left value", current_token.line); return NULL; } // 右侧表达式,走优先级爬升或递归下降的表达式函数 node->right = parse_expr(); return node; }这个函数的逻辑说明:先消费掉一个标识符作为左值,再要求下一个 Token 必须是赋值号,最后递归调用parse_expr去解析右侧表达式。值得注意的是parse_expr必须处理运算符优先级问题,不能简单地从左到右逐字生成 MIPS。在这里有两个实现路径:一是标准的运算符优先级表配合 Pratt 解析,二是按文法分层写parse_expr -> parse_term -> parse_factor的三层递归。我推荐新手用三层递归,因为它更贴近课本的E -> T | E + T文法,推导过程很好画,期中检查老师问起来你能讲得头头是道。缺点是多写一点代码,但够稳。
3.3 表达式中的三个陷阱:左递归、优先级和无括号的尴尬
讲到表达式解析,绕不开三个血泪踩坑点。第一是左递归:如果你直接把文法写成E -> E + T,递归下降会无限循环。解决办法是把左递归改成右递归——E -> T { + T },用循环代替递归,这也是我上面推荐三层递归的本质原因。第二是优先级,a + b * c必须被解析成a + (b * c)。实现上如果你用了 Prars 解析,就给每个运算符设定一个优先级数值(乘除取模为 40,加减为 30,比较为 20,逻辑为 10)和一个结合性;如果你用三层递归,那parse_expr只处理加减、parse_term处理乘除取模、parse_factor处理括号和一元符号。
第三个陷阱比较隐蔽:当你遇到a - b时,这个减号是二元的。但-a里的负号是一元运算符,在语法分析时必须走parse_factor里的TOKEN_MINUS分支把它变成一个「取负」节点,而不是当成缺失左操作数的二元运算。很多人在 AST 打印阶段才发现负号丢了,原因就是词法分析把-a解析成两个 Token,而语法分析没有做一元处理。解决方案是在parse_factor开头显式判断一下:
AstNode *parse_factor() { if (current_token.type == TOKEN_MINUS) { advance_token(); AstNode *operand = parse_factor(); return create_unary_node(AST_NEG, operand); } if (current_token.type == TOKEN_LPAREN) { advance_token(); AstNode *inner = parse_expr(); expect(TOKEN_RPAREN); return inner; } if (current_token.type == TOKEN_NUMBER) { return create_leaf_node(current_token); } // ... 处理标识符和函数调用 }这段代码最值得记的细节是:一元负号的处理放在parse_factor里,因为-a * b的语义是(-a) * b,而不是-(a * b)。如果你把一元负号的判断放在parse_expr或parse_term,优先级就会出问题。参数上没有太多玄学,你只要确保parse_factor能递归调用自己来支持--a这种连续取负的写法(语法允许的话),同时在expect(TOKEN_RPAREN)失败时要打印期望与实际 Token 的行号就行。
4. 语义分析与中间代码生成:把语法树变成有意义的对象
4.1 符号表设计:作用域链、类型和偏移量的三件套
有了 AST 之后,下一步是语义分析。这一阶段的任务是检查未声明的变量、类型不匹配、函数参数个数不匹配等问题,并在符号表里登记每个变量的类型和存储位置。假设你用的是 AST 直出 MIPS 的路线,符号表也可以直接为代码生成服务——每个符号在函数内栈帧的位置偏移、或者作为全局变量的标签名,都在语义分析阶段就确定好,生成 MIPS 时直接查表即可,不必二次遍历。
符号表的经典实现是「链表上的作用域链」。全局作用域在最底层,每进入一对{}就创建一个新的作用域。查找名字时从当前作用域一直往上找;插入时只允许插入当前作用域,避免重名覆盖。每个符号项至少要记录四个字段:
typedef struct Symbol { char *name; Type type; // TYPE_INT, TYPE_CHAR, TYPE_ARRAY int array_len; // 数组长度,非数组设 0 int offset; // 栈偏移(局部变量)或标签名索引(全局变量) struct Symbol *next; // 指向同一作用域的下一个符号 } Symbol;这里最容易被忽略的是数组变量的存储布局。在 MIPS 程序里,数组在栈上是一段连续内存,offset记录的是数组起始地址相对$fp的偏移。访问a[i]时,不能像普通变量一样直接lw,要先sll左移 2 位(因为 int 占 4 字节),再加到基地址上。如果你的实验支持 char 数组,左移位数改成 0(即不用移位,直接加下标)。这地方的缓存一致性坑在后面会专门讲。
语义分析的遍历是后序遍历:左右子树先检查,再回到根节点做类型规则检查。赋值表达式左边的类型和右边必须一致,但允许小范围的隐式转换,比如把 char 赋给 int 是可以的,反过来则要生成截断指令。数组下标必须是整数类型,函数调用的实参数量和形参数量要一致。这些规则用assert或自定义的error_report都可以,但我建议所有错误信息统一格式:错误类型 + 行号 + 预期 + 实际。打印格式对了,排错效率能提升一个量级。
4.2 从 AST 到 MIPS:每个表达式节点对应一条指令链
代码生成是整台编译器的发动机。我们需要对每种 AST 节点类型写一个gen_code(Node *node)函数,根据节点类型生成对应的 MIPS 指令序列。设计原则是:每生成一条指令,都要明确它把结果写到了哪个虚拟寄存器。以二元运算为例,假设是对两个操作数做加法,生成逻辑应该是:先递归生成左子树代码,结果落在一个虚拟寄存器;再递归生成右子树,结果落在另一个虚拟寄存器;最后生成add指令把两个源寄存器相加并写入一个新的目标寄存器。这个目标寄存器的编号怎么来?调用alloc_virt_reg()获得。伪代码片段如下:
void gen_binary_op(AstNode *node, FILE *out) { int left_reg = gen_expr(node->left, out); // 递归生成左子树,返回虚拟寄存器编号 int right_reg = gen_expr(node->right, out); int result_reg = alloc_virt_reg(out); const char *op = mips_operator[node->op]; fprintf(out, "%s $%d, $%d, $%d\n", op, result_reg, left_reg, right_reg); free_virt_reg(left_reg); free_virt_reg(right_reg); return result_reg; }这段代码背后的逻辑是:虚拟寄存器在子表达式计算完以后就不再需要了,所以free_virt_reg立刻释放掉左右操作数占用的资源,能显著降低并发活跃的虚拟寄存器个数、减少溢出。参数说明:mips_operator是一个从 AST 操作类型映射到 MIPS 助记符的表,加法是add,减法是sub,乘法是mul,除法是div(MIPS 的div是特殊寄存器操作,需要mfhi/mflo取结果)。特别提醒:div在处理器上可能因除零触发异常,实验通常不要求运行时检查,但你可以在代码生成里构造一个除零分支,打印runtime error: divide by zero再退出,加分项指数极高。
4.3 函数栈帧的细节:调用约定与本地变量偏移
如果你不打算支持递归和函数调用,那栈帧部分可以跳过。但大多数实验至少支持两层互相调用的函数,这意味着调用约定必须定清楚。我的约定是:参数用$a0-$a3传递前四个整型参数,多余的参数压栈;返回值放$v0;$ra保存返回地址;$fp指向当前函数的栈底;局部变量全部放在$fp下方(负偏移)。函数调用时,调用方需要先把实参求值,然后执行jal function_label;被调函数入口处需要做三件事:保存旧$fp和$ra到栈上、更新$fp、向下移动$sp为局部变量腾出空间。以下是一段经典的 prologue 模板:
# 函数入口部分 move $t0, $fp # 保存旧栈帧指针 li $t1, 4 sub $sp, $sp, $t1 sw $t0, 0($sp) # 旧 $fp 压栈 sw $ra, 4($sp) # 返回地址压栈 move $fp, $sp # 令 $fp 指向新栈帧底部这四条指令的逻辑说明:第一步用临时寄存器把旧$fp挪走;第二步移动栈指针为两个存储单元腾出空间;第三步把旧$fp和$ra分别压栈;第四步让$fp指向新的栈顶位置。之后访问局部变量就用$fp减偏移,例如int x的偏移是 -8,就生成sw $t0, -8($fp)。不同函数之间互不干扰,就是因为$sp和$fp在每次调用后都会恢复到正确位置。参数传递上有个经常被忽略的点:调用子函数之前,调用方自己的状态也会被jal覆盖$ra,所以你需要在调用点之前先保存$ra。最简单的策略是:主函数不做嵌套调用时不需要管$ra,但如果A调用B且B里又要调用C,那B的 prologue 里必须保存$ra并从栈上恢复。实验到后期很多人的崩溃都发生在多级函数调用上,排查时第一反应就要看寄存器保存表格是否对称。
5. C-MIPS 的运行时环境与 MIPS 汇编拼装:别在最后一步掉链子
5.1 代码生成的另一侧:数据段声明和控制流条件跳转
目标代码生成不只有指令段,还有数据段。MIPS 程序中全局变量要放在.data段,代码放在.text段。全局变量的声明方式是:globl_var: .word 0或者是str: .asciiz "hello"。如果你的实验支持字符串常量(例如printf需要格式串),你需要在代码生成时为每个字符串分配一个标签并放进.data。整个过程相当于在你自己的编译器里再做一次「汇编器到链接器」的工作,指令地址和数据地址的布局都是相对的,MARS 和 SPIM 都会在加载时自动重定位,你只需要确保标签名不冲突。
控制流跳转是另一大块。if (cond) stmt1; else stmt2;的翻译思路是:先把条件表达式求值,结果为 1 或 0,然后根据结果跳转。常见做法是生成beqz $t0, else_label。这要求条件表达式生成时,逻辑运算和关系运算的结果必须是 0/1 形式的规整值。坑在于 MIPS 的比较指令slt(小于则置位)只能比较整数大小;等于、不等于、大于都需要通过组合slt和逻辑取反来生成。例如a == b等价于xor $t0, $a_reg, $b_reg; sltiu $t1, $t0, 1,也就是「两个数异或后小于 1 意味着相等」。
while循环的翻译也是一个固定套路:条件测试在入口,先跳转到测试处。结构如下:
for_begin: # 循环体 # ... for_cond: # 计算条件表达式 # 若为真则跳回 for_begin bnez $t0, for_begin for_end: # 循环结束for循环的初始化、自增、条件三部分要拆成三个节点。continue会跳到for_cond,break会跳到for_end。在 AST 生成时,你需要维护一个「当前循环的 continue 标签栈和 break 标签栈」。嵌套循环时,后进先出即可对应正确的跳转目标。这部分代码没太多算法难度,但错误率高,建议在一开始就把标签的命名规则定成L_0_1这样的全局唯一格式,否则 debug 时根本没有方向感。
5.2 精简 C 的系统调用层:输出与退出的教学模拟器接口
C-MIPS 编译器的运行时库通常做得极简:只支持print_int、print_char、print_string和exit这类系统调用。MARS 里用li $v0, syscall_number加syscall指令。比如打印一个整数就是:
li $v0, 1 # 设置系统调用号为 1(print_int) move $a0, $t0 # 把要打印的整数放进 $a0 syscall # 触发系统调用这属于教学模拟器与真实硬件的巨大差异。真实 MIPS Linux 的syscall传参方式完全不同,实验里直接锁定 MARS/SPIM 的约定即可。你在生成函数main的返回处,要生成进程退出指令:
li $v0, 10 syscall一个容易翻车的点:MARS 对未初始化内存的读取默认是 0,但 SPIM 里会有警告;两份模拟器的 syscall 编号一致,但.asciiz和.word的伪指令细节略有差异。你在写实验报告的「测试环境」一节时,建议明确标注用的是 MARS 4.5 还是 SPIM 8.0,最好是两个都跑一遍。如果两边结果不一致,优先怀疑代码生成里有没有依赖未初始化的寄存器的值——这正是标准和陷阱的分水岭。
5.3 集成测试与基准样例:从 Hello 到五子棋的验证阶梯
代码生成完成后,怎么证明你的编译器是对的?常见的做法是准备一系列测试用例,从浅到深依次测试。我用的是四级阶梯:第一级是单表达式求值,例如int a = 12; print(a);,预期输出12;第二级加上控制流,例如算1+2+...+10的for循环,预期输出55;第三级加上函数调用和数组访问,写一个传入数组元素和返回和值的小函数;第四级做一个简单的小游戏,例如猜数字或冒泡排序,完整测试整个工具链的健壮性。第四级不是必须,但如果你能跑到这一步,这门实验基本就是一等奖的候选了。
测试时请务必使用 MARS 的「单步执行 + 寄存器窗口」模式。不要一次性运行完就只看输出结果,而是要在关键指令处打断点,观察$t0的值和源程序语义是否一致。这种做法初次会比较慢,但定位问题极快——一看出错的指令是哪条,马上就能回推到 AST 的哪个节点出了问题,不用在几万条模拟指令里大海捞针。下一章我就把几类最常见的踩坑记录系统整理一遍,全是实录级别的现象和解决方案。
6. 避坑 / 常见问题 / 排查:C-MIPS 编译器里最典型的 5 类翻车现场
6.1 数组下标生成的偏移没乘 4,数据全部错位
现象:声明了int a[10];,给a[2]赋值,结果打印a[1]却得到了刚赋给a[2]的值,而且a[0]附近的数据被莫名改写。原因:MIPS 的字节寻址机制里每个 int 占 4 字节;你生成了lw/sw指令,但地址偏移没有乘以 4,导致下标为 2 的元素实际上写到了基址加上 2 字节的位置,破坏了数组内部相邻的元素。解决办法:在生成数组访问时,如果是int型数组,先对下标sll $t1, $index_reg, 2左移两位;如果是char数组,不需要移位。检查方法可以是打印一条访问偏移,比如la $t0, array_label; sll $t1, $t2, 2; add $t0, $t0, $t1; lw $t3, 0($t0),单步观察$t1是否为下标的 4 倍。这也是最典型的「看着代码没错、跑起来全错」的坑,几乎每个做数组实验的人都会掉一次。
6.2 除零异常没有拦截,程序直接崩溃在运行时
现象:输入b = 10 / a;,当运行时a == 0,模拟器报Runtime exception at 0x... : Arithmetic overflow或divide by zero。原因:MIPS 的div指令在除数为零时会触发硬件异常,而你的编译器没有生成任何防御代码。解决办法:在生成除法代码前,先生成一段检查序列——把除数加载到寄存器,beqz到错误处理标签,打印一条错误信息,然后exit。具体模板:
# 除法前检查除数是否为 0 lw $t0, divisor_reg bnez $t0, div_ok la $a0, err_div_zero li $v0, 4 syscall li $v0, 10 syscall div_ok: div $t0, $t1 mflo $result_reg这段代码的关键参数是错误信息标签err_div_zero,必须在.data段声明为err_div_zero: .asciiz "Error: division by zero\n"。为什么要放在运行时而不是编译期?因为除数的值在编译期不可知,只有运行时才能确定。这个防御看起来不起眼,但一旦助教测试时故意输入除零用例,你的程序就是负分和满分的区别。
6.3 变量作用域阶段,局部变量与全局变量共用同一标签
现象:函数内声明了一个局部变量int temp;,全局也有一个int temp;,结果给局部temp赋值时,全局的值也被改了。原因:符号表作用域链未正确隔离,代码生成时把局部变量当成了全局变量,或者两个变量的偏移算重了。解决办法:在你的符号表里给每个局部变量分配一个基于$fp的负偏移,给每个全局变量分配一个.data段的标签。检查符号表时留意「变量名字相同但作用域层级不同」的情况,作用域链查找时从内到外、找到即停;生成代码时根据符号所属作用域决定走$fp偏移还是标签寻址。还有一个小坑:局部变量和全局变量共用同一标签名时,汇编器不会报错,因为$fp偏移和标签完全独立,但你 print 全局和局部变量时可能把值搞混。自测时加一条int temp; int main() { int temp = 5; },如果打印全局能拿到 5 的脏值,说明你的作用域隔离有漏洞。
6.4 函数调用前未保存返回地址,嵌套调用直接乱飞
现象:函数A调用函数B,在B返回后用$ra跳转,程序跳到了一个奇怪地址。原因:jal B会把返回地址写入$ra,但在进入B之后B又有调用C,这个$ra被覆盖了。因为你的编译器没有在B的 prologue 里保存和恢复$ra。解决办法:每个非叶子函数在入口处把$ra压栈,回到调用者之前恢复它。代码模板就是本章 4.3 里那四行 prologue 再加一行 epilogue:
# epilogue lw $ra, 4($fp) move $sp, $fp lw $fp, 0($fp) jr $ra如果是叶子函数(内部不调用其他函数),可以不用保存$ra,这是很常见的优化。但前提是你必须在语义分析阶段准确判断函数是否为叶子,不然会连带出错。调试时先看 MARS 的 Call Stack 窗口,如果你发现返回地址变成 0x00000000,那十有八九就是 epilogue 写错了。
6.5 逻辑运算符的短路求值没实现,程序执行了多余分支
现象:if (a != 0 && 10 / a > 2),当a = 0时若程序仍然进入除法分支,就会报除零异常。原因:&&和||在 C 语言语义里必须是短路求值的——&&左操作数为假时,右操作数不执行。你的编译器如果直接把&&当成普通二元运算,先算左右两边再取逻辑,与原始语义不符。解决办法:在 AST 节点里为逻辑运算单独设计代码生成路径,而不是复用算术运算的通用路径。模板如下:
# 计算左操作数,结果在 $t0 # beqz $t0, false_label (如果左操作数为假,整个表达式为假) # 计算右操作数,结果在 $t1 # and $t2, $t0, $t1,最后输出结果这里的关键参数是false_label,需要由代码生成器为每次逻辑运算分配一个唯一标签。按这个方法处理完&&、||和!之后,控制流语义就贴近真实 C 了。否则你最终测试一旦遇到含逻辑短路的目标样例,程序必挂。这也是语义分析和代码生成最容易脱节的地方。
7. 调试与验证手段:MARS 里单步观察三条关键证据
写到最后,我想倒过来把调试方法论压缩成三个可操作的验证动作,按顺序做,能解决 90% 的错位。第一步是「反汇编验证」:你的编译器生成 .asm 文件后,在 MARS 里打开,用 Text Segment 窗口对照源码逐行看生成的指令,看有没有明显跳转错误或寄存器使用错误。第二步是「寄存器窗口的断点对比」:在函数返回地址、数组基址、除法指令附近设置断点,单步运行的同时盯住$t0、$fp、$sp和内存窗口的值,对比你的算法期望值。第三步是「最小化复现」:如果你发现某个测试样例出错,立刻把它缩小到 10 行以内,用删减法定位出错路径,不要一开始就直接 debug 大程序。一个技巧是把你生成的汇编文件里的每条#注释行也输出对应的 AST 节点序号,这样出错时你可以从 MARS 的错误地址快速反查到对应的源码行。
有一点需要强调:MARS 的单步执行里,伪指令会被展开成多条真实指令,你看到的断点位置可能不是自己写的li或la,而是后续展开的lui和ori。这种情况下不要慌,它只是汇编器的展开过程,不影响指令语义。如果你想看原始的未展开形式,在 Settings 里关掉 Extended pseudo-instructions 选项,所有伪指令都会变成基础指令,调试起来更直观,但可读性会下降。我个人的习惯是第一次调试用伪指令模式,定位到问题区域后切到纯指令模式,确认无误再把汇编文件交上去。
实验做到后面,我个人的一套习惯是:每生成一个功能快照(比如刚做完表达式求值、刚做完函数调用),就先跑一遍所有已有测试,确保没有引入回退。如果后面发现新的 Bug,永远先怀疑自己最近改动的那个函数,而不是回头翻十几天前的旧代码。这套「增量验证」的纪律,帮我省下的时间远远超过写代码本身。这套实验最大的价值不在于生成汇编这个结果,而在于它逼你养成了「输入到输出之间,每一步都要能解释」的工程习惯。希望帮到你。
本文还有配套的精品资源,点击获取