编译原理课设实战:DFA词法分析与LALR(1)语法分析器实现
工试云启 考证服务中心整理
语法分析器实现)
简介这份资源面向高校计算机专业修读编译原理课程的学生与课程设计开发者提供一套完整的C实现方案涵盖基于DFA的词法分析器与基于LALR(1)的语法分析器帮助读者理解从记号序列到抽象语法树构建的完整流程并掌握action与goto表、分析栈等核心机制。压缩包共17个文件约2.48MB包含3个cpp源文件、3个h头文件、6个txt测试与过程输出文件、2个md说明文档、1份docx与1份pdf课程设计报告以及可直接运行的exe可执行文件覆盖源码、文档与运行验证全环节。目前已有98人学习下载。读者可据此对照文法规则调试词法与语法分析流程借助报告梳理设计思路参考过程输出文件排查分析栈与状态转移问题适合作为课程设计参考或编译原理实验的实践素材。1. 从一份课设说起DFA 词法 LALR(1) 语法分析到底在做什么很多人第一次接触编译原理课设都是被那句“实现一个词法分析器和语法分析器”吓住的。真动手才发现难点不在写代码而在搞清楚 DFA 和 LALR(1) 这两套东西各自负责哪一段、边界在哪。词法分析器负责把字符流切成 tokenDFA 是它最自然的实现模型语法分析器负责把 token 流还原成语法树LALR(1) 是自底向上分析里工程上最常用的一种。两者串起来就是一个能跑通的最小编译器前端。这套东西适合谁正在做编译原理课设的本科生、想补编译基础的 C 开发者、以及需要给自研 DSL 写解析器的人。它不追求完整编译器后端只把前端两个核心模块做扎实。下面按“先立住理论、再动手复现、最后讲坑”的顺序展开代码用 C 写环境用 VS Code 配 C/C 就能跑不需要额外依赖。2. DFA 词法分析器从正则到状态转移表2.1 为什么词法分析要用 DFA 而不是一堆 if-else手写 if-else 也能切 token但一旦关键字、标识符、数字、运算符混在一起分支会爆炸改一个规则要动一片代码。DFA 的好处是把“识别规则”和“识别逻辑”分开规则用正则描述逻辑就是一张状态转移表加一个循环。加一个关键字只改表不改代码。从正则到 DFA 的标准路径是正则表达式 → NFAThompson 构造→ 子集构造 → DFA → 最小化。课设里通常不需要完整实现这条链而是直接手写 DFA 的状态转移表因为 C 语言子集的 token 种类有限手写表反而更可控。常见做法是定义一个二维数组trans[state][char]行是状态列是输入字符值是下一个状态接受状态单独标记。这里有个容易忽略的点DFA 识别的是“最长匹配”。比如输入ifx不能匹配到if就停要继续读到ifx发现不是关键字再回退成标识符。所以实现里必须记录“最后一次处于接受状态的位置”这就是所谓的回溯点。2.2 手写状态转移表一个能跑的最小实现下面这段代码实现了一个简化版 DFA 词法分析器支持标识符、关键字、整数、单字符运算符。状态表用map存方便扩展。#include iostream #include string #include map #include cctype #include vector // 状态定义 enum State { S_START, S_ID, S_NUM, S_OP, S_ERR }; // 关键字表 const std::vectorstd::string keywords {if, else, while, int, return}; struct Token { std::string type; std::string value; }; // DFA 转移函数根据当前状态和字符返回下一状态 State transition(State s, char c) { switch (s) { case S_START: if (isalpha(c) || c _) return S_ID; if (isdigit(c)) return S_NUM; if (c || c - || c * || c /) return S_OP; return S_ERR; case S_ID: if (isalnum(c) || c _) return S_ID; return S_ERR; // 遇到非标识符字符交给外层处理 case S_NUM: if (isdigit(c)) return S_NUM; return S_ERR; case S_OP: return S_ERR; // 单字符运算符读一个就结束 default: return S_ERR; } } std::vectorToken lex(const std::string src) { std::vectorToken tokens; size_t i 0; while (i src.size()) { if (isspace(src[i])) { i; continue; } State cur S_START; size_t start i; size_t lastAccept std::string::npos; State lastState S_ERR; // 最长匹配循环 while (i src.size()) { State next transition(cur, src[i]); if (next S_ERR) break; cur next; i; if (cur S_ID || cur S_NUM || cur S_OP) { lastAccept i; lastState cur; } } if (lastAccept std::string::npos) { std::cerr 词法错误 at start : src[start] std::endl; i start 1; continue; } std::string lexeme src.substr(start, lastAccept - start); i lastAccept; // 回退到最长匹配位置 if (lastState S_ID) { bool isKw false; for (auto k : keywords) { if (lexeme k) { tokens.push_back({KEYWORD, lexeme}); isKw true; break; } } if (!isKw) tokens.push_back({ID, lexeme}); } else if (lastState S_NUM) { tokens.push_back({NUM, lexeme}); } else if (lastState S_OP) { tokens.push_back({OP, lexeme}); } } return tokens; } int main() { std::string src int main ifx 123 return; auto tokens lex(src); for (auto t : tokens) { std::cout t.type : t.value std::endl; } return 0; }这段代码的核心逻辑在lex函数里的最长匹配循环。lastAccept记录最后一次处于接受状态的位置当transition返回S_ERR时跳出内层循环然后回退到lastAccept。参数方面keywords表决定哪些标识符被提升为关键字transition函数里的字符判断决定状态走向。如果要做更完整的 C 语言子集把S_OP扩展成多字符运算符如、时需要在transition里加中间状态比如S_OP_EQ读到后再看下一个字符是不是。提示lastAccept初始化为npos如果第一个字符就非法直接报错并跳过避免死循环。2.3 符号表词法阶段就要埋的伏笔热词里有人搜“编译原理符号表”这不是语法分析才需要的东西。词法阶段识别出标识符后就应该往符号表里插一条记录记录名字、类型未知、作用域。符号表可以用unordered_mapstring, Symbol实现Symbol结构体里放type、scope、line。这样语法分析阶段做类型检查时直接查表就行不用重新扫一遍 token 流。我一般会在词法分析器里加一个symtab成员遇到ID类型的 token 就调用insert如果已存在就更新行号。注意别把关键字也插进去关键字是保留字不是用户标识符。3. LALR(1) 语法分析器从文法到分析表3.1 LALR(1) 和 LR(1)、SLR 的区别以及为什么课设选它LR 系列分析器分三种SLR(1)、LR(1)、LALR(1)。SLR 只看 FOLLOW 集冲突多LR(1) 给每个项目带一个展望符状态数爆炸LALR(1) 把 LR(1) 中同心项目集合并状态数和 SLR 差不多但分析能力接近 LR(1)。课设选 LALR(1)是因为它能在“状态数可接受”和“冲突少”之间取平衡而且 C 语言子集的文法用 LALR(1) 基本不会产生移进-归约冲突。构造 LALR(1) 分析表的完整流程是拓广文法 → 构造 LR(1) 项目集族 → 合并同心项目集 → 构造 ACTION 和 GOTO 表。课设里如果手写通常直接写文法然后跑一个生成器如果自己实现重点在项目集闭包的计算和展望符的传播。这里有个关键概念项目A → α·Bβ, a的展望符a来自 FIRST(βa)。闭包计算时如果B → γ是产生式就要把FIRST(βa)里的每个终结符作为B → ·γ的展望符。合并同心项目集时把核心项目相同、展望符不同的项目集合并展望符取并集。3.2 用 C 实现项目集闭包与 GOTO 函数下面这段代码实现了 LR(1) 项目集闭包和 GOTO 函数是构造 LALR(1) 分析表的基础。文法用产生式数组表示·用位置索引表示。#include iostream #include vector #include set #include map #include string // 产生式左部右部点位置 struct Item { int prod; // 产生式编号 int dot; // 点位置 std::setstd::string lookahead; // 展望符 bool operator(const Item o) const { if (prod ! o.prod) return prod o.prod; return dot o.dot; } }; // 文法产生式左部 - 右部 struct Production { std::string lhs; std::vectorstd::string rhs; }; std::vectorProduction grammar; std::mapstd::string, std::setstd::string firstSets; // 计算 FIRST 集简化版假设已预处理 std::setstd::string firstOfSequence(const std::vectorstd::string seq, int start) { std::setstd::string result; for (int i start; i seq.size(); i) { const std::string sym seq[i]; if (firstSets.count(sym)) { result.insert(firstSets[sym].begin(), firstSets[sym].end()); if (!firstSets[sym].count(ε)) break; } else { result.insert(sym); // 终结符 break; } } return result; } // 闭包计算 std::setItem closure(std::setItem items) { bool changed true; while (changed) { changed false; std::setItem newItems items; for (const auto item : items) { const Production p grammar[item.prod]; if (item.dot p.rhs.size()) continue; std::string B p.rhs[item.dot]; // 如果 B 是非终结符 for (int i 0; i grammar.size(); i) { if (grammar[i].lhs ! B) continue; // 计算 FIRST(βa) std::vectorstd::string beta(p.rhs.begin() item.dot 1, p.rhs.end()); std::setstd::string firstBeta firstOfSequence(beta, 0); std::setstd::string newLookahead; if (firstBeta.count(ε) || beta.empty()) { newLookahead.insert(item.lookahead.begin(), item.lookahead.end()); } newLookahead.insert(firstBeta.begin(), firstBeta.end()); newLookahead.erase(ε); Item newItem{i, 0, newLookahead}; // 检查是否已存在相同 prod 和 dot 的项 bool found false; for (auto it : newItems) { if (it.prod newItem.prod it.dot newItem.dot) { size_t before it.lookahead.size(); it.lookahead.insert(newLookahead.begin(), newLookahead.end()); if (it.lookahead.size() ! before) changed true; found true; break; } } if (!found) { newItems.insert(newItem); changed true; } } } items newItems; } return items; } // GOTO 函数 std::setItem goTo(const std::setItem items, const std::string X) { std::setItem moved; for (const auto item : items) { const Production p grammar[item.prod]; if (item.dot p.rhs.size() p.rhs[item.dot] X) { moved.insert({item.prod, item.dot 1, item.lookahead}); } } return closure(moved); }这段代码里closure函数是核心。它遍历项目集对每个点后面的非终结符B计算FIRST(βa)然后把B → ·γ加进去。注意newLookahead的处理如果β能推导出空串要把原项目的展望符也加进去。goTo函数把点向后移一位再求闭包。参数方面grammar是全局产生式表firstSets需要提前算好。实际课设里firstSets可以用一个单独的函数从文法生成这里为了聚焦闭包逻辑省略了。注意合并同心项目集时只合并核心项目点不在最左的产生式相同的项目集展望符取并集。这一步做错ACTION 表会多出很多不必要的归约项。3.3 构造 ACTION 和 GOTO 表移进-归约冲突怎么处理有了项目集族和 GOTO 函数构造分析表就顺了。对每个项目集I如果A → α·aβ, b且a是终结符ACTION[I, a] 移进 goTo(I, a)如果A → α·, aACTION[I, a] 归约 A → α如果S → S·, $ACTION[I, $] 接受对非终结符AGOTO[I, A] goTo(I, A)。冲突主要有两种移进-归约冲突和归约-归约冲突。LALR(1) 里归约-归约冲突通常意味着文法有二义性需要改写文法。移进-归约冲突最常见的是悬空 else 问题标准解法是“移进优先”因为移进相当于把 else 绑定到最近的 if。如果冲突解决不了说明文法不是 LALR(1) 的得换文法或换分析方式。课设里我一般会写一个冲突检测函数在填表时如果发现同一个格子被填了两次就打印冲突类型和涉及的产生式方便定位。别等到跑分析的时候才发现表是错的那时候调试成本高得多。4. 把两个分析器串起来驱动代码与测试用例4.1 主驱动流程token 流怎么喂给 LALR(1)词法分析器输出vectorToken语法分析器需要一个 token 流接口。常见做法是把Token转成终结符字符串比如ID对应文法里的idNUM对应num关键字直接用小写。然后 LALR(1) 驱动器维护一个状态栈和一个符号栈循环读 token查 ACTION 表决定移进还是归约。// 简化的 LALR(1) 驱动器 bool parse(const std::vectorToken tokens) { std::vectorint stateStack {0}; // 初始状态 std::vectorstd::string symbolStack; size_t pos 0; while (true) { int state stateStack.back(); std::string input (pos tokens.size()) ? tokens[pos].type : $; auto action actionTable[state].find(input); if (action actionTable[state].end()) { std::cerr 语法错误 at token pos : input std::endl; return false; } if (action-second.type SHIFT) { stateStack.push_back(action-second.target); symbolStack.push_back(input); pos; } else if (action-second.type REDUCE) { int prod action-second.target; int len grammar[prod].rhs.size(); for (int i 0; i len; i) { stateStack.pop_back(); symbolStack.pop_back(); } symbolStack.push_back(grammar[prod].lhs); int gotoState gotoTable[stateStack.back()][grammar[prod].lhs]; stateStack.push_back(gotoState); } else if (action-second.type ACCEPT) { return true; } } }驱动器的逻辑很直白移进就把状态和目标压栈归约就弹出对应长度的符号和状态然后查 GOTO 表压入新状态。参数方面actionTable是二维 mapaction-second.type区分移进、归约、接受。注意归约后查 GOTO 用的是归约前栈顶的状态不是归约后的这个顺序别搞反。4.2 测试用例设计从简单表达式到嵌套语句测试用例要覆盖三类正常语句、边界情况、错误输入。正常语句比如int a 1 2 * 3;验证运算符优先级边界情况比如空语句;、嵌套括号((a))错误输入比如int 3;看报错位置对不对。我一般会准备一个测试文件每行一个用例跑完打印“通过/失败”和错误位置。课设报告里附上测试结果比空口说“实现了”有说服力得多。如果时间够可以加一个简单的语法树打印功能把归约过程可视化调试时一眼就能看出哪步归约错了。5. 避坑与排查课设里最容易翻车的 5 个点5.1 词法分析器死循环token 流卡住现象程序跑起来不报错但一直不结束CPU 占用高。原因最长匹配循环里如果transition一直返回非S_ERR但lastAccept没更新i可能不前进。比如S_OP状态读到下一个字符还是运算符transition返回S_ERR跳出但如果lastAccept是nposi只前进了一位外层循环继续又进入同样的状态。解决在lastAccept为npos时强制i start 1并打印错误字符。5.2 LALR(1) 分析表出现归约-归约冲突现象填表时同一个格子被两个不同的归约产生式占用。原因文法有二义性或者两个产生式的右部在某个状态下都能归约。解决先检查文法是否有二义性比如E → E E | E * E这种没处理优先级的写法。改写文法引入T和F分层。如果文法本身没问题检查展望符计算是否正确FIRST集有没有漏掉空串情况。5.3 移进-归约冲突导致 else 绑定错误现象if (a) if (b) c; else d;解析成if (a) { if (b) c; else d; }但期望是if (a) { if (b) c; } else d;。原因LALR(1) 默认移进优先把else移进内层if。解决如果语言规范要求else绑定最近的if移进优先就是对的不用改。如果要求绑定外层得改写文法用matched和unmatched区分。5.4 符号表作用域没处理变量重复定义不报错现象同一个作用域里定义两个同名变量程序不报错。原因符号表只做了插入没做重复检查。解决在insert时先查当前作用域是否已存在同名符号存在就报错。作用域用栈式结构管理进入块时压入新作用域退出时弹出。5.5 VS Code 里 C 跳转失效改代码全靠猜现象#include头文件能找到但函数跳转和变量提示全没了。原因VS Code 的 C/C 扩展没配好c_cpp_properties.json或者compile_commands.json没生成。解决在.vscode/c_cpp_properties.json里配好includePath和compilerPath用 CMake 的话加-DCMAKE_EXPORT_COMPILE_COMMANDSON生成编译数据库。这个坑很常见配好之后开发效率翻倍。6. 进阶技巧用表驱动 单元测试把课设做成可复用的前端课设做完不是终点。如果想让这套代码真正能用在后续项目里有两个方向值得投入一是把 DFA 和 LALR(1) 的表生成过程自动化二是给每个模块写单元测试。表驱动自动化的意思是词法规则用配置文件描述语法产生式用 BNF 写然后写一个生成器自动产出状态转移表和分析表。这样换一个语言子集只改配置不改代码。我一般会用一个简单的 JSON 或自定义格式存规则生成器用 Python 写产出的表用 C 数组或constexpr结构体嵌入。这样课设代码就从“一次性作业”变成了“可复用工具”。单元测试方面词法分析器测三类正常 token、最长匹配、错误字符。语法分析器测四类正常语句、优先级、嵌套、错误恢复。用 Catch2 或 Google Test 都行VS Code 里配好测试任务跑一次几秒钟。下面是一个 Catch2 的测试片段#define CATCH_CONFIG_MAIN #include catch2/catch.hpp TEST_CASE(Lexer longest match, [lexer]) { auto tokens lex(ifx 123); REQUIRE(tokens.size() 2); REQUIRE(tokens[0].type ID); REQUIRE(tokens[0].value ifx); REQUIRE(tokens[1].type NUM); } TEST_CASE(Parser precedence, [parser]) { auto tokens lex(1 2 * 3); REQUIRE(parse(tokens) true); }测试用例不用多覆盖关键路径就行。跑通之后每次改文法或改状态表先跑测试绿了再继续。这个习惯能省下大量“改一处崩三处”的时间。最后说个我自己的教训第一次做课设时我把词法和语法写在一个文件里调试时根本分不清是 token 切错了还是分析表填错了。后来拆成两个独立模块各自有测试问题定位从半小时缩短到两分钟。如果你正在做这个课设先把两个分析器的接口定清楚再各自实现别急着串起来。希望帮到你。本文还有配套的精品资源点击获取