2026/10/18 7:30:54

SNL编译器课设源码解析:从词法分析到语义检查的完整实现

SNL编译器课设源码解析:从词法分析到语义检查的完整实现 简介吉林大学计算机学院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.txtLL1.h / LL1.cppLL(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_mapstd::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是全局当前 TokenMatch断言当前 Token 类型等于参数并推进到下一个。每个非终结符函数都必须在入口处至少调一次Match或NextToken否则可能死循环。3.3 if-else 悬空与同步恢复语法错误后还能继续往下跑if 语句是另一个高频踩坑点。LL(1) 递归下降处理 else 悬空靠的是前看一个 TokenTreeNode* 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; // 0int, 1boolean, 2array int level; // 0 是全局1 是第一个函数级 int offset; // 局部变量在栈帧里的偏移或数组长度 bool is_param; // 是形参就在函数入口登记 };符号表按层分组常见做法是std::vectorstd::vectorSymbol 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 文件里的PlatformToolsetv142/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 里打补丁。一开始我只盯着正确样本跑觉得能跑通就等于完成。后来某次调试一个数组下标越界正确样本全都正常语义错误样本却一个都没拦住我才意识到反面测试才是判断编译器脆不脆的关键。从那以后我每拆一份编译器源码都会先看它的错误测试样本有没有被归类好没有的话自己补。这个习惯帮我避开了很多正例全过、反例全崩的假完成。希望帮到你。本文还有配套的精品资源点击获取