多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手写C0编译器:从词法分析到MIPS汇编的完整工程实践

手写C0编译器:从词法分析到MIPS汇编的完整工程实践 简介北航计算机学院2017级编译原理课程设计项目核心任务是将教学用的C0语言编译为MIPS汇编完成一个可运行的编译器。资源包共33个文件压缩后仅81KB以19个C源文件和8个头文件构成编译器主体覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化及目标代码生成等关键模块另含3个txt文件用于保存C0源码输入与MIPS汇编输出2个docx文档和1个md说明则提供文法说明与项目导读。项目附带Compiler-master源码管理目录能直观展示编译器的迭代开发过程帮助读者理解抽象编译理论如何落地为实际代码并体会版本控制与协同开发的基本方法。目前已有61人学习/下载适合编译原理课程设计、教学演示以及希望从零构建教学编译器的本科生和研究生。1. 为什么2017级这个C0编译器项目到今天仍值得重做一遍如果你在学编译原理或者正被课程设计折磨大概率听说过C0语言到MIPS汇编这个题目。它是北航计算机学院2017级编译原理课程设计里的经典项目输入一个testfile.txt里面是C0语言源代码输出一个mips.txt里面是能跑在MIPS模拟器上的汇编代码。很多人在网上找这份“源代码文档”的压缩包但拿到的多半是师兄师姐的遗留物——代码能过测试却看不懂更不敢改。这份项目的价值恰恰不在于“能交差”而在于它是把编译原理从理论变成工程的最小完整闭环。我见过太多人把这门课当成背概念、画状态图、写正则表达式的考试课直到动手写一个真能把代码编译成汇编的编译器才意识到前端分析只占整个工程量的一半后面的中间代码生成、寄存器分配和指令选择才是真正让人失眠的部分。这个C0编译器项目好就好在它足够小C0语言只包含整数变量、数组、函数调用、if/while循环这些最基础的结构几十个测试点就能验证完整流程。新手顺着词法分析→语法分析→语义分析→代码生成这条线走一遍整个编译器的骨架就长在脑子里了熟手则可以把它当成一个实验床去换中间表示、调寄存器分配策略、比较不同优化开关的输出差异。这篇文章我会把这套项目从代码结构到每个阶段的实现选型、再到高频踩坑点完整拆开最后给出一个能验证正确性的测试方法和几条进阶改造路径。2. 词法到语法C0语言的识别器怎么搭不易翻车2.1 先认清C0语言的边界它根本不是精简版C开始写代码前最该做的事是拿到一份C0语言的文法定义。不同年份、不同老师给的C0文法细节有差异但核心通常锁定在这些元素上类别C0语言常见定义备注关键字const, int, if, else, while, return, void, main通常会要求标识符不以数字开头算术运算符 - * / %可能不包含自增自减关系运算符 !注意等号与赋值号区分逻辑运算符 丨丨 !大概率没有位运算注释// 单行 或 /* */ 块注释需重点确认是否支持嵌套数据类型int 和 void无符号、无浮点数组只限一维一个经常让新手直接翻车的地方是C0语言里函数的返回值类型以及main函数是否需要显式写出返回类型去掉这两点你就没法和MIPS的入口约定对齐。我的建议是动手前先把文法写成EBNF形式贴在屏幕上每个产生式对应一个语法分析函数。你从网上下到的项目压缩包里通常有一份文法说明文档先读它再读代码顺序反过来容易把别人的思路错当成自己的理解。2.2 手写递归下降还是用FlexBison两个方案的选型对比在做C0编译器时词法分析有两种主流做法用Flex生成词法分析器或者手写一个简单的扫描器。语法分析也存在同样的选择Bison即Yacc的GNU版本生成LALR(1)解析器或者手写递归下降。这个项目之所以多数人选择手写倒不是情怀而是C0文法足够简单递归下降实现更直观出错时查起来快而且课程设计通常要求你“讲得清楚代码在做什么”。用FlexBison的套路大致是// flex文件片段识别C0关键字和标识符 %{ #include globals.h #include y.tab.h %} %% if { return IF; } else { return ELSE; } while { return WHILE; } return { return RETURN; } int { return INT; } void { return VOID; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str strdup(yytext); return IDENTIFIER; } [0-9] { yylval.num atoi(yytext); return NUMBER; } [ \t\n] ; . { return yytext[0]; } %%这段代码里yylval是Flex与Bison之间的共享变量str和num用于把词法值传给语法分析器。每一行规则都遵循“正则模式动作”的结构前四个规则把关键字映射成独立终结符标识符规则把匹配到的文本复制一份存到yylval.str里数字串则转成整数存进yylval.num。需要注意Flex默认对匹配规则按最长匹配优先相同长度按先出现的规则优先所以关键字规则必须写在标识符规则前面否则“if”会被识别成标识符。手写递归下降的分析器长这样// 手写词法分析核心函数 Token getToken() { // 跳过空白和注释 while (isspace(ch)) ch getChar(); if (ch / peekNext() /) { while (ch ! \n ch ! EOF) ch getChar(); return getToken(); // 递归跳过行注释 } if (isalpha(ch) || ch _) { int len 0; while (isalnum(ch) || ch _) { lexeme[len] ch; ch getChar(); } lexeme[len] \0; // 查关键字表 return isKeyword(lexeme) ? getKeywordToken(lexeme) : IDENTIFIER; } if (isdigit(ch)) { int value 0; while (isdigit(ch)) { value value * 10 (ch - 0); ch getChar(); } return createNumToken(value); } // 双字符运算符如 的处理 // ... }getToken中每一轮循环只消费一个字符遇到字母开头就连续收集形成标识符遇到数字开头就累加数值。关键的坑在于注释处理和双字符运算符的向前看一个字符peekNext如果注释跳过写成了分支而不是循环多个连续注释会出现错误。我一般倾向用Flex做词法手写递归下降做语法这样词法规则调整时不必重新编译整个分析器同时语法树的形态掌握在自己手里后续做语义分析时更容易插入动作。2.3 抽象语法树的数据结构设计后续所有工序的地基语法分析器最终输出的不是一串伪指令而是一棵抽象语法树。C0语言里节点类型有限但设计不好会把后面的语义分析和代码生成逼进死胡同。我推荐用一个带有节点类型标签的联合体结构// AST节点定义 typedef enum { NODE_PROGRAM, NODE_FUNCTION, NODE_PARAM, NODE_INT_VAR, NODE_ARRAY_VAR, NODE_CONST_VAR, NODE_IF, NODE_WHILE, NODE_RETURN, NODE_ASSIGN, NODE_CALL, NODE_BINARY_EXPR, NODE_UNARY_EXPR, NODE_NUM, NODE_VAR_REF } NodeKind; typedef struct Node { NodeKind kind; int lineno; // 出错定位用 int numVal; // NODE_NUM的字面量 char* name; // 变量名或函数名 struct Node* left, *right; // 二元运算 struct Node* mid; // 三目结构如if的条件 struct Node** children; // 函数参数列表等 int childCount; int isArray; // 是否为数组变量 int arraySize; // 数组规模 } Node;每个节点先用kind标记类型再用一个通用指针组合记录子节点。left和right覆盖二元表达式mid配合if和while结构children用于函数调用时的参数列表。这个设计看起来朴素但配合一个统一的freeNode函数递归释放内存整个分析过程几乎不用关心内存泄漏。真正的坑出现在数组变量的处理上——声明时int a[10]要在NODE_INT_VAR里记录数组规模而引用变量a[3]则应该生成NODE_ARRAY_VAR节点并携带下标表达式子树。很多实现翻车是因为不给数组单独设kind导致代码生成阶段没法区分形参和实参是值传还是引用传递。每一个节点里保存lineno行号是最值得养成的习惯语义错误和汇编生成的报错全靠它定位。3. 语义分析和中间代码生成把AST翻译成三地址码再谈优化3.1 符号表的设计作用域嵌套是逃不掉的复杂度语法树建好之后接下来要做的是语义检查比如变量未声明就使用、类型不匹配、函数参数个数不一致和中间代码生成。这两个任务都依赖一张能处理嵌套作用域的符号表。C0语言支持函数内定义局部变量也支持全局变量所以作用域必须压栈式管理。// 符号表条目 typedef struct Symbol { char* name; int type; // 0int1array2function int paramCount; int arraySize; int offset; // 栈内偏移 struct Symbol* next; // 哈希碰撞链 } Symbol; // 作用域栈 typedef struct Scope { Symbol** buckets; // 符号哈希表 int scopeLevel; struct Scope* parent; } Scope; Scope* currentScope; Symbol* lookupSymbol(char* name) { Scope* s currentScope; while (s ! NULL) { int idx hash(name); Symbol* sym s-buckets[idx]; while (sym ! NULL) { if (strcmp(sym-name, name) 0) return sym; sym sym-next; } s s-parent; } return NULL; } void enterScope() { Scope* scope (Scope*)malloc(sizeof(Scope)); scope-parent currentScope; currentScope scope; } void exitScope() { Scope* old currentScope; currentScope old-parent; free(old); }lookupSymbol从当前作用域逐层向全局查找保证内层变量能屏蔽外层同名变量。enterScope进入函数体时调用exitScope在函数体结束前调用。符号表里一定要存offset字段——这个值在中间代码生成阶段用来计算局部变量的栈帧位置如果此时不计算到寄存器分配阶段就得回过头来重新遍历符号表平白增加代码复杂度。一个常见的错误是函数内声明局部变量时符号表把同名变量覆盖到同一哈希槽导致函数体内外层变量的引用错位。解决方式是每层作用域有独立的bucket数组查表时从内向外而不是全局共用一张表。另一个容易被忽略的细节是参数也要作为局部变量进入符号表这样代码生成时可以用统一的负偏移量访问。往函数作用域里塞参数时要从右往左压栈这个顺序来分配偏移量否则调用者传参顺序与callee取参顺序会打架。3.2 选用三地址码的原因为什么要绕开复杂的语法树直接变汇编语法树直接生成MIPS汇编不是不行但有一个麻烦——表达式求值的顺序、临时变量的生命周期、逻辑短路计算这些逻辑全都会散落在各个递归函数里难以控制。更靠谱的做法是先翻译成三地址码Three-Address Code简称TACTAC每条指令最多三个操作数和一个运算符是所有教科书里中间表示的标准形态。// 三地址码指令结构 typedef enum { TAC_LABEL, TAC_ASSIGN, TAC_ADD, TAC_SUB, TAC_MUL, TAC_DIV, TAC_LOAD, TAC_STORE, TAC_GOTO, TAC_IF_EQ, TAC_IF_NEQ, TAC_IF_LT, TAC_IF_GT, TAC_IF_LE, TAC_IF_GE, TAC_CALL, TAC_RETURN, TAC_PUSH, TAC_POP } TACOp; typedef struct TACIns { TACOp op; char arg1[32], arg2[32], result[32]; int labelNo; // TAC_LABEL和跳转用 struct TACIns* next; } TACIns;生成三地址码的动作在AST遍历中完成。比如处理二元表达式节点时先递归生成左子树的TAC把结果放进临时变量t1再递归生成右子树结果进t2最后生成一条t3 t1 t2。函数调用则生成参数压栈序列、调用指令、以及取出返回值的赋值指令。临时变量的命名可以简单按序号递增t1,t2,t3……这样方便后续寄存器分配阶段统一处理。标签也用序号——L1,L2——保证跳转指令引用时不会重名。C0语言中if/while条件表达式的短路求值是课程设计里最考理解的地方。比如while (i 10 a[i] ! 0)如果条件里的不处理成短路语义生成的汇编就会无脑先求两边再逻辑与容易在a[i]越界或除零时翻车。处理方式是在左侧生成TAC后添加一个条件跳转指令左侧为假就跳转到整个条件的出口标签跳过右侧的计算。同理||左侧为真跳出口。这个细节做到位生成的汇编在边界测试点上能少一大半问题。3.3 中间代码生成的标准翻译骨架if/while/函数调用逐一对照给一份简洁的C0代码片段然后翻译成TAC// 源代码testfile.txt 内 int f(int x) { int s 0; while (x 0) { s s x; x x - 1; } return s; } int main() { int a 5; int r f(a); return r; }对应生成的TAC类似f: L4: # 函数f入口 t1 0 s t1 L5: # while条件判断开始 if x 0 goto L6 goto L7 L6: # while循环体 t2 s x s t2 t3 x - 1 x t3 goto L5 # 跳回条件判断 L7: # while出口 t4 s return t4 main: t5 5 a t5 push a # 把实参压栈 t6 call f # 调用f r t6 t7 r return t7翻译的关键在于while的出口标签与条件判断标签的跳转关系函数调用前的push顺序必须与形参从左向右或从右向左的约定统一。在C0编译器中常见做法是把参数从右往左压栈这样被调函数内第一个形参处在栈顶附近偏移量更方便计算。三地址码输出的逻辑说明push指令并不真正对应MIPS里的某一条指令它只是一个抽象表示到目标代码生成阶段会被展开成subu $sp, $sp, 4和sw两条指令。call指令同理会被展开成jal指令配上$ra寄存器的行为。保持这种抽象与具体的分层会让代码生成阶段清爽很多。这个翻译骨架覆盖了C0语言绝大部分语法结构按照AST节点类型逐个实现translator函数即可。需要小心的是return语句的翻译如果return出现在while循环内部必须先把循环内已分配的临时变量清理掉再跳转到函数结尾的公共出口标签否则栈上会残留未释放的临时变量空间。4. MIPS汇编生成从TAC到mips.txt的每一步都是细节4.1 MIPS寄存器分配策略选择这个决策决定70%的代码质量中间代码生成完毕最后的大工程是把三地址码线性序列转换成MIPS汇编输出到mips.txt。MIPS有32个通用寄存器但是除了$zero,$sp,$ra,$fp这几个有固定用途可用于分配的是有限的。课程设计级别的编译器通常采用最简单的策略把TAC里的每个临时变量和局部变量都映射到栈上固定偏移量需要运算时把操作数加载进寄存器算完结果立即存回栈。这样实现最简单、绝对不会出错缺点是生成的代码中load/store指令极多性能差。稍微进阶一点的做法是寄存器描述符法Register Descriptor为每个寄存器维护一个当前存放的变量名描述为每个变量维护一个当前位置寄存器还是栈描述。每次生成运算前先查看左操作数是否已在某个寄存器中如果在就直接引用否则选择寄存器加载。// 寄存器描述符简版实现 char* regDesc[8]; // $t0-$t7 当前映射的变量名NULL表示空闲 char* getReg(char* varName) { // 若变量已在寄存器中直接返回该寄存器 for (int i 0; i 8; i) { if (regDesc[i] ! NULL strcmp(regDesc[i], varName) 0) return regNames[i]; } // 有空闲寄存器直接用 for (int i 0; i 8; i) { if (regDesc[i] NULL) { regDesc[i] strdup(varName); return regNames[i]; } } // 全部占用受害者选择策略选一个已同步回栈的寄存器替换 for (int i 0; i 8; i) { if (varInStack(regDesc[i])) { regDesc[i] strdup(varName); return regNames[i]; } } // 极端情况强制写回并复用 char* reg regNames[0]; emitStore(reg, regDesc[0]); regDesc[0] strdup(varName); return reg; }这套描述符法的核心收益在表达式计算密集的场景下非常明显它避免了每条TAC都去做一次垃圾的存栈取栈。但代价是需要维护变量与寄存器的同步关系——变量被修改前如果寄存器中还有旧值且尚未写回栈必须插入一条save指令。处理不当会造成脏数据。作为课程设计我建议先实现纯栈式版本拿到全部测试用例通过再用描述符法做优化把两个版本生成的mips.txt交给模拟器对比执行速度和指令数这一步本身就足够写成实验报告里的优化章节。全局变量可以选择固定映射到某个寄存器比如$s6和$s7但C0语言中全局函数间共享变量用的是栈上的静态区偏移更稳的做法是把全局变量也放在栈的固定位置载入地址用la指令完成。4.2 MIPS指令选择的对照表每条TAC该翻译成哪些真实指令把TAC翻译成MIPS指令时可以准备一张翻译对照表逐规则套用遇到特殊情况再单独处理TAC指令MIPS翻译模板备注t n (常量赋值)li $t0, nn先加载到寄存器t a blw/lw 加载两操作数后再 add后 sw操作数可能在栈上t a - b同上sub注意 sub 的两个源操作数顺序t a * bmult $t0, $t1; mflo $t2乘法的结果在LO寄存器t a / bdiv $t0, $t1; mflo $t2除法结果在LO余数在HIt a % bdiv $t0, $t1; mfhi $t2模运算取HIgoto Lb L直接跳转if a b goto Llw 两操作数后 bgt $t0, $t1, LMIPS有bgt伪指令call fsubu $sp,$sp,4; sw $t0,0($sp); jal f先压栈再调用return tmove $v0, $t0; sw $v0, 0($fp); jr $ra返回值约定在$v0这里有个常见的血泪经验除法运算中如果忘记取mflo生成的代码在模拟器里运行时会得到随机垃圾值而模运算如果取错寄存器把mflo当成模结果低阶测试点可能蒙混过关一旦除数出现负数就会出错。C0语言里负数除法在MARS和SPIM模拟器上的行为可能与C语言在PC上的行为不一致比如-7 / 2有的模拟器结果是-3向负无穷取整有的结果是-4向零取整这是MIPS指令集本身不规定而由模拟器决定的玄学。处理方法是在除法TAC生成时插入一段修正代码判断被除数和除数的符号关系如果符号不同且余数非零商需要加1调整。这是整个项目里少见的必须为“精确语义”写额外汇编的场景也是评测试用例最爱埋的点。数组访问的翻译是另一个重灾区。a[i]生成TAC后到MIPS阶段需要计算地址# 访问数组元素 a[i] # $t0 存放 a 的首地址栈帧偏移后的绝对地址 # $t1 存放 i sll $t2, $t1, 2 # i * 4MIPS里左移两位即乘4 addu $t2, $t0, $t2 # 元素地址 首地址 i*4 lw $t3, 0($t2) # 取出数组元素逻辑说明这里的一维数组首地址在进入函数时通过la $t0, ...或addiu $t0, $fp, offset获得i的计算要用mulo或sll不能简单用mul因为mul在MIPS Core上可能会触发溢出异常取决于协处理器配置。数组越界检查是课程设计的加分项——在所有lw/sw访问数组元素之前插入一条比较指令如果下标越界则跳转到报错标签输出错误信息并调用exit这是把安全性意识带进编译器的好实践。字符串常量、数组常量的存储布局也要提前想清楚MIPS里.byte,.word,.asciiz这些汇编伪指令负责把常量放入数据段汇编器会自动处理对齐。如果C0语言里允许int数组初始化你需要确保数组声明后所有元素在数据段里连续排布且从4字节对齐的地址开始。4.3 栈帧布局与函数调用的规则$fp/$sp怎么协同不冲突MIPS程序的函数调用遵循一套栈帧约定。每进入一个函数栈顶向下扩展出当前帧参数和返回地址保存在其中。C0编译器实践中最稳妥的布局是高地址 | 调用者帧中的返回地址 | | 调用者保存的$fp | | 局部变量1 | | 局部变量2 | | ... | | 临时变量区 | | 实参压栈区调用子函数前 | | 当前$sp - 低地址函数入口的标准指令序列如下# 函数入口 f_entry: sw $ra, -4($sp) # 保存返回地址 sw $fp, -8($sp) # 保存调用者帧指针 subu $sp, $sp, 8 # 分配返回地址和帧指针槽位 move $fp, $sp # 建立自己的帧指针 subu $sp, $sp, 32 # 分配局部变量区和临时变量区的大小按需计算函数出口则按逆序恢复# 函数出口 f_exit: move $sp, $fp # 回收帧空间栈顶回到帧指针处 lw $ra, -4($fp) # 恢复返回地址 lw $fp, -8($fp) # 恢复调用者帧指针 jr $ra其中move $sp, $fp这一步在优化后的寄存器分配版本里尤其重要因为函数内部对$sp做过多次增量分配直接还原到$fp才能一次性回收所有局部变量区。局部变量在帧内的偏移在语义分析阶段就应确定第一个局部变量从$fp-12开始每多一个int变量偏移减4数组则一次性减掉size*4。参数则通过$fp正向偏移访问调用方压入的第一个参数在8($fp)RET ADDR占4字节保存的FP占4字节后续参数依次递增4。这个规则想清楚了传参和取值就永远不会错位。一个让很多人卡住的细节是非void函数的返回路径如果函数内部有多个return点全部要跳转到统一的出口标签。在出口标签处先把返回值从$v0复制到栈里预留的位置再执行恢复指令序列切勿在任何一条return指令处直接执行jr $ra否则栈帧没有被正确回收连续调用两次函数后栈指针就飘到宇宙尽头了。5. 避坑与排查这套编译器项目里七个最折磨人的问题5.1 testfile.txt读取失败但编译环境正常——文件路径和编码的双重坑现象程序在Visual Studio或命令行的Debug目录下运行时提示找不到testfile.txt但文件明明就在项目文件夹里。原因工作目录Working Directory不等于可执行文件所在目录。在Visual Studio中默认工作目录是.vcxproj所在目录而编译产物输出在x64/Debug/子目录。C0项目压缩包里的源代码打开后默认使用的相对路径是“编译器所在目录的相对路径”不是项目根目录。解决把testfile.txt放到与可执行文件相同的目录或者在代码里显式拼接绝对路径。更推荐的做法是在main函数入口处立即打印当前工作目录根据打印结果调整文件位置。命令行运行时使用cd切到目标目录再执行程序避免资源路径猜测。5.2 语法分析无限递归调用导致栈溢出崩溃现象输入一段复杂嵌套表达式如a (b (c * (d - e))) / f;程序直接崩溃报错显示栈溢出或内存访问越界。原因递归下降分析器中某个产生式的递归参数写错导致文法左递归未消除。典型的是expr - expr term直接实现成了expr()函数开头就调用expr()本身而不是先解析term再循环处理加号。这是教科书里反复强调但手工实现时最容易犯的左递归死循环错误。解决把表达式文法重写为右递归或迭代形式。expr()函数实现时先调用term()然后while循环检查下一个token是否是加减号再调用term()并构造节点。在while循环入口处打印当前token信息可以快速定位到卡住的位置——如果始终输出同一个token说明分析器没有消费token问题出在词法分析的后移逻辑上。5.3 生成的mips.txt在MARS模拟器上报“address out of range”现象代码生成看起来完整但加载进MARS运行到一半就报错指向某条lw或sw指令内存地址越界。原因最常见于数组下标越界或栈指针$sp没有正确初始化。MARS默认的栈指针初始值在0x7fffeffc附近但如果代码通过la $sp, ...重新赋值或者函数调用压栈和弹栈不平衡栈区地址会偏移。另一个常见原因是数组元素的地址计算中少乘了4——下标i直接加到基地址上得到的是字节地址而非字地址。解决在MARS里单步执行重点观察$sp的变化轨迹。每次jal后打印$sp每次jr $ra后再打印一次比较是否恢复原状。数组访问前手动计算一遍期望地址把地址打印出来和MARS的Data Segment窗口对应查看。我还习惯在MIPS汇编里插入一段栈指针保护检查函数入口保存$sp旧值出口处断言等于旧值不等则跳转错误处理标签。5.4 乘法/除法结果在测试点上是错的低阶测试却全过现象高强度的运算测试点比如计算组合数、斐波那契数列结果错几个数。简单表达式测试点全部通过。原因乘法用mult后忘记mflo取低位结果或者乘数超过16位导致结果溢出到HI寄存器又或者除法指令里除数操作数顺序填写反了MIPS的div语法是div rs, rt商rs/rtrt是除数。MARS模拟器里的div与SPIM在负数处理规则上不完全一致也会导致模运算的符号差异。解决给TAC中的乘法指令单独生成两条MIPS指令的序列并在注释里写出被乘数、乘数、期望商三者方便测试时对照。遇到除法结果不一致先写一个最小化的测试程序只算一个(-7) / 2和一个7 / (-2)看模拟器输出是截断还是向下取整然后决定是否需要在生成的汇编里插入符号修正代码。不同MIPS模拟器对除零的处理不同如果是除零引发的异常错误不会在MARS的Run I/O窗口显示而是在Console里出现异常地址务必先检查除数是否可能为零再查指令模板。5.5 全局变量和局部变量混淆同一名字在不同函数里串味现象函数f里定义局部变量a主函数里也定义a调用f后主函数里的a被意外修改。原因符号表的作用域隔离没做好。如果实现时所有变量都放进一张哈希表没有做作用域分层内层声明会把外层符号覆盖掉或者代码生成时两个变量的栈偏移量重复分配一个函数内的修改覆盖了另一个函数的栈区。解决检查符号表的enterScope/exitScope调用时机是否与函数体绑定。每进入一个函数体的语法分析回调里必须调用enterScope函数结束后立刻exitScope。代码生成阶段每个函数的局部变量偏移量从$fp向下分配不同函数之间天然隔离但如果符号表查到的offset是全局统一的就可能重复。用调试器在生成汇编时直接断言所有局部变量偏移量不重复。符号表条目里增加ownerFunc字段是个不错的防御手段——查表时若符号属于其他函数直接判定为未声明变量。5.6 字符串比较用导致词法错误识别现象输入的C0代码里出现!运算符词法分析总是读成!和两个独立符号语法分析直接报错。原因双字符运算符的词法规则没处理。词法分析器读到!后直接返回没有向前看下一个字符是否为。同理被读成和被读成两个。解决在用Flex时给双字符运算符单独写规则并放在单字符规则之前。手写词法时明确在!、、、分支里调用peekNext()判断下一个字符如果匹配则消费该字符并返回组合token。建议把所有双字符运算符整理成一张表词法分析器里设置一个统一的二字符匹配函数避免在多个case里重复逻辑。特别注意与的差异错误地把赋值号识别成相等判断会让所有条件判断的语义正好相反测试结果全灭。5.7 优化后生成的汇编正确性回归失败现象把纯栈式版本的代码切换成寄存器描述符法后原本通过的测试用例挂了几个且每次挂的用例不一样。原因寄存器分配代码里的脏数据未及时写回。比如某个变量在寄存器中被修改后续代码又引用该变量在栈里的旧值——正确的做法是在每次函数调用、分支跳转前把所有已被修改但未同步回栈的寄存器强制写回。这个同步点的位置漏掉一个就会造成随机性错误。解决给regDesc数组额外增加一个dirty标志位每次寄存器被写入时置1在函数调用指令前扫描所有dirty寄存器并emitStore清0。分支跳转前也做同样操作。维护一个调试模式在生成每条TAC指令对应的汇编后把当前符号表里所有变量的值预期放在字符串里与模拟器实际输出对比不一致时立即定位到出错的TAC编号。如果坚持逐条对照通常半小时能锁定问题。6. 从能跑到能看用一个最小测试集验证编译正确性完成mips.txt输出后需要建立一个可重复的验证流程。我习惯把测试文件分成三类冒烟测试、边界测试、随机测试。冒烟测试覆盖C0语言每种语法结构的基本用法比如最简单的main里声明变量和赋值返回边界测试专门针对除零、数组越界、负数运算、嵌套调用这些容易出错的地方随机测试则写一个小的生成器产出大量合法C0代码批量编译并在MIPS模拟器上对比结果与预期值。MARS模拟器支持命令行批处理模式这对自动化测试非常关键java -jar Mars.jar nc mc CompactLargeText mips.txt run.log参数说明中nc关闭MARS的缓存效果让指令时序更可预测mc CompactLargeText指定代码段加载方式避免大汇编文件出现加载问题run.log保存模拟器运行的stdout输出程序里用print系统调用输出的值都进这个文件。测试脚本里还要同时准备一份用C语言写的C0解释器或者对每个测试用例手算预期值把run.log里的数值与预期逐行diff。这套流程跑通后每次改动编译器代码都能在两分钟内得到全量回归结果。从一个较实用的进阶方向来说如果你已经跑通了基础版本建议尝试把三地址码从“无优化栈机模式”升级成活跃变量分析寄存器分配。具体做两组实验第一组只把常量表达式折叠的优化开关打开什么也不改直接生成汇编第二组做基本块内的活跃变量分析识别出无用的临时变量并删除对应指令。比较生成的mips.txt文件行数、指令周期数以及测试执行时间把这个对比做成一个表格附在实验报告里。这个项目最迷人的地方在于加一行优化编译器的代码生成的汇编效率就能肉眼可见地提升这种反馈是所有纸上谈兵的教科书都给不了的。我自己做这个项目时最大的教训是贪快——总想一次性写出一个完整的代码生成器结果中间表示和寄存器分配耦合得太紧改一个功能就崩一遍。后来老老实实把TAC输出成文本格式写了100行的汇编级调试器所有问题都变得可控了。哪怕你现在只是拿到一份现成的源码压缩包也建议先看懂TAC生成这部分再跑通汇编生成把中间结果打印出来加上断言机制编译器的正确性是能被证明的前提是别跳过验证这一层。希望这个思路能帮你在课程设计里少走些弯路无论是为了交差还是为了真正理解编译器这套流程都值得完整走一遍。本文还有配套的精品资源点击获取
返回列表