简介:本资源是一份面向编译原理课程学习者的语法分析实验教学文档,适用于计算机专业本科生及编程语言基础实践者,聚焦于将理论文法转化为可执行分析程序的核心能力训练。文档完整呈现了基于LL(1)文法的算术表达式语法分析器设计全过程,涵盖BNF定义、文法改写、预测分析表构建、C++源码实现(含分析栈与符号栈操作逻辑)、测试用例设计及错误诊断机制,特别适合配合词法分析实验一进行编译前端综合实践。资源为单文件Word文档(.doc),大小127KB,内容结构清晰,含实验目的、语法定义、流程图设计、LL(1)分析表、完整可编译C++代码及运行输出示例。目前已有146人学习下载,读者可直接复现语法分析全流程,掌握从文法推导到程序落地的关键技术细节,并获得可调试的参考实现与典型错例反馈机制。
1. 这不是编译原理作业抄答案:一个能跑通、能调试、能改出错提示的LL(1)语法分析器实战包
你手头这份《实验二--语法分析程序设计与实现.doc》,表面看是某高校编译原理课程的常规实验文档,但实际藏着一套可直接编译、带完整错误定位、支持手动步进观察的C++ LL(1)分析器源码——它不是伪代码,不是流程图,而是真正在VC6.0(或兼容环境)下能cl /EHsc编译通过、输入i+i*i#就输出每一步栈变化、遇到i++#立刻报“出错”并停在错误位置的实打实程序。它解决的不是“什么是LL(1)”这种概念题,而是“为什么我写的预测分析表总卡死”“为什么‘匹配’后指针没动”“为什么空产生式^要特殊处理”这些让初学者抓狂的实操断点。适合刚写完词法分析器、正卡在语法分析环节的本科生,也适合想快速验证文法是否LL(1)、需要现场调试分析过程的嵌入式脚本开发者。别被标题里的“.doc”骗了——核心价值在它附带的那段200行C++代码,以及背后那张手填的7×5预测分析表。
2. 从BNF到预测分析表:为什么选LL(1)?为什么必须左递归消除?
2.1 文法改造:从直观BNF到LL(1)友好型的四步手术
原始BNF定义里藏着两个致命陷阱:左递归和公共前缀。看这句:<算术表达式> → <算术表达式>+<项> | <算术表达式>-<项> | <项>
直接翻译成递归下降函数会无限调用自身,栈溢出是分分钟的事。LL(1)分析器要求每个非终结符的FIRST集互不相交,而E → E+T | E-T | T的三个右部都以E开头,FIRST集完全重叠。必须动手改。
常见做法是引入新非终结符+右递归。我们把E拆成E → T G,再让G负责处理+T/-T的重复部分:
E → T G G → + T G | - T G | ε T → F S S → * F S | / F S | ε F → i | ( E )注意:ε在这里用^字符表示(代码里写作s2.array[0]='^'),这是为后续填表做准备。这个改写不是炫技,是让预测分析表能填出唯一入口——比如当栈顶是G、当前输入是+时,表里只能指向G→+TG这一条路,不能模棱两可。
提示:改写后务必验证
FIRST(F)和FOLLOW(G)是否满足LL(1)条件。比如F→i|(E)的FIRST集是{i, (},F→(E)的FIRST集也是{(},但F没有ε产生式,所以没问题;而G→ε存在,就必须检查FOLLOW(G)是否与FIRST(+TG)不相交——FOLLOW(G)包含{), #},FIRST(+TG)是{+},确实无交集。
2.2 预测分析表构建:手填比自动生成更懂坑在哪
代码里那个type C[10][10]二维数组,就是手工填的预测分析表。它的行列索引不是随便定的:
- 行对应非终结符:
v2[20] = {'E','G','T','S','F'}—— 注意顺序!E是第0行,G是第1行,T是第2行... 这个顺序必须和C[m][n]的m索引严格一致,否则查表就错位。 - 列对应终结符:
v1[20] = {'i','+','-','*','/','(',')','#'}——#是输入结束符,不是语法符号,但它决定分析何时终止(if(x=='#') finish=1)。
填表规则有三条铁律:
- 对每个产生式
A → α,把α填入C[A行][a列],其中a ∈ FIRST(α)
例如F → i,FIRST(i)={i},所以C[4][0] = f1(F是v2[4],i是v1[0]) - 若
α ⇒* ε(即α可推出空串),则把α填入C[A行][b列],其中b ∈ FOLLOW(A)
例如G → ε,FOLLOW(G)={')', '#'},所以C[1][6]=g2()是v1[6])、C[1][7]=g2(#是v1[7]) - 表中未填位置必须设为
'N'(空),代码里用C[m][n].origin='N'初始化,后续判断if(cha.origin!='N')才执行推导
下面这张表是原文档手填结果的还原(已修正原文档中几处笔误):
| 终结符 ↓ / 非终结符 → | E (0) | G (1) | T (2) | S (3) | F (4) |
|---|---|---|---|---|---|
| i | E→TG | — | T→FS | — | F→i |
| + | — | G→+TG | — | — | — |
| - | — | G→-TG | — | — | — |
| *** | — | — | — | S→*FS | — |
| / | — | — | — | S→/FS | — |
| ( | E→TG | — | T→FS | — | F→(E) |
| ) | — | G→ε | — | — | — |
| # | — | G→ε | — | — | — |
注意:原文档代码中
C[0][5]=e(E→TG填在(列)是正确的,因为(在v1[5]位置;但C[1][6]=g2填在)列,C[1][7]=g2填在#列,这两处g2必须是同一个对象(g2.length=1, g2.array[0]='^'),否则空产生式无法正确跳过。
2.3 数据结构设计:为什么用字符数组模拟栈?而不是STL stack?
代码里用char A[30]作分析栈、char B[30]存剩余输入,看似原始,实则是教学场景下的最优解:
- 可调试性:在VC6.0调试器里,
A和B是连续内存,top和b变量一目了然,每步A[++top]=cha.array[j]都能看到栈顶如何生长,比stack<char>的内部迭代器更直观; - 边界可控:
top初始为0,A[top]='#'后top++,A[++top]='E',此时A[0]='#'、A[1]='E',栈底到栈顶清晰;而stack的top()返回引用,push()后无法直接观察整个栈内容; - 空产生式处理简单:
if(A[top]=='^') top--;一行搞定,若用stack需额外判断是否为空再pop(),逻辑更绕。
当然,生产环境会用std::stack或std::vector,但在这个实验里,字符数组是故意为之的“透明化设计”。
3. 编译与运行:VC6.0兼容性补丁与输入格式玄学
3.1 头文件与编译命令:老式C++语法的现代适配
原文档代码用的是#include<iostream.h>等旧式头文件,在VS2019+或GCC下会报错。实测可用的兼容方案:
// 替换原文档中的所有旧头文件 #include <iostream> // 去掉.h #include <cstdio> // 替代<stdio.h> #include <cstdlib> // 替代<stdlib.h> #include <cstring> // 替代<string.h> using namespace std; // 必加,否则cout报错编译命令(Windows下):
cl /EHsc /W3 syntax_analyzer.cpp/EHsc:启用C++异常处理(虽代码没用try-catch,但cout内部需要)/W3:开启三级警告,能捕获strcpy安全问题(见下文避坑)
逻辑说明:
cl是微软C/C++编译器,/EHsc确保标准库流操作不崩溃;syntax_analyzer.cpp是保存后的文件名。若用MinGW,命令为g++ -std=c++98 -o analyzer.exe syntax_analyzer.cpp(必须指定C++98,因代码用void main())。
3.2 输入格式:为什么必须以#结尾?且不能有空格?
程序读取输入的循环是:
do { cin >> ch; if (非法字符) { cout<<"输入串中有非法字符"; exit(1); } B[j] = ch; j++; } while(ch != '#');关键点在于:
cin >> ch会自动跳过空白符(空格、回车、制表符),所以输入i + i #会被读成i、+、i、#四个字符,中间空格全丢弃;#是硬性结束符,程序靠它触发finish=1和acc!输出;- 若输
i+i*i不加#,程序会一直等待输入,卡死在do-while里。
正确输入示例(在控制台逐字符敲):
i+i*i#错误输入示例:
i + i * i # ← 有空格,被跳过,但`#`还在,实际输入是`ii*i#`,报错 i+i*i ← 没`#`,死等3.3 输出解读:三列对齐背后的调试逻辑
程序输出格式固定为:
步骤 分析栈 剩余字符 所用产生式- 步骤:
k++计数,从0开始,每处理一个字符(匹配或推导)+1; - 分析栈:
print()函数输出A[0]到A[top],注意A[0]='#'永远在最左; - 剩余字符:
print1()输出B[b]到B[l],b是已读字符数,B[b]是当前待处理字符; - 所用产生式:仅当非终结符推导时输出,如
G->+TG;匹配时只输出匹配。
例如输入i#的典型输出:
0 #E i# E->TG 1 #TG i# T->FS 2 #TFS i# F->i 3 #TFS i# 匹配 4 #TS # S->^ 5 #T # T->FS ...这里S->^后top--,栈从#TFS变成#T,S被弹出——这就是空产生式的作用。
4. 避坑:五个血泪经验总结,专治“明明代码一样却跑不通”
4.1 现象:编译报错'strcpy' : cannot convert parameter 1 from 'char [5]' to 'char *'
原因:strcpy(t.array, "FS")中t.array是char[5],但某些编译器版本要求第一个参数是char*,而数组名在特定上下文可能被解释为常量地址。
解决:显式取地址,改为strcpy(&t.array[0], "FS");或更安全地用strncpy(t.array, "FS", 4); t.array[4]='\0';
4.2 现象:输入i#后输出出错,但栈显示#E,剩余字符i#,卡在第一步
原因:预测分析表C[0][0](E行i列)没填e,或填错了行/列索引。检查v2和v1数组顺序,确认E是v2[0]、i是v1[0],且C[0][0]=e已执行。原文档代码中C[0][0]=e缺失,必须手动补上。
4.3 现象:输入i+i#时,+匹配后ch=B[++b]指向#,但下一步栈顶G查表C[1][7](#列)应为g2,却输出出错
原因:FOLLOW(G)包含#,但C[1][7]未赋值为g2,或g2对象未初始化(g2.length=1但g2.array[0]未设为'^')。检查初始化段:g2.origin='G'; g2.array[0]='^'; g2.length=1;三句缺一不可。
4.4 现象:程序运行一闪而退,看不到输出
原因:exit(1)强制退出,控制台窗口关闭太快。在main()末尾getchar();前加一句system("pause");,或直接在IDE里用Ctrl+F5运行(保持窗口)。
4.5 现象:输入(i)#报错,栈显示#E→#TG→#TFS→#TF(E),但(入栈后无法继续
原因:F→(E)的产生式填在C[4][5](F行(列),但(在v1[5],F在v2[4],需确认C[4][5]=f已执行。原文档代码中C[4][5]=f存在,但f.array应为"(E)",若误写成"E"则推导失败。
5. 扩展实战:三步把LL(1)分析器升级为带AST构建的表达式计算器
5.1 AST节点设计:从“输出RIGHT”到“计算3+4*5=23”
原文档只做语法检查,输出RIGHT/ERROR。要让它真正干活,需在推导过程中构建抽象语法树(AST)。核心改动在产生式执行处:
// 原代码:仅输出产生式 cout << cha.origin << "->"; for(j=0; j<cha.length; j++) cout << cha.array[j]; cout << "\n"; // 升级版:根据产生式类型创建节点 if (cha.origin == 'E' && strcmp(cha.array, "TG") == 0) { // 创建E节点,子节点为T和G的AST ast_node* e_node = new ast_node("E"); e_node->add_child(T_ast); // T_ast由T推导返回 e_node->add_child(G_ast); // G_ast由G推导返回 current_ast = e_node; // current_ast是全局AST根指针 }关键点:为每个非终结符推导定义返回AST节点的函数,如ast_node* parse_E(),在E→TG分支里调用parse_T()和parse_G()并组合。
5.2 计算逻辑注入:在匹配终结符时提取数值
当ch=='i'且栈顶F推导出F→i时,不能只输出F->i,而要从词法分析器获取当前i对应的值:
if (x == 'i') { // 假设词法分析器返回token结构体,含type和value token tok = get_next_token(); if (tok.type == UCON) { // 无符号常数 ast_node* num_node = new ast_node("NUM", tok.value); current_ast = num_node; } else if (tok.type == ID) { // 变量 ast_node* id_node = new ast_node("ID", tok.name); current_ast = id_node; } }这样i+i*i的AST就是ADD -> NUM(1), MUL -> NUM(1), NUM(1),遍历计算即可。
5.3 错误恢复:从“exit(1)”到“跳过错误符号继续分析”
原文档一出错就exit(1),无法看到后续错误。工业级做法是同步恢复:
- 当
ch不在FIRST(X)也不在FOLLOW(X)时,报告Unexpected token: ch; - 弹出栈顶
X,ch不变(不执行ch=B[++b]); - 若栈为空,报告
Syntax error at end of input; - 否则继续循环。
这样输入i++#会报Unexpected '+' after 'i',然后跳过第一个+,尝试用第二个+匹配,最终在#处结束。
从那以后我每次教学生编译原理实验,都会强制他们先用
printf("DEBUG: top=%d, b=%d, ch=%c\n", top, b, ch)在关键位置打点,再对照预测分析表手算三步——不是为了炫技,是让C[1][6]这种坐标从纸面数字变成脑海里的真实内存地址。希望帮到你。
本文还有配套的精品资源,点击获取