编译原理实验报告:词法分析器与递归子程序语法分析实战
简介东北大学秦皇岛分校的这份编译原理实验报告聚焦PL/0语言词法分析程序的设计与实现面向正在学习编译原理、需要完成词法分析实验的本科生及备考者。报告以C为载体完整实现了从源程序字符串到单词、类别二元组的转换并加入对‘2A’等非法单词的错误检测能够帮助读者理解词法分析在编译器前端的定位与工作方式。资源仅含1个doc文档压缩包约122KB文档内依次给出实验目的、实验环境、程序源码、运行流程与实验总结便于直接对照学习。该报告已有98人浏览学习适合作为同类课程实验的撰写范本。源码中设计了isLetter、isDigit、isP、isJ、isBlank等字符判断函数并通过预定义关键词表ktt对单词进行归类清晰演示了逐字符扫描、拼词、查表和输出的整个过程。读者可从中学到如何搭建一个最小可用的词法分析器同时也能借鉴其中规范的实验报告写法为后续语法分析、语义分析及代码生成等编译技术的学习打下基础。1. 编译原理实验报告代码能跑只是及格能讲清思路才是加分项一份编译原理实验报告很多人以为把代码贴上去、截图一放就算完事实际上老师一眼就能看出来你是真调通了还是在碰运气。我见过太多词法分析实验翻车的例子代码照着书抄下来了结果输入一个2A直接当成“常数 2 标识符 A”输出错误检测形同虚设递归子程序分析器第一遍跑得挺对换第二组输入就“分析失败”原因仅仅是上次的下标没复位。这份实验报告里恰好包含两个最典型的关卡实验 2 的词法分析PL/0 语言的单词识别和实验 4 的递归子程序语法分析。这两块是编译前端的核心也是课程设计里最容易卡住的地方。本文不打算把代码逐行念一遍而是按“设计思路 → 关键实现 → 踩坑排查 → 报告写法”的顺序把这门实验从能跑到能拿高分中间的差距补上。正在写词法分析器、或者被递归下降卡住的同学可以照着下面的思路改自己的实现。2. 词法分析器设计字符分类、查表匹配与类别编码三件套词法分析的任务说白了就一句话把源程序字符串切成一个个单词每个单词带上类别编号输出。PL/0 的词法分析器看起来功能不大但要把“数字、标识符、关键字、运算符、界符”五类东西分清楚还要能发现2A这种非法串里面有不少值得抠的细节。2.1 字符分类函数为什么值得单独拆出来词法分析器最底层的判断就是“这个字符是什么类型”。常见做法是写一堆返回布尔值的函数每个函数只判断一种字符类别比如bool isLetter(char ch) { return (ch A ch Z) || (ch a ch z); } bool isDigit(char ch) { return ch 0 ch 9; } bool isBlank(char ch) { return ch || ch \t; }有的同学觉得这几个函数太简单直接塞在主流程里用if判断就行。我的建议是不要省这一步。原因有两个第一可读性。主流程里全是ch A ch Z这种原始比较看三行就晕了后面写报告画模块图都不好画。第二可测性。分类函数拆出来之后每个函数都可以单独喂边界值验证比如判断isDigit(9)和isDigit(.)出问题能立刻定位是分类错还是匹配错。判断依据上注意字母函数只认 A-Z 和 a-z不认下划线。很多词法分析器会把下划线当作标识符的一部分但 PL/0 的语法描述里没有下划线所以这里不用扩展。数字函数只认 0-9小数点在后面单独处理这两个函数配套使用才能正确识别无符号数。2.2 关键字表与类别编码整张表就是分析器的灵魂词法分析器真正的结构核心是一张单词表。表里每一行是一个单词或符号行号就是它的类别编码。PL/0 的保留字加运算符加界符加两个特殊类别一共可以整理成一张 48 行的表大致结构如下编号单词类别说明0-20and, begin, const, div, do, else, end, function, if, integer, not, or, procedure, program, read, real, then, type, var, while, write关键字21标识符自定义类别22无符号数自定义类别23-32, ; : . ( ) [ ] .. --界符33-38 - * / 运算符39-46 : { } #复合符号这张表一出来识别逻辑就非常简单遇到字母开头的串先按“字母或数字”连续读完然后去表的前 21 行里查查到了就是关键字查不到就是标识符遇到数字连续读数字和小数点得到无符号数遇到运算符或界符按表里的最长匹配去比对。这里有一个关键设计点把关键字和标识符统一成“先读完整串再查表”的流程而不是边读边判断。两种做法的差别在const1这种输入上体现得很明显——先读完整串再查表得到的是标识符const1语义正确边读边判断的话读到const就以为自己遇到了关键字后面跟个1反而不知道怎么处理。2.3 从读文件到输出单词序列主流程怎么串起来整个分析器的主流程是一个循环从源文件读入整个字符串末尾补一个#作为结束标记然后从第一个字符开始扫描每读一个字符就判断类型、进入对应分支直到遇见#为止。整理后的核心框架大概是下面这样while (ch ! #) { if (isDigit(ch)) { // 数字分支连续读数字和小数点得到无符号数 while (isDigit(ch) || ch .) { str[i] ch; ch src[pos]; } str[i] \0; // 类别编码记为 22 } else if (isLetter(ch)) { // 字母分支连续读字母和数字得到完整单词 while (isLetter(ch) || isDigit(ch)) { str[i] ch; ch src[pos]; } // 查关键字表命中则输出对应编号未命中则是标识符(21) } else if (isOperator(ch)) { // 运算符分支匹配 - * / 等单字符 } else if (isDelimiter(ch)) { // 界符分支尽量吃进连续符号查表匹配 : 等复合符号 } else if (isBlank(ch)) { // 空白直接跳过 } else { // 遇到无法归类的字符报告非法输入 } }逻辑说明数字分支和字母分支都需要“向前多看一步”才能决定单词边界界符分支能识别:这种复合符号空白字符只负责跳过不产生任何单词。参数上要注意pos是当前读位置的下标ch是当前字符进入分支后必须同步更新ch src[pos]否则循环会死在一个字符上。很多同学调试时发现程序不往前走基本都是漏了这一句。值得一提的是这份报告里原始代码的单词拼接用了比较绕的stringstream方式其实改成整数转字符串再拼会直观得多。这不影响功能但你如果要把代码贴进报告建议顺手把这种地方简化老师看代码时印象分会好很多。3. 递归子程序分析把文法直接翻译成函数结构语法分析的任务是判断输入串是否符合文法规则。递归子程序也叫递归下降是最直观的语法分析方法每一个非终结符对应一个函数函数体就是文法产生式的直接翻译。实验里的文法 G[E] 很短正好适合把整套思路讲透。3.1 从 G[E] 文法到 E/A/B/C 四个函数的映射实验给出的文法如下E - e B a A A - a | b A c B B - d E d | a C C - e | d C递归子程序的核心思想是“产生式怎么写函数就怎么写”。比如E - e B a A翻译成函数就是先看当前字符是不是e是就消费掉然后调B()再要求当前字符是a消费掉之后调A()。每个非终结符一个函数函数之间互相调用就像一个调用链。void E() { if (a[s] e) { s; // 消费 e B(); // 处理 B if (a[s] a) { s; // 消费 a A(); // 处理 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; } }逻辑说明全局变量s是当前读位置a是存放输入串的字符数组。每个函数消费掉自己能识别的字符后s就往后移动函数返回后调用方继续检查下一个预期的字符。参数上最需要注意的是“消费”动作只发生在匹配成功时匹配失败就直接返回由上层决定是否报错。这个文法里每个非终结符的选择分支首字符都不同A 的分支首字符是b和aB 的分支首字符是d和a所以不需要回溯这也是递归子程序能高效工作的前提。3.2 为什么这个文法不需要回溯递归子程序最容易翻车的地方是产生式有多个可选分支而当前字符无法决定走哪条路。比如A - a | a b两个分支都以a开头读到a时你不知道该走哪边选错了还得回溯。实验给的这个文法避开了这个问题每个非终结符的可选分支首字符集合First 集互不相交。判断依据很简单看每个|左右两边可能出现的第一个终结符如果有重叠就必须引入预测分析或回溯。这里的 A 分支开头是b和aB 分支开头是d和aC 分支开头是d和e全部不重叠所以直接用if...else if就能正确选择分支不需要额外的预测符号表也不存在回溯的代价。这是一个刻意设计的文法写报告时如果能点出这一点说明你是真看懂了递归子程序的适用条件。另外要留个心递归子程序只能处理没有左递归的文法。如果文法写成A - A b | a函数A()一进来就调用自己栈会直接打爆。实验里 A 和 C 的递归都出现在消耗了一个终结符之后属于右递归或中间递归递归深度和输入长度成正比没问题。3.3 运行验证与输出判断正确结果长什么样用最小合法串eaea验证一下E 先吃e进入 BB 看到a吃a后进入 CC 看到e吃e返回回到 E吃掉a进入 AA 看到a吃a返回。此时输入串正好结束a[s]是#输出“句子结构正确”。判断失败的逻辑依赖一个简单的观察如果输入串不是该文法的句子E 分析完成后s不会停在#上而是要么某个应有的终结符没匹配上、要么多出无法归约的字符。代码里用if (a[s] #)来判断成功这个做法简单有效但有个缺点它只能区分“成功 / 失败”不能告诉你失败的具体位置。比如输入eabE 吃e后 B 吃a、C 想吃e却看到bC 直接返回回到 E 后要求a但当前字符是bE 也返回最后a[s]不是#输出“分析失败”。定位问题全得靠手动打印中间过程这是递归子程序做简单判断时的通病报告里能主动写出这个局限反而是加分项。4. 常见翻车与排查这四个坑我踩过你也躲不开词法分析器和递归子程序看起来代码量不大实际调起来全是细节。下面四条是这类实验里出现频率最高的故障每条都按“现象 → 原因 → 解决”写清楚你对照着查就行。4.1 现象把原文代码抄下来编译报错代码是从书或报告上直接复制粘贴的编译却报“标识符 While 未定义”“应为 while 关键字”之类的错误。原因是 C 大小写敏感.whileifint这些关键字必须全小写。有些资料排版时把首字母误写成大写粘贴下来直接编译不过。我开始也被这行坑过盯着代码看了半天才意识到是关键字大小写问题。解决复制来的代码先全局搜一遍关键字while、if、else、return、int等全部改成小写。这种低级错误最影响心情也最容易让报告显得不严谨编译之前先花一分钟扫一遍能省半小时调试时间。4.2 现象输入2A程序输出“常数 2”和“标识符 A”没有报错这是词法分析实验里最常见的功能缺陷。数字分支只判断了当前字符是不是数字或小数点读完2之后下一个字符是A既不是数字也不是小数点数字串就结束了。然后A走字母分支被识别成标识符。整个过程没有任何环节检查“数字后面紧跟字母”这种非法组合。原因是没有把“数字后紧跟字母”作为错误情况处理。按词法规则2A不能拆成2和A它是一个非法单词分析器应当直接报错。解决数字串读完之后加一个判断——如果isLetter(ch)为真说明数字后面紧跟字母一律报告“非法标识符”。同理标识符的合法性检查是首字符必须是字母所以以数字开头的情况已经在分支层被拦掉了不需要重复判断。4.3 现象递归子程序第一次分析正确第二次输入同样的串却“分析失败”程序第一次运行正常换输入再跑或者把同一个输入重新跑一遍结果却不对。这通常不是逻辑改了而是全局变量没有复位。原因分析函数依赖全局下标s。第一次分析把s推到了串尾第二次调用E()时如果s还保留着上次的值函数从错误的位置开始读字符结果自然全错。解决每次开始分析之前强制s 0如果输入串也重新读入一并复位。这属于“状态残留”问题排查方法很简单——在E()入口处打印一次当前s的值看到非零就说明上一轮的尾巴没清干净。从那以后我写这类程序都会把所有“本次运行需要的全局状态”集中在一个reset()函数里每次循环开头统一调用再没出过这种玄学 bug。4.4 现象输入带空格或超长字符串程序行为异常用scanf(%s, a)读输入时输入eaea eaea只读到了第一个eaea输入超过 9 个字符时程序直接崩溃或结果错乱。原因是%s遇到空格就停止读取天然无法处理带空格的输入同时字符数组a[10]长度固定超过容量的字符直接越界写坏内存。这两个问题叠加起来就是测试用例一复杂就翻车。解决改用fgets(a, sizeof(a), stdin)限定读取长度一次只读一行或者把数组从a[10]扩到a[100]再配合循环读取。语法分析实验的输入通常是一整行表达式用fgets是最省事的方案。如果坚持用scanf至少加上宽度限制scanf(%9s, a)能防止缓冲区溢出但空格问题依然存在。5. 实验报告写法让老师一眼看到工作量与理解深度实验做完只是第一步报告写得怎么样直接决定你这门课拿什么档次的成绩。很多同学代码调通了报告却只贴代码和截图老师看不出你哪里花了心思。下面按老师批阅时的关注点讲讲一份合格的编译原理实验报告该怎么组织。5.1 一份能拿分的实验报告结构清单老师批这类报告首先看结构是否完整其次看设计思路是否讲清最后看测试有没有覆盖边界情况。一份标准报告至少应该包含下面这些段落段落内容要点常见缺失实验目的用一两句话说明本次实验验证了什么原理照抄任务书没写自己的理解实验内容说明输入输出是什么、要支持哪些语法直接贴题目原文没做转化总体设计模块划分、函数说明、数据结构设计完全不写直接上代码关键代码核心函数带注释非核心部分可省略整篇代码全贴无注释测试用例正常输入、边界输入、非法输入三组只贴一组正常用例问题与心得记录实际遇到的 bug 和排查过程写成“通过本次实验我掌握了……”5.2 类别编码表和测试用例表两页纸顶过千言词法分析报告里有两张表是性价比最高的内容。第一张是类别编码表把关键字、标识符、常数、运算符、界符的编号列清楚老师一眼就知道你的分析器结构是清晰的。第二张是测试用例表每条用例写明输入、预期输出、实际输出、说明四列覆盖正常、非法、边界三类情况。比如词法分析可以设计这样一组测试用例CONST A10;验证关键字、标识符、常数、界符的整体配合2A验证错误检测A10验证标识符中可以包含数字1.5如果支持实数验证小数点的处理空行和多个制表符验证空白跳过。测试用例表列出来工作量一目了然比贴十行运行截图更有说服力。递归子程序部分则用合法串eaea、嵌套串ebaca?按文法构造一组多层的例子、非法串eab三组数据分别验证正确接受、递归展开和失败判定。5.3 实验心得怎么写才不像凑字数“通过本次实验我加深了对词法分析原理的理解”这种话十个报告九个写老师已经看麻了。真正有区分度的心得是记录你实际踩过的坑。比如“最初实现数字识别时2A被拆成了常数和标识符两个单词后来在数字串结束时增加isLetter检查才拦截住。这个 bug 让我意识到词法分析器的错误检测不是可选项而是和识别正确单词同样重要的一部分。”这种写法有现象、有原因、有措施一句话说明你在实验里动过脑子。写心得有一个原则宁可写一个小问题的完整排查过程也不要罗列十个空洞的“我学会了”。老师一天批几十份报告看到真实调试记录会比看到套话印象深得多。6. 一个能省一晚上的验收技巧交报告前把自检清单跑一遍最后分享一个我在多次实验里沉淀下来的习惯每次交实验报告之前不急着打包先拿着下面这张清单对着自己的实现过一遍。这张清单覆盖了从“能跑”到“经得起问”的所有关键检查点按顺序走一遍基本能挡住 90% 的返工。检查项操作通过标准关键字大小写全局搜 while/if/int全部为小写数字后紧跟字母输入2A报非法标识符而非拆分输出复合符号匹配输入:识别为对应类别不拆成两个单字符全局状态复位同一输入连跑两次两次结果一致空白处理输入含多空格和制表符正常跳过不产生空单词非法字符输入或$有错误提示程序不崩溃递归终结符匹配输入eaea输出“句子结构正确”失败判定输入eab输出“分析失败”这套清单用下来最直接的收益是不用再靠反复试输入来碰运气。以前我调试词法分析器总觉得“好像没问题了”交上去才发现2A这种用例没测过。现在每次实验收尾都强制走一遍清单边跑边勾跑完心里就有底了。编译原理实验的代码量不大真正的分差就在这些细节上——你是不是真的理解了词法分析的错误检测边界、递归子程序的适用条件、全局状态的管理方式跑一遍清单就能看出来。希望这份拆解对你接下来的课程实验有实际帮助。本文还有配套的精品资源点击获取