
简介西北工业大学编译原理试点班大作业完成了一个可正常工作的 SysY 语法编译器并附源代码、文档说明与实验报告适合计算机相关专业学生用于课程设计、编译原理实验或毕设初期演示。压缩包共168个文件大小为113KB包含大量SysY测试用例与输入文件、C/C实现源码如抽象语法树、中间表示与静态单赋值构造、Python工具脚本、Markdown说明文档等结构清晰便于学习。代码均已测试运行成功功能正常作者答辩评审平均分达到96分上传前经过验证可放心使用。已有249人学习下载项目对初学者友好作者支持私聊远程教学帮助解决运行环境与编译问题。通过阅读源码和实验报告可系统理解编译器前端的语法分析、中间代码生成、静态单赋值形式与优化流程也能在此基础扩展功能满足课设、毕设等不同需求。1. 拿到一个“能正常工作的SysY编译器”大作业资源它到底在训练什么如果你正在做的编译原理实验课要求是“写一个编译器”那么西北工业大学这套试点班大作业的核心载体——SysY语言编译器就是你最值得参照的完整样本。它不是那种只跑通教材示例的玩具而是一个覆盖了词法、语法、语义、中间代码生成和目标代码生成的完整链路产物最终能把你手写的C/C代码变成可执行的汇编程序。很多人第一次看到这个标题会问这和用IDE里那个“编译器”有什么区别区别在于你在用编辑器写代码而这份大作业要求你成为“编译器”本身——把字符串变成抽象语法树再把树变成机器能认的指令。本文会把这份大作业从规格到实现拆开讲适合正在做编译原理实验的学生对照复现也适合工作后想补上“前端到底怎么和后端接起来”的开发者。你不必照着西工大的题目一字不差地做因为SysY本身已经成了国内多所高校编译实验的事实标准理解了它你就理解了绝大多数教学编译器的骨架。2. 先读懂SysY语言规格、子集边界与设计取舍2.1 SysY不是一门新语言而是C语言的一个精心裁剪子集很多第一次接触SysY的人会误以为它是某种需要重新学习语法的学术语言实际上它就是你熟悉的C语言去掉若干难点之后剩下的骨架。SysY保留了int和float两种基本类型、一维数组以及可选的n维数组扩展、全局和局部变量、函数定义与调用、if/else、while、return、赋值、算术和逻辑运算。它刻意删掉了指针、结构体、位运算、函数指针、递归部分版本允许、字符串处理和复杂类型转换目的只有一个让一个学期内、两三个人的小组能从零写出一套能通过自动化评测的编译器。这里面的课程设计思路值得你留意——它不是要求你发明新语言而是要求你在一门已有语言的约束内实现完整编译流程。SysY规格的其余部分几乎和C一致表达式优先级是括号大于单目大于乘除模大于加减大于比较大于逻辑与大于逻辑或声明语句用分号结尾函数可以无参也可以带参全局变量必须在编译期确定初值。这套子集边界带来的直接好处是做语法分析时你不必处理C语言里那种“类型名和表达式难以区分”的lexer hack也就是著名的“最棘手解析”问题因为SysY的声明关键字只有int、float、void不存在typedef引入新类型名的情况。这意味着你的递归下降解析器可以放心地看到标识符就当作变量引用不必像真实C编译器那样回溯或做符号表预查询。我在做这类实验时习惯先把语言规格打印出来贴在屏幕边上因为几乎所有“看起来是Bug”的问题最后都发现是自己实现的语义和规格不一致而不是代码写错了。2.2 类型系统与作用域规则两个必须提前定的设计决策理论上你可以在动手写代码前先定好两个决策否则后面改起来会很痛。第一个是类型检查的严格程度。SysY要求int和float不能直接混用所以你在语义分析阶段需要一套类型推导规则二元运算的两个操作数类型必须一致才能“直接”运算不一致时需要插入int到float的隐式转换节点赋值时右值类型必须能无损转换到左值类型函数返回值要和声明类型一致。这些规则的落点通常是AST节点的类型标注字段我会用枚举来处理Type::Int、Type::Float、Type::Void、Type::ArrayInt(size)其中数组类型要带尺寸因为SysY的数组是静态的编译期必须知道总大小来计算栈上偏移。第二个决策是作用域模型。SysY的作用域规则和C一致全局作用域在最外层函数参数属于函数体作用域块语句{}内部可以声明新的局部变量遮蔽外层同名变量。常见做法是用一个作用域链来实现符号表每个作用域是一个unordered_mapstring, Symbol并带一个指向父作用域的指针。查符号时从当前作用域往上逐层找插入符号只插当前层。这里有一个隐藏的大坑函数名和变量名是否共用命名空间。SysY规格一般允许函数和全局变量各自命名——有些评测用例会故意声明一个全局变量名和之前的函数同名用来测试你的符号表是否把函数和变量分开存储了。我在别的实验里因为偷懒把函数和变量塞进同一张表结果评测时遇到这种用例直接翻车只能回头把符号表拆成两张。2.3 从规格到可执行计划把大作业拆成四个里程碑这一步不是写代码但能决定你后面的节奏。我一般会把整个工期拆成四个阶段第一阶段写词法分析和AST数据结构跑通“读源码、输出Token流、构建语法树”并打印出来人工校验第二阶段做符号表建立和语义检查让编译器能报出“未定义变量”“类型不匹配”“返回值缺失”三类典型错误第三阶段生成中间代码通常是三地址码或栈式指令这一阶段不关心具体目标机器只保证逻辑正确第四阶段把中间代码翻译成目标汇编常见是RISC-V并处理栈帧布局和寄存器分配。每个阶段都有一个可验证的产物不要试图一步到位直接产出汇编——因为一旦结果不对你完全无法定位是语法解析的问题还是代码生成的问题。Stage 1: 源码字符串 - Token流 - AST (验证打印AST结构) Stage 2: AST 符号表 - 带类型标注的AST (验证人为制造错误源码检查报错位置) Stage 3: 带类型AST - 三地址码IR (验证逐条解释执行IR并与源码逻辑对比) Stage 4: IR - RISC-V汇编 (验证用模拟器运行汇编比对输出)这个阶段划分的本质是让你每一次运行都能看到“新东西”词法不对时你看Token流语法不对时你看AST形状语义不对时你看报错信息IR不对时你看中间代码逐条执行结果最后才轮到汇编。很多同学把大量时间花在选框架、选工具链上其实教学编译器最稳的路线就是手写全部模块不用生成器。理由后面会展开。3. 前端三件套词法、语法、语义分析的最小可跑实现3.1 词法分析手写状态机远比Flex实用SysY的关键字只有int、float、void、return、if、else、while七个运算符也只有算术、比较、赋值、逻辑、位取反部分规格有和括号分号花括号等几十个Token类型。手写词法分析器的工作量很小——一个switch驱动的逐字符扫描就够用而且出错时调试成本比Flex生成的代码低得多。我的做法是定义Token结构体记录类型、词素文本、行号和列号其中行号和列号必须从一开始就维护好因为后面的语法错误提示要依赖它们定位。enum class TokenType { Int, Float, Void, If, Else, While, Return, Identifier, IntLiteral, FloatLiteral, Assign, Plus, Minus, Star, Slash, Percent, Eq, Ne, Lt, Gt, Le, Ge, And, Or, Not, LParen, RParen, LBrace, RBrace, LBracket, RBracket, Semicolon, Comma }; struct Token { TokenType type; std::string lexeme; // 实际文本数字常量保留原始字符串用于后续转换 std::string value; // 已转换的字面值int存十进制float存十六进制比特位 int line, col; };词法分析的主循环逻辑是跳过空白和注释“读到一个字符就决定下一个Token的扫描方式”。标识符或关键字用首字符是字母或下划线来触发数字用首字符是数字来触发运算符按最长匹配原则一个个试。SysY的注释是//行注释和/* */块注释块注释不嵌套这一点要在状态机里明确处理——如果你图省事写成“遇到*/就结束”遇到注释里出现/*就会提前关闭注释直接改变后续Token流。处理数字常量时有一个容易忽略的细节SysY的float字面量允许1f这样的后缀但没有double类型所有浮点数常量按32位存输出汇编时用十六进制立即数表示IEEE 754位模式而不是打印成可读的小数——因为汇编器的语法不一定接受十进制浮点。3.2 语法分析递归下降配上“每个函数一个语法产生式”的写法语法分析是整个编译器里最机械也最不能出错的部分我强烈建议你手写递归下降解析器不要用Yacc/Bison。SysY的语法没有左递归问题因为表达式文法用优先级爬升或分层展开就能避开也没有LL(1)冲突的经典陷阱。常见做法是给每个语法产生式写一个parseXxx()方法返回对应的AST节点指针遇到语法错误时调用统一的error()函数打印行号和期望的Token类型然后做panic-mode恢复——跳过当前语句到下一个分号或右花括号为止这样一次编译能报出多个错误而不是卡在第一个错误上。std::unique_ptrStmtNode Parser::parseStmt() { if (match(TokenType::LBrace)) { auto block std::make_uniqueBlockNode(); // 花括号内的语句序列每个语句可以是声明、表达式语句、if、while、return while (!check(TokenType::RBrace) !atEOF()) { block-stmts.push_back(parseStmt()); } expect(TokenType::RBrace); return block; } // 允许空语句直接一个分号 if (match(TokenType::Semicolon)) { return std::make_uniqueEmptyStmtNode(); } // 识别声明语句的关键是前看两个Tokenint或float 标识符 if ((check(TokenType::Int) || check(TokenType::Float)) peekNextIsIdentifier()) { return parseLocalDecl(); } if (match(TokenType::If)) { return parseIf(); } if (match(TokenType::While)) { return parseWhile(); } if (match(TokenType::Return)) { return parseReturn(); } return parseExprStmt(); }这段代码的关键点有两个。第一是“如何区分声明和表达式语句”——实现方式是向前看两个Token如果当前位置是int或float且下一个位置是标识符就按声明解析否则可能是一个函数调用表达式语句或老式的int类型出现在表达式中这在SysY里不应该出现。第二是空语句必须显式处理因为评测用例里会出现在循环体直接写一个分号的情况。AST节点全部用std::unique_ptr管理避免手写析构和拷贝带来的内存问题——这是我踩过的一个很实际的坑C11时代的课程代码用裸指针很容易在递归解析抛异常时造成内存泄漏而智能指针能让异常安全变得几乎免费。表达式解析用优先级爬升法比较简洁逻辑是为每个优先级层级写一个函数parseExpr处理赋值parseOr处理逻辑或parseAnd处理逻辑与parseEq处理相等比较parseRel处理大小比较parseAdd处理加减parseMul处理乘除模parseUnary处理一元正负和逻辑非parsePrimary处理字面量、标识符、括号表达式和函数调用。每一层递减地调用下一层函数并在循环中检查当前运算符是否属于本层这就避免了写十几层嵌套的尴尬。3.3 语义分析符号表建立、类型检查和错误恢复三位一体语义分析阶段做的事情可以概括为两次遍历AST第一次遍历收集所有函数声明与全局变量声明填入符号表——这一步必须在分析函数体之前完成否则函数体内调用后面定义的函数时会报“未定义”而这在SysY里是合法的C语言也一样第二次遍历逐函数检查函数体的语句和表达式做类型推导与验证。两次遍历的原因很朴素函数可以互相调用如果只做一次遍历解析到a()时a还没被收集就会误报错误。符号表条目需要记录三类信息符号名、类型包含数组尺寸信息、存储位置是全局变量、局部变量还是函数参数。对局部变量和参数还要在代码生成阶段之前就分配一个假的“栈偏移”这个偏移决定它在栈帧中的位置。我习惯在语义分析阶段就完成偏移分配因为后续生成汇编时每条访问变量的指令都需要知道偏移量。struct Symbol { std::string name; Type type; // Type::Int / Type::Float / Type::ArrayInt(64) 等 int offset; // 局部变量为相对帧指针的偏移全局变量为数据段标签 bool isGlobal; }; struct Scope { std::unordered_mapstd::string, Symbol symbols; std::shared_ptrScope parent; // nullptr 表示是全局作用域 };类型检查的核心函数是checkExpr它递归地处理AST节点并返回该表达式的类型。比如遇到二元运算节点时先递归检查左右操作数如果两边类型不同一边int一边float就按SysY的隐式转换规则在AST中插入一个ConvNode指明这是把int转成float——这个转换节点后续在代码生成阶段会翻译成具体的汇编指令序列。遇到函数调用节点时要检查函数是否存在、实参个数和形参个数是否一致、每个实参类型能否转换为对应形参类型。遇到return语句时要检查当前所在函数的返回类型是否匹配如果函数声明返回int而return后面是float需要插入转换如果返回void的函数写了return 5;则必须报错。这一阶段的所有报错信息格式建议统一为[error] line X: 具体描述——评测系统一般不检查你的报错文本但你自己调试时一个清晰的行号能省去大量时间。这里还有一个容易忽略的点作用域退出时要弹出当前作用域否则离开一个块后块内声明的变量仍然可见符号表会“漏”到后面。常见做法是在parseBlock时创建新的子作用域在块解析完成后恢复父作用域为当前作用域。如果你用了作用域链加“当前作用域指针”的方案注意在异常路径上也要恢复——可以用RAII或者defer风格的清理来保证。3.4 中间代码生成把AST压平成三地址码指令流中间代码的经典选择是三地址码Three-Address CodeTAC每条指令至多一个运算符和三个操作数比如t1 a b、t2 t1 * 4、store t2 - arr[0]。选择三地址码有三个实际理由第一它离AST很近每个AST节点都能映射成一到几条TAC指令按后序遍历生成即可第二它和汇编之间有清晰的映射关系你可以逐条把TAC翻译成RISC-V指令第三它可以被解释执行——我在调试编译器时写了一个极简的TAC解释器这样拿到IR就能检查逻辑是否正确不必等到汇编跑起来。struct Instruction { OpCode op; // ADD, SUB, MUL, DIV, MOD, NEG, LT, LE, GT, GE, EQ, NE, JMP, JZ, JNZ, ... std::string dst; // 目标临时变量或标签 std::string arg1; std::string arg2; int line; // 对应源码行号出问题时能溯源 }; struct FunctionIR { std::string name; std::vectorstd::string params; std::vectorInstruction instructions; std::vectorSymbol localSymbols; // 每个函数携带自己的符号表副本 int stackSize; // 帧大小由语义分析阶段统计 };生成TAC时有一个典型的难点是控制流的翻译。if语句翻译成“条件失败跳转到else或end标签”while翻译成“循环开始标签、条件检查、循环体、跳回标签、循环结束标签”。这套逻辑本身就是一个小型设计建议画一张“label与跳转关系”的草图再动手while (A) { B }翻译成Label_Loop、条件求值、JZ Label_End、执行B、JMP Label_Loop、Label_End。条件表达式如果是A B这类短路运算不能直接翻译成“先算A再算B”——因为A为假时不应计算B。常见做法是把A B展开成两条跳转指令计算A为假就跳转到“结果0”的公共出口为真才继续计算B。短路求值是教学编译器评测中必考的点因为很多大作业的复杂度都体现在“表达式的控制流化”上。关于临时变量的命名我统一用%t0、%t1递增编号最终在代码生成阶段把这些临时变量映射到寄存器或栈位置。这一阶段不需要关心目标机器的寄存器数量因为三地址码的临时变量是“无限”的后端的寄存器分配才会引入约束。4. 后端两步走从抽象语法树到目标代码的关键映射4.1 从IR到汇编先理解寄存器机再理解SysY的栈帧约定目标代码生成是整个大作业里最“硬”的部分。常见方案是生成RISC-V汇编因为它指令集规整、教程多、还有RARS这种模拟器可以直接跑很多教学编译器都默认输出RISC-V。你不需要一台真实的板子模拟器足够验证正确性。在做代码生成之前你得先想清楚一个关键约定栈帧怎么排我采用的标准做法是函数入口先保存返回地址RISC-V中的ra寄存器和旧帧指针fp然后把fp指向当前帧sp向下移动栈帧大小函数体内所有局部变量和临时变量的访问都通过fp加固定偏移来完成函数返回时恢复sp、fp、ra并跳转返回。这个布局的好处是访问局部变量不需要动态计算偏移结构最简单也最容易调试。高地址 | 调用者帧 | --------- | ra | 保存的返回地址 | 旧 fp | 保存的帧指针 | 局部变量| 第1个局部变量偏移为 -8 或 -12依对齐 | 临时变量| | 参数区 | 如果需要重定位不用SysY参数可以直接放寄存器 --------- sp 指向当前帧底 低地址局部变量的偏移分配规则是从fp-8开始向下分配因为ra占8字节、旧fp占8字节每个int占4字节每个float占4字节数组按元素个数乘元素大小分配连续空间。所有局部变量偏移都在语义分析阶段就算好代码生成阶段直接查符号表取偏移即可。如果遇到需要对齐的场景某些指令要求16字节对齐你可以在分配时把每个变量的大小按8字节向上一取整代价是浪费一点栈空间但换来的是不会出现非对齐访问。4.2 表达式求值的两种策略栈式求值与寄存器分配生成表达式求值代码有两条路线。一条是栈式求值每算一个子表达式就把结果压入栈需要时再从栈弹出。这种策略实现最简单——每个二元运算翻译成“右操作数压栈、左操作数压栈、弹出右操作数到t0、弹出左操作数到t1、执行运算、把结果压栈”。但它生成的代码效率低、栈指令多而且对熟悉后续优化的同学来说不是好基础。另一条是寄存器求值为每个临时变量尝试分配一个物理寄存器用完就释放。SysY大作业对性能没有硬性要求但用寄存器策略写出来的后端从结构上更接近真实编译器。我在做这类后端时用的是统一寄存器分配策略给每个TAC临时变量分配一个虚拟寄存器编号然后用一个简单的映射表把虚拟寄存器映射到RISC-V物理寄存器上——t0到t6共7个。分配的规则是定义一个“活跃区间”的概念如果一个临时变量在它的最后一次使用之后就可以释放寄存器。最简版本是“线性扫描”遍历指令序列维护一个“当前活跃变量”集合变量第一次出现时分配一个空寄存器没有空寄存器就把最早被使用的变量溢出到栈上。对教学编译器来说溢出逻辑可以简化——你把所有临时变量都映射到栈上的固定偏移运行时一条指令从栈取出来算完再存回去物理寄存器只用t0/t1/t2三个即可正确性完全不受影响。我更喜欢这种简化方案因为它的汇编可读性极高你一眼能看出哪条指令对应哪条IR。// 代码生成的简化映射临时变量 %t0 映射到栈偏移 -36%t1 映射到 -40 int tempOffset(const std::string temp, int currentOffset) { // 维护一个 mapstring,int每个临时变量分配8字节偏移递减 }4.3 函数调用与数组寻址两个“写错了不报错但结果错”的重灾区SysY的函数传参规则通常被称为“整型按值传、数组退化为指针传地址”——但是SysY里没有指针类型所以数组参数的正确保数方式是按“基址尺寸”传递。我的做法是调用函数时实参如果是普通变量或常量直接加载到a0~a7寄存器超过8个参数的部分压栈实参如果是数组名则把数组首地址加载到寄存器。这样被调用函数内部就可以通过基址加偏移访问数组元素。这两个方向的映射都必须测一遍调用者把数组首地址放进了a0被调用者也要按“a0里是数组首地址”来取元素。这里最容易出bug的是嵌套函数调用如果foo(bar(x))在计算bar(x)时覆盖了a0那么foo的第一个参数必须在你计算bar之前就保存到栈上——很多同学的编译器是在这里翻车的。数组寻址的另一个细节是下标类型。SysY允许int a[10]; a[i1] 3;其中i1是int表达式你需要在IR层面生成“计算下标表达式、乘以元素大小、加上基址、访存”的指令序列。如果数组是float类型下标还要乘以4字节。这一步比较容易出错的地方是减法下标和负数下标——SysY没有规定越界检查所以负数下标会访问到数组基址之前的地址这其实是内存安全漏洞但在教学评测中不会触发崩溃你要做的是保证“合法的下标计算正确”而不是“非法的下标有保护”。5. 大作业最容易翻车的五个坑现象、原因与修复5.1 编译通过但模拟器报“未包含main类型”症状是汇编器或链接器提示找不到main符号或者分配器报“entry point not found”。根本原因是你的编译器在处理全局变量时把全局变量声明当成了函数声明导致main函数没被识别为入口。另一种可能更隐蔽你的词法分析把main识别成了关键字而非标识符从而无法在符号表中登记main这个函数名。我的解决方法是检查三个位置词法分析器的标识符判定规则里是否意外包含了main语法分析器的全局声明解析是否把所有以int或float开头的语句都吞掉代码生成时入口标签是否用了统一格式通常汇编器要求.globl main。这类问题排查最快的办法是把你生成的汇编写到文件里人工查看开头20行看到main:标签缺失就知道问题出在哪儿了。5.2 本地测试全过评测机上一个用例崩溃这是最经典的血泪经验。本地跑一个简单用例正常评测一上去就挂——多半是数组越界没有崩溃、但破坏了相邻变量的值。原因是你的栈帧分配过于紧凑局部变量a占4字节、b占4字节它们的偏移紧挨着如果代码生成对数组的偏移计算多乘或少乘了一个元素大小可能访问到另一个局部变量的位置数据被污染但程序没崩。解决办法是回归到最简单的确定性测试写一个int a[4]; int b; b 5; a[3] 1; return a[3] b;的用例人工检查生成汇编中每次对a和b的访存偏移是否和符号表记录一致。如果对b的访问出现在数组地址附近就说明数组偏移基准或下标乘数有问题。5.3 全局变量和局部变量同名语义分析直接“串味”症状是函数内声明了一个和全局变量同名的局部变量结果全局变量在该函数内被意外修改或者出现“重复定义”错误。原因是你的符号表在进入函数体时没有创建新的作用域并把全局符号表作为父作用域而是还在全局表上继续插入。解决方法是严格执行作用域链在解析每个函数体之前新建一个子作用域parent指向全局作用域在解析每个块语句时再往下建一层。查询符号时从当前层向上找找到第一层为止插入时只插入当前层。这样同名遮蔽天然就正确。5.4 短路求值被优化成了“先算两边”症状是int a 0; if (a (1/a ! 0))这类用例你的编译器输出结果与C语义不符——因为如果先把1/a算出来就会触发除零错误而正确行为是短路跳过右操作数。问题在于你的IR生成对和||做了直接求值而不是控制流展开。修复方式是把A B翻译成如下IR序列计算A如果A为假则跳转到“结果0”标签如果A为真再计算B结果就是B的值公共出口标签处把结果整合。这个修复必须在IR阶段做不能留到汇编阶段再打补丁因为汇编阶段看不到“短路”的语义信息只有跳转结构。5.5 递归函数导致栈无限增长本地跑着跑着就卡死SysY规格中的递归支持因版本而异如果你的版本允许递归那么函数调用的栈帧必须每次调用都重新分配。很多人图省事把局部变量映射到了“固定的全局地址”这导致递归时第二次调用覆盖了第一次调用的数据。修复方式就是严格执行动态栈帧分配每次函数调用入口都更新sp局部变量的偏移始终相对当前fp计算而不是绝对地址。检查方法也简单写一个int fact(int n) { if (n 1) return 1; return n * fact(n-1); }如果计算结果对说明栈帧做得正确如果结果不对看生成的汇编是否在递归调用时重复使用了同一块栈偏移。6. 收尾验证用SysY官方测试集框架做回归再动手优化走到这个阶段你的编译器已经能通过手写用例了但大作业能不能拿高分取决于验证方法的系统性。SysY社区有一套公开的测试样例组织方式常见结构是每个功能点一个目录目录下多个.sysy源文件每个源文件配一个.out文件保存期望输出。你可以照着这个思路搭建自己的回归测试脚本用Python写一个简单的测试运行器遍历测试目录、逐个调用编译器、用模拟器运行生成的目标代码、与期望输出比对并统计通过率。import subprocess, pathlib, sys compiler ./mysysy # 你的编译器可执行文件 simulator java -jar rars.jar # RISC-V模拟器参数按模拟器文档调整 base pathlib.Path(tests) passed failed 0 for src in sorted(base.rglob(*.sysy)): exp_file src.with_suffix(.out) asm_file src.with_suffix(.s) subprocess.run([compiler, str(src), -o, str(asm_file)], checkTrue) result subprocess.run( simulator.split() [str(asm_file)], capture_outputTrue, textTrue ) expected exp_file.read_text().strip() actual result.stdout.strip() if actual expected: passed 1 print(fPASS {src.name}) else: failed 1 print(fFAIL {src.name}: expected {expected!r}, got {actual!r}) print(fpassed {passed}, failed {failed}) sys.exit(1 if failed else 0)这套框架有几个好处跑完一轮你能立刻知道哪个功能点挂了第二次回归时只需重跑脚本就能防止“改好了东墙拆了西墙”。我的习惯是每实现一个新功能就立刻增加对应测试用例而不是等所有功能都写完再一次性测试——一次性测试时面对20个失败用例你根本不知道从哪儿开始查。脚本里的编译器参数-o指定输出文件和模拟器调用方式都要按你自己的实现改这不是能直接复用的代码而是一个可参考的验证骨架。验证通过之后下一步才是“优化”。大作业评分通常包含“性能测试”——同样一个复杂用例你的编译器生成的代码比别人的慢就会丢分。但教学编译器优化的起点不是做复杂的寄存器分配、指令调度或循环优化而是消除那些容易消除的低效模式表达式中的冗余栈操作、常数运算的提前折叠、if (1)这种永真条件的分支消除。我在做了基础正确性验证后会加一个轻量的窥孔优化扫描生成的汇编把“把常数加载到寄存器、再和另一个常量做运算”的指令序列替换成直接用常数的立即数指令。这个优化代码量不大但性能提升非常明显是一种成本收益比极高的技巧。另一个必须做的收尾工作是文档和实验报告。西工大这套大作业的资源包包含源代码、文档说明和实验报告实验报告的重点不是抄代码而是讲清楚你的设计决策为什么用递归下降而不是LL/LR生成器、为什么用三地址码而不是栈式指令、符号表和栈帧是怎么设计的、测试数据覆盖了哪些边界。把这些决策写清楚比你贴200行代码更能体现工作量。我的习惯是每完成一个里程碑就写一小节报告而不是最后一周集中补——集中补出来的报告读起来像流水账缺失了设计过程里的取舍记录。最后说一个我做编译器实验时养成的习惯永远保留“最小复现用例”。每遇到一个bug不要急着改代码先把这个bug缩成一个20行以内的SysY程序保存到regression/目录下修复之后把它加进回归测试。这个习惯看起来朴素但能保证你离校之后回头看这份代码时任何一次改动都能被测试保护住。编译器的坑大多藏在语义边界上——短路求值、类型转换、数组偏移、作用域遮蔽哪个方向想漏了都会在评测时给你一个措手不及的失败。希望这份从规格到验证的完整路径能帮到你让你在做自己的SysY编译器时少走几个通宵。本文还有配套的精品资源点击获取