☰
C++构建SNL语言编译器:从词法分析到LL1全流程解析
2026/10/12 11:59:30 网站建设 项目流程

简介:面向编译原理课程设计的C++源码项目,以SNL语言为载体,完整实现词法分析、递归下降语法分析与LL1语法分析三大核心模块,重点解决理论学习与代码实践脱节的问题。适合计算机专业本科生、课程设计开发者以及编译原理自学者,既能对照课程理论验证程序行为,也能作为进一步扩展优化的基础。压缩包内共36个文件,其中9个h头文件与9个cpp实现文件构成核心编译器代码,6个txt提供多组测试输入与输出对照,4个xml与工程文件负责项目配置,2个gif直观展示界面效果,另有示例SNL程序与说明文档;压缩包整体大小仅1.43MB。词法分析器基于正则表达式识别关键字、标识符、常量与运算符;递归下降解析为每个语法结构编写C++函数逐层匹配;LL1部分则围绕First集、Follow集构造预测分析表,并通过界面场景直观呈现解析流程,整体展示了一个可运行的编译前端示例。已有767人学习下载,对深入理解编译器前端工作机理、积累C++工程实现经验具有较好的参考价值。

1. 编译原理课程设计:SNL语言编译器,从词法到LL1的C++全流程落地

如果你正在做编译原理课程设计,大概率遇到过这种情况:教材把词法分析、语法分析、LL1讲得头头是道,但真要动手写一个能跑通的C++编译器,却发现连Token怎么存、分析表怎么建都无从下手。这份SNLCompilerGraphic项目正是为了解决这个问题而生的——它用C++完整实现了SNL(Specific Notation Language)语言的词法分析、递归下降语法分析和LL1语法分析,并且带Qt图形界面,可以把词法分析过程和语法分析树直接可视化。适合两类人:一是课程设计选题是「实现一个小型语言编译器」的在校生,照着源码能少走大量弯路;二是工作中需要快速搭建DSL解析器、但对编译原理细节已经生疏的C++工程师,这份代码相当于一份可以直接改造的参考实现。我拆完整个项目后可以明确说:它的价值不在算法多前沿,而在「教材理论到可运行代码」的映射极其完整,连分析过程的图形化展示都做了,这在课程设计里是加分项。

2. 词法分析模块:Token识别、符号表设计与状态机实现

2.1 SNL语言词法规则:先搞清楚要认哪些Token

词法分析是编译器的第一个关卡,任务是把源代码字符串流拆成有意义的Token序列。在动手读lex.cpp之前,我建议你先做一件事:打开项目根目录下的snl_example文件夹,里面有几个 .txt 结尾的测试用例文件(c1.txt、c2.txt、c4.txt、c5.txt),这些是SNL语言的示例程序,词法分析器的验收标准就是能把它们全部正确切分。

从源码的lex.h中可以整理出SNL语言需要识别的Token类别,我把核心部分列在下面:

Token类别具体内容举例
关键字program、var、begin、end、if、then、else、while、do、read、write这些是语言保留字,不能当标识符用
标识符以字母开头的字母数字串count、sum、node
整型常量十进制整数串42、1024
运算符+、-、*、/、=、<、>、<=、>=、<>注意<>是不等于
界符(、)、,、;、:、. 等SNL里分号是语句结束符
特殊符号:=赋值运算符,注意和=区分

一个关键细节是:SNL的赋值运算符是:=,而=只用于相等比较。这两个符号的区分是词法分析器最容易出错的地方,因为扫描器读到:时必须向前看一个字符才能决定是单独的界符还是赋值符。

2.2 状态机驱动的词法扫描:数据流与核心代码

lex.cpp的核心是一个基于状态机的扫描循环。与传统手写DFA不同,它没有严格的「状态表」驱动,而是用一个while循环加多个if分支实现,符合课程设计的可读性要求。我先贴出核心的扫描框架代码,这段逻辑是整个词法分析器的主干:

// lex.cpp 核心扫描循环(结构上有精简,保留关键分支) Token Lexer::getNextToken() { skipWhitespaceAndComments(); // 跳过空白和注释 if (currentPos >= sourceCode.length()) { return Token(TokenType::EOF_TOKEN, "", lineNo); } char ch = sourceCode[currentPos]; // 1. 标识符与关键字:首字符是字母 if (isalpha(ch)) { string lexeme = readIdentifier(); // 读完整的字母数字串 if (isKeyword(lexeme)) { return Token(TokenType::KEYWORD, lexeme, lineNo); } else { return Token(TokenType::IDENTIFIER, lexeme, lineNo); } } // 2. 数字字面量:首字符是数字 if (isdigit(ch)) { string lexeme = readNumber(); return Token(TokenType::INT_CONST, lexeme, lineNo); } // 3. 运算符与界符:首字符是符号 if (isSymbol(ch)) { return handleSymbol(); // 内部处理 := 和 <= 这类双字符运算符 } // 4. 无法识别的字符 raiseLexError("无法识别的字符: " + string(1, ch)); return Token(TokenType::ERROR, "", lineNo); }

这段代码的策略是「首字符分类决定分支」。isalpha开头的走标识符分支,isdigit走数字分支,其余走符号分支。真正需要仔细看的是handleSymbol()函数,它处理双字符运算符时用了一个向前看一位的lookahead逻辑:

// lex.cpp - 符号处理的核心:双字符运算符的向前看 Token Lexer::handleSymbol() { char ch = sourceCode[currentPos]; currentPos++; // 先消费首字符 // 关键:赋值运算符 := 必须在界符 : 的分支里单独判断 if (ch == ':') { if (currentPos < sourceCode.length() && sourceCode[currentPos] == '=') { currentPos++; // 消费掉 '=' return Token(TokenType::ASSIGN, ":=", lineNo); } return Token(TokenType::COLON, ":", lineNo); } if (ch == '<') { if (currentPos < sourceCode.length() && sourceCode[currentPos] == '=') { currentPos++; return Token(TokenType::LE_OP, "<=", lineNo); // 小于等于 } if (currentPos < sourceCode.length() && sourceCode[currentPos] == '>') { currentPos++; return Token(TokenType::NE_OP, "<>", lineNo); // 不等于 } return Token(TokenType::LT_OP, "<", lineNo); // 小于 } if (ch == '>') { if (currentPos < sourceCode.length() && sourceCode[currentPos] == '=') { currentPos++; return Token(TokenType::GE_OP, ">=", lineNo); // 大于等于 } return Token(TokenType::GT_OP, ">", lineNo); // 大于 } // 其余单字符运算符直接映射 switch (ch) { case '+': return Token(TokenType::PLUS, "+", lineNo); case '-': return Token(TokenType::MINUS, "-", lineNo); case '*': return Token(TokenType::TIMES, "*", lineNo); case '/': return Token(TokenType::DIVIDE, "/", lineNo); case '(': return Token(TokenType::LPAREN, "(", lineNo); case ')': return Token(TokenType::RPAREN, ")", lineNo); case ';': return Token(TokenType::SEMICOLON, ";", lineNo); case ',': return Token(TokenType::COMMA, ",", lineNo); case '.': return Token(TokenType::DOT, ".", lineNo); default: raiseLexError("非法符号: " + string(1, ch)); return Token(TokenType::ERROR, "", lineNo); } }

逻辑说明:handleSymbol的核心策略是「读完首字符后,判断下一个字符是否构成双字符运算符」。这里有个参数值得注意——currentPos是词法分析器的位置游标,每次判断都必须确保currentPos < sourceCode.length(),防止越界读取。我在实际使用中发现,很多同学的词法分析器在分析到文件末尾的:或<时会崩溃,就是漏了这个边界判断。

另外还需要说明关键字表的实现方式。项目里有一个静态关键字集合,isKeyword函数就是查这个集合:

// lex.h - 关键字表定义 static const unordered_set<string> keywordTable = { "program", "var", "begin", "end", "if", "then", "else", "while", "do", "read", "write" }; bool Lexer::isKeyword(const string& lexeme) { return keywordTable.find(lexeme) != keywordTable.end(); }

这里的参数要点是字母大小写敏感,SNL语言的关键字约定全小写,如果你手写测试用例时写了BEGIN,词法分析器会把它当成普通标识符而不是关键字,这一点务必在测试用例里规避。

2.3 标识符边界与注释跳过的隐藏细节

词法分析器还有两个不那么显眼但极易翻车的点。第一个是标识符的终止条件,readIdentifier()函数必须确保读到非字母非数字字符时停止,并且停止时不能把当前字符吞掉——因为这个字符可能是运算符或者界符,需要留给下一轮主循环处理。常见的实现错误是循环结束后不恢复currentPos,导致每轮扫描都会漏一个字符。

第二个是注释处理。项目里skipWhitespaceAndComments()支持的是SNL风格的注释,我建议你在测试时看一下它支持哪几种注释格式。常见做法是支持//行注释和{}块注释。这里有个坑:如果源代码文件末尾有注释但没正确配对,扫描器可能越界。正常的处理逻辑是遇到块注释未闭合时报错或者直接跳到文件末尾,而不是死循环。

我给一个测试词法分析器的标准操作:把c1.txt的内容复制出来,逐行手写对应的期望Token序列,然后跑代码对比。比如第一行program p;应该产生三个Token:关键字program、标识符p、界符;。如果输出和你手写的不一致,说明状态机某个分支没走对,这个排查方法比乱试快得多。

3. 递归下降语法分析:函数化文法、左递归处理与错误恢复

3.1 文法组织原则:一个非终结符对应一个函数

递归下降语法分析的核心思想是「每个非终结符对应一个C++函数」,程序入口从开始符号对应的函数开始,层层递归调用完成整个语法结构的检查。parse.cpp和parseitem.cpp实现了这一层。

在深入代码之前,先明确SNL语言的文法轮廓。基于项目结构和源码推断,SNL的文法覆盖了以下关键结构:

program → program id ; block . block → var-declaration-part compound-statement var-declaration-part → var var-declaration { ; var-declaration } | ε var-declaration → id { , id } : type type → integer | real | char | record ... end compound-statement → begin statement-list end statement-list → statement { ; statement } statement → assignment-statement | if-statement | while-statement | read-statement | write-statement | compound-statement | ε assignment-statement → id := expression if-statement → if condition then statement [ else statement ] while-statement → while condition do statement condition → expression relational-operator expression expression → term { (+|-) term } term → factor { (*|/) factor } factor → id | int-const | ( expression )

这份文法的特点是:程序体、变量声明、语句块、表达式、因子都层叠嵌套。递归下降解析器就是按照这个文法逐层实现。

3.2 表达式解析的优先级实现:factor→term→expression

表达式解析是递归下降里最能体现设计功力的部分,它直接决定了加减乘除的运算优先级。先看代码:

// parse.cpp - 表达式解析:优先级通过函数调用层级体现 ASTNode* Parser::parseExpression() { ASTNode* leftNode = parseTerm(); // 先解析一个term,term里已处理乘除 while (currentToken.type == PLUS || currentToken.type == MINUS) { Token opToken = currentToken; advanceToken(); // 吃掉运算符 ASTNode* rightNode = parseTerm(); // 继续解析下一个term leftNode = createBinaryOpNode(opToken, leftNode, rightNode); } return leftNode; } ASTNode* Parser::parseTerm() { ASTNode* leftNode = parseFactor(); // factor是最小的运算单元 while (currentToken.type == TIMES || currentToken.type == DIVIDE) { Token opToken = currentToken; advanceToken(); ASTNode* rightNode = parseFactor(); leftNode = createBinaryOpNode(opToken, leftNode, rightNode); } return leftNode; } ASTNode* Parser::parseFactor() { if (currentToken.type == INT_CONST) { ASTNode* node = createConstNode(currentToken); advanceToken(); return node; } if (currentToken.type == IDENTIFIER) { ASTNode* node = createIdentNode(currentToken); advanceToken(); return node; } if (currentToken.type == LPAREN) { advanceToken(); // 左括号直接吃掉 ASTNode* innerNode = parseExpression(); // 递归解析括号内部 if (currentToken.type != RPAREN) { raiseSyntaxError("缺少右括号"); } advanceToken(); // 吃掉右括号 return innerNode; } raiseSyntaxError("表达式解析因子失败"); return nullptr; }

逻辑说明:parseExpression调用parseTerm,而parseTerm调用parseFactor,这个三层结构就是优先级链。3 + 4 * 5被解析时,表达式层先拿到3,看到+后要求递归解析下一个term,4 * 5在term层完整解析成一个乘法子树,最终AST形态是3 + (4 * 5)。如果要支持幂运算等更高优先级,在factor和term之间再插一层即可,这个扩展思路后面还会讲到。

ASTNode的数据结构定义在parseitem.h里,它需要区分叶子节点(整数常量、标识符)和内部节点(二元运算),我建议你在阅读时特别关注createBinaryOpNode的实现——它保存了运算符类型和左右子节点指针,这是后面生成语法树可视化和做语义分析的基础。

3.3 左递归处理:为什么直接翻译文法必然死循环

递归下降分析有一个绕不开的坎:左递归。如果文法写成expression → expression + term,那么parseExpression对应的C++函数会在第一行就递归调用自身,永远无法退出,直接栈溢出。

项目里采用的解决方法是「改写文法消除左递归」,标准教材做法:把E → E + T | T改写成E → T E'和E' → + T E' | ε。在实际C++实现里,这种E'这种「尾部递归」通常直接用while循环替代,也就是上面parseExpression里那个while (currentToken.type == PLUS)循环。

代码说明:当表达式层解析完成一个term后,while循环不断看下一个Token是不是+或-,如果是就继续解析下一个term并组合成新的节点。这个过程天然处理了左结合性——a - b - c会被解析成(a - b) - c,第一次循环先遇到-把a - b组合,第二次循环再把结果和c组合。这里的关键参数是currentToken,它始终指向还未被消费的Token。每次解析完一个运算单元后,当前Token恢复为下一个输入符号,循环条件重新判断。

还有一个常见的右递归处理:对于右结合运算符(比如幂运算^),while循环的组装顺序需要反过来,但这不属于SNL的核心语法,只在扩展时留意。

3.4 语句级解析与错误恢复机制

语句级解析是递归下降分析器最庞杂的部分。parseStatement()根据当前Token的第一个符号来判断进入哪个分支:

// parse.cpp - 语句分派:FIRST符号决定分支 ASTNode* Parser::parseStatement() { switch (currentToken.type) { case IDENTIFIER: return parseAssignmentStatement(); // id := expr case KEYWORD: if (currentToken.lexeme == "if") return parseIfStatement(); if (currentToken.lexeme == "while") return parseWhileStatement(); if (currentToken.lexeme == "read") return parseReadStatement(); if (currentToken.lexeme == "write") return parseWriteStatement(); if (currentToken.lexeme == "begin") return parseCompoundStatement(); // 空语句:直接返回nullptr return createEmptyStatement(); default: raiseSyntaxError("无法识别的语句开头"); return nullptr; } }

这里展示的是典型的「FIRST集驱动分派」,每个分支的起始Token是唯一的,所以预测不需要回溯。课程的验收重点在于if-else的悬挂else问题(dangling-else),SNL语言的约定是else匹配最近的未匹配if。递归下降天然就能正确处理这个问题,因为parseIfStatement读到then后面的statement时,如果这个statement又是if开头的嵌套,内层if会先消费掉自己的else,剩下外层if再消费自己的else。

错误恢复方面,项目用了「同步Token」策略:当解析出错时,raiseSyntaxError会记录错误信息并跳过当前语句级Token,直到遇到分号或end这类同步点,再继续尝试解析下一条语句。这个策略能保证分析器在遇到第一个语法错误后不会立刻崩溃,而是报告完错误继续分析后续代码。实际测试中,c4.txt或c5.txt里如果有语法错误,会自动报错并尽量把后面的结构也解析出来,这一点对课程设计的验收展示非常关键。

4. LL1语法分析:First集、Follow集计算与分析表驱动的预测分析

4.1 LL1的「1」到底意味着什么:单向前看符的决策能力

LL1是另一种自顶向下分析法,全称是「Left-to-Right, Leftmost derivation, using one look-ahead」。和递归下降相比,LL1把「每个非终结符对应一个函数」的隐性逻辑显式化成了一张分析表。这张表的每一行对应一个非终结符,每一列对应一个终结符(Token),表项里存放的是「在当前非终结符见到当前输入Token时应该选用哪条产生式」。

项目里ll1_parse.cpp和ll1_parse.h实现的就是这一套。它和递归下降在课程设计里是二选一的关系,但这份项目巧妙在把两套都实现了,并且用Qt可视化把两种分析过程的差异直接展示出来——递归下降显示的是AST树,LL1显示的是分析栈的逐层推导。知识点表现力直接拉满。

要用LL1分析,必须先回答一个问题:这张表从哪来?答案是先求First集和Follow集,再通过这两个集合构造分析表。下面分别拆开讲。

4.2 First集与Follow集的计算:闭包算法与代码实现

First集的定义是:从某个非终结符出发,能推导出的所有句子中最左边的终结符集合。如果这个非终结符能推导出空串(ε),那么空串也属于它的First集。

Follow集的定义是:在所有的句型中,紧跟在某个非终结符后面可能出现的终结符集合。

ll1_parse.cpp里实现的计算逻辑一般分两步。先是初始化:

// ll1_parse.cpp - First集与Follow集的初始化与迭代计算(算法框架) void LL1Parser::buildFirstSets() { // 初始化:终结符的First集是自身 for (auto& term : terminalSet) { firstSet[term].insert(term); } // 迭代:对每条产生式 A → X1 X2 ... Xn // 根据X1的First集向A的First集添加元素 for (auto& prod : productions) { const Symbol& left = prod.left; const vector<Symbol>& right = prod.right; // 情况1:右部第一个符号是终结符,直接加入A的First集 if (isTerminal(right[0])) { firstSet[left].insert(right[0]); } // 情况2:右部第一个符号是非终结符,拷贝它的First集(不含ε) else if (isNonterminal(right[0])) { for (symbol s : firstSet[right[0]]) { if (s != EPSILON) firstSet[left].insert(s); } } } // 迭代直到所有集合不再变化(fixed-point iteration) }

迭代过程有个容易忽略的细节:如果某条产生式的右部是A → X Y,且X的First集里包含ε,那么还需要继续看Y的First集,把所有能推出ε的前缀全部跳过,直到遇到一个First集不含ε的符号为止。例如A → B C中如果B能推导出ε,那么C的First集也要并入A的First集。这个「跳过ε链」的处理是很多实现的bug温床,计算时要特别小心。

Follow集的计算比First集复杂一些,因为Follow(A)的定义涉及「所有句型中A后面可能出现的终结符」,这需要在全局的产生式集合上做闭包运算。项目的实现框架是:

// ll1_parse.cpp - Follow集迭代更新 void LL1Parser::buildFollowSets() { // 规则1:开始符号的Follow集包含结束符 # followSet[startSymbol].insert(END_MARKER); // 规则2:对形如 A → α B β 的产生式 // 把First(β)(不含ε)加入Follow(B) // 规则3:如果β能推出ε(或β为空),把Follow(A)加入Follow(B) // 循环应用所有规则,直到全集不再更新 bool updated = true; while (updated) { updated = false; for (auto& prod : productions) { const vector<Symbol>& right = prod.right; for (size_t i = 0; i < right.size(); i++) { if (!isNonterminal(right[i])) continue; // 分析右部符号 right[i] 后面跟什么 // 实现与First集联动的闭包传递 } } } }

这段代码可以解释一个排查思路:如果最后得到的Follow集中缺少某些终结符,一定是规则3的「传递」没做完整,或者ε判断条件写错了。我验证过,SNL文法本身是满足LL1条件的,所以理论上每个非终结符的First集和Follow集都不存在「交集非空」的冲突。

4.3 LL1分析表构造:预测表与冲突检测

有了First集和Follow集,分析表的构造就机械化了。对于每条产生式A → α,依次执行:

  1. 对First(α)中的每个终结符t,把产生式填入表项table[A][t]。
  2. 如果ε属于First(α),对Follow(A)中的每个终结符t,也把产生式填入table[A][t]。
// ll1_parse.cpp - 分析表填充核心逻辑 void LL1Parser::buildParsingTable() { for (auto& prod : productions) { const Symbol& A = prod.left; const vector<Symbol>& alpha = prod.right; // 计算 FIRST(alpha) set<Symbol> firstAlpha = getFirstOfSequence(alpha); for (Symbol t : firstAlpha) { if (t != EPSILON) { if (!table[A][t].empty()) { raiseConflictError("表项冲突: " + A + " 在终结符 " + t + " 下有多个产生式"); } table[A][t] = prod; } } if (firstAlpha.count(EPSILON)) { for (Symbol f : followSet[A]) { if (!table[A][f].empty()) { raiseConflictError("表项冲突(ε产生式): " + A + " 在终结符 " + f + " 下有多个产生式"); } table[A][f] = prod; // 当A能推出空串时,用Follow集填充 } } } }

构造完成后,必须做一步冲突检查——如果table[A][t]里被填入了两条不同的产生式,说明文法不是LL1文法。这时候你有三个选择:改写文法、提取左公因子、或者接受「这是课程设计,写个报告说明为什么冲突但不强制消除」。但SNL文法是经过验证的教科文法,正常情况下不会冲突。如果出现冲突,先自查是First集还是Follow集合算错了。

4.4 预测分析器的驱动:栈顶匹配与推导过程可视化

分析表建好后,预测分析的执行过程是一个显式栈驱动的循环:

// ll1_parse.cpp - LL1预测分析主循环 bool LL1Parser::analyze(const vector<Token>& tokenStream) { stack<Symbol> analysisStack; analysisStack.push(END_MARKER); // 栈底标记 analysisStack.push(startSymbol); // 初始时栈里压入开始符号 size_t inputIndex = 0; // 当前输入Token的下标 Symbol currentSymbol = tokenStream[inputIndex].type; while (!analysisStack.empty()) { Symbol top = analysisStack.top(); if (top == currentSymbol && currentSymbol == END_MARKER) { return true; // 栈空且输入结束,接受 } if (isTerminal(top)) { if (top == currentSymbol) { analysisStack.pop(); // 匹配终结符 inputIndex++; currentSymbol = tokenStream[inputIndex].type; } else { raiseSyntaxError("栈顶终结符与输入不匹配"); return false; } } else { // 非终结符:查分析表 auto& entry = table[top][currentSymbol]; if (entry.isEmpty) { raiseSyntaxError("分析表项为空,无法推导"); return false; } analysisStack.pop(); // 产生式右部逆序压栈(保证栈顶是最左边的符号) const auto& right = entry.right; for (int i = right.size() - 1; i >= 0; i--) { if (right[i] != EPSILON) { analysisStack.push(right[i]); } } } // 每步推导记录快照,供图形化展示 recordDerivationStep(analysisStack, inputIndex); } return false; }

这段代码最值得注意的参数是「逆序压栈」。产生式E' → + T E'右部逆序压栈后,栈顶是+,所以下一步直接和输入Token比对;如果正序压栈,栈顶会变成E',逻辑就全乱了。ll1_parse.cpp里每次推导都通过recordDerivationStep保存栈快照,这些快照就是Qt界面里那个「推导过程表格」的数据来源。

与第3章的递归下降对比,LL1的好处是分析表驱动,文法改起来不需要大改代码,只有改表;缺点是非LL1文法的适配要额外做改写。课程设计报告中如果能写清楚「两者在SNL上的等价性」,是个很好的加分论点。

5. 避坑与调试排查:SNL编译器落地中的五个高频翻车点

5.1 跨文件编译:CMakeLists配置与Qt环境分离的坑

现象:在Windows上用命令行直接g++编译src目录下的.cpp文件,报一堆「未定义引用」或「找不到QMainWindow」错误。

原因:这个项目是Qt工程,文件里有mainwindow.h、parsescene.h、*.ui等Qt组件。词法分析和语法分析的核心代码是纯C++,但main.cpp和mainwindow.cpp依赖Qt库。直接g++ *.cpp没法自动处理Qt的元对象编译器(moc)和UI文件编译。

解决:根目录已经给了CMakeLists.txt和SNLCompilerGraphic.pro。Qt Creator里直接打开.pro文件,或者用CMake配置。命令行CMake的推荐操作是:

mkdir build && cd build cmake .. -DCMAKE_PREFIX_PATH=/path/to/qt/install make -j4

注意CMAKE_PREFIX_PATH必须指向Qt安装目录的lib/cmake层级,否则会报找不到Qt5Config.cmake。另外,word文档里有些同学直接复制.cpp到Visual Studio编译,也会失败,因为缺了那个.ycm_extra_conf.py里配置的include路径——这个是YouCompleteMe插件的配置,和编译无关,但说明项目原本是VSCode + Qt环境开发的,如果你用VS,需要手动配置Qt的include和lib目录。

5.2 赋值运算符:=被识别成两个独立符号

现象:输入x := 10,词法分析输出是x、:、=、10四个Token,而不是x、:=、10三个Token。

原因:handlerSymbol里对冒号的处理没做向前看,或者向前看的lookahead逻辑被跳过了。这是词法分析最常见的翻车点。

解决:对比我在2.2节贴出的handleSymbol代码,确认:分支里有currentPos < sourceCode.length() && sourceCode[currentPos] == '='的判断。如果自己重写,可以加一个断言:解析x:=10时,输出Token序列必须包含ASSIGN类型。

5.3 递归下降分析器在空语句处死循环

现象:测试用例解析到begin end(空语句块)时,程序卡住或栈溢出。

原因:parseStatement对空语句(ε产生式)处理不当。如果文法里有statement → ε,而实现代码在currentToken是end或;时返回nullptr,但外层调用者又一次loop调用parseStatement,就会无限循环。

解决:空语句分支必须明确「消费一个同步Token后再返回」,或者设置循环退出条件。正确逻辑是:检测到当前Token是end或;时,直接返回空节点并且不消费Token,让上层statement-list的循环条件自己判停。排查时在循环里加一个计数器,打印每次循环的Token类型,就能飞快定位。

5.4 LL1表构造崩溃:非终结符在map中找不到

现象:buildParsingTable运行时抛std::out_of_range,定位在table[A][t]或followSet[A]。

原因:产生式里右部的某个符号在初始化阶段没有进terminalSet或nonterminalSet。通常是在定义产生式时手抖敲错了字符,比如把&这种非法符号写进了产生式串里。

解决:在buildParsingTable之前加一个断言——遍历所有产生式的所有符号,确认每个符号要么在terminalSet要么在nonterminalSet里,否则报错提示语法错误。这个检查能帮你从「运行时奇怪的崩溃」定位到「文法书写错误」。SNL语法要求严格,一个多余的空格都可能造成这类问题。

5.5 可视化界面不刷新或语法树错位

现象:词法分析完毕,但lexscene界面上的Token表格是空的;或者语法分析树节点位置错乱。

原因:lexscene.cpp和parsescene.cpp的刷新逻辑依赖信号槽机制。词法分析完成后没有emit对应的signal,或者分析树的父子节点指针悬空。

解决:检查mainwindow.cpp里词法分析和语法分析的调用时机。我先说一种常见做法是让词法分析动作执行完后显式调用scene->update()并emit tokenListReady信号。如果节点错位,检查ASTNode的坐标计算——父节点必须在子节点左边界之前,递归计算宽度时要用返回的子树宽度来累加偏移量。

6. 从课程设计到实用DSL解析器:验证方法与三个扩展思路

6.1 用内置测试集做全链路验收:一个可复制的验证流程

项目根目录的snl_example文件夹里给了c1.txt、c2.txt、c4.txt、c5.txt四份SNL源程序(可能还有c3,视压缩包而定),这是最宝贵的验证资产。我建议的验收流程是:

第一步,逐个跑词法分析。用Qt界面打开c1.txt,执行词法分析,确认输出的Token流顺序正确。重点观察:=被拆成几个Token、关键字是否被正确识别、行号是否连续。

第二步,跑递归下降分析。看语法树是否完整覆盖了整个程序。对c1.txt这种简单程序,树上应该能看到program节点下挂block、declaration-list、statement-list等子节点。

第三步,跑LL1分析。对比「推导步骤记录」中的每一步栈变化,确认最终得到接受结果。如果某次推导栈顶是非终结符而输入Token不在分析表对应列,说明这一行代码的语法分析和LL1分析用的文法不一致——这个问题我在代码里检查过,两套文法在关键产生式上是一致的。

第四步,故意破坏一个测试文件。把c2.txt里的一个分号删掉,重新分析。正常情况下应该报语法错误但不会崩溃,错误提示里应该包含行号和期望的Token类型。

6.2 扩展思路一:把动作代码挂进产生式,形成三地址码

LL1分析表驱动的一个天然好处是,可以在每条产生式右边挂一个「语义动作」。我建议你在此基础上添加一个中间代码生成模块:在recordDerivationStep的同时,把x := expr + term这类归约动作转化成三地址码。示例接口如下:

// 扩展示例:在分析表归约时生成三地址码 struct ThreeAddrCode { string op; // "+", "-", "*", "/", ":=" string arg1, arg2, result; string toString() const { return result + " := " + arg1 + " " + op + " " + arg2; } };

插入点在analyze函数里「栈顶弹出一个产生式」之后。根据产生式左部非终结符的类型,决定生成哪种中间码。生成结果可以直接输出到文本框中,作为语法分析的后期产物。

6.3 扩展思路二:给LL1分析器增加错误恢复的同步集合

目前的LL1实现在遇到非法的输入Token时会直接返回失败。标准做法是给每个非终结符定义一个「同步终结符集合」(一般取Follow集),当发生错误时,从栈顶开始连续弹出非终结符,直到栈顶的Follow集中包含当前输入Token,再恢复正常分析。这是编译原理课程里「Panic Mode」的经典解法。你在README里看一下项目是否实现了这个,如果没实现,做成扩展能明显提升容错性。

6.4 扩展思路三:符号表收集与作用域管理

词法分析结束后,可以通过遍历Token流收集所有IDENTIFIER类型的Token,按声明顺序构建符号表。SNL支持record结构,所以符号表需要支持嵌套作用域——用栈结构维护作用域链,遇到块结构(begin/end)时入栈和出栈。这一步做完,编译器就从「只做语法分析」推进到「能做简单的语义检查」,比如「变量未声明就使用」的报错。

6.5 我在拆这个项目时的习惯

回想这次拆解,我最大的教训是:不要一上来就抱着parse.cpp从头读到大。在编译原理这个领域,「先跑通、再读代码」的效率远高于「先读代码、再跑通」。我先把四份样例代码分别跑完词法和两种语法分析,观察到界面上的Token表、AST树、LL1推导步骤三条输出线,再带着「这段代码对应的应该是哪个输出」这个问题去源码里找答案。从那以后,我每次拿到一个新的编译器项目,都强制自己先花半小时把样例跑一遍、把界面上的输出表格截图存下来,再去读源码。这样做的好处是,你会先建立「输入输出行为的预期」,读代码时每一个函数都对应一个你已经见过的结果,理解速度和准确率完全不一样。希望帮到你。

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

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

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

立即咨询