☰
算符优先分析算法详解:C语言完整实现与工程实践
2026/9/26 12:24:38 网站建设 项目流程

从大二下学期第一次翻开《编译原理》教材开始,“语法分析”这四个字就压得人喘不过气。等学到算符优先分析这一节时,很多人直接在纸上画完FIRSTVT和LASTVT集合就算交差,一到上机实验要用C语言写一个能跑通的分析器,立刻卡壳。这篇文章我想把整个算符优先分析算法从头到尾拆一遍,用C语言完整实现,适合正在做编译原理课程设计、准备考研复试上机,或者单纯想搞清楚“分析表怎么生成、驱动器怎么工作”的读者。我会从最底层的文法设计讲起,一步一步推到终结符优先级表的构建,再给出可运行的分析器核心代码,最后聊聊我在调试过程中踩过的坑。

1. 整体设计与实现思路

1.1 为什么选算符优先分析而不是递归下降

很多人一上手语法分析就想着递归下降,因为每个非终结符写一个函数,思路直观,代码也好读。但递归下降有一个硬前提:文法必须是LL(1)的,也就是说不能有左递归,不能有公共左因子。而教科书里最经典的算术表达式文法E -> E+T | T这种写法,天然就是左递归的,直接写递归下降会死循环。虽然有办法改写文法消除左递归,但改写之后语义动作会变得别扭,运算符的结合性还得额外处理。

算符优先分析走的是另一条路。它不关心非终结符的调用关系,只看终结符之间的优先级。只要能构造出一个“算符优先表”,就能用一个栈加一个输入缓冲区,机械地完成“移进-归约”过程。对表达式这类文法来说,算符优先分析几乎是天然匹配的,代码结构简单,运行效率也高,适合在C语言这种偏底层的环境里实现。当然它也有局限:要求文法里不能出现两个非终结符相邻,也就是所谓的算符文法(OG文法)。大多数表达式文法天然满足这个条件,所以用起来问题不大。

1.2 核心套路:FIRSTVT、LASTVT与三种优先级关系

在动手写代码之前,必须把三个概念吃透,否则后面代码写得再漂亮也是空中楼阁。

  • FIRSTVT(P):非终结符P能够推导出的、出现在最左边的终结符集合。
  • LASTVT(P):非终结符P能够推导出的、出现在最右边的终结符集合。
  • 优先级关系:终结符a和b之间只可能有三种关系,a <· b(a的优先级低于b)、a =· b(a的优先级等于b)、a ·> b(a的优先级高于b)。

判断优先级关系的规则很简单,一共三条:

  1. 如果文法中有产生式形如...aB...,那么对FIRSTVT(B)里的每个终结符b,都有a <· b。直观理解是:a在非终结符B的左边,B推导出的第一个终结符b一定比a更先被处理。

  2. 如果文法中有产生式形如...Ba...,那么对LASTVT(B)里的每个终结符a',都有a' ·> a。意思是:B推导出的最后一个终结符a'一定比右边的a更先被归约。

  3. 如果产生式里有...ab...或者...aBb...,那这两个终结符a和b是“同时出现”的,优先级相等,记作a =· b。

这三条规则就是整个算法的基础。之后的代码里,我会直接用这三个规则生成优先级表,而不是手算填表。手算表格容易漏,尤其文法一大,眼睛看不过来。

2. 文法设计与数据结构准备

2.1 实验文法怎么定

我用一个非常经典的算术表达式文法来做实验:

E -> E + T | T T -> T * F | F F -> ( E ) | i

这里i代表任意整数标识符。这个文法有左递归,不能用于LL(1),但它是标准的算符文法,完全满足算符优先分析的需求。它的FIRSTVT和LASTVT如果手算,结果是这样:

  • FIRSTVT(E) = {+, *, (, i}
  • LASTVT(E) = {+, *, ), i}
  • FIRSTVT(T) = {*, (, i}
  • LASTVT(T) = {*, ), i}
  • FIRSTVT(F) = {(, i}
  • LASTVT(F) = {), i}

这些集合在生成优先级表的时候要用,但我不打算在程序里手工写死,而是用迭代计算的方式自动求出来。这样代码稍微复杂一点,但换文法的时候不用改逻辑,只改产生式数据就行,通用性更强。

2.2 C语言里的数据结构设计

C语言没有现成的map、set容器,所以所有集合运算都得自己用数组模拟。我在实现里做了这么几件事:

  • 终结符和非终结符统一用枚举类型表示,方便在二维数组里做下标索引。
  • FIRSTVT和LASTVT用二维布尔数组表示,set[i][j]为1代表终结符j属于非终结符i的FIRSTVT集合。
  • 优先级表用二维字符数组,precTable[i][j]存的是字符'<'、'>'、'=',或者0表示无关系。

先定义符号类型:

#define MAX_TERM 16 #define MAX_NONTERM 16 #define MAX_PROD 32 #define MAX_RHS_LEN 8 typedef enum { SYM_EOF = 0, // # 结束符 SYM_PLUS = 1, SYM_STAR, SYM_LPAREN, SYM_RPAREN, SYM_ID, SYM_E, SYM_T, SYM_F, SYM_NONE = -1 } Symbol;

这里把#也当作终结符放进表里,用于标记输入串的结束。分析栈初始状态是#,输入串结束也是一个#。这样做的好处是,所有的比较逻辑统一处理,不需要在代码里单独判断栈是否为空、输入是否读完。

产生式我用结构体数组存:

typedef struct { char lhs; // 左部非终结符 Symbol rhs[MAX_RHS_LEN]; int rhsLen; } Production;

注意C语言里char类型可以直接和枚举值互相赋值,但为了可读性,我建议把lhs也直接用Symbol类型。实际代码里我用了Symbol,省去转换的麻烦。

2.3 产生式初始化

把上面的算术表达式文法翻译成产生式数组:

void initProductions(Production prods[], int *count) { // E -> E + T prods[0].lhs = SYM_E; prods[0].rhs[0] = SYM_E; prods[0].rhs[1] = SYM_PLUS; prods[0].rhs[2] = SYM_T; prods[0].rhsLen = 3; // E -> T prods[1].lhs = SYM_E; prods[1].rhs[0] = SYM_T; prods[1].rhsLen = 1; // T -> T * F prods[2].lhs = SYM_T; prods[2].rhs[0] = SYM_T; prods[2].rhs[1] = SYM_STAR; prods[2].rhs[2] = SYM_F; prods[2].rhsLen = 3; // T -> F prods[3].lhs = SYM_T; prods[3].rhs[0] = SYM_F; prods[3].rhsLen = 1; // F -> ( E ) prods[4].lhs = SYM_F; prods[4].rhs[0] = SYM_LPAREN; prods[4].rhs[1] = SYM_E; prods[4].rhs[2] = SYM_RPAREN; prods[4].rhsLen = 3; // F -> i prods[5].lhs = SYM_F; prods[5].rhs[0] = SYM_ID; prods[5].rhsLen = 1; *count = 6; }

这里注意:为了后面归约时方便对照产生式编号,我特意让每个产生式都保留下标。归约到E时,如果句柄是E+T就归约为E,如果句柄就是T也是归约为E,但产生式编号不同。如果要做语义分析,这个编号能帮你确定该执行哪条语义动作。

3. FIRSTVT与LASTVT的自动生成

3.1 集合计算的迭代思路

FIRSTVT的计算规则有两条:

  • 如果有产生式P -> a...或者P -> Qa...,也就是右部第一个符号或第二个符号是终结符,那a属于FIRSTVT(P)。
  • 如果有产生式P -> Q...,那FIRSTVT(Q)的所有元素都属于FIRSTVT(P)。

这一条其实是“传递”关系:P能推导出Q开头的东西,那Q开头能出现的终结符,P也能出现。

LASTVT的计算对称:

  • 如果有产生式P -> ...a或者P -> ...aQ,那a属于LASTVT(P)。
  • 如果有产生式P -> ...Q,那LASTVT(Q)的所有元素都属于LASTVT(P)。

因为集合之间会互相传递,C语言里没有不动点运算的现成库,所以我用了一个while循环:每次遍历所有产生式,能加就加,直到某一轮没有任何新增为止。这个做法虽然笨,但对于这么小的文法来说,计算量可以忽略不计,一般四五轮就收敛了。

void computeFIRSTVT(int firstvt[][MAX_TERM], int *changed) { // 初始化已经为0,这里只做增量计算 for (int p = 0; p < prodCount; p++) { Symbol *rhs = prods[p].rhs; int len = prods[p].rhsLen; // 规则1:P -> a... if (len >= 1 && isTerminal(rhs[0])) { if (!firstvt[prods[p].lhs][rhs[0]]) { firstvt[prods[p].lhs][rhs[0]] = 1; *changed = 1; } } // 规则1扩展:P -> Qa... if (len >= 2 && isNonTerminal(rhs[0]) && isTerminal(rhs[1])) { if (!firstvt[prods[p].lhs][rhs[1]]) { firstvt[prods[p].lhs][rhs[1]] = 1; *changed = 1; } } // 规则2:P -> Q... if (len >= 1 && isNonTerminal(rhs[0])) { Symbol Q = rhs[0]; for (int t = 0; t < TERM_COUNT; t++) { if (firstvt[Q][t] && !firstvt[prods[p].lhs][t]) { firstvt[prods[p].lhs][t] = 1; *changed = 1; } } } } }

注意这里TERM_COUNT指的是终结符的数量,包括#在内。isTerminal和isNonTerminal我自己用枚举值范围判断的,代码里就简单写一下:

int isTerminal(Symbol s) { return s >= SYM_EOF && s <= SYM_ID; } int isNonTerminal(Symbol s) { return s >= SYM_E && s <= SYM_F; }

在实际工程里,建议把终结符和非终结符放入两个独立的枚举范围,中间留一个边界值,这样判断逻辑扩展起来更安全。

3.2 为什么不能直接手算填表

我知道有人会说,就这么几个产生式,FIRSTVT手算几分钟就出来了,写个自动计算是不是过度设计?这里我想多说两句。如果你只是为了交一次作业,手算填表确实更快。但算符优先分析器的核心不只是这一张表,后面你还可能扩展文法,加一个幂运算、加一个单目负号,甚至加一个数组下标访问。一旦文法变复杂,手算集合就特别容易漏边界情况,比如一个非终结符出现在右部最右侧但前面还有别的非终结符,这种组合在纸上很容易看漏。

用程序自动计算集合还有一个好处:你可以把输出打出来和自己手算的结果比对,一旦发现不一致,就说明你对文法规则的理解有偏差。这比直接抄答案然后程序跑错了再回头debug要轻松得多。所以我还是建议用代码生成,哪怕最终代码里跑一次就把结果打印出来,不再参与运行。

3.3 LASTVT怎么生成

LASTVT的计算逻辑和FIRSTVT完全对称,实现的时候我单独写了一个函数。核心就两条:

void computeLASTVT(int lastvt[][MAX_TERM], int *changed) { for (int p = 0; p < prodCount; p++) { Symbol *rhs = prods[p].rhs; int len = prods[p].rhsLen; // 规则1:P -> ...a if (len >= 1 && isTerminal(rhs[len - 1])) { if (!lastvt[prods[p].lhs][rhs[len - 1]]) { lastvt[prods[p].lhs][rhs[len - 1]] = 1; *changed = 1; } } // 规则1扩展:P -> ...aQ if (len >= 2 && isNonTerminal(rhs[len - 1]) && isTerminal(rhs[len - 2])) { if (!lastvt[prods[p].lhs][rhs[len - 2]]) { lastvt[prods[p].lhs][rhs[len - 2]] = 1; *changed = 1; } } // 规则2:P -> ...Q if (len >= 1 && isNonTerminal(rhs[len - 1])) { Symbol Q = rhs[len - 1]; for (int t = 0; t < TERM_COUNT; t++) { if (lastvt[Q][t] && !lastvt[prods[p].lhs][t]) { lastvt[prods[p].lhs][t] = 1; *changed = 1; } } } } }

外层我用了一个do-while,持续调用computeFIRSTVT和computeLASTVT,直到某轮两个函数都没有改动任何数据:

int changed = 1; while (changed) { changed = 0; computeFIRSTVT(firstvt, &changed); computeLASTVT(lastvt, &changed); }

这个写法虽然有点糙,但胜在简单直观。如果你有兴趣,也可以用链式传递的思想把集合做成邻接表,用Warshall算法求闭包,但在这个实验规模下没必要。

4. 算符优先表的构建

4.1 把三条规则翻译成代码

有了FIRSTVT和LASTVT,优先级表的生成就是纯机械操作了。遍历每一条产生式,逐个检查右部符号串,找到终结符和非终结符的组合,然后按照规则填表。

规则一:形如...aB...的,a是终结符,B是非终结符,那么对FIRSTVT(B)里每个终结符b,填precTable[a][b] = '<'。代码是这样:

for (int p = 0; p < prodCount; p++) { Symbol *rhs = prods[p].rhs; int len = prods[p].rhsLen; for (int i = 0; i < len - 1; i++) { if (isTerminal(rhs[i]) && isNonTerminal(rhs[i + 1])) { Symbol a = rhs[i]; Symbol B = rhs[i + 1]; for (int b = 0; b < TERM_COUNT; b++) { if (firstvt[B][b]) { precTable[a][b] = '<'; } } } } }

规则二:形如...Ba...的,B是非终结符,a是终结符,则对LASTVT(B)里每个终结符a',填precTable[a'][a] = '>'。

规则三:形如...ab...或...aBb...的,直接填precTable[a][b] = '='。注意优先级相等的最高优先级处理:如果同一个位置在规则一二时已经填了其他符号,一般以规则三为准?其实在合法的算符文法里这些关系不应该冲突。我在代码里加了一个检查:如果新写入的关系和已有关系不同,就打印警告。这个检查对于排查文法设计错误非常有帮助。比如你写了一个二义性文法,很可能出现同一个格子既要求'<'又要求'>'的情况。

最后别忘了设置#和其他终结符的边界关系。标准做法是:

  • 对栈底符号#,任何终结符都比它优先级高,所以如果某个终结符b在输入串中,且此时栈里只有#,那么precTable['#'][b]应该为'<',表示移进。
  • 对输入串结束符#,任何栈顶终结符a遇到#都应该归约,precTable[a]['#']='>'。
  • 归约到栈里只剩#和某个非终结符,输入也结束时,接受。

这些边界关系要不要在表里显式生成,取决于你的分析器怎么写。我建议直接在表初始化时把#这一行一列的特殊关系全填好,这样驱动器不用特殊处理边界。

4.2 优先级表打印出来长什么样

写完生成逻辑后,我打印了这个文法的优先级表:

关系+*()i#
+·><·<··><··>
*·>·><··><··>
(<·<·<·=·<·无
)·>·>无·>无·>
i·>·>无·>无·>
#<·<·<·无<·无

这个表里最容易被问到的就是左括号和右括号那一对,precTable['('][')']='=',这个等于关系来自产生式F -> ( E )里的“( E )”结构,两个终结符中间隔了一个非终结符E,它们优先级相等。分析过程中碰到这个关系,意味着括号内部的内容已经处理完毕,可以进行一次归约。

有一行有意思的关系是precTable['i']['('],按理说i后面跟一个左括号,在标准文法里是非法输入,但我在表里填的是“无关系”。分析器运行时如果碰到“无关系”的情况,应该直接报语法错误。这个设计是有意为之,目的就是让错误检测尽量提前,不要等到栈已经乱了才暴露问题。

5. 主分析驱动器的C语言实现

5.1 分析栈和输入缓冲区怎么设计

算符优先分析器本质上是一个下推自动机。我用了两个数组分别模拟分析栈和输入串:

#define STACK_CAP 64 Symbol stack[STACK_CAP]; int top = 0; // 指向栈顶元素 char input[MAX_INPUT_LEN]; int ip = 0; // 输入指针

初始化时stack[0] = SYM_EOF(也就是#),top = 0。输入串处理成终结符序列,最后补一个SYM_EOF。

这里有一个很关键的实现细节:分析栈里存的符号不光是终结符,还会有非终结符。算符优先分析在比较优先级的时候,只看栈里“最靠近栈顶的终结符”。为什么?因为栈顶的非终结符只是一个已经被归约出来的结构,并不参与算符优先比较。比如栈里的内容从底到顶是“# E + T”,最靠近栈顶的终结符是+,下一个输入符号是*,我们要比的是precTable['+']['*']。

所以在C语言里,我写了一个函数专门向上扫描,找到最近的一个终结符:

Symbol topTerminal(Symbol stack[], int top) { for (int i = top; i >= 0; i--) { if (isTerminal(stack[i])) return stack[i]; } return SYM_NONE; }

这个函数在每一次“移进-归约”决策前都要调用,是整个驱动器最核心的辅助函数。

5.2 移进和归约的循环逻辑

主循环的伪代码逻辑如下:

  1. 取当前输入符号b。
  2. 找到栈内最靠近栈顶的终结符a。
  3. 查表比较a和b的关系。
  4. 如果a <· b或者a =· b,执行移进:把b压入栈顶,输入指针前进。
  5. 如果a ·> b,执行归约:从栈顶开始找“最左素短语”的边界,找到之后用产生式左部非终结符替换这一段,输入指针不动。
  6. 如果查表结果是“无关系”,报错。
  7. 如果栈内符号是“# E”且输入是#,报告“接受”。

归约时怎么找句柄边界?算符优先分析里有一个标准做法:从栈顶往下找终结符,当栈内某个终结符的优先级低于它左边那个终结符时,这个位置就是句柄的起点。具体来说,我们从栈顶向下扫描,记录遇到的终结符位置j,如果precTable[stack[j-1]的最近终结符][stack[j]]='<',那j就是句柄起点。这个操作在教科书上叫“找最左素短语”。

代码我这样实现的:

int findHandleEnd() { // 从栈顶向下扫描,返回句柄的左边界下标 int j = top; while (j > 0) { Symbol t1 = stack[j]; if (isNonTerminal(t1)) { j--; continue; } // 找到终结符t1,再看左边离它最近的终结符t0 Symbol t0 = SYM_NONE; for (int k = j - 1; k >= 0; k--) { if (isTerminal(stack[k])) { t0 = stack[k]; break; } } if (t0 == SYM_NONE) break; if (precTable[t0][t1] == '<') break; j--; } return j; }

找到句柄左边界j之后,从j到top这一段就是待归约的句柄。用这个句柄去匹配产生式右部,找到匹配的产生式后,把栈从j到top全部弹出,再压入该产生式的左部非终结符。

匹配产生式的时候要注意,句柄里既有终结符也有非终结符,比如“T * F”就是一个三段结构。我写了一个逐个符号比较的函数:

int matchProduction(int j) { int len = top - j + 1; for (int p = 0; p < prodCount; p++) { if (prods[p].rhsLen != len) continue; int ok = 1; for (int k = 0; k < len; k++) { if (stack[j + k] != prods[p].rhs[k]) { ok = 0; break; } } if (ok) return p; } return -1; }

这里有一个常见的坑:栈里可能存在两个不同的符号序列同时匹配多个产生式,比如E -> T和T -> F这种单非终结符归约。因为算符优先分析每次归约的句柄只对应一个素短语,理论上不会出现二义。但如果你在调试时发现归约后栈内容不对劲,优先检查是不是句柄边界没找对,把一些明明还应该继续移进的符号也当成句柄归约掉了。

5.3 完整主循环代码示例

把上面的模块拼起来,主循环大概是这个样子:

void analyze(char *tokenStream) { top = 0; stack[0] = SYM_EOF; ip = 0; strcpy(input, tokenStream); // 确保输入串以#结尾,这里假设tokenStream已经包含SYM_EOF while (1) { Symbol b = input[ip]; Symbol a = topTerminal(stack, top); char rel = precTable[a][b]; if (rel == '<' || rel == '=') { // 移进 top++; stack[top] = b; ip++; } else if (rel == '>') { // 归约 int j = findHandleEnd(); int p = matchProduction(j); if (p < 0) { printf("语法错误:无法匹配句柄\n"); return; } top = j - 1; top++; stack[top] = prods[p].lhs; } else { printf("语法错误:终结符关系缺失 a=%d b=%d\n", a, b); return; } // 判断接受 if (top == 1 && stack[top] == SYM_E && b == SYM_EOF) { printf("分析成功\n"); break; } } }

这段代码里我刻意省去了对栈溢出的检查,但实际使用的时候建议加一个top >= STACK_CAP的判断,报“栈溢出”。因为在某些错误句柄的输入下,分析器会持续移进而不归约,栈很容易爆掉。我一开始没加这个检查,结果调试一个括号不匹配的用例时,程序直接崩了,半天没找到原因。

5.4 词法分析怎么对接

算符优先分析器的输入不能是一串原始字符,你需要先在前面做一个词法分析,把数字、标识符、运算符和括号都转成终结符序列。我这个实验里的终结符很简单,正整数字面量、+、*、括号,所以词法分析函数可以写得非常精简:

void lex(const char *src, Symbol *tokens, int *len) { int i = 0, n = 0; while (src[i] != '\0') { if (src[i] == ' ') { i++; continue; } if (src[i] >= '0' && src[i] <= '9') { while (src[i] >= '0' && src[i] <= '9') i++; tokens[n++] = SYM_ID; continue; } switch (src[i]) { case '+': tokens[n++] = SYM_PLUS; break; case '*': tokens[n++] = SYM_STAR; break; case '(': tokens[n++] = SYM_LPAREN; break; case ')': tokens[n++] = SYM_RPAREN; break; default: printf("非法字符: %c\n", src[i]); exit(1); } i++; } tokens[n++] = SYM_EOF; *len = n; }

注意数字串我只识别成“整数”,没有做类型区分。如果你想支持小数、负数或者变量名,词法分析需要扩展,但语法分析这部分不需要改任何东西,因为对所有标识符,算符优先分析只关心它属于终结符“i”,不关心它的具体值。这个特性让语法分析阶段和词法分析阶段解耦得很干净。

6. 测试用例与常见问题排查

6.1 正常表达式的分析过程演示

我用“i+i*i”这个输入串跑一遍分析器,打印每一步的栈和输入缓冲区,过程大致如下:

步骤栈内容输入剩余动作
1#i+i*i#移进i
2#i+i*i#归约F->i
3#F+i*i#归约T->F
4#T+i*i#归约E->T
5#E+i*i#移进+
6#E+i*i#移进i
7#E+i*i#归约F->i
8#E+F*i#归约T->F
9#E+T*i#移进*
10#E+T*i#移进i
11#E+T*i#归约F->i
12#E+T*F#归约T->T*F
13#E+T#归约E->E+T
14#E#接受

这个序列是算符优先分析的典型过程。你可以看到,i第一次出现时,没有立刻归约成F吗?不对,实际上i会在遇到更高优先级符号时归约。在第2步到第3步之间,栈里的“#i”优先级关系是i ·> +,所以i先归约成F,再一路归约到E。等遇到*时,因为+ <·,说明的优先级更高,所以不会把E+T归约成E,而是继续移进。这就是算符优先分析能够正确处理运算优先级的关键。

6.2 错误输入会怎样

我测试了一个非法输入“i+i”,分析器在读到时,栈里是“# E +”,输入符号是*。查表发现precTable['+']['']='<',于是把移进。接着栈变成“# E +”,输入是i,查表precTable['']['i']='<',继续移进i。此时栈是“# E + * i”,输入是#,归约i为F,再归约T->F,再归约T->T*F?这里问题就来了:栈里是“# E + T”,输入是#,查表precTable['T'最近的终结符+]['#']='>',于是开始找句柄。找句柄时发现“E + T”是一个潜在句柄,于是归约成E,最终栈是“#E”,居然“接受”了。

这听起来很荒谬,但实际就是算符优先分析的一个天然缺陷:它能识别出“块”,但无法表达“T前面必须有E或者T”这种结构约束。所以对于i+*i这种非法输入,它可能走上一条错的归约路并“假接受”。真实编译器会怎么做?它会在语义分析阶段发现E+T的中间缺了一个T,或者归约时检查产生式匹配失败,然后报错。我在matchProduction函数里,如果找不到匹配的产生式就会报错,但上面这个流程里存在匹配路径,所以会蒙混过关。

要解决这个问题,有两条路。一是严格检查算符优先表,在“无关系”的情况下直接报错。二是配合一个独立于语法分析之外的类型检查或语义检查,把这种语法上“看似合理但结构非法”的输入拦截掉。在课设答辩时,能把这一点讲清楚,往往比多跑几个正确用例更让老师眼前一亮。

6.3 常见报错和调试技巧汇总

我在写这个实验的过程中,整理了几个特别容易踩的坑,放在这里供大家参考:

  1. 句柄边界找错了。最典型的表现是归约完成之后栈里出现“# E T”这种连续两个非终结符相邻的结构。算符文法要求任何产生式右部都不能有两个非终结符相邻,所以一旦出现,说明你的句柄边界找过头了,把不该归约的符号卷进去了。

  2. 优先级表隔三岔五出现“无关系”导致误报。这个问题多出在手工填表的情况。一个容易漏的地方是F -> ( E )里的等于关系,以及#在边界上的小于/大于关系。用自动生成代码的话,几乎不会漏,但我建议生成之后把表打印出来人工检查一遍,验证一下是否符合直觉。

  3. 输入串末尾的#被误认为是普通终结符。如果你在词法分析时把#当成了文本的一部分,分析器会把#塞进栈里,导致永远无法触发接受条件。我在设计里让lex函数在链接完所有符号后,主动追加一个SYM_EOF,这样分析器就不会看到用户输入的原始#。

  4. 栈溢出问题。前文已经提过,括号不匹配或者表达式过长时,移进路径过长,栈会爆炸。调试时先加一个栈深度的打印输出,看看是哪一个环节一直在移进,大概率能迅速定位到缺失的归约条件。

  5. C语言里枚举值和数组下标混用时容易越界。如果你把非终结符也放进终结符的优先级表里做索引,就会读出乱七八糟的值。我在代码里用isTerminal和isNonTerminal做严格区分,这样即使发生下标越界,也至少能快速定位到是哪个调用点传入的类型错了。

6.4 如何把分析器扩展成带语义动作的版本

如果真的想做一个小型计算器,可以在归约的时候根据产生式编号执行对应的语义动作。比如产生式E -> E + T归约时,从栈里弹出三个符号,取第一个E的值和第三个T的值,做一次加法,把结果压回去。配合一个值栈valStack,和符号栈同步操作,C语言实现起来并不复杂。

我提供一个简单的思路:

double valStack[STACK_CAP]; // 在归约后,根据产生式编号p计算: switch (p) { case 0: // E -> E + T valStack[j-1] = valStack[j] + valStack[top]; // 简化示意 break; case 2: // T -> T * F valStack[j-1] = valStack[j] * valStack[top]; break; }

需要注意的是,单非终结符产生式如E -> T,不需要额外的运算,直接继承T的值即可。这里最烦的是下标管理,因为有一个非终结符压栈,意味着符号栈和值栈的pop/push要严格同步。我在调试时反复因为下标错位导致值算错,最后干脆封装了一个popValue/syncStack的小函数,才把问题理顺。

我个人在实际操作中的体会是,算符优先分析虽然看起来只是编译原理教材里的一小节课,但它能把“自底向上分析”“优先级关系”“移进-归约”这些抽象的概念全部落地到代码里。而且用C语言实现有它独特的价值:你需要真正理解指针、数组、内存布局和边界条件,没有任何容器帮你擦屁股。很多同学第一次跑通分析器的时候,都会有一种“原来语法制导翻译真的能跑”的感觉。如果你也想提高自己的编译原理实战能力,我强烈建议不要用Python的字典和列表偷懒,老老实实用C语言把FIRSTVT、LASTVT、优先级表、驱动器全部推一遍,痛苦一次,后面再写LR分析器都会顺手很多。

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

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

立即咨询