1. 实验三的需求本质:从词法到语法结构的那一步
标着 3 月 20 日的“实验三:语法分析的 C 语言实现”,放在编译原理课程节奏里,基本就是词法分析刚交完、开始进入语法分析的时间点。很多同学在这个实验前最大的困惑是:词法分析已经把 i + i * i 切成了 id、加号、乘号这样一串 Token,语法分析到底还要做什么?
简单说,语法分析的任务是判断这串 Token 是否符合文法规则,并且按照产生式把它们组织成一颗语法树。词法分析解决的是“单词拼写对不对”,语法分析解决的是“句子结构对不对”。比如 i + * i 每个单词都合法,但是结构不合法;又比如 (i + i 少了右括号,也要在这里被拦下来。用 C 语言实现语法分析器,就是让你抛开工具生成器,亲手把上下文无关文法的识别过程写出来。
在动手之前,我建议你把实验要求拆成三件事来看。
1.1 输入输出边界要确认到“符号级”
实验会给出一个小型文法,通常是表达式文法,有的还包含赋值语句或 if 语句。输入一般有三种形态:直接读一行字符串、读词法分析器输出的 Token 序列、或者程序内部预设几个测试串。三种形态差很多:
- 直接读字符串:需要自己在语法分析器里做一个极简的词法切分,把
i + i * i变成 id、+、id、*、id 这样的内部符号,再交给分析栈处理。 - 读 Token 序列:假设实验二已经把每个单词的类型和值输出到文件,分析器每次调用一个类似
getToken()的函数就能取下一个终结符。 - 内部预设测试串:最省事,但调试时也很痛苦,因为每测一个用例都要重新编译。
输出一般要求给出分析过程,而不是只打印一句“accept”。常见要求是打印每一步的推导所用产生式,或者打印栈顶符号、剩余输入串、动作三列。如果你不确定老师要求哪种输出,建议按“过程 + 结果”都打印来设计,这样最稳。
1.2 “要求”里通常藏着实验报告的得分点
这类实验的评分不只看代码能不能跑,还看你有没有把理论环节交代清楚。我见过不少实验报告,代码很漂亮,但 FIRST 集、FOLLOW 集、预测分析表只有最终结果,没有计算过程,被扣掉不少分。
按我经验,一份能拿高分的报告至少要包含六块:
- 文法描述,包括原始文法和改造后的文法;
- 改造理由,比如左递归为什么必须消除、公共左因子为什么需要提取;
- 改造后文法的 FIRST 集与 FOLLOW 集手工计算过程;
- 预测分析表(LL(1) 路线)或递归下降子程序的调用关系;
- 核心代码的设计说明,不能只贴代码不解释;
- 测试用例及结果分析,且必须包含合法输入和非法输入两类。
后面我会逐个展开。你最好在第一周就把这六项在脑子里过一遍,然后按这个结构去写实验报告,而不要等代码写完再补。
1.3 先搞清楚实验三在整个编译器课程里的位置
实验二做完词法分析,程序能识别关键字、标识符、常数、运算符了。然而识别出来的 Token 仍然是“拍扁”的线性序列,无法体现 a + b * c 中乘法优先级高于加法、括号能改变优先级这些结构信息。
语法分析做的就是构建层级结构。最常见的教学路线是:先讲上下文无关文法和推导、语法树,然后讲自顶向下分析(LL(1)、递归下降),再讲自底向上分析(LR 系列)。实验三如果排在学期大概四到六周,通常对应自顶向下分析,因为 LR 分析需要更多前置知识,放在后面实验更合适。
所以你看到“语法分析的 C 语言实现”,先不要慌,不需要实现一个完整的 C 语言编译器语法分析器,大概率是给你一个定义了运算优先级的小型表达式文法,让你实现识别器。把握住这个定位,后面所有工作量其实都很清晰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 路线选择与文法准备:先消灭左递归,再谈实现
语法分析的主流路线有两条:表驱动的 LL(1) 预测分析和递归下降分析。它们都属于自顶向下分析,即从开始符号出发,不断用产生式去匹配输入串,尝试构造最左推导。
你可能听过 LL(1),也可能听过“递归下降法”。实验三到底选哪条?我的建议是:如果题目没指定,优先做 LL(1) 预测分析器,因为它的理论链条完整,FIRST 集、FOLLOW 集、预测分析表可以直接在报告里展示,非常适合课程实验。如果指定了“递归下降”,那就老老实实写递归下降,它更贴近实际手写解析器的风格,后面我会专门说。
2.1 用标准表达式文法看看问题在哪
假设实验给出如下的表达式文法:
code复制E -> E + T | E - T | T
T -> T * F | T / F | F
F -> (E) | i
这里的 i 可以理解为一个标识符或常数,对应词法分析里的 id 和 num。这个文法本身很自然地表达了优先级:E 管加减,T 管乘除,F 管括号和原子项。但直接拿去做自顶向下分析根本行不通,因为:
E -> E + T是左递归。而自顶向下分析要从 E 出发,第一步就陷入E -> E + T后仍然要推导 E 的无限循环,相当于死循环。- 即使没有左递归,候选式之间如果有公共前缀,也需要向前看更多符号才能确定选哪条。
2.2 消除左递归的两个经典例子
消除左递归是自顶向下分析的第一步。对于上面文法,将直接左递归改成右递归:
code复制E -> T E'
E' -> + T E' | - T E' | ε
T -> F T'
T' -> * F T' | / F T' | ε
F -> (E) | i
这里用了最常用的办法:把左递归的产生式拆成一个“打头”的非终结符和一个“收尾”的非终结符。原来的 E 负责 T 之后的加加减减,现在改成 E' 用右递归不断吃下 + T E' 或 - T E',最后用 ε 结束。
消除左递归后,还要看有没有公共左因子。比如题目如果给的是:
code复制S -> if E then S
S -> if E then S else S
S -> other
两个 S 的产生式都以 if E then S 开头,自顶向下分析读到 if 后无法确定后面是否还有 else。这时需要提取左因子:
code复制S -> if E then S S' | other
S' -> else S | ε
提取后,读到 if 就直接选第一条,然后由 S' 根据下一个符号决定是匹配 else 还是直接结束。注意,这个例子也暗示了 C 语言里 else 的“最近匹配”问题,实际递归下降解析 if 语句时,内层 if 的 S' 会优先消耗 else,自然匹配到最近的 if,这正符合 C 语言的语义。
2.3 为什么必须在代码之前做文法改造
文法改造不只是在纸上画一画。预测分析表是根据改造后的文法生成的,FIRST 集、FOLLOW 集也是基于改造后的文法计算的。如果你在代码里用原始文法,程序要么根本跑不起来,要么表里出现大量冲突。
所以编程的第一步不是打开编辑器,而是拿纸笔把你手上文法改写清楚,并验证改造前后的语言是等价的——注意不是文法等价,而是生成的语言相等。比如消除左递归后的文法能生成的句子集合和原来完全相同,但推导过程变了。这一点实验报告里最好提一句,会显得你理解到位。
3. 先手工算出 FIRST 集、FOLLOW 集和预测分析表,再写一行代码
不少人写 LL(1) 分析器时,喜欢跳过集合计算,直接在代码里硬编码预测分析表。如果只是一次作业
