简介:这是一份编译原理课程的无符号数词法分析实验报告,适合高校计算机专业学生、考研复习者及需要完成同类实验的开发者参考。报告以本科实验报告形式呈现,完整覆盖从文法规则、程序流程图到Java代码实现与运行结果的全过程,重点讲解词法分析基本思想、无符号整数与实数的识别规则,以及科学计数法输出等细节。资源共1个PDF文件,压缩包大小约201KB,内容结构清晰,便于直接查阅打印。该资源已有1562人学习下载,配套实验代码可直接运行验证,能帮助读者快速理解词法分析程序的实现思路,也可作为撰写课程实验报告或设计小型编译器的参考资料。
1. 编译原理这门课,为什么一本薄讲义比厚教材更值得过一遍
你手上这份「编译原理-太原理工大学[参照].pdf」,名字平平无奇,但这类课程讲义往往比八百页的经典教材更适合当入门路线图。我见过太多人把编译原理学成“背名词解释”:正则、文法、LL(1)、LR(1)全都认识,合上书连一个词法分析器都写不出来。问题不在人,在于教材的编排是为了“讲全”,而讲义的编排是为了“讲完一门课”。
对着一份讲义学编译原理,最大的收益是它帮你划出了最小必做集:词法分析做一遍、语法分析做一遍、中间代码生成摸一遍,你才算真正入行,而不是在“编译原理实验”的作业里抄完答案就扔。我接下来要讲的,就是照着这份讲义把一个能跑的最小编译器拆出来的完整路径,包括代码、参数、以及我这些年踩过的坑。
2. 把讲义当工程手册读:先跑通三个最小程序
2.1 用讲义里的词法分析章节,搭一个带最长匹配的词法分析器
词法分析是所有编译实验的入口,也是很多人的第一个翻车点。讲义里通常先给正则表达式,接着讲 DFA 状态图,看上去很简单,但一写代码就会遇到一个关键问题:同一个字符序列可能匹配多个 token,你必须实现“最长匹配”,否则>=会被拆成>和=,while会被拆成wh和ile。
我一般会直接用 Python 写一个手动控制的匹配循环,把词法规则按优先级排列,每次都选出匹配长度最长的那条规则,而不是用re.finditer一把梭。核心代码如下:
import re TOKEN_SPEC = [ ('KEYWORD', r'if|else|while|return|int|void'), ('IDENT', r'[A-Za-z_][A-Za-z0-9_]*'), ('NUMBER', r'\d+(\.\d+)?'), ('OP', r'==|!=|<=|>=|\+|-|\*|/|[\{\}\(\);,=<>]'), ('SPACE', r'[ \t\n\r]+'), ] def tokenize(src: str) -> list: tokens = [] pos = 0 while pos < len(src): best_len = -1 best_type = None best_text = None for typ, pat in TOKEN_SPEC: m = re.match(pat, src[pos:]) if m and len(m.group(0)) > best_len: best_len = len(m.group(0)) best_type = typ best_text = m.group(0) if best_len < 0: raise SyntaxError(f"第 {pos} 个字符无法识别: {src[pos]!r}") if best_type != 'SPACE': tokens.append((best_type, best_text)) pos += best_len return tokens这段代码最关键的设计是best_len这个变量。它把五类规则放在同一个循环里比较,最终胜出的不是“先匹配到的规则”,而是“匹配得最长的规则”。TOKEN_SPEC的顺序也有讲究:双字符运算符<=、>=必须排在单字符<、>前面,尽管最长匹配本身能解决一部分问题,但规则顺序仍会影响相同长度时的取舍。SPACE规则放在最后兜底,匹配到空白就跳过,不进入 token 列表。这样写出来的词法分析器,行为与讲义里的 DFA 是等价的,但肉眼可读,出了错也能直接跟踪到具体第几个字符。
2.2 按讲义语法分析章节,写一个不跳进左递归死循环的递归下降解析器
语法分析是编译原理的核心,也是讲义中篇幅最大的部分。递归下降是最好上手、也最适合手工实现的策略,但直接把讲义里的 BNF 文法抄成函数,会立刻遇到左递归问题:E -> E + T这种规则,写成def E(): E(); match('+'); T(),调用栈会无限膨胀,最终RecursionError。
问题不是文法错了,而是机械翻译文法行不通。解法是先消除左递归,再把文法改写成等价的迭代循环。以表达式文法为例,消除左递归后变成:
parse_table = { ('E', 'id'): ['T', 'E\''], ('E', '('): ['T', 'E\''], ('E\'', '+'): ['+', 'T', 'E\''], ('E\'', ')'): [], # ε 产生式 ('E\'', '$'): [], # ε 产生式 ('T', 'id'): ['F', 'T\''], ('T', '('): ['F', 'T\''], ('T\'', '*'): ['*', 'F', 'T\''], ('T\'', '+'): [], # ε ('T\'', ')'): [], # ε ('T\'', '$'): [], # ε ('F', 'id'): ['id'], ('F', '('): ['(', 'E', ')'], } def predict(stack, tokens): while stack: top = stack[-1] lookahead = tokens[0] if top == lookahead: stack.pop() tokens.pop(0) continue production = parse_table.get((top, lookahead)) if production is None: raise SyntaxError(f"预测分析失败: 栈顶 {top}, 前瞻 {lookahead}") stack.pop() stack.extend(reversed(production)) return True这段代码模拟的是预测分析表驱动的下推自动机:栈里存文法符号,tokens是词法分析产出的终结符流。每次循环先看栈顶是不是终结符,是就直接匹配;不是终结符就查预测分析表,找到对应的产生式,把栈顶弹出,再把产生式右部逆序压栈。[]表示 ε 产生式,意思是什么都不压栈,直接消掉栈顶的非终结符。reversed(production)这个细节不能省,因为压栈顺序决定了展开顺序,不逆序就会把产生式右部倒着匹配。
2.3 语义分析和中间代码生成:讲义讲理论,代码要自己补
太原理工大学这份讲义在语义分析部分通常会讲到语法制导翻译、属性文法、中间代码形式,但讲义里很少给出完整可运行的代码,这一节是很多人觉得“黑匣子”的地方。其实中间代码生成不需要一次做完整,只需要在语法分析的过程中,把归约动作翻译成四元组即可。
四元组的形式是(op, arg1, arg2, result),比如(+, a, b, t1)表示t1 := a + b。表达式生成中间代码,本质是在递归下降的求值函数里插入 emit 动作:
quads = [] temp_count = 0 def new_temp(): global temp_count temp_count += 1 return f't{temp_count - 1}' def emit(op, arg1, arg2, result): quads.append((op, arg1, arg2, result)) return result def expr(): left = term() while lookahead == '+': match('+') right = term() left = emit('+', left, right, new_temp()) return left这里的逻辑是:表达式被解析成左操作数和右操作数,emit生成一条加法四元组,并把结果临时变量作为下一个操作数参与后续运算。a + b + c会被拆成两条四元组:(+, a, b, t1)和(+, t1, c, t2),这就是经典的三地址码。这个模式的价值在于,它把分析过程与翻译过程合二为一,不需要单独建一棵完整的 AST 再去遍历。
讲义里的“语法制导翻译”章节要求你理解每个产生式对应什么语义动作。我的建议是,动手给每个非终结符的求值函数加一个返回值,让它在返回时携带“这个符号代表的值或地址”,这样中间代码生成就是顺水推舟的事。
3. 讲义里的理论不是摆设:FIRST、FOLLOW、LR 表都要亲手算
3.1 文法和 LL(1) 分析表:为什么必须亲手算一遍
很多人看讲义上的 FIRST、FOLLOW 集合推导过程觉得很简单,就是“扫一眼”,然后直接跳到预测分析表。这是个致命的错觉。考试也许能蒙对,但写代码时你会发现自己根本不知道集合的迭代过程是怎么收敛的。
FIRST 集合的算法是一个不动点迭代:给每个终结符的 FIRST 集合初始化为它自己,给每个非终结符初始化为空集,然后不断遍历所有产生式,把右部首符号的 FIRST 集合并入左部的 FIRST 集合,直到集合不再变化。以讲义里最常见的表达式文法为例:
E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id手工计算的中间结果长这样:
| 符号 | FIRST 集合 |
|---|---|
| E | { (, id } |
| E' | { +, ε } |
| T | { (, id } |
| T' | { *, ε } |
| F | { (, id } |
这里最值得注意的地方是 ε 的出现:E' 的 FIRST 里有 ε,意味着 E' 可以被展开为空串。当 ε 进入某个非终结符的 FIRST 集合后,FOLLOW 集合计算就会受到牵连,进而影响预测分析表的空白格填充。如果你把 ε 漏了,后面分析表中E'遇到)或$时会直接报错,而不是走 ε 产生式。
我建议你至少手工做两遍:第一遍用笔推导,第二遍写一个脚本用集合迭代验证。写脚本时用while changed循环就够,关键是要把“本次迭代新增的元素”和“上次迭代已有元素”区分开,否则集合会错误地包含一个产生式右部多个符号的 FIRST 集合。
3.2 自底向上分析与 LR 表:讲义里那几张表到底在告诉你什么
自底向上分析是讲义的另一个重头戏,LR(0)、SLR(1)、LR(1) 这些名词能把初学者绕晕。从落地角度看,你至少要把 LR(0) 的项目集看懂:项目是一个产生式右部带一个圆点的状态,圆点左边是“已经读入的部分”,右边是“还没读入的部分”。
每当你看到一张讲义里的 LR 分析表,它本质上回答一个问题:“当前栈顶状态是 i,下一个输入符号是 a,我应该移进、归约、接受还是报错?” 表里的每个格子都是一个决策。SLR(1) 与 LR(0) 的区别,就是在归约项目产生冲突时,用 FOLLOW 集合来排除那些不该归约的输入符号。
这里有个实操方法:不要试图把整个 LR 表背下来,而是构造一个小文法,比如只包含S -> L = R | R和L -> * R | id,把它的自动机手动画一遍,自己画出状态转移图。画图的过程会让你彻底理解“项目集闭包”是什么意思:当圆点后面是非终结符时,要把该非终结符的所有产生式都作为新项目加进来。这个闭包计算是 LR 自动机构造里最枯燥也最容易出错的地方,但一旦用代码实现过,后面几十个状态都不在话下。
3.3 语法制导翻译和符号表:面试和实验考核都围着它转
语义分析章节通常被轻视,因为考试权重不高,但实际做编译实验时,语义分析和符号表才是真正的分水岭。讲义里的“属性文法”概念,对应到代码里就是一个函数签名设计的问题。
符号表的设计有三个必须想清楚的参数:作用域怎么切分、标识符重名怎么处理、类型信息放在哪一层。我见过不少初学者的符号表是一个全局 dict,变量重名直接覆盖,结果int x; { int x; }这种合法程序被误判。正确的做法是维护一个作用域链表:进入花括号时新建一层表,退出时弹出当前层。查找标识符时从当前层逐层向外找,这才能正确处理变量遮蔽。
语法制导翻译要落地,核心是把产生式对应的语义动作写在递归下降函数的合适位置。生成中间代码时,赋值语句x = expr的语义动作是先递归生成 expr 的中间代码,再 emit 一条赋值四元组;声明语句的语义动作则是在符号表里注册新名字。这两类动作的执行时机完全不同,前者发生在“值”层面的归约完成时,后者发生在“名”层面的声明解析时,混淆了就会产出乱序的四元组。
4. 照着讲义做实验最容易翻车的 5 个地方:避坑与排查
4.1 词法分析的正则表达式写对了,状态机却跑不完整
现象:用re.findall扫描源码,得到的结果里标识符被拆断,比如int x1被识别成int和x,后面跟了个孤零零的1。更诡异的是,源码里明明有字符串"hello",输出却只剩下hello没有引号。
原因:这是词法分析里最经典的“匹配优先级”错误。re.findall默认从左到右按模式顺序匹配,遇到第一个能匹配的模式就返回,并不会在多个模式之间比较“谁匹配得更长”。标签在后面排着时,只要标识符模式在数字模式之前,数字尾巴就会被永远截断。字符串引号缺失,则是模式里写了[a-zA-Z]+却忘记把引号本身纳入匹配。
解决:放弃findall,改用上一节那段手动比较best_len的循环。核心不是代码本身,而是你要建立一个认知:词法分析器的本质约束是“最长匹配 + 规则优先级”的联合决策,不是简单地“按顺序套模式”。调试时在失败分支里打印pos和src[pos:pos+20],你会很快定位到是哪个规则没兜住。
4.2 递归下降碰到左递归,代码直接死循环
现象:解析a + b + c时程序不报错,但 CPU 占用拉满,最后抛出RecursionError: maximum recursion depth exceeded。
原因:文法里E -> E + T被直接当成递归下降函数。左递归产生式的右部第一个符号还是 E,函数E()第一行就调E(),永远不会走到match('+')。这不是代码 bug,而是文法形态与算法不匹配。
解决:在写解析器之前,先把所有左递归产生式消除掉。常见做法是把E -> E + T | T改写成E -> T E'和E' -> + T E' | ε,或者更实用地,直接用循环表达结合性:expr()先解析一个term(),然后while lookahead == '+'循环解析后续 term。后者不需要引入额外非终结符,代码也更贴近运算符的结合性。记住:递归下降可以处理右递归,天然不处理左递归。
4.3 手工算 FIRST、FOLLOW 时漏了 ε,预测表出现冲突
现象:生成的预测分析表里,同一行同一列有两个产生式,代码运行时查表走到这个格子,不知道该选哪个。更隐蔽的情况是表里某些格子为空,输入合法也会报“语法错误”。
原因:计算 FIRST 集合时没把 ε 传播到位。比如E' -> + T E' | ε这个产生式,E' 的 FIRST 必须包含 ε;如果漏了,FOLLOW(E') 就无法正确计算,最终影响M[E', 期望输入符号]的填表。所有“空表”都不是真的无解,而是 ε 产生式没有正确触发。
解决:把 FIRST 集合计算写成不动点循环,不要用一次遍历。循环体里先处理所有“右部第一个符号是终结符”的规则,再处理“右部第一个符号是非终结符”的规则,最后处理 ε 直接出现在右部的规则。算完 FIRST 再算 FOLLOW, FOLLOW 计算时会用到 FIRST 集合里去掉 ε 的部分。把这两个函数做成独立的调试函数,输入文法直接打印所有集合,你会发现手算时漏掉的东西一目了然。
4.4 符号表作用域处理错,变量遮蔽和重复声明全乱
现象:程序里有外层变量x和内层变量x,编译器要么把内层识别成重声明,要么在外层作用域里读到了内层的类型信息。更烦人的是,两个平行的兄弟块作用域里的同名变量互相覆盖。
原因:整个符号表只有一个全局字典,没有作用域分层。兄弟块共享同一层表,导致同一个名字被第二次声明时误判为重复定义。
解决:给符号表加一个作用域链表,或者直接用 Python 的列表模拟栈:scopes = [dict()],进入块时scopes.append(dict()),退出块时scopes.pop()。查找变量时,从scopes[-1]一路找到scopes[0],只要有一层命中就返回。插入变量时,只往scopes[-1]里写。这样内层变量遮蔽外层变量是天然行为,重复声明检查只需要看当前层是否已存在同名条目。这是个三十分钟的改造,却能解决一系列后续类型检查的诡异 bug。
4.5 讲义里的工具链版本和你本地的对不上
现象:按照讲义里的步骤用 Flex/Bison 编译实验代码,命令报了一堆错,但讲义截图里明明是同样的命令。报错原因从undefined reference to 'yywrap'到bison: option --enable-parsing五花八门。
原因:讲义基于某个特定版本的 Linux/Windows 实验环境写成,而本地装的工具链版本可能更高,接口和行为都变了。比如新旧 Flex 对yywrap的处理不同,新版本不再默认链接libfl;Bison 新旧版本生成的解析器默认头文件位置和函数签名也不同。
解决:先看报错的第一行,而不是最后一行。yywrap问题最常用的解法是在词法文件末尾手动加int yywrap() { return 1; };Bison 报错则优先检查.output文件里的冲突报告。更省时间的办法是:先跑讲义自带的最小示例,而不是直接编译自己的整个项目。如果最小示例能过,说明环境没问题,问题在自己写的文法里;如果最小示例都不能过,再去查工具链版本差异。这个排查顺序能帮你节省几个小时。
5. 把 PDF 讲义变成调试能力:给自己做一个“分析过程观察器”
很多人读完讲义、做完实验,遇到“这个编译器的内部状态到底是怎么变的”这样的问题还是无从下手。我自己的一个习惯是,不只在递归下降和预测分析的表驱动代码里塞 print,而是做一个统一的分析过程观察器,把栈变化、当前输入符号、执行动作三件事同时打出来。
关键代码其实很短:
debug_enabled = True def trace(stack, lookahead, action): if debug_enabled: print(f"栈: {stack[:5]}... 前瞻: {lookahead!r} 动作: {action}")把trace放在预测分析循环的每次迭代开头,或者递归下降每个产生式的入口。这个习惯的价值在于,当你写一个复杂表达式解析出错时,你能直接看到“栈顶是什么、输入走到了哪个 token”,而不是面对一行SyntaxError瞎猜。配合这个工具,把讲义里那张 LR 分析表逐步跑一遍,你会亲眼看到移进、规约动作的序列是如何对应到推导树的。
另一件值得做的事是把讲义末尾的练习题当调试用例,而不是当考试题。每道题自己构造输入,用写好的词法分析器和语法分析器去跑,看错误发生在哪一步。这个过程会让你发现,真正难的从来不是背与分析表,而是把讲义与代码样本之间的“接口”打通。我当年最大的收获,就是找了一个很简单的 C 语言子集,把词法、语法、四元组生成串成一条完整的流水线,之后再看任何编译技术的资料都像在复习老朋友。希望这份路径和坑位清单能帮到你少走弯路,早点把讲义变成你自己的调式工具。
本文还有配套的精品资源,点击获取