编译原理实验通关:从词法分析到四元式生成的完整实践 简介这是一份中国海洋大学2020年春季学期编译原理课程的全部实验代码包适合校内修读该课程的学生也适合正在自学编译器构造的开发者作为课程设计参考。资源以RAR压缩包形式提供共74个文件、约774KB包内主要包含C语言源码、lex/flex语法定义文件、yacc/bison文法文件、头文件、可执行程序以及实验要求文档和makefile构建脚本便于对照实验目标查看实现代码与运行结果。压缩包按实验编号组织八个实验完整覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理和编译器综合各实验分别包括对应源码、测试输入与部分生成文件例如二义/无二义文法对比、产生式输出、语法树打印等可帮助理解编译器从前端到后端的整体构造流程。目前已有4400余人学习下载是完成实验、课程复习和编译知识巩固的实用参考资料对准备相关考试或参与编译器项目也很有帮助。1. OUC编译原理实验到底在做什么一条从词法到代码生成的完整链路很多同学拿到“编译原理全部实验”的任务单时第一反应是赶紧把几个实验拼成一个能交差的程序。但真正动手做下来你会发现这套实验是把编译器的前端到中端完整拆开训练词法分析让字符流变成Token语法分析让Token流变成语法树语义分析靠符号表做类型检查最后翻译成四元式中间代码。做完这条链路你对“一段程序是怎么被机器理解的”就不再是黑匣子式的想象。这套实验适合正在赶实验进度的学生也适合想用一两周时间搭出一个玩具编译器、彻底搞懂符号表与中间代码如何协作的从业者。2. 把词法分析做成最小可跑的DFAToken识别与状态机的三个关键写法词法分析实验通常摆在第一个要求也最明确把源码里的标识符、关键字、整数、实数、运算符、分隔符分别识别出来跳过空白和注释最后输出一个Token流。很多人第一反应是上flex但我建议实验课手写状态机。理由不是flex不好而是手写版本每一步状态迁移都能写进报告调试时只需要盯一个函数出了问题一眼能看出是哪个状态没处理。2.1 手写状态机 vs 自动机生成为什么实验里我建议手写flex这类生成器的核心价值是“正则表达式直接编译成DFA”语法简洁、处理大型词法规范很快。但放在实验课里有三个现实问题第一实验要求的token类型通常只有二三十种文法很小flex的高吞吐优势根本用不上第二报告要解释“这个DFA是怎么来的”手写状态迁移表比贴flex源码更容易讲清楚第三flex生成的跳转表是黑匣子一旦识别错一个token排查路径比手写长得多。常见做法是给每个token类别设计独立的识别分支用状态变量记录当前读到哪一步。比如识别数字时状态在“整数”和“实数”之间流转只需要在.字符处加一个判断识别标识符时先收集完整字符串再去查关键字表。这样整个Scanner只有一个主函数加若干小函数逻辑完全可见。这里有一个容易忽略的边界关键字和标识符的区分不能在读第一个字符时做必须等整个词读完之后再查关键字表否则“ifx”会被误判成关键字“if”加标识符“x”。这是手写Scanner最常见的翻车点下面代码里我用统一查表方式处理。实际实验里还有一个选择是直接用正则库去匹配类别但正则库依然要自己查关键字表还引入了匹配顺序的坑省不了多少事。2.2 一个能识别标识符/关键字/数字/运算符的Scanner骨架下面这段C代码是一个能直接跑通的最小Scanner核心覆盖了标识符、关键字、整数、实数、运算符和分隔符六类token的识别框架#include stdio.h #include ctype.h #include string.h #include stdlib.h typedef enum { TOK_IDENT, TOK_KEYWORD, TOK_INT, TOK_REAL, TOK_OP, TOK_SEP, TOK_EOF } TokenType; typedef struct { TokenType type; char lexeme[64]; int line; int col; } Token; static const char *keywords[] { if, else, while, return, int, float }; #define KEYWORD_COUNT 6 static int is_keyword(const char *s) { for (int i 0; i KEYWORD_COUNT; i) { if (strcmp(s, keywords[i]) 0) return 1; } return 0; } Token next_token(const char **p, int *line, int *col) { Token tok; memset(tok, 0, sizeof(tok)); tok.line *line; tok.col *col; // 跳过空白与换行 while (**p || **p \t || **p \n) { if (**p \n) { (*line); *col 1; } else { (*col); } (*p); } if (**p \0) { tok.type TOK_EOF; return tok; } // 标识符或关键字 if (isalpha(**p) || **p _) { int len 0; while (isalnum(**p) || **p _) { if (len 63) tok.lexeme[len] **p; (*p); (*col); } tok.lexeme[len] \0; tok.type is_keyword(tok.lexeme) ? TOK_KEYWORD : TOK_IDENT; return tok; } // 数字整数或实数 if (isdigit(**p)) { int len 0, is_real 0; while (isdigit(**p)) { if (len 63) tok.lexeme[len] **p; (*p); (*col); } if (**p .) { is_real 1; if (len 63) tok.lexeme[len] **p; (*p); (*col); while (isdigit(**p)) { if (len 63) tok.lexeme[len] **p; (*p); (*col); } } tok.lexeme[len] \0; tok.type is_real ? TOK_REAL : TOK_INT; return tok; } // 运算符与分隔符 if (strchr(-*/(){};, **p)) { tok.lexeme[0] **p; tok.lexeme[1] \0; if (**p || **p - || **p * || **p / || **p || **p || **p ) { tok.type TOK_OP; } else { tok.type TOK_SEP; } (*p); (*col); return tok; } // 无法识别的字符实验里通常要求报错并定位 sprintf(tok.lexeme, %c, **p); tok.type TOK_OP; /* 占位真正的实验会在这里抛异常 */ (*p); (*col); return tok; }逻辑说明核心思路是“一个字符一个字符往前吃”用指针p维护当前读入位置用line和col维护行列号。标识符分支先把完整词收进来再查关键字表解决了“ifx”被误拆的问题数字分支遇到.才切到实数状态且要求小数点后至少有一位数字才算合法实数否则应该报错。用const char **p而不是单个char *p是为了让next_token能持续移动外部游标调用方不需要每次手动更新指针。几个值得注意的参数选择lexeme[64]是实验里够用的缓冲区大小实际应该加一个“超长标识符报错”分支KEYWORD_COUNT要和你定义的文法关键字保持一致少一个语法阶段会翻车行列号从1开始计数是实验报告里约定俗成的写法不要用0起始。这个Scanner没有处理注释和字符串如果实验要求里有在跳过空白分支的后面再加两个状态分支即可思路是遇到/再往后看一个字符是*就进入注释状态直到*/结束是//则一直吃到换行。2.3 Token表的组织方式行号、列号、类型与字面量怎么存Token结构体里的四个字段不是随手写的type供语法分析判断移进还是规约lexeme保存原始字面量供符号表查询line和col是报错时的定位依据。很多同学会漏存行列号结果语法分析报错只能报“第几个Token有问题”而实验报告要求的是“第几行第几列出错”这就是血泪经验。另一个常见的组织问题是词法阶段要不要顺便判断变量类型答案是不要。词法分析只负责“这是什么词”不管“这个变量是int还是float”后者是语义分析查符号表之后才能决定的所以这里Token里故意没有类型字段。Token流建议存成动态数组或链表语法分析时按顺序取用。实际操作中语法分析经常需要“预读一个Token”来判断分支所以我会在Token流上包一层游标结构提供peekToken()和nextToken()两个方法peek只读不移位next取走并前移游标这样递归下降写起来会顺手很多。3. 语法分析选LL(1)还是LR(1)从FIRST/FOLLOW到预测分析表语法分析是整套实验里最劝退的一章因为第一步不是写代码而是先回答一个问题你的文法到底适不适合LL(1)。如果答案是不适合后面写的递归下降全是白费功夫。判断标准就两条没有左递归、没有公共前缀。满足这两条LL(1)表驱动或递归下降都能做不满足要么改写文法要么换LR路线。实验课里绝大多数语言子集经过改写后都能进LL(1)框架所以我一般先带着学生把文法改到LL(1)再动手。3.1 消除左递归与提取公因子两种文法的改写手段先看教科书里最常出现的算术表达式文法E - E T | T T - T * F | F F - (E) | id这条文法首先是左递归的直接写递归下降会无限递归。改写方式是把左递归转成右递归引入一个新的非终结符EE - T E E - T E | ε T - F T T - * F T | ε F - (E) | id改写原则是把E - E α | β变成E - β E和E - α E | ε。注意这里的ε表示空串在递归下降代码里对应“直接return”在预测分析表里对应“该格子填同步或错误”。做完这步之后还要检查公共前缀比如if_stmt - if ( E ) S | if ( E ) S else S两条产生式以if ( E ) S开头属于公共前缀需要提取公因子变成if_stmt - if ( E ) S else_part、else_part - else S | ε。这两个改写做完文法才能进预测分析表。很多同学会问LR(1)不需要消除左递归为什么不直接上原因是实验课按阶段推进语法分析之后还有语义分析和中间代码生成递归下降的代码结构和产生式一一对应后面做语法制导翻译时往里插动作最方便。LR(1)的分析表由生成器产生你很难在归约动作里优雅地挂语义规则。所以我的建议是除非实验指定用yacc/bison否则LL(1)框架是性价比最高的选择。3.2 计算FIRST集与FOLLOW集手算流程与一段校验脚本FIRST集合定义是“一个非终结符能推导出的所有终结符开头的集合”FOLLOW集合是“在某个句型里紧跟在该非终结符之后的终结符集合”以及结束符$。手算流程就三步先处理所有产生式右部的第一个符号如果是终结符直接加入FIRST如果是非终结符则递归合并它的FIRST然后处理ε产生式把ε加入FIRST最后算FOLLOW把产生式右部中某个非终结符后面的FIRST加入它的FOLLOW如果后面是ε或该非终结符在末尾则把左部的FOLLOW也并进去。这是每次实验里最机械的部分手算容易漏我建议写一段小脚本校验。比如下面这个计算FIRST集的Python版本做完王生原那本第3版第三章的课后习题之后再用它核对效果比自己干瞪眼强得多def first_sets(grammar, terminals): first {nt: set() for nt in grammar} changed True while changed: changed False for lhs, productions in grammar.items(): for rhs in productions: if rhs [ε]: if ε not in first[lhs]: first[lhs].add(ε) changed True continue for sym in rhs: if sym in terminals: if sym not in first[lhs]: first[lhs].add(sym) changed True break if sym in grammar: before len(first[lhs]) first[lhs] | (first[sym] - {ε}) if len(first[lhs]) ! before: changed True if ε not in first[sym]: break else: if ε not in first[lhs]: first[lhs].add(ε) changed True return first # 用法示例文法用字典表示 grammar { E: [[T, E\]], E\: [[, T, E\], [ε]], T: [[F, T\]], T\: [[*, F, T\], [ε]], F: [[(, E, )], [id]], } terminals {, *, (, ), id} print(first_sets(grammar, terminals))逻辑说明采用不动点迭代反复扫描所有产生式直到任何FIRST集合都不再变化。核心判断是如果产生式右部第一个符号是终结符直接加入FIRST并停止扫描如果是非终结符把它的FIRST去掉ε并进来只有它本身能推导出ε才继续看右部下一个符号如果右部所有符号都能推导出ε则把ε也加入左部的FIRST。参数设计的关键点terminals集合必须手动指定脚本不会替你推断终结符调用前要先确认哪些符号是没有产生式的终结符。另一个容易出错的细节是break和continue的边界条件我第一次做实验就被这个不动点迭代卡了半小时后来发现在if sym in grammar分支里漏了break导致同一个FIRST集合被重复合并。FOLLOW集合的计算逻辑类似区别是要从右向左扫描产生式并记录“后面的FIRST集”和“左部的FOLLOW集”脚本结构和FIRST完全对称。提示FOLLOW集计算中勿忘每个开始符号的FOLLOW集要包含结束符$。很多同学的预测分析表第一列漏了$导致表驱动分析永远走不到接受状态。3.3 LL(1)预测分析表与递归下降哪个更适合实验课预测分析表驱动的做法是把FIRST/FOLLOW结果填入一张二维表行是非终结符、列是终结符表项是产生式编号分析时维护一个栈根据栈顶符号和当前Token查表决定移进还是规约。表驱动的好处是代码量少、逻辑统一但坏处是错误定位很差一旦栈顶和输入对不上你只能看到“栈顶是E输入是)”很难知道是文法错还是程序错。递归下降则是把每个非终结符写成一个函数函数内部按产生式逐个匹配终结符或调用其他非终结符函数。它的优点是错误定位精确到函数调用栈我一般在每个函数入口打印一行“进入E当前Tokenxxx”调试时一眼看出文法走到了哪条分支。实验课我强烈推荐递归下降因为它和后面要做的语法制导翻译是天然配合的在函数体里插入“生成四元式”的代码就是对语法树的遍历过程不需要额外维护分析栈。一个容易被忽略的坑是递归下降中的ε产生式处理。比如E - T E | ε写成代码是如果当前Token是就匹配并继续递归否则直接return。这个“否则直接return”就是ε分支很多同学会忘记写导致输入到末尾时函数还在等一个报错报得莫名其妙。我的习惯是在每个函数开头注释里标清楚这个非终结符的FIRST集合看到当前Token不在FIRST里就明确报错而不是盲目return。另外递归下降的错误恢复也值得花一点时间做最简单的策略是同步符号法——报错后跳过输入直到遇到当前非终结符的FOLLOW集中的字符这样一次能报多个错误而不是第一个错就把整次分析停掉。4. 符号表与语义分析避坑作用域、类型检查和四个必踩的坑词法和语法做完之后程序长什么样你已经能描述了但“这个变量是int还是float”“这里能不能用return”还判断不了这就是符号表与语义分析的活。符号表是编译原理实验里最容易被低估的一块很多人觉得它就是个查字典实际上作用域、遮蔽、先声明后使用这些规则全部要靠它撑起来。4.1 符号表结构分层作用域与最近嵌套原则实验要求通常至少支持函数级作用域和块级作用域所以符号表不能是一张平铺的哈希表否则两个同名变量在不同函数里会互相覆盖。常见做法是维护一个“作用域链”每进入一个花括号块压入一个新的作用域每退出弹出一个。查符号时从当前作用域开始往上逐层找这叫最近嵌套原则。typedef struct Symbol { char *name; int type; // 0int, 1float, 2函数 int line_decl; // 声明所在行 struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope; Scope *current_scope NULL; void push_scope(void) { Scope *s (Scope *)malloc(sizeof(Scope)); s-head NULL; s-parent current_scope; current_scope s; } void pop_scope(void) { Scope *s current_scope; current_scope s-parent; free(s); } Symbol *lookup(const char *name) { Scope *s current_scope; while (s) { for (Symbol *sym s-head; sym; sym sym-next) { if (strcmp(sym-name, name) 0) return sym; } s s-parent; } return NULL; }逻辑说明push_scope和pop_scope在语法分析进入和退出代码块时成对调用lookup从当前作用域向上查找所以内层作用域的同名变量会遮蔽外层。line_decl字段用来在报错信息里提示“first declared here”这是实验报告评分点之一。参数设计要注意type字段只存基本类型是不够的如果实验要求支持函数和数组应该扩展成结构体包含返回类型和形参表lookup返回的是最近一层的符号如果需要判断遮蔽关系可以再加一个lookup_all遍历整条链做诊断。另外一个实践细节声明符号时先查当前作用域是否存在同名符号如果存在应该报“重复定义”而不是直接覆盖这是很多同学容易翻车的地方。还有一点退出作用域时要记得释放整个作用域链上的Symbol内存否则测试用例跑多了内存只涨不降容易被实验平台判超时。4.2 类型检查在什么时候做语法制导翻译的挂接点类型检查不是单独一个阶段而是嵌在语法分析的过程里这叫语法制导翻译。具体挂接点有三个变量声明处检查类型是否匹配赋值语句处检查左右类型兼容函数调用处检查实参和形参数量和类型。递归下降实现时就在对应产生式的函数返回前插入检查代码。最常见的检查逻辑是这样的对赋值语句id expr先lookup(id)拿声明类型再给表达式函数返回一个表达式类型两者不一致就报“type mismatch”。表达式类型是自底向上合成的a b的类型由a和b决定而a的类型只能查符号表得到。这一块没什么玄学就是把类型当成属性在语法树节点之间传递唯一要小心的是int和float之间的隐式转换规则实验文档说允许就做提升不允许就直接报错不要自己发明规则。4.3 坑一悬空else与最近的if匹配现象输入if (a) if (b) c 1; else c 2;本意可能是else匹配外层if但分析器把它匹配到了内层if。原因if_stmt - if ( E ) S | if ( E ) S else S这条文法有经典二义性一个else既可以是内层if的也可以是外层if的。解决语言里的标准做法是规定else匹配最近的未闭合if在递归下降实现时让每一个if分支在解析完语句体后主动检查else而不是把else留给外层处理。具体到代码里解析if的返回路径上要有“吃掉else并解析第二个语句体”的逻辑同时保证嵌套if先内后外。4.4 坑二符号表回填与“先声明后使用”现象代码里写int a b; int b 1;运行分析器直接就报“b未定义”。原因符号表是边扫描边插入的b的声明发生在赋值语句之后扫描到a b时b还没有进表。解决如果实验语言只要求“先声明后使用”这个行为其实是正确的但如果实验语言允许前向引用比如函数调用你需要把“收集声明”和“生成代码”拆成两次遍历。实验课里最稳妥的做法是每进入一个作用域先扫描一遍所有声明插入符号表再处理语句序列这就是所谓的两遍扫描。4.5 坑三临时变量的编号冲突现象表达式a b * c生成的四元式里出现两个不同的t1导致后续使用混乱。原因很多同学喜欢用“t 计数器”生成临时变量名但计数器的作用域没有控制好或者生成完四元式后忘了递增。解决临时变量计数器必须是全局的、只会单调递增的任何时候都不允许复用旧编号。这看起来是小事但中间代码生成实验的扣分点一大半都在这只要有一个t编号重复后面的优化和代码生成阶段逻辑全乱。4.6 坑四实验报告里DFA图和语法树格式的边界现象语法分析实验报告交上去被打回评语是“DFA图未标初态终态”或“语法树缺少属性标注”。原因实验报告对图的格式要求比代码严格得多状态图必须清晰标出开始状态一个箭头指向的圆圈和接受状态双圈读入字符要标在转移边上语法树的每个节点都要标出对应的Token类型或非终结符名。解决画图前先看实验文档的示例格式如果手画先用铅笔画草稿再誊到工具里不要直接在白板上画完就拍照交。我一般会先列一个“图内必须出现的元素清单”画完一项勾一项这个方法帮我避免了多次返工。5. 从语法树到三地址码四元式的生成顺序与回填技术中间代码生成实验是整套实验的收官阶段常见要求是把语法正确的程序翻译成三地址码然后用文本形式打印出来供人工检查。三地址码的表达形式很多实验里最常用的是四元式一条指令由操作符、两个操作数和一个结果组成形如(, a, b, t1)。这个形式的好处是统一、紧凑、适合后续做优化也方便在控制台里按行打印检查。5.1 中间代码为什么选三地址码/四元式很多人在这一步会问语法树本身就是一种中间表示为什么还要再生成四元式原因有两点。第一语法树是二维嵌套结构不适合做顺序扫描和局部优化三地址码是线性序列每一行只依赖固定数量的操作数优化器按行处理即可。第二实验课要求打印中间代码给老师检查四元式一行一条指令肉眼可读性比树形结构好得多。和三元式、间接三元式相比四元式的显式结果字段还避免了“结果存在操作数位置”带来的精妙但难懂的优化问题实验课上不容易写错。四元式的操作符集合需要提前约定。我一般会定一份最小的操作符表算术运算用 - * /一元负号用uminus赋值用比较运算用j j jeq jne直接对应跳转指令函数调用用call和param。这套约定足够覆盖实验要求的表达式、赋值、if和while也不需要引入额外的中间代码形式。5.2 表达式与赋值语句的四元式生成规则给表达式生成四元式本质上就是后序遍历语法树先处理左操作数子树生成它的四元式再处理右操作数子树最后用根节点的操作符生成一条新四元式操作数是两个子树的临时变量结果是新的临时变量。赋值语句a expr则是在表达式结束后额外生成一条(, expr_result, _, a)把临时变量的值“落”到变量a上。函数调用也是一样先按实参顺序生成param四元式最后生成call指令结果用临时变量接住。class Quad { String op; String arg1; String arg2; String result; Quad(String op, String arg1, String arg2, String result) { this.op op; this.arg1 arg1; this.arg2 arg2; this.result result; } } class QuadGenerator { ListQuad quads new ArrayList(); int tempCounter 0; int nextQuadAddr 0; String newTemp() { return t (tempCounter); } void emit(String op, String arg1, String arg2, String result) { quads.add(new Quad(op, arg1, arg2, result)); nextQuadAddr; } String genExpr(ExprNode node) { if (node.isLeaf()) { return node.name; } String left genExpr(node.left); String right genExpr(node.right); String result newTemp(); emit(node.op, left, right, result); return result; } }逻辑说明genExpr递归遍历表达式树左子树有临时变量时先算左再算右最后用emit生成四元式并把结果临时变量名返回给上层。newTemp用全局递增计数器保证临时变量永不重复这正是上一章坑三的正面解法。nextQuadAddr记录下一条四元式的位置回填技术会用到它。参数说明isLeaf对应标识符或常量叶子节点不产生四元式直接返回它的名字作为操作数。这里没有处理一元负号遇到-a应该额外生成一条(uminus, a, _, t)而不是直接把它当二元操作这是容易漏的边界。四元式的操作数如果是临时变量直接写入arg即可不需要提前展开。5.3 布尔表达式与控制流的回填一种不画flow图的写法if和while语句的翻译比表达式麻烦因为跳转目标在翻译时还不知道需要先留空再“回填”。实验课的标准做法是维护两个链表叫trueList和falseList分别记录当条件为真和为假时要跳转的四元式序号。以if (E) S1 else S2为例先为E生成四元式E产生的四元式里带两个跳转占位一个是条件真跳转一个是条件假跳转trueList指向真跳转的位置falseList指向假跳转的位置然后生成S1的第一条四元式此时把trueList回填到这条指令的地址再生成S2的第一条四元式把falseList回填进去。这个“先记录待回填的指令序号、等目标确定后统一填”的模式叫回填技术。ListInteger trueList new ArrayList(); ListInteger falseList new ArrayList(); void backpatch(ListInteger list, int targetAddr) { for (int idx : list) { quads.get(idx).result String.valueOf(targetAddr); } } int genExprWithJumps(ExprNode cond) { int start nextQuadAddr; // 条件为真时的跳转地址先留空放入trueList emit(jtrue, cond.left, , ); trueList.add(nextQuadAddr - 1); // 条件为假时的跳转地址同样留空 emit(jfalse, cond.right, , ); falseList.add(nextQuadAddr - 1); return start; }逻辑说明这段代码要配合语义动作来看。在递归下降分析if语句的方法体里先调用genExprWithJumps生成条件部分的四元式然后解析S1解析完之后立即backpatch(trueList, startOfS1)把trueList里的所有占位指令的结果改成S1入口地址。之后如果有else解析S2前先把falseList回填到S2入口。这里最关键的参数是nextQuadAddr它既是回填的目标值也是跳转占位指令的下标来源。需要特别提醒回填只能填“向前跳”的目标也就是目标地址已经在前面生成过的指令。如果实验要求支持break和continue它们属于向后跳目标在翻译时还不存在需要在语法分析结束后再去补一轮回填顺序反了就会出现“回填到不存在的地址”这种运行时错误。实验课里不要求完全支持break/continue时我一般建议在文法里直接禁掉比强行实现再翻车要好。6. 验证与跑通五类最小测试用例与一种省时间的调试技巧整个实验链做完最后一件事是稳定通过测试。我见过太多人写完词法分析就急着写语法分析结果后面每章都在返工。最省时间的做法是先把下面的测试用例写好再写实现每个阶段只验证当前阶段的输出格式。6.1 五类最小测试用例怎么设计按这张表准备输入文件每类至少两三条能覆盖绝大多数扣分点测试类别输入示例预期结果合法程序int a 1 2 * 3; return a;正常输出Token流、语法树或四元式词法错误int a 1;、未闭合注释报出第几行第几列无法识别语法错误if (a ) else、缺少分号报出期望的token类型语义错误int a; a str;报类型不匹配边界输入空文件、只有注释、超长标识符不崩溃给出明确提示每一条用例都要有对应的预期输出或明确的报错信息验证时才能快速判断对错。我第一次只写了合法程序结果错误路径的代码一直拖到交实验前才暴露通宵改到凌晨三点这就是教训。6.2 打印分析栈与中间代码流定位错误的最快路径我保留到现在的工作习惯是递归下降的每个函数入口打印“进入非终结符X当前Tokenxxx”四元式emit完成后立刻打印那一条指令。这样测试用例跑一遍所有路径一目了然。词法阶段出错看Token流语法阶段出错看函数调用栈语义阶段出错看符号表操作四元式阶段出错看回填后的跳转目标十分钟内能定位问题不用反复加断点。后来我做工程也先写用例再写实现测试不通过绝不开下一章。希望帮到你。本文还有配套的精品资源点击获取