news 2026/10/3 13:13:03

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
编译原理实验拆解:词法分析器与递归下降语法分析器实现

简介:面向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 如何把字符流切成 Token

2.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 BF),C++ 的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 的表达式部分文法可以浓缩成下面这套 EBNF:

program = 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调用parseTerm,parseTerm调用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 的每一行都看得明明白白,实验路上少一点玄学,多一点确定的输出。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 13:13:00

el-table实现Excel式方向键移动光标:单元格高亮与滚动跟随实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:12:54

ThinkPHP+Layui实战ERP:从架构设计到成本核算全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:12:54

OCP 19c 082备考:吃透存储与事务物理行为,告别盲目背题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:12:21

PyCharm 2020.1 官方中文插件手动安装指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:12:00

Cadence安装全指南:Linux系统级适配与故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:08:19

通达信主升浪潜伏主图指标原理与实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华