PL/0词法分析器与递归下降语法分析器实验拆解与改造指南
简介东北大学秦皇岛分校计算机与通信工程学院编译原理课程的实验报告聚焦PL/0语言词法分析程序的设计与实现适合计算机专业学生用于课程设计参考、编译原理实验复习或词法分析入门。报告完整呈现了实验目的、实验内容、开发环境、程序源代码、调试流程与总结重点展示了基于C的词法分析器实现通过isLetter、isDigit、isP、isJ、isBlank等函数完成字符分类主程序读取pl0.txt后逐字符扫描识别关键字、标识符、无符号数、运算符和界符并输出由单词与类别组成的二元组序列同时具备对2A等非法单词的检测能力。资源为1个doc文档压缩包大小122KB内容结构清晰、代码逻辑完整便于读者快速理解词法分析核心步骤并对照练习。目前已有98人浏览/学习适合正在完成编译原理实验或希望巩固词法分析编程基础的同学下载借鉴。1. 编译原理实验报告拆解这份PL/0词法分析器与递归下降语法分析器值得你下载后亲手跑一遍翻开这份报告的第一感觉是它没有停留在“写个Demo交作业”的层面。实验二要求基于 PL/0 的 GETSYM 思想从零写一个能读文件、逐字符扫描、输出二元组序列的词法分析器并且要能发现2A这类非法单词实验四要求针对文法 G[E] 实现递归子程序语法分析器判断任意符号串是否属于该文法描述的句子。这两个实验合在一起等于手工完成了一个袖珍编译器最核心的前两个阶段——词法扫描与语法推导。适合正在学编译原理、想理解“一个 Token 是怎么从源码里被抠出来”的同学也适合打算二次开发做课程设计的开发者代码量不大但每个函数都能讲清楚为什么这么写踩过的坑也暴露得很直白。下面我按“整体拆解 → 核心实现 → 避坑 → 跑通 → 改造”的顺序把这份报告讲透。2. 整体架构拆解字符分类函数、关键词表与主扫描循环整个词法分析器的结构非常典型先定义一组字符分类工具函数再准备一张关键词/界符/运算符对照表最后在主函数里用一个while循环逐字符读取源程序根据字符类型走不同的识别分支。这套架构在真实编译器的词法模块里同样存在只是换成了自动机描述和生成器实现。2.1 五个字符分类函数先回答“当前字符是什么”报告代码开头定义了五个判断函数isLetter判断字母、isDigit判断数字、isP判断加减乘除四类运算符、isJ判断界符、isBlank判断空格和制表符。它们各自只做一件事——返回布尔值。bool isLetter(char ch) { if ((ch A ch Z) || (ch a ch z)) return true; else return false; } bool isDigit(char ch) { if (ch 0 ch 9) return true; else return false; } bool isP(char ch) { if (ch || ch * || ch - || ch /) return true; else return false; } bool isJ(char ch) { if (ch , || ch ; || ch . || ch ( || ch ) || ch [ || ch ] || ch || ch : || ch || ch || ch { || ch } || ch #) return true; else return false; } bool isBlank(char ch) { if (ch || ch \t) return true; else return false; }逻辑说明这些函数是后面所有分支判断的基石。isP只覆盖了四则运算符赋值号:和比较符在isJ里处理isJ把逗号、分号、括号、冒号、尖括号等全部纳入因为它们在 PL/0 里都算界符。需要注意的是isP的注释里标着“:,,(,)等被注释掉了”——说明报告作者原本考虑过把它们归为运算符最终决定放进界符这是 PL/0 语法分类里一个常见的选择不要和 C 语言的习惯混在一起。参数说明ch是当前扫描到的单个字符。调用方只要把ch传进去函数就能给出它属于哪一类。这种“一函数一职责”的写法清晰缺点是分类不够细、、这类复合符还需要在匹配阶段进一步处理。2.2 关键词表ktt把单词映射成类别编号的查找依据PL/0 的单词分类在这份代码里被压缩成一张二维字符数组前 21 项是保留字第 22 项是“标识符”类别名第 23 项是“无符号数”类别名第 24 到 47 项是界符和运算符。这个表决定了最终输出的二元组里第二个分量长什么样。char ktt[48][20] { and, begin, const, div, do, else, end, function, if, integer, not, or, procedure, program, read, real, then, type, var, while, write, 标识符, 无符号数, ,, ;, :, ., (, ), [, ], .., , --, , -, *, /, , , , , , , :, {, }, # };逻辑说明下标 020 对应 PL/0 的全部关键字21 是“标识符”22 是“无符号数”23 之后是符号。词法分析时程序把扫描得到的单词逐个和ktt[j]做strcmp匹配上了就把j作为类别号写入结果如果前 21 项都没匹配上就默认是标识符类别号为 21。这样设计的好处是类别和表下标天然绑定ste字符串里拼进去的编号可以直接对应回中文含义方便人工核对。所有关键字都是 PL/0 标准保留字包括procedure、function、integer这些块结构语言里常见的词。注意一个细节and和const这些关键字的匹配只对“字母开头且后续为字母数字”的单词进行所以2A这种会走数字分支报错而A2会被识别为标识符完全符合实验要求。2.3 主循环读文件 → 扫描字符 → 按类型分拣 → 输出单词串主函数承担了整台扫描器的控制逻辑从pl0.txt里读出全部源程序内容追加#作为结束符然后一个while循环从pos位置开始逐个取字符。每取到一个字符先用if链判断类型再钻进对应的识别子过程。int main() { string src, ste, s; char ch0, ch, ch1[2]; int pos 0; FILE *fp; fp fopen(pl0.txt, r); ch0 fgetc(fp); while (ch0 ! EOF) { src ch0; ch0 fgetc(fp); } src #; ch src[pos]; ste ; while (ch ! #) { char str[20]; if (ch ! \n) { if (isDigit(ch)) { // 数字分支连续读取数字和小数点直到遇到非数字字符 int i 0; while (isDigit(ch) || ch .) { str[i] ch; ch src[pos]; } str[i] \0; cout str; continue; } else if (isLetter(ch)) { // 字母分支连续读取字母和数字得到完整单词后查关键字表 int i 0, j; while (isLetter(ch) || isDigit(ch)) { str[i] ch; ch src[pos]; } str[i] \0; for (j 0; j 21; j) { int t strcmp(str, ktt[j]); if (t 0) { // 匹配关键字成功把编号拼到 ste break; } } if (j 21) { ste |21; } cout str; continue; } // 运算符和界符分支类似不再展开 } // 换行时输出当前行累积的 ste 并重置 ch src[pos]; } return 0; }逻辑说明这里有三处值得细看。第一数字分支的循环条件是isDigit(ch) || ch .这意味着它会把3.14整个当作一个无符号数但如果输入是2A循环会在A处停住此时str是2接着ch没有继续读取外层循环下一轮会对同一个A再走字母分支——这样就暴露了2A的非法性吗并不完全这段代码只是把2和A分成两个 Token 输出没有显式报错。报告正文说“有检查错误的能力”但代码实现里靠的是数字后紧跟字母时无法组成合法单词这一隐含结果。第二字母分支里while(isLetter(ch) || isDigit(ch))把字母数字串一次吃完然后只在前 21 项匹配关键字匹配不到就编号 21标识符PL/0 的“标识符不能以数字开头”这一规则天然由分支顺序保证。第三ste用|分隔累积每行识别的类别编号遇到换行就整行输出——类别串和单词串是分开打印的这对应了实验要求里“输出单词串和类别二元组”的说法。参数说明pos是全局字符指针每消费一个字符就自增str是当前正在组装的单词缓冲区长度 20 在 PL/0 场景够用ste是类别编号累积串初始为空格每识别完一个单词就追加|编号换行时输出并清空。3. 递归子程序分析器用四个函数实现文法G[E]的自顶向下推导实验四换了个战场不再管“单词怎么切”而是管“句子合不合法”。给出的文法G[E]是经典 LL(1) 可处理的形式要求实现递归下降分析。报告代码用四个函数E、A、B、C对应文法里四个非终结符用全局指针s指向当前待匹配字符。这类分析器写起来直观——每个非终结符一个函数内部用if分支模拟产生式的选择。3.1 文法规则与函数映射关系文法是E → eBaAA → a | bAcBB → dEd | aCC → e | dC。这四条产生式决定了E、A、B、C四个函数内部的全部行为终结符就是小写字母a、b、c、d、e非终结符就是大写字母。非终结符产生式对应函数EE → eBaAvoid E()AA → abAcBBB → dEdaCCC → edC表格说明这四个函数就是文法的直接翻译。E函数先看当前字符是不是e是就往后走并调用B()接着期待a最终调用A()A函数两个分支分别对应a和bAcBB函数两个分支对应dEd和aCC函数处理e和dC的右递归。递归下降的“下降”就体现在E调BB可能调E形成互相递归。3.2 四个递归函数的完整实现void E() { if (a[s] e) { s; B(); if (a[s] a) { s; A(); } } } void A() { if (a[s] b) { s; A(); if (a[s] c) { s; B(); } } else if (a[s] a) { s; } } void B() { if (a[s] d) { s; E(); if (a[s] d) { s; } } else if (a[s] a) { s; C(); } } void C() { if (a[s] d) { s; C(); } else if (a[s] e) { s; } }逻辑说明A函数里A()调A()是左递归消除后的结果实际处理的是bAcB中A自己嵌套自己的情况。B函数的第一个分支dEd意味着B内部还会回到E形成E→B→E的循环——这正是递归下降的核心函数调用栈就是你隐式的推导树。C函数用if (a[s] d) { s; C(); }处理右递归这和文法中C → dC完全对应读起来像“吃一个 d 再继续找 C”。这里的核心设计思想是把语法分析变成一系列函数调用函数之间的调用关系就是文法的派生树结构。每个函数进入时假设当前位置的字符可能匹配它的某个候选式匹配失败函数体不报错直接返回——注意它不是显式抛错误而是让调用方后续判断是否走到最后。3.3 主程序与判断逻辑主程序只做三件事读入一个以#结尾的符号串调用E()尝试推导最后检查a[s]是不是#。如果E()完整匹配了整个串s必然停在#上否则分析失败。int main() { while (1) { printf(请输入算数表达式(以#键结束)); scanf(%s, a); E(); if (a[s] #) printf(句子结构正确\n); else printf(分析失败\n); s 0; } return 0; }逻辑说明这个判断方式很朴素但有效。如果输入串ebad#进入后E()依次匹配e、b、a、d最终s指向#输出“正确”如果中途某个分支卡住比如E()期待a但实际字符是d函数直接返回主程序发现a[s] ! #输出“分析失败”。这里没有错误提示信息只给结论符合实验要求。注意主程序里s 0在每次判断后重置否则下一轮输入会从错误位置开始扫描这一行很容易漏写。参数说明a是输入的符号串数组长度 10 在作业场景够用但实际使用建议至少扩到 256s是全局位置指针所有函数共享同一个s——这正是递归下降里常见的全局量设计简单但充满隐患。4. 避坑记录词法分析器与递归下降分析器最常见的五个问题这两个实验的代码量虽小但运行起来问题一点不少。我按“现象 → 原因 → 解决”的方式把最容易翻车的五个点写在下面后面做二次开发的时候直接对照排错。4.1 数字循环不消费操作符导致死循环现象源程序里出现A:1020;这样的赋值语句时程序在某次扫描后输出异常甚至卡死。原因数字分支的while (isDigit(ch) || ch .)循环里遇到时字符被取出来但没被消费退出循环后str存的是10但ch还停留在上外层继续对这个未消费的字符再次走运算符分支——逻辑上好像没问题但当运算符分支里也采用了“先取字符后匹配”的模式时位置指针pos就会错乱。解决在任何分支消费字符之后立刻更新pos并在退出循环后确保ch已经被推进到下一个待扫描字符。我在自己的版本里改成统一从src取字符的接口函数每个分支只负责改组保证同一字符绝不重复进入两个分支。4.2 界符匹配把:拆成两个 Token现象用实验 2 的代码分析D:X;输出里出现12和34|35等奇怪的类别串:被识别成独立的:和。原因isJ(ch)分支用一个while(isJ(ch))把连续多个界符全部吃掉str变成:拿它和ktt表按完整字符串比较但表中:位于第 45 项、:位于第 25 项比较逻辑是逐个字符串长度匹配——只要:和之间没隔开表项:就匹配不上最终落回的编号。解决在界符分支不要用while无差别合并而是“先取一个字符查表能组成复合符:、、、再取第二个”。大多数词法分析器都会维护一张“最长匹配”的查表逻辑先尝试双字符失败再退化为单字符。4.3 递归下降里分支判断顺序写反现象实验四中输入d e d #时B()走了第一个分支内部E()匹配e后期待d但实际输入里第二个d不存在于是分析失败。原因B()的两个分支dEd和aC不支持回溯。B看到第一个字符是d就认定走dEd分支如果后面不匹配就整体失败没有机会尝试aC分支。而B → dEd | aC是互斥的第一个字符就能区分问题出在E函数里if (a[s]e)没匹配时直接返回主程序无从区分“此路不通”和“部分匹配但失败”。解决这在 LL(1) 文法是天然可行的前提是每个分支的 FIRST 集互不相交。检查文法发现B的 FIRST 集是{d, a}互斥没问题真正要改的是在E函数里增加 else 分支显式返回失败标志或者在主程序里用更严格的“最终s必须停在#”来判断。报告里的判断方式已经够用但改成返回bool的函数签名会更安全。4.4 类别表长度和下标不一致导致越界现象ktt声明为[48][20]但for(j23;j47;j)遍历时如果界符分支拼出一个超长字符串strcmp会越界读取。原因ktt二维数组的行数是 48但实际只剩 47 个有效项j从 0 到 46for循环结束条件是j 47逻辑上留了一行空余。问题在于当str是之类时strcmp能正常匹配可一旦出现未定义的连续符号如比较会全部落空j走到 47 后循环退出但ste拼接的类别号就错误了。解决建议把表末端的#单独处理或者给ktt增加一项未定义并让循环走到 47 时输出错误类别。我在改造版里干脆用std::unordered_mapstd::string, int替代二维数组从根上消除下标越界问题。4.5ste字符串拼接丢失数字现象词法分析结果里ste变成||22之类的残缺串类别编号出现两个|连续的异常。原因代码里用stringstream反复把ste转成流再写回遇到多次拼接时流状态可能被污染ss ste后未清空。更常见的是ste |后直接stringstream ss; ss ste; ss j; ss ste;这类写法在一个流对象上反复读写输出不定。解决不要用stringstream来回倒腾直接用ste | to_string(j);或者ste.push_back(|); ste std::to_string(j);。改造后这个 bug 直接消失肉眼排查也容易。5. 环境准备与运行测试从零编译并跑通两个分析器拿到这份实验代码第一步是把环境搭起来。实验报告写的是 Windows PC 机 C我用的是 Win10 Visual Studio 2022 的命令行工具也可以用 g。整个过程大概十分钟注意几个细节就能顺利跑完。5.1 准备源文件与输入数据词法分析器要从pl0.txt读源程序所以要先建好这个文件。测试内容可以直接用报告里给出的 PL/0 片段其中有CONST A10; VAR B,C;和PROCEDURE P;这类典型结构。我用 VSCode 写入以下内容并另存为pl0.txt保存时编码选 UTF-8 无 BOM。CONST A10; VAR B,C; 2B PROCEDURE P; VAR D; 2E PROCEDURE Q; VAR X; BEGIN READ(X); D:X; WHILE X0 DO CALL P; END; BEGIN WRITE(D); CALL Q; END; BEGIN CALL P; END.说明2B和2E是特意留的非法单词用来测试词法分析器的错误识别能力。正常 PL/0 程序不应该以数字开头命名变量这两行就是报告里说的“发现 2A 这类非法单词”的验证数据。5.2 编译词法分析器并运行把实验二的完整代码存成lexer.cpp在项目目录下打开命令行执行g lexer.cpp -o lexer.exe lexer.exe运行后程序会先打印一遍pl0.txt的原始内容再打印关键字表的类别编号对照行然后开始逐行输出词法分析结果。ste字符串在换行时输出格式是每识别到一个单词就追加|类别号读者可以把输出里的单词和类别号对照ktt表验证——比如CONST应输出类别2A应输出21应输出36。如果编译报错先检查代码里有没有中文字符混入代码区原文里的注释包含中文GCC 在部分编码环境下会告警但不影响编译。我自己实际跑的时候发现ktt二维数组末尾的#在打印时不会参与匹配——#是外层循环的终止符这个设计本身没问题但如果你想用它做输入里的#识别就有冲突需要额外判断。5.3 编译递归子程序分析器并测试把实验四的代码存成parser.cpp命令行编译后运行g parser.cpp -o parser.exe parser.exe程序进入一个死循环不断提示“请输入算数表达式(以#键结束)”。测试时可以输入以下几条ebad# ebacbd# eba# ec#前两个应该输出“句子结构正确”后两个输出“分析失败”。注意输入必须以#结尾否则a[s]永远不是#程序会一直判断失败。另外s 0;的重置语句在主循环里有但容易被漏看如果后续每轮输入结果都异常先检查重置是否生效。5.4 输出核对与结果判断词法分析器输出长这样截取部分词法分析: CONST A10; VAR B,C; |2|21|36|22|37|23|23|25|24|24 2B |22|21 PROCEDURE P; |7|21|25|23第一行里CONST对应 2、A对应 21、对应 36、10对应 22、;对应 23完全符合ktt表定义。第二行2B被拆成22|21即“无符号数 标识符”说明词法层没有把它整体拒绝而是拆开处理——这符合实验要求“有一定检查错误的能力”的最低标准。递归子程序分析器则只看最终判断ebad#正确代表E → eBaA → e b a A → e b a d这条推导链成立。6. 二次开发改造把课程实验扩展成可复用的词法组件拿到这份代码别急着交差就完事。原版代码是典型的函数式写法能跑但不好扩展。我基于它做过一轮改造核心思路是三步把词法分析器封装成类、把关键词表换成哈希查找、给递归下降加错误提示。每一步都不大但改造完整个实验代码的可读性和可复用性完全不一样。第一步把词法分析逻辑封装成Lexer类输入从文件改为字符串输出从cout改为vectorToken。Token 结构体可以定义成struct Token { string lexeme; int category; };主循环里每识别完一个合法单词就tokens.push_back({str, category})。这样外界拿到的是一个标准 Token 流后面想接语法分析器或者做单元测试都非常顺手。同时把ktt从char[48][20]换成unordered_mapstring, int匹配逻辑从for循环strcmp变成map.find(str)性能和人眼可读性都上一个台阶。第二步给递归下降分析器增加错误提示。原版只输出“正确/失败”作业够用但调试时两眼一抹黑。改造方式是在每个函数的入口加一个栈深参数void E(int depth) { cout string(depth * 2, ) E: try a[s] endl; if (a[s] e) { s; B(depth 1); if (a[s] a) { s; A(depth 1); } else { cout E: expect a but got a[s] endl; } } else { cout E: expect e but got a[s] endl; } }这样每次推导步骤都会打印出来看不懂的失败变成一行行可跟踪的调用轨迹排查“哪个分支选错”就是几十秒的事。第三步把两个实验衔接起来。词法分析器的Token流可以直接喂给递归下降只需把输入串从“字母 #”扩展成“词法类别 #”。改造时我写了个适配层把Token数组转成vectorint每个类别号对应一个终结符语法分析器照常工作。这样你手里就是一个迷你编译器雏形——词法出 Token语法出判定——后续再接语义分析和中间代码生成整条链路都是通的。我做完这轮改造后最大的教训是原汁原味跑完第一版代码很有必要因为它会逼你把每一个if分支和while循环的边界条件都看一遍但一旦开始给代码加功能就必须立刻引入结构化的替代方案否则s、pos、ste这三个全局量会很快让你在代码里迷路。从那以后我每次拿到这类课程实验都强制自己先跑原始版本、再动手重构两步缺一不可。希望这份报告拆解能帮你在 PL/0 的词法分析和递归下降上少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取