☰
编译原理实验:PL/0词法分析与递归子程序语法分析器C++实现
2026/10/18 5:15:13 网站建设 项目流程

简介:一份编译原理课程实验报告,源自东北大学秦皇岛分校计算机与通信工程学院,主要围绕PL/0语言词法分析程序的设计与实现展开,适合正在学习编译器原理的高校学生作为课程设计或实验参考。报告完整包含实验目的、实验环境、程序设计与调试过程,核心部分是C++编写的词法分析器完整源码,详细实现了isLetter、isDigit、isP、isJ、isBlank等字符类别判断函数,并通过关键词表比对将源程序转换为(单词,类别)二元组序列,同时具备对“2A”这类非法输入的检测能力。压缩包内共1个doc文档,约122KB,文档层次清晰,涵盖源代码、运行输出及必要注释,便于对照学习和二次修改。目前已有98人学习下载,对于希望掌握词法分析基本流程、练习文本处理与错误检测编码技能的初学者来说,具有直接的参考价值。通过研读这份报告,还能理解从读取pl0.txt源文件到逐字符扫描、单词识别、分类输出的完整流程,为后续学习语法分析、语义分析打基础。

1. 编译原理实验报告的开题:词法分析与递归子程序两道坎

一份编译原理实验报告,最容易卡死的不是写结论,而是词法分析和递归子程序分析这两个程序怎么从“代码能编译”变成“原理能讲清”。某次课程要求做一个 PL/0 语言的词法分析程序,输入是源程序字符串,输出是(单词、类别)二元组序列,并且要有检查错误的能力,比如发现2A这类非法单词;另一道题是递归子程序分析器,判断任意符号串是否属于文法G[E]生成的句子。这两个实验在 Windows 上用 C++ 完成,看起来一个偏字符处理、一个偏文法推导,实际放到同一份方案里正好覆盖了编译前端的两块核心。这篇方案就按“需求背板 → 总体设计 → 关键实现 → 常见问题 → 测试验收 → 答辩技巧”的顺序展开,适合正在赶编译原理课设的学生,也能直接给从业者做演示程序时参考。

2. 词法分析程序总体设计:字符分类、类别码表与主循环

词法分析本质上就是四个动作:读字符、归类、拼词、查表。整个 PL/0 词法分析程序把源文件读成单字符流,用pos下标逐个推进;每遇到一个字符,先判断它是字母、数字、运算符、界符还是空白符,然后按不同分支“贪心”读取后续字符,拼出一个完整单词,再到预定义的类别码表里查它到底是关键字、标识符还是无符号数。这个方案没有用状态机,而是用“字符分类函数 + 表驱动”来简化实现,实验报告里解释起来非常直白,答辩时也容易给老师讲清楚。

2.1 字符分类函数:isLetter、isDigit、isP、isJ、isBlank 的职责边界

先看字符判断层,这五个函数是词法分析器能工作下去的地基。

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覆盖逗号、分号、括号、冒号、尖括号这些符号。这样分开定义不是为了写起来好看,而是为了在主循环里确立判断顺序:先看数字、再看字母、再看运算符、最后看界符。PL/0 语言里标识符和常数出现频率最高,把它们放在最前,可以减少无效判断;同时+、-这种算术运算符不会掉进界符分支,避免isJ把+ -误拼成一个符号串。字符分类函数尽量不要合并,实验改起来也方便:比如想扩充一个下划线_,只改isLetter一处即可。

2.2 关键字表与类别码:47 项表项的映射与查询逻辑

字符判断做完之后,还需要一张表把单词映射成类别码。原实现里定义了一张 47 项的字符串表,前 21 项是保留关键字,接着是标识符和无符号数,再往后是界符和运算符。

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", "标识符", "无符号数", ",", ";", ":", ".", "(", ")", "[", "]", "..", "++", "--", "+", "-", "*", "/", "=", "<", ">", "<>", "<=", ">=", ":=", "{", "}" };

类别码的映射规律是:0~20为关键字(and是 0,write是 20),21是标识符,22是无符号数,23~46是界符与运算符。这个映射表一旦定下来,词法分析器后面所有输出都依赖它,所以写实验报告时最好把这张表完整贴出来,再配一行文字说明“单词与类别码的对应关系是词法分析的输出契约”。查询实现采用最朴素的strcmp线性查找,扫描表里每一项做字符串比较;关键字总共不超过 21 个,符号表最长也就 47 项,在课程设计的源程序量级上线性查找性能完全够用,而且比用map更容易在报告里解释查找次数和匹配过程。

提示:类别码表是词法分析器最核心的“黑匣子”,答辩时老师常会指着某个输出问“这个 44 是哪来的”,能立刻答出:=的类别码是 44,就说明真的理解了表驱动机制。

2.3 主循环状态推进:单字符前瞻与单词拼装的实现

类别码表准备好以后,主循环负责把源文件pl0.txt的内容逐个字符吃进去,拼出单词,查表,并把类别码累积到ste字符串里,遇到换行就输出一行类别码。核心逻辑如下:

int pos = 0; char ch = src[pos++]; while (ch != '#') { if (ch == '\n') { cout << ste << endl; // 输出当前行的类别码串 ste = " "; ch = src[pos++]; continue; } if (isBlank(ch)) { ch = src[pos++]; continue; } if (isDigit(ch)) { // 无符号数 int i = 0; char str[20] = {0}; while (isDigit(ch) || ch == '.') { str[i++] = ch; ch = src[pos++]; } ste += "|22"; // 无符号数类别码 cout << str; continue; } if (isLetter(ch)) { // 标识符或关键字 int i = 0; char str[20] = {0}; while (isLetter(ch) || isDigit(ch)) { str[i++] = ch; ch = src[pos++]; } // 在 ktt 表 0~20 中找关键字;找不到则类别码为 21 ste += "|21"; cout << str; continue; } // 运算符、界符分支省略,处理思路相同 ch = src[pos++]; }

这段代码里的src是读入的源程序字符串,末尾人为追加#作为终止符;pos是当前读到的字符位置,ch是当前字符。ste保存的是类别码串,比如|0|21|38|22,遇到换行才整体输出,这样源程序每一行都对应一行类别码,报告里截图非常直观。这里的关键设计是“单字符前瞻”:每次读超过一个字符时,比如拼标识符COUNT,循环读到不是字母也不是数字的位置才停,而这个“停下来的字符”不丢失,留到下一轮再分类,这是词法分析不出错的最重要习惯。原实验代码在不做额外检查时,输入2A会被拆成数字2和标识符A,但题目要求的是发现非法单词,所以需要在数字分支结束后补一个判断,这一步放到后面常见问题里详细说。

3. 递归子程序分析器设计:文法 G[E] 到四个递归函数

词法分析把字符串切成单词后,语法分析要判断这些单词能不能组成合法句子。实验 4 要求用递归子程序法实现文法G[E]的句子识别。递归子程序法的核心思想是“一个非终结符对应一个函数”,函数体照抄产生式右部:遇见终结符就与当前输入字符比对,遇见非终结符就调用对应函数。本项目只有四个非终结符E、A、B、C,代码结构非常清晰,适合作为递归下降分析的入门样例写进报告。

3.1 文法拆解:E→eBaA 的产生式到函数骨架

要分析的文法如下:

G[E]: E -> eBaA A -> a | bAcB B -> dEd | aC C -> e | dC

这个文法最大的特点是各候选式之间不存在二义性冲突:A的两个候选a和bAcB,首字符分别是a和b,不会撞车;B的两个候选dEd和aC,首字符是d和a;C的两个候选e和dC,首字符是e和d。所以分析器不需要回溯,读一个当前字符就能决定走哪个分支,这就是递归下降能在这里成立的前提。把产生式翻译成函数骨架,E先匹配e,然后调B,再匹配a,最后调A;B按当前字符决定是走dEd还是aC;C则根据e或d决定是直接结束还是继续递归。特别注意E和B之间存在间接递归:E调B,B在dEd分支又调E。这个特点决定了四个函数必须先声明后定义,不然编译阶段直接报错。

3.2 递归函数实现:A、B、C 的间接递归调用

下面给出可直接编译的递归子程序分析器完整代码,修正了原稿里几个隐蔽问题,并加了行内注释方便写报告时引用:

#include <stdio.h> #include <string.h> char a[100]; // 存放输入串 int s = 0; // 当前读到的字符位置 void E(); void A(); void B(); void C(); void E() { if (a[s] == 'e') { // 匹配 e s++; B(); // 非终结符 B if (a[s] == 'a') { // 匹配 a s++; A(); // 非终结符 A } } } void A() { if (a[s] == 'b') { // 候选 bAcB s++; A(); if (a[s] == 'c') { s++; B(); } } else if (a[s] == 'a') { // 候选 a s++; } } void B() { if (a[s] == 'd') { // 候选 dEd s++; E(); if (a[s] == 'd') { s++; } } else if (a[s] == 'a') { // 候选 aC s++; C(); } } void C() { if (a[s] == 'd') { // 候选 dC,右递归 s++; C(); } else if (a[s] == 'e') { // 候选 e s++; } } int main() { while (1) { printf("请输入算数表达式(以#键结束):"); scanf("%s", a); s = 0; // 每次分析前重置游标 E(); if (a[s] == '#') printf("句子结构正确\n"); else printf("分析失败\n"); } return 0; }

对照文法看这段代码,E的函数体就是eBaA的逐符号翻译:先看当前字符是不是e,是则推进游标,调用B(),再看下一个字符是不是a,是则推进游标,调用A()。A内部先判断b,然后递归调用A,再匹配c、调用B;如果首字符是a就直接吃掉。整个程序没有显式错误恢复,某一步匹配失败时函数直接返回,主函数发现游标没停在#就判定分析失败,这正是递归下降最朴素也最好解释的判别方式。需要特别注意两个地方:一是while(1)在 C++ 中不能写成While(1),Windows 下编译报错时会提示While未定义,花两秒改掉就好;二是每次输入换新的测试串前必须把s重置为 0,否则第二次分析会从上次停下的位置继续判断,结果全是“分析失败”。

3.3 句子判别主控:输入串读取与 # 结束符判定

主控逻辑非常短,但它是整个分析器的“裁判”:读入一串以#结尾的符号,调用E()从起始符号开始推导,如果分析结束后游标正好停在#,说明整条串被完整消费掉,句子合法;否则说明某个位置匹配不上,输出“分析失败”。这种写法的好处是输出极其简单,实验结果只需要三行:输入串、分析结果、游标停留位置。做实验报告时,截图上最好把a[s]停在哪一步也打印出来,比如输入eaa#时,E匹配e后调B,B匹配a后调C,C不匹配,最终a[s]停在第二个a上,这个第二个a就是断言失败的位置。能把“停在哪个字符”讲清楚,比只贴一句“分析失败”更有说服力。

4. 常见问题与排查:误判、串读与栈溢出的翻车点

这份实验报告的程序,我实际改过好几轮,最容易反复出事的就是下面四个点。每条都按“现象 → 原因 → 解决”来写,实验过程中直接对照排查。

4.1 翻车点一:2A 被拆成两个合法单词

现象:输入2A,词法分析器输出数字2和标识符A,完全两个合法单词,题目要求的“发现非法单词”等于没做。原因:数字分支在读完整段数字后立刻收手,没有检查紧跟的字符是否还能作为标识符的一部分。解决:在数字分支的while循环结束后,紧跟着判断当前ch:

if (isDigit(ch)) { // ... 拼数字 if (isLetter(ch) || ch == '_') { printf("非法单词:%s%c\n", str, ch); ch = src[pos++]; // 跳过非法字符 continue; } }

这样2A会被当场拦截,而不是被拆成两个“正常单词”。这是词法分析器“错误检查能力”最直接的体现,写报告时单独列一条“非法字符串识别”是加分点。

4.2 翻车点二:连续界符 == 被整段吞掉

现象:输入A==B,输出里A、B都在,但两个等号消失,词法分析器像没看见它们一样。原因:isJ分支用while(isJ(ch))一口气读取连续界符,==会被拼成一个字符串"==",而类别码表里没有"=="这一项,查表失败后既没输出单词也没输出错误,整个串被静默丢弃。解决:PL/0 的最长复合符号是:=、<=、>=、<>这类两字符组合,所以isJ读取时最多读两个字符,读到第二位就停下,单字符也能单独查表。更稳妥的做法是查表失败时不吞字符,把pos回退到当前串的起始位置,让下一个字符重新进入主循环分类。

4.3 翻车点三:递归函数未提前声明导致编译失败

现象:代码写完后编译报错,提示B未定义。原因:E()的定义在B()前面,而E()内部调用了B(),C++ 要求标识符先声明后使用,函数定义顺序没对上就报错。解决:在文件顶部加四个函数前置声明void E(); void A(); void B(); void C();,这是递归子程序代码的固定动作,不属于“写错”,而是 C++ 编译规则。实验中很多学生卡在这一步,报告里可以写一句“因递归调用需提前声明函数原型”,反而显得懂原理。

4.4 翻车点四:pl0.txt 缺失或内容为空时静默失败

现象:运行词法分析程序后控制台一片空白,没有任何输出,看起来像死机。原因:fopen("pl0.txt", "r")成功与否没有检查,文件不存在时fgetc立刻返回EOF,读取循环直接结束,后面自然什么都不输出;如果文件是空文件,读完后只有末尾追加的#,主循环一次都不执行。解决:fopen后立刻判空,失败时打印错误提示并返回;读取结束后判断src是否为空,如果只有#就输出“源文件为空”。这两步虽然不起眼,但能让程序在异常输入面前不至于“装死”,验收时也会被当作工程严谨性的一项。

5. 测试用例与实验结果:pl0.txt 全流程复现

方案写完必须用测试用例证明它能跑。测试分两层:词法分析用pl0.txt作为标准输入,递归子程序分析用一组合法和非法句子做断言。把预期输出和实际输出并排贴进实验报告,是最直接也最有效的验收方式。

5.1 测试用例设计:关键字、标识符、常数与复合运算符覆盖

词法分析的测试文件故意设计成这样,用来一次覆盖多个分支:

CONST A=10; VAR B,C; 2B PROCEDURE P; VAR D; BEGIN READ(X);D:=X;WHILE X<0 DO CALL P; END.

这里面CONST、VAR、PROCEDURE、BEGIN、READ、WHILE、DO、END覆盖关键字,A、B、C、P、X覆盖标识符,10、0覆盖无符号数,;、,、.覆盖界符,:=覆盖复合运算符,2B专门用来触发非法单词检查。期望的类别码序列如下表:

输入片段期望类别串说明
CONST A=10;CONST(2) A(21) =(38) 10(22) ;(24)关键字、标识符、等号、常数、界符
VAR B,C;VAR(18) B(21) ,(23) C(21) ;(24)逗号和分号分别归类
D:=XD(21) :=(44) X(21)复合运算符应整体识别
WHILE X<0 DOWHILE(19) X(21) <(39) 0(22) DO(4)关系符紧跟常数不粘连
2B输出“非法单词:2B”改进版错误检测生效

递归子程序分析的测试用例更简单:合法句子输入eaeaa#,预期输出“句子结构正确”;非法句子输入eaaa#,预期输出“分析失败”。测试时用一个极小样例就够,没必要跑大段代码,因为递归下降的判定只取决于当前位置的字符匹配,样例越小越容易定位问题。

5.2 实验结果核对:类别码序列与预期输出对照

核对实验结果时,最常遇到的问题是类别码对不上。比如把:=输出成:(25) =(38),说明isJ分支没有按最长符号优先匹配,或者在查表时匹配到了单字符项;把无符号数输出成实际数值而非类别码22,说明输出逻辑里混用了单词内容和类别码。我一般会先在报告里画一张“源行 → 类别码行”的对照截图,然后在代码里对每一个分支标注类别码来源,这样对不上时一眼就能看出是查表顺序还是累加逻辑的问题。实验里所有无符号数的类别码统一是22,不区分数字大小,这是很多人容易忽略的点。

5.3 可复现运行流程:Windows 命令行编译与批量测试

为了让验收的人能原样跑通,把编译运行步骤写清楚比贴一段代码更有价值。Windows 下打开命令行,进入源文件目录,依次执行:

g++ lexer.cpp -o lexer.exe lexer.exe

词法分析器默认读取当前目录下的pl0.txt,控制台会先输出源程序全文,再按行输出类别码。递归子程序分析器编译运行后进入交互模式,直接输入测试串加#回车即可。批量测试时可以用命令行重定向:把多个测试串写进test_cases.txt,然后执行analyzer.exe < test_cases.txt,一次跑完所有用例,避免手动逐个输入。这一条写进报告的实验环境说明里,会显得整套方案具备可复现性,而不是只能看截图。

6. 验收与答辩:从能跑通到能讲清

6.1 验收清单:实验报告八项自查

提交前按下面这份清单过一遍,能避免绝大多数低级扣分:词法分析能输出(单词、类别)或类别码行;2A这类非法串能被拦截而不是拆成两个合法单词;:=、<=这类复合符号被识别为整体,类别码分别是44、42;pl0.txt缺失时程序给出明确提示;递归子程序分析器对eaeaa#判断正确,对eaaa#判断失败;每次输入新串前s都重置为 0;代码文件有四个递归函数的前置声明;实验报告包含实验目的、设计思路、完整代码、测试用例、结果截图五部分。这八项里前六项直接跑测试用例就能验证,后两项靠查代码和翻报告就能确认。

6.2 答辩主线:用一组最小样例把两个实验串成编译前端

答辩时不要把词法和语法当成两个孤立程序,一句话就能把两者串起来:词法分析得到的(单词、类别)二元组序列,就是递归子程序分析器要消费的输入流;前者负责切词,后者负责判断切出来的词是否满足文法。现场演示用最小样例就够了:词法部分输入CONST A=10;,输出CONST(2) A(21) =(38) 10(22);语法部分输入eaeaa#,输出“句子结构正确”。讲词法时强调字符分类、最长匹配、类别码表三层结构;讲语法时强调产生式到函数体的逐条翻译,以及为什么这个文法不需要回溯。

我自己的一个习惯是:每份编译原理实验报告在提交前,都先留一份「预期结果对照表」,代码改了哪一行,对照表就同步更新哪一行。有过一次报告里截图和代码行为完全对不上,临时慌着重跑所有用例的经历之后,我就再也不敢跳过这步了。希望这套方案能帮到你的实验顺利收尾。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询