简介面向高校编译原理课程学习者、实验设计者与备考复习人员完整呈现基于Engintime CP Lab平台的两项核心实验正则表达式到NFA非确定有限自动机的转换以及使用Lex工具自动生成扫描程序。报告对实验流程的描述细致完整从领取任务、克隆项目到生成与调试均有清晰交代适合作为实验报告撰写与考前复习的参照。报告源自暨南大学本科实验课程包含实验主题、实验内容、源代码逐段分析与调试方法可帮助读者快速掌握CP Lab环境下的编译前端实现全流程。文档重点剖析了re2post后缀表达式生成与post2nfa转换的过程、NFA片段栈数据结构以及观察点调试等关键环节便于理解从正则语言到自动机再到词法分析器的完整映射。资源包内含1个doc文档体积约1.75MB内容覆盖三个头文件与三个C源文件的组织结构对CreateNFAState、MakeNFAFragment等函数的作用也逐一说明结构清晰、步骤详尽。截至目前已有1471人学习下载是编译原理实验方向扎实且实用的参考资料也适合自学编译器词法分析原理的初学者。1. 编译原理CP lab实验报告.doc先弄清这份实验到底要求什么很多人在下载「编译原理CP lab实验报告.doc」这个文件时真正想找的其实不是文档模板而是怎么把这个实验做明白。CP lab是编译原理课程里最常见的配套实验平台任务内核通常是为一个教学子集语言写出词法分析、语法分析、符号表最后把设计与测试过程整理成实验报告。这个实验的价值被严重低估——它不只是一门课的作业而是你第一次亲手把教科书里的文法、自动机、作用域规则变成能跑的代码。适合正在赶课程实验的本科生也适合想补编译原理短板的工程师。我的建议是别急着找模板先按下面这套主流程把代码跑通报告只是它的副产物。2. 实验前端怎么拆词法、语法、语义三块的分工与选型2.1 先看实验指导书对照这份清单确认验收口径不同学校的CP lab细节不一样但验收口径基本落在四件事上词法分析识别哪些token、语法分析到哪一层、要不要符号表和简单类型检查、最终交什么。常见做法是先打开实验指导书找「验收演示」那一节那里写了老师现场会让你跑什么输入。模块常见要求验收时老师会问什么词法分析识别关键字、标识符、整数、运算符、分隔符报行号遇到非法字符怎么办关键字和标识符怎么区分语法分析用递归下降或生成器识别声明、表达式、print语句文法是LL(1)吗左递归怎么消的语义分析符号表记录变量名与类型查重复声明和未声明作用域怎么管理查表失败时怎么报错报告设计说明、代码结构、测试用例与真实输出截图的输出和当前代码是不是一致这四件事里词法和语法的代码量占七成符号表占两成剩下的一成是错误处理和测试。很多人的误区是把精力全花在「把报告写得好看」上结果答辩时被问一句「你这里为什么这么设计」就卡住。报告是结果代码里的每个设计决策才是老师真正想看的东西。2.2 手写还是有穷自动机生成器选型决定了报告好不好写做词法和语法分析有两条路一是手写词法器加递归下降分析器二是用 Flex/Bison 或 JFlex/CUP 这类生成器。两者的差异不只是实现速度还直接决定报告怎么写、答辩怎么答。维度手写递归下降JFlex/CUP 生成器代码量中等两三百行能写完少但生成的代码黑匣子学习成本低每个函数对应一条产生式中要理解生成器的冲突报告调试难度低报错位置就是函数调用栈高出错在生成的复杂状态机里报告好写程度高能画出状态转移图和函数对应关系中要解释冲突消除和生成产物我一般建议如果你是第一次做编译原理实验且教学文法规模不大手写递归下降是更稳的路线。理由是出错信息完全可控报告里能把「每个函数对应文法里哪条产生式」写得清清楚楚。用生成器不算作弊也是正规做法但你得在报告里解释清楚 shift/reduce 冲突是怎么消除的这部分比手写难讲。Java 方向常用 JFlex CUPC 方向常用 flex bison这两个方向我都见过翻车的翻车点集中在第 5 章排查里。2.3 教学文法怎么设计给出一个能跑通全流程的极简子集实验文法不能太大太大会陷入调试泥潭也不能太小小到看不出编译原理的痕迹。一个经过验证的折衷方案是做一个「类 C 的极简子集」只支持变量声明和 print 语句表达式包含加减乘除和括号。文法定义如下我建议直接抄成 EBNF 挂在报告的设计说明里program : { statement } statement : varDecl | printStmt varDecl : int identifier [ expression ] ; printStmt : print ( expression ) ; expression : term { ( | -) term } term : factor { (* | /) factor } factor : number | identifier | ( expression ) | (- | ) factor identifier : letter { letter | digit } number : digit { digit }这个文法有三点值得注意。第一它没有左递归expression用{ }表示循环天然是 LL(1)手写递归下降时不会出现无限递归。第二factor里显式加了(- | ) factor专门处理-5这种一元负号字面量很多人的实验就是在这里翻车的第 5 章会详细说。第三优先级通过文法层次天然体现乘除的 parse 函数在加减的下层所以1 2 * 3会先算2 * 3。教科书第三章的 FIRST/FOLLOW 理论在这里的体现就是每个非终结符的推导集合互不冲突递归下降不需要回溯。3. 用 Java 把最小词法分析与递归下降语法分析跑通可直接照改的代码3.1 Token 类型设计先把词法分析器的输出定义清楚词法分析器的作用是把源码字符串切成 token 流。做实验的时候Token 类建议至少带四个字段类型、文本、行号、列号。行号和列号不是装饰是错误处理的地基——没有位置信息的报错老师演示时根本没法跟你讨论。enum TokenType { KEYWORD, IDENTIFIER, NUMBER, OPERATOR, LPAREN, RPAREN, SEMICOLON, EOF } class Token { TokenType type; String text; int line; int col; Token(TokenType type, String text, int line, int col) { this.type type; this.text text; this.line line; this.col col; } boolean is(TokenType t, String value) { return this.type t this.text.equals(value); } public String toString() { return [行 line 列 col ] type text ; } }这个类本身没有复杂逻辑但字段取舍会影响后续所有代码。text保存原始单词type保存类别is()方法用于语法分析里快速判断「当前 token 是不是关键字 int」「当前 token 是不是加号」。很多同学的代码里没有is()导致语法分析器里到处是type TokenType.OPERATOR text.equals()这种长条件可读性很差。toString()的实现也值得照抄调试时直接打印 token 流就能看到每个词的位置报告里的 trace 输出就是从它来的。3.2 词法分析器主体一个 nextToken 方法吃遍全部词法手写词法分析器的核心是维护「当前字符」和「读下一个字符」这对动作在读到每个字符的瞬间决定是继续吞并还是断开。下面的代码是 CP lab 这类实验里最常用的结构你改关键字集合和运算符集合就能适配自己的文法class Lexer { private String src; private int pos; private int line 1; private int col 0; private static final SetString KEYWORDS Set.of(int, print); Lexer(String source) { this.src source; } private int peekChar() { return pos src.length() ? src.charAt(pos) : -1; } private int readChar() { int c peekChar(); pos; if (c \n) { line; col 0; } else { col; } return c; } Token nextToken() { while (peekChar() || peekChar() \t || peekChar() \n) { readChar(); } int c peekChar(); if (c -1) return new Token(TokenType.EOF, , line, col); if (Character.isLetter(c)) { int startCol col; StringBuilder sb new StringBuilder(); while (Character.isLetterOrDigit(peekChar())) { sb.append((char) readChar()); } String word sb.toString(); TokenType t KEYWORDS.contains(word) ? TokenType.KEYWORD : TokenType.IDENTIFIER; return new Token(t, word, line, startCol); } if (Character.isDigit(c)) { int startCol col; StringBuilder sb new StringBuilder(); while (Character.isDigit(peekChar())) { sb.append((char) readChar()); } return new Token(TokenType.NUMBER, sb.toString(), line, startCol); } int startCol col; char cur (char) readChar(); switch (cur) { case : case -: case *: case /: case : return new Token(TokenType.OPERATOR, String.valueOf(cur), line, startCol); case (: return new Token(TokenType.LPAREN, (, line, startCol); case ): return new Token(TokenType.RPAREN, ), line, startCol); case ;: return new Token(TokenType.SEMICOLON, ;, line, startCol); default: throw new RuntimeException(无法识别的字符 cur 行 line); } } }这段代码有三个参数你需要根据自己的文法调整。第一个是KEYWORDS集合你如果支持if、while就往里加。第二个是数字识别的边界——目前只认十进制无符号整数如果实验要求支持十六进制或浮点得在Character.isDigit的判断里加分支。第三个是单字符运算符的集合如果文法里有或需要在读到或时向前多看一个字符决定是返回一个双字符运算符还是两个单字符运算符。判断关键字要在「整个单词读完」之后做不能读完第一个字母就查表否则intabc会被误判成关键字加标识符。行号累加的时机放在readChar()里碰到换行才加这样 token 的起始行号是准确的。3.3 递归下降语法分析每个函数对应一条产生式报错也能定位有了 token 流语法分析就变成一个「向前看一个 token决定走哪个分支」的过程。递归下降的名字听起来玄学实际就是把文法里每个非终结符写成一个方法方法体就是该产生式的右部。下面这段代码直接对应第 2 节文法里的program、statement、expression和factorclass Parser { private ArrayListToken tokens; private int idx 0; Parser(ArrayListToken tokens) { this.tokens tokens; } private Token peek() { return tokens.get(idx); } private Token advance() { return tokens.get(idx); } private void expect(TokenType type, String value) { Token t peek(); if (!t.is(type, value)) { throw new RuntimeException(行 t.line : 期望 value 实际得到 t.text ); } idx; } void parseProgram() { while (peek().type ! TokenType.EOF) { parseStatement(); } } private void parseStatement() { if (peek().is(TokenType.KEYWORD, int)) { parseVarDecl(); } else if (peek().is(TokenType.KEYWORD, print)) { parsePrint(); } else { throw new RuntimeException(行 peek().line : 需要 int 或 print 开头); } } private void parseVarDecl() { advance(); // 吃掉 int expect(TokenType.IDENTIFIER, peek().text); // 名字必须是标识符 if (peek().is(TokenType.OPERATOR, )) { advance(); parseExpression(); } expect(TokenType.SEMICOLON, ;); } private void parsePrint() { advance(); // 吃掉 print expect(TokenType.LPAREN, (); parseExpression(); expect(TokenType.RPAREN, )); expect(TokenType.SEMICOLON, ;); } private void parseExpression() { parseTerm(); while (peek().is(TokenType.OPERATOR, ) || peek().is(TokenType.OPERATOR, -)) { advance(); parseTerm(); } } private void parseTerm() { parseFactor(); while (peek().is(TokenType.OPERATOR, *) || peek().is(TokenType.OPERATOR, /)) { advance(); parseFactor(); } } private void parseFactor() { Token t peek(); if (t.type TokenType.NUMBER) { advance(); return; } if (t.type TokenType.IDENTIFIER) { advance(); return; } if (t.is(TokenType.OPERATOR, -) || t.is(TokenType.OPERATOR, )) { advance(); // 一元正负号 parseFactor(); return; } if (t.type TokenType.LPAREN) { advance(); parseExpression(); expect(TokenType.RPAREN, )); return; } throw new RuntimeException(行 t.line : 表达式里出现了无法处理的 token t.text ); } }这里最值得琢磨的是parseExpression和parseTerm里的 while 循环它们在语法上加的是「右结合循环」而不是左递归。文本文法里expression : term { (|-) term }转换成代码就是一个parseTerm()加 while 循环这样既不违反 LL(1)也不会让函数无限递归下去。parseFactor里的一元正负号是第 2 节文法特意留的口子int x -5;能走通全靠它。expect()方法负责统一报错格式所有语法错误都输出行号和期望值与实际值的对比这份输出直接截进报告就是很好的「错误处理演示」。3.4 驱动入口把三块代码串起来输出解析结果语法分析器只认 token 列表所以需要一个入口负责读文件、跑词法器、把 token 收集成列表再交给 Parser。这个驱动类同时也是实验报告里的「系统流程图」素材import java.nio.file.*; import java.util.*; public class CompilerLab { public static void main(String[] args) throws Exception { String source Files.readString(Path.of(args[0])); Lexer lexer new Lexer(source); ArrayListToken tokens new ArrayList(); while (true) { Token t lexer.nextToken(); tokens.add(t); if (t.type TokenType.EOF) break; } Parser parser new Parser(tokens); try { parser.parseProgram(); System.out.println(语法分析通过共 tokens.size() 个 token); } catch (RuntimeException e) { System.out.println(语法分析失败 e.getMessage()); } } }tokens.add(t)在读到 EOF 时才停止确保 EOF token 在列表末尾Parser 的parseProgram()拿它当循环终止条件。这里有个容易被忽略的设计先把全部 token 读完再做语法分析而不是一边读一边分析。对课程实验来说这种两阶段写法更清晰也方便你在报告里单独展示词法器输出。args[0]是测试文件路径演示时准备好三五个.c后缀的测试文件内容是我们这个教学子集逐个跑一遍把输出截图贴进报告整个实验的主流程就闭环了。4. 符号表与语义动作放在哪编译原理符号表设计与作用域处理4.1 符号表不只是 HashMap作用域栈才是关键「编译原理符号表」这个点被搜得多是因为很多人的词法语法都写完了却卡在符号表上——拿一个全局 HashMap 存变量名一遇到花括号块就不知道怎么处理作用域。符号表的核心不是查得快而是「查得到该查到的查不到不该查到的」。变量遮蔽、重复声明检查、未声明变量报错全都靠作用域链撑起来。class Symbol { String name; String type; // 目前只有 int int declaredLine; Symbol(String name, String type, int line) { this.name name; this.type type; this.declaredLine line; } } class SymbolTable { private DequeMapString, Symbol scopes new ArrayDeque(); void enterScope() { scopes.push(new HashMap()); } void exitScope() { scopes.pop(); } boolean declare(Symbol s) { if (scopes.isEmpty()) throw new IllegalStateException(没有作用域); if (scopes.peek().containsKey(s.name)) return false; // 本层重复声明 scopes.peek().put(s.name, s); return true; } Symbol lookup(String name) { for (MapString, Symbol scope : scopes) { if (scope.containsKey(name)) return scope.get(name); } return null; } }Deque当栈用时push进新作用域pop离开作用域peek只看当前层。declare只检查栈顶这一层有没有同名变量这样内层可以遮蔽外层同名变量但同一层重复声明会报错——这正是 C 语言的作用域规则。lookup从栈顶往栈底找找到第一个就返回保证内层变量优先。这段代码在报告里可以画一张「作用域栈变化图」每进一个块压一层每出一个块弹一层配着第 4.3 节的快照输出是答辩时最直观的展示。4.2 语义动作挂载点声明时填表引用时查表符号表写好了关键问题是「在哪调用」。答案是语法分析器里遇到声明语句时填表遇到标识符引用时查表。这需要给 Parser 注入一个 SymbolTable并在parseVarDecl和parseFactor两个位置加语义动作。private void parseVarDecl() { advance(); // 吃掉 int Token name peek(); expect(TokenType.IDENTIFIER, name.text); boolean ok symTable.declare(new Symbol(name.text, int, name.line)); if (!ok) { throw new RuntimeException(行 name.line : 变量 name.text 重复声明); } if (peek().is(TokenType.OPERATOR, )) { advance(); String exprType parseExpression(); if (!int.equals(exprType)) { throw new RuntimeException(行 name.line : 初始化表达式类型不是 int); } } expect(TokenType.SEMICOLON, ;); }parseFactor里原来遇到 IDENTIFIER 只advance()就返回现在要改成先查表再收下这个 tokenif (t.type TokenType.IDENTIFIER) { Symbol s symTable.lookup(t.text); if (s null) { throw new RuntimeException(行 t.line : 变量 t.text 未声明); } advance(); return s.type; }parseExpression和parseTerm的返回值也从 void 改成 String一路返回类型信息最终在 print 语句里检查参数类型。print 的参数必须是 int这一步做完你的实验就覆盖了「语义分析」的验收点。这样设计的好处是语法分析器在同一个函数里同时完成「结构检查」和「语义检查」报告里写「在递归下降过程中穿插语义动作」这句话时你能指着代码说出来而不是背概念。4.3 快照输出报告里最容易被老师认可的符号表截图符号表是内部数据结构不打印出来老师看不到。实验报告里最好放两类快照一是变量声明时的填表记录二是离开作用域时的剩余符号。在 SymbolTable 里加两个打印方法声明时和退出作用域时各调一次void printSnapshot(String event) { StringBuilder sb new StringBuilder(); for (MapString, Symbol scope : scopes) { sb.append([); for (String name : scope.keySet()) { sb.append(name).append( ); } sb.append(] ); } System.out.println(event 当前作用域链: sb); }在declare成功后调用printSnapshot(行 s.declaredLine 声明 s.name)在exitScope前调用printSnapshot(退出作用域)。跑一个带花括号块内变量的测试用例输出会明确显示「进块时多了一层出块时少了一层」。这段输出配合第 4.1 节的作用域栈图是整个报告里含金量最高的部分——因为它证明的不只是「你会写代码」而是「你理解作用域在编译期是怎么动态维护的」。要有花括号块的支持你需要在文法里加block : { { statement } }Parser 里对应写parseBlock方法在进入和退出时配对调用enterScope/exitScope和printSnapshot这个扩展大约半小时能加完性价比非常高。5. 复用CP lab实验代码的五个常见问题排查现象、原因与处理流程5.1 网上下载的实验代码能编译但一跑自己的文法就全错现象从学长或网上拿到现成的词法语法代码能跑通它自带的测试文件换成实验指导书里的文法样例后从第一个 token 开始报错关键字不识别、符号全乱。原因网上代码按照它自己的语言子集设计TokenType 集合和你的文法不一致。解决动手改代码前先把实验指导书的文法转成 EBNF第 2 节那个格式再对照 Lexer 的 TokenType 枚举和 Parser 的分支条件逐行核对删掉多余 token补上缺失 token。不要上来就改代码先改文档这是最快的路径。5.2 JFlex/CUP 生成器报 shift/reduce conflict不知道该怎么处理现象用生成器的同学在cup生成 parser 时看到shift/reduce conflict警告数量从几条到几十条程序还能跑但行为不符合预期。原因表达式文法里、*、括号天然存在二义性生成器不知道优先级。解决在 CUP 的语法文件里声明运算符优先级%left -然后下一行%left * /优先级低的写在前面。写完后报告里主动写一段「该文法存在一条 shift/reduce 冲突通过优先级声明规定乘除优先于加减」老师看到你是理解这个冲突的反而比假装没看见加分。如果你手写递归下降文法设计时已经通过分层规避了这类冲突基本遇不到这个问题。5.3 负数字面量翻车int x -5;直接报语法错误现象词法器能正确切出-和5两个 token但语法分析器在-5这里停下来说「期望数字或标识符」。原因词法器按字符切分-5被拆成一元运算符和正数语法分析器的factor分支里没有处理一元运算符的代码。解决在parseFactor入口加一个判断当前 token 是或-时先advance()再递归调用parseFactor()把符号当成一元运算符处理。改完后int x -5;和int x 0 - 5;语义等价但-5在抽象语法上是一个单独的 factor 节点报告里可以注明这一设计。5.4 符号表作用域没弹栈全局变量被误报「重复声明」现象程序只有一个块但第二次声明同名字段时报重复声明或者块结束之后块内变量在外层还能查到。原因enterScope和exitScope没配对parseBlock结束时忘了调用exitScope。解决在parseBlock里用try { enterScope(); ... } finally { exitScope(); }包住语句序列确保异常时也能弹栈。配合 4.3 节的快照输出每次跑完测试都检查「退出作用域」记录如果看到块结束后作用域链长度没变回去就是这里的问题。这个坑几乎所有手写符号表的人都会踩一次踩完就能理解运行时栈帧和编译期作用域链的对应关系。5.5 报告里的截图和最终代码不一致演示现场翻车现象报告里贴的测试输出是三天前的演示时跑的是最新代码结果和报告对不上老师追问「这行输出是哪个版本跑出来的」。原因写完报告后又改了实现忘了重新跑测试更新截图。解决建立一个固定测试集第 6 节会给出用例表格任何代码改动后都跑一遍全部用例报告里的每张输出截图都来自「演示前最后一次跑的完整输出」不要单独截某一次的结果。我的习惯是报告写完后把测试文件、代码、报告三者的时间戳统一核对一遍再生成最终 PDF——这步花五分钟能避免答辩时最尴尬的局面。6. 用测试用例和TRACE日志给报告提一档两个能落地的验证习惯先准备一个最小测试集不用多六个用例覆盖合法、非法和边界场景。每个用例是一份源码文件配一句期望输出把「输入→期望→实际」三列放进报告里就是一份老师挑不出毛病的测试说明。用例名输入片段期望结果覆盖点合法声明与初始化int a 10;通过变量声明、等号、分号运算符优先级print(1 2 * 3);语法通过乘法优先于加法括号嵌套print((1 2) * 3);语法通过括号语法未声明变量print(x);报错「x 未声明」符号表查询重复声明int a1; int a2;报错「a 重复声明」作用域检查非法字符int a ;报错「无法识别 」词法错误处理第二个习惯是 TRACE 日志。在词法分析器里加一个静态开关打印每个 token 的行列位置、类型和文本跑测试时打开交报告时关掉。它能让你看到语法错误报的「行 N」是从哪冒出来的报告里放一小段真实 trace 输出能直接证明词法分析真的在逐字符工作。private static final boolean TRACE false; // 调试和报告截图时改成 true private void trace(Token t) { if (TRACE) { System.out.println([token] 行 t.line 列 t.col 类型 t.type 文本 t.text ); } }把trace()调用放在nextToken()的每个return之前跑测试时就能看到完整 token 流。我自己的教训是第一次做这个实验时把报告写成了作业感很重的八股每章都像抄教材答辩被问「你这里为什么这么设计」时只能背概念。后来改成每个非终结符函数开头写注释说明它对应哪条产生式报告里贴真实 trace 输出和符号表快照答辩时顺着代码讲设计决策顺畅很多。希望帮到你。本文还有配套的精品资源点击获取