编译原理实验:手写词法分析器与LL(1)/LR(1)语法分析器全攻略
简介面向编译原理课程设计的完整实验资源包涵盖词法分析器、LL(1)语法分析器与LR(1)语法分析器三部分源码及配套文档定位为课程实验参考。词法分析器不仅支持关键字、标记符、运算符、分界符、无符号数的匹配还额外实现字符/字符串与行间注释并配有图形界面前端适合需要深入理解词法分析全流程的同学对照学习。压缩包内共52个文件以C源文件、输入输出测试用例、PDF/MD说明文档为主体同时包含HTML页面、动态图和makefile构建文件源码目录、测试数据与说明文档分类存放前端界面与底层实现彼此分离便于按模块查阅整体大小约6.88MB。目前已有1089人学习下载。借助源码、用例和文档读者既能掌握词法分析器从底层逻辑到界面展示的完整设计思路也能进一步迁移到语法分析器的构建与调试中为二次开发和课程设计复用提供扎实基础。1. 这个压缩包到底解决什么从“会做题”到“能交实验”编译原理实验最磨人的一环不是语法规则本身而是把课堂上的集合运算、分析表推导变成一坨能编译、能跑、能对着输入串吐出分析过程的程序。你拿到的这个 zip 里词法分析器、LL(1) 语法分析器、LR(1) 语法分析器三个模块通常都是独立工程各自对应实验指导书上的第 2、3、4 次实验做完能直接覆盖一个学期的大部分实验分。这个方案适合两类人一类是实验截止前想快速补齐代码并看懂每一行的人另一类是已经写完但被“栈溢出、表不完整、报错信息乱”卡住想对照常见实现找差异的人。先说结论这套资源值不值得下取决于你能不能把它从“别人写好的黑匣子”变成“自己讲得清楚的代码”。下面按三个模块拆开讲每段都会给到能直接抄的步骤、参数和边界坑。2. 词法分析器从正则到 DFA最小扫描器与三类边界2.1 三种词法实现怎么选DFA 是保险做法词法分析器常见实现有三条路直接手写多分支 if、用 Lex/Flex 生成、手写 DFA 状态迁移。实验场景里我见过太多人跪在第一条路上——if 嵌 if 处理关键字、标识符、数字还能扛住一旦加上双字符运算符、:、分支就开始乱。用 Flex 生成虽然快但很多实验手册要求提交源码并现场改词法规则对 Flex 生成的 .c 文件又要解释一堆生成逻辑。手写 DFA 是最稳的状态少、逻辑直白、验收时能指着状态转换表讲清楚而且和后面 LL(1)、LR(1) 的“表驱动”思路一脉相承答辩不容易被问穿。常见做法是在代码里维护一张二维状态表横轴是当前状态纵轴是输入字符类别字母、数字、运算符、空白、其他表项填下一个状态或 -1 表示出错。这张表在实验报告里可以直接截图充当“状态转换矩阵”的设计说明很加分。// state_table[row][col]行是当前状态列是字符类别 // 字符类别常量C_LETTER0, C_DIGIT1, C_OP2, C_SPACE3, C_OTHER4 int state_table[5][5] { /* 状态0开始*/ { 1, 2, 3, 0, -1 }, /* 状态1标识符*/ { 1, 1, 4, 4, 4 }, /* 状态2数字*/ { -1, 2, 4, 4, 4 }, /* 状态3运算符*/ { 4, 4, 4, 4, 4 }, /* 状态4终态*/ { 4, 4, 4, 4, 4 } };上面是简化版真实项目里状态会更多。注意状态 1 遇数字仍留在 1这就是“标识符允许数字跟在字母后面”的规则状态 2 遇字母直接进终态 4由上层决定是报错还是把“a123”这类非法标识符单独拎出来。这张表最容易被问到的点正是这里数字后面紧跟字母到底算非法标识符还是两个 token不同实验要求结论不同做之前先看指导书。2.2 字典表、状态转换表与返回结构DFA 写好后词法分析器还需要一张关键字表和一个 token 返回结构。关键字表的常见做法是先按标识符规则读出一个字符串再去关键字表里查一遍查到就是关键字查不到就是普通标识符。这样处理 关键字标识符 的分流最省事写完也是十来行代码。struct Token { int type; // 枚举KEYWORD, IDENT, NUMBER, OP, END string lexeme; // 原始字符串 int line; // 行号报错用 }; vectorstring keywords {if, else, while, return, int, float}; // 读完一个 identifier 后判断 if (find(keywords.begin(), keywords.end(), buf) ! keywords.end()) tok.type KEYWORD; else tok.type IDENT;返回结构里 type 用枚举常量不要用字符串。原因很简单LL(1) 语法分析器拿到的 token 流要按终结符编号查表你最后总是要维护一张“终结符名到编号”的映射词法器这边把 token 类型直接定义成枚举后面查询表代码会好看很多。行号字段是给语法分析器做错误恢复用的没有行号的报错信息在验收时会被老师一眼看穿是纯玩具。2.3 双字符运算符和非法字符的最小处理词法分析器里有个高频翻车点两个相邻字符拼成的一个运算符、、:、。处理方案是引入“多看一个字符”的缓存逻辑但很多初版代码会把多看的那一个字符直接扔了导致下一次 getchar 少读一位整个 token 流错位。我一般建议用一个 peek 变量缓存读取运算符时先 peek 一个字符若能拼成双字符运算符就两个一起消费否则把 peek 的字符压回输入流或交给下一轮 token 读取。非法字符的处理要有统一出口。常见做法是记录行号后跳过该字符继续扫描并把错误信息累积到一份列表里而不是 printf 完就崩。词法分析器的输出格式最好做成固定形式比如 “行号 token类型 词素” 一行一条因为语法分析器做测试时要能人工核对 token 序列格式乱了根本没法对。3. LL(1) 语法分析器FIRST/FOLLOW 集与预测表的可落地方案3.1 先算对 FIRST 再谈预测表算法与手算顺序LL(1) 分析器的核心资产是预测分析表而预测分析表的每一格都得先有 FIRST 集和 FOLLOW 集支撑。很多人直接抄网上的求集合代码却不知道集合数据可能是手工填进数组的导致换一条文法就满盘崩。正确顺序应该是先手动对实验文法求一遍 FIRST 和 FOLLOW再用程序验证最后把程序结果硬编码或从配置文件读入。def first_set(grammar, nonterminal): result set() for production in grammar[nonterminal]: for symbol in production: if symbol.islower() or symbol in terminals: result.add(symbol) break else: result | first_set(grammar, symbol) if ε not in first_set(grammar, symbol): break else: result.add(ε) return result这段递归实现有个隐藏问题它是“按需计算”文法里出现间接左递归或循环依赖时会死循环。实验文法是 LL(1) 文法理论上不会有左递归但很多同学把“右递归消掉后”的文法填进了另一个函数定义却没同步更新还是会踩。程序验证阶段我建议把 FIRST 集合打印出来和手算结果逐项对拍别只看一两个非终结符就以为全对。3.2 FOLLOW 集的 3 条传播规则与终结符边界FOLLOW 集的传播规则有三条漏掉第三条是重灾区文法的开始符号 S 永远包含 $如果 A→αBβ则 FIRST(β) 中除 ε 外全部进 FOLLOW(B)如果 A→αB 或 A→αBβ 且 β 推导出 ε则 FOLLOW(A) 整体进 FOLLOW(B)。很多人只写前两条第三条没写导致 M 表里很多格填了 error实际验收时随便一个合法输入串就被打回。代码实现上FOLLOW 需要反复迭代直到集合不再变化这与 FIRST 的递归求法不一样很多初版代码会把 FIRST 的递归思路套到 FOLLOW 上写出来永远是空集。正确做法是初始化加规则然后外层 while 循环判断是否有集合发生变化内层对每条产生式做一次传播。这里给个容易抄的实现框架def follow_set(grammar, first_sets, nonterminal): follow {nt: set() for nt in grammar} follow[grammar.start].add($) changed True while changed: changed False for nt, productions in grammar.items(): for prod in productions: for i, symbol in enumerate(prod): if symbol in grammar: # 是非终结符 beta prod[i1:] if beta: follow[symbol] | (first_sets[beta[0]] - {ε}) if ε in first_sets[beta[0]]: follow[symbol] | follow[nt] else: follow[symbol] | follow[nt] # 这里注意beta 可能多符号要遍历完 # 每次迭代后比较集合快照有变化就继续 return follow注意上面这段只处理了 beta 单符号多符号的 FIRST 传播在实现时要加一个小循环遍历 beta 前缀的 FIRST 集。这不是最优代码但作为实验证明“集合收敛过程”够用。3.3 三种表驱动实现方案驱动循环、错误标记和栈顶处理预测分析表 M[A, a] 填的是产生式编号找不到就代表语法错误。驱动循环的写法网上千篇一律但三个细节决定成败。第一分析栈初始化是压入 $ 和开始符号别压反。第二弹栈时输入 token 与栈顶终结符相等才算匹配匹配后要立刻读下一个 token很多代码把读 token 放在循环末尾导致多吞了一个。第三出错时不能直接 exit要打印行号和期望 token 集合再把栈顶非终结符弹出panic recovery。表驱动分析器里还有一个常被忽略的问题栈顶是非终结符 X输入是 a查表 M[X, a] 得到出错。这时常见做法不是直接崩而是打印“syntax error, unexpected token a at line n”然后跳过若干 token 或弹栈。实验报告里如果能写出这段恢复逻辑通常能多拿两三分因为指导书只要求“报错”没要求“恢复”你做了就超出预期。LL(1) 的测试用例要准备三组完全合法的输入串、中途出错的输入串、开头就出错的输入串。第三类最容易暴露栈空却还有输入 token 的 bug很多代码处理不了“一上来就错”的情况会直接段错误。4. LR(1) 语法分析器项目集族与移进-归约的表格驱动套路4.1 LR(1) 项目与闭包运算和 SLR(1) 的区别点LR(1) 和 SLR(1) 的差别从项目集闭包开始。SLR(1) 做归约决策时看 FOLLOW 集LR(1) 项目自带向前看符号归约决策更精准。实验代码里你要体现这个区别关键在闭包函数当 [A→α·Bβ, a] 在项目集中且存在产生式 B→γ那么 [B→·γ, b] 也该进来其中 b 是 FIRST(βa) 的结果。很多人的闭包代码只算了 FIRST(β)把末尾那个 a 漏了这会导致 ACTION 表出现本不该有的冲突用 SLR(1) 能跑通的文法到 LR(1) 反而报错非常诡异。闭包运算要用 worklist 迭代队列里装新产生的项目处理完一个就把它标记避免重复遍历。这个结构要手动实现STL 里没有现成的“项目集去重 闭包传播”容器偷懒用 vector 从头扫到尾会造成超长文法下指数膨胀实验文法虽然小但代码风格会被扣分。4.2 ACTION/GOTO 表手工构造移进、归约、接受、报错LR(1) 分析表分成 ACTION 和 GOTO 两张表。ACTION 表按“状态 终结符”定位GOTO 表按“状态 非终结符”定位。构造时反复出现的操作是 goto(I, X)对项目集 I 里所有形如 [A→α·Xβ, a] 的项目把圆点右移得到 [A→αX·β, a]然后对这批项目做闭包生成新状态。手写时最烦的是状态编号管理一个不小心就把不同项目集合并成一个ACTION 表全乱。我建议把状态集做成一棵树的结构每个项目集带上它在全部项目集列表里的 ID生成新集合时先查重再分配新 ID。查重逻辑是考试和实验都爱考的点两个项目集是否相同判定标准是项目集合完全一样包括向前看符号。只比对内核项目不看向前看符号写出的是 LALR(1) 风格的表虽然后面可能也能跑但老师一问就露馅。接下来是 ACTION 表填充若项目 [A→α·aβ, b] 在状态 I 中且 a 是终结符则 ACTION[I, a] 移进到状态 J若项目 [A→α·, a] 在状态 I 中则对 a 填归约第 k 条产生式若项目 [S→S·, $] 在状态 I 中则 ACTION[I, $] 接受。一个坑是归约项目被多个终结符重复填入并且和另一个移进项目撞在同一格时说明文法不是 LR(1) 的程序要能识别冲突类型并输出具体是哪两个项目打架不要只打一条笼统的“conflict”。4.3 一个可跑的模拟循环栈状态与终结符同步LR(1) 分析器运行时维护两个栈符号栈和状态栈。状态栈的栈顶是当前状态查 ACTION 表决定动作。移进时把终结符压符号栈新状态压状态栈归约时按产生式右部长度弹掉同样数量的符号和状态再用弹完后的新栈顶状态查 GOTO 表把左部非终结符压符号栈、新状态压状态栈。很多人只压符号栈不压状态栈驱动循环跑两步就乱了。while (true) { int s state_stack.top(); int a current_token.type; Action act action_table[s][a]; if (act.kind SHIFT) { symbol_stack.push(a); state_stack.push(act.target); current_token lexer.next_token(); } else if (act.kind REDUCE) { Production p productions[act.prod_index]; for (int i 0; i p.rhs_len; i) { symbol_stack.pop(); state_stack.pop(); } int t state_stack.top(); symbol_stack.push(p.lhs); state_stack.push(goto_table[t][p.lhs]); } else if (act.kind ACCEPT) { break; } else { error_report(current_token.line, current_token.lexeme); recover(); } }这段循环里最容易被忽略的是归约后只能重新查一次 GOTO 表不能把 GOTO 表结果和 ACTION 表混用。我第一次写就把 goto_table[t][p.lhs] 写成了 action_table[t][p.lhs]结果非终结符一行根本没数据程序给了一串“unknown action”才反应过来。另外recover() 的粒度控制在跳过当前 token 还是跳到同步非终结符实验场景简单做法是直接跳过几个 token别让分析器进入死循环。5. “跑不起来”的整改清单代码搭建、运行测试与三个常见验收坑5.1 编译流程与运行步骤Makefile 和最小测试输入三个模块一般是三个独立程序我建议你先把词法分析器编出来拿到稳定的 token 流再编 LL(1)最后编 LR(1)。原因很朴素LR(1) 的调试输出依赖 token 流词法器输出不稳定时LR(1) 的问题和词法器的问题会混在一起根本分不清是谁的锅。make lexer ./lexer test.c ./lexer input.txt make parser_ll1 ./parser_ll1 program.txt make parser_lr1 ./parser_lr1 program.txtMakefile 里三个目标分开写依赖项写上各自目录下的 .cpp 文件。头文件放 include 目录源文件放 src 目录实验报告里贴目录树截图会显得工程感很强。输入文件统一用 UTF-8 无 BOM 编码保存BOM 会把第一个 token 的第一个字符变成不可见字符词法器直接认为非法输入这个和 IDE 设置有关是 Windows 下最常见的隐形杀手。5.2 避坑一FIRST 集永远算不对问题出在没处理空产生式现象代码和 ppt 算法看起来一模一样但某个非终结符的 FIRST 集里多出或少了 ε然后预测表整列错掉。原因many 初版实现把“产生式右部全为空时加入 ε”的判断写在了循环外面或者右部 B C 且 B 能推 ε 时没继续检查 C 是否也能推 ε提前 break 了。解决对产生式右部做短路扫描只有右部所有符号都能推出 ε左部的 FIRST 集才加入 ε。调试时打印 “production X: first so far …” 逐条对。5.3 避坑二LR(1) ACTION 表不完整移进归约撞车现象分析表构造程序运行完错误后处理直接把整个表丢掉运行合法用例却报 syntax error。原因向前看符号在闭包时被截断导致两个项目集合本应有不同向前看符号却被判定为同一状态归约项目不完整。解决把闭包函数里的向前看计算单独抽一个函数 first_plus(beta, lookahead) 返回集合单元测试直接断言某个闭包结果是否包含预期项目。排查时打印状态集合的完整项目重点看带逗号后面的符号位。5.4 避坑三cin 读 token 吞掉下一个字符现象词法分析器单独跑没问题接进 LL(1) 分析器后 token 序列错位第一个词总是丢失。原因词法器里用 cin.get() 读字符判定了双字符运算符后把看一看的那个字符丢掉而 LL(1) 驱动循环里又用 cin token.lexeme 方式读取缓冲流互相干扰。解决词法器全面改成逐字符 get() 读取并实现一个 unread_char(char) 把多读的字符塞回流。任何模块都不允许用格式化提取运算符读 token统一走 lexer 接口。6. 把三个模块缝成一个 token 管道一个期末常被忽略的加分技巧很多学校把三个实验分开交代码不要求联动。但如果你时间富余或者老师验收时随口问“词法器和语法器怎么接”你已经会写一个只有二十行的 token 管道场面会完全不一样。做法是给词法器加一个 get_next_token() 接口返回 Token 对象而不是直接打印然后用一个 token_buffer 实现一个“推回一个 token”的能力这样 LL(1) 和 LR(1) 的 lookahead 都能靠它实现不用各自维护一套对输入文件的读指针。class LexerWithBuffer { Token lookahead; bool has_lookahead false; public: Token peek() { if (!has_lookahead) { lookahead scan(); has_lookahead true; } return lookahead; } Token next() { if (has_lookahead) { has_lookahead false; return lookahead; } return scan(); } };接入语法器时把所有直接 read_token() 的调用换成 peek()/next() 交替使用。这个习惯帮我避开过很多次“多读一个 token”的翻车现场。我现在的习惯是所有实验代码里词法器一律暴露 peek/next 两个方法哪怕只写一个实验也这样留接口因为到最后缝管道时永远需要 lookahead黑匣子开一次不如一开始就留好门。上面这个是纯头文件类不需要额外依赖放进 include 目录重新编译就能接入。补充一个验证技巧跑完一个完整用例后把 token 流、LL(1) 的移进步骤、LR(1) 的移进归约步骤存成三份日志文件逐行对照。第一次对照你会发现三个模块各自都对合在一起就对不上原因就是 token 同步问题。养成这个保存日志的习惯验收时拿日志讲“这边 token 流是这么喂给语法器的”胜过长篇大论背算法。希望帮到你。本文还有配套的精品资源点击获取