简介:《编译原理》课程实验报告是一份面向计算机科学与技术专业学生、围绕词法分析器实验的实践型教学文档,适用于需要完成课程报告或理解词法分析流程的读者。报告以C语言子集源程序为处理对象,从实验目的、内容与要求出发,详细介绍了词法分析器的主程序设计、识别过程、内部码表示方式,以及错误定位与跳过机制;同时结合C++示例代码,演示了字符读取、行列统计、空白过滤、关键字/标识符/常数分类与符号表管理,并将程序划分为主程序、词法分析过程和错误处理子程序,便于理解模块化实现思路。资料包内为1个doc文档,压缩包大小302KB,结构紧凑,适合直接参考实验架构和编码思路。该实验报告已有532人学习下载,对正在学习编译原理的学生、复习词法分析考点或准备实验答辩的读者具有一定参考价值。
1. 这门课淘汰率最高的不是笔试,是实验报告
《编译原理》这门课,淘汰率最高的不是期末笔试,而是课程实验。多数人栽在同一个地方:以为实验是“写一个能跑的编译器”,结果交上去的报告只有一段能跑的代码,没有文法、没有token表、没有测试用例,老师问两句就露馅。这份《编译原理》课程实验报告资源,是一份把词法分析、语法分析、中间代码生成完整串起来的实验文档,里面包含文法定义、token表、核心代码和测试结果,能直接当“实验报告该长什么样”的参照系。适合两类人:一类是马上要交实验还没动笔的,一类是已经写完但想对齐评分点查漏补缺的。它不能替你做实验,但能让你开写之前就把每个模块的边界和坑看清楚。
2. 先改报告骨架:评分点拆解与六模块的排布顺序
写报告和写代码一样,先定结构。课程实验报告的扣分点常常不在代码里,而在结构上。代码跑得通、结果截图也放了,但老师找不到你的文法定义,或者token表只写了三行,这种报告大概率归到B档。我拆的这份报告,骨架基本是六块:实验目的、实验环境、实验设计、关键代码、测试结果、实验总结。下面把这六块的分量、写法和顺序一次说清。
2.1 实验设计的四个必写项:文法、token表、数据结构、模块划分
实验设计占分最重,也最容易被忽略。四个必写项一个都不能少:文法定义放最前面,因为后续所有代码都是文法的实现;token表是词法分析的契约,没有token表,词法代码等于黑匣子;数据结构列个清单,一般不超过五行,token链表、符号表用HashMap、函数调用栈;模块划分一段话讲完,Lexer负责扫描、Parser负责语法、Main负责输出。
不少同学的报告在这里翻车,原因是把“实验设计”写成了“系统介绍”:上网抄一段编译过程概述,从词法分析一路写到代码优化。评分老师一眼就能看出你没做实验。正确做法是只写你实现的部分,每个模块配一个输入输出示例。比如词法模块写成“输入字符串 a=1+2*3,输出 (ID,a) (ASSIGN,=) (NUM,1) (PLUS,+) ...”,一句话就说明白你做了什么、做到什么程度。
报告的顺序建议照这个走,照这个排基本不会乱:
- 实验目的(两到三句,必须与自己实现的语法子集对应,不能照抄课本)
- 实验环境(语言、JDK版本、IDE、运行命令)
- 实验设计(文法定义 → token表 → 数据结构 → 模块划分)
- 关键代码(Lexer核心扫描 → Parser子程序 → 错误处理)
- 测试结果(正常输入、边界输入、错误输入各一组)
- 实验总结(遇到的三个问题与解决过程,不写感想)
第5项最容易翻车:测试结果里的输出,要和你第3项里的示例一致。见过报告里文法支持乘除,测试结果只测了加减,这属于前后矛盾,答辩时基本必被问。在2.2会给出一个能直接抄的骨架。
2.2 从文法定义开始写,别从代码开始写
写报告的顺序和做实验的顺序可以不同。很多人先写代码,写报告时又按代码逻辑写,结果报告变成了一篇代码注释合集。一份好的实验报告是一条线:文法 → token → 语法分析 → 测试,读的人顺着线就能重建你的程序。
文法定义放第二节,直接给算术表达式文法。左递归版本要写成产生式,并标注这是“原文法”:
E -> E + T | E - T | T T -> T * F | T / F | F F -> ( E ) | id | num注意必须在文法的下一行注明问题:左递归。自顶向下分析遇到 E -> E + T 会无限递归。报告里如果只给这个文法,后面又用递归下降去实现,老师一眼就能挑出错。所以同一页要给出消除左递归后的文法:
E -> T E' E' -> + T E' | - T E' | ε T -> F T' T' -> * F T' | / F T' | ε F -> ( E ) | id | num把“原文法”和“改造后文法”放一起对比,是报告里最显示理解深度的位置。读完这两段文法,老师已经确定了你的实验范围:只支持加、减、乘、除、括号、标识符和整数。这个范围定义得越清楚,后面的代码和测试越好写。FIRST和FOLLOW集合的计算不放在这里,放到语法分析章节,因为那是语法分析的输入条件,不是词法分析的内容。
2.3 自检清单:交报告前逐项核对
最后给一份自检清单,交之前过一遍,比检查代码更值钱。这四类缺项是我在各类课程报告里见得最多的:
| 检查项 | 缺失后果 | 补法 |
|---|---|---|
| toean表是否齐全 | 词法部分无法评审 | 补全所有token类别及正则描述 |
| 文法是否消除左递归 | 递归下降无法实现 | 改写并计算FIRST验证 |
| 测试用例是否含错误输入 | 错误处理代码形同虚设 | 补三类错误用例各一条 |
| 代码与测试输出是否一致 | 报告可信度暴跌 | 重新运行并截图 |
比如token表,只列了标识符、整数、加号、减号,没列括号和赋值号,老师会怀疑你的词法分析根本没有实现括号。再比如测试截图里的输出,格式应该和文本tokens一致性测试的预期输出完全一致,不能出现“手打输出”的情况。你可以在报告里放一张截图,截图里包含程序自己打印的token序列,这比贴一张手工整理的Word表格可信得多。
3. 词法分析实现:token表、状态转换图与扫描器代码
词法分析是整份报告的第一块代码,也是唯一能独立运行的模块。它做的事情很窄:把源代码字符串切成token序列。用Java写课程实验报告是比较常见的搭配,Java+编译原理的组合在很多学校延续了很多年,下面这份token定义和Lexer实现,就是从报告场景里拆出来能独立运行的版本。
3.1 token表设计:把“字符流”变成“token流”
动手写代码前,先定token表。token表是词法分析模块的接口定义,包含所有可能出现的词法单元。对算术表达式实验,token表通常是这样的:
| token类别 | token码 | 含义 | 示例 |
|---|---|---|---|
| ID | 0 | 标识符 | a, sum |
| NUM | 1 | 整数常量 | 12, 345 |
| PLUS | 2 | 加号 | + |
| MINUS | 3 | 减号 | - |
| TIMES | 4 | 乘号 | * |
| DIV | 5 | 除号 | / |
| ASSIGN | 6 | 赋值号 | = |
| EQ | 7 | 等号 | == |
| LPAREN | 8 | 左括号 | ( |
| RPAREN | 9 | 右括号 | ) |
| ERROR | 10 | 非法字符 | @ |
关键字可以单独一组编号,也可以并入ID类在语法层处理。课程实验一般并入ID,词法层只负责拼词素。token类的字段设计也直接决定后续调试的便利程度:
public class Token { public final int type; // token码,取值来自Token常量表 public final String text; // 原始词素,调试和报错都靠它 public final int line; // 所在行号,供语法分析报错使用 public Token(int type, String text, int line) { this.type = type; this.text = text; this.line = line; } @Override public String toString() { return "(" + type + ", " + text + ")"; } }逻辑说明:Token是词法分析器和语法分析器之间的传输对象,三个字段缺一不可。text字段保存的是原始词素而不是规约后的内容,之后如果做大小写不敏感的关键字识别,text保留原始值,比较时才统一转换。line字段看起来多余,但如果语法分析报错要带行号定位,没有它就只能报“第几个token”的错,识别度差很多。
3.2 用状态转换图推扫描逻辑,再写代码
状态转换图先画,代码随后跟上。文字描述版的状态转换图:初始状态读到字母进入ID状态;读到数字进入NUM状态;读到=先假设是ASSIGN,如果下一个字符也是=则进入EQ状态;其余符号按单字符token返回。下面的核心扫描函数直接对应这张状态转换图:
public class Lexer { private final String input; private int pos = 0; private int line = 1; private static final Set<String> KEYWORDS = Set.of("if", "else", "while", "return"); public Lexer(String input) { this.input = input; } public Token nextToken() { skipWhitespace(); if (pos >= input.length()) { return new Token(Token.EOF, "<EOF>", line); } char c = input.charAt(pos); if (Character.isLetter(c)) { return lexIdentifierOrKeyword(); // 字母开头,先拼完整词素 } if (Character.isDigit(c)) { return lexNumber(); // 数字开头,按最大匹配读取 } pos++; switch (c) { case '+': return new Token(Token.PLUS, "+", line); case '-': return new Token(Token.MINUS, "-", line); case '*': return new Token(Token.TIMES, "*", line); case '/': return new Token(Token.DIV, "/", line); case '(': return new Token(Token.LPAREN, "(", line); case ')': return new Token(Token.RPAREN, ")", line); case '=': if (pos < input.length() && input.charAt(pos) == '=') { pos++; // 消耗第二个'=',得到'==',属于最大匹配 return new Token(Token.EQ, "==", line); } return new Token(Token.ASSIGN, "=", line); default: return new Token(Token.ERROR, String.valueOf(c), line); } } }这是报告里最经常贴的核心片段,两个辅助方法在3.3给出。nextToken的执行顺序是固定的:先skipWhitespace、再判断文件结束、按首字符分类。顺序不能反,skipWhitespace必须最先执行,否则换行符会被当成非法字符处理,整个程序的行号计数也会全乱。注意这个版本把{}、[]这类符号归为ERROR,实验范围只覆盖了算术表达式,界符只保留括号。
3.3 识别顺序里的三个边界:关键字表、最大匹配、数字粘连
这一节是词法分析翻车最多的三个点,每条都有固定解法。先说关键字的识别顺序:必须先拼完整词素,再查关键字表。字符串"ifx"必须先拼成整体,再判断它是不是关键字,最后结果是否为单词"if";如果边读边查表,读到'i'时先匹配"if",下一个字符是'x'又退回一步,后面的变量名全废。正确的序列是:读完整词素 → toLowerCase → 查HashMap,命中是关键字,未命中是标识符。
数字粘连是另一个高频问题。输入"123abc"时,读入123之后必须检查下一个字符。如果下一个是字母,这个token应该被标记为错误,或者继续拼接成一个非法标识符,绝不能直接返回NUM=123再留下一个ID=abc,那样语法分析阶段会收到一个莫名其妙的token流。常见的做法是在lexNumber里先读数字,然后判断下一个字符:
private Token lexNumber() { StringBuilder sb = new StringBuilder(); while (pos < input.length() && Character.isDigit(input.charAt(pos))) { sb.append(input.charAt(pos++)); } // 边界检查:数字后面紧跟字母属于非法词素,整体报错 if (pos < input.length() && Character.isLetter(input.charAt(pos))) { while (pos < input.length() && Character.isLetterOrDigit(input.charAt(pos))) { sb.append(input.charAt(pos++)); } return new Token(Token.ERROR, sb.toString(), line); } return new Token(Token.NUM, sb.toString(), line); }这里的逻辑说明:第一个while读取连续数字,第二个while在遇到数字+字母粘连时把剩余字母数字都读进来,保证pos一定会前进,避免死循环。token记成ERROR类型而不是NUM,语法分析阶段遇到ERROR直接抛出异常,错误定位能精确到这一行。最大匹配规则在运算符上的体现就是'=='的识别:先读一个'=',再看下一个字符是不是'=',是就组成双字符token。判断代码里pos++的位置很关键,消耗第二个字符的操作必须在这个分支里完成,否则下一个nextToken会从同一个字符开始,程序卡死。
把这三条边界处理完,可以自己验证一下:任意输入串都能在有限步内返回token或ERROR,不会卡死,也不会把非法字符吞掉。“不会卡死”这一点写进实验总结,答辩时反而成为加分项。
4. 语法分析落地:FIRST/FOLLOW迭代算法与递归下降子程序
语法分析是课程实验里最模糊的部分。很多人不是不会写递归下降,而是不知道文法怎么改才不无限递归,也不知道报错信息怎么给才显得专业。这一章把改文法、算集合、写子程序三个环节拆开。
4.1 选递归下降还是算符优先:看你的文法和答辩风格
课程实验常见的两个方向:递归下降(LL(1))和算符优先分析。递归下降更直观,代码和产生式一一对应,报告里“一个非终结符一个函数”的说法很容易写清楚;算符优先适合纯算术表达式,但要维护算符优先关系表,报告里需要放一张大表,解释成本高。我的习惯是掌握递归下降,因为它能把分析过程明明白白展示出来,答辩时讲起来最省力。
选递归下降后第一关就是消除左递归。前面给的改造后文法可以直接用,写成代码前先手算四个集合:
FIRST(E) = FIRST(T) = FIRST(F) = { (, id, num } FIRST(E') = { +, -, ε } FIRST(T') = { *, /, ε } FOLLOW(E) = { ), $ } FOLLOW(E') = { ), $ } FOLLOW(T) = { +, -, ), $ } FOLLOW(T') = { +, -, ), $ }这几行的作用是证明文法满足LL(1)条件,也就是每个非终结符的FIRST集合互不相交,不会出现选择哪个产生式的不确定性。这部分必须出现在报告里,很多人的报告只有文法没有集合推导,老师一眼就能看出你没走完整流程。
4.2 用迭代法算FIRST和FOLLOW:不背公式,半小时算完
手工从文法推导集合容易漏,特别是带ε的产生式。可以用迭代法:反复扫描全部产生式,直到所有集合不再变化。FIRST的迭代逻辑写成伪代码放在报告里:
// first: Map<非终结符, Set<终结符或ε>> // nullable: Set<非终结符>,表示该非终结符能否推导出空串 boolean changed = true; while (changed) { changed = false; for (Production p : grammar) { for (Symbol sym : p.rhs) { if (sym.isTerminal()) { changed |= first.get(p.lhs).add(sym); break; // 遇到终结符,产生式贡献结束 } changed |= first.get(p.lhs).addAll(first.get(sym)); if (!nullable.contains(sym)) break; // 当前符号不可为空,不再后看 } } }逻辑说明:add和addAll返回boolean表示集合是否真的发生变化,这是循环的终止条件。break的位置是关键:只有当前符号不可为空时才停止;如果sym可为空,就要继续考察下一个符号。对照产生式 E' -> + T E':读入+后break,E'的FIRST里就多了+,下一轮循环还能继续把-加进来。
FOLLOW的迭代方向相反:看产生式右部,把跟在某个非终结符后面的终结符加进它的FOLLOW集合:
while (changed) { changed = false; for (Production p : grammar) { for (int i = 0; i < p.rhs.size(); i++) { Symbol A = p.rhs.get(i); if (!A.isNonTerminal()) continue; if (i + 1 < p.rhs.size()) { // A后面还有符号 changed |= follow.get(A).addAll(first.get(p.rhs.get(i + 1))); if (nullable.contains(p.rhs.get(i + 1))) { // 后继可为空,跟随A的FOLLOW changed |= follow.get(A).addAll(follow.get(p.lhs)); } } else { // A在产生式末尾 changed |= follow.get(A).addAll(follow.get(p.lhs)); } } } }这段代码最值得说明的是“A在产生式末尾”这个分支。以 E' -> ε 为例,ε产生式右部没有符号,循环根本不会进入,所以FOLLOW(E')的初始值不会来自自身。它来自 E -> T E' 产生式中E'在末尾的情况,因此E的FOLLOW会向上传递到E'。计算出的FOLLOW(E')= { ), $ } 与代码里parseEPrime方法的设计是直接对应的。
4.3 一个非终结符一个函数:递归下降子程序的写法
有FIRST/FOLLOW铺垫,递归下降代码几乎可以照着文法抄。每个非终结符写一个方法,方法体按产生式右部展开:
public class Parser { private final Lexer lexer; private Token look; // 当前token,构造时读入第一个 public Parser(Lexer lexer) { this.lexer = lexer; } private void match(int expected) { if (look.type == expected) { look = lexer.nextToken(); // 消耗当前token,前进一步 } else { throw new ParserException("第 " + look.line + " 行期望 " + tokenName(expected) + ",实际是 " + look.text); } } // E -> T E' private void parseE() { parseT(); parseEPrime(); } // E' -> + T E' | - T E' | ε private void parseEPrime() { if (look.type == Token.PLUS || look.type == Token.MINUS) { match(look.type); parseT(); parseEPrime(); // 递归调用,处理连续加减 } // 其他情况对应ε产生式,什么都不做,直接返回 } }逻辑说明:parseEPrime没有else分支,这就是ε产生式的代码形态:不匹配任何token就直接返回,把控制权交还给调用者。这里不需要显式输出任何东西,返回本身就是“本层分析完成”。递归深度由括号嵌套层级决定,嵌套多深JVM方法栈就压多深。早期见过给parseF加深度计数器防止栈溢出的写法,其实括号嵌套超过几百层属于非法输入,在match里抛一个“嵌套过深”的错误更合理。报错信息必须带行号,行号从token的line字段来,这也是词法分析那章保留line的原因。
5. 编译原理实验避坑指南:五条真实翻车记录
这一章是血泪经验,每条都来自课程实验报告里反复出现的真实错误。写报告之前先看这几个坑,能省下大量调试时间。
5.1 现象 → 原因 → 解决五连
翻车1:词法分析进入死循环,程序卡住不输出。现象:输入一串带空格的表达式后,控制台无输出,CPU占满。 原因:nextToken里有分支读到了字符但没有移动pos,peek到的字符永远不变。 解决:给nextToken的每个分支都加上pos++确保前进。还有一种自检方法:每次调用nextToken后打印一次token,正规情况下pos必然比上次大。
翻车2:标识符带数字尾巴,被拆成两个token。现象:输入"a1+b",输出(ID,a)(NUM,1)(PLUS,+)(ID,b)而不是(ID,a1)。 原因:lexIdentifier只是读到字母停止,没有把后续字母数字作为标识符一部分。 解决:标识符的匹配范围改成[a-zA-Z_][a-zA-Z0-9_]*,在lexIdentifier里用while循环读取字母、数字、下划线。注意数字不能开头,所以“ab1c”合法而“1a”不合法。
翻车3:递归下降一跑就栈溢出。现象:调用parseE()直接StackOverflow。 原因:用的是原文法E -> E + T,parseE先调parseE再匹配+,递归没有终止条件。 解决:换成消除左递归后的文法,并先用FIRST集合验证:E'的开头符号是+或-,与E的FIRST={ (, id, num } 没有交集,证明无歧义后再写代码。
翻车4:关键字识别用数组遍历,几千行代码慢到肉眼可见。现象:一个1000行的测试文件,词法分析跑了十几秒。 原因:每次识别标识符都遍历关键字数组,相当于O(n)的时间复杂度。 解决:换成HashMap或HashSet,词素拼接完成查一次,O(1)命中。报告里写一句“关键字表采用哈希表,查询复杂度O(1)”,这也是能写进实验总结的性能优化点。
翻车5:测试用例全是正常输入,答辩被现场输入问倒。现象:答辩演示时老师现场输入@、未闭合括号、单个=号,程序要么抛异常崩溃,要么输出乱码。 原因:只测了合法表达式,错误处理路径完全没覆盖。 解决:在词法层把非法字符转ERROR token,在语法层match里抛带行号的ParserException,测试用例里每种错误都放一条。这条做扎实了,答辩反而变成你的展示环节。
5.2 报告的“非技术坑”:文档结构、代码格式与查重
技术之外还有三个坑。代码粘贴到Word后缩进全乱,老师看不清,解决方法是代码段用等宽字体Consolas,行距和正文一致,每个方法前留空行。报告查重时整段源码被标红,解决方法是只贴核心方法,完整代码放附录,或者用“核心逻辑+伪代码”方式表达中段代码,既体现设计又避开查重。截图不配文字说明,每张测试截图下面必须加一行说明,写明“输入是什么、预期输出是什么、实际输出是什么”,三个信息缺一不可。
这三条不扣技术分,但扣印象分。答辩老师一天看几十份报告,排版整齐、说明清楚的在心理上就高一档。尤其是截图里的输出与正文里token表格式不一致的情况,会直接导致可信度下降,一定要在交之前逐张核对。
6. 答辩前的最后一道工序:测试用例的分层设计
实验报告写完了,代码也跑通了,最后一道工序是测试用例。多数实验报告只有一个main函数,输入一个表达式输出结果,这只能证明“程序能跑”,证明不了“程序对”。我一般会把测试用例分成三层:正常路径、边界路径、错误路径。下面这张表可以直接用于算术表达式实验:
| 用例类型 | 输入 | 预期结果 |
|---|---|---|
| 正常 | a=1+2*3 | 正确输出token序列,计算结果为7 |
| 边界 | a=(((1+2)))*3 | 多重括号完成归约,不报错 |
| 边界 | a=1+ 2 | 空格被词法层跳过,不产生token |
| 错误 | a=1+@2 | 词法层报错,提示第1行非法字符@ |
| 错误 | a=(1+2 | 语法层报错,期望右括号 |
| 错误 | a=1++2 | 语法层报错,E'后紧跟非法token |
这张表本身可以直接粘进报告测试章节。答辩演示的顺序固定:先跑正常用例说明“能工作”,再跑边界用例说明“考虑过细节”,最后跑错误用例说明“错误处理不是摆设”。演示时先说文法再跑代码,这是检验你懂不懂自己程序的试金石,说不上来基本就是源码没读透。
还有一个习惯很重要:在每个错误输出的旁边写一句“该错误由parser.match()抛出,携带行号信息”,把代码和输出连起来,老师在报告里就能看出你对出错路径是理解的。从那以后我每次交实验报告前,都会强制走一遍“对照token表检查输出 → 跑三个错误用例 → 检查代码块字体和截图说明”的固定流程,这三步能挡住九成低级翻车。希望帮到你。
本文还有配套的精品资源,点击获取