资讯详情 C++实现SNL编译前端:词法分析、递归下降与LL1语法分析器
📅 2026/10/12 23:17:43
简介面向编译原理课程设计提供一份基于C实现的SNL语言编译器完整源码覆盖词法分析、递归下降语法分析与LL1语法分析三大核心模块适合计算机专业学生、编译原理学习者以及希望深入理解解析器实现原理的开发者。压缩包共36个文件以9个C头文件、9个C源文件为主体并配6个测试输入文本、工程配置文件、界面演示动图与Markdown说明文档资源整体约1.43MB目录划分清晰便于对照代码和示例逐模块研读。目前已有767人学习下载。代码中完整实现了Token识别、First集与Follow集计算、LL1分析表构造、递归下降匹配和语法树可视化等关键环节帮助读者将抽象理论落地为可运行程序。通过阅读源码和运行示例可以清晰地看到词法分析器、递归下降解析器与LL1解析器如何组织与协作适合作为课程设计参考也能为后续设计更复杂的编译器提供实践基础。1. 从SNL语言课设说起为什么一份源码里要塞进两套语法分析器如果你是计算机相关专业的学生大概率躲不过编译原理课程设计这道坎。题目里写着“SNL语言”限定C实现还点名要“词法分析、递归下降、LL1”三个东西同时出现在一份源码里——这不是老师故意刁难而是想让你一次把编译前端的主干走完。SNL语言不是工业界的产物它是教学场景下刻意设计的一门小型过程式语言结构上和Pascal、C有亲缘关系但把作用域、类型、语句的种类都压到了能在一学期内手工实现的程度。正因如此它特别适合做课程设计没有复杂的泛型、闭包、异常机制却保留了词法识别、语法分析、符号表管理这些编译器的核心骨架。最常见的实现路线是先写词法分析器把源码切成Token流然后做两套语法分析一套用递归下降一套用LL1预测分析表共用同一个Token流接口最后把两套分析器的语法树输出结果做对比作为验证手段。这种做法既能覆盖课程要求的“两种分析方法”又不至于真的写两棵完全不同的语法树——很多同学在这里翻车因为递归下降和LL1对文法的要求不一样后者要求LL(1)文法前者虽然更自由但你得小心处理左递归和回溯稍有疏忽就会陷入死循环或者错误匹配。这篇笔记就把这两条线拆开讲从词法分析的状态机设计到LL1预测分析表的构造再到具体的C实现细节和我在调试过程中踩过的几个大坑一步步给你能直接复现的方案。整个工程不复杂核心代码量大约能控制在1200到1800行之间对C的语法特性要求不高主要用标准库的vector、unordered_map和string就够关键是各模块之间的接口设计。接下来我们先从词法分析器这个最基础也最容易出“玄学错误”的部分入手。2. 词法分析器用状态机把SNL源码切成标准Token流2.1 SNL语言的记号体系先列清单再写代码词法分析器的本质是一个有限状态自动机。写代码之前我习惯把SNL语言的全部记号分类列成一张表这样后面写状态转移时才不会漏掉边界情况。SNL语言的Token大致分为以下几类关键字program、var、integer、begin、end、if、then、else、while、do、repeat、until、read、write、procedure、function等、标识符、无符号整数、字符串常量、运算符与分隔符、-、*、/、:、、、、、、、(、)、,、;、.、:等。注意这里有个SNL特有的坑赋值号是:而不是而是等于判断。如果你在写词法分析器时把这两者混在一起后面的递归下降分析器会成片报错这是很多初学者复制通用C词法分析器之后翻车的第一现场。我把Token定义成如下结构体这是整个编译器前端的公共接口递归下降分析器、LL1分析器甚至后续的符号表都依赖它enum class TokenType { KEYWORD, IDENT, NUMBER, STRING, OPERATOR, DELIMITER, END_OF_FILE, ERROR }; struct Token { TokenType type; std::string lexeme; // 原始单词 int line; // 行号报错用 int col; // 列号方便定位 double value; // 如果是数字存数值 }; // 关键字表用 unordered_set 做O(1)查找 static const std::unordered_setstd::string keywordTable { program, var, integer, begin, end, if, then, else, while, do, repeat, until, read, write, procedure, function };这里有一点要提醒把line和col带进Token结构体不是可选项。课程设计答辩时老师几乎一定会要求你演示“错误定位”如果你的报错信息只有“syntax error”而没有行号答辩体验会非常糟糕。另外关键字表的查找时机很重要——我的做法是先按标识符识别再用std::unordered_set::count判断是不是关键字这样能用一张表同时处理保留字和用户自定义标识符。2.2 状态机扫描循环最长匹配与标识符陷阱词法分析器的核心循环是一个逐字符扫描的自动机。我一般不会用专门的表驱动状态机框架对课设来说那是过度设计而是直接用一个switch加char类型判断的循环配合一个nextChar()函数维护当前的扫描位置。几个关键点写在这里class Lexer { public: explicit Lexer(const std::string src) : src(src), pos(0), line(1), col(0) {} Token nextToken() { skipWhitespaces(); // 跳过空格和换行 if (pos src.size()) { return makeToken(TokenType::END_OF_FILE, ); // EOF标记 } char c peek(); // 数字开头进入数字状态 if (std::isdigit(c)) { return recognizeNumber(); } // 字母或下划线开头进入标识符状态 if (std::isalpha(c) || c _) { return recognizeIdentifier(); } // 运算符分隔符需要向前看一位 return recognizeOperator(); } private: std::string src; size_t pos; int line; int col; char peek() const { return src[pos]; } char advance() { char c src[pos]; if (c \n) { line; col 0; } else { col; } return c; } // ... 其余方法见下 };这里最值得讲的是recognizeIdentifier末尾对关键字的二次判断。SNL语言的标识符规则是“字母开头后跟字母、数字或下划线”不能以数字开头。但有一个边界情况如果标识符恰好是关键字的一部分比如用户定义变量integer_value我们不能在识别到integer时就停而必须继续向后匹配直到遇到非字母数字字符才停下来然后再判断整串是不是关键字。这就是“最长匹配”原则。很多同学的词法分析器在输入integer_value时会错误地拆成integer和_value两个Token根本原因就是贪心不够看到关键字前缀就立刻返回。我的建议是无论输入什么字符开头都先把完整的候选串读出来最后统一查表。2.3 运算符的二义性与:的特殊处理运算符识别是最容易出“边角料Bug”的地方。SNL语言里存在两个字符的运算符组合:、、、。还有单字符的、-、*、/、、(、)、,、;、.,:。其中:单独出现是分隔符用于变量声明类型:是赋值号所以扫描到:时必须向前看一位如果是合并成赋值号否则单独成Token。Token Lexer::recognizeOperator() { char c advance(); // : 后必须判断是否紧跟 if (c :) { if (peek() ) { advance(); return makeToken(TokenType::OPERATOR, :); } return makeToken(TokenType::DELIMITER, :); } if (c ) { if (peek() ) { advance(); return makeToken(TokenType::OPERATOR, ); } if (peek() ) { advance(); return makeToken(TokenType::OPERATOR, ); } return makeToken(TokenType::OPERATOR, ); } if (c ) { if (peek() ) { advance(); return makeToken(TokenType::OPERATOR, ); } return makeToken(TokenType::OPERATOR, ); } // 其余单字符运算符/分隔符 return makeToken(TokenType::OPERATOR, std::string(1, c)); }为什么把不等号也视为运算符而不是布尔逻辑操作符因为SNL语言里没有单独的“逻辑非”符号条件判断用表示不等于这和你平常用的C风格完全不同。初次写的时候我把漏了结果所有带不等号比较的测试样例都在语法分析阶段报错且报错位置极其诡异——因为分析器永远等不到它期望的布尔表达式结果。这个经验说明词法分析器的错误往往要在语法分析阶段才会暴露排查时如果语法错误集中出现在运算符位置要先回头检查词法层的Token切分是否正确。词法单元设计完整后下一步就能把这个Token流喂给递归下降分析了。3. 递归下降语法分析先把SNL文法改造成适合手写的形态3.1 为什么直接搬文法会左递归死循环递归下降分析器本质上是将每个非终结符对应成一个C函数函数体内部按照文法的产生式依次匹配终结符和非终结符。思路听起来简单但SNL的原始教学文法并非天生适合递归下降。最典型的障碍是左递归产生式比如表达式文法expr → expr term | term如果照抄成如下函数void parseExpr() { if (current token is ) { parseExpr(); // 无限递归 match(); parseTerm(); } else { parseTerm(); } }调用parseExpr时会无限调用自身直到栈溢出。这就是递归下降遇到直接左递归的典型死结。解决办法是先做文法等价改写把左递归改成右递归expr → term expr_tail expr_tail → term expr_tail | ε对应到C函数里就变为一个循环用while而不是递归这是一个重要的口诀右递归对应函数递归左递归改写后的右递归对应迭代循环。所以解析表达式的函数长这样void parseExpr() { parseTerm(); while (lookahead.type TokenType::OPERATOR (lookahead.lexeme || lookahead.lexeme -)) { advance(); // 吃掉运算符 parseTerm(); // 继续解析右边的项 } }加上一个符号位之后就是完整的加减法表达式解析。这也是为什么递归下降擅长处理表达式——它的优先级结构可以通过层级的函数调用来自然体现不需要像LR分析器那样维护一棵显式状态栈。3.2 从文法到C递归一个可运行的Parser骨架完整的SNL语法分析需要覆盖程序声明、变量声明、过程/函数定义、语句序列、表达式、赋值、条件、循环while和repeat两种等结构。我习惯于把整套语法分析器拆成三个文件Parser.h放类声明与Token接口Parser.cpp放所有parseXxx函数实现main.cpp负责读取文件、调用词法分析器、启动解析并打印分析树。下面给出一个最小可跑的覆盖主要语句的递归下降Parser关键实现片段// Parser.h 核心接口 class Parser { public: explicit Parser(Lexer lexer); void parseProgram(); // 入口program 声明 private: void match(TokenType type, const std::string lexeme ); void advance(); void parseBlock(); // 语句块 void parseStatement(); // 语句分发 void parseExpr(); // 表达式 void parseTerm(); // 项 void parseFactor(); // 因子 void parseCondition(); // 条件 void error(const std::string msg); Lexer lexer; Token lookahead; // 当前Token };// Parser.cpp 节选语句分发 void Parser::parseStatement() { if (lookahead.type TokenType::IDENT) { // 赋值语句 identifier : expr std::string varName lookahead.lexeme; advance(); match(TokenType::OPERATOR, :); parseExpr(); match(TokenType::DELIMITER, ;); } else if (lookahead.type TokenType::KEYWORD) { const std::string kw lookahead.lexeme; if (kw if) { advance(); parseCondition(); match(TokenType::KEYWORD, then); parseStatement(); if (lookahead.lexeme else) { advance(); parseStatement(); } match(TokenType::KEYWORD, end); } else if (kw while) { advance(); parseCondition(); match(TokenType::KEYWORD, do); parseStatement(); match(TokenType::KEYWORD, end); } else if (kw repeat) { // SNL 的 repeat-until 和其他语言不太一样注意结束关键字 advance(); parseStatement(); match(TokenType::KEYWORD, until); parseCondition(); } else if (kw read) { advance(); match(TokenType::DELIMITER, (); match(TokenType::IDENT); match(TokenType::DELIMITER, )); } else if (kw write) { advance(); match(TokenType::DELIMITER, (); parseExpr(); match(TokenType::DELIMITER, )); } else { error(非法的语句起始关键字: kw); } } else { error(语句必须以标识符或关键字开头); } }写递归下降分析器有一条血泪经验不要试图一次性写完所有语句的解析函数。先只支持赋值语句跑通“program→begin…end”的最小程序再加入if和while最后加read/write和repeat。每加一个语句都用对应的SNL测试程序验证。否则所有语句一起写完一旦出错你很难判断是哪个产生式的匹配逻辑有问题。3.3 优先级与错误的“最短路径”报告表达式解析中的因子factor——即最内层的原子单元——在SNL中只包括标识符、数字和括号括起来的子表达式。因此parseFactor的逻辑非常短void Parser::parseFactor() { if (lookahead.type TokenType::IDENT) { advance(); } else if (lookahead.type TokenType::NUMBER) { advance(); } else if (lookahead.lexeme () { advance(); parseExpr(); match(TokenType::DELIMITER, )); } else { error(factor 位置出现无法识别的 Token: lookahead.lexeme); } }这里有个实战向的细节error函数里应该携带当前Token的行号和列号并同步把预期的符号输出到错误信息里。比如“line 3, col 5: 需要标识符、数字或 ( 但发现 :”。这样的报错信息在答辩时格外加分也方便你自查。递归下降的优势之一就是错误位置精确——错误发生的时刻当前Token和栈上的函数调用链就是问题所在最小范围不需要像LL1那样做漫长的后续恢复推导。但这里也埋着一个定位陷阱错误发生的位置往往不是真正需要修的位置。比如赋值语句右端表达式少了一个右括号真正错误发生在表达式内部但报错点可能在match())那里。所以递归下降的报错要做“最小吞噬”——每一层函数只消耗自己能确定匹配的Token遇到意外Token立刻抛出而不是挣扎着恢复。4. 第二个分析器LL1从递归下降到预测分析表的迁移4.1 为什么课程设计总喜欢同时要求LL1老师在课设题目里把“递归下降”和“LL1”捆在一起用意在于让你体会自顶向下分析的两条实现路径一条是人类友好的手写递归下降一条是面向“自动化生成”的表驱动分析。LL1分析器的核心思维是把文法的产生式映射到一张预测分析表M[A][a]A是非终结符a是终结符然后用一个显式栈代替递归调用栈进行推导。它的优点是不存在函数的嵌套调用深度的系统限制——栈的大小由栈数据结构本身管理缺点是分析表的构造依赖FIRST集和FOLLOW集计算而且对文法本身要求严格——文法必须无左递归、无公共前缀且任何非终结符在遇到同一个终结符a时不能有两个产生式都能匹配。实际的课设中很多同学的LL1分析器实现到一半就写不下去了原因是SNL的原始文法在表达式部分直接做LL1有冲突。常见解决办法是层级化改写表达式文法并且把算术运算符的左结合性质通过“等价改写”变成右递归再配合while循环在表驱动分析时做“伪左结合”——具体做法是在栈顶弹出表达式非终结符并展开后遇到时不是把它归约丢掉而是记录运算符继续压栈右边的产生式。这样既能满足LL1无左递归的要求又能让表达式按正确的结合方式计算。另外一个典型的冲突是else的可选性经典的悬空else问题会让LL1预测表在if语句的then分支后出现M[Stmt][else]冲突。SNL语言通常不要求处理悬空else但如果你把文法改写成带“悬空”的版本就必须额外做消除冲突的手工处理。4.2 构造FIRST集和FOLLOW集手算与程序校验LL1分析表的构造依赖FIRST和FOLLOW两套集合。手算过程我会写在课程设计报告里但程序里要用自动化的方式验证否则很容易因为一个集合算错导致整张表错误而错误的现象又很隐蔽——某个合法语句就是无法被接受但报错位置离真正出问题的地方很远。下面是计算FIRST集的参考实现using SymbolSet std::setstd::string; // 用string表示符号方便处理终结符和非终结符 std::mapstd::string, SymbolSet computeFIRST( const std::mapstd::string, std::vectorstd::vectorstd::string productions) { std::mapstd::string, SymbolSet first; bool changed true; // 初始化所有非终结符的 FIRST 集合为空 for (const auto p : productions) { first[p.first] SymbolSet{}; } while (changed) { changed false; for (const auto p : productions) { const std::string A p.first; for (const auto rhs : p.second) { // 如果右部第一个符号是终结符直接加入 if (!rhs.empty() !isNonTerminal(rhs[0])) { if (first[A].insert(rhs[0]).second) changed true; } else { // 右部第一个符号是非终结符把它的FIRST集搬过来 bool allHaveEpsilon true; for (size_t i 0; i rhs.size(); i) { if (isNonTerminal(rhs[i])) { SymbolSet subFirst first[rhs[i]]; bool hasEpsilon subFirst.count(ε) 0; for (const auto s : subFirst) { if (s ! ε first[A].insert(s).second) changed true; } if (!hasEpsilon) { allHaveEpsilon false; break; } } else { // 遇到终结符无法再继续延伸 if (first[A].insert(rhs[i]).second) changed true; allHaveEpsilon false; break; } } if (allHaveEpsilon first[A].insert(ε).second) changed true; } } } } return first; }computeFIRST的逻辑本质是一个不动点迭代changed标记控制循环何时停止。初学时会担心死循环但只要每个集合只增不减迭代次数就有上限最多等于符号总数。FOLLOW集的计算稍微麻烦一些需要考虑产生式右部某个非终结符后面跟着什么以及当后面没有符号时要把左部的FOLLOW集嫁接过去。这里不展开完整代码核心就一句话先扫描右部每个非终结符紧跟的终结符加入它的FOLLOW如果右侧可以推出ε则把左部的FOLLOW也加入它的FOLLOW。4.3 预测分析表与驱动器实现预测分析表的直接表现形式是一个二维映射std::mapSymbolPair, Production其中SymbolPair是{非终结符, 终结符}的组合键。每个表项存的是“当栈顶是非终结符A、当前输入是a时应该采用哪个产生式展开A”。构造规则如下对每条产生式A → α先计算FIRST(α)若FIRST(α)含终结符b则把M[A][b]设为该产生式若α能推导出ε则对FOLLOW(A)中的每个终结符c把M[A][c]设为产生式A → ε。如果某格被两次赋值且产生式不同就是文法冲突LL1不适用。有了分析表驱动器的实现就是一个标准的栈模拟using Symbol std::string; using Production std::vectorSymbol; using PredictTable std::mapstd::pairSymbol, Symbol, Production; bool ll1Driver(const Symbol start, const std::vectorSymbol inputTokens, const PredictTable table, const SymbolSet allSymbols) { std::vectorSymbol stack; stack.push_back($); // 栈底 stack.push_back(start); // 初始非终结符 size_t idx 0; // 当前输入Token下标 while (!stack.empty()) { Symbol top stack.back(); stack.pop_back(); Symbol cur inputTokens[idx]; if (top cur) { // 终结符匹配 idx; if (cur $) return true; // 成功接受 } else if (allSymbols.count(top) 0) { // 该符号不是非终结符也不是匹配的终结符分析失败 return false; } else { auto it table.find({top, cur}); if (it table.end()) return false; // 表中无产生式 const Production prod it-second; // 注意产生式右部逆序压栈保证从左到右展开 for (auto s prod.rbegin(); s ! prod.rend(); s) { if (*s ! ε) stack.push_back(*s); } } } return false; }这个驱动器的代码量其实比递归下降更少难的是输入Token序列如何从词法分析器转换而来——Token的lexeme字符串就是终结符符号但像ID、NUM这类泛化的Token需要映射成统一的标识符终结符名。我的做法是在词法分析器里把ID和NUM直接作为终结符符号输出这样LL1的输入流和词法层就能直接复用。递归下降的lookahead用的是Token类型而LL1的栈用的是符号字符串两者之间需要一个terminals of Token - LL1 symbol的映射。这里有一个很实用的建议不要分别测试递归下降和LL1两个分析器要为它们设计同一个语法树输出接口。也就是两种分析器的最终结果都输出一棵“抽象语法树的线性化表示”比如前序遍历打印然后用diff工具比较同一份测试程序的输出。这样做的额外好处是答辩时你面对“两种方法结果是否一致”这种问题只需展示一个脚本的运行结果非常直观。5. 课设中的常见坑5条血泪排查记录5.1 现象词法分析把::识别成了三个Token这是我见过发生频率最高的问题。当SNL语言里出现嵌套赋值实际语法里可能不存在或者用户误写::时词法分析器的状态转移图如果不完整会把:识别成分隔符再用:识别一次最后留下一个多余的。更常见的场景是在过程声明部分SNL用procedure 名字;开头这里没有冒号但有些学生会把教材上的Pascal样例直接拿过来跑里面就有procedure foo;的变体如果没有预处理好就会出现奇怪的Token边界错位。原因分析recognizeOperator里对:的处理只判断了后面是不是但没有处理连续多个冒号的情况。编译理论里这叫“最长匹配原则的边界分支缺失”。解决方法是当扫描到:时先尝试匹配:两个字符如果第二个字符不是则单独返回:并让pos回退到第二个字符位置注意不是回退到第一个字符。更稳健的做法是使用双指针预读先保存当前位置为savePoint尝试匹配:成功则前进两位失败则回到savePoint并返回单字符。5.2 现象LL1分析表构造后出现多个冲突项这是我调试LL1分析器时最耗时的部分。在生成表达式的FIRST集合时我把右递归表达式规则expr_tail → term expr_tail | ε的FIRST(expr_tail)里包含了这个没问题。但当我同时把factor → ( expr )和factor → ID | NUM摊在一个FACTOR非终结符里并且不小心把产生式顺序写成FACTOR → ( EXPR ) | ID | NUM | ( EXPR )时分析表在M[FACTOR][(]这一格会出现两次赋值冲突。原因分析这不是文法有问题而是我用了重复的产生式这在集合语义上没有影响但在表构造时会产生“同一格被重复覆盖”。解决位置不应该在代码里加判断“如果已有值就直接跳过”——那样会把真正有冲突的非法文法掩盖成合法文法。正确做法是在构造表之前用std::setProduction去重并且在赋值前检查该格是否已经存在值且产生式不同如果不同就要报错并提示检查文法。我在这个坑里耗了一个晚上最后是写了一个小的打印工具把整张分析表输出为二维文本才看出来的。5.3 现象递归下降在解析if...then...else...end时莫名跳过了elseSNL的if语句在then分支结束后会判断当前Token是不是else关键字如果是就解析else分支之后再匹配end。这里有一个顺序问题如果你先写了parseStatement()去解析then分支内部的复合语句而复合语句本身可能以end结束那么当内部语句匹配完自己的end后外层if的位置判断就会看到else——正常但如果你把match(KEYWORD, end)放在else分支之后还没有处理好会导致then分支结束后的else被误以为是新一条语句的起始关键字从而报错。这个问题本质上是分析器对“else归属”的判定依赖上下文而递归下降的判定只有一次向后看。解决方法是严格按SNL的语法定义来写每个if语句结束时必须匹配完endelse分支只是可选的它位于then分支的语句之后、end之前。更进一步的建议是如果你的SNL文法没有显式的“复合语句用begin...end括起来”规则那么then和else内部只能各跟一条简单语句这种情况下的代码会简单很多。如果允许复合就必须用begin...end括住不然二义性会让手写分析器非常难受。5.4 现象算术表达式解析出现“优先级倒挂”23*4在递归下降里被算成(23)*4即输出语法树时先打印*再打印。原因非常典型表达式的产生式层级没有写对。最底层的因子对应数字和括号上一层是乘除法term再上一层才是加减法expr解析函数调用的顺序必须是expr调用term、term调用factor。如果你把term的循环里放的是*和/那么3*4会作为term被解析随后expr层的循环第二次调用term解析4——此时表达式链是在根*在子节点计算时就是加法优先于乘法优先级倒挂。检查技巧在打印语法树时对expr节点、term节点分别加缩进前缀一眼就能看出来嵌套层级对不对。另外还有一个常见连带Bugterm层要在同一个while循环里同时处理*和/如果你写了两个循环先处理*再处理/会导致左结合错误比如8/4/2会被算成8/(4/2)4而不是(8/4)/21。左结合错误比优先级倒挂更隐蔽因为它不改变最终结果的情况很多但一旦除数不为1就出乱子。5.5 现象程序能分析但运行时出现了“符号表同名覆盖”语法分析器通常只负责检查语法结构但课程设计里有时会要求同时维护符号表用于后续语义分析和代码生成。常见的问题是局部变量和全局变量同名时符号表如果只是一个简单的unordered_mapstring, SymbolInfo新插入的局部变量会覆盖同名的全局变量记录导致后续引用全部解析到错误的作用域。原因分析整天用哈希表存符号表忽略了作用域栈的概念。解决方法是把符号表实现为一个栈式的std::vectorstd::unordered_mapstd::string, SymbolInfo每进入一个块或过程就push_back一个新的映射表查找时从栈顶往栈底方向遍历找到第一个出现该名字的表项停止。注意在退出作用域时要pop_back否则内存泄漏是小事更重要的是意味着符号残留会污染后面的语法分析。我到现在写任何带作用域的编译器组件都会沿用这个“栈式符号表”的结构这是从课设里学来的最重要的经验之一。6. 源码组织与验证技巧让两套分析器共用一套测试管线做课设时最大的浪费是维护两套完全独立的测试脚本一个给递归下降一个给LL1。我建议把测试统一成一条命令写一个run_tests.py脚本依次读取test_cases/目录下的所有.snl源码文件分别调用两个分析器语法树输出为.ast1和.ast2后缀的文本文件再用diff比较。这能在你重构文法或者修改Token定义后立刻发现回归。下面是测试脚本的核心逻辑框架#!/bin/bash # 语法树对比测试 for f in test_cases/*.snl; do ./sln_parser_recursive $f ${f%.snl}.ast1 21 ./sln_parser_ll1 $f ${f%.snl}.ast2 21 if diff -q ${f%.snl}.ast1 ${f%.snl}.ast2 /dev/null; then echo [PASS] $f else echo [FAIL] $f diff ${f%.snl}.ast1 ${f%.snl}.ast2 fi done测试用例的选取也有讲究。至少要有这几类空程序只有program头和begin end、简单变量声明与赋值、整数算术表达式包含所有运算符的优先级组合、if和while嵌套、repeat循环、过程定义与调用、错误的程序缺少分号、缺少end、赋值号写成。错误用例的目的是确认两种分析器都能报出语法错误而不是只通过正确用例。LL1分析器由于驱动栈的存在报错位置往往没有递归下降那么精确所以测试脚本里错误用例的比对应该只看“是否报错”而不必强求错误行号一致。最后说一个我自己的习惯项目目录里保留一个design_notes.md文件记录你手工计算的FIRST/FOLLOW集合结果、预测分析表的冲突消除过程、以及每个测试用例对应的语法树截图。这份笔记的价值甚至超过代码本身——课程设计答辩时老师问“为什么这个产生式这样改写”你能拿起笔记讲清楚推导过程比现场翻代码直观得多。我自己当年就是靠这份笔记把“源码是网上找的”这类质疑消弭于无形。以上是我在这类课设中的完整沉淀希望帮到你。本文还有配套的精品资源点击获取