简介:重庆大学计算机学院编译原理课程实验项目合集,聚焦Cminusf语言的完整编译流程,面向正在学习编译原理、需要完成词法/语法/语义分析与中间代码生成实验的本科生或自学者。资源包含实验一至实验三的代码实现与调试记录,可帮助读者对照真实项目理解编译器各阶段的设计思路与排错方法。压缩包共361个文件,以out、sy、tk、json输出文件及h/cpp源码为主,并含txt说明、py脚本等,整体大小1.52MB,目录结构便于按实验逐项检索。包内代码覆盖词法分析、语法分析、语义分析和中间代码生成关键环节,调试记录详述了实验中的常见问题与分析过程;另有说明文件与主目录Compilation_principle_ex-main,方便快速搭建环境并运行测试。目前已有78人学习下载,适合作为编译原理课程实验的参考资料与实现模板。
1. 编译原理课设为什么总在Cminusf上翻车:这门实验到底在训练什么
很多人第一次拿到Cminusf实验时,直观感受是“这语言也太小了”——没有类、没有结构体、连布尔类型都要自己模拟,跟平时写的C++或Java比起来像玩具。但真正动手做词法分析、语法分析、语义分析到中间代码生成,才发现这个“玩具”精准地切中了编译原理最核心的难点:正规式与状态机、上下文无关文法与递归下降、符号表与类型检查、三地址码与临时变量管理。重庆大学计算机学院这个编译原理课程实验集合,把实验一(词法分析)、实验二(语法分析)、实验三(语义分析与中间代码生成)串成一条线,最终产出的是能跑通Cminusf源码并输出四元式的完整前端。适合正在做课设的学生、想补编译原理短板的开发者,以及那些只背过概念、没真正写过一遍语法树遍历的人——这门实验不做完一遍,你对编译器前端的理解永远是黑匣子。
2. 词法分析:从Cminusf到Token流的完整实现
2.1 先看懂Cminusf的词法规则:保留字、标识符和数字的三个判断点
Cminusf是C语言子集,关键词大概有if、else、while、return、int、float、void这几个,外加运算符和分隔符。词法分析器要做的第一件事不是写代码,而是把语言的词法规则画成一张状态图。我最常犯的错是一上来就写大循环,等发现<=被拆成<和=再回头改,状态转换早就乱成一团。正确的顺序是:先列出所有终结符的类别,再定义每种类别的匹配规则,最后设计一个getNextToken()函数。
三个容易判断错的地方:第一,标识符必须以字母或下划线开头,后面可以跟数字、字母、下划线,但如果保留字表没先查,if会被当成标识符;第二,数字分整数和浮点,Cminusf要求浮点数必须带小数点,3.合法,3.0合法,但3后面直接跟.5是非法输入,这需要在状态机里加一个“小数点后必须跟数字”的转移条件;第三,注释以/*开始,以*/结束,但这里的坑在于注释里不能嵌套,遇到/*必须一直读到*/,中间出现/*也不能进入嵌套状态。把这三条理清,词法规则表就完成了80%。
2.2 手写词法分析器的代码结构:一个循环加一张状态表
我用Java实现词法分析器,核心是一个Scanner类,内部维护input字符串、position指针、currentLine行号。每次调用nextToken(),先跳过空白和注释,再根据当前字符决定进入哪个分支。下面这个简化版本完整展示了状态机和最大匹配的逻辑:
public Token nextToken() throws SyntaxException { // 跳过空白与注释 while (position < input.length()) { char c = input.charAt(position); if (Character.isWhitespace(c)) { position++; } else if (c == '/' && position + 1 < input.length() && input.charAt(position + 1) == '*') { int startLine = currentLine; position += 2; while (position + 1 < input.length() && !(input.charAt(position) == '*' && input.charAt(position + 1) == '/')) { if (input.charAt(position) == '\n') { currentLine++; } position++; } if (position + 1 >= input.length()) { throw new SyntaxException("Unterminated comment at line " + startLine); } position += 2; // 跳过结束的 */ } else { break; } } if (position >= input.length()) { return new Token(TokenType.EOF, "", currentLine); } int start = position; char first = input.charAt(position); // 标识符或保留字 if (Character.isLetter(first) || first == '_') { while (position < input.length()) { char ch = input.charAt(position); if (Character.isLetterOrDigit(ch) || ch == '_') { position++; } else { break; } } String word = input.substring(start, position); TokenType type = reservedMap.getOrDefault(word, TokenType.IDENTIFIER); return new Token(type, word, currentLine); } // 数字:整数或浮点数 if (Character.isDigit(first) || (first == '.' && position + 1 < input.length() && Character.isDigit(input.charAt(position + 1)))) { boolean isFloat = false; while (position < input.length()) { char ch = input.charAt(position); if (Character.isDigit(ch)) { position++; } else if (ch == '.' && !isFloat) { isFloat = true; position++; // 小数点后必须跟数字 if (position >= input.length() || !Character.isDigit(input.charAt(position))) { throw new SyntaxException("Malformed float at line " + currentLine); } } else { break; } } String num = input.substring(start, position); return new Token(isFloat ? TokenType.FLOAT : TokenType.INTEGER, num, currentLine); } // 运算符和分隔符 String twoChar = position + 1 < input.length() ? input.substring(position, position + 2) : ""; if ("<=".equals(twoChar) || ">=".equals(twoChar) || "==".equals(twoChar) || "!=".equals(twoChar) || "&&".equals(twoChar) || "||".equals(twoChar)) { position += 2; return new Token(tokenTypeMap.get(twoChar), twoChar, currentLine); } String oneChar = input.substring(position, position + 1); if (tokenTypeMap.containsKey(oneChar)) { position++; return new Token(tokenTypeMap.get(oneChar), oneChar, currentLine); } throw new SyntaxException("Unexpected character '" + oneChar + "' at line " + currentLine); }这个实现的关键在于“最大匹配”——处理<=必须先看两位字符,再看一位字符,否则会把<=拆成<和=。注释扫描单独用一个while循环,因为注释内容不需要产生Token,但跳过的行号要累加,否则后续语法分析的报错行号会全错。数字状态机的判断条件里,我特意让小数点开头的.5也能被识别,这是很多同学忽略的边界情况,Cminusf虽然少见,但语法规则没有禁止。保留字和标识符统一处理,在结束处查reservedMap,这样省去了单独建一张DFA的麻烦。
2.3 词法分析调试记录:最大匹配和错误恢复怎么处理
调试记录里最容易出问题的不是常规代码,而是特殊输入。我遇到过一段测试用例只有一行/* comment */,结果我的词法分析器抛出了“unexpected character”而不是正常返回EOF,原因是注释结束后position已经越过字符串末尾,但外层循环没有正确判断。后来我规定:nextToken()开头先处理空白和注释,处理完后必须if (position >= input.length()) return EOF。另一个血的教训是错误恢复——很多同学遇到非法字符直接抛出异常,导致整个分析停止。课程实验通常要求词法分析报告错误后继续分析,因此我在抛出SyntaxException之前会将position++,然后返回一个ERROR类型的Token,让调用方能收集所有词法错误。但这里有一个边界:如果连续出现非法字符,不能死循环,所以nextToken()内部要保证每次调用至少消费一个字符。把这个逻辑写清楚,词法分析这关就稳了。
3. 语法分析:递归下降还是LR(1),Cminusf实验怎么选
3.1 语法分析的两种路线,为什么课程实验推荐递归下降
Cminusf的语法规模适合手写递归下降,但很多教材强调LR(1)更“正规”,导致初学者陷入抉择。我的建议是:除非实验允许使用Yacc/Bison,否则一律手写递归下降。理由有三条。第一,LR(1)需要构造Action表和Goto表,表驱动代码虽然机械,但一旦文法有冲突,排错成本极高,而递归下降的每个函数对应一个非终结符,报错位置能直接定位到函数调用栈。第二,Cminusf的表达式优先级关系在递归下降里可以用分层函数清晰表达,expression -> additive_expr -> term -> factor,每层只做一件事,别人看你的代码也容易给分。第三,课程实验的测试用例通常包含语法错误,递归下降可以精确知道当前期望的终结符,报错信息能具体到“期望遇到;,实际遇到)”。
3.2 用递归下降实现Cminusf表达式的优先级与左递归消除
递归下降最大的坑是左递归。Cminusf的产生式如expr -> expr + term,如果照抄成parseExpr()先调用parseExpr(),直接无限递归。我的做法是转成循环:用parseExpr()先解析一个term,然后while循环里看下一个Token是不是+或-,是就继续解析下一个term并构造二元运算节点。下面是一个简化版表达式解析代码,覆盖加减乘除和括号:
public ASTNode parseExpression() throws SyntaxException { // 先解析乘除优先级更高的项,然后处理加减 ASTNode node = parseTerm(); while (currentToken.is(TokenType.PLUS) || currentToken.is(TokenType.MINUS)) { Token op = currentToken; nextToken(); ASTNode right = parseTerm(); node = new BinaryOpNode(op, node, right); } return node; } public ASTNode parseTerm() throws SyntaxException { ASTNode node = parseFactor(); while (currentToken.is(TokenType.MUL) || currentToken.is(TokenType.DIV)) { Token op = currentToken; nextToken(); ASTNode right = parseFactor(); node = new BinaryOpNode(op, node, right); } return node; } public ASTNode parseFactor() throws SyntaxException { if (currentToken.is(TokenType.LPAREN)) { nextToken(); ASTNode node = parseExpression(); expect(TokenType.RPAREN); return node; } if (currentToken.is(TokenType.IDENTIFIER) || currentToken.is(TokenType.INTEGER) || currentToken.is(TokenType.FLOAT)) { Token token = currentToken; nextToken(); return new LeafNode(token); } if (currentToken.is(TokenType.MINUS)) { // 一元负号,单独处理 Token op = currentToken; nextToken(); ASTNode operand = parseFactor(); return new UnaryNode(op, operand); } throw new SyntaxException("Unexpected token " + currentToken + " in factor"); }这段代码的层级关系就是文法的优先级映射:parseExpression处理加减,parseTerm处理乘除,parseFactor处理括号、常量标识符和一元负号。注意一元负号的位置我放在parseFactor里,这样-a*b会先被解析成(-a)*b而不是-(a*b),这符合C语言的语义。如果你的实验要求一元负号优先级最高,放在factor层完全正确。另外,expect()函数负责检查当前Token是否是指定类型,不是就抛异常,这是递归下降里最常见的错误处理方式。实际项目中你还需要处理数组下标[expr]、函数调用ident(args)等,但它们不影响这层结构。
3.3 语法分析的错误定位:如何让报错信息真正可用
很多同学的语法分析器能跑通合法程序,一遇到非法程序就崩溃在未知异常,报错信息只有一行“NullPointerException”。这背后是缺少全局的错误捕获与恢复机制。我的方案是:在递归下降的每个入口函数顶层捕获SyntaxException,记录当前Token的行号和期望类型,然后执行errorRecovery()——跳过Token直到遇到分号或}等同步标记。这样一来,一个错误不会导致一连串假错误。另一个细节是嵌套恢复的顺序:当parseFactor失败时,不能直接吞掉Token,因为上层parseTerm的循环可能还在等待运算符。我一般让parseAbstractError()先返回一个特殊的ErrorNode,上层看到ErrorNode就不会再继续试图解析右操作数,直接把它当作一个整体返回。调试记录里我把所有语法错误测试用例分成三类:缺少分号、括号不匹配、表达式运算符缺失,分别验证错误信息中的行号和期望Token是否正确。这三类能覆盖大部分课设测试点。
4. 语义分析与中间代码生成:把语法树变成四元式
4.1 符号表与作用域:语义分析的第一道关卡
语义分析的前提是符号表。Cminusf只有全局变量、函数参数和局部变量,作用域规则是:函数内部可以引用函数参数和本函数内声明的局部变量,但不能引用其他函数的局部变量。我实现的是链式符号表:一个全局Map<String, Symbol>,加上一个List<Map<String, Symbol>>作为作用域栈。进入函数体时压入新层,退出时弹出。查找符号时从栈顶向下查,这样局部变量可以遮蔽全局变量。这里有个容易忽略的点:函数名本身也是一种符号,且和变量名放在同一命名空间时,如果实验要求区分,你需要用SymbolKind(FUNCTION / VARIABLE / PARAMETER)来标记,否则遇到int foo; int foo(){}会报冲突但实际C语言允许它们分别存在。更多课程实验不要求这么细致,但符号表的结构决定了后面类型检查的难易。
4.2 类型检查与中间代码生成:四元式的设计要点
Cminusf要求int和float混用时要进行隐式类型转换,比如int + float会生成一个int转float的四元式。许多同学在语法分析阶段直接生成中间代码,跳过了语义检查,这会导致int x; x = "abc";这种错误在运行期才暴露。正确流程是:先遍历语法树做类型检查,同时把常量折叠掉,然后再生成四元式。四元式我用一个类表达:
public class Quad { public String op; // 操作码,如 ADD, SUB, MUL, DIV, ASSIGN, GOTO, LABEL public String arg1; // 第一操作数 public String arg2; // 第二操作数 public String result; // 结果临时变量或目标标签 // 构造函数省略 }中间代码生成的常见做法是为每个表达式引入临时变量。例如a + b * c会生成:
MUL b c t1 ADD a t1 t2类型检查通过后,生成器需要知道每个AST节点的类型,才能决定是否插入INT_TO_FLOAT四元式。我的实现里,BinaryOpNode的inferType()会做子节点类型提升,如果左操作数是int,右操作数是float,就会在生成MUL或ADD之前先在子表达式的结果上生成转换。语义分析和中间代码生成可以合并成一趟遍历,但不建议合并成一步——因为中间代码生成需要知道每个符号的地址或临时变量编号,这部分可以单独放在符号表里。
4.3 调试记录:从“通过”到“得分”的差距在哪
我见过很多实验报告,词法分析测试全过,语法分析测试全过,但到中间代码生成一环,输出的四元式顺序错误、临时变量编号混乱、条件跳转的label名重复。根本原因是缺少对中间代码的“可读性”要求。课程实验的评分标准往往包含手工检查——老师会打开你生成的四元式文本,看是不是规范。所以我在调试记录里特意总结了三个规范:临时变量必须从t1开始递增,不能跳过;label必须按照L0、L1顺序编号,且每个函数内部的label不能重复;无条件跳转GOTO最好明确写出目标label,不要使用相对跳转。另外,if语句的中间代码生成容易产生多余label。Cminusf的if (cond) stmt else stmt,正确的跳转结构应该是:计算cond后以假跳转跳过then分支,如果存在else,在then分支末尾加一个无条件跳转跳过else分支。很多同学把条件取反放在生成的cond代码里,导致<=变成>时出错。我建议将所有比较运算符在中间代码层原样保留,由跳转指令来决定条件真假。这样语义更清晰,调试时也容易追踪边界。
5. 避坑实录:Cminusf实验里最常踩的五个坑
5.1 现象:词法分析把注释尾部的换行符吞了
测试用例里有一段注释后紧跟下一行代码,词法分析器返回的Token行号和预期不符,导致语法分析报错指向错误行号。原因是我的注释跳过逻辑在遇到*/后立即返回,但之前已经消费了换行符,而行号累加逻辑放在注释循环内部。更隐蔽的问题是:如果注释紧贴换行,比如/*...*/\nint a;,换行符被当成了注释的一部分。解决方法是:在跳过注释的循环中,只有当字符是\n时才增加行号,且注释结束后的position停在*/的下一个字符,这样换行符会在外层空白跳过逻辑中被处理,行号不会重复累加。为了验证,我把“注释中只有换行”“注释后紧跟着程序”这两类测试用例单独做成一个文件,跑完对比每个Token行号的期望值。
5.2 现象:语法分析对一元负号产生移进/归约冲突
用Yacc的同学在Bison里遇到expr: '-' expr | expr '-' expr时会出现移进归约冲突,报错说“0 shift/reduce conflicts”。这不是Bison的错,而是文法本身有歧义。解决方法是把一元负号的优先级声明为%left '+' '-'之后再加一行%left UNARY_MINUS,并在文法中用%prec UNARY_MINUS指定。但手写递归下降的同学会碰上另一个翻车点:在parseFactor里遇到-时直接递归调用parseFactor,结果-a-b被解析成-(a-b),因为内层parseFactor把后面的减号也消费了。正确做法是内层只解析一个基本因子(常量、变量、括号表达式),不能再次调用parseFactor。我最后用小括号隔离测试用例-a*b和-(a*b)分别验证,这个坑才算填平。
5.3 现象:语义分析时数组下标类型检查漏掉隐式转换
Cminusf允许数组下标是整数表达式,但不允许浮点数。我的符号表里数组名有type: int[]或float[],下标表达式的inferType()返回int时才能通过。但实际测试用例中写了arr[i + j],i和j是int,没问题;可arr[1.0]居然也过了——因为我在生成下标取址时忘了检查类型。原因是ArrayAccessNode的类型检查逻辑只验证了数组本身的类型,没有递归检查下标表达式的结果类型。修复方式是:在typeCheck()里先调用下标表达式的typeCheck(),如果结果是FLOAT,直接抛语义错误。这个坑提醒我:语义分析不能只盯着符号表,每个语法树的节点都必须有完整的类型传播。
5.4 现象:中间代码生成时临时变量重复使用导致值被覆盖
a = (b + c) * (d + e)生成的中间代码应该是先算t1 = b + c,再算t2 = d + e,最后t3 = t1 * t2。但如果我在生成器里用一个全局计数器tempCounter,每次生成一个临时变量就++并返回t + counter,那么两个加法会得到t1和t2,没问题。问题出在我对常量表达式做了折叠:如果b=1, c=2,我直接生成t1 = 3,然后后续生成器还在用原来的t1计数,导致同一个t1既被常量结果占用又被后来的乘法结果占用。解决方法是把常量折叠和临时变量分配分开:折叠的结果直接写入四元式的arg1,不再分配临时变量;临时变量分配器严格递增。调试时我在所有四元式后面打印生成的临时变量列表,一眼就发现了t1被重复定义。
5.5 现象:实验报告调试记录写成流水账,得分上不去
这个坑和代码无关,但直接影响最终得分。老师的评分标准里明确写了“调试记录需要体现问题的定位过程”,很多同学写的是“第3行出错,修复后通过”,这等于没说。我的做法是记录三个要素:触发问题的测试用例输入、报错的完整输出(含行号)、根因分析(哪段逻辑、哪个边界条件没考虑)。每条调试记录控制在50字内的现象描述加100字左右的解决思路,例如“对空文件调用nextToken()时返回了空指针——原因是最初的循环没有处理输入为空的边界——解决办法是在nextToken()开头加长度判断并直接返回EOF”。这种记录才能真正体现你的工作量,也是答辩时帮你解释代码的最好材料。
6. 让实验得分再往上走的三个验证技巧
6.1 用最小用例集做回归测试
很多同学的测试方法是把几个样例文件跑通就算完。我建议把测试用例拆分成“每个语法特性一个文件”:void_function.cminus测试无返回值函数,int_return.cminus测试整型返回,float_expr.cminus测试浮点运算,nested_if.cminus测试嵌套条件,array_access.cminus测试数组读写。每个文件不超过10行,好处是定位错误时能快速缩小到某几个特性。我自己维护了一个tests/目录,配合一个run_all.py脚本,每个用例的输出和期望输出做diff。这个习惯帮我至少避免了三次“修改A特性导致B特性回归”的翻车。
6.2 把调试记录整理成“现象-原因-解决”表格
实验报告中的调试记录部分如果只是代码时间线,老师很难判断你有多深入。我一般把所有踩坑项整理成三列表格:现象(含输入输出截断)、原因(定位到具体函数和状态)、解决(代码改动和结果)。表格的好处是老师扫一眼就能看到问题数量和覆盖面,也方便答辩时自己回顾。另外,在表格前面加一行说明“所有调试用例均已保存在tests/regression目录,可复现”,这比写十页废话更有说服力。
6.3 中间代码的静态检查:手工模拟执行一遍
生成四元式后,不要急着交差。我会选一个包含条件跳转和赋值语句的简单函数,比如int f(int a) { if (a > 0) return a; else return -a; },然后手工模拟四元式的执行顺序,检查跳转目标是否正确、临时变量是否在正确位置被赋值。这个动作能发现很多只在动态运行时才暴露的问题,比如条件跳转的label名拼写错误、return后面的临时变量没有生成赋值指令。我自己的经验是:这种手工模拟虽然繁琐,但比写一个解释器快得多,而且能加深对中间代码控制流的理解。做完这一步,把结论写进调试记录,再配上“通过模拟执行验证了中间代码的正确性”这句话,得分自然就上去了。
最后说一个我的习惯:每次跑完实验,我会把四个阶段的中间产物——Token流、语法树、符号表、四元式——全部打印到文件里,保留一份原始输出。这个习惯救了我很多次,因为老师偶尔会抽查某个阶段输出,而重新生成可能因为环境差异对不上。编译原理这门课,最后的差距往往就体现在这些看似笨拙但可靠的细节上。希望帮到你。
本文还有配套的精品资源,点击获取