拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SNL编译器源码解析:词法分析、递归下降与LL1实战

SNL编译器源码解析:词法分析、递归下降与LL1实战

简介:这份资源是面向高校计算机专业学生的编译原理课程设计完整源码包,基于C++实现SNL语言的词法分析、递归下降语法分析与LL1语法分析三大核心模块,适合正在做课程设计或希望把编译理论落地为代码的学习者。压缩包共36个文件,以9个h头文件与9个cpp源文件为主体,另有6个txt测试用例、4个xml配置、2个gif演示图及py、snl、pro、ui等辅助文件,整体约1.43MB,结构清晰便于按模块阅读。目前已有767人学习下载。源码中词法分析器负责识别关键字、标识符、常量与运算符并生成标记序列,递归下降部分为每个语法结构编写对应函数,LL1部分则涉及First集、Follow集计算与预测分析表构造,还配有图形界面展示分析过程。读者可借此理解编译器从词法到语法的完整工作流程,掌握左递归处理与LL1分析表构建等难点,为后续更复杂的编译器设计与优化打下实践基础。

1. 从一份 SNL 编译器源码说起:词法分析、递归下降与 LL1 到底怎么串起来

很多人学编译原理,课本上 First 集、Follow 集、预测分析表背得滚瓜烂熟,一到动手就卡壳——词法分析器怎么把字符流切成 Token?递归下降的函数到底谁调谁?LL1 分析表算出来之后又该怎么驱动栈?这份 SNLCompilerGraphic-master 就是冲着这三个问题来的。它用 C++ 实现了一个针对 SNL(Specific Notation Language)语言的完整前端,把词法分析、递归下降语法分析、LL1 语法分析三条线并排放在同一个工程里,还配了图形界面把每一步的 Token 序列、语法树、分析栈状态可视化出来。适合正在做编译原理课程设计的学生,也适合想拿一份能跑通的 C++ 编译器前端代码来对照课本理论的开发者。源码里带了 c1.txt 到 c5.txt 五个测试用例,覆盖了基本声明、表达式、控制流等典型结构,拿到手就能编译运行看效果。

2. 工程结构与构建:CMake 和 qmake 两套入口怎么选

2.1 源码目录拆解与模块职责

先把 SNLCompilerGraphic-master 解压后的目录结构理清楚,不然后面改代码容易迷路。根目录下有两套构建配置:CMakeLists.txt 和 SNLCompilerGraphic.pro,前者给 CMake 用,后者给 Qt 的 qmake 用。src 目录是核心,里面按功能拆成了几组文件:

文件职责
lex.h / lex.cpp词法分析器,字符流到 Token 序列
parse.h / parse.cpp递归下降语法分析器
ll1_parse.h / ll1_parse.cppLL1 分析表构建与栈驱动解析
globals.h全局 Token 类型、符号表、错误码定义
utils.h / utils.cpp文件读取、字符串处理等辅助函数
mainwindow.h / mainwindow.cpp / mainwindow.uiQt 图形界面主窗口
lexscene.h / lexscene.cpp词法分析结果的可视化场景
parsescene.h / parsescene.cpp语法分析过程的可视化场景
parseitem.h / parseitem.cpp语法树节点的图形项

snl_example 目录下放着 c1.txt 到 c5.txt 五个 SNL 源程序样例,res 目录里是 lex.gif 和 parse.gif 两个界面动图资源。README.md 里有基本的编译说明。整个工程是 Qt Widgets 应用,不是纯命令行工具,所以构建之前得先确认 Qt 环境。

2.2 用 CMake 构建的完整步骤

我一般优先走 CMake,因为跨平台省心。在工程根目录下开终端,按下面这套走:

# 创建独立的构建目录,避免污染源码树 mkdir build && cd build # 生成构建文件,-DCMAKE_PREFIX_PATH 指向你的 Qt 安装路径 cmake .. -DCMAKE_PREFIX_PATH=/opt/Qt/5.15.2/gcc_64 # 编译,-j 后面跟 CPU 核心数加速 make -j8

如果 Qt 装在系统默认路径下,CMAKE_PREFIX_PATH可以省略。CMakeLists.txt 里已经写好了find_package(Qt5 COMPONENTS Widgets REQUIRED)和target_link_libraries,正常情况下不需要手动改。编译产物是一个可执行文件,直接运行就能弹出图形界面。

2.3 用 qmake 构建的备选路径

有些课程环境里 Qt Creator 是主力,那就直接用 .pro 文件。用 Qt Creator 打开 SNLCompilerGraphic.pro,选好 Kit 之后点构建即可。命令行方式如下:

# 在工程根目录执行,生成 Makefile qmake SNLCompilerGraphic.pro # 编译 make -j8

两条路径的源码是同一套,区别只在构建系统。CMake 适合集成到 CI 或者非 Qt Creator 的 IDE 里,qmake 适合纯 Qt 开发流。我建议先用 CMake 跑通,确认环境没问题之后再切到 qmake 做界面调试。

提示:如果编译时报fatal error: QApplication: No such file or directory,说明 Qt 开发头文件没装全,Linux 下需要装qtbase5-dev或对应版本的 dev 包。

3. 词法分析器怎么切 Token:从字符流到标记序列的实现细节

3.1 Token 类型定义与识别策略

词法分析的第一步是定义清楚 SNL 语言里有哪些 Token 类型。打开 globals.h,能看到一个枚举或者常量列表,把关键字(如program、var、begin、end、if、then、else、while、do、read、write)、标识符、整数常量、运算符(+、-、*、/、=、<、>)、界符(;、,、(、)、.)都列了出来。每个 Token 用一个结构体或类表示,通常包含类型和值两个字段。

lex.cpp 里的核心是一个getToken()函数,它从源文件字符流里逐个读取字符,跳过空白和注释,然后根据当前字符判断进入哪条识别分支。标识符和关键字的识别逻辑是:先读一个字母开头,继续读字母或数字直到非字母数字字符,然后把读到的字符串拿去和关键字表比对,命中就是关键字,否则是标识符。整数常量的识别是连续读数字字符,然后std::stoi转成数值。

3.2 手写词法分析器的关键代码段

下面这段代码还原了 lex.cpp 里最核心的扫描逻辑,我做了简化但保留了关键结构:

// 从源文件流中读取下一个 Token Token Lexer::getToken() { Token token; skipWhitespaceAndComments(); // 跳过空白和注释 char ch = peek(); // 看当前字符,不前进 if (isalpha(ch)) { // 字母开头:可能是关键字或标识符 std::string word = readWhile([](char c) { return isalnum(c) || c == '_'; }); token.type = isKeyword(word) ? KEYWORD : IDENTIFIER; token.value = word; } else if (isdigit(ch)) { // 数字开头:整数常量 std::string num = readWhile([](char c) { return isdigit(c); }); token.type = CONSTANT; token.value = num; } else { // 运算符和界符,单字符或双字符 token.type = matchOperatorOrDelimiter(); token.value = std::string(1, ch); advance(); // 前进一个字符 } return token; }

逻辑说明:skipWhitespaceAndComments()负责把空格、制表符、换行以及//或/* */注释吃掉,保证后续读到的第一个字符是有意义的。peek()和advance()是一对游标操作,前者只看不移动,后者移动读取位置。readWhile是一个循环读取的辅助函数,传入一个判断谓词,返回读到的字符串。关键字判断用的是一个std::set<std::string>或者std::unordered_set,查找复杂度 O(1)。

参数说明:token.type是枚举值,后续语法分析器靠它做分支判断;token.value是原始字符串,用于错误提示和符号表插入。如果遇到无法识别的字符,比如@或#,应该走错误处理分支,记录行号和列号,方便定位。

3.3 测试用例怎么跑与结果怎么看

工程自带的 c1.txt 到 c5.txt 是现成的输入。在图形界面里点“打开文件”选中其中一个,再点“词法分析”,界面会展示 Token 序列。c1.txt 通常是最简单的声明语句,c5.txt 可能包含嵌套的 if-else 或 while 循环。我建议按 c1 到 c5 的顺序逐个跑,观察 Token 序列的变化,特别是关键字和标识符的区分是否正确。

如果某个标识符被误判成关键字,检查关键字表里是不是多写了或者大小写没统一。SNL 语言的关键字一般全小写,如果源码里写了Program,应该被识别为标识符而不是关键字。这个边界在 globals.h 的关键字初始化里能改。

4. 递归下降语法分析:每个非终结符一个函数,怎么组织不翻车

4.1 递归下降的函数映射与调用关系

递归下降的核心思想很朴素:文法里每个非终结符对应一个 C++ 函数,函数体按照产生式的右部依次调用其他函数或者匹配终结符。打开 parse.cpp,能看到parseProgram()、parseDeclarations()、parseStatement()、parseExpression()这样一组函数。parseProgram()是入口,它先匹配program关键字,然后调用parseDeclarations()处理变量声明,再调用parseStatement()处理语句序列,最后匹配end。

每个函数的典型结构是:先看当前 Token 是不是自己期望的,如果是就消费掉并前进,如果不是就报错或者走备选分支。比如parseStatement()里会根据当前 Token 是if、while、read、write还是标识符,分派到不同的处理逻辑。这种“看一个 Token 决定走哪条路”的模式,就是 LL(1) 的雏形。

4.2 表达式解析与左递归消除

表达式解析是递归下降里最容易翻车的地方。如果文法写成Expr -> Expr + Term | Term,直接翻译成函数会导致无限递归,因为parseExpr()一进来就调自己。标准做法是消除左递归,改写成Expr -> Term Expr',Expr' -> + Term Expr' | ε。对应到代码里就是两层函数:

// 解析表达式:Term 后跟可选的 (+|- Term) 序列 ASTNode* Parser::parseExpression() { ASTNode* left = parseTerm(); // 先解析一个 Term while (currentToken.type == OPERATOR && (currentToken.value == "+" || currentToken.value == "-")) { std::string op = currentToken.value; advance(); // 消费运算符 ASTNode* right = parseTerm(); // 解析右边的 Term // 构建二元运算节点,左结合 left = new BinaryOpNode(op, left, right); } return left; } // 解析项:Factor 后跟可选的 (*|/ Factor) 序列 ASTNode* Parser::parseTerm() { ASTNode* left = parseFactor(); while (currentToken.type == OPERATOR && (currentToken.value == "*" || currentToken.value == "/")) { std::string op = currentToken.value; advance(); ASTNode* right = parseFactor(); left = new BinaryOpNode(op, left, right); } return left; }

逻辑说明:parseExpression()先调parseTerm()拿到左操作数,然后在一个 while 循环里检查当前 Token 是不是+或-。如果是,消费掉运算符,再调parseTerm()拿右操作数,把两者组合成一个新的二元运算节点。循环继续,直到当前 Token 不是加减运算符为止。这样就实现了左结合的多项加减,而且没有左递归。

参数说明:currentToken是当前正在看的 Token,advance()把它替换成下一个。BinaryOpNode是语法树节点,保存运算符和左右子树指针。parseFactor()负责处理括号和基本操作数,遇到(就递归调parseExpression(),遇到标识符或常量就生成叶子节点。

4.3 错误恢复与同步机制

递归下降的另一个坑是错误恢复。如果输入里少了一个分号,解析器不能直接崩溃退出,得想办法跳过一些 Token 继续往下走,尽量多报几个错误。常见做法是在每个语句解析函数的末尾检查分号,如果没看到就报错,然后跳到下一个分号或者end为止。parse.cpp 里应该有类似的synchronize()函数,把当前 Token 一直消费到遇见;或者语句起始关键字为止。

注意:错误恢复做得好不好,直接影响课程设计的演示效果。如果一遇到错误就退出,老师给个带小错的测试用例就露馅了。

5. LL1 分析表构建与栈驱动:First 集、Follow 集算完怎么用

5.1 First 集与 Follow 集的计算逻辑

LL1 分析和递归下降是两条路,但目标一样:根据当前非终结符和向前看一个 Token,决定用哪条产生式。ll1_parse.cpp 里首先要做的是计算 First 集和 Follow 集。First 集的含义是“一个符号串可能推导出的首终结符集合”,Follow 集是“一个非终结符后面可能紧跟的终结符集合”。

计算 First 集的算法是迭代到不动点:对于每条产生式A -> X1 X2 ... Xn,先把 X1 的 First 集(去掉 ε)加入 A 的 First 集;如果 X1 能推导出 ε,就继续看 X2,以此类推。如果所有 Xi 都能推导出 ε,那把 ε 也加入 A 的 First 集。Follow 集的计算类似:开始符号的 Follow 集包含$;对于产生式A -> αBβ,把 First(β) 去掉 ε 加入 Follow(B);如果 β 能推导出 ε,把 Follow(A) 加入 Follow(B)。

5.2 预测分析表的构建与冲突处理

有了 First 集和 Follow 集,构建预测分析表就水到渠成。表的行是非终结符,列是终结符,单元格填产生式编号。对于每条产生式A -> α,对 First(α) 里的每个终结符 a,把A -> α填入M[A][a];如果 α 能推导出 ε,对 Follow(A) 里的每个终结符 b,也填入A -> α。

如果同一个单元格被填了两次,说明文法不是 LL1 的,存在冲突。SNL 语言的文法通常是 LL1 的,但如果你自己改了文法,可能会引入冲突。常见的冲突来源是公共左因子和左递归,前者需要提取左因子,后者需要消除左递归。

5.3 栈驱动解析的完整流程

LL1 解析器的主循环用一个栈来模拟推导过程。栈初始放开始符号,然后循环读输入 Token:

// LL1 栈驱动解析主循环 void LL1Parser::parse() { std::stack<std::string> stk; stk.push(startSymbol); // 开始符号入栈 int pos = 0; // 输入 Token 序列的当前位置 while (!stk.empty()) { std::string top = stk.top(); std::string input = tokens[pos].value; if (isTerminal(top)) { // 栈顶是终结符,必须和当前输入匹配 if (top == input) { stk.pop(); pos++; } else { reportError("期望 " + top + ",实际 " + input); return; } } else { // 栈顶是非终结符,查预测分析表 std::string prod = table[top][input]; if (prod.empty()) { reportError("无法为 " + top + " 和 " + input + " 找到产生式"); return; } stk.pop(); // 产生式右部逆序入栈 std::vector<std::string> rhs = splitProduction(prod); for (auto it = rhs.rbegin(); it != rhs.rend(); ++it) { if (*it != "ε") stk.push(*it); } } } }

逻辑说明:栈顶是终结符时,必须和当前输入 Token 完全一致,匹配成功就双双前进;栈顶是非终结符时,用table[top][input]查表拿到产生式,把栈顶弹出,然后把产生式右部逆序压栈。逆序是为了让最左符号在栈顶,下次循环先处理它。如果查表为空或者终结符不匹配,就报错。

参数说明:startSymbol是文法的开始符号,通常是Program。tokens是词法分析输出的 Token 序列,末尾要加一个$表示结束。table是二维 map 或者二维数组,键是非终结符和终结符的组合。splitProduction把产生式右部按空格拆成符号列表。

5.4 递归下降与 LL1 的对比与选型

两条路都走通之后,可以对比一下。递归下降代码直观,每个函数对应一条文法规则,调试方便,但文法改动后要手动改函数。LL1 分析表是数据驱动的,改文法只需要改表,代码不用动,但表构建的逻辑要写对。课程设计里通常要求两种都实现,这份源码正好满足。如果只选一种,我建议递归下降用于快速原型,LL1 用于展示理论完整性。

6. 避坑与排查:这份源码跑不起来时先看这几条

6.1 编译报错找不到 Qt 头文件

现象:fatal error: QApplication: No such file or directory或者QtWidgets/QApplication: No such file or directory。

原因:Qt 开发包没装,或者 CMake 没找到 Qt 的安装路径。

解决:Linux 下sudo apt install qtbase5-dev qt5-qmake,macOS 下用brew install qt@5,Windows 下确认 Qt 安装时勾选了对应版本的 MSVC 或 MinGW 组件。CMake 构建时显式指定-DCMAKE_PREFIX_PATH指向 Qt 的lib/cmake上级目录。

6.2 词法分析结果里标识符和关键字混淆

现象:源码里写program被识别成标识符,或者Program被识别成关键字。

原因:关键字表初始化时大小写没统一,或者比对时用了==但字符串里有不可见字符。

解决:打开 globals.h 检查关键字集合的初始化,确认所有关键字都是小写。在isKeyword函数里加一句std::transform把输入转小写再比对,或者严格按 SNL 语言规范要求源码全小写。

6.3 递归下降解析时栈溢出

现象:程序运行到某个测试用例时崩溃,报stack overflow或者段错误。

原因:文法里有左递归没消除,导致某个解析函数无限递归调用自己。

解决:检查 parse.cpp 里每个函数的调用链,确认没有parseExpr -> parseExpr这样的直接或间接自调用。如果有,按第 4 章的方法消除左递归,改成循环加尾调用的形式。

6.4 LL1 分析表出现空单元格

现象:解析到某个 Token 时查表返回空,报“无法找到产生式”。

原因:First 集或 Follow 集算错了,或者文法本身不是 LL1 的。

解决:先打印 First 集和 Follow 集,和手工计算的结果比对。重点检查 ε 产生式的处理:如果某个非终结符能推导出 ε,它的 Follow 集要正确传递。如果确认集合没问题但表里还是有空,说明文法有冲突,需要提取左因子或消除左递归。

6.5 图形界面打开文件后没反应

现象:点了“打开文件”选了 c1.txt,但 Token 列表和语法树都是空的。

原因:文件路径里有中文或空格,std::ifstream打开失败但没报错;或者界面刷新逻辑没触发。

解决:把测试文件放到纯英文路径下再试。在openFile函数里加一句if (!file.is_open()) { QMessageBox::warning(...); return; }确认文件是否真的打开了。如果文件打开了但界面没刷新,检查lexscene和parsescene的更新信号有没有正确连接。

7. 进阶技巧:把这份源码改成你自己的课程设计

7.1 扩展 SNL 语言的一个新语法结构

假设你要加一个for循环,语法是for i := 1 to 10 do ... end。需要改三个地方:globals.h 里加for、to、do三个关键字;lex.cpp 的关键字表里注册它们;parse.cpp 里加一个parseForStatement()函数,在parseStatement()的分派逻辑里根据for关键字调用它。LL1 那边还要改文法、重算 First 和 Follow 集、重建分析表。改完之后用一个新的测试用例验证,确保词法、递归下降、LL1 三条路都能正确处理。

7.2 用可视化调试语法树构建过程

mainwindow.cpp 和 parsescene.cpp 里已经有一套语法树绘制的逻辑。如果你想更直观地看递归下降的调用过程,可以在每个parseXxx()函数入口加一句qDebug() << "enter parseXxx, token=" << currentToken.value;,出口加一句qDebug() << "exit parseXxx";。运行后在 Qt Creator 的应用程序输出窗口就能看到完整的调用轨迹,对照语法树看,哪个函数在哪个 Token 上进入和退出一目了然。

7.3 批量测试与结果对比

c1.txt 到 c5.txt 五个用例手动跑一遍还行,如果要加更多用例,可以写一个简单的批处理脚本:

# 批量跑所有测试用例,输出 Token 数量和解析结果 for f in snl_example/c*.txt; do echo "=== $f ===" ./SNLCompilerGraphic --cli "$f" 2>&1 | tail -5 done

前提是源码里支持命令行模式。如果不支持,可以在 main.cpp 里加一个--cli参数分支,不走 Qt 界面,直接调词法分析和语法分析函数,把结果打印到标准输出。这样就能集成到自动化测试里,改完代码跑一遍脚本就知道有没有回归。

7.4 我踩过的一个血泪坑

第一次跑这份源码的时候,我用的是系统自带的 Qt 5.9,编译过了但界面里的中文字体全是方块。折腾了半天以为是编码问题,后来发现是 Qt 5.9 的字体配置和测试环境不匹配。换成 Qt 5.15 之后直接就好了。从那以后我每次拿到 Qt 工程,都先确认版本号,再检查QApplication::setFont有没有设置合适的字体。这份源码的 README 里没写 Qt 版本要求,但实测 5.12 以上都能跑,建议用 5.15 或 6.2 的 LTS 版本。

希望帮到你。

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

返回列表