简介:本资源是一份面向计算机专业本科生与编译原理初学者的语法分析实验教学文档,聚焦编译器前端核心环节——语法分析程序的设计与实现。文档以算术表达式简化子集为分析对象,系统讲解LL(1)文法构造、预测分析表设计、C++源码实现及调试验证全过程,并配套完整流程图、BNF/EBNF文法改写、分析栈与符号栈运行示例、典型正确/错误输入测试用例及输出格式说明。资源为单文件Word文档(.doc),大小127KB,内容结构清晰,含实验目的、内容要求、步骤分解、LL(1)文法推导、预测分析表构建、完整可编译C++代码及运行结果说明,便于读者边学边练、理解语法分析本质。目前已有146人学习下载,适合作为课程实验参考、课程设计基础材料或编译原理复习提纲。
1. 这不是教科书习题——而是一个能跑通、能调试、能嵌入真实编译器流程的LL(1)语法分析器实战工程
你有没有试过把《编译原理》课本里那个“预测分析表”画在纸上,对着i、+、(、#一行行推导,结果手算三遍答案都不一样?我第一次写这个实验时,在宿舍熬到凌晨两点,盯着屏幕上“ERROR”和空空的分析栈发呆——不是不会推,是根本不知道程序哪一步把ch指针挪错了、top越界了、还是v1/v2数组下标搞反了。这份名为“实验二--语法分析程序设计与实现.doc”的资源,表面看是高校课程实验文档,实则是一份完整可运行的C++ LL(1)语法分析器源码包:它自带BNF文法改写逻辑、手填预测分析表、栈式驱动控制流、逐行输出分析过程,甚至预留了与词法分析器(实验一)对接的输入接口。它不讲抽象理论,只解决一个硬问题:给定一串由UCON/ID/PL等单词符号组成的token流(比如"UCON PL UCON MU ID"),如何用确定性栈算法判断其是否构成合法算术表达式,并输出每一步归约/匹配动作?适合正在啃编译原理大作业的本科生、想补全前端解析链路的前端工程师、或需要快速验证自定义DSL语法可行性的嵌入式开发者。别被“.doc”后缀骗了——核心是那三百多行带注释的C++代码,它比多数开源parser generator生成的代码更透明、更易调试。
2. 架构拆解:从BNF文法到预测分析表的四层映射关系
2.1 文法改造:为什么必须左递归消除?原始G2与LL(1)兼容版的血泪转换
原始BNF定义中藏着两个致命陷阱:
<算术表达式> → <算术表达式> + <项>是典型的左递归,LL分析器无法处理——因为看到第一个符号E就无限递归调用自身,栈直接爆掉;<项> → <项> * <因式>同样左递归,且<因式> → ( <算术表达式> )引入嵌套,导致FIRST/FOLLOW集计算复杂。
解决方案不是硬扛,而是标准文法变换:
- 提取左公因子:
E → T | E+T | E-T→ 拆出公共前缀E,引入新非终结符G(代表“加减后缀”); - 消除左递归:
E → TG,G → +TG | -TG | ε(ε用^字符占位); - 同理处理T/F:
T → FS,S → *FS | /FS | ε,F → i | (E)。
最终得到LL(1)兼容文法(文档中称“LL1文法”):
E → TG G → +TG | -TG | ^ T → FS S → *FS | /FS | ^ F → i | (E)提示:
^不是C++运算符,是文档约定的ε(空产生式)标记,代码中用g2.array[0]='^'存储,后续入栈时检测到^直接跳过——这是LL(1)预测分析器处理空产生式的标准手法,不是玄学。
2.2 数据结构设计:分析栈、剩余串、终结符/非终结符表的内存布局真相
整个分析器仅用两个固定长度字符数组撑起全部状态:
char A[30]:分析栈(Analysis Stack),从底向上存符号,top指向栈顶元素索引(初始为0,A[0]='#',A[1]='E');char B[30]:剩余输入串(Buffer),B[0]到B[l-1]存输入字符,b指向当前待匹配位置(初始b=0),ch = B[b]即当前输入符号。
终结符与非终结符被硬编码为两个独立数组,这是手工实现LL(1)的典型做法:
char v1[20] = {'i', '+', '-', '*', '/', '(', ')', '#'}; // 终结符,索引0~7 char v2[20] = {'E', 'G', 'T', 'S', 'F'}; // 非终结符,索引0~4关键细节:v1和v2的顺序决定预测分析表C[m][n]的行列索引!C[0][0]对应E→i的产生式(实际是E→TG,但表中填的是e对象),C[1][1]对应G→+TG(g对象)。这种硬编码牺牲了扩展性,但换来极致的可读性——查表时m=j(v2[j]==x得行号),n=j(v1[j]==ch得列号),零抽象,纯指针运算。
2.3 预测分析表C[10][10]:手填表背后的FIRST/FOLLOW集验证逻辑
文档给出的预测分析表看似随意填写,实则严格依赖FIRST和FOLLOW集计算。以E行为例:
FIRST(E) = FIRST(T) = {i, '('}→ 所以C[0][0](i列)和C[0][5]((列)填e(E→TG);FOLLOW(E) = {')', '#'},而G的产生式G→^需填入FOLLOW(G),FOLLOW(G) = FOLLOW(E) = {')', '#'}→ 所以C[1][6]()列)和C[1][7](#列)填g2(G→^)。
表中每个type对象存储三项:
origin:产生式左部非终结符(如'G');array[5]:右部字符串(如"+TG",注意是字符数组,非std::string);length:右部长度("+TG"为3,"^"为1)。
初始化时C[m][n].origin='N'(N表示Null),查表时若cha.origin!='N'才执行产生式展开,否则报错——这是LL(1)分析器拒绝歧义的核心机制。
2.4 控制流主循环:match、expand、acc三态驱动的有限状态机
main()函数的do-while(finish==0)循环是整个分析器的引擎,它将LL(1)算法翻译成四步原子操作:
- 弹栈取顶:
x = A[top--],x是当前待处理符号; - 终结符分支:若
x在v1[]中(flag==1),则:x=='#'且ch=='#'→finish=1,输出acc!(接受);x==ch→ 匹配成功,ch=B[++b]推进输入指针;- 否则报错退出;
- 非终结符分支:若
x在v2[]中,查表C[m][n]:- 表项非空 →
print()输出当前栈/剩余串,cout<<cha.origin<<"->"打印产生式,逆序入栈(for(j=cha.length-1;j>=0;j--) A[++top]=cha.array[j]); - 表项为空(
origin=='N') → 报错退出;
- 表项非空 →
- 空产生式处理:入栈时检测
if(A[top]=='^') top--,跳过ε符号。
这个循环没有递归调用,纯栈操作,时间复杂度O(n),是教科书LL(1)算法最忠实的C++实现。
3. 编译与运行:从Windows Dev-C++到Linux g++的跨平台适配指南
3.1 环境准备:三分钟搞定编译环境(含头文件兼容性修复)
原始代码使用老旧头文件(<iostream.h>、<stdio.h>),在现代编译器中会报错。必须修改以下三处:
// 原始(错误) #include <iostream.h> #include <stdio.h> #include <stdlib.h> #include <string.h> // 修正后(标准C++) #include <iostream> // 移除.h,使用std命名空间 #include <cstdio> // C标准库头文件用c前缀 #include <cstdlib> #include <cstring> using namespace std; // 关键!否则cout未定义注意:
<iostream>在GCC中可能需链接-lstdc++,但Dev-C++默认包含;若用VS2019+,还需在项目属性→C/C++→语言→启用C++17标准。
3.2 输入格式规范:为什么必须以'#'结尾?token流与字符流的转换协议
程序输入不是原始代码(如a+b*c),而是词法分析器输出的token序列,格式为:
- 终结符映射:
i代表任意标识符或无符号常数(如UCON或ID),+-*/( )直接输出; - 必须以
#结束,这是LL(1)分析器的输入结束符(类似EOF); - 示例正确输入:
i+i*i#(对应UCON PL UCON MU ID#),错误输入:i++i#(连续+非法)。
提示:若要对接真实词法分析器(实验一),需在词法器输出端做token到字符映射:
UCON→'i',PL→'+',MU→'*',LP→'(',RP→')',最后追加'#'。这是课程实验设计的精妙之处——强制学生理解词法/语法层边界。
3.3 运行效果实录:RIGHT/ERROR输出背后的栈状态快照
以输入i+i#为例,程序输出:
步骤 分析栈 剩余字符 所用产生式 0 #E i+i# E->TG 1 #TG i+i# T->FS 2 #TFS i+i# F->i 3 #TFS i+i# 匹配 4 #TS +i# S->^ 5 #T +i# T->FS 6 #FS +i# F->i 7 #FS +i# 匹配 8 #S +i# S->^ 9 # +i# 匹配 10 # # acc!每行print()输出栈(#TG)、print1()输出剩余串(i+i#),匹配表示终结符相等,S->^表示应用空产生式。若输入i++#,第4步ch='+'但栈顶S查表C[3][1]为空(s2填在C[3][1]?不,文档表中C[3][1]是s2,但S的FOLLOW集不含+!此处应报错),程序立即输出出错并退出——这正是LL(1)的强错误检测能力。
3.4 调试技巧:用printf替代cout定位top/b越界问题
新手最常遇到Segmentation fault,根源是top或b越界。不要依赖IDE调试器,用土办法:
// 在循环开头插入 cout << "DEBUG: top=" << top << ", b=" << b << ", l=" << l << ", ch=" << ch << endl; // 检查top是否>=29(A[30]最大索引29),b是否>=l if(top >= 29 || b >= l) { cout << "STACK OVERFLOW! Exit." << endl; exit(1); }再配合gdb ./a.out,输入i+i#后bt看崩溃栈帧,90%的越界问题迎刃而解。
4. 避坑指南:五个让开发者抓狂的LL(1)实现细节与解决方案
4.1 现象:程序启动后直接报“输入串中有非法字符”,但输入明明是'i+i#'
原因:cin>>ch读取单个字符时,若输入含空格或回车,ch会读到'\n'或' ',而v1[]未包含这些字符,触发exit(1)。
解决:改用getchar()逐字读取,并过滤空白符:
do { ch = getchar(); if(ch == '\n' || ch == ' ' || ch == '\t') continue; // 跳过空白 if((ch!='i')&&(ch!='+')&&...&&(ch!='#')) { cout<<"输入串中有非法字符\n"; exit(1); } B[j++] = ch; } while(ch != '#');4.2 现象:分析栈输出乱码,如#E?或#E
原因:A[]和B[]数组未初始化,print()遍历A[0]到A[top+1]时,A[top+1]可能是随机垃圾值。
解决:声明时显式初始化:
char A[30] = {'\0'}; // 全置0 char B[30] = {'\0'};4.3 现象:输入i#输出RIGHT,但输入(i)#报ERROR,查表发现C[4][5](F列,(列)应填f却为空
原因:文档预测分析表有笔误!F→(E)的FIRST(F)={i,'('},所以C[4][0](i列)和C[4][5]((列)都应填f,但原文只写了C[4][0]=f1;C[4][5]=f;,f1是F→i,f是F→(E),必须确保C[4][5]=f执行。检查代码发现C[4][5]=f;存在,但f对象的array被误设为"(E)"(3字符),而length=3正确,问题在print()输出时未对齐。
解决:确认f.origin='F',strcpy(f.array,"(E)"),f.length=3,并在print()中加空格:
void print() { for(int a=0; a<=top; a++) cout << A[a] << " "; // 每个符号后加空格 cout << "\t\t"; }4.4 现象:输入长串如i+i*i+i#时,top超过29导致崩溃
原因:A[30]太小,i+i*i+i#分析过程栈深可达20+,但复杂表达式可能超限。
解决:动态扩容或增大数组。保守方案:char A[100] = {'\0'};,同时检查top<99:
if(++top >= 99) { cout << "Stack overflow! Increase A[] size." << endl; exit(1); }4.5 现象:g2.array[0]='^'后,print()输出栈时显示^而非跳过
原因:空产生式入栈后未在print()中过滤'^',导致栈显示冗余符号。
解决:修改print(),跳过'^':
void print() { for(int a=0; a<=top; a++) { if(A[a] != '^') cout << A[a] << " "; } cout << "\t\t"; }5. 二次开发:从语法分析器到可嵌入编译器的三大改造路径
5.1 改造一:支持真实token类型——用struct替代单字符,打通词法分析器接口
当前用'i'代表所有标识符/常数,但真实场景需区分ID("a")和UCON(123)。改造核心是替换字符映射为token结构体:
struct Token { enum { ID, UCON, PL, MU, LP, RP, END } type; string value; // 如"abc"或"123" }; Token B[30]; // 替代 char B[30]相应地,v1[]终结符表升级为Token::type枚举,ch变为Token ch = B[b],查表逻辑改为:
// 原逻辑:for(j=0;j<=7;j++) if(ch==v1[j]) {n=j; break;} // 新逻辑: switch(ch.type) { case ID: case UCON: n = 0; break; // 归为'i' case PL: n = 1; break; case MU: n = 3; break; case LP: n = 5; break; case RP: n = 6; break; case END: n = 7; break; // '#'对应END }这样,实验一的词法器只需输出Token数组,本程序直接消费,无需字符串转换——这是工业级编译器前后端解耦的标准范式。
5.2 改造二:输出AST而非"RIGHT/ERROR"——构建抽象语法树节点
LL(1)分析过程天然对应AST构建时机:每次应用产生式X→α,就创建X节点,子节点为α中各符号(终结符忽略,非终结符递归)。在cha=C[m][n]后插入AST构建代码:
// 假设Node结构体 struct Node { string name; // "E", "T", "i" vector<Node*> children; Node(string n) : name(n) {} }; Node* root = new Node("E"); // 初始根节点 stack<Node*> nodeStack; nodeStack.push(root); // 在应用产生式后(cha非空时) Node* parent = nodeStack.top(); nodeStack.pop(); for(int j=0; j<cha.length; j++) { char sym = cha.array[j]; if(sym == '^') continue; Node* child = new Node(string(1,sym)); parent->children.push_back(child); if(strchr(v2, sym)) { // 若为非终结符,压入栈等待其子节点 nodeStack.push(child); } }最终root即为AST根,可序列化为JSON或转为中间代码——这才是语法分析的真正产出,而非屏幕上的"RIGHT"。
5.3 改造三:生成错误恢复策略——从exit(1)到同步恢复(Synchronization)
原程序遇错即exit(1),但生产环境需报告错误并继续分析。LL(1)标准恢复法是“同步符号集”:为每个非终结符X定义SYNC(X)(通常为FOLLOW(X)),当X预测失败时,丢弃输入直到遇到SYNC(X)中符号。例如E的FOLLOW(E)={')','#'},若E分析失败,跳过输入直到')'或'#':
// 在非终结符分支报错处 else { print(); print1(); cout << "ERROR at position " << b << ": expected " << x << endl; // 同步恢复:跳过输入直到SYNC(x) while(b < l && !isInSyncSet(x, B[b])) { b++; } // 重置栈顶为x,重新尝试(需保存原栈状态) A[++top] = x; // 恢复x到栈顶 } bool isInSyncSet(char x, char ch) { if(x == 'E') return (ch == ')' || ch == '#'); if(x == 'T') return (ch == ')' || ch == '#' || ch == '+' || ch == '-'); return false; }此改造使分析器具备实用级错误报告能力,是课程实验迈向工业代码的关键跃迁。
从那以后我每次写语法分析器,都强制走一遍FIRST/FOLLOW手工推导,再对照预测分析表逐格验证——哪怕用ANTLR,我也先手写一遍LL(1)表来校验文法是否真无冲突。因为编译器的确定性,永远建立在人对文法边界的绝对掌控之上,而不是工具生成的黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取