简介:面向编译原理课程实验的一份完整报告,依托 Engintime CP Lab 集成环境,覆盖从正则表达式到 NFA 的转换,以及使用 Lex 自动生成扫描程序两大核心任务,适合正在完成同类实验、需要理解实现原理或撰写实验报告的本科生参考。资源为单个 doc 文档,体积 1.75MB,按实验环境使用、正则到 NFA 转换、Lex 扫描程序生成三部分展开,系统梳理了入口程序、正则转后缀、NFA 片段栈等核心模块的作用,解释了 re2post、post2nfa、CreateNFAState 等关键函数的设计思路,并完整记录在 CP Lab 中生成项目、处理语法错误及观察点调试的过程。已有 1471 人浏览学习,报告步骤清晰、细节完整,既可用于对照检查自己的实现,也可作为实验报告写作和编译器前端复习的参考资料。
1. 编译原理 CP lab 实验报告:先搞清楚这份报告到底让你交什么
到了学期末,"编译原理CP lab实验报告.doc"几乎是计算机专业通用的问候语。这份报告不是让你把课堂 PPT 抄一遍,而是要把词法分析、语法分析、语义分析这三层编译器前端完整走一遍,并且把每一步的设计取舍写成可读的东西。它适合两类人:一类是正在赶实验、需要知道报告里必须有实打实的内容才能过查重和答辩的学生;另一类是工作后想补编译基础、拿实验题当迷你项目练手的开发者。报告本身不要求你写出惊为天人的编译器,但要求你讲清楚:从字符串到 token,从 token 到语法树,每一步做了哪个决策,为什么。
2. CP lab 的四个标准实验:词法、语法、语义,报告里的重点考核项
2.1 词法分析实验:正则到 NFA/DFA 的落地写法
词法分析是 CP lab 里最容易拿分、也最先卡住人的一环。实验要求通常是:读入字符串,识别整数、浮点数、标识符、关键字、运算符和括号,输出 (类型, 值, 行号) 的 token 流,有些老师还要求统计符号表。原理层面,你要在报告里写清楚两件事:第一,为什么用正则表达式描述单词;第二,怎么从正则表达式构造 NFA、再确定化为 DFA、再最小化。但在实际 lab 里,除了少数学校强制用 Flex,大多数人只用两种选择:手写状态机,或者直接用一个循环做模式匹配。我的建议是:报告原理部分认真写三态模型(NFA、DFA、最小化),代码部分却不必真的去写子集构造——你只需要把状态转换图画出来,然后在代码里用一个直观的逐字符扫描流程实现,最后拿两种结果做对比。这么做的理由很简单:子集构造算法是理解型考点,不是工程型交付,交一个你自己都看得懂的状态机代码,答辩时不会一问就慌。
最小可落地的词法分析流程是四步:跳过空白和注释;在当前位置尝试匹配最长的合法 token;分类并记录值;更新行号与错误信息。有一个隐蔽的坑叫"最长匹配与规则优先不一致",比如 else 和 elsex 在同一个程序里,如果你按"遇到 e 就当成标识符"的短匹配走,else 这个关键字就丢了;正确写法是先收集完整的最长字母序列,再在关键字表里查是否存在。报告里如果能把这一条写成"实验现象 → 原因 → 修正"的小段落,比单独贴一百行代码更能拿分,因为老师最想看到的就是你踩过这个坑。
2.2 语法分析实验:LL(1) 与 LR(1) 选型,报告里要写清楚的理由
语法分析是 CP lab 的硬骨头,因为这里第一次出现"理论好像懂了、代码无从下手"的局面。常见实现路线有三条:手写递归下降、用预测分析表做 LL(1)、用 Bison/Yacc(或 JavaCC)做 LALR(1)。报告一定要有一个小节专门讲选型依据,而不是默认一种方案一行不解释。我的经验是:如果你写的是 C/Java/Python,优先手写递归下降,因为代码可读性好、出错好调试,而且刚好避开 LL(1) 分析表的构造细节;如果你想表现对形式化方法的掌握,就补一张预测分析表和一个 LR 冲突的案例。注意,递归下降要求文法必须是 LL(1) 的变体,你要在报告里展示你是如何消除左递归、提取左公因子的——这是老师查代码最爱抽查的位置。
选型还要考虑实验规模:如果题目只要求解析算术表达式和简单的声明语句,递归下降最省篇幅;如果题目要做完整的类 C 文法(含 if-else、while、函数定义),手写递归下降会变得很长,此时用 Bison 更合适。但用 Bison 也有代价:冲突归约信息你要能看懂,不能把 42 个 shift/reduce conflict 直接贴进报告里当"正常现象"。报告里的正确姿势是写"参与构建的文法共 X 个非终结符、Y 个终结符;Bison 报告 2 个冲突,经排查为悬空 else 引入,采用优先级声明消除",并把冲突消除过程写清楚。没有这句话,只贴出 conflict 输出,老师一眼就知道你没理解自己的文法。
2.3 语义分析与中间代码生成:符号表和三地址码是报告核心产出
到了语义分析这一环,实验报告终于从"能跑"走向"有价值"。语义分析的核心产出有两个:符号表和中间代码。符号表不是简单放一个 HashMap 就完事,你需要记录标识符的名字、类型、作用域深度、声明位置和引用位置。很多同学的代码在词法分析阶段已经建过一个 token 表,语义阶段又建一个符号表,两套东西对不上,导致变量重定义和类型检查功能双双失效。报告里应该有一张表说明符号表每个字段的设计理由,并画一个作用域压栈/出栈的示意,这将直接证明你不是在贴玩具代码。
中间代码生成的考核点通常是"短路语义"和"类型转换"。以 C 语言常见的 && 和 || 为例,原理上要生成短路跳转的三地址码:t1 = a > 0;if_false t1 goto L1;t2 = b > 0;if_false t2 goto L1;result = 1;goto L2;L1: result = 0;L2: ...。如果你的实验报告里有类似这样一段完整的三地址码输出,并标注了每条语句的跳转目标,答辩时基本能镇住大部分同学。语义阶段还有一个老师说烂了但总有人不做的操作:错误恢复。真实编译器不可能一遇到错误就停机,你要在报告里写清楚错误处理策略是恐慌模式还是同步记号法,以及实验里出现的三个典型语义错误(变量未定义、类型不匹配、越界常量)分别由哪一层捕获。
2.4 实验环境与工具链:Flex/Bison 和手写递归下降怎么选
用什么环境完成 CP lab,很多同学觉得无所谓,但实验报告往往要求提供"开发环境"一节,而这节的答法会影响老师对报告可信度的判断。常见组合是三种:C + Flex/Bison,适合课程指定 Linux 环境,特点是能接触到经典的 lex/yacc 体系,但调试门槛高;Java + JavaCC,适合面向对象基础好的同学,语义动作写起来顺手;Python 手写,适合快速出效果、自动机代码易读,但会被个别老师认为是"纯调库"。首选通常是 C + Flex/Bison,因为这个组合和教材贴合度最高;但如果你的实验环境是 Windows 且没有装 GNU 工具链,直接用 Python 手写也不丢人,关键是在报告里说明你用了什么方法来保证可复现性(比如命令、测试脚本、退出码)。
关于环境,"能跑"和"能复现"是两回事。我检查实验报告时经常看到同学写"在本机测试通过",却没有任何运行参数和输入文件说明;老师重新运行得到的输出和报告里的输出不一致,对方连解释都解释不了。我的建议是报告里固定写清三个东西:输入文件格式(比如 test.c 为 C 子集,支持哪些语法)、运行命令(比如 flex lex.l;bison -d parse.y;gcc lex.yy.c parse.tab.c -o cpc;./cpc test.c)、一组标准输入输出样例。如果报告里有 Makefile 片段更好,因为 Makefile 本身就是在声明实验的构建边界。
3. 把实验报告写成能答辩的文档:结构、图表、代码块组织
3.1 报告骨架:目的、原理、设计、测试、结论五段式
一份能扛住答辩的 CP lab 报告,结构上遵循五段式:实验目的与要求、实验原理与环境、总体设计与模块划分、测试与分析、实验总结与改进方向。很多同学的报告问题是"实验原理"占了六成、把编译原理教材第二章的答案整个抄进去,而"设计与实现"只有几段废话加一堆代码。正确的篇幅分配应该是原理占两成,设计占四成,测试与总结占四成。关键认知是:原理部分写"为什么",设计部分写"在你的工程里具体做成什么样",测试部分写"怎么证明它成立",三者的比例失衡会让老师根本找不到你的工作量。
总体设计这一节要有层次:先画一张数据流图,表示源程序 → 预处理器(可选)→ 词法分析器 → 符号表 → 语法分析器 → 中间代码生成 → 输出的链条;然后逐个模块写"输入-输出-内部结构"。比如词法分析模块的输入是字符流,输出是 Token 序列,内部维护一个当前状态变量和一个行号计数器;语法分析模块的输入是 Token 序列,输出是抽象语法树或直接输出三地址码,内部维护一个"下一个 Token"指针。每个模块用一百到两百字描述,配关键数据结构(类名、函数签名),老师就能判断你到底写了没有。最忌讳的写法是:词法分析模块"本模块实现了词法分析功能"一句话带过,或者把整个源码粘贴进"代码实现"小节。
3.2 图表和状态转换图怎么画才不算反坑
编译原理实验报告的图表要求与其它课程最大的区别是:状态转换图不是示意图,是交付物。词法分析报告里必须要有标识符、整数常量、关系运算符的状态转换图,而且这张图要画对"接受态进入接受态的路径",比如整数常量从 digit 状态遇到非数字字符,要回到 start 态并且回退一个字符;如果这张图把回退行为画丢了,老师一旦追问"102abc 你怎么处理",你就会当场翻车。状态转换图推荐两种画法:手绘拍照(清晰就行)或 Graphviz 写 dot 脚本,后者还能把最小化前后的 DFA 对比做出来。不推荐用 Word 自带的形状乱拼,因为图元不对齐反而会被认为态度有问题。
语法树(parse tree / AST)的图可以在报告里放两颗:一颗是表达式 a + b * c 的 AST,一颗是 if-else 语句的 AST,用来解释你的递归下降调用层次。这里有个小技巧:用程序自动打印 AST(每层递归输出 indent 加节点名),把输出截图贴进报告,比手绘图可信且省力。千万注意不要贴一堆无意义的运行截图:课程报告最常见的败笔是连续六张终端截图,每张只看到程序启动;正确做法是贴输入文件一个、token 输出一个、语法树输出一个、错误报告一个,每张旁边加两行图注,说明这张图对应哪个模块、哪个测试用例。
3.3 代码附录怎么贴:只贴核心、注释到位、次要代码省略
代码怎么收纳进报告,是一个容易被低估的加分项。原则是:附录只放核心代码,次要的(错误处理、打印函数)用省略表达;正文里出现的代码必须和附录一致,不能两份代码版本对不上。我的经验是代码块按模块切分,每个模块开头给一个两行说明:本文件是什么、编译命令是什么。代码行数控制在三百行以内,太长了老师不想看;如果你真的是千行工程,把 Makefile 和目录结构树贴出来,再挑三个关键函数(比如词法分析器的 nextToken、语法分析器的 parseExpression、语义分析的 typeCheck)完整给出。这样保证报告厚度够,又不显得在凑页数。
代码里的注释也要讲究:不是每行都写"// 加 1"那种,而是在函数签名上方写职责、在分支条件处写"为什么这么判定"。比如 nextToken 里读到/时要决定是不是行注释开头,注释应当写"读到 /,需要 peek 下一个字符判断是 / 还是 *";这种注释能直接向老师展示你对边界情况的思考。另外附录里的代码不要用截图代替文本,原因有两个:一是查重系统处理不了截图,二是老师想复制到本机验证时,截图完全不可用。凡是被要求提交源码的实验,报告附录给文本,另外单独传一个 zip。
4. 从零跑通一个最小词法+语法分析器:可复现的 CP lab 最小工程
4.1 用 Python 手写一个能交差的最小词法分析器
下面这个例子对应 CP lab 中"手工实现词法分析器"的题目,我用 Python 写一个不依赖第三方库的最小版本。它只识别数字、标识符、加减乘除运算符、括号、分号,并输出 token 流。代码尽量保持形状和你在报告里画的状态转换图一一对应。
import re class Lexer: def __init__(self, text: str): self.text = text self.pos = 0 self.line = 1 self.tokens = [] TOKEN_SPEC = [ ("NUM", r"\d+(\.\d+)?"), ("ID", r"[A-Za-z_][A-Za-z0-9_]*"), ("OP", r"[+\-*/=<>!]+"), ("LPAR", r"\("), ("RPAR", r"\)"), ("SEMI", r";"), ("WS", r"\s+"), ] def next_token(self): while self.pos < len(self.text): # 按规则表顺序尝试匹配,数字分支额外做混合字符检查 for token_type, pattern in self.TOKEN_SPEC: regex = re.compile(pattern) match = regex.match(self.text, self.pos) if match: value = match.group(0) self.pos = match.end() if token_type == "WS": self.line += value.count("\n") break # 数字后面紧跟字母或下划线,属于非法标识符,如 123abc if token_type == "NUM" and self.pos < len(self.text): ch = self.text[self.pos] if ch.isalpha() or ch == "_": raise SyntaxError( f"line {self.line}: 无效的数字/标识符混合 {value}{ch}...") self.tokens.append((token_type, value, self.line)) break else: raise SyntaxError(f"line {self.line}: 无法识别的字符 {self.text[self.pos]!r}") return self.tokens if __name__ == "__main__": code = "x = 12.5 + 34 * (y - 1);" ts = Lexer(code).next_token() for t in ts: print(t)实现逻辑说明:next_token 内部不断用各规则的编译正则从当前 pos 尝试匹配,匹配成功后推进 pos,并把 (类型, 值, 行号) 追加到 tokens;空白规则匹配时不产出 token,只更新行号。关键点在 for...else 结构:for 尝试匹配所有 token 规则,如果某个规则命中就 break,如果所有规则都没命中,else 分支抛 SyntaxError 并提示位置。这个结构直接对应词法分析里的"无法识别字符"分支。特别说明一下 NUM 分支里的混合字符检查:正则匹配到 123 之后,如果下一个字符是字母或下划线,立即报"数字/标识符混合",这对应状态转换图中"数字接收态遇到非法后继字符"的边界处理。
参数说明:若要支持关键字 int/float,可以在类里加一个集合KEYWORDS = {"int", "float", "if", "while"},然后在 ID 分支加一层if value in KEYWORDS: token_type = "KEYWORD"。如果要支持 C 风格的//注释,在 TOKEN_SPEC 增加("COMMENT", r"//.*"),并在命中时直接忽略。有一点要特别注意:正则匹配顺序里 NUM 必须放在 ID 之前,否则像 123abc 这种字符串会被 ID 规则吃掉;即使 ID 规则禁止数字开头,也要保留 NUM 分支后的非法字符检查,否则 123abc 会被拆成两个 token 而不是报错,这是我调试时踩过的第一个坑。
4.2 递归下降解析表达式:不带优先级和结合性的解析器会翻车
光有 token 流不够,CP lab 一般要求做语法分析。下面用递归下降法解析"表达式 + 赋值语句"的文法:statement → ID = expr ;,expr → term ( (+|-) term )*,term → factor ( (*|/) factor )*,factor → NUM | ( expr )。
class Parser: def __init__(self, tokens): self.tokens = tokens self.index = 0 def peek(self): return self.tokens[self.index] if self.index < len(self.tokens) else None def consume(self, expected_type): tok = self.peek() if tok and tok[0] == expected_type: self.index += 1 return tok raise SyntaxError(f"期望 {expected_type},实际 {tok}") def parse_statement(self): ident = self.consume("ID") op = self.consume("OP") if op[1] != "=": raise SyntaxError("赋值语句必须使用 = 运算符") expr = self.parse_expr() self.consume("SEMI") return ("assign", ident[1], expr) def parse_expr(self): node = self.parse_term() while self.peek() and self.peek()[0] == "OP" and self.peek()[1] in ("+", "-"): op = self.consume("OP")[1] right = self.parse_term() node = ("binop", op, node, right) return node def parse_term(self): node = self.parse_factor() while self.peek() and self.peek()[0] == "OP" and self.peek()[1] in ("*", "/"): op = self.consume("OP")[1] right = self.parse_factor() node = ("binop", op, node, right) return node def parse_factor(self): tok = self.peek() if tok and tok[0] == "NUM": self.consume("NUM") return ("num", tok[1]) if tok and tok[0] == "LPAR": self.consume("LPAR") node = self.parse_expr() self.consume("RPAR") return node raise SyntaxError(f"非法的因子起始 token: {tok}") if __name__ == "__main__": lexer = Lexer("x = 12.5 + 34 * (y - 1);") parser = Parser(lexer.next_token()) ast = parser.parse_statement() print(ast)逻辑说明:parse_expr 和 parse_term 都做了"先消费一个子节点,再看下一个 token 是否属于当前层的运算符",这实际上是 EBNF 的 while 循环实现,等价于左递归消除后的文法expr → term expr'和expr' → (+|-) term expr' | ε。好处是运算符左结合天然成立,因为循环里每遇到一个运算符就把左节点当作已经算好的左侧操作数。为什么用 while 而不是递归去写 expr'?两种写法都正确,但 while 版本对 Python 递归深度更友好,而且报告里更好解释"终结符驱动循环"。
参数说明:parse_statement 里我对 OP 做了值检查,必须是=;如果要支持x += 1这种复合赋值,需要扩展运算符表并在这里增加分支。准备答辩时,你至少要在报告里回答三个问题:如果输入没有分号会怎样、如果括号不匹配会怎样、1+2*3输出哪棵 AST。这三个问题分别对应代码里的异常处理和优先级分层。用(1+2)*3和1+2*3两个用例的输出对比,可以直接在测试部分展示你的优先级处理是符合 C 语言规则的。
4.3 测试用例和输出验证:token 流和语法树怎么检查
实验报告里测试部分的核心不是"程序没崩",而是"输出正确性可验证"。我建议你自己准备三组测试用例:合法程序、非法语法、非法语义(如果需要语义层)。合法程序用一段覆盖所有运算符和嵌套括号的代码,比如x = 12.5 + 34 * (y - 1);,然后打印 token 流和 AST:
('NUM', '12.5', 1) ('OP', '+', 1) ('NUM', '34', 1) ('OP', '*', 1) ('LPAR', '(', 1) ('ID', 'y', 1) ('OP', '-', 1) ('NUM', '1', 1) ('RPAR', ')', 1) ('SEMI', ';', 1) ('assign', 'x', ('binop', '+', ('num', '12.5'), ('binop', '*', ('num', '34'), ('binop', '-', ('id', 'y'), ('num', '1')))))把这段输出原样贴进报告,并在旁边写一行判断:"AST 中 * 的父节点是 +,与 C 语言优先级一致"。非法语法用例选一个最常见的:x = 1 + ;,期望在 parse_term 层抛出 SyntaxError。报告里展示"错误发生在第几行、错误消息是什么"比展示正确运行更能反映调试能力。最后再给一个边界用例:空文件、只有分号、只有注释(如果支持注释)。这些用例不是越多越好,而是每个用例对应一种你可能写错的路径;老师提问时通常也是按这些路径问的。
5. CP lab 避坑:报告和代码里最容易翻车的五个点
5.1 悬空 else 导致语法分析器二义
现象:语法分析器在处理if (a) if (b) c=1; else d=2;时报语法错误,或者结果树的归属和想象中不一样。原因:绝大多数语言的 if-else 文法天生二义——else 既可以属于内层 if 也可以属于外层 if;如果不做约束,递归下降/预测分析器按先匹配到的分支处理,得到的语义就不对。解决:报告里明确声明采用"最近匹配"规则,即 else 必须匹配最近的未配对 if;在递归下降实现中,每个 if 分支在解析 else 子句之前,先把当前 token 上下文保存好,遇到 else 时直接附加给最近的 if 节点。如果使用 Bison,就写一句"对 if-else 文法声明优先级或直接消除二义",并把预报告中的 conflict 数量变化写进实验记录。
5.2 左递归没消除,递归下降无限递归
现象:运行语法分析器,输入一个合法表达式,程序直接 RecursionError 或段错误。原因:直接把课本文法抄进代码,比如expr → expr + term | term,第一个产生式右边又出现 expr,parse_expr 第一步又调用 parse_expr,形成无限循环。解决:在报告里必须有一段"消除左递归"的过程展示,例如把expr → expr + term | term改写为expr → term expr'、expr' → + term expr' | ε;更工程化的方案是像 4.2 节那样用 while 循环代替显式的 ε 产生式。这个坑是最容易让新手从"报告只写原理"变成"报告里有真实现"的契机。
5.3 数字与标识符混合输入被错误拆包
现象:输入123abc或elseif,得到的结果不是报错,而是把123abc当成整数 123 加标识符 abc,或者把elseif拆成关键字 else 加标识符 if。原因:正则规则各自独立匹配,没有做"当前正在构成一个 token"的统一状态管理,也没有做最长匹配与关键字表配合。解决:词法分析器不要按规则优先级简单蛮干,要在读到数字后立即检查下一个字符是否仍属于数字集合;如果在数字后遇到字母,直接报"非法标识符/数字混合"。实现方法是把状态机的"接收态"和"回退"逻辑显式写出来:数字串结束于非数字字符且该字符不是分隔符时,做非法字符检查。在报告里放123abc的报错截图,是这类题目最有说服力的证据。
5.4 测试用例太少,答辩一问就露馅
现象:报告里只有一个 test.c,跑了输出正常,感觉实验完成;老师随便问"括号不匹配会产生什么错误""除数为 0 谁检查""while 条件里写赋值语句允不允许",当场答不上来。原因:测试用例不是为了"跑通"设计的,而是为了覆盖每个语法规则的边角情况。解决:按三类准备测试脚本:normal、boundary、error,三类各不少于五个用例。boundary 里放空文件、只有注释、超长标识符、最大整数、嵌套一百层括号;error 里放缺少分号、括号不匹配、非法数字混合、未定义变量。在每个用例里写一行预期输出,运行后对照。报告里只选三到五个最有代表性的展开,剩余放在附录或 zip 里。这个习惯能让你在答辩时对任何"如果...会怎样"的问题都接得住。
5.5 报告把"设计思路"当成名词解释
现象:报告里写"词法分析是指将字符序列转换为记号序列的过程""递归下降分析是一种自顶向下的分析方法",大段照抄教材,没有一句属于你自己的工程。原因:写报告的人没有真正把实验当工程,而是当作文科作业在凑字数。解决:设计部分一律用"我采用了什么结构 + 为什么 + 在哪个函数里体现"的句式,比如"我采用优先级分层实现表达式文法:parse_expr 调用 parse_term,parse_term 调用 parse_factor,从而保证乘除高于加减"。老师想从报告里看到的是你的决策记录,不是复述教材。以后每写一个实验报告都问自己一句:"这一段去掉之后,报告是不是就不完整了?"如果去掉完全不影响,那它就不该存在。
6. 报告交了之后,还能用它长出什么:从最小分析器到可验证的小编译器
如果你做到这一步,手里的东西其实已经是一个可扩展的编译器前端,别再把它当一次性作业。我建议你做一件低成本高回报的事:把 4.1 和 4.2 的 Python 代码绑成一个脚本,再用 README 记录输入输出约定,这样答辩时可以直接当场演示而不是翻阅报告;如果还有余力,更好的方向是给语法分析器加一个简单的中间代码生成层。目标不是写 LLVM,而是把表达式输出成三地址码并做基本块划分,比如对x = 12.5 + 34 * (y - 1);生成:
t1 = 34 * (y - 1) t2 = 12.5 + t1 x = t2然后对比你手算的结果,把对比表放进报告附录。具体做法是写一个继承 Parser 的 CodeGen 类,在 parse_expr 返回 AST 之后做后序遍历,每次遇到 binop 就分配一个临时变量并输出指令。这里值得注意的坑是临时变量编号要全局唯一,否则两个表达式共用 t0 会把数据覆盖。我的习惯是用一个 self.temp_count,每次需要临时变量时自增,生成 t0、t1...;并且每个语句结束后清理临时变量池,防止长期运行内存膨胀。这个三地址码输出可以作为全实验报告"运行成果"部分的压轴内容,比单独发 token 流有说服力得多。
这段经历也让我养成了一个习惯:以后接触任何编译相关的工具链(从 grep 的正则到前端构建工具)都下意识想"它的词法/语法层是怎么组织的"。实验报告本身可能只会被判分一次,但这个把源文件变成结构化数据的思考方式,会一直留在你脑子里。希望这份从零到能交差的拆解能帮到你,至少让你在提交那份"编译原理CP lab实验报告.doc"之前,心里清楚哪些内容是被认真做出来、能经得住当场提问的。
本文还有配套的精品资源,点击获取