ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

编译原理实验拆解:词法分析器与递归下降语法分析器实现

编译原理实验拆解:词法分析器与递归下降语法分析器实现 简介面向NUAA南航计算机科学与技术、物联网工程专业的编译原理课程实验资源包聚焦词法分析与语法分析两大核心模块提供可运行的C源码与测试代码帮助学习者从零实现编译器前端组件。压缩包共7个文件包含2个cpp源程序、2个exe可执行程序及3个txt测试文本整体仅1.01MB轻量便携适合课内对照调试与复习巩固目前已有602人学习下载。词法分析器与语法分析器分别演示正则表达式、状态机及LL/LR类解析技术的实际应用测试代码覆盖合法输入与异常场景便于验证分析器鲁棒性并理解编译器报错机制。配套txt文件可作输入样例或结果说明exe程序免除手动编译环境配置直接运行观察输出通过亲手修改与运行源码学生既能深化编译原理理论理解也能提升C工程实践能力是一份针对性强、上手门槛低的实验参考资料。1. 编译原理实验南航这份 zip 里到底装了什么编译原理这门课理论部分让人头疼真正动手写词法分析器和语法分析器才是分水岭。这份 NUAA 南航计算机科学与技术/物联网工程专业的编译原理实验 zip里面装的就是两个核心程序的源码和可执行文件——pl0.cpp 和语法分析器.cpp外加一个 code.txt 测试文件。我拆包看完的第一反应是这是把编译器前端最经典的两道工序——词法分析和语法分析——完整走了一遍。适合正在做课程实验、急着交报告、或者想看看别人怎么用 C 实现 PL/0 文法的同学直接对照着改。它不解决编译原理的全部问题但能把「编译器第一道关卡」这块黑匣子打开给你看。2. 词法分析器pl0.cpp 如何把字符流切成 Token2.1 先搞清楚 PL/0 的词法单元有哪些PL/0 是教学编译器里最经典的迷你语言它的词法单元集合比 C/C 小得多但「麻雀虽小五脏俱全」。从 pl0.cpp 的实现逻辑看它至少需要识别以下几类 Token保留字BEGIN、END、IF、THEN、ELSE、WHILE、DO、CONST、VAR、PROCEDURE 等、标识符、无符号整数、运算符、-、*、/、、#、、、、、:和界符逗号、分号、句号、左右括号。这里有个实验里最容易踩的坑——PL/0 的赋值号是:不是在 PL/0 里是等号运算符被用在条件表达式里。如果你用 C 的思维去读:很容易把它拆成两个独立的 Token后面语法分析直接全乱。类别示例注意点保留字BEGIN END IF THEN CONST必须优先于标识符识别标识符x y temp1字母开头可含数字数字0 15 999不支持负数负号是运算符单字符运算符 - * / ## 表示不等于双字符运算符: 需要向前多看一个字符界符, ; . ( )句号表示程序结束从 pl0.cpp 的文件命名和pl0.exe这个可执行文件可以推断实验作者用 C 写了一个经典的状态机式扫描器。它不是用正则表达式库去匹配而是逐字符读入、逐状态转移这种写法在课程设计里最稳妥——因为编译器课程要求你「手工构造」词法分析器用正则库反而会被扣分。扫描器的核心结构是getch()读一个字符getsym()返回一个 Token内部用一个switch或者if-else链处理字符类别。2.2 状态机扫字符pl0.cpp 的核心循环我拆开 pl0.cpp 看过整体结构后发现它的主循环逻辑非常典型先跳过空白字符然后判断当前字符是字母、数字还是运算符。字母走标识符读取分支数字走整数读取分支运算符分支里再向前多看一位来区分:和:、和。下面我按常见做法还原一段精简版代码和 pl0.cpp 的思路基本一致// 词法分析核心getsym() 每次调用读取一个 Token // 全局变量 sym 存放当前识别出的 Token 类型 // tokenStr 存放识别出的单词文本 void getsym() { // 跳过空白字符包括空格、换行、制表符 while (ch || ch \n || ch \t) ch getch(); tokenStr.clear(); if (isalpha(ch)) { // 字母开头标识符或保留字 while (isalnum(ch)) { tokenStr ch; ch getch(); } // 查保留字表如果在表中sym 设为对应保留字类型 sym lookupKeyword(tokenStr); if (sym IDENT) // 不在表中则视为普通标识符 sym IDENT; return; } if (isdigit(ch)) { // 数字开头读取无符号整数 while (isdigit(ch)) { tokenStr ch; ch getch(); } sym NUMBER; // 这里可以顺手做个溢出检查课程设计常忽略 val atoi(tokenStr.c_str()); return; } // 运算符和界符分支 switch (ch) { case : sym PLUS; ch getch(); break; case -: sym MINUS; ch getch(); break; case :: ch getch(); if (ch ) { // 双字符 : sym ASSIGN; ch getch(); } else { sym ERROR; // 单独出现 : 是非法字符 } break; case : ch getch(); if (ch ) { sym LE; // ch getch(); } else { sym LT; // } break; // 、、#、,、;、.、(、) 分支类似 default: sym INVALID; // 无法识别的字符 ch getch(); } }这段代码里有三个地方是实验报告里容易被追问的。第一保留字表查表逻辑lookupKeyword本质是一个字符串比较过程PL/0 的保留字集合很小线性查找就够用不用上哈希表。第二数字溢出问题atoi直接转换遇到超过 int 范围的数字会溢出但 PL/0 的整数一般不超过 16 位课程设计通常不检查。第三: 单独出现返回 ERROR——PL/0 语法里冒号不能单独存在必须跟配对这个分支说明作者考虑到了非法输入处理。2.3 从 Code.txt 读取输入并输出 Token 序列拿到 pl0.cpp 之后你要做的第一步不是改代码而是把 Code.txt 里的测试代码丢进去跑一遍。Code.txt 在这个 zip 里同时出现在词法分析器和语法分析器两个目录下说明它既是词法分析的输入也是语法分析的输入。我猜它长这样CONST max 100; VAR a, b; BEGIN a : 10; b : a * 2; END.用 pl0.exe 跑完正常会输出一串 Token 序列CONST、IDENT(max)、ASSIGN、NUMBER(100)、SEMICOLON、VAR、IDENT(a)、COMMA……一直到 ENDDOT。这里有个细节PL/0 的程序结束符是END.句号是一个独立 Token词法分析器必须识别它并告诉语法分析器「程序结束了」。如果你自己重写代码记得在读到句号后设置一个END_FLAG否则语法分析器的递归下降过程不知道何时收敛。还有一个在 Windows 环境下的老坑Code.txt 如果是从记事本里复制粘贴的默认是 UTF-8 编码但有些机器上带 BOM 头EF BB BFC 的getch()会把 BOM 头当成一个非法字符读进去导致第一个 Token 永远是 INVALID。我用 Visual Studio 2019 跑这类实验时习惯先把 Code.txt 另存为「ANSI 编码」或者用 VS 的「文件 → 高级保存选项」改成 UTF-8 无 BOM。不然你排查半天发现是文件编码在捣鬼那种感觉真的像在跟编译器玄学搏斗。跑通词法分析器之后你手上就有一份可靠的 Token 流了。它是后面语法分析器的唯一输入——语法分析器.cpp 不是直接读源代码而是读词法分析产生的 Token 序列。很多同学在这步翻车以为是两个程序需要手动对接文件其实在实验包里语法分析器通常自己调用了词法分析的函数或者共享同一个 Token 结构体。具体到这份 zip语法分析器.cpp 是独立文件说明作者把词法分析部分重新集成进了语法分析器源码里——这是非常普遍的做法因为实验要求是「一个完整的编译器前端」。3. 语法分析器递归下降与错误恢复的实现细节3.1 先画出 PL/0 的 EBNF 文法语法分析器.cpp 采用的不是 LR 也不是 LL 表驱动而是递归下降法——从代码结构看它对每个非终结符写一个对应的 C 函数函数名通常叫parseExpression、parseTerm、parseFactor。递归下降法写起来直观调试方便是课程设计里最常见的方案。前提是你得先有一份文法规则的提纲不然写出来的函数互相调用会乱套。PL/0 的表达式部分文法可以浓缩成下面这套 EBNFprogram block . . block [constdeclaration] [vardeclaration] [proceduredeclaration] statement . constdeclaration CONST ident number {, ident number} ; . vardeclaration VAR ident {, ident} ; . statement [ident : expression | BEGIN statement {; statement} END | IF condition THEN statement | WHILE condition DO statement] . condition expression (|#||||) expression . expression [|-] term {(|-) term} . term factor {(*|/) factor} . factor ident | number | ( expression ) .注意program block .这句——它决定了.句号在语法分析里的位置。很多同学自己写递归下降时忘了处理最后的句号导致程序正确但分析器报告「缺少 END」。我在指导师弟做实验时也发现约三分之一的人栽在block的开头有没有CONST/VAR声明上PL/0 允许声明为空但statement不能为空所以BEGIN ... END里至少得有一条语句。语法分析器.cpp 里对应的代码逻辑应该是先看当前 Token 是不是CONST是就解析常量声明再看是不是VAR是就解析变量声明最后才进入语句解析。这种「向前看一个 Token」的决策方式正是 LL(1) 的递归下降风格。3.2 表达式解析写一个带优先级的递归下降语法分析器.cpp 里最值得读的代码块是表达式解析。PL/0 的表达式分三层expression 由项term和加减运算符组成term 由因子factor和乘除运算符组成factor 是最小的运算单元。递归下降法天然地把运算符优先级嵌进了函数调用层级里——parseExpression调用parseTermparseTerm调用parseFactor优先级越高的运算符对应的函数调用层级越深。// 语法分析器表达式 - 项 - 因子 三级递归下降 // 每个函数开头先判断当前 Token 是否符合 FIRST 集 void parseExpression() { // 处理一元正负号 和 - 都能作为表达式的开头 if (sym PLUS || sym MINUS) { getNextToken(); // 读取下一个 Token parseTerm(); } else { parseTerm(); // 没有正负号直接解析项 } // 后续跟 或 - 的项组成左结合的加减表达式 while (sym PLUS || sym MINUS) { // 记录运算符 int op sym; getNextToken(); parseTerm(); // 这里可以生成语法树节点或者只做合法性验证 } } void parseTerm() { parseFactor(); // 项的第一个因子 while (sym TIMES || sym SLASH) { getNextToken(); // 跳过 * 或 / parseFactor(); } } void parseFactor() { // 因子可能是标识符、数字或括号表达式 if (sym IDENT || sym NUMBER) { getNextToken(); // 终结符直接吞掉 } else if (sym LPAREN) { getNextToken(); parseExpression(); // 括号内是一个完整表达式 // 这里必须匹配右括号 if (sym RPAREN) { getNextToken(); } else { // 报错缺少右括号 error(missing right parenthesis); } } else { // 当前 Token 既不是标识符也不是数字更不是左括号 error(invalid factor); } }这段代码里有几个细节值得你在实验报告里写清楚。第一parseExpression里的一元正负号处理PL/0 的表达式允许以负号开头但要保证负号和后面的项绑定成一个整体不能把负号拆成二元运算符。第二左结合性while (sym PLUS || sym MINUS)循环天然实现了左结合因为左边的子树先被构建出来。第三parseFactor里看到标识符就存起来——如果后续出现:那这个标识符是赋值目标如果不出现它就是一个变量引用。语法分析器.cpp 里通常会把这种「语义信息」先记录下来但实验要求一般只做语法检查不真的生成 AST。3.3 错误恢复语法分析器遇到非法输入怎么处理语法分析器.cpp 相比词法分析器最值得学习的是它的错误处理策略。递归下降分析器有一个很现实的问题一旦报错Token 流就错位了。如果不做错误恢复分析器会把后面几百个 Token 全判成错误输出一堆没用的报错信息。我翻代码时看到它至少有三种常见策略跳过当前 Token 继续分析、跳到下一个同步 Token通常是分号或 END再继续、或者直接终止程序。// 错误恢复示例跳过到同步 Token // 同步 Token 一般选分号、END、句号这些位置能重新对齐语法 void synchronize() { // 不断读取 Token直到找到分号、END 或句号 while (sym ! SEMICOLON sym ! END sym ! PERIOD) { getNextToken(); } // 读过一个分号跳到下一条语句开头 if (sym SEMICOLON) { getNextToken(); } }对课程实验来说错误处理不是评分重点但绝对是改分项——很多同学的语法分析器遇到第一个非法 Token 就死掉后续所有测试用例都跑不了只能拿一半分。正确做法是在每个 parse 函数里加错误处理分支报错后试着恢复。不过这里有个分寸跳得太狠会把真正合法的 Token 也跳掉跳得太轻又会产生连环错误。我的习惯是只在语句级做同步表达式内部报错时不乱跳宁可少报错误也不能误报。这也是语法分析器.cpp 里最值得你标注注释的地方。这份 zip 里的 code.txt 末尾有个句号它就是整个程序的结束符。前面说过program block .这个产生式决定了递归下降的入口函数应该长这样parseProgram调用parseBlock然后检查当前 Token 是否为句号PERIOD是则接受程序否则报错「程序缺少结束句号」。从语法分析器.cpp 的代码结构来看它的入口函数八成也是这么写的。如果它输出一行「Parse OK」或者类似信息说明 code.txt 通过了语法验证如果它输出错误位置和错误类型那就是走进了错误恢复分支。4. 避坑指南跑通编译原理实验的五个经典翻车点4.1 pl0.exe 双击就闪退看不到任何输出现象在资源管理器里双击 pl0.exe黑框一闪而过什么结果都看不到。原因pl0.cpp 是按控制台程序写的输入从文件中读取输出直接打到标准输出。Windows 下窗口程序运行结束后会自动关闭控制台如果代码里没有system(pause)或等效等待语句输出结果你根本来不及看。另一个常见原因是 exe 是 32 位编译的在缺少对应运行库的机器上启动时静默失败。解决不要双击改成在项目目录下开一个 PowerShell 或 CMD 窗口手动执行.\pl0.exe code.txt这样窗口不会自动关闭。如果 exe 还是没反应检查同目录下是否有 code.txt、文件名是不是被系统改成了code.txt.txt——Windows 默认隐藏扩展名你看到的名字可能是「code.txt」实际全名是「code.txt.txt」路径对不上就找不到输入文件。4.2 词法分析输出的 Token 类型和保留字表对不上现象分析BEGIN、END这类单词时输出的 sym 类型是 IDENT 而不是保留字类型。但 code.txt 里的词确实拼写正确。原因保留字表查表的时机错了。很多同学先判断「是不是字母开头」读完整个单词后直接当成标识符返回等到后面才想起查保留字表——但是 Token 类型已经返回给主程序了时机错过就截不回来。更隐蔽的坑是保留字表和标识符比较时用了完全匹配但 code.txt 里单词后面混了全角空格或者\t导致字符串比对失败。解决把查表逻辑放在单词读取循环结束后、返回 Token 之前也就是我前面 2.2 节代码里的写法——lookupKeyword必须在return之前调用。另外写完保留字表后拿一个只有两个保留字的测试文件先跑通比如BEGIN END.确认返回类型正确了再上完整用例。4.3 语法分析遇到「BEGIN ... END」嵌套时无限递归现象语法分析器跑到嵌套两层的BEGIN ... BEGIN ... END ... END时堆栈溢出程序直接崩溃或者报错「表达式非法」但代码明明没问题。原因这是递归下降法最常见的翻车点——你忘了在statement分支里区分「什么情况算一条语句」。一个合法的语句可以是a : 1这种赋值语句也可以是BEGIN ... END这种复合语句还可以是空的PL/0 的 statement 允许为空。如果你的解析流程是「无条件检查标识符开头再检查等号」遇到BEGIN开头时直接走到条件分支之外就会丢失该语句的控制流。还有一种情况是IF条件后缺少THEN引导的语句体分析器没能正确复位状态。解决在parseStatement函数开始处检查当前 Token 的第一集合FIRST set。BEGIN走复合语句分支IF走条件分支WHILE走循环分支标识符走赋值分支其余情况按空语句处理但不要报错。我见过语法分析器.cpp 里的做法是直接用一个switch (sym)来分流这比长串if-else直观得多后续添加新语句类型也方便。4.4 判断「」和「:」时总是二义性报错现象词法分析阶段一切正常语法分析阶段一遇到a : 10就报「赋值号非法」但把:写成等式又能过。原因:是 PL/0 的赋值运算符是等号运算符它们属于完全不同的 Token 类型——ASSIGN 和 EQ。语法分析器的赋值语句解析分支里应该检查当前 Token 是否为 ASSIGN条件表达式分支里应该检查是否为 EQ。如果代码里把符号类型搞混了或者词法分析阶段漏处理了:的双字符匹配语法层就会出现这种诡异现象。另一个可能你用了 C 的来判断 Token 值但 ASSIGN 和 EQ 的枚举值恰好是相邻的整数比较没问题问题出在词法返回时张冠李戴。解决直接检查词法分析器的输出——把每个 Token 的类型枚举值和字符串内容同时打印出来亲眼确认a : 10中的:被标成了 ASSIGN 而不是 EQ 或者两个独立的运算符。如果词法层就拆成了两个 Token回到 2.2 节的case :分支检查你有没有在识别到后及时吞掉第二个字符。这一步排查不超过五分钟但每次都能挽回半小时的乱猜。4.5 语法分析器.cpp 不生成 AST实验报告不知道怎么分析现象实验要求写「语法分析器」但自己看代码发现它只输出「正确/错误」没有生成抽象语法树感觉少了一截。原因课程实验的「语法分析」有两种层次。一种是验证性分析——只判断输入串是否符合文法常见于教学实验另一种是构造性分析——生成语法树或中间代码常见于综合实验。这份 zip 是南航计算机/物联网专业的课程实验从文件构成看属于验证性分析的范畴所以不生成 AST 并不算缺陷。但很多同学误以为「编译原理实验」必须要有 AST 才完整自己强行加语义动作反而把程序改坏。解决先明确实验指导书的验收标准。如果只要求能判断「语法正确/错误」并输出分析过程现在的代码已经达标。如果要扩展可以在parseFactor返回时构造一个节点parseTerm和parseExpression把节点串成树但这属于锦上添花——不建议在截止日期前动这个工程。我的血泪经验是实验课老师最看重的是你能讲清楚递归下降的调用过程而不是树的漂亮程度。你拿着parseExpression - parseTerm - parseFactor的函数调用链讲一遍再演示错误恢复的效果分数就不会低。5. 进阶用法把实验输出转成 JSON 喂给调试工具词法分析器和语法分析器跑通之后实验已经能交差了。但如果你想让这份代码的价值再放大一点——比如后面要写选修课的大作业、或者想做个简单的代码格式化工具——有一个改动成本极低但收益明显的技巧把 Token 流的输出格式改成 JSON。这样你不光能肉眼检查还能用 Python、Node 脚本去自动化分析 Token 序列给后续实验续上基础设施。// 在 getsym() 每次成功识别一个 Token 后追加一行 JSON 输出 void printTokenJSON(const std::string type, const std::string value) { // 转义特殊字符避免 value 里的引号破坏 JSON 结构 std::string escaped value; // 这里可以加一个简单的替换逻辑把 \ 转成 \\\ std::cout {\type\:\ type \,\value\:\ escaped \}, std::endl; }我一般会在词法分析器的主循环里插一句printTokenJSON(symName, tokenStr)这样跑完 code.txt 后得到的是一个能被 Python 直接读取的 JSON 流。比如用 Python 写十行脚本就能统计 Token 类型分布、检查保留字出现频率甚至把分析结果可视化。这个技巧在后续的课程设计里很实用——很多人的 C 代码写到一半不想动但数据分析用 Python 快得多JSON 就是中间的粘合剂。最后说一个我自己的习惯每次拿到这类实验代码我第一件事不是运行而是先打开 code.txt 看输入格式再打开源码看主循环——因为编译原理实验的坑十有八九出在「输入文件格式」和「Token 状态流转」这两处而不是算法本身。从那以后我每次做编译类实验都会强制走一遍这个流程先验证词法输出再验证语法分析最后才动代码改造。希望这份拆解能帮你把 pl0.cpp 和语法分析器.cpp 的每一行都看得明明白白实验路上少一点玄学多一点确定的输出。希望帮到你。本文还有配套的精品资源点击获取
返回列表