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) 分析器时,喜欢跳过集合计算,直接在代码里硬编码预测分析表。如果只是一次作业,硬编码可能也能跑通,但一旦文法是动态读入的,或者你想把程序做得通用一些,就必须实现集合计算。更重要的是,预测分析表本身需要验证。
我先以上一节改造后的表达式文法为例,完整推一遍 FIRST 集、FOLLOW 集和预测分析表的构造过程。建议你自己也照着这个流程,把题目给的那个文法完整推一遍,再对照代码的结果。
3.1 FIRST 集的推导其实是一个固定点计算
改造后的文法:
code复制E -> T E'
E' -> + T E' | - T E' | ε
T -> F T'
T' -> * F T' | / F T' | ε
F -> (E) | i
FIRST 集表示“一个符号串可能推导出的第一个终结符集合”。对最简单的产生式:
F -> (E)右部以终结符(开头,所以(属于 FIRST(F);F -> i右部以终结符i开头,所以i属于 FIRST(F);- 因此 FIRST(F) = {
(,i}。
再看 T:
T -> F T'右部第一个是非终结符 F,而 FIRST(F) = {(,i}。因为 F 不能推导出 ε,所以不需要继续看 T',把 FIRST(F) 中除 ε 外的符号都加入 FIRST(T);- 于是 FIRST(T) = {
(,i}。
再看 E:
E -> T E',同样因为 FIRST(T) = {(,i},且 T 不能推出 ε,所以 FIRST(E) = {(,i}。
这里面容易出问题的是带 ε 的 E' 和 T':
- E' 的三个候选式:
+ T E'给出+,- T E'给出-,ε表示 E' 本身可以推导出空串; - 所以 FIRST(E') = {
+,-, ε }; - T' 的三个候选式:
* F T'给出*,/ F T'给出/,ε则意味着 FIRST(T') = {*,/, ε }。
如果某个产生式右部是 X1 X2 ... Xn,则要一直检查 X1、X2 等符号的 FIRST 是否含 ε,只要中途某一步遇到首个不能推导出 ε 的符号,就停止增加。实际在代码里,这个“反复加入直到集合不再增长”的过程就是固定点计算。
3.2 FOLLOW 集最容易算漏,三个规则要记牢
FOLLOW 集表示“在推导过程中,可能出现在某个非终结符后面的终结符集合”。注意几点:开始符号的 FOLLOW 一定包含结束符,语言中一般用 #(工程上常写 $);FOLLOW 集里永远不包含 ε。
计算 FOLLOW(E):
- E 是开始符号,把
#放入 FOLLOW(E); - 看产生式
F -> (E),右部 E 后面跟着终结符),把)加入 FOLLOW(E); - 所以 FOLLOW(E) = {
#,)}。
计算 FOLLOW(E'):
- 在
E -> T E'中,E' 位于产生式右部末尾,因此 FOLLOW(E) 中的符号都会出现在 E' 后面,即 FOLLOW(E') = FOLLOW(E) = {#,)}。
计算 FOLLOW(T):
- 在
E' -> + T E'中,T 后面跟着 E'。加入 FIRST(E') 中除 ε 外的所有终结符,得到+和-;又因为 E' 可以推导出 ε,所以 FOLLOW(E') 中的符号也要加入 FOLLOW(T)。于是 FOLLOW(T) = {+,-,#,)}; - 注意这里不能忘记由 E' 可空带来的传播。
计算 FOLLOW(T'):
- 在
T -> F T'中,T' 位于右部末尾,所以 FOLLOW(T') = FOLLOW(T) = {+,-,#,)}。
计算 FOLLOW(F):
- 在
T' -> * F T'中,F 后面跟着 T'。加入 FIRST(T') 中除 ε 外的符号,得到*和/;又因为 T' 可以推导出 ε,所以 FOLLOW(T') 也要加入 FOLLOW(F); - 于是 FOLLOW(F) = {
+,-,*,/,#,)}。
FOLLOW 集的代码实现一般也可以写成循环迭代:不断扫描所有产生式,如果产生式右部出现非终结符 A 且 A 后面有符号 β,则把 FIRST(β) 中非 ε 的符号加入 FOLLOW(A);如果 β 可以推导出空串,那么把 FOLLOW(产生式左部) 加入 FOLLOW(A)。这个过程必须反复执行到集合不再变化为止。
3.3 预测分析表的填法,以及“表冲突”意味着什么
拿到 FIRST 集和 FOLLOW 集后,可以构造 LL(1) 预测分析表。表行是非终结符,列是终结符(包括结束符),单元格里填对应的产生式。
对每个产生式 A -> α:
- 对 FIRST(α) 中的每一个终结符 a,在 M[A, a] 填入
A -> α; - 如果 α 可以推导出 ε,那么对 FOLLOW(A) 中的每一个终结符 b,在 M[A, b] 填入
A -> α。
以 E' 的两个产生式为例。FIRST(E') = { +, -, ε },所以 E' 这一行中,+ 和 - 两列填 E' -> + T E' 和 E' -> - T E'。因为 E' 可以推导出 ε,再看 FOLLOW(E') = { #, ) },所以在 M[E', #] 和 M[E', )] 填入 E' -> ε。
T' 同理:
- M[T', *] =
T' -> * F T' - M[T', /] =
T' -> / F T' - 由于 T' 可空,M[T', +]、M[T', -]、M[T', #]、M[T', )] 都填
T' -> ε
F 的两个产生式也直接填入 ( 和 i 列。
如果填表时发现某个单元格同时被两个不同产生式占用,说明文法不是 LL(1),自顶向下预测分析无法唯一确定下一步动作。这就是实验报告里常说的“冲突”。最常见的两类冲突是 FIRST/FIRST 冲突(公共左因子没有提取干净)和 FIRST/FOLLOW 冲突(如经典悬空 else 问题)。所以我在第 2 节强调先改造文法,目的就是让这张表干干净净地每格至多一个产生式。
4. C 语言核心实现:分析栈、预测分析表与推导过程可视化
在动手写代码前,我建议先把分析器的主流程吃透。LL(1) 预测分析器的核心是模拟一个带栈的下推自动机:
- 分析栈里保存着推导过程中等待匹配的符号;
- 输入缓冲区保存剩余的 Token 串;
- 每一步都根据“栈顶符号 X”和“当前输入符号 a”做动作;
- 如果 X 是终结符且 X == a,则匹配成功,弹出 X,输入指针前移;
- 如果 X 是终结符且 X != a,则报语法错误;
- 如果 X 是非终结符,查预测分析表 M[X][a]:若有产生式,则弹出 X,将产生式右部逆序压入栈,同时记录这是哪一步推导;若表项为空,则报语法错误。
其中最容易写错的一步是“将产生式右部逆序压栈”。比如 E' -> + T E',在栈中 E' 原本位于栈顶,匹配后应该弹出 E',再把 +、T、E' 按逆序压入。由于栈是后进先出,你必须先压右部最后一个符号,最后压右部第一个符号,才能保证下次弹出的是右部第一个符号。
4.1 用整数编号统一终结符与非终结符
C 语言里没有现成的枚举栈容器,但我建议不要直接用字符串压栈。字符指针比较、入栈出栈都非常麻烦,而且容易造成内存管理问题。比较干净的做法是用整数编号表示文法符号。
c复制#define SYM_END 0 /* 输入结束符 # */
#define SYM_PLUS 1
#define SYM_MINUS 2
#define SYM_TIMES 3
#define SYM_DIV 4
#define SYM_LPAREN 5
#define SYM_RPAREN 6
#define SYM_ID 7
#define SYM_E 100 /* 后面这些是非终结符,随便挑个大整数起始段 */
#define SYM_E1 101
#define SYM_T 102
#define SYM_T1 103
#define SYM_F 104
定义完符号,再定义产生式结构体。实验所需文法产生式数量很少,不搞复杂动态结构,用固定数组就行:
c复制typedef struct {
int lhs; /* 产生式左部,非终结符 */
int rhs[8]; /* 产生式右部符号序列,数组末位用 -1 结束 */
char *desc; /* 产生式字符串,用于输出 */
} Production;
对于空产生式 E' -> ε,可以把 rhs[0] 设为 -1,表示右部为空。
4.2 预测分析表用“产生式编号”二维数组
预测分析表可以定义成 int table[NT_NUM][TERM_NUM],其中存储的是产生式编号,-1 表示错误表项。为什么不是直接存 Production 结构体?因为二维结构体数组既占空间又不好初始化,存编号会轻很多。
一个实用做法是先把所有产生式放数组里:
c复制Production prods[] = {
{SYM_E, {SYM_T, SYM_E1, -1}, "E -> T E'"},
{SYM_E1, {SYM_PLUS, SYM_T, SYM_E1, -1}, "E' -> + T E'"},
{SYM_E1, {SYM_MINUS, SYM_T, SYM_E1, -1}, "E' -> - T E'"},
{SYM_E1, {-1}, "E' -> ε"},
{SYM_T, {SYM_F, SYM_T1, -1}, "T -> F T'"},
{SYM_T1, {SYM_TIMES, SYM_F, SYM_T1, -1}, "T' -> * F T'"},
{SYM_T1, {SYM_DIV, SYM_F, SYM_T1, -1}, "T' -> / F T'"},
{SYM_T1, {-1}, "T' -> ε"},
{SYM_F, {SYM_LPAREN, SYM_E, SYM_RPAREN, -1}, "F -> ( E )"},
{SYM_F, {SYM_ID, -1}, "F -> i"},
};
初始化表时可以把对应表项直接写好,或者编写一个自动构造表的函数。对课程实验来说,我推荐写自动构造函数。因为一旦题目换成一个 if 语句文法,你只需要改产生式数组,不需要重写表。
自动填表要用到 FIRST 和 FOLLOW 集合,所以还得多定义两个布尔矩阵:
c复制#define MAX_SYM 128
int first[MAX_SYM][MAX_SYM];
int follow[MAX_SYM][MAX_SYM];
这个布尔矩阵的意义是 first[A][b] = 1 表示终结符 b 属于非终结符 A 的 FIRST 集。终结符的 FIRST 集就是自身,所以矩阵维度统一处理也可以;不过实现时很多人喜欢把终结符和非终结符分开,那样会多一点代码,但思路更清晰。
4.3 驱动循环:只保留一个主循环
分析器主循环可以参考下面这个骨架:
c复制int stack[256];
int top = 0;
int cur = 0; /* 当前输入符号在数组中的下标 */
int token; /* 当前输入符号 */
void push(int s) { stack[++top] = s; }
int pop(void) { return stack[top--]; }
void parse(void) {
token = next_token(); /* 取第一个输入符号 */
push(SYM_END);
push(SYM_E);
while (1) {
int X = stack[top];
if (X == SYM_END && token == SYM_END) {
printf("accept\n");
break;
}
if (is_terminal(X)) {
if (X == token) {
pop();
token = next_token();
} else {
error("terminator mismatch");
break;
}
} else {
int prod_no = table[nt_index(X)][term_index(token)];
if (prod_no == -1) {
error("table entry empty");
break;
}
printf("%s\n", prods[prod_no].desc);
pop();
for (int i = 0; prods[prod_no].rhs[i] != -1; i++) {
push(prods[prod_no].rhs[i]);
}
}
}
}
仔细看这段代码,和伪代码几乎一一对应。但有几个隐藏问题需要注意。
第一,逆序压栈时,代码中循环从 rhs 的下标 0 开始,把右部第一个符号先压栈,最后一个符号最后压栈。这样最终栈顶是右部第一个符号吗?不对,应该从右部最后一个符号开始压入,因此这个循环方向需要反过来:
c复制int i = 0;
while (prods[prod_no].rhs[i] != -1) i++;
for (i--; i >= 0; i--) {
push(prods[prod_no].rhs[i]);
}
这是 C 语言实验里最容易踩的坑。写代码时建议画一张 3 层栈的小图,模拟一次压栈,就能避免。
第二,当产生式是空串时,只要先打印产生式,再弹出栈顶非终结符,然后不压入任何符号即可。所以空右部用 { -1 } 表示是很方便的。
4.4 推导过程的输出设计
实验报告里的“分析过程”往往需要打印每一步推导。最直观的是三列表格:步骤序号、分析栈中剩余符号、剩余输入串。分析栈里我们从栈顶开始打印,直到栈底。可以用一个临时数组逆转栈内容再打印,或者递归打印。递归打印更简单,但别在递归里做复杂操作。
c复制void print_stack(void) {
for (int i = top; i >= 1; i--) {
print_symbol(stack[i]);
}
printf("\n");
}
注意我定义的栈底在 stack[0],栈顶在 stack[top]。如果从左到右打印是为了让“栈顶在左”,那么应该从 top 往下循环,然后还要注意实际栈底方向,不同人实现不一样。这个细节不需要过度纠结,只要报告里说明清楚自己的栈顶方向即可。
为了直观展示分析过程,这里给出输入串 i + i * i 的前几步推导轨迹(我约定左侧是栈顶):
| 步骤 | 分析栈 | 剩余输入 | 所选产生式 |
|---|---|---|---|
| 1 | E # | i + i * i # | E -> T E' |
| 2 | T E' # | i + i * i # | T -> F T' |
| 3 | F T' E' # | i + i * i # | F -> i |
| 4 | i T' E' # | i + i * i # | 匹配 i |
| 5 | T' E' # | + i * i # | T' -> ε |
| 6 | E' # | + i * i # | E' -> + T E' |
| 7 | + T E' # | + i * i # | 匹配 + |
| 8 | T E' # | i * i # | T -> F T' |
| ... | ... | ... | ... |
从这张表能直观看出,分析过程恰好模拟了输入串的最左推导。课程实验要求打印推导过程时,就是要产生类似上面这个序列,而不是只输出 accept 或 reject。
4.5 错误处理:不要 panic 完就了事
简单的语法分析器遇到错误直接退出也能得分,但很多实验会额外要求“错误恢复”或者“报告错误位置”。我推荐的策略是“恐慌模式”恢复:当预测分析表某个表项为空时,跳过输入串中的若干个符号,直到遇到当前栈顶非终结符的 FOLLOW 集中的某个符号,再继续分析。
核心逻辑是:
c复制void recover(void) {
while (token != SYM_END) {
int nt = nt_index(stack[top]);
if (is_nonterminal(stack[top]) && follow[nt][term_index(token)]) {
printf("recovered at symbol %d\n", token);
return;
}
token = next_token();
}
}
这种恢复方式实现简单,但要注意不能跳过头,否则会产生一连串假错误。课程实验的正确做法是先保证“能正确识别合法输入”,再讨论错误恢复,不要在错误恢复上投入过多时间。
5. 测试阶段最容易翻车的地方:从空白符到结束符
写完代码,最激动人心的时刻是编译运行。但根据我带实验的经验,很多同学在测试阶段翻车,问题往往不在算法本身,而在一些看似小的细节上。
5.1 输入里的空格、换行、EOF 都要处理干净
如果程序直接读一行表达式,那么 scanf("%s", buf) 会把空格当分隔符,导致 i + i * i 被拆成三段。用 fgets 读一行更合适,但读进来的字符串末尾可能带换行。我的建议是:读入后先过滤一遍空白字符,再存入一个紧凑的字符数组。
假如实验二已经结束了 Token 序列,Token 流经过文件接口传给语法分析器,还要额外处理文件末尾的 EOF。建议词法输出时统一在末尾加一个 # 符号,表示输入结束。这里是很多实验衔接出问题的重灾区:词法分析器没输出结束符,语法分析器永远等不到 end,程序就卡死了。
5.2 结束符的命名和逻辑要统一
中文编译原理教材里经常用 # 作为输入结束符,英文教材和很多工具用 $。不管用哪个符号,程序内部最好统一用一个 SYM_END 枚举值,不要直接把字符 '#' 作为整数来比较。这样以后换结束符只改一处即可。
判断分析器结束的条件有两个,缺一不可:栈里只剩 #,并且输入缓冲区里也只剩 #。栈空但输入没读完,说明输入有多余内容;输入读完但栈里还有符号,说明输入不完整。很多同学只判断一侧,会导致某些非法输入被错误接收。
5.3 非法输入要覆盖哪些情况
一份合格的测试报告至少要有 6 个用例。
合法用例至少覆盖:
- 单个项:
i - 左结合、优先级:
i + i * i - 括号改变优先级:
(i + i) * i - 多层括号和连续运算:
(i + i) / (i - i)
非法用例覆盖:
- 缺右括号:
(i + i - 缺操作数:
i + * i或i + - 括号空内容:
() - 连续运算符:
i + - i
非法输入主要考错误报告是否准确。不要求程序能优雅恢复,但至少不能崩溃,不能出现数组越界,也不能无限循环。如果你把非法输入直接喂给 parse(),很容易遇到 table[nt_index(X)][term_index(token)] 越界,因为 term_index 对未知符号返回 -1。所以取得 token 时要先做合法性检查。
5.4 用调试输出验证每一步推导
当代码运行结果不对时,别急着拿 print 乱打。先找一个小用例,比如最简的 i,手动把表格推导一遍,看看从第一步开始理论输出应该是什么。然后让程序打印每一步的栈和剩余输入,对比理论结果,定位错在表初始化还是压栈方向。
我见过一个很典型的错误:预测分析表本身填对了,但某个产生式的右部数组顺序写反,导致衍生序列完全错乱。这种问题从最终输出很难看出来,但一步步比对就能立即定位。
5.5 输出格式尽量做成规范的表格样式
如果实验允许自由设计输出,我建议在程序里把推导过程输出成对齐的表格形式:
code复制步号 分析栈 剩余输入 所用产生式
1 E # i+i*i# E -> T E'
2 T E' # i+i*i# T -> F T'
...
C 语言里用 %-20s 这样的格式控制比较方便。把每个符号转换成字符串再拼接,别直接打印 %d,否则报告里很难看。
6. 更贴近真实解析器的递归下降版本
如果你的实验题目标明“递归下降”,或者老师点名要求用递归下降法,那上一个 LL(1) 表驱动版本就不太符合题意了。这里需要另起炉灶,但它和 LL(1) 并不冲突,核心还是靠 FIRST 集做预测,只是把“查表动作”换成了“函数调用”。
6.1 递归下降的本质:一个非终结符对应一个函数
递归下降分析器为每个非终结符写一个函数。比如 E 对应 parse_E(),T 对应 parse_T(),F 对应 parse_F()。函数内部根据当前输入符号,选择对应的候选产生式,然后依次调用右部符号对应的解析函数或匹配终结符。
一个简化的表达式递归下降版本长这样:
c复制void parse_E(void) {
parse_T();
while (lookahead == SYM_PLUS || lookahead == SYM_MINUS) {
match(lookahead);
parse_T();
}
}
void parse_T(void) {
parse_F();
while (lookahead == SYM_TIMES || lookahead == SYM_DIV) {
match(lookahead);
parse_F();
}
}
void parse_F(void) {
if (lookahead == SYM_ID) {
match(SYM_ID);
} else if (lookahead == SYM_LPAREN) {
match(SYM_LPAREN);
parse_E();
match(SYM_RPAREN);
} else {
error("unexpected symbol in F");
}
}
这里我把 E 和 T 写成循环而不是先转成 E' 和 T',是因为递归下降天然支持用 while 循环处理左递归的文法语义,也就是同一层运算符的迭代匹配。这种写法比把 E' 拆成函数更符合直觉,实际工程中的手写解析器大多是这种风格。
6.2 递归下降为什么容易处理“悬空 else”
C 语言的 if 语句可能带 else 也可能不带。带 if 的文法:
code复制statement -> if ( expr ) statement
| if ( expr ) statement else statement
| other
直接用递归下降处理时,parse_statement() 读到 if 后先匹配 if ( expr ),然后递归调用 parse_statement() 解析 then 分支。当 then 分支结束后,只要 lookahead 是 else,就继续匹配 else 分支。这样设计,内层 if 必然优先吃掉 else,恰好符合 C 语言“else 与最近的 if 匹配”的规则。如果用 LL(1) 表驱动做,这个文法会产生经典的二义性冲突,需要额外改造。
所以在实际开发手写解析器时,递归下降比表驱动 LL(1) 灵活得多,这也是很多开源 C 语言解析器采用递归下降的原因。不过作为课程实验,递归下降的缺点是:报告里很难像表驱动那样展示完整的预测分析表,你需要在报告里画出函数调用树,并说明每个函数如何根据 lookahead 选择分支。
6.3 递归下降常见 bug:lookahead 没更新导致死循环
写递归下降最容易犯的错误是忘记在 match() 中更新 lookahead。比如在 parse_F() 里匹配了左括号后,后面调用 parse_E(),如果 lookahead 还是左括号而没读下一个 Token,整个递归就会死循环。
我的建议是定义一个全局 lookahead 变量,并让所有 match() 都统一负责推进 Token。这样每个函数只关心匹配和递归,不需要频繁调用取 Token 的函数,逻辑更清晰。此外,递归函数深度受 C 语言栈限制,如果输入表达式括号嵌套特别深,可能栈溢出;课程实验通常不会用极端输入,这个风险可以先不管。
6.4 实验报告里怎么写递归下降的设计
如果选了递归下降,报告就不要照抄 LL(1) 的表格,而应该从这几个角度阐述:
- 每个非终结符的函数职责,用函数调用伪代码表达递归关系;
- lookahead 符号如何决定函数分支,即“预测”如何体现在代码里;
- 优先级和结合性的实现:为什么加减循环放在 parse_T 之后,乘除循环放在 parse_F 之后;
- 测试用例的预期递归调用过程,而不是推导过程表格。
我特别建议把 parse_E、parse_T、parse_F 三层函数之间的调用关系画成一棵调用树,标注每个节点在解析 i + i * i 时的调用顺序,这比贴一大段代码更有说服力。
7. 一些在实验里反复出现的“玄学”问题与我的处理习惯
做这个实验时,很多人会遇到一些报错和奇怪现象。我把这些年最常被问到的问题列出来,你可以对照排查。
第一,程序运行后没有任何输出,或者卡死不动。这通常是输入结束符没有读入,或栈里结束符与输入结束符一直匹配不上。检查一下你给 Token 流末尾加的结束符到底是什么,程序里判断的又是什么。建议在循环开头加一个计数上限,比如最多执行 1000 步强制退出,避免死循环把终端刷爆。
第二,非法输入导致数组越界。预测分析器查表的行列下标依赖于终结符编号映射。如果输入串中出现一个不在文法终结符集合里的字符,而你的词法切分又只是简单规则,会把未知字符映射成奇怪的编号。最好在词法切分时就报“非法字符”并退出,而不是等查表时才崩。
第三,FIRST 集和 FOLLOW 集迭代计算不稳定。如果自己实现了集合计算,一定要用“本轮是否新增元素”作为外层循环条件;千万不要用固定 for 循环次数来强行收敛,那样换一个文法可能就少算或多算了。测试时可以用一个小工具把 FIRST、FOLLOW 集打印出来,和手工推导对比。
第四,使用 C 语言的 enum 时容易把非终结符和终结符混在一个区间里。如果代码里经常出现 if (sym >= START_NONTERMINAL) 来判断是不是非终结符,建议统一用 is_terminal()、is_nonterminal() 两个函数隔离开,不要散落着多处判断。
第五,输出过程中打印符号名和内部符号编号对不上。我自己踩过这个坑:枚举里顺序调了一下,结果所有输出都错位,但语法分析结果却仍然正确,因为表里存的是编号而非名字。最后只能把输出符号表改成 switch,逐个 case 返回字符串,问题才解决。所以测试时第一时间打印完整符号名,别偷懒看编号。
从个人经验来说,这个实验最值得花时间的不是写代码本身,而是把文法改造、集合计算、预测分析表构造这一整套过程亲手走一遍。代码实现永远是在验证你的理论推导。只要你把语法分析表推导正确了,C 语言代码撑死三百行就够;如果推导错了,无论代码怎么写,结果都不会对。做完这个实验,你对“编译器前端如何从 Token 流构建语法结构”的理解会一下子通透起来,后面做语义分析、中间代码生成都会顺畅不少。
