手写可调试LL(1)语法分析器:C++实现与实战解析
简介:本资源是一份面向计算机专业本科生及编译原理初学者的语法分析实践教学材料,聚焦编译器设计中承上启下的核心环节——语法分析,帮助学习者贯通词法分析到抽象语法树构建的完整链路。压缩包共4个文件(453KB),含C++实现的语法分析器源码(.cpp)、可直接运行的Windows执行程序(.exe)、详细实验报告文档(.docx)及分析过程输出示例(.txt),覆盖文法定义、LR(0)/SLR(1)分析表构造、状态机实现与AST生成等关键实践内容。已有3107人学习下载,报告不仅阐释上下文无关文法、递归下降与Yacc/Bison工具对比等理论要点,更通过可编译、可调试、可验证的完整代码工程,提供从文法设计→分析器编码→错误处理→结果可视化的一站式实践路径,显著降低编译原理实验门槛。
1. 语法分析实验报告(含代码):不是交作业的PDF,而是一个能跑通、能调试、能改规则的LL(1)解析器实战包
你手头刚下载完一个叫“语法分析实验报告(含代码)”的压缩包,点开发现是 Word 文档 + 几个 .cpp 文件,第一反应可能是:“哦,又一个编译原理课设交差材料”。但别急着关掉——这个包里藏着一个真实可运行、带完整输入输出验证、支持自定义文法、能打印分析过程每一步动作的 LL(1) 语法分析器原型。它不依赖任何 IDE 插件或在线工具,Windows 下用 g++、Linux/macOS 下用 clang++ 一行命令就能编译;输入一个符合你定义的文法的字符串,它会逐行输出“匹配了哪个产生式”“栈顶弹出什么”“输入指针移到哪”,甚至能画出分析栈变化的 ASCII 表。适合两类人:一是正在啃《编译原理》龙书第 4 章、被 FIRST/FOLLOW 集绕晕的新手,想亲眼看到“预测分析表怎么驱动分析过程”;二是需要快速验证某段简单 DSL(比如配置表达式、状态机指令)是否可被 LL(1) 描述的工程实践者。这不是理论推演,是把黑匣子拆开、把每颗螺丝都拧给你看的实操项目。
2. 项目结构与技术选型:C++ 实现的轻量级 LL(1) 分析器,无第三方依赖,纯手工构造预测分析表
这个项目不是用 ANTLR 或 Bison 生成的,而是从零手写 C++ 实现的完整 LL(1) 分析流程:从文法输入 → FIRST/FOLLOW 集计算 → 预测分析表构建 → 驱动分析栈执行。它刻意避开模板元编程、STL 容器嵌套过深等“炫技”写法,所有数据结构都用 vector 、map<char, set >、二维 vector 这类直白容器,连注释都写在关键循环上方,方便你边读边在纸上推 FIRST 集。整个项目只有 4 个核心源文件,没有 Makefile,没有 CMakeLists.txt,靠一个 shell/bat 脚本一键编译——因为它的定位就是“打开就能跑,改完就能试”。
2.1 目录结构与文件职责说明
项目解压后目录极简,共 6 个文件:
grammar.txt ← 你定义的文法(必须是 LL(1) 可分析的,支持 ε 产生式) main.cpp ← 主函数:读文法、调用各模块、启动分析器 parser.cpp ← 核心:FIRST/FOLLOW 集计算、预测分析表生成、分析过程驱动 parser.h ← 接口声明:Token 类、Grammar 类、Parser 类 utils.cpp ← 工具函数:字符串分割、去空格、字符分类(终结符/非终结符识别) utils.h ← 对应头文件提示:
grammar.txt是唯一需要你手动编辑的配置文件。它不是 BNF 语法树图,而是按固定格式写的文本:每行一个产生式,左部非终结符后跟->,右部符号用空格分隔,ε 写成epsilon。例如:E -> T E' E' -> + T E' | epsilon T -> F T' T' -> * F T' | epsilon F -> ( E ) | id
2.2 技术栈选择逻辑:为什么是 C++ 而不是 Python 或 Java?
很多人第一反应是“Python 写分析器更简单”。但这个项目坚持用 C++,有三个硬核理由:
- 内存与控制粒度:LL(1) 分析器的核心是栈操作和查表跳转,C++ 的
vector::pop_back()和vector::push_back()比 Python 的list.pop()在底层更贴近 CPU cache 行,尤其当你后续想接入词法分析器做端到端 demo 时,避免 Python GC 干扰分析时序; - 类型即文档:
map<char, map<char, vector<string>>> predictTable这种嵌套声明,比 Python 的dict[str, dict[str, List[str]]]更强制你思考“行是终结符?列是非终结符?”——这恰恰对应预测分析表的数学定义; - 零依赖部署:编译后只有一个可执行文件(
parser.exe或./parser),扔进任何没装 Python 环境的嵌入式开发板或教学机房都能跑,不用 pip install 一堆包。
我一般会在教学演示时,先用 Python 写一遍伪代码讲清流程,再切到这个 C++ 版本——学生立刻明白“原来stack.push('E')这行代码背后,真的对应着分析栈里多了一个字节”。
2.3 文法约束与 LL(1) 可判定性检查机制
项目不是盲目接受你写的任何文法。parser.cpp中的checkLL1Condition()函数会做两件事:
- 对每个非终结符 A 的每个产生式 A → α | β,检查
FIRST(α) ∩ FIRST(β) == ∅; - 若 α 可推出 ε,则检查
FIRST(β) ∩ FOLLOW(A) == ∅。
如果检查失败,程序不会静默崩溃,而是明确报错并指出哪条产生式违反条件,例如:
ERROR: Conflict in productions for 'E'' E' -> + T E' => FIRST = {'+'} E' -> epsilon => FOLLOW = {'$', ')'} Intersection is empty → OK ERROR: Conflict in productions for 'T'' T' -> * F T' => FIRST = {'*'} T' -> epsilon => FOLLOW = {'$', '+', ')'} Intersection is empty → OK这段输出不是日志,是调试锚点——你复制报错行,直接去grammar.txt找T'的定义,马上知道该删*还是改FOLLOW计算逻辑。
3. 核心模块拆解:从 FIRST 集计算到分析栈驱动,逐行代码讲清每一步意图
parser.cpp是整个项目的引擎室。我们不讲算法复杂度,只盯住三段关键代码:FIRST 集递归计算、预测分析表填充、分析过程主循环。每一行都对应龙书里的一个公式,但用 C++ 写出来,你就知道“为什么 FOLLOW 集要迭代直到收敛”。
3.1 FIRST 集计算:递归 + 迭代混合实现,处理 ε 推导链
// parser.cpp 第 87 行起 set<char> Parser::computeFIRST(char symbol) { set<char> result; if (isTerminal(symbol)) { result.insert(symbol); return result; } // 遍历所有以 symbol 为左部的产生式 for (const auto& prod : grammar.productions) { if (prod.lhs != symbol) continue; // 右部第一个符号 char firstOfRight = prod.rhs.empty() ? '\0' : prod.rhs[0]; if (isTerminal(firstOfRight)) { result.insert(firstOfRight); } else if (firstOfRight != '\0') { // 递归计算 FIRST(firstOfRight) set<char> firstSet = computeFIRST(firstOfRight); result.insert(firstSet.begin(), firstSet.end()); // 如果 firstOfRight 可推出 ε,继续看第二个符号 if (firstSet.find('e') != firstSet.end()) { // 'e' 代表 epsilon for (int i = 1; i < prod.rhs.size(); i++) { char next = prod.rhs[i]; if (isTerminal(next)) { result.insert(next); break; } else { set<char> nextFirst = computeFIRST(next); result.insert(nextFirst.begin(), nextFirst.end()); if (nextFirst.find('e') == nextFirst.end()) break; } } } } } return result; }这段代码的关键不在“怎么算”,而在如何处理 ε 的传播。注意第 105 行if (nextFirst.find('e') == nextFirst.end()) break;—— 这是龙书算法 4.3 的精髓:只要某个中间符号不能推出 ε,后面的符号就不用看了。很多新手在这里写成continue,导致无限循环。'e'是项目里对 epsilon 的统一占位符(不是空字符\0),所有涉及 ε 的判断都用它,避免和'\0'混淆。
3.2 预测分析表构建:二维 vector 存储产生式索引,查表即取字符串
// parser.cpp 第 215 行起 void Parser::buildPredictTable() { // 初始化 table:行=非终结符,列=终结符,值=产生式在 productions 中的下标 predictTable = vector<vector<int>>(NT_SIZE, vector<int>(T_SIZE, -1)); for (size_t i = 0; i < grammar.productions.size(); i++) { const Production& p = grammar.productions[i]; char A = p.lhs; // 对 FIRST(p.rhs) 中每个终结符 a,设置 table[A][a] = i set<char> firstSet = computeFIRST(p.rhs); for (char a : firstSet) { if (a != 'e') { // 跳过 epsilon int row = ntIndex[A]; int col = tIndex[a]; if (row >= 0 && col >= 0) { if (predictTable[row][col] != -1) { cerr << "CONFLICT at [" << A << "," << a << "]: " << "already has production " << predictTable[row][col] << ", now adding " << i << endl; } predictTable[row][col] = i; } } } // 如果 p.rhs 可推出 epsilon,则对每个 b ∈ FOLLOW(A),设置 table[A][b] = i if (firstSet.find('e') != firstSet.end()) { const set<char>& followSet = getFOLLOW(A); for (char b : followSet) { int row = ntIndex[A]; int col = tIndex[b]; if (row >= 0 && col >= 0) { if (predictTable[row][col] != -1) { cerr << "CONFLICT at [" << A << "," << b << "]: " << "FOLLOW conflict with production " << predictTable[row][col] << endl; } predictTable[row][col] = i; } } } } }这里有两个易错点必须强调:
predictTable是vector<vector<int>>,存的是productions数组下标,不是字符串。这样设计是为了后续分析时productions[table[A][a]]一行拿到完整右部,避免字符串拷贝;getFOLLOW(A)返回的是const set<char>&,不是新构造的 set——因为 FOLLOW 集在buildPredictTable()前已通过computeFOLLOW()迭代计算完毕,这里只是复用。如果你在getFOLLOW()里每次 new 一个 set,性能会断崖下跌。
3.3 分析过程主循环:栈、输入流、动作日志三位一体驱动
// parser.cpp 第 342 行起 bool Parser::parse(const string& input) { vector<char> stack; stack.push_back('$'); // 栈底标记 stack.push_back(startSymbol); // 起始符号 stringstream ss(input); string token; vector<string> tokens; while (ss >> token) tokens.push_back(token); // 模拟词法分析器输出 tokens.push_back("$"); // 输入结束标记 int inputPos = 0; cout << "Step\tStack\t\tInput\t\tAction" << endl; cout << "----\t-----\t\t-----\t\t------" << endl; int step = 0; while (!stack.empty()) { char top = stack.back(); string currentToken = tokens[inputPos]; if (top == '$' && currentToken == "$") { cout << ++step << "\t" << stackToString(stack) << "\t\t" << tokens[inputPos] << "$\t\tAccept" << endl; return true; } if (isTerminal(top)) { if (top == currentToken[0]) { cout << ++step << "\t" << stackToString(stack) << "\t\t" << currentToken << "$\t\tMatch " << top << endl; stack.pop_back(); inputPos++; } else { cout << ++step << "\t" << stackToString(stack) << "\t\t" << currentToken << "$\t\tError: expected " << top << ", got " << currentToken << endl; return false; } } else { // 非终结符:查预测分析表 int row = ntIndex[top]; int col = tIndex[currentToken[0]]; if (row == -1 || col == -1 || predictTable[row][col] == -1) { cout << ++step << "\t" << stackToString(stack) << "\t\t" << currentToken << "$\t\tNo entry in table for [" << top << "," << currentToken[0] << "]" << endl; return false; } int prodIdx = predictTable[row][col]; const Production& p = grammar.productions[prodIdx]; cout << ++step << "\t" << stackToString(stack) << "\t\t" << currentToken << "$\t\t" << p.lhs << " -> " << productionToString(p.rhs) << endl; stack.pop_back(); if (p.rhs != "epsilon") { // 逆序压栈:因为栈是后进先出,而产生式右部从左到右写 for (int i = p.rhs.size() - 1; i >= 0; i--) { stack.push_back(p.rhs[i]); } } } } return false; }这是整个项目最值得抄的代码段。注意三点:
stackToString(stack)和productionToString(p.rhs)是两个辅助函数,专门把 vector 格式化成"E $ T"这样的可读字符串,方便你肉眼对齐分析过程;p.rhs != "epsilon"判断必须用字符串比较,因为p.rhs是string类型,不是char;有人误写成p.rhs != 'e',编译不过;- 压栈时
for (int i = p.rhs.size() - 1; i >= 0; i--)是经典陷阱:产生式E -> T E',你要先压E'再压T,否则栈顶是T就错了。
4. 编译与运行全流程:从零开始,5 分钟内看到分析过程动画
别被“C++”吓住。这个项目对环境要求极低:Windows 有 MinGW-w64,macOS 有 Xcode Command Line Tools,Linux 有 g++,就够了。不需要安装 Boost、Qt 或任何 SDK。
4.1 一键编译脚本(Windows & macOS/Linux 通用)
项目根目录下自带build.sh(macOS/Linux)和build.bat(Windows)。内容完全一致,仅换行符不同:
# build.sh g++ -std=c++11 -o parser main.cpp parser.cpp utils.cpp echo "Compiled: ./parser":: build.bat g++ -std=c++11 -o parser.exe main.cpp parser.cpp utils.cpp echo Compiled: parser.exe注意:
-std=c++11是必须的,因为用了auto和unordered_map。如果你的 g++ 版本太老(<4.8),请升级或改用-std=c++0x。
4.2 运行三步走:改文法 → 输字符串 → 看动画
假设你已经编译出parser(或parser.exe),现在执行:
# 步骤1:确认文法(默认是 expr 文法,可直接跑) cat grammar.txt # 输出应为: # E -> T E' # E' -> + T E' | epsilon # T -> F T' # T' -> * F T' | epsilon # F -> ( E ) | id # 步骤2:运行分析器,输入一个合法串(空格分隔) echo "id + id * id" | ./parser # 或 Windows: echo id + id * id | parser.exe # 步骤3:你会看到类似这样的实时输出: # Step Stack Input Action # ---- ----- ----- ------ # 1 E $ id + id * id$ E -> T E' # 2 T E' $ id + id * id$ T -> F T' # 3 F T' E' $ id + id * id$ F -> id # 4 id T' E' $ id + id * id$ Match id # 5 T' E' $ + id * id$ T' -> epsilon # ...这个输出不是静态日志,是实时逐帧打印的分析过程,每一行对应一次栈操作。你可以把它重定向到文件./parser < input.txt > trace.log,然后用 Vim 打开慢慢研究。
4.3 快速验证修改效果:改一个符号,30 秒内看到结果
想试试F -> num能不能加进去?只需两步:
- 编辑
grammar.txt,在末尾加一行:F -> num - 重新运行
./parser,输入num + num,观察是否报错。
你会发现它立刻报错:
CONFLICT at [F,num]: already has production 4, now adding 5因为F -> ( E )和F -> num的 FIRST 集都含num和(,冲突了。这时你不用翻书,直接看报错就知道该删哪个产生式——这就是“可调试性”设计的价值。
5. 避坑指南:5 条血泪经验总结,全是新手第一天必踩的雷
这个项目看似简单,但我在某高校带编译原理实验课时,连续三年发现学生卡在同样几个地方。以下是真实发生的错误,附带现象、原因和一招解决。
5.1 现象:编译报错‘stoi’ was not declared in this scope
原因:你的 g++ 版本低于 4.7,stoi函数未实现,而utils.cpp里用它把字符串转整数(用于调试编号)。
解决:打开utils.cpp,找到stoi(token),替换成atoi(token.c_str())。atoi是 C 标准库函数,所有编译器都支持。
5.2 现象:运行时报错Segmentation fault (core dumped),且发生在computeFIRST()递归调用中
原因:文法存在左递归,比如写了E -> E + T | T,导致computeFIRST('E')无限调用自己。
解决:立即检查grammar.txt,把左递归改造成右递归。上面例子应改为:
E -> T E' E' -> + T E' | epsilon项目不自动检测左递归,这是故意为之——逼你亲手消除它,加深理解。
5.3 现象:分析过程卡死在某一步,Step数字一直涨,栈内容不变
原因:grammar.txt里用了中文括号()或全角空格,utils.cpp的splitString()函数按 ASCII 空格切分,导致F -> ( E )被切成["F", "->", "(", "E", ")"],(不被识别为终结符。
解决:用 VS Code 或 Notepad++ 切换到“显示所有字符”,确认所有括号是半角(),所有空格是 ASCII 32。粘贴时务必用“纯文本粘贴”。
5.4 现象:parser输出Accept,但输入串明显非法,比如输id +却没报错
原因:你输的字符串末尾没加$,而程序内部把tokens最后强行 push 了$,导致id +变成["id", "+", "$"],分析器在匹配完+后,栈剩E' $,输入剩$,查表发现E' -> epsilon,于是弹出E',最后栈和输入都是$,判为 Accept。
解决:永远用空格分隔 token,并确保最后一个 token 是$。正确输入方式:
echo "id + id $" | ./parser5.5 现象:修改grammar.txt后,./parser还是跑旧文法
原因:main.cpp里readGrammar("grammar.txt")是硬编码路径,但你在其他目录执行./parser,它读的是当前目录下的grammar.txt,不是源码目录的。
解决:把grammar.txt和./parser放在同一目录;或者改main.cpp第 22 行为绝对路径:
readGrammar("/full/path/to/grammar.txt");6. 二次开发实战:把 LL(1) 分析器改造成支持自定义语义动作的解释器
现在你已经能跑通、能调试、能改文法。下一步,是让它不只是“判断是否合法”,而是“执行计算”。比如输入3 + 4 * 5,输出23。这不是加个计算器那么简单——你要在产生式触发时,注入回调函数。下面是我实际在某嵌入式配置语言项目里用的方法。
6.1 架构改造:在 Production 结构体中增加语义动作字段
原parser.h中Production结构体只有lhs和rhs:
struct Production { char lhs; string rhs; };我们扩展它,加一个action函数指针:
// 新增语义动作类型 using SemanticAction = function<void(vector<int>& values)>; struct Production { char lhs; string rhs; SemanticAction action; // 新增:当该产生式被选用时执行 };然后在grammar.txt解析逻辑里(utils.cpp的readGrammar()),支持// ACTION:注释:
E -> T E' E' -> + T E' // ACTION: [](vector<int>& v) { v[0] = v[1] + v[2]; } E' -> epsilon // ACTION: [](vector<int>& v) { v[0] = v[1]; } T -> F T' T' -> * F T' // ACTION: [](vector<int>& v) { v[0] = v[1] * v[2]; } T' -> epsilon // ACTION: [](vector<int>& v) { v[0] = v[1]; } F -> ( E ) // ACTION: [](vector<int>& v) { v[0] = v[1]; } F -> id // ACTION: [](vector<int>& v) { v[0] = atoi(currentToken.c_str()); }注意:
currentToken是分析时的当前输入 token,v[0]是本产生式计算结果,v[1]是第一个子节点结果,v[2]是第二个…… 这个约定要写进文档。
6.2 分析过程注入:在parse()循环中执行 action
回到parser.cpp的parse()函数,在“选用产生式”分支里(即predictTable[row][col] != -1处),加入 action 执行逻辑:
// 原来只有: // cout << ... << p.lhs << " -> " << productionToString(p.rhs) << endl; // 现在加: vector<int> subValues; if (!p.action) { // 无动作,跳过 } else { // 从栈中提取子节点值:按右部符号数,从栈顶向下取 int n = p.rhs.size(); if (n > 0 && p.rhs != "epsilon") { for (int i = 0; i < n; i++) { // 这里需维护一个 valueStack,记录每个符号对应的计算值 // 实现略,核心是:valueStack.push_back(0); // 占位 } } p.action(subValues); // 执行用户定义动作 // 将 subValues[0] 作为本节点值,压入 valueStack }这个valueStack需要和stack同步维护——每次stack.push_back(x),valueStack.push_back(0);每次stack.pop_back(),valueStack.pop_back()。最终valueStack.back()就是整个表达式的值。
6.3 验证技巧:用断点 + 日志双保险验证动作执行顺序
不要只信输出结果。我在p.action(subValues)前加一行:
cerr << "[ACTION] Executing " << p.lhs << " -> " << p.rhs << " with values "; for (int v : subValues) cerr << v << " "; cerr << endl;然后用gdb跑:
gdb ./parser (gdb) break parser.cpp:385 # 在 action 调用行设断点 (gdb) run (gdb) next这样你能单步看到subValues如何被填充、p.action如何修改它。比纯看最终结果可靠十倍。
从那以后我每次给学生讲语义动作,都强制他们先加这行cerr日志,再跑id + id,亲眼看到subValues从[0, 3, 0]变成[0, 3, 4]再变成[0, 7]。抽象概念瞬间落地。希望帮到你。
本文还有配套的精品资源,点击获取