简介:这是一份编译原理课程中语法分析程序设计与实现的完整实验报告文档,面向高校计算机专业学生及自学编译原理的开发者,帮助掌握LL(1)、递归下降、算符优先等典型语法分析方法。文档覆盖实验目的、内容要求、实验步骤、问题分析及源程序、结论等完整环节,以算术表达式简化子集为分析对象,给出BNF定义与LL(1)文法改写过程,并通过预测分析表展示C++实现细节,含分析栈、剩余串数据结构与完整可运行源码。压缩包内共1个doc文档,127KB,内容与实验报告结构一一对应,可直接参照实现或作为课程设计提交材料。目前已有143位学习者浏览下载,适合正在完成词法分析实验、需要语法分析对照参考的学生使用。
1. 这份语法分析实验资源:从单词串到RIGHT/ERROR的完整LL(1)实现
写词法分析器还能照着正则硬刚,一到语法分析,不少人就卡住了:面对UCON, PL, UCON, MU, ID这样的单词序列,怎么判断它是不是一个合法的算术表达式?递归下降想用,却发现文法里有左递归,写了没几步就开始绕圈。这份以《实验二 语法分析程序设计与实现》为名的doc资源,给的就是一条省力路线:把原始BNF改写成LL(1)文法,构造预测分析表,用C++写一个表驱动分析程序,输入字符串后输出RIGHT或ERROR,并打印每一步的分析栈和剩余串。它不只是实验报告,里面还有完整可编译的C++源码、分析表填充逻辑和运行结果,适合正在做编译原理课程实验、想快速吃透LL(1)表驱动原理的人直接借鉴改动。
2. 为什么选LL(1):左递归消除与FIRST/FOLLOW手工推导
2.1 原始BNF不能直接上递归下降的两个硬伤
把这份资源的分析对象摊开看,它的语法核心是:
E → T | E+T | E-T T → F | T*F | T/F F → i | (E)
i代表运算对象(无符号常数或变量),也就是说拿到的是词法分析器输出的单词类别。这份文法描述算术表达式很直观,但直接拿去做自顶向下分析会碰两个硬伤。首先是左递归:E → E+T | E-T的第一个候选左边就是E自己,递归下降遇到E会先调用E,一路下去永远不消耗输入,程序直接爆栈。其次是公共前缀:T → T*F | T/F两个候选都以T开头;F → i | (E)还好区分,但T那两个候选在向前看一个符号时根本没法决定走哪条路。
遇到这种文法,两条路可以走。一条是换自下而上的SLR(1)或LR(1),左递归对它们来说不是问题,但分析表构造复杂,手工算项目集一族能把人算到怀疑人生。另一条就是这份资源选择的LL(1):先把文法改写成无左递归、无公共前缀的形式,再手工构造一个很小的预测分析表,用表驱动去分析。对想尽快把实验跑通的人来说,LL(1)的优点是表规模小、出错定位直观、每一步分析过程都能打印出来对答案。这也是常见的编译原理课程实验二默认路线,因为它在"能实现"和"能讲清楚原理"之间取得了一个平衡。
2.2 改写后文法:E→TG、G→+TG|-TG|ε、T→FS、S→*FS|/FS|ε
原始的G2文法不是LL(1)文法,所以要看懂这份资源,第一步不是读C++代码,而是看它是怎么改写的。文档里给出了改写后的产生式集合:
E → TG G → +TG | -TG | ε T → FS S → *FS | /FS | ε F → (E) | i
这里的含义和原始BNF一一对应:E还是算术表达式,G专门吃掉跟在项后面的加减号以及后续项,T对应项,S吃掉乘除号以及后续因式,F对应因式。E→TG的意思是表达式先是一个项,后面可能跟一个G(加减尾巴);T→FS同理,项先是一个因式,后面可能跟S(乘除尾巴)。这样改写以后,任何产生式右侧开头的第一个符号都是终结符,从这里看已经有了LL(1)的样子。
我一般会提醒别人先别急着写程序,把改写后的每个非终结符代表什么写在旁边。G、S是这次改写里新增的两个"尾巴非终结符",它们的出现就是为了替代原来的左递归结构。原始文法的E → E+T | E-T,被改成了E → TG、G → +TG | -TG | ε,意思是一个表达式要么没有加减运算,要么在加减号后面又递归地接一个表达式尾巴。同理T → T*F | T/F改写成T → FS、S → *FS | /FS | ε。括号的优先级则由F → (E)保证,遇到左括号递归进入E,右括号在G以及S的ε产生式中被吸收。
2.3 FIRST/FOLLOW手工推导:分析表构造的输入
预测分析表不是拍脑袋填的,它的每一项都来自FIRST集和FOLLOW集。文档源码里直接给出了填好的表,但如果你想自己验算、或者改文法之后重新填表,就必须会推导这两个集合。我按常规算法手工推一遍,把结果列在下面,你可以对照第3章的分析表核对:
| 非终结符 | FIRST集 | FOLLOW集 |
|---|---|---|
| E | {i, (} | {), #} |
| G | {+, -, ε} | {), #} |
| T | {i, (} | {+, -, ), #} |
| S | {*, /, ε} | {+, -, ), #} |
| F | {i, (} | {+, *, /, -, ), #} |
推导时几个容易漏的点值得单独说。FIRST(E)不用算尾巴,只看E → TG,而T → FS、F → (E)|i,所以FIRST(E)=FIRST(T)=FIRST(F)={i, (}。G因为有ε,FOLLOW(G)要跟着FOLLOW(E)走,因为G出现在E → TG的末尾;E作为开始符号,FOLLOW(E)先有#,再由F → (E)产生右括号。T后面可以跟G(当G为ε时则跟FOLLOW(G)),所以FOLLOW(T)=FIRST(G){ε} ∪ FOLLOW(G)={+, -, ), #}。S的情况和T类似,FOLLOW(S)=FOLLOW(T)。F后面跟S时,S可为ε,所以FOLLOW(F)要把FIRST(S){ε}和FOLLOW(S)都收进来,最后是{+, *, /, -, ), #}。这里最容易翻车的是把FOLLOW(S)漏掉#,结果分析表在遇到#列时查不到S → ε,合法句子i#都会被判错。
把FIRST和FOLLOW推导出来之后,填表规则就一句话:对产生式A → α,若终结符a属于FIRST(α),则M[A,a]=A→α;若ε属于FIRST(α),则对b属于FOLLOW(A),M[A,b]=A→ε。这两条规则覆盖了后面所有填表逻辑,第3章我会按资源里的数组实际填一遍。
3. 预测分析表的构造与表驱动算法:矩阵里每一格都是一次决策
3.1 分析表的数据组织:行是非终结符、列是终结符
这份资源里的预测分析表用C[10][10]二维数组存储,行索引对应非终结符集合{v2}={E,G,T,S,F},列索引对应终结符集合{v1}={i,+,-,*,/,(,),#}。每一格存的是一个产生式对象,也就是class type的实例:origin保存产生式左部的大写非终结符,array保存产生式右部字符序列,length保存右部长度。查表时先按栈顶非终结符定位行号m,再按当前输入符号定位列号n,取C[m][n],如果origin是初始化时赋的'N',就代表该表项为空、应当报错。
用char数组而不是string,是这份老式C++代码的一个特点。它把字符逐个存进A[30]分析栈和B[30]剩余串,操作直观、入栈就是A[++top]=某个字符,出栈就是x=A[top--],打印分析栈时直接for循环输出数组前top+1个字符。缺点也明显:长度写死30,输入串一长就溢出了。我自己复现时会保留这个设计,因为实验要求输出每一步分析过程,字符数组在打印上非常省事,不用反复做迭代器操作。
3.2 手工填出的LL(1)分析表:与源码逐格对照
按2.3的FIRST/FOLLOW和填表规则,把资源里的C[10][10]填充逻辑还原成表格,行与列顺序和源码完全一致:
| 非终结符\终结符 | i | + | - | * | / | ( | ) | # |
|---|---|---|---|---|---|---|---|---|
| E | E→TG | 空 | 空 | 空 | 空 | E→TG | 空 | 空 |
| G | 空 | G→+TG | G→-TG | 空 | 空 | 空 | G→ε | G→ε |
| T | T→FS | 空 | 空 | 空 | 空 | T→FS | 空 | 空 |
| S | 空 | S→ε | S→ε | S→*FS | S→/FS | 空 | S→ε | S→ε |
| F | F→i | 空 | 空 | 空 | 空 | F→(E) | 空 | 空 |
对照源码看一遍:C[0][0]=e表示E行i列填E→TG,C[0][5]=e表示E行(列也填E→TG,因为FIRST(TG)包含i和(;G行只有+、-列有产生式,而遇到)和#时因为G→ε、且)和#都在FOLLOW(G)里,所以写G→ε,其余列全是空。T行和E行结构一样,都是i、(列填T→FS。S行最特别,列填S→FS,/列填S→/FS,遇到+、-、)、#时全部填S→ε,因为FOLLOW(S)包含这四个终结符。F行i列填F→i,(列填F→(E)。
这个表有个值得注意的点:S行的ε产生式覆盖了四个列,如果填表时漏了其中任何一列,遇到i+i#这样的句子,栈顶S碰上输入+时查不到表项,就会报错。这就是为什么我强调先手工推导FOLLOW再填表,源码里的g2、s2对象第一次看可能觉得重复,实际上它们就是在给ε产生式的不同触发列建立同一个产生式对象,用origin='N'之外的表项来区分"可接受"和"出错"。
3.3 表驱动主循环:查表、逆序入栈、匹配
预测分析表的执行机制,概括成一句话:栈里存的是推导的预期,输入串是待匹配的事实,每一步都用预期和事实去查表,然后让推导继续展开。算法的骨架如下:
// 表驱动主循环骨架 初始化: 分析栈A:先压'#',再压'E' 剩余串B:待分析字符串 + '#',当前位置b=0 重复: x = A[top] // 看一眼栈顶(不急着弹出) ch = B[b] // 当前输入符号 if x是终结符: if x == '#' and ch == '#': 接受,输出"acc!" elif x == ch: 弹出x;b指向下一个输入符号 else: 报错"栈顶终结符x与输入ch不匹配" else: 查表得到产生式 cha = C[m][n] if 表项为空: 报错 else: 弹出x 将产生式右部逆序压栈 如果右部是ε,不压栈这里有个常见误解:很多人以为每次分析都要"先看栈顶是不是终结符、是的话直接比较",其实在查表之前必须区分栈顶是终结符还是非终结符。源码里用一个flag标志位实现:for循环遍历v1终结符数组,如果x匹配上了某个终结符,flag=1;如果循环完flag还是0,说明x是非终结符。这个判断在每一轮循环的开始处都会执行,是表驱动分析器的核心分流逻辑。
入栈的顺序值得单独说:产生式右部是正着写的(比如+TG),但压栈时必须倒着压,先压G再压T最后压+,这样下次弹出时才能先弹出最左边的符号。源码里for(j=cha.length-1; j>=0; j--) A[++top]=cha.array[j]做的就是这件事。遇到ε产生式时,源码的写法是先把'^'字符压进栈,再判断A[top]=='^'就把top减回去,等效于不压栈。这个'^'占位符在代码里是字符串操作的妥协,也容易引出第5章要说的bug。
4. 把这份C++源码跑起来:核心数据结构和主循环逐段读
4.1 全局定义:分析栈、剩余串、产生式结构体
这部分代码块的源文本我重新整理为清晰可编译的C++形式,保留了原资源的判断逻辑:
#include <iostream> #include <cstdlib> #include <cstring> using namespace std; char A[30]; // 分析栈 char B[30]; // 剩余输入串 char v1[20] = {'i', '+', '-', '*', '/', '(', ')', '#'}; // 终结符 char v2[20] = {'E', 'G', 'T', 'S', 'F'}; // 非终结符 int b = 0, top = 0, L; // b:输入位置 top:栈顶指针 L:输入串长度 struct type { char origin; // 产生式左部非终结符 char array[5]; // 产生式右部字符序列 int length; // 右部长度 }; type e, t, g, g1, g2, s, s1, s2, f, f1; type C[10][10]; // 预测分析表A和B都固定30长度,对应实验输入串的规模是够用的,但如果你要测长表达式,记得同步放大数组长度。v1的列顺序不是随意的,它决定了后面查表时n的计算;v2的行顺序同理。如果你要加一个新的终结符(比如支持取模运算%),必须在v1里同步插入、并把C数组的列填充代码全部平移,否则行号列号对不上,查出来的表项全是乱的,这类问题最难排查。
4.2 产生式赋值与分析表填充:ε怎么表达
把文法产生式逐条存进type对象,再往C[10][10]里填。源资源的写法值得保留的是用'^'这个字符表示ε,因为strcpy在部分老编译器上遇到空串有边界问题,但这里处理起来处处要记得排除'^':
void initProduction() { e.origin = 'E'; strcpy(e.array, "TG"); e.length = 2; t.origin = 'T'; strcpy(t.array, "FS"); t.length = 2; g.origin = 'G'; strcpy(g.array, "+TG"); g.length = 3; g1.origin = 'G'; strcpy(g1.array, "-TG"); g1.length = 3; g2.origin = 'G'; g2.array[0] = '^'; g2.length = 1; s.origin = 'S'; strcpy(s.array, "*FS"); s.length = 3; s1.origin = 'S'; strcpy(s1.array, "/FS"); s1.length = 3; s2.origin = 'S'; s2.array[0] = '^'; s2.length = 1; f.origin = 'F'; strcpy(f.array, "(E)"); f.length = 3; f1.origin = 'F'; f1.array[0] = 'i'; f1.length = 1; } void fillTable() { for (int m = 0; m <= 4; m++) for (int n = 0; n <= 7; n++) C[m][n].origin = 'N'; // 全部初始化为空表项 C[0][0] = e; C[0][5] = e; // E行: i, ( -> E->TG C[1][1] = g; C[1][2] = g1; // G行: + -> G->+TG, - -> G->-TG C[1][6] = g2; C[1][7] = g2; // G行: ), # -> G->epsilon C[2][0] = t; C[2][5] = t; // T行: i, ( -> T->FS C[3][1] = s2; C[3][2] = s2; // S行: +, - -> S->epsilon C[3][3] = s; C[3][4] = s1; // S行: * -> S->*FS, / -> S->/FS C[3][6] = s2; C[3][7] = s2; // S行: ), # -> S->epsilon C[4][0] = f1; C[4][5] = f; // F行: i -> F->i, ( -> F->(E) }为什么C[1][6]和C[1][7]都填g2?因为G→ε在右括号和#号这两个FOLLOW(G)终结符上都要触发。C[3][1]、C[3][2]、C[3][6]、C[3][7]同理,S→ε一共覆盖四个列。这里最怕的是把S行的+、-列漏掉,因为看起来"乘除尾巴S碰到加减号应该出错",实际上遇到i+i#时,第二个i处理完后栈顶是S,输入指向+,必须靠S→ε把S弹出、暴露出下面的G去处理+,如果S→ε缺失,合法输入直接误杀。
4.3 主循环:弹出、分流、查表、逆序压栈
驱动循环的骨架是所有表驱动分析器的标准结构,我抽出来保留原资源的print逻辑:
void printStack() { for (int a = 0; a <= top + 1; a++) cout << A[a]; cout << "\t\t"; } void printRest() { for (int j = 0; j < b; j++) cout << " "; for (int j = b; j <= L; j++) cout << B[j]; cout << "\t\t\t"; } int main() { // 读入字符串到B,L为长度,例如输入 "i+i*i#" int m = 0, n = 0; char ch = B[0]; A[top] = '#'; A[++top] = 'E'; int step = 0, finish = 0, flag = 0; do { char x = A[top--]; // 弹出栈顶 flag = 0; for (int j = 0; j <= 7; j++) // 判断栈顶是否为终结符 if (x == v1[j]) { flag = 1; break; } if (flag == 1) { if (x == '#') { finish = 1; cout << "acc!" << endl; } else if (x == ch) { printStack(); printRest(); cout << "匹配" << endl; ch = B[++b]; // 读下一个输入符号 } else { printStack(); printRest(); cout << "出错" << endl; exit(1); } } else { for (int j = 0; j <= 4; j++) if (x == v2[j]) { m = j; break; } for (int j = 0; j <= 7; j++) if (ch == v1[j]) { n = j; break; } type cha = C[m][n]; if (cha.origin != 'N') { printStack(); printRest(); cout << cha.origin << "->"; for (int j = 0; j < cha.length; j++) cout << cha.array[j]; cout << endl; for (int j = cha.length - 1; j >= 0; j--) A[++top] = cha.array[j]; if (A[top] == '^') top--; // epsilon不入栈 } else { printStack(); printRest(); cout << "出错" << endl; exit(1); } } } while (finish == 0); return 0; }这个循环里最容易被忽视的是x = A[top--]这一行。它先把栈顶字符赋给x、再递减top,也就是说当x是终结符且匹配成功时,栈已经被弹空了;printStack()打印的是弹出后的栈内容。在对照答案时,你要习惯这个"先弹后打印"的视角。匹配分支里ch = B[++b]让输入指针前移,而printRest()先用b个空格做对齐,保证剩余串和打印列对齐,这在对分析步骤做自动化diff时特别有用。另外printStack()里a <= top + 1是原资源的写法,会多打印一个数组元素,复现时改成a <= top更严谨。
4.4 用测试串过一遍:RIGHT/ERROR怎么输出
文档要求输入串无论对错都有明确输出,这份源码在匹配成功时输出"匹配",全部推导完毕输出"acc!",出错时输出"出错"并退出。我按数组逻辑跑了一个典型例子i+i*i#,前面几步是这样:
| 步骤 | 分析栈 | 剩余串 | 动作 |
|---|---|---|---|
| 0 | #E | i+i*i# | E→TG |
| 1 | #GT | i+i*i# | T→FS |
| 2 | #GSF | i+i*i# | F→i |
| 3 | #GSi | +i*i# | 终结符i匹配 |
| 4 | #GS | +i*i# | S→ε |
| 5 | #G | +i*i# | G→+TG |
| 6 | #GT+ | i*i# | 终结符+匹配 |
后面继续,最终栈里只剩#、输入只剩#时输出acc!。注意第4步,栈顶S遇到输入+时,S先通过ε产生式弹出,把G暴露出来去匹配+,这正是分析表里S行+列要填S→ε的原因。错误的例子比如i+i#,会在T行遇到时查不到表项(T行在*列是空),直接输出"出错",此时分析栈里还有未展开的推导,打印出来的中间结果就是排错依据。
5. LL(1)分析避坑指南:调试这份程序时最常踩的五个坑
把这份程序跑通之后,你会发现LL(1)分析器表面简单,实际调试时出的问题都很一致。下面五条是我自己复现这份源码、以及帮身边的人查错时碰到的典型翻车现场,按现象、原因、解决的顺序说,每一条都能对应一段真实的调试经历。
5.1 输入串里混进终结符集之外的字符,程序直接静默退出
现象:输入一个含数字的真实表达式比如"1+2#",程序没有打印任何分析过程就退出了,或者输出乱码。
原因:源码读入后的校验循环对每个字符逐一比对v1数组,发现非法字符直接exit(1),退出的位置没有做提示输出,用户根本不知道是哪个字符非法。
解决:读入时先记录非法字符的位置,统一在读完一行后输出:"非法字符x出现在第n位",再决定是否退出。校验集合要和v1的列定义保持一致,否则会出现词法上合法、语法表里却查不到的脱节。
5.2 先pop再判断的写法,让报错时的栈状态错位
现象:出错后打印分析栈,发现栈顶符号和触发错误的输入符号对不上,排错时对答案非常痛苦。
原因:x = A[top--]在拿到x的同时递减了top,printStack()只能打印新栈顶,旧栈顶已被丢弃。如果报错点发生在匹配之前,想展示"期望x、实际ch"就无从谈起。
解决:先读取x = A[top]但不递减,等确认匹配或查表成功后,再用top--或top = top - 1去真正弹出。这个改动不影响后续逻辑,却能让每步报错信息都准确反映"当前遇到什么"。
5.3 '^'占位符的入栈再退栈,连续ε时栈指针会退过头
现象:处理i#时,G→ε之后紧接着S→ε,在某些实现里top变成了负数,或者把上一个ε产生式入栈的内容误删。
原因:源码把ε写成一个字符'^',先执行A[++top]='^',再通过if(A[top]=='^') top--还原。如果上一次的'^'还停留在数组里,而top指针因为连续两个ε产生式被反复加减,就会变成-1或者跳过有效栈元素。
解决:在入栈循环之前直接判断产生式右部是否是'^',是的话整段跳过入栈循环,不要用"压入再弹出"的抵消方案。统一用cha.length == 1 && cha.array[0] == '^'作为判断条件,避免把普通字符误判成ε。
5.4 填表时漏掉S行的+、-列,合法句子被判ERROR
现象:i+i#、i-i#这类最简单合法的加减表达式,程序输出"出错"。
原因:分析表里S行只在*、/列填了S→*FS、S→/FS;+、-、)、#四列全部漏填S→ε。而实际上S是T → FS的尾巴,遇到加减号时它必须先消失,才能让G处理加法。
解决:对照第2章的FOLLOW(S)={+, -, ), #},把S→ε同时填进这四列。表填完先跑i#、i+i#、i*(i+i)#三个最小合法用例,任何一个报错,优先检查ε产生式的覆盖列是否完整。
5.5 自己改写文法后FIRST/FOLLOW没重算,表项张冠李戴
现象:把文法改成支持一元负号或幂运算后,填表时沿用资源里的C[10][10]填充代码,只改了几个对象,结果莫名其妙的冲突。
原因:新增产生式会改变多个非终结符的FIRST和FOLLOW。比如加入F→-F后,FOLLOW(F)和FIRST(F)同步变化,S行的ε触发列也要重算。老表项和新文法不匹配,分析表查出来的产生式右部展开后与预期不一致。
解决:无论怎么改文法,都先按2.3的方式手工重推所有FIRST/FOLLOW,再画一张新表,最后逐格和C数组里的赋值对照。我自己的习惯是每次改动都把表打印出来贴在手边,分析步骤一旦卡住,一眼就能看出查的是哪一格、那格该不该有值。
6. 进阶用法:把词法输出接入输入串,再加一个panic-mode错误恢复
6.1 词法输出到终结符串的映射
实验二的要求里明确写到"输入是由实验一输出的单词串",但这份资源演示的是直接输入字符序列。实际接入时,词法分析器输出的通常是(类别码, 属性)二元组,比如(UCON, 值)、(PL, '+')、(ID, 名字)。要做的是把类别码映射到i、+、-、、/、(、)这些分析器终结符上:UCON和ID都映射到i,PL映射到+,MI映射到-,MU映射到,DI映射到/,LP映射到(,RP映射到)。
映射关系可以放在一个数组里,按词法类别码的顺序排好,遍历单词序列时逐词查表、拼成新的输入串,再进第4章的分析循环。这样词法分析和语法分析就是两个独立模块,中间只隔一层映射表,词法改单词编码方式时语法程序一行不用动。我在实际做实验时还会在映射后打印一行中间串,看词法到语法的输入是否正确,这个习惯帮我挡掉了不少"语法报错其实是词法传错"的假故障。
6.2 panic-mode错误恢复:跳过输入直到同步符号
原始程序出错就exit(1),对单次判断够用,但如果是句子中间一个字符错,后面全被跳过,分析过程只能停在第一个错。要做错误恢复,常见的做法是panic-mode:发现表项为空时,把栈顶非终结符弹出,然后不断读输入、丢弃当前符号,直到遇到的符号属于该非终结符的FOLLOW集(或属于#),再继续正常分析。同步符号集合可以选FOLLOW(A) ∪ {#},因为FOLLOW集里的符号正是"这个非终结符应该在此结束"的位置。
实现上就是改掉5.3后面那个出错处理的exit(1)分支,替换成一段循环:while当前输入ch不在同步集合里,b++读下一个符号;然后continue回到主循环顶部。这样整个分析能延续到最后,即使有错也能把后续的错误一并报出来,对"不论对错都给出明确信息"的实验要求是更好的匹配。注意同步符号里要同时包含#,否则错误一直延续到串尾时可能越界访问B数组。
6.3 完整的验证习惯
拿到这份资源后,我建议你先不急着跑课设代码,而是按下面的用例清单过一遍程序:合法串i#、i+i#、i*(i+i)#、i+i*i#必须全部输出RIGHT或acc!;非法串+i#、i+*i#、i##、((i#必须全部输出ERROR,其中i##和((i#能考察G行、S行、F行在边界列的表项是否正确。这个清单既是验证手段,也是改文法后的回归用例。从那以后我每次做语法分析实验都强制先跑这八个用例,再动手改功能,省下的调试时间远比跑用例花掉的多。这份doc里的源码和分析表填充逻辑足够支撑你完成实验二,希望帮到你。
本文还有配套的精品资源,点击获取