简介:吉林大学计算机学院2022年编译原理课程设计项目,是一份基于C++实现的SNL语言编译器完整方案,面向需要完成同类课程设计或系统学习词法分析、语法分析、语义分析的本科生。项目采用LL(1)分析法构建语法解析,配合词法分析器、语义分析器与符号表管理,代码模块划分清晰,且由作者独立完成、相似性低,适合直接借鉴或二次拓展。压缩包共91个文件,以cpp、h源文件为主体,涵盖词法分析、LL(1)语法分析、语义分析等关键模块,另含obj、exe、pdb等编译产物、Visual Studio工程文件,以及多组txt测试用例与错误测试文本;压缩包整体142.28MB。内容还涉及DS文法定义、保留字表、Token输出与语义检查用例,工程配置完整,便于构建运行和对照学习。已有1374人学习该资源,对于希望获得高分课程设计范例、深入理解编译原理各阶段实现的读者,具有较高的参考价值。
1. SNL 语言编译器课设源码包:为什么一个能拿满绩的参考值这个价
某高校的编译原理课程设计,要求手写一个 SNL 语言编译器,词法、语法、语义三个阶段全部真实跑通。SNL 是类 Pascal 的教学语言,规模不大,但保留字、算术表达式、函数调用、作用域和类型检查一个不少。网上能翻到的参考,要么是表达式计算器级别,要么是公开仓库里流传烂大街的模板,相似性高到不敢交。这份源码包是 2022 年的完整交付,C++ 编写,从 config 下的 reserved_words.txt 到 LexAnalyzer.cpp,再到 LL1.cpp、ParseLL1.cpp、SemAnalyzer.cpp 和 symtable.h,整条链路全在,还自带 lex_error_test、sem_error_test、sem_correct_test 三组测试样本。它适合正在写课设找不到语法树接语义思路的人,也适合已经写完词法、卡在 LL(1) 递归下降的人。下面我按自己复现的顺序,把文件、参数和坑一个个过一遍。
2. 从 main.cpp 到 SemAnalyzer:先盘清源码包的数据流
2.1 文件清单:哪些是核心代码,哪些是 Visual Studio 编译残留
解压后是一个标准的 Visual Studio 工程,Project2.sln 可以直接双击打开。先把根目录文件分个类,避免一头扎进 Debug 目录里看那些 obj 和 pdb。
| 文件/目录 | 定位 | 说明 |
|---|---|---|
| main.cpp | 程序入口 | 串联词法、语法、语义三个阶段,读源文件并输出结果 |
| config / reserved_words.txt | 词法配置 | 保留字表,LexAnalyzer 运行时加载 |
| LexAnalyzer.h / LexAnalyzer.cpp | 词法分析器 | 产出 Token 流,输出到 tokens.txt / new_tokens.txt |
| LL1.h / LL1.cpp | LL(1) 集合与冲突检测 | 计算 First/Follow 或维护预测逻辑 |
| ParseLL1.h / ParseLL1.cpp | 语法分析器 | 递归下降主程序,按文法产生式构建语法树 |
| ds_grammer.h | 文法定义 | 产生式、终结符/非终结符编号 |
| TreeNode.h / TreeNode.cpp | 语法树节点 | 孩子兄弟链表,供语义分析遍历 |
| Kind2Str.h / Kind2Str.cpp | 可读化输出 | 把节点枚举转成字符串,调试输出靠它 |
| SemAnalyzer.h / SemAnalyzer.cpp | 语义分析器 | 符号表查询、类型检查、作用域管理 |
| symtable.h / symtable.cpp | 符号表 | 记录变量/函数的名字、类型、层级、偏移量 |
| test1.txt ~ test8.txt | 输入样本 | 混合覆盖词法、语法、语义的正确与错误用例 |
| lex_error_test / sem_error_test / sem_correct_test | 分类测试集 | 分别用于验收词法错误、语义错误和完全正确程序 |
Debug 目录下的 vc142.pdb、Project2.pdb、.obj、.tlog 全是 Visual Studio 的编译残留,不影响理解逻辑。Project2.vcxproj.user 是用户级配置,换机器后建议直接删掉,让 VS 重新生成。
从 main.cpp 往下看,标准数据流是:读入源文件 → LexAnalyzer 产出 Token 流 → ParseLL1 按文法组织语法树 → SemAnalyzer 遍历语法树并查符号表 → 最后把结果打印到 rc.txt 或 outout.txt。tokens.txt 和 new_tokens.txt 同时存在,说明词法阶段至少有两种输出模式,常见的是普通 Token 流和带行号位置的 Token 流。调试词法错误时,这两个文件能直接告诉你错误发生在哪一行。
2.2 词法分析:reserved_words.txt 与 LexAnalyzer.cpp 怎么配合
很多人的词法分析是把关键字硬编码在几十个 if-else 里,这份资源把保留字单独放在 reserved_words.txt,代码里用映射表查类型。下面这个加载函数是这类实现的标准骨架,和你最后在 cpp 里看到的结构基本一致:
void LoadReservedWords(const std::string& path, std::unordered_map<std::string, int>& kw) { std::ifstream in(path); std::string word; int kind = 0; while (in >> word) { // 文件每行格式:保留字 类型编号,例如:if 1 else 2 while 3 // 这里把字符串和编号分别读进 word 和 kind in >> kw[word]; } }这段代码的逻辑说明:先用ifstream打开保留字文件,然后以空白为分隔符连续读。第一次in >> word拿到保留字本身,第二次in >> kw[word]读入后面的整数并直接作为映射值。unordered_map的选择有讲究,词法分析阶段每个标识符都要查一次表,哈希查找能保证均摊 O(1),不会成为瓶颈。
这样组织的好处,一是新增一个保留字只需要改 txt,不需要动代码;二是不容易出现满屏魔法数字,查重时的代码观感也干净很多。LexAnalyzer 里识别标识符的逻辑就是逐字符拼:遇到字母开头就持续收集字母和数字,拼完先查这个unordered_map,查得到就是保留字,查不到就是普通标识符。Token 本身一般是struct Token { int type; std::string value; int line_no; };,type 枚举定义在 macro.h 或 LexAnalyzer.h 里。
注意 reserved_words.txt 的编码必须和源文件一致。最常见的坑是 UTF-8 带 BOM,导致第一个关键字额外多出一个不可见字符,后面避坑章会专门讲。
2.3 语法树:TreeNode 的孩子兄弟链表与 Kind2Str 的可读化输出
一段程序光有 Token 流不够,语义分析要在一个能表达嵌套结构的树上跑。TreeNode.cpp 用的是孩子兄弟表示法,节点骨架长这样:
struct TreeNode { int kind; // 节点类型,对应 Kind2Str 里的枚举名 TreeNode* child; // 第一个子节点 TreeNode* sibling; // 右兄弟节点 std::string value; // 标识符名或字面量 int line_no; // 来源行号,语义报错全靠它 };为什么不用子节点数组?因为 SNL 文法里 if 语句的子节点个数不固定,有 else 是三个子节点,没有 else 是两个;用孩子兄弟链表可以动态挂节点,遍历函数统一走child和sibling两条指针即可。Kind2Str.cpp 就是把 kind 整数映射成字符串,比如某个节点 kind 是 101,打印出来是AddExpr,比对输出文件时一目了然。
打印语法树的递归函数一般长这样:
void PrintTree(TreeNode* node, int depth) { while (node != nullptr) { // 缩进表示层级,Kind2Str 负责把枚举转成可读名 std::cout << std::string(depth * 2, ' ') << Kind2Str(node->kind) << " " << node->value << std::endl; PrintTree(node->child, depth + 1); node = node->sibling; } }depth是当前树深度,每次递归子节点时加一;sibling在同一层横向遍历。main.cpp 遍历语法树后输出到 rc.txt 或 outout.txt,对照输出文件就能验证递归下降函数的调用顺序是否正确。
3. 语法分析:LL(1) 递归下降如何组织文法与预测
3.1 为什么选 LL(1) 而不是 LR:课设文法规模决定的取舍
从文件命名能看出设计意图:LL1.h/cpp 带着预测分析的味道,ParseLL1.cpp 是实际的递归下降入口。SNL 这种课程语言文法一般就四五十条产生式,用 LL(1) 手工写一组递归下降函数完全可控。LR 系虽然能处理更多文法,但要引入自动生成工具,课设老师更希望你解释清楚预测逻辑,所以 LL(1) 是这类资源的主流选择。
LL1.cpp 通常负责计算 First/Follow 集合,或者在建表过程中发现冲突时给出提示;ParseLL1.cpp 按非终结符写函数,一个非终结符对应一个 C++ 函数。ds_grammer.h 里的产生式就是这些函数的直接来源,比如expr → term { (+|-) term }对应的就是下面这样的函数。
3.2 四则运算表达式的递归下降:一个可直接抄的结构
算术表达式是最典型的一层。下面的代码是递归下降实现里必然会出现的结构,你会在 ParseLL1.cpp 里看到它的变体:
TreeNode* ParseExpr() { TreeNode* left = ParseTerm(); // 先解析优先级更高的项 while (cur_token == TOKEN_PLUS || cur_token == TOKEN_MINUS) { TokenKind op = cur_token; // 保存当前运算符 Match(cur_token); // 吃掉当前运算符 TreeNode* right = ParseTerm(); // 解析右侧操作数 TreeNode* parent = CreateNode(KIND_BIN_EXPR); parent->child = left; // 左操作数挂到 child left->sibling = right; // 右操作数挂到 sibling parent->value = TokenToText(op); // 记录运算符文本 left = parent; // 新节点作为左侧继续循环 } return left; }逻辑说明:ParseExpr先调ParseTerm,因为乘除优先级高于加减,这步是文法层级决定的。while循环处理同优先级运算符的左结合,每次构造一个KIND_BIN_EXPR父节点,把已有的left挂在child,新解析出来的right挂在sibling。因为 TreeNode 是孩子兄弟结构,"左操作数+右操作数"实际上是一条链,而不是两个独立指针。这个循环不会产生左递归,因为它把文法里的左递归改写成了迭代。
参数说明:cur_token是全局当前 Token,Match断言当前 Token 类型等于参数并推进到下一个。每个非终结符函数都必须在入口处至少调一次Match或NextToken,否则可能死循环。
3.3 if-else 悬空与同步恢复:语法错误后还能继续往下跑
if 语句是另一个高频踩坑点。LL(1) 递归下降处理 else 悬空,靠的是前看一个 Token:
TreeNode* ParseIfStmt() { Match(TOKEN_IF); TreeNode* cond = ParseExpr(); // 条件表达式 Match(TOKEN_THEN); TreeNode* thenStmt = ParseStmt(); // 必选分支 TreeNode* elseStmt = nullptr; if (cur_token == TOKEN_ELSE) { // 前看一个 Token 决定分支 Advance(); elseStmt = ParseStmt(); // 可选分支 } Match(TOKEN_END); return BuildIfNode(cond, thenStmt, elseStmt); }逻辑说明:解析完 then 分支后并不急着消费TOKEN_ELSE,而是看当前 Token 是不是 else。是则额外解析一个语句,不是则直接匹配END。这样嵌套的 if-else 会自动就近配对,不会出现悬空 else。注意这里ParseStmt的调用位置决定了递归深度,遇到复杂嵌套时要确认每个函数都在推进 Token。
语法错误不能一遇到就停,否则一个错误只报一行,后面全被屏蔽。常见做法是同步恢复:Match失败时,从出错位置跳过 Token,直到遇到分号、END、ELSE这类同步记号,再重新尝试。test1.txt、test2.txt 这类输入里故意放了几处缺分号或括号不匹配,输出能看出行号能报到哪里,这就是同步恢复在起作用。调试时重点看 ParseLL1.cpp 里所有return false或主动Abort的地方,错误信息通常会标unexpected token at line N。
4. 语义分析与符号表:symtable 与 SemAnalyzer 如何完成作用域检查
4.1 符号表设计:层级、类型、偏移量怎么填
语义分析的核心是符号表和类型检查。symtable.h 里的符号条目通常包含名字、类型、层级和偏移量。作用域是一个栈,全局在最底层,进入函数压一层,进入内层块再压一层。SNL 里同名变量能不能重复声明,就看栈顶那一层有没有同名条目。
一个可用的符号表条目结构长这样:
struct Symbol { std::string name; // 变量名或函数名 int type; // 0=int, 1=boolean, 2=array int level; // 0 是全局,1 是第一个函数级 int offset; // 局部变量在栈帧里的偏移或数组长度 bool is_param; // 是形参就在函数入口登记 };符号表按层分组,常见做法是std::vector<std::vector<Symbol>> scopes。声明变量时push_back到scopes[current_level],退出代码块时把整层pop_back。level字段不是必须的,因为从scopes的下标能推出来,但保留它能让调试信息更友好。
4.2 声明检查和类型检查落地:一边遍历语法树一边查表
语义分析从语法树根节点开始递归下降,和语法分析不同,它不再消费 Token,而是消费 TreeNode。赋值语句的类型检查是一个典型例子:
void CheckAssign(TreeNode* node) { Symbol* left = FindSymbol(node->child->value); // 左值查符号表 if (left == nullptr) { SemError("variable '%s' not declared", node->child->value.c_str()); // 未声明变量 return; } int rightType = ExprType(node->child->sibling); // 递归求右式类型 if (left->type != rightType) { SemError("type mismatch at line %d", node->line_no); } }逻辑说明:FindSymbol从最内层作用域向外层查,因为内层同名变量会遮蔽外层。这和声明重复检查正好相反,声明查重只看当前层,类型检查要看所有可见层。这两个方向很容易搞反,我的习惯是先读懂函数名,Find和CheckDecl分开写,不要把两个语义混进一个函数。
数组下标检查是另一个加分点:取 array 类型的长度,比较下标表达式是否越界。课设不要求运行时求值,但下标是常量时可以当场报越界,这类实现会明显提升代码完成度。
4.3 三组测试样本:为什么错误测试和正确测试同样重要
资源里的测试文件夹分三个,这是最容易忽视的财富。lex_error_test 里是词法错误样本,比如非法字符、未闭合注释;sem_error_test 是能过语法但过不了语义的样本,比如未声明变量、类型不匹配;sem_correct_test 是应该完全通过的合法程序。
拿到源码后最好按顺序跑,判断标准很明确:正确样本输出不能有错误标记,错误样本每个都要在正确的行号上报错。如果你发现某个词法错误样本被语法分析拦截了,说明词法阶段对非法字符没有拦截干净;如果某个语义错误样本到最后都没有报错,说明 SemAnalyzer 的某个检查分支根本没被走到。三组样本一起跑,能验证编译器不只是"好路径上不崩溃"。
5. 常见问题排查:拿这份资源跑通时最容易翻车的五个点
5.1 现象:编译成功但 Debug 目录里没有新生成的 Project2.exe
按下 F7 编译成功,运行却发现跑的是旧 exe,或者 Debug 目录下根本没有 exe。这个情况我遇到过不止一次。原因是工程被复制后,.vcxproj 里的输出目录还指向原作者机器的绝对路径,比如D:\someone\Debug,你编译到别人目录去了。解决方式很直接:右键 Project2 属性 → 常规 → 输出目录改成$(SolutionDir)Debug\,目标文件名改成Project2.exe,然后重新生成。改完后看输出窗口的编译日志,确认传到的是你当前工程的 Debug 目录。
5.2 现象:源码里明明有 if、while,词法分析却全部识别成标识符
代码里读保留字表时,第一个关键字永远匹配不上,后面几个正常。原因是 reserved_words.txt 用了 UTF-8 带 BOM 编码,BOM 头在文件开头加了一个不可见字符,导致第一个单词读出来是\xEF\xBB\xBFif,和代码里的"if"字符串不同。另一个常见变体是 Windows 行尾的\r没去掉,行末单词多一个回车符。解决方式是用 Visual Studio 的文件 → 高级保存选项,把 reserved_words.txt 改成 UTF-8 无 BOM;同时养成习惯,读入每个单词后做一次裁剪,去掉非打印字符。从那以后我每次加载外部配置文件,都会先打印一条调试信息看看第一个键到底是什么。
5.3 现象:语法分析跑到一半栈溢出,或者直接死循环
递归下降最常见的死循环来自左递归文法。比如你照着教科书写expr → expr + term,函数里第一句又调ParseExpr自己,Token 还没消费就无限递归下去,栈很快爆掉。解决方式是把左递归改写成迭代:ParseExpr里先ParseTerm,再用 while 循环吃掉+和-,这样每个循环都会消耗一个 Token,永远不会原地打转。另外还有一种隐蔽情况,某个非终结符函数里漏了Match,连续调用同一个函数两次却没有推进cur_token。排查技巧很简单:在每个非终结符函数入口加一行断点,观察cur_token的 type 有没有变化,没变化的函数就是凶手。
5.4 现象:语义分析把内层作用域的同名变量误判成重复声明
内层声明了一个与外层同名的变量,编译器直接报"重复声明"。原因是符号表没有真正实现作用域栈,进函数前没压新层,所有名字都堆在同一个全局表里。解决方式是把scopes.push_back({})放到函数入口,退出前scopes.pop_back()。声明查重只遍历scopes.back(),也就是当前层;查找符号则从back()往 0 方向遍历。如果把这两个方向写反,就会出现外层变量找不到、内层同名变量全报错的情况。调试时可以在符号表每次 push/pop 时打印当前作用域大小,一眼就能看出栈有没有正常退层。
5.5 现象:用新版 Visual Studio 打开工程提示 v142 工具集未安装
Debug 目录里的 vc142.pdb 已经暴露了工程生成环境,v142 是 Visual Studio 2019 的工具集。如果你用 VS2022 直接打开,会提示找不到 v142 生成工具,编译直接失败。解决方式有三个,任选其一:装 VS2019 的生成工具;在项目属性 → 常规 → 平台工具集里改成 v143,也就是 VS2022 自带工具集;或者直接改 .vcxproj 文件里的<PlatformToolset>v142</PlatformToolset>为v143,再重新加载工程。顺手检查一下 Windows SDK 版本,如果机器上只有新 SDK,把属性里的 SDK 版本改成已安装的那个,别让它自动选择错误的版本。
6. 最后一关:用三组测试样本把自己的交付变成证据
分享一个能让编译器课设少走弯路的验收习惯。拿到这份资源后,我先把 main.cpp 里默认读取的文件名改成从命令行参数读取,然后写一个脚本把三组测试样本全部跑一遍,而不是在 IDE 里手动一个个改文件名。这个改动很小,收益却很直接,它可以自动跑完几十个样本,并用文件对比工具检查输出。
# 以 test5.txt 为输入,把结果写到 out5.txt Project2.exe test5.txt out5.txt # 用 fc 对比实际输出与期望输出 fc out5.txt expected5.txt跑完后的检查清单我每次都会过一遍:lex_error_test 里的每个样本,是否都在词法阶段报错;sem_error_test 里的未声明变量和类型不匹配,是否都有正确的行号;sem_correct_test 里的合法程序,是否一次通过且输出语法树结构完整。如果某个样本的错误出现在错误的阶段,比如词法错误被语法分析弹出,那说明词法阶段的拦截不够,优先回 LexAnalyzer 找原因,而不是在 ParseLL1 里打补丁。
一开始我只盯着正确样本跑,觉得"能跑通"就等于完成。后来某次调试一个数组下标越界,正确样本全都正常,语义错误样本却一个都没拦住,我才意识到反面测试才是判断编译器脆不脆的关键。从那以后,我每拆一份编译器源码,都会先看它的错误测试样本有没有被归类好,没有的话自己补。这个习惯帮我避开了很多"正例全过、反例全崩"的假完成。希望帮到你。
本文还有配套的精品资源,点击获取