简介:这是一份《编译原理》课程设计完整实现方案,面向计算机专业学生或需要完成类C语言编译器大作业的开发者。资源提供带图形界面的编译器程序,包含代码编辑、语法高亮、行号显示、自动补全等编辑器功能,并支持新建、打开、保存及未保存提示等文件操作。编译部分覆盖词法分析、LR(1)语法分析、语义分析、中间代码优化与目标代码生成,可输出单词符号串、LR分析表、语法树、符号地址表及汇编代码,帮助理解编译器各阶段原理。压缩包共104个文件,其中8个cpp和7个h为源码,25个dll为运行依赖库,另有qt界面相关qm/ui/ts文件、30张png说明图及exe可执行程序等,整体约25.63MB。已有583人学习下载,适合课程设计参考、实验对照或编译器原理自学。
1. 类C语言编译器课程设计:这块骨头到底有多硬
《编译原理》课上最劝退的作业,就是自己写一个类C语言编译器。我见过太多组同学卡在同一个地方:前两周斗志昂扬写词法分析,第三周开始怀疑人生,最后答辩前熬夜拼出来一个只能算四则运算的玩具。这套课程设计真正的难度不在“写代码”,而在你要把一整条编译流水线都串起来——从源码读入、词法扫描、语法树构建,到语义检查和代码生成,任何一环有洞,后面全崩。它适合想真正搞懂编译器和编辑器区别的人,也适合做编译原理实验想拿高分的人,更适合那些想在简历上写“编译器开发经验”的学生。这篇文章我就按自己做课设的血泪经验,把架构、代码、参数和坑一次讲透。
2. 先定架构再写代码:词法、语法、语义三层怎么切分
2.1 整体流水线:从哪里进,从哪里出
一个类C编译器,骨架永远是这条链:源代码 → 词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 目标代码生成。常见做法是用一个驱动函数按阶段调用,每个阶段返回的数据结构是下一个阶段的输入。前端处理的产物是抽象语法树(AST),后端消费AST生成指令序列。
我一般会加一个中间表示(IR)层,而不是直接从AST跳到目标机器码。原因很简单:你后端的指令集可能要换,目标平台可能从MIPS换到x86,中间表示替你挡住了这层变化。类C语言课设的IR不需要像LLVM那样庞大,三地址码配合符号表就够了。
一个值得注意的点:很多同学在写代码生成时才意识到,前面AST里存的类型信息不够用。比如一个int和float的加法,你在语法分析阶段只记住了“这是个加法节点”,到代码生成阶段你才发现需要知道操作数类型才能生成正确的算术指令。所以语义分析阶段必须在AST节点上补齐类型推导结果,这件事要尽早留出接口。如果没有中间IR层,调试时你会在目标汇编里看到一个完全不像源码逻辑的指令序列,排查难度直接翻倍。
2.2 实现语言怎么选:C、C++、Java还是Python
选实现语言,直接影响你愿意写多少行。我用的是C++,因为结构体指针建AST很顺手,但调试指针悬空也让我翻车很多次。Java的话,对象模型清晰,垃圾回收帮你省了内存管理的麻烦,但代码量偏大。Python写起来快,适合验证思路,但类型检查弱,跑起来慢,而且课设要求“类C”,用动态语言实现一个静态语言编译器有点搞笑,但交作业没毛病。
如果目标是快速出活,我建议选C++或Java;如果想练底层,就选C。一个现实考量是:课程设计通常要求提交源代码和文档说明,文档里如果有“编译方法”一栏,C++写出来的Makefile会比Python脚本显得更正式。但不要选你完全不熟的语言,编译器本身已经够难调试了,再加上语言细节不熟,很容易陷入“解法对但代码写不对”的境地。我见过选C语言实现但连strtok都调试半天的组,最后答辩时根本没时间讲设计,全在讲内存错误。
2.3 工程组织:头文件、模块划分和全局信息表
模块划分我建议遵循“一个阶段一个目录”的原则:lexer/、parser/、semantic/、codegen/,每个目录一个头文件一个实现文件。公共数据结构放include/ast.h、include/symtab.h。看似多折腾,实际上答辩评审最看重这一点。源代码管理上,别把所有代码塞进一个compiler.cpp,那会让你连搜索一个函数定义都要滚半天。
这里要说一个血泪经验:符号表不要做全局单例。类C语言有函数作用域、块作用域,符号表必须支持压栈出栈。我一个同学把符号表写成全局哈希表,结果两个同名局部变量互相覆盖,所有变量类型检查全部错乱,整整调了两天。符号表必须设计成“一个作用域一张表”,有父指针指向外层,进入块作用域压入新表,退出时弹出。
2.4 输入输出格式:别在报告里忽略命令行参数
编译器必须有清晰的命令行接口。常见做法是:compiler [选项] 源文件.c,支持-o指定输出文件名,-dump-ast输出语法树,-dump-ir输出中间代码。别小看这个,有没有-dump系列选项,决定你调试时能不能直视AST结构。我一般会在词法阶段加一个-dump-tokens选项,打印每个token的类型、行号和文本。调试“哦原来这里切错了”比任何gdb下断点都好使。
下面是一个最小驱动代码框架,可以在main里看到完整的执行顺序:
int main(int argc, char* argv[]) { // 1. 读取源文件字符串,注意要按二进制读,保留换行符 std::string src = readFile(argv[1]); // 2. 词法分析:得到token流 Lexer lexer(src); std::vector<Token> tokens = lexer.tokenize(); // 3. 语法分析:构建AST,根节点是Program Parser parser(tokens); Program* prog = parser.parseProgram(); // 4. 语义分析:类型检查 + 符号表填充 SemAnalyzer sema; sema.check(prog); // 5. 中间代码生成:三地址码 IRGenerator irGen; std::vector<IR> ir = irGen.generate(prog); // 6. 目标代码生成与输出,写入argc指定的输出文件 CodeGen codeGen; codeGen.emit(ir, outputPath); return 0; }这段代码的逻辑很直白:每个阶段拿到上一个阶段的产物,处理完丢给下一个阶段。src是整个源文件的字符串,tokens是切词的结果,prog是根节点,sema.check(prog)做类型检查,IRGenerator生成中间表示,CodeGen把IR翻译成目标指令。参数说明里要注意:argc和argv的处理不能偷懒,类C编译器要支持多个源文件吗?课设一般不需要,但要支持一个源文件加若干选项。我建议用简单的参数解析循环,别引第三方库,答辩时不方便讲。
这里还要谈一下编译器和编辑器的区别。很多初学者把编译器理解成“把代码变成能跑的软件”的IDE,其实编辑器只是帮你写字的工具,编译器才是真正做词法、语法、语义分析的程序。你的课设写的是后者,答辩时一句话能说清这个区别,很加分。
3. 用递归下降手写语法分析器:表达式优先级与AST设计
3.1 为什么不用yacc/bison,而是手写递归下降
类C语言课设用flex/bison能省一半工作量,但我不推荐。原因有三:第一,自动生成的语法分析器出错信息是“语法错误”,没有行号没有预期符号,你不能把flex/bison当作黑匣子,出了问题只能干瞪眼;第二,递归下降分析器是手写的,你可以精确控制报错位置和恢复策略;第三,答辩时老师一定会问“你的文法怎么消除左递归的”,你要是用bison,这个问题就变成了“bison怎么解决左递归的”,答不上来很尴尬。
我一般用递归下降法配合每个非终结符一个函数的结构。每个函数对应文法产生式的一个非终结符,函数内部按产生式顺序去匹配终结符或调用其他非终结符函数。这种写法的可读性高,而且每段错误都有自定义消息,能直接说出“expected ')' at line 5”这种有用信息,而不是一句干巴巴的“syntax error”。
3.2 表达式优先级:一个函数一层优先级
类C语言里最麻烦的是表达式。加减乘除、关系运算、逻辑与或,还有一元负号、括号。递归下降处理优先级的经典方案是:每个优先级写一个函数,最高优先级在最深层。比如比较运算那一层调用加减那一层,加减那一层调用乘除那一层,乘除那一层调用一元操作那一层,一元操作最后落到主表达式(数字、变量、括号)。
这样处理的好处是优先级暗含在调用链里,不需要额外的优先级表。缺点是函数数量多。我通常把等级拆成四层:逻辑或/逻辑与、相等与关系比较、加减、乘除、一元。用代码来看会更清楚:
// 处理加减法,左结合 Expr* Parser::parseAdditive() { Expr* left = parseMultiplicative(); // 先解析更高优先级的乘除 while (peek(PLUS) || peek(MINUS)) { Token op = advance(); Expr* right = parseMultiplicative(); // 操作数同样可能是乘除表达式 auto* node = new BinaryExpr(op, left, right); left = node; } return left; }这个函数体现的是标准递归下降做法:调用下一级优先级函数拿到左操作数,然后循环消费加减号,每遇到一个运算符就用右结合的方式解析右操作数,再构造二元表达式节点。peek(PLUS)是向前看一个token,advance()消费token,new BinaryExpr(op, left, right)生成AST节点。
由于递归下降要求文法没有左递归。类C表达式的乘法、加法这些在文法规则里通常是“乘除表达式→乘除表达式 乘号 一元表达式”这种左递归形式,直接映射成递归函数会无限递归。处理方式是改成迭代:先解析右侧操作数,再循环处理后续运算符。这就是上面代码里用while而不是递归调用的原因,属于消除左递归的手工实现。
3.3 AST节点设计:用基类加类型标签,别用万能的struct
AST是语法分析阶段的产物,后面语义分析和代码生成都要用它。节点设计得不好,后面会用得很痛。我见过最失败的设计是:一个大struct里面放所有可能的字段,是/否/类型/名称全堆在一起。这样代码生成阶段判断“这个节点到底是什么”要靠一堆if (node.kind == ...),写着写着就麻了。
推荐的做法:一个基类Expr或Stmt,派生出NumberExpr、BinaryExpr、AssignExpr、IfStmt、WhileStmt等。基类里有一个Kind枚举,子类各自保存自己的字段。核心是line字段保存行号,这是后面报错的基础设施,没有它你只能报“第0行有错”。
AST节点的释放是个坑。C++用手写课设最烦内存泄漏,你new出来的所有节点都要有人负责delete。如果选择智能指针,注意别用shared_ptr到处传,unique_ptr够用。但每个类都要把移动构造处理好,经常是拷贝构造没禁掉,编译直接报错。一个折中方案是干脆用一个AST分配器(arena allocator),所有节点都从一个大池子里分配,程序结束一次性释放,这算是我最满意的后悔药。你以后做真实编译器也能用上这种思路。
3.4 语句与声明:块、if、while、函数定义怎么接
类C语言的语句基本就是:表达式语句、赋值语句、if、while、for(可选)、return、复合语句(块)、变量声明、函数定义。这些在AST上对应不同的节点,语法分析函数的组织是:parseStatement()按当前token分发到具体的语句解析函数。
函数定义的解析要注意返回类型和参数列表。类C语言里可能有int main(void)或int foo(int a, int b),返回类型和参数列表都要存下来,供语义分析阶段做类型匹配。有一点容易漏:函数在什么时候解析。常见做法是两次扫描——第一次先注册所有函数签名,第二次才解析函数体,这样函数可以先调用后定义。如果只扫一遍,像int main(){ f(); } void f(){}这种代码就会在调用f时查不到f的符号。
这里就涉及“编译器未包含main类型”这个报错的根源:如果你的语法分析阶段没有把int main()登记成入口函数,语义阶段就找不到入口,最后链接或目标代码生成阶段就会报类似错误。避免这个问题的办法很朴素:在parseProgram()里扫完所有外部声明后,单独检查符号表里是否存在返回值类型为int且参数列表为空的main函数。没有就抛出“缺少main函数”的语义错误,而不是让它在链接阶段以莫名其妙的方式爆出来。
4. 语义检查与中间代码生成:符号表和三地址码的实现
4.1 符号表:作用域嵌套、类型记录、重复声明检查
语义分析阶段的第一步,是给每个作用域建符号表。类C语言的作用域规则和标准C一致:函数体外是全局作用域,函数体是函数作用域,块内的局部变量在块作用域,内层可以遮蔽外层同名变量。
我推荐的符号表接口很简单:enterScope()压入新作用域,exitScope()弹出,declare(name, type, kind)声明一个符号,lookup(name)从当前作用域向外层逐级查找。实现上用std::unordered_map加一个父指针即可。下面是实现要点:
struct Symbol { std::string name; Type type; // INT, FLOAT, VOID, FUNC... Kind kind; // VAR, FUNC, PARAM int line; // 声明所在行,报错用 }; class SymTable { std::vector<std::unordered_map<std::string, Symbol>> scopes; public: void enterScope() { scopes.emplace_back(); } void exitScope() { scopes.pop_back(); } bool declare(const Symbol& sym) { auto& cur = scopes.back(); if (cur.count(sym.name)) return false; // 重复声明 cur.emplace(sym.name, sym); return true; } const Symbol* lookup(const std::string& name) const { for (auto it = scopes.rbegin(); it != scopes.rend(); ++it) { auto found = it->find(name); if (found != it->end()) return &found->second; } return nullptr; } };这个实现核心是vector模拟作用域栈,scopes.back()永远是最内层。lookup从最内层向外查,找到就返回,找不到最后返回nullptr。declare只往当前作用域插入,插不进去就说明重复声明。参数说明里值得讲的是:Type和Kind我用枚举,不要用字符串。字符串一拼写错就变成“找不到类型”的玄学问题;枚举还能直接放进switch里做类型检查。函数符号的Type应该记返回类型,但参数列表必须单独挂到函数节点上,因为符号表里存一个参数向量在后续调用检查时会方便很多。
还有一个细节:数组声明支持不支持?课设里我建议支持一维数组,但要用单独的结构体ArrayType { Type elemType; int size; }来表示,不能简单当成普通类型。如果你不做数组,至少要明确拒绝带下标的变量声明,别让语法分析阶段接受int a[10]然后再在语义阶段报一个让人看不懂的“类型不匹配”。
4.2 类型检查:赋值兼容、运算数类型、函数调用参数匹配
类型检查的目标只有这么几条:赋值表达式左右类型兼容;二元算术运算的操作数是int或float,不能是函数或未知类型;关系运算返回int;函数调用时的实参类型和数量要与形参一致;return表达式的类型与函数返回类型一致。
有的同学在这里会走偏,试图把类型检查做成完整的一等公民系统。课设不用,按上述约束做即可。类C语言没有隐式转换规则也要定义清楚:我一般只允许int隐式转float,反过来要报错,这能避免很多歧义。比如float x = 1;合法,但int y = 2.5;应该报错,不然代码生成阶段不知道截断规则,生成的结果和你预期完全不符。
类型检查要挂在AST上,给每个表达式节点存一个推导出的类型。我的常见做法是给Expr基类加一个Type inferredType字段,检查完赋值,后面生成IR的时候直接读这个字段决定生成IADD还是FADD。要是留到代码生成阶段再算,就得在AST和IR之间维护一张类型映射表,多一道工序多一份错。调试时更麻烦:你看IR觉得“指令没问题啊”,但忘了类型信息丢在那里,查半天才知道是上游没传。
4.3 中间代码三地址码:指令集设计与临时变量管理
三地址码是类C编译器后端的地基。常见的实现是每行一条指令,格式为op dst src1 src2,比如ADD t1 a b表示t1 = a + b。它能表达表达式计算、赋值、跳转、函数调用、返回等语义。跳转指令需要标号,我用Label指令占位。
设计指令集时,我建议指令数量控制在15条左右:ASSIGN、ADD/SUB/MUL/DIV、NEG、CMP(各种关系比较)、JMP、JZ/JNZ(条件跳转)、CALL、RET、PUSH/POP。有了这些就可以把AST翻译成一套线性指令序列。临时变量的命名很简单:t1、t2……用一个计数器不断生成。
vector<IR> IRGenerator::genExpr(Expr* e, const string& dest) { vector<IR> ir; if (auto* num = dynamic_cast<NumberExpr*>(e)) { ir.push_back(IR("ASSIGN", dest, num->value)); } else if (auto* bin = dynamic_cast<BinaryExpr*>(e)) { string ltmp = newTemp(); string rtmp = newTemp(); vector<IR> lhs = genExpr(bin->left, ltmp); vector<IR> rhs = genExpr(bin->right, rtmp); ir.insert(ir.end(), lhs.begin(), lhs.end()); ir.insert(ir.end(), rhs.begin(), rhs.end()); string opcode = opToIR(bin->op, bin->type); // IADD / FADD / ISUB... ir.push_back(IR(opcode, dest, ltmp, rtmp)); } else if (auto* var = dynamic_cast<VarExpr*>(e)) { ir.push_back(IR("ASSIGN", dest, var->name)); } return ir; }这个函数的逻辑非常标准:遇到数字节点,直接把常量赋给目标临时变量;遇到二元运算,先递归生成左子表达式的指令到ltmp,再递归生成右子表达式的指令到rtmp,最后生成一条加法或减法指令,结果写到dest。newTemp()生成t1、t2……每次调用自增计数器。参数说明:这里有个容易被忽略的点——opToIR要根据之前语义分析推导的bin->type来决定用整数加法还是浮点加法。类C语言里a + b如果两边都是float就必须生成FADD而不是IADD,否则目标代码生成阶段会算出完全错误的结果。这也验证了刚才说的类型要提前推导。
临时变量管理还有一个坑:生成IR时,表达式嵌套生成的临时变量数量可能不少,但有些临时变量用完就不再引用,也没人去清理。课设阶段可以不优化,但要在IR结构里保留“活跃变量”的雏形,比如每条IR记一个lastUse标志,后面做寄存器分配或死代码消除时能直接复用这些信息。
4.4 控制流语句:if/while/return翻译成跳转
if语句翻译成跳转的经典方案是:先对条件表达式生成IR,结果放到临时变量,然后生成JZ temp label_else,条件为假跳走;生成条件为真执行的指令;生成JMP label_end;生成label_else:和假分支指令;最后生成label_end:。
标号管理用计数器,label_1、label_2这样一路递增。我的习惯是每个控制流语句生成两个标号:一个“跳过分支”,一个“结束”。嵌套if-else时标号命名要全局唯一,否则目标代码会跳错。这部分代码不复杂,但写的时候要反复在纸上画跳转图,我踩过的坑是标号重复,后来越过越小心。while和for的翻译同理:JMP跳到条件判断处,JZ跳出循环体,JMP跳回条件判断。嵌套循环时,内层循环的break要跳到哪一层,这种边界问题建议在语义分析阶段就把break的目标标号写到AST里,否则代码生成阶段会找不到。return的翻译简单,但要注意在函数中间return时,应该生成JMP跳到函数末尾的公共出口,而不是直接在指令流中间RET,否则它会跳过函数收尾代码,栈帧被破坏。
5. 避坑指南:课设编译器最常见的5个翻车现场
5.1 现象:报错说“未定义变量”,变量明明声明在上一行
原因:符号表的作用域压栈出栈时机不对。常见错误是enterScope()和exitScope()不配对,比如在解析函数体开始前压入作用域,但函数体解析完忘记弹出。结果是如果遇到同名变量在不同作用域里时,声明了int a;紧接着又在块里声明int a;,第二次声明被解释为重复声明,后面的引用又找不到变量。
解决:在语义分析器里用RAII思想管理作用域。进入复合语句前调用enterScope(),离开前无论正常返回还是抛异常都执行exitScope()。为保险,我会在exitScope()后打印一条调试日志,输出现有作用域深度,一旦发现深度不对就能立刻定位。
5.2 现象:递归下降解析表达式时栈溢出崩溃
原因:文法没消除左递归。有的同学把加减法写成expr -> expr + term,然后在函数里直接先递归调用自己,无限递归把函数栈挤爆。表现为编译自己写的测试程序时直接segfault,且gdb看不出有效调用栈。
解决:所有左递归产生式改成右递归或循环。我明确建议用循环,因为循环直观且不用引入额外的右结合标记。如果你非要保留左递归文法结构,就必须先把操作数压入一个列表,等全部解析完再逆序构建AST,这也是可以的,但很容易写错。
5.3 现象:int main没写,编译器却一路飘到链接才报错
原因:编译器缺入口检查。有些同学在语法分析阶段就结束工作了,把“有没有main”当作链接器的责任。课设编译器一般自己直接生成目标代码,不跑外部链接器,所以必须自己检查。如果输出的是汇编再交给外部汇编器,那asm阶段会在入口缺失时报一个毫无上下文提示的错误,特别难排查。这就是网络上被问烂的“编译器未包含main类型”问题。
解决:在语义分析完成后的一个独立pass里,扫描全局符号表,必须具备名字为main、返回类型为int且无参或void参数的函数。没有就报错,报错信息要带行号:“line 1: function 'main' not found”。这一步放在IR生成之前,避免白忙活。
5.4 现象:printf调试输出对不上,AST打印出来全是null
原因:节点构造时忘了分配内存。常见于二元表达式里右操作数解析失败返回nullptr,但上层没检查就直接存进节点,到dump AST时访问空指针。类C语言里a + b如果b是非法token,parseMultiplicative()返回空指针,new BinaryExpr仍然被调用,于是AST里出现null子节点。
解决:在每个parse函数入口和出口做空指针断言。我一般写一个expect(Expr*),如果传入nullptr就抛出带当前token行号的“语法错误:无法解析表达式”。这样错误发生在源头而不是远离现场的打印阶段。别在已经构造的AST里后期修补,直接在语法分析阶段切断。
5.5 现象:编译大一点点的测试程序时内存占用暴涨,或者堆空间不足
原因:AST节点大量泄漏。C++实现里到处new,如果忘了delete,一个大型测试程序可能产生几万个小节点,内存没有释放。加上如果用了std::shared_ptr管理AST且图里有环,泄漏更隐蔽。某个同学用shared_ptr存AST,节点相互引用形成环,refcount永远不为0,程序跑完内存不减。
解决:两个方案。方案一是用arena分配器,所有AST节点从一个大内存池里分配,编译完一次性释放整个池子。方案二是统一用unique_ptr。我强烈推荐arena,它让析构不负任何责任,报告里还能写“使用内存池优化编译器内存占用”,作为编译原理实验的加分项。避免在网络提问里硬套“编译器堆空间不足”这种表述,实际是内存碎片或泄漏。
5.6 现象:同一个程序,优化开关开了就出错,关了就好
原因:做常量折叠但这些优化在语义分析之前执行了,破坏了AST节点类型信息。常见做法是遍历AST做常量折叠,把2 + 3直接替换成5。如果折叠发生在类型检查之前,2 + 3替换成5没问题,但2 + 3.5如果按整数折叠就出错了。
解决:优化必须在语义分析之后。先做类型推导,折叠时根据推导出的类型决定用整数运算还是浮点运算。并且折叠要在IR生成之前完成,否则IR已经生成了再去改,你需要重写一整条指令序列,相当于重做代码生成。更稳妥的方案是课设阶段只做“一个常量加另一个常量”的折叠,别碰复杂模式。复杂模式比如2 * a + 0这种,折叠后变成2 * a没问题,但0 * a折叠成0就有风险——如果a是浮点数,结果类型就变了,还会影响后面的寄存器分配。
6. 目标代码生成与验证:让你的编译器输出真正能跑的汇编
6.1 从三地址码到MIPS汇编的映射规则
中间代码生成后,最后一步是目标代码生成。课设常见的落地路径是输出类MIPS汇编,再用MIPS模拟器(比如课设机房常见的Mars或SPIM这类工具)验证。我一般这样映射指令:
| IR指令 | MIPS指令 | 说明 |
|---|---|---|
| ASSIGN t1 a | lw t0, 偏移($fp) | 从栈帧读变量 |
| ADD t3 t1 t2 | add t3, t1, t2 | 三操作数运算 |
| JMP label | j label | 无条件跳转 |
| JZ t label | beq t, $zero, label | 比较跳转 |
| CALL foo | jal foo | 函数调用 |
| RET | jr $ra | 返回 |
MIPS的寄存器分配最简单的是固定映射:算术运算结果按顺序分配$t0到$t9,用完回写栈帧。这不做真正的分配,但能跑。变量全部保存在栈帧里,每次使用先load,用完store。生成的汇编文件可以直接交给模拟器跑,你会看到寄存器里数值一个个蹦出来,那是你写的类C代码在另一个指令集上活过来的瞬间。
6.2 验证方法:不靠“看起来对”,要造一个覆盖全部特性的测试套件
测试套件不能只写一个helloworld。我建议按功能分组:表达式、控制流、函数、变量作用域、类型混合。每组至少两个测试文件,一个正向一个负向。负向测试专门用来验证报错信息是否正确。把这些测试组织进一个shell脚本里,跑一遍比对输出与期望文件,能省下答辩前最后一夜的大量时间。
我最后养成的习惯是每次改动代码,先跑测试套件再继续下一项,而不是攒到最后一次“放个大招”。编译器这种程序,回归测试是你难得的后悔药——今天改的bug,可能明天在另一个功能里又冒出来。负向测试更是如此,你以为修好的报错逻辑,可能改了个符号表接口就全乱了。
6.3 评价和后续扩展:函数内联、本地寄存器分配、断点打印
如果你的课设做完还有余力,往这三个方向加一点就能在答辩里提升一个层次:第一,支持const折叠和死代码消除,这是编译器优化的最小可讲案例;第二,实现一个简单的寄存器分配,去掉“所有变量都存内存”的做法,性能有直观提升;第三,加-v选项打印每个阶段的耗时和token数,至少能让老师看到你的工具链是完整的。希望帮到你。
我第一次完成这个课设的时候,目标代码生成前的每个阶段都觉得已经“完美”了,结果真正跑起来才发现,汇编输出第一个程序时满屏幕的“null pointer”。后来我把经验浓缩成一句话:编译器开发的每个阶段,都要让数据能“裸奔”可见——dump token、dump AST、dump IR、dump asm,出问题时一眼看到在哪坠毁的。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取