☰
C++实现LL(1)语法分析器:课设可用的表驱动源码与调试指南
2026/10/10 15:26:28 网站建设 项目流程

简介:这是一份编译原理课程中语法分析实验的C++实现方案,面向计算机相关专业学生,解决的是基于词法分析结果,采用递归子程序法识别各类语法成分,并按要求输出单词信息与语法成分名的问题。代码在CG实验平台满分通过,具备较强参考价值。

资源包共2个文件,包含doc格式的实验问题描述文档和cpp格式的完整源码,压缩包仅17KB,结构精简,便于直接下载研读。目前已有6107人学习浏览,说明该方案得到了不少同类学习者的认可。

读者可获得完整可运行的语法分析程序,以及对应的问题描述说明,用于对照理解递归下降分析流程、预读处理、输出格式控制等关键实现细节。适合正在完成编译原理课程设计、需要参考实验代码或准备平台评测的同学下载学习。

1. 编译原理语法分析实验(C++版):一份能直接改文法的课设源码

编译原理课的语法分析实验,C++ 版是公认的“看起来会,写起来废”。理论课能看懂 FIRST 集怎么算,拿到题目却不知道第一个类该叫什么。我拆的这套资源,是一份完整可编译的语法分析实验工程:核心是 LL(1) 预测分析器,带文法配置文件、Token 接口、分析表构建和报错定位。它解决的核心问题只有一个——把文法变成能跑的代码,并且换文法时不用改 C++ 源码。适合正在做课设的学生、想快速复用解析器骨架的开发者,以及被左递归和 FIRST 集折磨到想放弃的人。

2. 语法分析的实现选型:LL(1) 表驱动与递归下降怎么挑

在写任何代码之前,先要明白语法分析器在整个编译流程里的位置。词法分析把源代码切成 Token 流,语法分析器负责按文法规则把这堆 Token 组织成结构——要么输出推导过程,要么构建一棵语法树。判断“输入是否符合文法”只是表面要求,真正重要的是为后续语义分析准备好结构化的中间表示,这就是为什么实验一定要你写分析器的原因。

2.1 LL(1) 为什么是课程实验的默认答案

LL(1) 表示三个约束:从左到右扫描输入、产生最左推导、每一步向前看 1 个 Token 就能决定用哪条产生式。课程实验选它而非 LR(1),核心原因是表构造足够直观。LR(1) 家族要构造项目集规范族,状态可能上百个,ACTION 表和 GOTO 表错一个数字整台状态机就乱掉,调试成本对一次作业来说太高。

LL(1) 的代价是文法必须经过改写:左递归要消除,公共左因子要提取。比如四则运算文法原始写法是expr -> expr + term | term,这种直接拿去建分析表,FIRST 集算完就是一堆冲突。需要先改写成右递归形式:

expr → term expr' expr' → + term expr' | ε term → factor term' term' → * factor term' | ε factor → ( expr ) | id | num

这个文法对应优先级关系:expr' 处理加法、term' 处理乘法、factor 处理括号和原子,优先级通过文法层级的嵌套天然体现。改完之后的分析表中每个格子只有一个候选,这正是后面代码能用表驱动的前提。

2.2 递归下降与表驱动:两种实现路线的工程量对比

同一份 LL(1) 文法,有两类写法。递归下降是每个非终结符写一个 C++ 函数,函数体里按候选依次调用其它函数,代码读起来完全对应文法,调试时可以看调用栈,定位到某一步推导。它的坑有两个:一是间接左递归非常难肉眼排查;二是每改一条文法就要改函数,实验报告不好写“可配置”。

表驱动则是把分析逻辑收敛成“一张表 + 一个栈 + 一个循环”。文法变了只改配置文件,C++ 代码一行不动。代价是要多写文法加载、FIRST/FOLLOW 计算和表构建三段代码,工程量前移。就我经验,如果老师要求“文法可配置、支持多组测试用例”,表驱动写起来更稳;如果只针对一个固定文法、追求代码量少,递归下降更快。

维度递归下降表驱动
代码结构一个非终结符一个函数一张表 + 一个循环
左递归问题必须消除,间接左递归难排查表构建阶段冲突会暴露
调试手段调用栈可见,直观需要打印栈状态辅助
扩展文法每加产生式要加函数只改配置文件
报告友好度一般较高,可展示表内容

这份资源走的是表驱动路线,后面所有拆解都围绕这条路线展开。理由很现实:课设答辩时,考官常做的一件事就是现场改一条文法让你重新跑,表驱动是唯一能让他不挑刺的结构。

2.3 FIRST 集与 FOLLOW 集:手工算一遍再写代码

预测分析表的每个格子存“当前非终结符 + 当前输入终结符 → 选用哪个候选”,决策依据就是 FIRST 集和 FOLLOW 集。FIRST 集表示“一个符号串开头可能出现哪些终结符”,FOLLOW 集表示“某个非终结符后面可能紧跟哪些终结符”。ε 产生式是难点:只有存在 ε 候选时,才需要去看 FOLLOW 集兜底。

我习惯动手写代码前先把这两个集合在纸上算一遍,因为代码里任何一步算错,最终表现就是分析表里出现冲突或空格子,到时候再回头找哪一步错,比重新算还累。用上面表达式文法,手工算完的结果是:

非终结符FIRSTFOLLOW
expr( id num$ )
expr'+ ε$ )
term( id num+ $ )
term'* ε+ $ )
factor( id num* + $ )

注意 expr' 的 FOLLOW 里有 $ 和右括号,term' 的 FOLLOW 里混入了加法运算符,这两个都是 ε 产生式引起的。如果计算结果和这张表对不上,先回头检查 ε 产生式的位置,这是最常见的计算错误来源。

3. 核心模块拆解:Token 流、预测分析表与驱动循环的 C++ 实现

这一章把表驱动分析器的几个核心模块逐个拆开,代码按“接口 → 数据 → 循环”的顺序递进。拿到资源包后,你应该能照着这张地图快速定位每个文件里哪段代码对应什么职责,而不是打开工程从头翻到尾。

3.1 Token 接口:语法分析器只认类型不认文本

语法分析器的输入是 Token 流,但不关心标识符具体叫什么名字,只关心它是“标识符”还是“数字”还是“加号”。所以 Token 结构体里 type 是核心字段,lexeme 和 line 是报错服务的附属信息。词法分析器逐个吐出 Token,语法分析器通过统一的 getNextToken 接口消费:

// token.h #ifndef TOKEN_H #define TOKEN_H #include <string> enum TokenType { TOK_ID, // 标识符 TOK_NUM, // 数字常量 TOK_PLUS, // + TOK_MUL, // * TOK_LPAREN, // ( TOK_RPAREN, // ) TOK_END // 输入结束标记 }; struct Token { TokenType type; // 决定性字段 std::string lexeme; // 原始文本,报错打印用 int line; // 行号,定位错误位置 }; Token getNextToken(std::istream& in); #endif

type 用枚举而不是字符串,是为了让查表逻辑避免字符串比对的开销和出错概率。lexeme 保留原始文本,是因为报错时要告诉用户“第 3 行出现了一个无法处理的符号 #”,只有类型的话用户根本不知道哪里错了。line 字段是血泪经验,没有行号的语法分析器在实验验收时会被老师反复要求改。

3.2 预测分析表的存储:STL 容器,别用固定二维数组

预测分析表的行是非终结符,列是终结符,格子内容是产生式。很多初学者会下意识建一个 string table[行][列] 二维数组,但文法一换行列就不对,还要手动维护索引到符号名的映射。我一般用 map 套 pair 当复合键,查询语义和表的概念完全一致,符号增量加入也不用改代码结构:

// grammar.h #include <map> #include <vector> #include <string> using SymbolList = std::vector<std::string>; struct Production { std::string lhs; // 左部非终结符 SymbolList rhs; // 右部符号序列,空表示 ε }; // 分析表:key = (栈顶非终结符, 当前输入终结符) using ParseTable = std::map<std::pair<std::string, std::string>, Production>;

rhs 用 vector<string> 而不是单个字符串,是因为候选产生式右部通常是多个符号的序列,比如 expr -> term expr' 的 rhs 是 {"term", "expr'"}。ε 产生式对应空 vector,加载时遇到 ε 就跳过压栈,逻辑上等价于“这一步什么都不推导”。

3.3 加载文法文件:格式约定与解析细节

表驱动方案里,文法文件是分析器的灵魂。资源包里默认的 grammar.txt 每一行描述一个非终结符的所有候选,用竖线分隔,井号开头是注释:

# 四则运算文法,每一行:非终结符 -> 候选1 | 候选2 | ... expr -> term expr' expr' -> + term expr' | ε term -> factor term' term' -> * factor term' | ε factor -> ( expr ) | id | num

加载代码的核心是逐行读取,把每行拆成左部和多个候选:

bool loadGrammar(const std::string& path, ParseTable& table) { std::ifstream in(path); if (!in.is_open()) { std::cerr << "无法打开文法文件: " << path << std::endl; return false; } std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] == '#') continue; size_t pos = line.find("->"); if (pos == std::string::npos) continue; std::string lhs = trim(line.substr(0, pos)); std::string body = line.substr(pos + 2); // 用 '|' 切出多个候选,逐个存入 table for (auto& candidate : split(body, '|')) { Production prod; prod.lhs = lhs; for (auto& sym : split(trim(candidate), ' ')) { if (sym != "ε") prod.rhs.push_back(sym); } // 关键:根据 FIRST/FOLLOW 计算结果决定存到哪个 (lhs, token) 格子 fillTableEntry(table, prod); } } return true; }

trim 和 split 是两个工具函数,前者去掉字符串首尾空格,后者按分隔符切分。注意 loadGrammar 只负责读入产生式,真正决定“这个候选对应哪个终结符”的是 fillTableEntry。它的规则沿用 2.3 节的结论:右部第一个符号是终结符就直接用它,非终结符就取它的 FIRST 集,如果整个右部能推导出 ε,还要并入左部的 FOLLOW 集。这一步最容易错,错的结果就是表里出现空格子。

3.4 驱动循环:栈、输入指针和报错恢复

分析器的主循环用“栈 + 输入指针”模拟最左推导。栈顶是终结符就对碰消费,栈顶是非终结符就查表替换,整套逻辑大约四十行:

bool parse(const ParseTable& table, const std::vector<Token>& tokens) { std::vector<std::string> stk; stk.push_back("$END"); stk.push_back(startSymbol); // 默认 "expr" size_t pos = 0; while (!stk.empty()) { std::string top = stk.back(); std::string cur = tokenToString(tokens[pos].type); if (top == cur) { // 终结符对碰 stk.pop_back(); ++pos; continue; } if (!isNonTerminal(top)) { // 栈顶终结符不匹配 reportError(tokens[pos].line, "期望 " + top + " 但遇到 " + cur); return false; } auto it = table.find({top, cur}); if (it == table.end()) { // 表里没有该组合,语法错误 reportError(tokens[pos].line, "无法为 " + top + " 选择候选"); return false; // 简单版本直接终止 } stk.pop_back(); const auto& rhs = it->second.rhs; for (auto r = rhs.rbegin(); r != rhs.rend(); ++r) { stk.push_back(*r); // 逆序入栈 } } return pos == tokens.size(); }

两个细节值得记住。第一,右部逆序压栈,因为栈是后进先出,逆序压才能保证下一个要处理的符号在栈顶,推导顺序不乱。第二,tokens[pos] 取类型前要保证 pos 不越界,好在词法接口在末尾会补一个 TOK_END,驱动循环读到 TOK_END 时 tokenToString 返回 $END,正好和栈底的哨兵对碰,循环自然结束。

真正工程还要考虑报错恢复。上面代码遇到错误直接 return false,做作业够用;但想跑完一个测试文件里所有用例,最好加同步符号集合:遇到错误就丢 Token,直到遇到分号、右括号或 $END 再重试。这种 panic mode 是编译原理教材的标准做法,加代码成本很低,答辩时是加分项。

4. 在 VS2022 里跑通整套实验:工程配置与测试用例构造

代码逻辑归逻辑,跑不起来等于零。这一章按我拿到任何课设资源包的固定流程写:先清点文件,再建工程导入,最后构造测试用例。这套流程改一改能用在任何 C++ 小项目上。

4.1 先清点文件:每个文件负责什么

拿到资源包第一件事不是编译,是打开目录看结构。常见布局是头文件和源文件分开,外加一个文法配置文件和若干测试输入。以我拆的这套为例:

文件职责
main.cpp入口,处理命令行参数、调用词法与语法分析
token.h / tokenizer.cppToken 定义与词法接口实现
grammar.h / grammar.cpp文法加载、FIRST/FOLLOW 计算、分析表构建
parser.h / parser.cpp表驱动主循环与报错输出
grammar.txt默认四则运算文法,可替换
test_cases.txt一组带预期结果的测试输入

如果你的资源包布局略有不同,别慌,按依赖关系判断:main 依赖 parser,parser 依赖 grammar 和 token,grammar 依赖 token。理清这个依赖链,就知道编译错误应该从哪个文件开始查。

4.2 新建工程与编译配置三步走

用 VS2022 为例。第一步,新建空项目,选 C++ 控制台应用,把源文件和头文件全部拖进工程目录。第二步,把工程字符集调成和源码一致,这里最容易翻车——源代码文件如果是 UTF-8 无 BOM,VS2022 会按 GBK 解析,中文注释会乱码,字符串字面量里的中文字节也会错位,导致 Token 比对莫名其妙失败。

第三步,把 MSVC 的 C4996 安全警告提前堵上。语法分析器里免不了用 fopen、strcpy 这类老接口,安全检查默认把它们当编译错误。加宏后编译直接通过,不用把所有调用改写成 _s 版本。如果你用 VS Code 配 C/C++ 插件,命令行编译的方式同样适用,注意编译器路径和调试器配置就行:

# Linux 或 MinGW 环境下等价于 IDE 里加宏 g++ -D_CRT_SECURE_NO_WARNINGS main.cpp parser.cpp grammar.cpp tokenizer.cpp -o syntax_analyzer

IDE 操作路径是:右键工程 → 属性 → C/C++ → 预处理器 → 预处理器定义,把_CRT_SECURE_NO_WARNINGS加进去,确定后重新编译。命令行只是展示这个宏的作用,实际课设一般用 IDE 界面操作。

注意 VS 调试时的工作目录默认是工程文件所在目录,不是 .exe 所在目录。grammar.txt 如果放在源码同级目录,直接写相对路径即可,不用纠结绝对路径。

4.3 调试时看什么:三行日志定位黑匣子

表驱动分析器对新手是个黑匣子:表内容看不到,栈状态看不到,输入读到哪也看不到。改动任何东西之前,我先把三处调试日志加上——加载文法后打印产生式数量,构建表后打印表条目数,驱动循环里每次压栈弹栈打印当前栈顶和输入符号:

// 在 parse 的 while 循环开头加 #if DEBUG_LOG std::cout << "[stack] top=" << top << " cur=" << cur << std::endl; #endif

DEBUG_LOG 是个宏,定义成 1 就输出,定义为 0 就彻底关掉。调试完记得关,否则测试结果会被海量日志淹没。这三行日志能把“分析表是空的”“栈陷入循环”“终结符对不上”三类问题直接从玄学变成可见的推导过程。

4.4 测试用例怎么构造:合法、非法、边界三件套

实验验收一般看三件事:合法输入能不能接受、非法输入能不能报错、边界输入会不会崩。所以测试用例至少分三组。典型的一组用例:

输入内容预期行为
1+2*3接受,乘法优先于加法
(1+2)*3接受,括号优先级最高
1+2*报错,* 后面缺操作数
(1+2报错,缺少右括号
1#2报错,# 不是合法 Token

运行方式用命令行参数最省事:

./syntax_analyzer grammar.txt test_cases.txt

main 里解析两个参数:第一个是文法文件路径,第二个是测试输入路径,输出逐条打印“通过/失败”和错误位置。如果你拿到的版本是硬编码文件名的,直接在 main.cpp 里改字符串即可。

5. 避坑记录:语法分析实验最常见的五个翻车点

下面五条全是实际调试中遇到过的问题,每一条都按“现象 → 原因 → 解决”写,直接对照你的报错行为查就行。

5.1 程序死循环,CPU 占用拉满

现象:输入合法的表达式后,程序迟迟不结束,任务管理器里进程单核 100%。

原因:文法文件中存在间接左递归,比如 A → B x、B → A y,表构建时没有检测到循环推导,驱动循环里栈越来越大,陷入无限展开。

解决:先手工做左递归消除,把所有间接路径改成直接路径再消除;同时在驱动循环里加一个最大栈深限制,这样即便文法写错了,程序也能正常报错而不是挂死:

if (stk.size() > 1000) { std::cerr << "推导过深,疑似左递归未消除" << std::endl; return false; }

这个限制是后悔药,宁可误报也要保证程序能终止。数值 1000 对课设文法完全够用,正常表达式推导深度不会超过几十层。

5.2 中文输出全是乱码

现象:报错信息里的中文提示变成乱码,更隐蔽的是文法文件里写的终结符文本和 Token 比对不上,语法分析一直误报。

原因:源码文件是 UTF-8 无 BOM 编码,VS2022 默认按 GBK 编译。字符串字面量在两种编码下的字节序列不同,终端显示自然崩了。

解决:把源文件统一另存为 UTF-8 带 BOM。如果实验报告要求 GBK,就反过来,把文件存成 GB2312 并保持工程默认字符集。核心原则只有一条:源代码文件编码和编译器解析编码必须一致。

5.3 fopen、strcpy 报 C4996 编译错误

现象:编译到一半报 error C4996,提示用 fopen_s 替代 fopen。

原因:MSVC 默认启用 CRT 安全检查,把不安全的传统函数当错误处理。

解决:工程属性 → C/C++ → 预处理器 → 预处理器定义,加_CRT_SECURE_NO_WARNINGS。不改函数调用,编译立刻通过。这个宏在学完这门课之前可以一直用,等你要写生产代码时再逐个换成安全版本,不要在课设阶段耗时间。

5.4 文法文件加载成功但分析表是空的

现象:程序正常跑,不报文件错误,但任何输入都被拒绝。调试发现 table.size() 为 0。

原因:ifstream 打开失败后没有检查 is_open();或者文法文件首行带着 UTF-8 的 BOM 头,读取第一行时 lhs 字符串前面混入了不可见字节,解析失败。

解决:加载函数里打开文件后立刻判断 is_open(),失败就输出路径;读第一行前用代码剔除 BOM 三字节(0xEF 0xBB 0xBF)。同时加载完成后打印一条“已加载 N 条产生式”的日志,一眼看出有没有读进来。

5.5 输入全部跑完后栈里还残留符号

现象:输入内容完全正确,但程序最后报错,说栈不是空的,无法正常结束。

原因:输入文件末尾没有显式结束标记,或者词法接口把文件末尾的换行符当成一个终结符读进来了,输入指针已经走完,栈里还有未匹配的非终结符。

解决:词法接口在文件末尾必须返回 TOK_END,驱动循环把 TOK_END 映射为 $END 哨兵与栈底对碰。读取 Token 时用流运算符 >> 跳过空白字符,避免把换行符纳入 Token。这两处改完,合法输入才能干干净净走完整个循环。

6. 进阶玩法:给分析器加一棵语法树,把推导过程可视化

表驱动分析器默认只输出接受或拒绝,这够交作业但不够理解原理。我一般会加一个最小的 AST 打印功能:每次展开非终结符时创建一个节点,把右部符号挂成它的子节点;推导结束后,从根节点按缩进递归打印,整个语法结构一目了然。

#include <iostream> #include <vector> #include <string> #include <memory> struct ASTNode { std::string symbol; std::vector<std::unique_ptr<ASTNode>> children; // 子节点 bool isLeaf() const { return children.empty(); } }; void printTree(ASTNode* node, int depth) { for (int i = 0; i < depth * 2; ++i) std::cout << ' '; std::cout << node->symbol << '\n'; for (auto& child : node->children) { printTree(child.get(), depth + 1); } }

printTree 里 depth 乘以 2 是为了每层缩进两个空格,递归输出时父节点先打印,子节点依次缩进。这就是教材里语法树方格图在控制台下的等价物。验证方式很简单:输入 1+2*3,你应该看到 factor 层的 1 先展开,乘法子树在加法子树之下嵌套出现,说明分析树忠实还原了优先级。每次打印前用一条分隔线分段,多个测试用例的输出就不会混在一起,看结果时舒服很多。

从那以后我每次拿到课设源码,第一件事都是先看 token 定义和文法文件,理清楚数据长什么样再碰编译按钮,这个习惯帮我避掉了后面几乎所有的玄学问题。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询