☰
语法分析实验报告写作指南:LL(1)递归下降与FIRST/FOLLOW集实战
2026/10/13 1:22:30 网站建设 项目流程

简介:这是一份面向编译原理课程学习者的语法分析实验报告资源包,完整提供实验文档与可运行的解析器代码。资源压缩包内共包含4个文件,其中包括C++源码、Word版实验报告、程序输出文本以及可直接执行的exe文件,压缩包整体仅453KB,小巧而完整。已有3106人学习下载,适合正在完成语法分析实验或想深入理解编译器前端的学生。资料围绕上下文无关文法展开,系统讲解LL与LR分析、LR(0)/LALR(1)/LR(1)变体、Yacc与Bison工具以及递归下降解析等核心知识点,并结合代码展示了抽象语法树的构建过程和语法错误处理策略。通过研读源码与实验报告,读者既能掌握语法分析的理论框架,也能获得实际编写和调试解析器的具体经验,为后续语义分析和代码生成打下基础。

1. 语法分析实验报告(含代码):一份能过答辩的交付物该是什么样

“语法分析实验报告(含代码)”这个标题,放到学期末就是大型翻车现场:文法在教材上写得干干净净,一到自己手上,FIRST 集算不明白、递归下降爆栈、分析表里全是冲突,最后代码倒是跑通了,报告却不知道贴什么才显得“做过”。语法分析要做的事其实一句话就能讲清楚——词法分析把字符流切成 token,语法分析把 token 流按文法组织成一棵语法树;而这份报告,就是要把“这棵树是怎么长出来的”这件事,用文字和代码双重证明一遍。这篇笔记写给正在编译原理实验课上挣扎的人,也写给想补一份能过答辩的实验报告的人。我会按“先定方案、再写代码、最后组织报告”的顺序,把语法分析实验从头到尾捋一遍。

2. 方案先行:选 LL(1) 还是 LR,直接决定你后面几天怎么过

动手写代码之前,先花半天把方案定死。语法分析实验最常见的三条路线是 LL(1) 递归下降、SLR(1) 查表驱动,以及对着某个生成器工具敲命令。选择哪条,取决于你的课时、编程语言和想从报告里拿到多少分。

2.1 三种语法分析方案的选型对比:课时、语言和调试成本

这里给一张我常用的对比表,照着它选型基本不会出大错:

对比维度LL(1) + 递归下降SLR(1) 查表驱动借助生成器工具
代码量小,一个非终结符一个函数中,一张分析表 + 一个驱动循环极小,写文法描述文件即可
报错定位精确到产生式和 token靠栈和表定位,需要自己维护信息取决于工具的错误报告
分析表构建可手算,程序校验程序自动生成,冲突容易暴露工具自动完成
对原理的理解直观,适合入门能深入理解查表机制容易被当成黑匣子
答辩风险低,能讲清楚每一步中,要能解释项目集构造高,老师一问项目集就露馅

我一般会选 LL(1) + 递归下降,原因有两个:一是报错信息能直接定位到“你正处在哪个非终结符的处理逻辑里”,调试时不用在状态栈里翻来翻去;二是实验报告要贴预测分析表,LL(1) 的分析表规模小、能手算校验,答辩时老师拿着表随机指一个入口,你能当场还原推导过程。想冲高分的话,再用同一份文法把 SLR(1) 查表驱动也做了,两种实现对照着写,报告的价值直接上一档。

语言选择上,Python 足够完成实验,且没有编译环境问题;如果课程要求 C 语言,也用递归下降,只是内存管理要小心。选型后立刻把文法定下来,这是下一节的事。

2.2 从四则运算文法入手:消除左递归与 FIRST/FOLLOW 集的手算过程

实验用算术表达式文法最稳妥,覆盖了左递归、提取公因子、括号嵌套这些考点,规模又小。初始文法写出来是教科书经典款:

E -> E + T | E - T | T T -> T * F | T / F | F F -> ( E ) | id | num

这里 id 对应变量名,num 对应数字字面量。直接拿它写递归下降是跑不起来的,因为 E、T 都带直接左递归,函数会在进入 E 后立刻又选 E -> E + T,永不返回,栈溢出只是时间问题。消除左递归后的 LL(1) 文法如下,建议给产生式编上号,报告里引用会非常方便:

编号产生式
(1)E → T E′
(2)E′ → + T E′
(3)E′ → − T E′
(4)E′ → ε
(5)T → F T′
(6)T′ → * F T′
(7)T′ → / F T′
(8)T′ → ε
(9)F → ( E )
(10)F → id
(11)F → num

接下来手算 FIRST 与 FOLLOW 集。算之前先明确终结符集合:{ +, −, *, /, (, ), id, num },外加输入结束符 EOF。手算结果是这份实验报告第一处可以被老师抽查的内容,必须保证每一步都能讲出来:

非终结符FIRST 集FOLLOW 集
E{ id, num, ( }{ ), EOF }
E′{ +, −, ε }{ ), EOF }
T{ id, num, ( }{ +, −, ), EOF }
T′{ *, /, ε }{ +, −, ), EOF }
F{ id, num, ( }{ *, /, +, −, ), EOF }

手算时最容易错的是 FOLLOW(T′)。看产生式 E′ → + T E′,T 后面跟着 E′,所以 FIRST(E′) 里的 +、− 进 FOLLOW(T);又因为 E′ 可空,FOLLOW(E′) 里的一切还要并进 FOLLOW(T)。这一串推导有经典的坑,第 5 章会专门讲。如果你不想手算,也可以在写完代码后让程序输出一遍再做比对,但报告里一定要保留手算过程,“程序算的”和“我算的”两张表一致,才是完整的实验记录。

2.3 和词法分析对接:token 流格式在动手前就要定死

语法分析器不负责分词,它消费的是词法分析产出的 token 流。很多实验代码翻车,是因为 token 流的格式没定清楚就开始写语法分析,最后两个模块耦合在一起,报告里的“模块划分”一节根本没法写。

我给 token 流的约定是:每个 token 一个四元组 (type, lexeme, line, col)。type 是终结符类型,lexeme 是原始字符(报错时用),line 和 col 是位置信息。一个示例输入 id + 5 对应:

[('ID', 'id', 1, 1), ('PLUS', '+', 1, 3), ('NUM', '5', 1, 5), ('EOF', None, 1, 6)]

规则只有两条:标识符统一成 ID,数字字面量统一成 NUM,EOF 固定是最后一个 token。注意类型名不要和字符本身混用,代码里用 PLUS 表示加号,报告里写成“+”,中间需要一张对应表。

提示:如果你们的实验还要求画语法树,token 的 line 和 col 就是你给树节点标注位置的数据来源,别省略。

3. 用 Python 实现 LL(1) 分析器:三段可以直接抄的报告级代码

方案定成 LL(1) 递归下降之后,实现拆成三块:FIRST 集计算、FOLLOW 集与分析表构建、递归下降主程序。下面给出的代码片段都是可独立运行的,注释和输出格式可以直接放进实验报告的附录。

3.1 FIRST 集计算:不动点迭代的代码与终止条件

文法在代码里的表示方式先统一:用字典,键是非终结符,值是该非终结符的所有产生式右部;空产生式用空列表[]表示。以消除左递归后的文法为例:

prods = { 'E': [['T', 'EP']], 'EP': [['PLUS', 'T', 'EP'], ['MINUS', 'T', 'EP'], []], 'T': [['F', 'TP']], 'TP': [['STAR', 'F', 'TP'], ['SLASH', 'F', 'TP'], []], 'F': [['LPAREN', 'E', 'RPAREN'], ['ID'], ['NUM']], } terminals = {'PLUS', 'MINUS', 'STAR', 'SLASH', 'LPAREN', 'RPAREN', 'ID', 'NUM'}

计算 FIRST 集用不动点迭代,循环直到所有集合都不再变化。代码如下:

EPS = 'ε' def compute_first(prods, terminals): # first 初始化:非终结符为空集,终结符是自身 first = {nt: set() for nt in prods} for t in terminals: first[t] = {t} changed = True while changed: changed = False for A, alternatives in prods.items(): for alt in alternatives: if len(alt) == 0: # 空产生式:ε 直接进 FIRST(A) if EPS not in first[A]: first[A].add(EPS) changed = True continue can_be_empty = True for sym in alt: # 把 FIRST(sym) 中非 ε 的符号并入 FIRST(A) for s in first[sym]: if s != EPS and s not in first[A]: first[A].add(s) changed = True # 只要有一个符号不可空,整个右部就不可空 if EPS not in first[sym]: can_be_empty = False break if can_be_empty and EPS not in first[A]: first[A].add(EPS) changed = True return first

这段代码的逻辑是这样的:外层 while 控制不动点迭代,什么时候算完?当一轮遍历下来没有任何集合变化时,说明已经到达固定点。内层对每条产生式右部逐个符号处理,把 FIRST(sym) 中非 ε 的终结符并进来,一旦遇到某个符号的 FIRST 不含 ε,右部后续符号就不会再贡献 ε。整体右部全部可空或右部本身为空时,才把 ε 放入 FIRST(A)。

值得注意的参数是EPS,它是一个全局哨兵字符串,用来区分“真终结符”和“空串标记”。如果你在报告里展示代码,务必把EPS和终结符集合分开维护,否则后面 FOLLOW 集和预测分析表都会出问题。

3.2 FOLLOW 集与预测分析表:一个冲突就回文法里查

FOLLOW 集的计算同样用不动点迭代,但规则比 FIRST 集复杂,容易漏。核心规则有三条:开始符号的 FOLLOW 一定包含 EOF;产生式 A → α B β 时,FIRST(β) 中非 ε 的符号进 FOLLOW(B);如果 β 可空或 β 不存在,FOLLOW(A) 的一切符号进 FOLLOW(B)。代码实现如下:

def compute_follow(prods, terminals, first, start): follow = {nt: set() for nt in prods} follow[start].add('EOF') nonterms = set(prods) changed = True while changed: changed = False for A, alternatives in prods.items(): for alt in alternatives: for i, sym in enumerate(alt): if sym not in nonterms: continue # 只处理后跟是非终结符的情况 rest = alt[i+1:] for B in rest: # 规则 2:FIRST(B) 中非 ε 的符号进 FOLLOW(sym) for s in first[B]: if s != EPS and s not in follow[sym]: follow[sym].add(s) changed = True if EPS not in first[B]: break else: # 规则 3:rest 可空或为空,FOLLOW(A) 并入 FOLLOW(sym) for s in follow[A]: if s not in follow[sym]: follow[sym].add(s) changed = True return follow

这里用到了 Python 的 for-else:当rest中所有符号的 FIRST 都含 ε,或者rest本身为空时,循环正常结束,进入 else 分支;只要有一个符号不可空,break 跳出,else 不会执行。这个语法细节很容易被忽略,写注释时我建议明确写一句“for-else 表示 rest 全可空”。

拿到 FIRST 和 FOLLOW 后构建预测分析表。表是一个二维映射,键是 (非终结符, 终结符),值是产生式右部:

def build_table(prods, first, follow): table = {nt: {} for nt in prods} for A, alternatives in prods.items(): for alt in alternatives: syn = set() if len(alt) == 0: # 空产生式:FOLLOW(A) 决定入口 syn = follow[A] else: eps_all = True for sym in alt: syn |= (first[sym] - {EPS}) if EPS not in first[sym]: eps_all = False break if eps_all: # 右部整体可空,FOLLOW(A) 也要加入入口 syn |= follow[A] for t in syn: if t in table[A]: raise ValueError(f"冲突: {A} 遇到 {t} 既有 {table[A][t]} 又有 {alt}") table[A][t] = alt return table

构建表时只要发现同一个格子被两条产生式填了,就抛异常。这是一种保护机制:LL(1) 文法要求分析表每个入口至多一个产生式,冲突意味着文法不是 LL(1),此时回文法里检查公因子提取或空串处理,而不是继续往下写代码。注意table[A]的键包含EOF,这会用在最后的输入结束判断上。

3.3 递归下降主程序:报错要能定位到行号和列号

有了分析表,递归下降函数可以写得非常薄:每个非终结符对应一个解析函数,但它不再手工判断选哪条产生式,而是查表决定。这种“递归下降 + 查表决策”的混合风格,既保留了递归下降的调用栈语义,又让代码和报告里的分析表严格一一对应:

class Parser: def __init__(self, tokens, table): self.tokens = tokens self.pos = 0 self.table = table def peek(self): return self.tokens[self.pos][0] def match(self, t): if self.peek() != t: raise SyntaxError(self.error_msg(expected={t})) tok = self.tokens[self.pos] self.pos += 1 return tok def error_msg(self, expected=None): line, col = self.tokens[self.pos][2], self.tokens[self.pos][3] got = self.tokens[self.pos][0] exp = ','.join(sorted(expected)) if expected else '?' return f"line {line}, col {col}: expected one of [{exp}], got {got}" def parse_symbol(self, A): if A in terminals: self.match(A) return alt = self.table[A].get(self.peek()) if alt is None: raise SyntaxError(self.error_msg(expected=set(self.table[A].keys()))) for sym in alt: self.parse_symbol(sym)

parse_symbol是递归下降的核心:如果当前符号是终结符,直接匹配并消费;如果是非终结符,用当前输入 token 作为索引查分析表,找到产生式后逐个递归处理右部符号。找不到入口时,报错信息会列出该非终结符所有合法入口和当前 token 的实际位置,比如:

line 1, col 4: expected one of [ID,NUM], got PLUS

这一段代码里最值得学的是error_msg的格式:一定带上 line、col、期望集合、实际 token。缺少任意一项,实验报告的“错误处理”小节就没法写。如果想让程序在第一个错误后继续分析,可以在parse_symbol外加一层异常捕获,遇到错误后跳过 token 直到某个同步符号,这就是教材上的 panic-mode 错误恢复,简单实现放在第 6 章。

4. 把实验报告写成“能复现”:测试用例、过程表和附录代码

代码能跑只是第一步,实验报告的分数取决于别人能否照着你的步骤复现结果。这一章讲怎么设计测试、怎么生成证据、怎么组织附录代码。

4.1 四类测试用例:合法表达式、非法输入、边界情况和长串压测

报告里的测试用例不能只放两三个“能跑通”的例子。我通常会设计一组覆盖不同路径的用例,并用表格呈现,每一条都注明它验证的是哪部分逻辑:

用例编号输入表达式期望结果验证目标
T1id + id * id接受基本优先级链 + , *
T2( id + id ) * num接受括号嵌套与 F → ( E )
T3id + * id拒绝,定位到第 1 行第 4 列附近缺操作数时的错误恢复
T4( id + id拒绝,EOF 前发现等待 RPAREN括号不匹配
T5id - num - id + id * ( id )接受左结合与多运算符混用
T6空输入(只有 EOF)拒绝空串不属于该文法的语言

每个用例在报告里都要附上程序输出,输出里必须能看出“接受/拒绝”和“精确位置”。T3 和 T4 是答辩时最容易被抓着问的,因为多数人只测了合法输入。注意 T4 的报错要在 EOF 处触发 F → ( E ) 对 RPAREN 的期待,你的错误信息要能表达这一点。

4.2 分析过程的证据:栈状态表和递归日志怎么生成

实验报告最硬核的证据是分析过程记录。查表驱动的实现可以打印“步骤、分析栈、剩余输入、动作”四列表,递归下降实现则打印非终结符的进入与离开日志。递归下降日志模板:

enter E enter T enter F match ID leave F leave T enter EP match PLUS enter T enter F match ID leave F leave T leave EP leave E accept

这个日志生成很简单:在parse_symbol开头打印enter A,结尾打印leave A。报告里贴一小段即可,不必全贴。如果想生成教材上那种“栈状态表”,就用查表驱动循环直接在每次迭代时打印当前栈和剩余输入。我建议报告同时放这两种证据:递归日志证明“实现是递归下降”,栈状态表证明“产生式推导路径符合分析表”。

4.3 附录代码格式三件套:能编译、能运行、能对应正文

很多人写报告,到最后一天才把代码往附录一贴,格式随意,结果老师想运行复现时根本对不上。我按三件套标准来组织附录代码:

第一,每个代码文件都要有文件头注释,写明文件名、编译或运行命令、输入格式。比如“parser.py:python parser.py test1.txt,无第三方依赖,Python 3.8+”。第二,正文引用代码时用编号对应,比如正文写“预测分析表见表 3-1”,附录代码注释里就写“本代码对应正文表 3-1 的分析表”。第三,贴代码用等宽字体并保留行号,控制台输出直接复制文本,不要截图。

实验报告正文的骨架我一般固定为:实验目的、文法设计(含产生式编号)、FIRST/FOLLOW 集表格、预测分析表、测试用例与运行记录、结果分析、附录代码。附录放完整代码,正文里只放关键片段和分析表。这样“报告是说明书,代码是证据”的分工就清楚了。

5. 语法分析实验的 5 个高频避坑点:现象、原因、解决

下面的每一个坑,都是我见过或者自己踩过、能在答辩前救你一命的经验。按“现象 → 原因 → 解决”的顺序写,方便你对照排查。

5.1 FIRST 集空串处理出错,分析表里出现多余入口

现象:程序能跑,但生成的预测分析表里出现了不该有的入口,比如 FIRST(E) 里混进了 PLUS,或者某个非终结符在遇到 ID 时多了一条产生式。手算表和程序表对不上,越查越玄学。

原因:空串处理链出了问题。常见的是把 ε 直接当普通终结符参与 FIRST 合并,或者右部“全部可空”的判断不完整——比如 F 明明不可空,却因为某个符号的 FIRST 含 ε 就误判整个右部可空,导致 ε 污染了后续符号。

解决:把“可空性”单独算一遍,得到 nullable 集合后再算 FIRST;代码里严格区分EPS哨兵和真实终结符;报告中把 nullable 集合也列出来,作为 FIRST 表的推导依据。这样一旦分析表异常,能快速定位到具体是哪个产生式的可空判断出了问题。

5.2 递归下降无限递归:十有八九是左递归没消除干净

现象:输入第一个 token 就报RecursionError: maximum recursion depth exceeded,而且栈很深时你在报告里根本看不出是哪一步开始的。

原因:只消除了直接左递归,漏了间接左递归,比如 E → A,A → E 这种。递归下降在进入 E 后,经过 A 又绕回 E,形成不消耗输入的无限循环。

解决:写代码前先做一个左递归检测,扫描每个非终结符,沿着“产生式右部首符号”的路径看能否回到自身:

def has_left_recursion(prods, nonterms): for A in nonterms: stack = [(A, A, [])] while stack: cur, start, path = stack.pop() for alt in prods.get(cur, []): if alt and alt[0] in nonterms: if alt[0] == start: return True, path + [alt[0]] stack.append((alt[0], start, path + [alt[0]])) return False, None

如果检测到间接左递归,处理方法是给非终结符排个序,把编号靠后的非终结符的产生式代入编号靠前的非终结符右部,消掉环路后再写代码。这一步放报告里,比单纯贴“跑通了”更有说服力。

5.3 报错信息没有行列号,实验记录根本写不下去

现象:测试非法输入时程序只输出syntax error,既不知道错在哪一行,也不知道期望看到什么 token。报告里“错误处理”一节只能用“程序报错”四个字糊弄过去。

原因:报错函数偷懒,只打印了一个固定字符串,没有把 token 流的位置信息和期望集合传出来。

解决:统一用设计好的error_msg格式输出。误报错也要带位置。一旦报错位置准确,实验结果分析就能写成“该错误由缺操作数触发,检测点在 id 后的 PLUS 处”,答辩时这是非常扎实的细节。

5.4 c 语言实现的环境坑:代码跑不起来,先别怀疑分析器

现象:用 Visual Studio 写的 C++ 版解析器在自己机器上一遍过,换到没有装 VS 的机器上双击运行,弹窗提示“由于找不到 msvcp140.dll,无法继续执行代码”。很多人会误以为是代码问题,开始一行行检查语法分析逻辑,白费半天。

原因:这是 VC++ 运行库缺失,和语法分析代码本身无关。实验验收环境下,机器可能没有安装对应的运行时。

解决:代码不要依赖 IDE 的调试运行。附录第一页写明编译方式,推荐用 gcc 命令行编译,比如gcc parser.c -o parser,不依赖第三方库,这样到任何机器都能现场编译验证。提交源码而不是只提交 exe;如果是 C++ 代码,提前在验收机器上确认 VC++ 运行库已安装。

5.5 报告里的分析表和代码版本不一致,答辩当场翻车

现象:报告里贴的是 SLR(1) 分析表,代码却是递归下降实现的 LL(1);或者上一届模板里的 FIRST/FOLLOW 和自己代码生成的完全对不上。老师用报告中的表推导一个句子,得到的结果和代码输出不一致,全场安静。

原因:赶 deadline 时从别的报告里摘了图表,没有和代码同步生成。这是最不值得丢的分。

解决:让分析表、FIRST/FOLLOW 集、测试输出都从同一个数据结构导出。我给每个实验都写一个export_tables.py,直接输出 Markdown 格式的表格,报告里需要的所有表都由它生成。附录代码开头加一句注释“本代码对应正文第 4 节表 4-1”,提交前再抽查三条产生式,手动走一遍看代码是否按表执行。表格和代码永远是同一份,就不会有这种翻车时刻。

6. 进阶:把递归下降换成查表驱动,顺便把错误恢复补上

想在报告里多拿几分,可以把第 3 章的递归下降替换成统一的查表驱动循环。这个实现代码量不大,却能在报告中展示“同一张分析表、两种执行方式”的对比。核心循环如下:

def table_driver(tokens, table, start, terminals, sync): stack = ['EOF', start] # 栈底 EOF,栈顶是 start idx = 0 while True: top = stack[-1] cur = tokens[idx][0] if idx < len(tokens) else 'EOF' if top == cur: if top == 'EOF': return True # 栈底和输入同时到 EOF,接受 stack.pop() idx += 1 elif top in terminals: raise SyntaxError(f"unexpected {top} at line {tokens[idx][2]}") else: alt = table[top].get(cur) if alt is None: # panic-mode 错误恢复:跳到同步符号 print(f"skip: {cur}") while idx < len(tokens) and tokens[idx][0] not in sync: idx += 1 continue stack.pop() if alt: for sym in reversed(alt): stack.append(sym)

同步符号集合取{'EOF', 'RPAREN', 'PLUS', 'MINUS'},遇到错误时跳过输入,直到遇到这些“大概率能重新同步”的 token。和递归下降相比,查表驱动的报错需要自己维护栈来推导出错路径,而递归下降的调用栈本身就是语义信息——把两种实现的优缺点写进报告,比单纯说“我实现了语法分析器”更有深度。

我当年交实验报告时吃过一次亏:手算 FIRST 集漏了 ε,分析表多了两个垃圾入口,代码怎么跑都对,但老师一眼看出表和代码不匹配。后来我养成一个习惯——报告里每张表都从数据结构自动生成,表和代码永远同一份。这个习惯让我后来少返工无数次。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询