资讯动态

编译原理课设实践:PL/0词法分析与递归下降语法分析

发布时间:2026/10/18 9:51:58 来源:尧图企业网站定制
简介面向东北大学秦皇岛分校编译原理实验二的完整实验报告围绕PL/0语言词法分析程序的设计与实现展开涵盖字符分类函数isLetter、isDigit等、关键字表匹配、错误检测及单词二元组输出流程。包体为1个doc文档大小122KB内含实验目的、运行环境、完整C源代码与结果展示代码结构清晰、可直接复用或修改。报告对非法单词如2A的识别处理、源程序文件读取与逐字符扫描等关键环节均有具体交代能帮助学生对照实验要求完成词法分析器也可作为其他院校编译原理课程设计的参考范例。已有98人学习使用适合正在做编译原理实验或备战课程设计的学生下载。1. 从一套能跑的代码到一份能答辩的实验报告词法分析与递归下降怎么一起落地编译原理课设里最常见的翻车现场不是程序跑不起来而是程序跑通了、报告也交了导师一问“词法分析这一步到底输出了什么”就卡壳。这份编译原理实验报告解决的是两个连续的问题先说清楚 PL/0 语言的词法分析程序怎么设计——把源程序字符串拆成单词、类别二元组序列并具备发现 2A 这类非法单词的能力再讲给定文法 G[E] 的递归子程序分析器怎么实现——判断任意输入符号串是否属于该文法。两个实验都给出了完整的 C 源代码、测试用的 PL/0 源程序文件以及运行结果适合正在做编译原理课设、或者毕设里需要实现编译器前端的人直接参考。代码是教学环境里的经典写法我按工程习惯重新整理过一遍每一段都能在报告里解释清楚。2. 总体架构设计词法分析、语法分析与类别编码的三层配合一份完整的编译原理课程设计通常会把前端的两个核心阶段拆成独立实验词法分析和语法分析。实验报告里的第一部分就是把源程序字符流切成单词流第二部分则是把单词流按文法规则组织成语法结构。设计上遵循一个基本原则词法分析只做字符级别的识别不管文法语法分析只做单词序列的匹配不再回头读文件。只要中间数据的格式定好两个实验拼起来就是一条完整的编译前端链路。2.1 第一个实验把源程序字符串变成单词、类别二元组序列词法分析器接收的输入是待分析的源程序字符串输出的是由“单词、类别”组成的二元组序列。所谓类别就是给每个单词打上的类型标签关键字、标识符、无符号数、运算符、界符等。这个设计把字符层面的琐碎判断全部收敛在词法阶段后续语法分析只需要查类别号不需要关心这个单词长什么样。实验报告里定义的类别编号非常明确0 到 20 是保留字21 是标识符22 是无符号数23 到 47 是界符和运算符。例如CONST A10;被分析后应当输出类似这样的二元组序列(CONST,0)、(A,21)、(,38)、(10,22)、(;,24)。这里的数字就是类别编号语法分析器拿到这串编号就可以直接判断文法结构是否合法。报告里的程序具备基本错误检测能力典型场景就是2A。严格来说2A并不是一个合法单词因为它既不是无符号数也不是标识符。词法分析器需要在识别数字后立刻检查下一个字符是否为字母如果是就报告非法单词。2.2 第二个实验递归子程序如何验证一个句子属于文法语法分析部分面对的文法如下G[E] E → eBaA A → a | bAcB B → dEd | aC C → e | dC这个文法属于 LL(1) 文法每个非终结符的产生式右部都以不同的终结符开头可以直接用递归下降的方法分析。递归子程序分析器的核心思想是为每个非终结符编写一个子程序子程序按照产生式的右部顺序去匹配输入串中的单词遇到非终结符就调用对应的子程序遇到终结符就检查当前输入是否匹配。例如产生式E → eBaA对应函数E()先匹配终结符e再调用B()然后匹配a最后调用A()。如果整个输入串扫描完毕且最后停在结束符#上就说明该句子属于文法否则分析失败。2.3 类别编码表关键字、标识符、无符号数与界符的统一编号实验代码里定义了一张全局的关键字表这张表就是词法分析器用来匹配单词类型的最重要依据。下面是我整理后的表格保留了报告原始定义的全部内容只调整了排版编号范围含义具体内容0 - 20保留字andbeginconstdivdoelseendfunctionifintegernotorprocedureprogramreadrealthentypevarwhilewrite21标识符用户自定义的变量名、过程名等22无符号数整数或带小数的数字23 - 31界符,;:.()[]..32 - 38运算符与界符---*/39 - 44关系运算符与赋值号:45 - 47附加界符与结束符{}#其中#是输入串的结束标记也叫哨兵字符不在正常输出中出现。这张表的设计有几个值得写进报告的点一是编号连续方便直接作为数组下标使用二是把:、、这类双字符运算符单独列出而不是拆成两个单字符体现了词法分析中的最长匹配原则三是 32、33 号的和--不是 PL/0 原始语法的内容是实验扩展项遇到它们同样要做匹配并输出对应类别。注意整张表在程序里用char ktt[48][20]存储前 21 项是保留字后面是其他类别。写进实验报告时这张表就是词法分析器“认识”单词的唯一依据也是答辩时导师大概率会问到的数据。3. 词法分析器实现字符分类、最长匹配与非法单词的识别词法分析器的程序结构可以分成两层底层是五个字符分类函数负责判断单个字符的类型上层是主分析流程负责读取字符、拼装单词并在关键字表中查找类别。下面按这两层分别说明实现细节并给出整理后的代码。3.1 五个字符分类函数字母、数字、运算符、界符和空白的判定字符分类函数是词法分析的地基。实验代码里一共定义了五个判断函数分别对应字母、数字、加减乘除运算符、界符、空白字符。整理后的实现如下bool isLetter(char ch) { return (ch A ch Z) || (ch a ch z); } bool isDigit(char ch) { return ch 0 ch 9; } bool isP(char ch) { // 运算符只包括加减乘除 return ch || ch * || ch - || ch /; } bool isJ(char ch) { // 界符逗号、分号、句点、括号、方括号、赋值号、关系符等 return ch , || ch ; || ch . || ch ( || ch ) || ch [ || ch ] || ch || ch : || ch || ch || ch { || ch } || ch #; } bool isBlank(char ch) { return ch || ch \t; }这里有个容易被忽视的设计点isP只识别 - * /四种运算符号而、、、:这类符号全部归入isJ界符分支。也就是说程序对运算符和界符的划分并不是严格按语法语义分的而是按“匹配复杂度”分的单字符运算符走isP其他符号走isJ并在循环里做最长匹配。这样安排的好处是分类函数简单缺点是两者的边界在报告里必须写清楚否则后续自己都会分不清。分类函数本身是纯字符判断不涉及状态测试时可以直接写死几个字符跑一遍确认每个函数的返回值符合预期。3.2 主分析流程读文件、逐字符扫描和单词归类主程序的分析流程是从pl0.txt中读取全部源程序字符在末尾补一个#作为结束哨兵然后从左到右扫描。每遇到一类字符就进入对应分支连续读取直到这个单词结束再到关键字表里查类别编号。整理后的核心流程如下// ktt关键字表0-20保留字21标识符22无符号数23-47界符/运算符 const char* ktt[48] { and, begin, const, div, do, else, end, function, if, integer, not, or, procedure, program, read, real, then, type, var, while, write, 标识符, 无符号数, ,, ;, :, ., (, ), [, ], .., , --, , -, *, /, , , , , , , :, {, }, # }; vectorpairstring, int analyze(const string src) { vectorpairstring, int tokens; int pos 0; char ch src[pos]; while (ch ! #) { if (isBlank(ch) || ch \n || ch \t) { // 空白和换行直接跳过换行同时作为一行的结束标志 ch src[pos]; continue; } if (isDigit(ch)) { // 无符号数连续数字和小数点 string word; while (isDigit(ch) || ch .) { word ch; ch src[pos]; } tokens.push_back({word, 22}); continue; } if (isLetter(ch)) { // 标识符字母开头后续允许字母或数字 string word; while (isLetter(ch) || isDigit(ch)) { word ch; ch src[pos]; } // 查关键字表命中则用表的编号否则是用户标识符21 int type 21; for (int j 0; j 21; j) { if (word ktt[j]) { type j; break; } } tokens.push_back({word, type}); continue; } if (isP(ch)) { // 单字符运算符分支 string word(1, ch); ch src[pos]; tokens.push_back({word, 33 kttIndex(word, 34)}); // 实际编号从34开始 continue; } if (isJ(ch)) { // 界符分支这里做最长匹配把:、等双字符合并 string word; while (isJ(ch)) { word ch; ch src[pos]; } tokens.push_back({word, findType(word)}); continue; } ch src[pos]; } return tokens; }这段代码的关键点有两个。一是标识符的读取循环用的是“字母开头、后面可跟字母或数字”的规则这是 PL/0 标识符的标准定义二是界符分支采用了while (isJ(ch))的连续读取策略因此:会被完整识别为类别 44而不是先输出:再输出。这就是词法分析里的最长匹配原则实验报告里必须单独说明。输出格式上原实验代码使用全局字符串拼接类别号每遇到换行符就输出当前行的单词序列。整理后的代码返回值是vectorpairstring,int二元组的定义和实验要求完全一致。3.3 数字后紧跟字母的情况2A 是怎么被发现的实验内容里明确要求程序有一定错误检查能力例如发现2A这类不能作为单词的字符串。由于2A不符合 PL/0 中无符号数的定义也不符合标识符“字母开头”的定义分析器必须给出明确提示而不能静默地把2和A当成两个合法单词吞掉。在这个实验的实现里通常的做法是在数字分支的循环结束后检查下一个字符如果读到的是字母说明数字后面紧跟字母整体不能构成合法单词。类似地标识符分支也要检查是否以数字开头因为2A的前缀2会被数字分支先截获。if (isDigit(ch)) { string word; while (isDigit(ch) || ch .) { word ch; ch src[pos]; } if (isLetter(ch)) { // 数字后紧跟字母属于非法单词报错而不是继续吞字符 cout Error: invalid token word ch at position pos endl; continue; } tokens.push_back({word, 22}); continue; }我在整理代码时特意保留了这段报错逻辑的雏形。原实验代码里这一块只做了简单的cout str没有在错误发生时终止分析或记录错误位置教学实验可以接受但如果你想把报告写得更有竞争力建议输出错误位置和具体的非法单词。这样导师问“错误检测怎么做”时你可以直接给出一个可演示的例子。提示2A这类错误的本质是词法分析的状态机里缺少一个“数字后不允许直接跟字母”的转移分支。在报告里画一个简单的状态转移图比堆代码更能说明你理解了词法分析的原理。4. 递归子程序分析器实现G[E]文法的函数化与匹配判定词法分析把字符流变成二元组序列之后语法分析要判断这些单词是否符合文法。递归子程序分析器是语法分析里最容易手工实现的一种它把文法中的每个非终结符直接写成一个函数函数体内按产生式的右侧顺序去匹配输入串。4.1 从文法到递归函数的映射每个非终结符对应一个子程序给定文法G[E] E → eBaA A → a | bAcB B → dEd | aC C → e | dC四个非终结符正好对应四个函数E()、A()、B()、C()。每个函数的实现逻辑都可以直接从产生式翻译出来非终结符产生式函数行为EE → eBaA匹配e调用B()匹配a调用A()AA → a或A → bAcB若当前字符为a则直接吃掉若为b则调用A()匹配c调用B()BB → dEd或B → aC若当前字符为d则调用E()并匹配结尾d若为a则调用C()CC → e或C → dC若当前字符为d则递归调用C()若为e则直接吃掉这个映射关系是递归下降的核心。注意A → bAcB里的A处于中间位置所以代码里是先递归调用A()再匹配c最后调用B()顺序不能颠倒。4.2 带回溯判定的 E、A、B、C 函数实现实验报告的原始代码里每个函数都是void类型匹配失败不会返回失败信号主函数只能通过扫描结束后是否停在#来判断整句是否合法。我整理后的实现让每个函数返回bool一旦某一层匹配失败就立刻返回false上层可以马上停止分析定位更准#include iostream #include string using namespace std; string input; // 待分析的输入串 int pos 0; // 当前扫描位置 bool E(); bool A(); bool B(); bool C(); // E → eBaA bool E() { if (input[pos] e) { pos; if (B()) { if (input[pos] a) { pos; return A(); } } } return false; } // A → a | bAcB bool A() { if (input[pos] b) { pos; if (A()) { if (input[pos] c) { pos; return B(); } } return false; } if (input[pos] a) { pos; return true; } return false; } // B → dEd | aC bool B() { if (input[pos] d) { pos; if (E()) { if (input[pos] d) { pos; return true; } } return false; } if (input[pos] a) { pos; return C(); } return false; } // C → e | dC bool C() { if (input[pos] d) { pos; return C(); } if (input[pos] e) { pos; return true; } return false; }这里最需要注意的是A → bAcB的递归写法A()内部先递归处理中间的A而不是先匹配c。原因在于文法描述的是“以b开头中间是一个完整的A之后是cB”所以递归调用必须发生在c之前。很多同学第一次写会把顺序搞反直接导致输入的合法句子被判为非法。C → dC是典型的右递归代码里用return C()实现自调用。这种写法对应的是文法的右递归结构和E、B产生式里的中间非终结符调用不太一样建议在报告里单独标注一下而不是笼统地说“递归调用”。4.3 测试输入的设计合法句子与非法句子的区分主程序不断从键盘读入待分析串然后调用E()最后检查扫描位置是否停在末尾的#上。这里必须保证测试串以#结尾比如合法的ebacdda#非法的ebca#等。什么串是合法句子根据E → eBaA它必须以e开头并最终以A推导出的终结符作结。A → a能直接导出a所以任何A的推导都以a收尾整句的末字符一定是a。同理C → e | dC只可能以e结尾B → dEd | aC的末字符是d或e。按这个规律可以手工构造几个正例和反例合法串ea#。E → eBaA其中B → aC → aeA → a展开就是e a e a尾部恰好是#。合法串ebacdda#。A → bAcB → b a c d e d d a。非法串eda#。以e开头匹配B()时首字符必须是d或a而实际是dB → dEd要求中间的E()后面必须再有一个d输入串不够长分析失败。实验报告里把正例和反例各列两组再配上程序的输出结果这一章就完整了。提示测试输入串务必带#结尾。递归下降函数只负责匹配到最后一个合法字符#的检查是主程序判断整句是否分析完成的唯一依据。5. 常见问题排查词法分析器最容易翻车的四个细节这两个实验代码量不大但翻车的点非常密集而且多是那种“看起来对、跑起来错”的隐蔽问题。我按现象、原因、解决三个层次列出最容易踩坑的四类情况。5.1 长标识符把 20 字节缓冲区写穿现象源程序里出现一个超过 20 个字符的标识符程序直接崩溃或者后续单词全部识别错误。原因原始代码用char str[20]保存当前单词while (isLetter(ch) || isDigit(ch))循环没有限制写入长度。标识符超过 19 个字符时str[i] ch就会越界写坏栈上的其他变量属于典型的内存踩踏。解决改用std::string存储单词或者写入前检查i 19。你在报告里不需要大改逻辑只要把char str[20]换成string word循环里直接word ch这类问题就彻底消失了。顺带说明PL/0 的标识符长度理论上有上限但词法分析器不应该用固定数组去硬扛。5.2 关键字与标识符的匹配顺序颠倒现象输入begin被输出成“标识符”而不是关键字begin的类别 1。原因原代码的标识符分支先拼完整个单词然后遍历0 - 20的保留字区间查表。这个顺序本身是对的但如果查表范围写错比如for (j 0; j 21; j)写成了j 20begin就会被漏掉落到最后的j 21分支被判成标识符。解决查表范围必须是0 j 21完整覆盖前 21 项保留字。更稳妥的做法是把保留字匹配放在标识符分支里用字符串比较而不是strcmp避免 C 字符串的\0处理带来边界问题。我整理后的代码里用的就是word ktt[j]这也是报告里可以强调的一个改进点。5.3 文件结尾丢字符EOF 之后必须补 # 哨兵现象源程序最后一行末尾的 token 没有被识别结果缺少一个单词。原因while (ch ! #)的主循环依赖#作为终止条件但pl0.txt里根本没有#读取完最后一个字符后ch变成EOF循环条件判断失败最后一个单词的收尾处理被跳过。解决在读完全部文件内容后手动追加#字符确保主循环有一个明确的结束标志。原代码里已经有src #这一步关键是不能漏掉漏了就会出现“最后一个 token 神秘消失”的现象且很难排查。调试时可以打印src的内容确认结尾确实有#。5.4 递归下降失败时定位困难只靠“分析失败”不够现象输入非法句子后程序输出“分析失败”但到底错在哪个非终结符、哪个位置完全看不出来想改都没法改。原因原始递归子程序的四个函数都是void类型一旦匹配不中就静默返回主程序只知道最终结果不对不知道是哪一层出的问题。解决把函数返回值改成bool失败时逐层返回false同时打印当前扫描到的位置和期望的字符。我给出一段示范bool E() { if (input[pos] e) { pos; if (B()) { if (input[pos] a) { pos; return A(); } } } cout E() failed at pos pos : expected e or invalid branch endl; return false; }改造之后测试非法输入能直接看到是E()里第几步出的问题比对着空白的“分析失败”猜测快得多。这也是一种答辩时的工程化加分项。6. 验收与答辩准备从测试用例到导师提问的应对清单实验报告的验收不能只停留在“代码能跑”。我一般会按三层去走完整个验证流程每一步都有对应的检查依据。第一层是词法分析的功能验收。用报告里的 PL/0 测试源程序作为输入对每一行应产生的二元组序列做逐一核对。以下是标准的预期输出源程序片段单词类别编号CONSTconst 关键字0A标识符21等号3810无符号数22;分号24VARvar 关键字18B标识符21第二层是错误检测验收。准备三组特殊输入2A、超过 20 字符的标识符、文件末尾无换行的最后一行。2A必须报非法单词长标识符不能崩溃最后一行不能丢 token。这三类用例覆盖了语法功能之外的健壮性导师验收时也最容易拿它们来试。第三层是语法分析的功能验收。构造两组合法句子和两组非法句子每组都记录输入串、预期结果、实际输出。参考组合可以是ebacdda#合法、ea#合法、ebca#非法、edaa#非法。把结果表格放进实验报告比贴十张运行截图更有说服力。答辩时最常被问到的问题有三个。第一个是“为什么用递归下降而不是算符优先”回答要点是本实验的文法规模小、产生式之间无左公因子递归下降直观且易于手工实现还能自然携带错误定位信息。第二个是“怎么处理左递归”回答要点是文法设计阶段已经规避了左递归C → dC用的是右递归递归下降只适合这种结构。第三个是“词法分析里最长匹配怎么实现”回答要点是指出界符分支的while (isJ(ch))循环以及:、这类双字符运算符的匹配流程。从我自己的教训来说以前赶课设时只跑了报告自带的那一个测试样例就交差结果验收现场被导师替换成一批边界输入当场翻车。从那以后我每次写完词法分析器都强制走一遍“边界字符 非法输入 文件结尾”的用例清单确认全部通过再进入下一个模块。这份清单花不了二十分钟但能帮你把“程序能跑”变成“程序经得起问”希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑