ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手写C0到MIPS编译器:从词法分析到汇编生成全流程解析

手写C0到MIPS编译器:从词法分析到汇编生成全流程解析 简介北航计算机学院2017级编译原理课程设计项目实现C0语言到MIPS汇编的编译器包含完整课程设计源码与配套资料。面向编译原理学习者与高校计算机专业学生可用于教学演示、课程设计参考与编译技术实践。压缩包内共33个文件大小81KB以19个C源文件、8个头文件为主涵盖词法分析、语法分析、语义分析、中间代码生成、优化及目标代码生成等模块另有testfile.txt源代码输入文件、mips.txt目标代码输出文件、说明文档与2019年文法、附赠资源等docx文档以及readme.md。已有61人学习。通过这份压缩包可直观理解C0语言到MIPS汇编的完整转换流程参考编译器各阶段的代码实现与模块划分还可借助附赠资源中的编译原理基础介绍和语法手册系统掌握编译技术并提升工程实践能力。1. 从课程设计题包到能跑的编译器这个C0到MIPS项目到底要做什么拿到“北航计算机学院2017级编译原理课程设计项目_实现C0语言到MIPS汇编的编译器”这个题包大多数人第一反应是“又得写个翻译器”但真正动手才发现这是一条从词法分析到汇编生成的完整链路输入是testfile.txt输出是mips.txt中间隔着语法分析、语义检查、寄存器分配和栈帧布局。C0语言本身是教学裁剪过的C子集MIPS汇编是RISC教学机两者之间的翻译逻辑足够清晰又足够暴露各种实现细节。这篇文章不是泛泛介绍编译器理论而是照着能复现的路子把选型理由、核心代码、必调参数和最高频的翻车点摊开讲。适合正在做同类课程设计的学生、想把手写编译器从“读过”变成“跑通”的开发者以及需要用一门小语言演示编译全过程的教学场景。2. 先把C0语言和目标MIPS子集定下来不画清边界后面全是坑2.1 C0语言到底裁剪了什么词法与语法边界C0不是标准语言不同学校出的课程设计会微调语法但核心子集很统一只有整数类型、一维数组、函数、局部变量、赋值、加减乘除、取模、比较、if/else、while有的版本还带for和逻辑与或非。理解这道题的第一步就是把题目里定义的C0子集完整地列表出来而不是想当然地全盘支持C语法。因为一旦你多支持了指针、结构体、浮点整个代码生成路径全变了工作量翻倍不说还容易在评分时被当成“偏离要求”。我一般会先读题包里的样例。假设testfile.txt长这样这是这类课程设计最常见的样例形态// testfile.txt 示例 int a; int b[10]; int add(int x, int y) { int c; c x y; return c; } int main() { int i; int s; i 0; s 0; while (i 10) { s s i; i i 1; } a add(s, 100); b[0] a; return 0; }这个样例覆盖了全局变量、数组、函数定义、函数调用、局部变量、while循环、数组下标访问。注意C0常见的词法单元标识符、整型数字、关键字int、return、if、else、while、for等、运算符 - * / % ! 、分隔符; , ( ) { } [ ]。很多同学在词法分析里漏掉复合赋值运算符如但C0通常不支持因为课程设计要把“简单”放在第一位。边界要画到什么程度我建议你写一个“支持的语法”清单贴在代码头注释里比如变量类型仅int数组仅一维整数数组下标是整数表达式函数返回值是int参数是int或int数组语句包括赋值、if/else、while、return、函数调用、复合语句没有浮点数、字符、字符串、结构体、指针、break/continue、switch这条清单第一能约束你的实现范围第二能帮助调试遇到清单外语法直接报错比硬翻译更安全。把边界定死之后词法分析器的token集合也就固定了语法分析器的产生式也能顺着写出来。2.2 目标MIPS子集寄存器约定与内存布局MIPS是精简指令集教学演示几乎都用MARS或SPIM做模拟器。生成的目标代码要能被模拟器直接读入所以必须遵守模拟器的约定代码放.text段数据放.data段入口是main标号程序结束用li $v0, 10加syscall退出。寄存器分配是代码生成前就要拍板的选择。我不会一上来就做完善的寄存器分配教学编译器先用固定的临时寄存器池就够了。常见的约定如下寄存器用途$a0-$a3函数参数传递$v0函数返回值、系统调用号$t0-$t9表达式临时值调用者保存$s0-$s7全局/局部变量映射被调用者保存$sp栈指针$fp帧指针可选$ra返回地址内存布局上全局变量直接放.data段用.word或.space预留空间访问时用la取地址、lw/sw读写。局部变量统一分配在栈上用$sp加偏移访问。栈从高地址向低地址增长函数入口先addiu $sp, $sp, -N返回前addiu $sp, $sp, N。下面是一小段预期输出mips.txt的样式对应上面C0的main开头的赋值语句.data a: .word 0 b: .space 40 .text .globl main main: li $t0, 0 sw $t0, -8($sp) li $t1, 0 sw $t1, -12($sp)这里的-8($sp)是局部变量i的栈槽-12($sp)是s的栈槽。这个约定必须全局统一否则符号表和访存代码会对不上。还要注意MARS里支持li这类伪指令它会被展开成ori或addiu用起来省心不要求必须展开成真指令。2.3 编译器整体架构一趟还是多趟怎么选课程设计编译器有两种常见做法单趟直接翻译或者先建AST再遍历生成代码。单趟翻译在语法分析的同时生成汇编代码量少但遇到if分支、函数调用时会很别扭因为你要在没看见整个语法结构前就决定跳转标签。我建议用AST抽象语法树把整个源程序解析成一棵树然后再做一次AST遍历生成MIPS。这样设计清晰作用域管理和代码生成也方便。一个比较顺手的模块拆分是lexer.c词法分析输出token流parser.c递归下降语法分析构建ASTast.cAST节点定义和构建函数symbol.c符号表管理codegen.c遍历AST生成MIPS汇编main.c入口负责读testfile.txt、调用各模块、写mips.txt整体流程是读入testfile.txt的整个文本lexer逐字符识别tokenparser按C0文法递归下降同时把变量和函数登记进符号表遇到表达式和语句时创建AST节点。解析完之后codegen从根节点出发先输出.data段和.text段的框架然后对每个函数生成指令最后把生成的指令文本写入mips.txt。这种“分析时建AST、生成时再遍历”的架构最大的好处是语义检查和代码生成分离。比如你要检查“变量未定义”或“函数返回类型不匹配”可以在parser阶段做也可以在codegen时做但如果你单趟翻译就很难在生成到一半时回头报错。3. 词法和语法分析落地手写递归下降比生成器更稳3.1 用递归下降手写parser还是用flex/bison很多教程推荐用flex和bison但在这个C0项目里我强烈建议手写。原因有三第一C0文法规模小递归下降代码量不多出错时能精确定位到函数和行第二课程设计通常要求提交“自己实现的编译器”用生成器容易被质疑第三flex/bison生成的代码在Windows下环境配置本身就够头疼和“编译器”这个主题无关的时间成本高。词法分析的逻辑很固定跳过空白识别数字和标识符处理运算符和分隔符。核心代码可以写成下面这样typedef struct { int type; // TOKEN_ID, TOKEN_NUM, TOKEN_EOF, 或运算符编号 char text[64]; // 原始字符串 int num; // 数字值 } Token; Token next_token(FILE *fp) { int c; Token t; memset(t, 0, sizeof(Token)); c fgetc(fp); while (c || c \t || c \n) c fgetc(fp); if (c EOF) { t.type TOKEN_EOF; return t; } if (isdigit(c)) { // 整数常量 int val 0; while (isdigit(c)) { val val * 10 (c - 0); c fgetc(fp); } ungetc(c, fp); t.type TOKEN_NUM; t.num val; return t; } if (isalpha(c) || c _) { int i 0; while (isalnum(c) || c _) { t.text[i] c; c fgetc(fp); } ungetc(c, fp); t.text[i] \0; // 关键字查表否则是标识符 t.type lookup_keyword(t.text); return t; } // 运算符、分隔符可做多字符匹配如 , , t.type c; return t; }这段代码的关键在数字和标识符识别后用了ungetc把多读的一个字符退回去不然会吞掉下一个token的首字符。对运算符的处理我一般会单写一个match_token函数遇到时再往后读一个字符判断是还是避免在next_token里写得太乱。注意滑过空白必须包括\rWindows下testfile.txt可能是CRLF换行漏掉\r会导致很多莫名其妙的错误。递归下降parser就顺着文法的产生式来。比如表达式部分的优先级算术表达式 - 项 - 因子写成函数parse_additive、parse_multiplicative、parse_primary。每个函数返回一个AST节点指针。下面是一个parse_primary的核心片段ASTNode *parse_primary() { if (cur_token.type TOKEN_NUM) { ASTNode *node new_ast_node(NODE_INT); node-int_value cur_token.num; advance(); return node; } if (cur_token.type TOKEN_ID) { char name[64]; strcpy(name, cur_token.text); advance(); if (cur_token.type () { // 函数调用 advance(); // 解析参数列表... return new_call_node(name, args); } // 变量或数组访问 if (cur_token.type [) { // 解析数组下标... } return new_var_node(name); } error(expected primary expression); return NULL; }这里最容易被忽略的是“函数调用”和“数组访问”都从标识符开始必须在看过下一个token才能决定建哪种AST节点。这种前瞻一个token的做法是递归下降的常态所以parser函数要能访问当前token和扫描指针。3.2 最小符号表设计与作用域管理符号表是编译器里最容易“先画个大的再删功能”的部分。对C0来说一张哈希表加一个作用域栈就够了。每个符号条目记录名字、类型变量/数组/函数、作用域深度、以及存储位置全局变量在data段的标签名局部变量在栈上的偏移量。结构可以这样定义typedef struct Symbol { char name[64]; int kind; // SYMBOL_VAR / SYMBOL_ARRAY / SYMBOL_FUNC int type; // TYPE_INT / TYPE_ARRAY_INT int scope_depth; // 0 是全局0 是局部 int stack_offset; // 局部变量在栈中的偏移 char global_label[64]; // 全局变量标签名 struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; // 当前作用域的符号链表 int depth; struct Scope *parent; } Scope;作用域管理用一个栈进入函数或复合语句时创建新Scope退出时销毁。查找变量时从当前Scope逐层向上找。我习惯用链表加深度遍历因为C0作用域嵌套不深没必要用哈希表。每个局部变量在声明时需要计算栈偏移偏移从0往下增长。比如函数int add(int x, int y)参数x是4($sp)实际上要结合返回地址压栈情况见后面函数调用章参数y是8($sp)局部变量c是-4($sp)。这些偏移必须在声明语句解析时一次性分配好并写进符号表。这里有一个常见的坑块作用域里的变量会在出块时销毁但MIPS栈空间在编译时已经分配了最大深度。我一般会让stack_offset为负数也就是相对当前$sp的负偏移。如果你想让多个块共享栈槽需要在进入块时记录当前最大栈深度出块时恢复但这对C0不是必须的浪费几个槽位无关紧要。3.3 AST节点的定义与构建AST不需要很复杂给出节点类型和统一结构即可。节点类型大致有程序根、函数定义、语句列表、表达式、赋值语句、if/while/return语句、函数调用、二元运算、变量访问、常量。结构体可以这样设计typedef enum { NODE_PROG, NODE_FUNC, NODE_STMT_LIST, NODE_ASSIGN, NODE_IF, NODE_WHILE, NODE_RETURN, NODE_CALL, NODE_BINARY, NODE_VAR, NODE_INT } NodeType; typedef struct ASTNode { NodeType type; char name[64]; // 变量名/函数名/标签名 int int_value; // 常量值 struct ASTNode *left; struct ASTNode *right; struct ASTNode *next; // 语句列表或参数列表的链式下一个 } ASTNode;这个结构省去了大量的具体字段靠type区分用途。比如语句列表节点left指向第一条语句next指向下一条if节点的left是条件表达式right是then分支可以通过next再把else分支挂上。虽然不够优雅但课程设计代码追求的是容易扩展、容易读。每个AST节点在parser里通过new_ast_node(type)分配然后把子节点挂进去。为什么一定要AST因为代码生成时需要根据上下文决定分支跳转方向。比如if (a b) s1; else s2;在解析时只知道条件表达式和两个分支不知道条件的真假跳转在哪个位置需要遍历AST后生成两个标签。如果你单趟翻译遇到if时也可以生成但遇到复杂的嵌套循环时就很容易乱。AST让整个代码生成变成一次对树的递归遍历思路非常直白。4. MIPS代码生成从AST到mips.txt的关键路径4.1 表达式求值的栈式翻译临时寄存器与数值细节MIPS的算术指令是典型的“三操作数”add $d, $s, $t结果放进目标寄存器。每个二元运算在AST里都是一棵子树遍历子树时先把左子树结果算到某个临时寄存器再算右子树结果到另一个临时寄存器最后做运算。问题是临时寄存器只有$t0-$t9十个嵌套表达式深了就不够用。我用的方法是给每个AST表达式节点分配一个“临时寄存器编号”从$t0开始递增递归返回时再释放。实现上用一个计数器维护当前最大临时寄存器号表达式求值完成后重置。代码模板如下void gen_expr(ASTNode *node) { if (node-type NODE_INT) { printf( li $t%d, %d\n, temp_index, node-int_value); return; } if (node-type NODE_VAR) { Symbol *s lookup_symbol(node-name); if (s-kind SYMBOL_GLOBAL) { printf( lw $t%d, %s\n, temp_index, s-global_label); } else { printf( lw $t%d, %d($sp)\n, temp_index, s-stack_offset); } return; } if (node-type NODE_BINARY) { gen_expr(node-left); // 结果在 $t0 int left temp_index; temp_index; gen_expr(node-right); // 结果在 $t1 int right temp_index; // 生成运算指令结果放回 $t0 switch (node-int_value) { case OP_ADD: printf( add $t%d, $t%d, $t%d\n, left, left, right); break; case OP_SUB: printf( sub $t%d, $t%d, $t%d\n, left, left, right); break; case OP_MUL: printf( mul $t%d, $t%d, $t%d\n, left, left, right); break; case OP_DIV: printf( div $t%d, $t%d\n, left, right); printf( mflo $t%d\n, left); // 商 break; case OP_MOD: printf( div $t%d, $t%d\n, left, right); printf( mfhi $t%d\n, left); // 余数 break; default: error(unsupported operator); } temp_index left; // 释放右子树寄存器 return; } }这段代码里的temp_index是全局计数器每次调用gen_expr前如果是已经算出左子树计数器已经指向下一个空闲寄存器。注意除法必须用mflo取商、mfhi取余数这是MIPS相对其他体系最容易被忽略的点。还有一个细节比较运算的结果不是0就是1如果后面要用它做算术运算你需要用slt指令生成0/1而不是用beq直接跳转。比较运算的翻译也走NODE_BINARY只是指令变成slt、sle一类的set指令。如果C0只支持、等我建议先把形如翻译成slt $t0, $t1, $t0再调换操作数不要去申请$t10这种不存在的寄存器。这种“把比较转为slt”的做法在教材里很常见也是模拟器兼容性最好的写法。4.2 变量存储、赋值与地址计算全局数据段与栈上局部变量全局变量在.data段声明。标号名要和C0变量名一致但为了防止和汇编关键字冲突我会加个前缀比如g_a。数组用.space预留字节数C0里的int b[10]占40字节所以写b: .space 40。访问全局变量需要两条指令la $t0, b取地址然后lw $t1, 0($t0)读数据。数组下标访问更复杂需要把下标值乘以4再和数组基地址相加void gen_array_load(ASTNode *node) { // node 是数组访问节点left 是数组名right 是下标表达式 Symbol *sym lookup_symbol(node-name); gen_expr(node-right); // 下标在 $t0 // 下标 * 4 printf( sll $t0, $t0, 2\n); // 取数组地址 if (sym-kind SYMBOL_GLOBAL) { printf( la $t1, %s\n, sym-global_label); } else { printf( addiu $t1, $sp, %d\n, sym-stack_offset); } printf( addu $t0, $t0, $t1\n); printf( lw $t2, 0($t0)\n); // 结果在 $t2按需使用 }局部变量的存储分配在函数入口统一完成。先算好这个函数需要的栈帧大小保存返回地址4字节 保存$fp4字节可选 所有局部变量的总字节数 临时栈空间比如函数调用参数溢出。在函数入口生成addiu $sp, $sp, -N sw $ra, N-4($sp) sw $fp, N-8($sp) addiu $fp, $sp, N-8$fp可以不用但如果你的代码生成涉及可变深度数组或复杂表达式一个稳定的帧基址能避免$sp在函数调用过程中变化导致偏移全错。我习惯用$fp所有局部变量统一按fp的负偏移访问比如变量c分配在-4($fp)。用了帧指针后符号表里的stack_offset要改成相对fp的偏移生成代码时写lw $t0, -4($fp)同时函数返回前要做addiu $sp, $fp, 8再lw $ra。这个约定会在整个项目里保持一致否则一旦出现函数嵌套调用栈就被踩烂了。赋值语句的处理是先计算右边表达式再根据左边是普通变量还是数组索引来存值。对普通变量直接sw对数组元素需要计算地址后再存注意这里的地址计算和数组读取是完全一样的区别只在最后是sw而不是lw。4.3 控制流语句翻译if/while/for与标签生成控制流的核心是跳转标签。我维护一个全局的label_count每次需要新标签就用L%d生成。判断条件的编译采用“条件为假跳转”的模板这样代码布局比较自然。if-else的MIPS模板如下# 条件表达式求值结果在 $t00 表示假非0 表示真 beqz $t0, L_else # then 分支... j L_end L_else: # else 分支... L_end:如果你是生成真条件时直落就会多出很多反向逻辑。把条件表达式编译成0/1后可以直接用beqz或bnez读起来直观。while循环模板L_cond: # 生成条件表达式结果在 $t0 beqz $t0, L_end # 循环体... j L_cond L_end:注意循环体最后必须无条件跳回L_cond否则只执行一次。for循环可以翻译成while先执行初始化表达式然后循环条件判断循环体结束后执行步进表达式。我一般把for的AST展开成一个while节点序列来生成省得写独立的for生成逻辑。标签的命名最好带有语义提示比如L_while_3、L_if_2但模拟器不在乎最怕的是重复标签。我在每个标签生成时用一个计数器递增同时把对应的C0源文件行号写进注释方便调试时对照。比如L_while_1: # line 12 lw $t0, -8($fp) li $t1, 10 slt $t2, $t0, $t1 beqz $t2, L_end_while_1这些注释不会影响MARS执行但后续单步调试时一眼能找到对应源码。4.4 函数调用栈帧、传参与返回值约定函数调用是C0编译器里最复杂的部分。我采用标准MIPS调用约定参数前四个用$a0-$a3多余的压栈返回值放$v0调用者保存临时寄存器被调用者保存s寄存器和$ra。调用序列分三步# 将第N个参数放入 $aN 或压栈 # 执行 jal 函数名 # 从 $v0 取返回值具体生成代码时对一个CALL节点遍历它的参数列表先计算每个参数的表达式再把结果送到对应寄存器。这里一定要先计算所有参数再调用顺序不能乱。如果你参数超过4个多余的参数要按逆序压栈也就是最后一个参数最先压入保证在函数内偏移递增。被调用函数的栈帧布局我习惯这样定0($sp)开始是返回地址由调用方jal写入$ra进入函数后把$ra压栈保存因为后续还有嵌套调用会覆盖$ra然后压入调用时用到的$t保存值可选再往下是局部变量函数入口的完整模板func_label: addiu $sp, $sp, -N sw $ra, N-4($sp) sw $fp, N-8($sp) addiu $fp, $sp, N-8 # 如果需要把所有 $s0-$s7 压栈 sw $s0, -4($fp) sw $s1, -8($fp) ... # 局部变量区继续往下分配参数如何访问如果调用时参数在$a0-$a3函数入口要把它们保存到栈上的“参数槽”这样后面就能统一按$fp偏移访问。我一般会在栈帧最顶部$fp4、$fp8等预留参数槽。也就是说调用方压栈的参数和被调用方自己的栈帧构成一层一层的衔接。这个设计对新手有点绕但能把“传参”和“局部变量”统一成偏移量。返回值处理很简单return expr;生成gen_expr(expr); # 结果在 $t0 move $v0, $t0 # 恢复 $s 寄存器和 $fp, $sp lw $s0, -4($fp) ... addiu $sp, $fp, 8 lw $ra, -4($fp) lw $fp, -8($fp) jr $ramain函数结束时也要生成退出系统调用不能直接jr $ra因为MARS的main返回地址没有实际意义。我一般会在main的return语句里生成li $v0, 10加syscall而不是普通的函数返回序列。这算是一个教学编译器里的特殊约定。5. 从testfile.txt到mips.txt的高发坑路径、寄存器冲突和模拟器玄学5.1 文件读写坑testfile.txt和mips.txt的路径、编码、换行很多代码逻辑完全正确却在第一步就翻车编译器运行时找不到testfile.txt或者写出的mips.txt里是乱码。这类现象的原因很简单——工作目录不对。使用IDE或命令行运行时当前工作目录未必是项目目录。我一般会让编译器直接读取命令行参数如果没有参数则默认使用testfile.txt同时把程序放到和testfile.txt同一目录下运行。代码里用fopen(/path/to/testfile.txt, r)固然能跑但不通用。更稳的做法是读取环境变量或用相对路径并在打开失败时打印当前工作目录帮助定位。编码也是一个隐蔽坑。Windows下保存的testfile.txt可能是GBK或带BOM的UTF-8。如果词法分析器直接按字节读取遇到BOM头EF BB BF会当成三个字符导致第一个token无法识别。解决办法是用fread读到内存后检测开头的BOM跳过它。GBK编码如果只涉及ASCII范围内的标识符和数字不受影响但如果注释里出现中文就会引入非ASCII字节最好在读取后把非ASCII字节替换为空格或者干脆规定testfile.txt必须无注释、纯ASCII。遇到乱码输出mips.txt时多半是编译器把源文件编码问题带到了输出字符串里检查写入时用的编码格式统一。换行符同样要处理。Windows的\r\n会让next_token里的跳空白逻辑多碰到\r我在词法分析里直接把它当作空白字符。如果你用fgets逐行读更要小心每行末尾的\r它可能被当成标识符的一部分。我建议词法层统一把\r、\t、空格、\n都视为空白这是最省心的方案。5.2 模拟器兼容坑延迟槽、伪指令和syscallMARS和SPIM默认不模拟延迟槽但很多MIPS教材为了简化会关闭延迟槽。如果你的编译器生成的跳转指令后面紧跟下一条指令而在SPIM中开启延迟槽那下一条指令会被错误地执行。我建议在生成的汇编文件顶部写一个明确的注释并且统一生成nop填充每个跳转后避免在不同模拟器间来回折腾。li和la是伪指令MARS和SPIM都支持但注意有些极度精简的教学环境要求真指令。如果题目明确要求“只能是MIPS真实指令”你需要把li $t0, 100展开成ori $t0, $zero, 100把la展开成lui加ori两步。我在实现时做一个开关用一个宏控制是否展开伪指令课程演示默认开伪指令省事。syscall也是坑点。li $v0, 10加syscall在MARS里正常退出但在SPIM里有的版本要求addiu $v0, $zero, 10。反正都一样写li兼容性最好。如果要读字符串或打印数字记得不同syscall编号功能不同别把返回值当成系统调用号。5.3 五条高发错误现象、原因、解决错误1模拟器报“编译器未包含main类型”或程序根本不执行。现象MARS载入mips.txt后提示找不到main或者PC停在0x00000000不跑。原因生成的汇编没有定义.globl main或者main的标号被优化掉又或者你用了main以外的名字。解决在代码生成器里遍历AST时一旦遇到名字为main且类型是函数的定义就输出.globl main并把标号写成main:不要写成_main或Main。还要确认整个代码段最终以li $v0, 10加syscall结尾否则程序跑完会继续执行到未知地址。错误2局部变量偏移重叠导致函数还没执行完返回地址被覆盖。现象单步执行时函数调用后返回地址变成乱值程序跳飞到外星。原因栈帧大小计算错误sw $ra保存的位置和局部变量区重叠或者没有给局部变量预留足够空间。解决在符号表里为每个局部变量分配偏移时用一个变量frame_size从0开始增长每次分配局部变量先frame_size 4同时检查字符串数组等大对象是否越界。函数入口的addiu $sp, $sp, -N中N必须大于等于所有局部变量加保存寄存器的总字节数编译期用断言检查N至少是4的倍数。错误3嵌套表达式结果被覆盖算出的值不对。现象a (b c) * (d e);得到结果等于b c乘自己或者随机值。原因临时寄存器分配没有考虑递归深度左子树算完后寄存器编号被右子树再次使用。解决代码生成器里的temp_index不能简单递增要保证左右子树的寄存器范围不重叠。一个简单办法是生成表达式前记录start_temp temp_index递归左树后temp_index会增加右树开始时使用新的编号最后释放到start_temp。或者直接使用一个显式栈把中间结果压到$sp上方绕过寄存器数量限制。错误4if条件的跳转方向和C0语义相反。现象if (x 5)变成x 5时执行then分支。原因比较指令生成时slt $t0, $t1, $t0导致操作数顺序颠倒或者beqz用错条件。解决把比较翻译写成统一模式先算右表达式存入R再算左表达式存入L然后slT $temp, L, R表示LR为真。在生成beqz之前用注释在汇编里标出“若条件为真则执行then”看着注释检查条件的方向。我每次写完都会拿一个if (01) a1; else a2;的最小样例先跑MARS专门验证跳转方向。错误5数组下标的字节寻址忘了乘4。现象b[1]访问到b[0]或者b[10]不报错但读的是野地址。原因MARS的字节寻址一个int占4字节下标必须乘以4才能作为偏移。解决在数组下标翻译里生成sll左移2位后再加基址。同时检查数组越界C0通常不做运行时越界检查但如果你在代码生成时能静态判断下标范围比如循环变量从0到10访问b[10]肯定越界那就直接报编译错误而不是产生非法汇编。5.4 一晚上调不通玄学问题先怀疑构建目录和缓存最后一条经验很多人改了代码后重新编译但编译器还是旧版本。课程设计里经常出现“我改完代码输出没变化”的玄学其实是因为在IDE里没有重新构建或者测试时用的还是旧exe。我养成的习惯是每次运行前先清理中间文件然后从命令行重新编译一次再跑testfile.txt。还有生成mips.txt前先把旧文件删除如果新文件没能生成至少不会去调试一份过期输出。这些看着像白痴建议但在熬夜赶工的时候能救你半小时。6. 验证mips.txt是不是真能跑MARS分步调试与批量diff代码生成完不是终点还要证明mips.txt能在MARS里运行出正确结果。我常用的验证方法有三层。第一层是语法层用MARS载入汇编文件看它有没有报语法错误第二层是数值层在MARS里点“Run”看程序退出码是否为0寄存器$v0是不是预期值第三层是语义层写一段C0程序用编译器生成mips.txt然后用MARS的“虚拟键盘和显示器”或内存窗口检查数组内容是否和预期一致。对于函数调用和循环我用MARS的单步执行配合“Run - Pause”来逐步看$sp和$ra的变化特别是函数嵌套调用重点检查栈指针是否在调用前后保持一致。如果想做批量化回归测试我一般会准备一组testfile.txt用例从最简单的只包含一个int a; a1;到复杂的有函数递归虽然C0可能不要求递归。每个用例运行前先用一个参考编译器或人工写出期望输出的mips.txt然后跑自己的编译器生成实际输出用diff命令对比。注意diff结果不能直接证明语义正确即使汇编文本一致MARS运行后的寄存器值也可能不同因为标签命名顺序会不同。更稳的做法是让每个测试程序最后把结果写进MARS的内存窗口再用脚本读取MARS导出的内存值但这太繁琐。课程设计阶段我通常只做三个关键检查程序正常退出、返回值符合预期、至少一个数组元素的值正确。还有一个验证技巧在生成的汇编里加入注释输出每个C0语句对应的行号。这样在MARS里单步时能对照源码行看当前执行到哪个语句。我的编译器里在codegen时会把当前AST节点关联的行号随指令一起写入mips.txt比如# line 15: s s i; lw $t0, -12($fp) lw $t1, -8($fp) add $t2, $t0, $t1 sw $t2, -12($fp)从实践来看这个习惯让调试成本下降了不止一半。最让我记忆深刻的一次是嵌套函数调用导致栈指针没有恢复正确MARS单步到返回时$ra变成0x10010000程序直接跳进数据段。那时我才意识到局部变量的偏移计算不能只算字节数还得把保存$ra和$fp的空间算进去。后来我在编译器里加了一个断言函数入口生成的栈减量必须等于所有局部变量加保存寄存器空间再加一个固定余量。从那以后这类栈踩踏问题再也没有半夜出现。希望这篇能帮你把每一步的坑提前绕开真的照着做一遍把testfile.txt变成能跑的mips.txt再从MARS里跑出正确结果你会觉得编译器其实没那么神秘。本文还有配套的精品资源点击获取
返回列表