ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SNL编译器课设实战:C++实现词法分析、递归下降与LL1语法分析

SNL编译器课设实战:C++实现词法分析、递归下降与LL1语法分析 简介这份资源面向高校计算机专业学习编译原理的学生与课程设计开发者提供一套用C实现的SNL语言编译器源码覆盖词法分析、递归下降语法分析与LL1语法分析三大核心环节适合需要完成课程设计或想将编译理论落地为代码的读者。压缩包共36个文件以9个h头文件与9个cpp源文件为主体另有6个txt测试用例、4个xml配置、2个gif演示图及py、snl、pro、ui等辅助文件整体约1.43MB结构清晰便于按模块阅读。项目围绕标记识别、First集与Follow集计算、LL1分析表构造等知识点展开源码中可看到词法扫描、递归下降函数匹配与预测分析流程的具体组织方式并配有示例输入文件便于验证。目前已有767人学习下载可作为理解编译器工作流程、积累C工程实践的参考案例。1. SNL 编译器课设从词法到 LL1一套能跑通的 C 实现路径SNL 是一门为编译原理教学设计的类 Pascal 小语言语法结构清晰、关键字少、类型系统简单非常适合拿来练手完整的编译器前端。很多学校的课程设计要求在一周内交出词法分析、递归下降语法分析和 LL1 语法分析三件套还要能对同一份源码给出统一的错误定位。我当年做这个课设时第一版把词法分析写成了一堆 if-else 硬编码结果遇到:和:的区分就翻车了后来重构成状态机才稳定下来。这篇文章面向正在做编译原理课程设计、需要 C 实现 SNL 词法分析与语法分析的读者把词法分析器的状态机设计、递归下降的写法、LL1 预测分析表的构造以及三者在同一个工程里怎么组织按可复现的步骤讲清楚。读完你应该能直接照着搭出一个能通过基础测试用例的 SNL 前端。2. SNL 词法分析器用状态机替代 if-else 的 C 实现词法分析是整个前端的入口输入是 SNL 源程序的字符流输出是 token 序列。SNL 的 token 类型不算多关键字program、procedure、type、var、begin、end、if、then、else、while、do、read、write、return、integer、char、array、record、of、标识符、整数常量、字符常量、运算符 - * / :、界符( ) [ ] ; , . ..以及注释。看起来简单但真正写起来最容易出问题的是多字符运算符的识别和注释的跳过。2.1 为什么用状态机而不是逐字符 if-else逐字符 if-else 的写法在 token 种类少的时候能跑但一旦遇到:后面跟还是跟别的字符、后面跟还是、.后面跟.还是单独作为记录字段访问分支就会爆炸。状态机把「当前读到什么字符、处于什么状态、下一步跳哪里」显式建模代码可读性和可维护性都高一个档次。常见做法是定义一个TokenType枚举和一个Token结构体然后用一个DFA函数驱动整个扫描过程。2.2 Token 定义与状态机骨架先定义 token 类型和结构体这是后续语法分析器的输入契约必须一开始就定好不然后面改起来牵一发动全身。// token.h #pragma once #include string enum class TokenType { // 关键字 PROGRAM, PROCEDURE, TYPE, VAR, BEGIN, END, IF, THEN, ELSE, WHILE, DO, READ, WRITE, RETURN, INTEGER, CHAR, ARRAY, RECORD, OF, // 标识符与常量 ID, INTCONST, CHARCONST, // 运算符 PLUS, MINUS, TIMES, DIV, EQ, LT, GT, ASSIGN, // 界符 LPAREN, RPAREN, LBRACKET, RBRACKET, SEMI, COMMA, DOT, DOTDOT, // 特殊 EOF_TOKEN, ERROR }; struct Token { TokenType type; std::string lexeme; // 原始字符串 int line; // 行号用于报错 int col; // 列号 };lexeme保留原始字符串是为了后面语法分析报错时能直接打印出问题位置的内容line和col是错误定位的基础。很多同学第一版不记录行列号结果语法分析报错只能说「第几个 token 出错」调试体验极差。2.3 扫描主循环与关键字识别扫描主循环的核心逻辑是跳过空白和注释读一个字符判断进入哪个分支然后尽可能多地消费字符组成一个完整 token。// lexer.cpp #include token.h #include cctype #include unordered_map static const std::unordered_mapstd::string, TokenType keywords { {program, TokenType::PROGRAM}, {procedure, TokenType::PROCEDURE}, {type, TokenType::TYPE}, {var, TokenType::VAR}, {begin, TokenType::BEGIN}, {end, TokenType::END}, {if, TokenType::IF}, {then, TokenType::THEN}, {else, TokenType::ELSE}, {while, TokenType::WHILE}, {do, TokenType::DO}, {read, TokenType::READ}, {write, TokenType::WRITE}, {return, TokenType::RETURN}, {integer, TokenType::INTEGER}, {char, TokenType::CHAR}, {array, TokenType::ARRAY}, {record, TokenType::RECORD}, {of, TokenType::OF} }; std::vectorToken tokenize(const std::string src) { std::vectorToken tokens; size_t i 0; int line 1, col 1; auto advance []() { if (src[i] \n) { line; col 1; } else col; i; }; while (i src.size()) { // 跳过空白 if (std::isspace(static_castunsigned char(src[i]))) { advance(); continue; } // 跳过注释 { ... } if (src[i] {) { while (i src.size() src[i] ! }) advance(); if (i src.size()) advance(); // 跳过 } continue; } int startLine line, startCol col; // 标识符或关键字 if (std::isalpha(static_castunsigned char(src[i]))) { std::string lex; while (i src.size() (std::isalnum(static_castunsigned char(src[i])) || src[i] _)) { lex src[i]; advance(); } auto it keywords.find(lex); TokenType t (it ! keywords.end()) ? it-second : TokenType::ID; tokens.push_back({t, lex, startLine, startCol}); continue; } // 数字常量 if (std::isdigit(static_castunsigned char(src[i]))) { std::string lex; while (i src.size() std::isdigit(static_castunsigned char(src[i]))) { lex src[i]; advance(); } tokens.push_back({TokenType::INTCONST, lex, startLine, startCol}); continue; } // 运算符与界符 char c src[i]; switch (c) { case :: advance(); if (i src.size() src[i] ) { advance(); tokens.push_back({TokenType::ASSIGN, :, startLine, startCol}); } else tokens.push_back({TokenType::ERROR, :, startLine, startCol}); break; case .: advance(); if (i src.size() src[i] .) { advance(); tokens.push_back({TokenType::DOTDOT, .., startLine, startCol}); } else tokens.push_back({TokenType::DOT, ., startLine, startCol}); break; case : advance(); if (i src.size() src[i] ) { advance(); tokens.push_back({TokenType::LT, , startLine, startCol}); } else tokens.push_back({TokenType::LT, , startLine, startCol}); break; // 其余单字符运算符和界符省略按同样模式补全 default: tokens.push_back({TokenType::ERROR, std::string(1, c), startLine, startCol}); advance(); } } tokens.push_back({TokenType::EOF_TOKEN, , line, col}); return tokens; }这段代码的关键点有三个。第一关键字识别用哈希表而不是一长串 if-else新增关键字只需改表。第二advance用 lambda 统一维护行列号避免手动i时忘记更新位置。第三:、..、这类多字符 token 必须在读到第一个字符后立即 peek 下一个字符不能先 push 再修补。参数方面line和col从 1 开始计数和大多数编辑器的显示习惯一致如果你们课设的测试用例用 0 起始改初始值即可。2.4 字符常量的边界处理SNL 的字符常量用单引号包裹比如a。这里有个容易忽略的点如果源码里出现两个连续单引号有的教材定义它表示空字符有的直接判错。我一般按「读到第一个后再读一个字符再读一个三者构成合法字符常量」来处理中间那个字符如果是本身就报错。这个细节在课设评分里经常被单独测建议在词法分析阶段就明确处理不要留给语法分析。3. 递归下降语法分析把 SNL 文法直接翻译成 C 函数递归下降是最好写的语法分析方法因为它的代码结构和文法产生式几乎一一对应。SNL 的文法不算复杂但有几个地方需要左递归消除和提取左公因子这是课设里最容易卡住的地方。3.1 消除左递归与提取左公因子SNL 的声明部分和语句部分都存在左递归。比如变量声明VarDecl - VarDecl ; ID : Type这种形式直接写成递归函数会无限递归。标准做法是改写成右递归或者用循环。我一般用循环处理列表类产生式因为 C 里写循环比写递归更直观也不容易栈溢出。表达式部分需要提取左公因子。SNL 的表达式文法里Exp - Term ExpExp - Term Exp | - Term Exp | ε这种形式在递归下降里对应一个parseExp函数加一个parseExpPrime函数。很多同学把Exp直接内联进parseExp的循环里也能跑但和 LL1 分析表对不上后面做 LL1 分析器时还得重写一遍。3.2 递归下降分析器的函数骨架下面给出表达式和语句部分的骨架声明部分结构类似按文法补全即可。// parser_rd.cpp #include token.h #include vector #include stdexcept class RecursiveDescentParser { std::vectorToken tokens; size_t pos 0; Token peek() { return tokens[pos]; } Token consume() { return tokens[pos]; } bool check(TokenType t) { return peek().type t; } void expect(TokenType t, const std::string msg) { if (!check(t)) { throw std::runtime_error( Line std::to_string(peek().line) Col std::to_string(peek().col) : msg , got peek().lexeme ); } consume(); } public: explicit RecursiveDescentParser(std::vectorToken t) : tokens(std::move(t)) {} // Exp - Term Exp void parseExp() { parseTerm(); parseExpPrime(); } // Exp - Term Exp | - Term Exp | ε void parseExpPrime() { while (check(TokenType::PLUS) || check(TokenType::MINUS)) { consume(); parseTerm(); } } // Term - Factor Term void parseTerm() { parseFactor(); parseTermPrime(); } // Term - * Factor Term | / Factor Term | ε void parseTermPrime() { while (check(TokenType::TIMES) || check(TokenType::DIV)) { consume(); parseFactor(); } } // Factor - ( Exp ) | INTCONST | ID | ID [ Exp ] | ID . ID void parseFactor() { if (check(TokenType::LPAREN)) { consume(); parseExp(); expect(TokenType::RPAREN, expected )); } else if (check(TokenType::INTCONST)) { consume(); } else if (check(TokenType::ID)) { consume(); if (check(TokenType::LBRACKET)) { consume(); parseExp(); expect(TokenType::RBRACKET, expected ]); } else if (check(TokenType::DOT)) { consume(); expect(TokenType::ID, expected field name after .); } } else { throw std::runtime_error( Line std::to_string(peek().line) Col std::to_string(peek().col) : unexpected token peek().lexeme in factor); } } // Stmt - if Exp then Stmt [else Stmt] // | while Exp do Stmt // | read ID | write Exp | ID : Exp | return Exp void parseStmt() { if (check(TokenType::IF)) { consume(); parseExp(); expect(TokenType::THEN, expected then); parseStmt(); if (check(TokenType::ELSE)) { consume(); parseStmt(); } } else if (check(TokenType::WHILE)) { consume(); parseExp(); expect(TokenType::DO, expected do); parseStmt(); } else if (check(TokenType::READ)) { consume(); expect(TokenType::ID, expected ID after read); } else if (check(TokenType::WRITE)) { consume(); parseExp(); } else if (check(TokenType::ID)) { consume(); expect(TokenType::ASSIGN, expected :); parseExp(); } else if (check(TokenType::RETURN)) { consume(); parseExp(); } else { throw std::runtime_error( Line std::to_string(peek().line) Col std::to_string(peek().col) : unexpected token peek().lexeme in statement); } } };这段代码里parseExpPrime和parseTermPrime用 while 循环替代了递归效果等价但更省栈。parseFactor里对ID的处理覆盖了三种情况裸标识符、数组下标、记录字段访问这是 SNL 里比较有特色的地方。expect函数统一做错误报告把行列号和实际读到的 token 都打出来调试时能直接定位到源码位置。参数方面pos从 0 开始tokens末尾必须有一个EOF_TOKEN作为哨兵否则peek会越界。3.3 错误恢复的取舍递归下降的错误恢复是个难点。课设里通常不要求做复杂的错误恢复遇到第一个语法错误直接抛异常、打印位置、终止分析就能拿到大部分分数。如果你们老师要求「一次分析报出多个错误」可以在expect失败时记录错误、跳过当前 token、继续往下走但这样容易产生连锁误报。我的建议是先保证单错误定位准确有多余时间再做恢复。4. LL1 语法分析预测分析表的构造与驱动LL1 分析和递归下降是同一套文法的两种实现方式。递归下降是「代码即文法」LL1 是「表即文法」。课设里通常要求两种都做目的是让你理解两者的等价性。LL1 的核心工作是构造 FIRST 集、FOLLOW 集和预测分析表然后用一个栈驱动的循环来模拟最左推导。4.1 FIRST 集与 FOLLOW 集的 C 计算FIRST 集和 FOLLOW 集的计算是固定点迭代用 C 实现时要注意终止条件。下面给出核心逻辑。// ll1_table.cpp #include map #include set #include vector #include string using Symbol std::string; // 非终结符或终结符 using Production std::vectorSymbol; // 右部 using Grammar std::mapSymbol, std::vectorProduction; // 计算 FIRST 集 std::mapSymbol, std::setSymbol computeFirst(const Grammar G, const std::setSymbol terminals) { std::mapSymbol, std::setSymbol first; // 终结符的 FIRST 是它自己 for (auto t : terminals) first[t].insert(t); bool changed true; while (changed) { changed false; for (auto [lhs, prods] : G) { for (auto prod : prods) { bool allNullable true; for (auto sym : prod) { for (auto f : first[sym]) { if (f ! ε first[lhs].insert(f).second) changed true; } if (first[sym].count(ε) 0) { allNullable false; break; } } if (allNullable first[lhs].insert(ε).second) changed true; } } } return first; } // 计算 FOLLOW 集start 为开始符号 std::mapSymbol, std::setSymbol computeFollow( const Grammar G, const std::mapSymbol, std::setSymbol first, const Symbol start) { std::mapSymbol, std::setSymbol follow; follow[start].insert($); // 输入结束符 bool changed true; while (changed) { changed false; for (auto [lhs, prods] : G) { for (auto prod : prods) { for (size_t i 0; i prod.size(); i) { Symbol B prod[i]; if (G.count(B) 0) continue; // 终结符跳过 // 把 FIRST(β) - {ε} 加入 FOLLOW(B) bool betaNullable true; for (size_t j i 1; j prod.size(); j) { Symbol beta prod[j]; for (auto f : first.at(beta)) { if (f ! ε follow[B].insert(f).second) changed true; } if (first.at(beta).count(ε) 0) { betaNullable false; break; } } // 如果 β 可空把 FOLLOW(lhs) 加入 FOLLOW(B) if (betaNullable) { for (auto f : follow[lhs]) { if (follow[B].insert(f).second) changed true; } } } } } } return follow; }computeFirst里用changed标志控制固定点迭代每轮扫描所有产生式直到没有新符号加入为止。computeFollow的逻辑分两步先把FIRST(β) - {ε}加入FOLLOW(B)如果 β 可空再把FOLLOW(lhs)加入。注意first.at(beta)用at而不是[]因为终结符的 FIRST 集在初始化时已经填好用[]会意外插入空集导致逻辑错误。4.2 预测分析表的构造与冲突处理有了 FIRST 和 FOLLOW预测分析表的构造就是机械操作对每个产生式A - α把FIRST(α) - {ε}对应的终结符位置填上这条产生式如果 α 可空把FOLLOW(A)对应的位置也填上。如果同一个格子被填了两次就是 LL1 冲突。SNL 文法在消除左递归和提取左公因子之后通常是 LL1 的。但如果你在改写文法时偷懒比如把Stmt - if Exp then Stmt | if Exp then Stmt else Stmt直接留着就会在then后面遇到else时产生冲突。解决办法是提取左公因子改写成Stmt - if Exp then Stmt ElsePartElsePart - else Stmt | ε。这个改写是课设里必考的点建议在文法设计阶段就处理好。4.3 栈驱动的 LL1 分析器分析表建好后驱动器就是一个栈加一个输入指针的循环。// ll1_driver.cpp #include stack #include iostream void ll1Parse(const std::vectorToken input, const std::mapstd::pairSymbol, Symbol, Production table, const Symbol start) { std::stackSymbol stk; stk.push($); stk.push(start); size_t ip 0; auto tokenToSymbol [](const Token t) - Symbol { // 把 TokenType 映射成文法里的终结符名按你的命名补全 // 例如 TokenType::ID - id, TokenType::PLUS - return tokenTypeToString(t.type); }; while (!stk.empty()) { Symbol top stk.top(); Symbol cur tokenToSymbol(input[ip]); if (top $ cur $) { std::cout Parse success\n; return; } if (isTerminal(top)) { if (top cur) { stk.pop(); ip; } else { std::cerr Line input[ip].line Col input[ip].col : expected top , got input[ip].lexeme \n; return; } } else { auto key std::make_pair(top, cur); auto it table.find(key); if (it table.end()) { std::cerr Line input[ip].line Col input[ip].col : no production for ( top , cur )\n; return; } stk.pop(); const Production prod it-second; // 逆序压栈保证最左符号在栈顶 for (auto rit prod.rbegin(); rit ! prod.rend(); rit) { if (*rit ! ε) stk.push(*rit); } } } }驱动器的关键点是产生式右部要逆序压栈这样最左符号才会在栈顶被优先匹配。tokenToSymbol负责把词法分析输出的TokenType映射成文法里的终结符名这个映射表要和你的文法定义严格一致否则会出现「明明 token 对了但表里查不到」的玄学问题。错误报告同样带上行列号和递归下降保持一致。5. 三个模块怎么组织工程结构与联调避坑词法、递归下降、LL1 三部分写完后需要在一个工程里组织起来共用 token 定义和错误报告格式。这一章讲工程结构和联调时最容易踩的坑。5.1 推荐的目录结构与编译方式我一般按下面的结构组织每个模块一个.h加一个.cppmain.cpp只负责读文件、调词法、再分别调两个语法分析器。snl_compiler/ ├── include/ │ ├── token.h │ ├── lexer.h │ ├── parser_rd.h │ └── ll1_table.h ├── src/ │ ├── lexer.cpp │ ├── parser_rd.cpp │ ├── ll1_table.cpp │ └── main.cpp └── tests/ ├── ok1.snl ├── err_lex.snl └── err_syntax.snl编译命令用 g 一行搞定g -stdc17 -Iinclude src/*.cpp -o snl_compiler-stdc17是因为代码里用了结构化绑定auto [lhs, prods]C14 不支持。如果你用的是 Visual Studio把标准调到 C17 或更高即可。-Iinclude指定头文件搜索路径避免在源码里写一长串相对路径。5.2 避坑联调时最常见的 5 个问题现象一词法分析把:拆成:和两个 token。原因是在switch里读到:后没有 peek 下一个字符就直接 push 了。解决方法是读到:后先advance再判断当前字符是不是是则合并不是则报错或按单字符处理。现象二递归下降在if语句嵌套时栈溢出。原因是parseStmt对if分支直接递归调用自身深层嵌套时栈帧累积。解决方法是把if的else分支改成循环处理或者限制嵌套深度并在超限时报错。课设测试用例一般不会嵌套太深但养成习惯没坏处。现象三LL1 分析表构造时FIRST集算错导致表里大量空格。最常见的原因是在处理可空产生式时没有正确传播ε。检查方法是手动算一遍Exp的 FIRST 集应该是{, -, ε}如果少了ε说明可空判断逻辑有问题。现象四两个语法分析器对同一个错误报的位置不一致。原因是递归下降用 token 的line/col而 LL1 驱动器用了输入指针ip的下标。解决方法是统一从input[ip]取line/col不要混用。现象五读文件时 Windows 换行符导致列号偏移。如果源码文件是 CRLF 换行\r会被当成普通字符计入列号导致报错位置偏一列。解决方法是在读文件后统一把\r\n替换成\n或者在advance里把\r也当作换行处理。提示课设验收前至少准备三个测试用例——一个完全正确的程序、一个词法错误比如非法字符、一个语法错误比如缺少then确保两个分析器都能给出带行列号的错误信息。6. 用测试用例反推实现一个可复用的验证套路写编译器前端最怕的是「自己觉得对了一跑测试全是错」。我的习惯是先写测试用例再反推实现。具体做法是准备一组覆盖 SNL 主要语法结构的.snl文件每个文件只测一个点然后用一个脚本批量跑对比预期输出。比如下面这个最小测试用例覆盖了变量声明、赋值、if-else、while 和数组访问program test; var i, n : integer; a : array [1..10] of integer; begin read(n); i : 1; while i n do begin a[i] : i * 2; i : i 1 end; if n 0 then write(a[1]) else write(0) end.跑通这个用例基本能说明词法、递归下降、LL1 三条链路都通了。如果某个环节报错按错误信息里的行列号定位到源码再对照文法检查。我一般会再准备几个「故意写错」的版本把:写成、把then漏掉、把end写成end;看两个分析器是否都能报出合理的位置。验证 LL1 分析表是否正确还有一个更直接的办法把分析表打印出来人工抽查几个关键格子。比如(Stmt, if)应该对应Stmt - if Exp then Stmt ElsePart(ElsePart, else)应该对应ElsePart - else Stmt(ElsePart, $)和(ElsePart, end)应该对应ElsePart - ε。这几个格子对了表基本就没大问题。最后一个习惯每次改完文法先把 FIRST 和 FOLLOW 集打印出来存成文件和上一版对比。文法改动对 FIRST/FOLLOW 的影响往往比想象中大有个 diff 能省很多排查时间。做课设那会儿我就是靠这个习惯在最后一天发现ElsePart的 FOLLOW 集漏了end及时补上才没翻车。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进