☰
PL/0词法分析与递归下降语法分析实验避坑指南
2026/11/4 3:02:38 网站建设 项目流程

简介:这份东北大学秦皇岛分校编译原理课程实验报告,面向计算机相关专业学生与初学者,聚焦PL/0语言词法分析程序的设计与实现,帮助读者理解编译器前端如何将源程序切分为单词并输出(单词、类别)二元组。报告完整记录实验目的、实验内容与提交要求,在Windows PC环境下使用C++编写,包含isLetter、isDigit、isP、isJ、isBlank等字符分类函数及主程序处理流程,并讨论“2A”这类非法单词的检测思路。压缩包内共1个doc文件,约122KB,篇幅精炼,便于直接查阅实验框架与代码组织。已有102人学习,适合需要完成同类实验、梳理解析流程或参考错误处理方式的学生对照使用,可从中了解文件读取、关键词表匹配、单词分类与结果输出等关键环节。

1. 词法分析器不是抄一遍就行:这份编译原理实验报告里真正能复用的两条流水线

拿到一份编译原理实验报告,多数人第一反应是照着敲一遍交差,结果 PL/0 词法分析跑起来只吐单词不吐类别,递归子程序那段连While(1)都编译不过。这份资源的真正价值不在代码本身,而在于它把两个必做实验的完整链路摆在了一起:字符级扫描 → 二元组序列 → 递归下降判定,中间还留了2A这类非法串的检查口子。它适合正在赶编译原理实验、被 GETSYM 和 FIRST 集绕晕的计算机专业学生,也适合想快速复盘一遍「词法 + 语法」两段流水线的前端从业者。下面按我重跑一遍的顺序拆,顺手把原报告里几处会导致结果不对的硬伤标出来。

2. PL/0 词法分析器的骨架:48 项单词表与五类字符判定怎么落地

词法分析这一段,说白了就三件事:把字符分成类,按类切出单词,给单词编个号。听起来简单,但类别怎么划、码表怎么排、主循环怎么转,直接决定了后面语法分析能不能接得上。原报告的思路是对的——先用一组isXxx()布尔函数把字符归类,再拿一张ktt表做单词与类别码的映射,最后在主循环里按字符类型分流。问题出在分类边界和循环控制上,下面按能跑通的方式重新摆一遍。

2.1 五类字符判定函数:isLetter / isDigit / isOp / isBd / isBlank

先明确一点:词法阶段的分类不是为了「理解」,而是为了「无歧义地切分」。分类多了会互相抢字符,分类少了要写一堆 if。我一般划五类:字母、数字、算术符、界符、空白。

// 字母:只认 ASCII 字母,PL/0 的标识符不允许下划线开头 bool isLetter(char c){ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } // 数字:0~9,词法阶段不做数值转换,只负责把数字串切出来 bool isDigit(char c){ return c >= '0' && c <= '9'; } // 算术运算符:+ - * / 四个单字符 bool isOp(char c){ return c == '+' || c == '-' || c == '*' || c == '/'; } // 界符与关系符:凡是能参与"双字符组合"的字符统统放进来 bool isBd(char c){ return std::string(",;:.()[]=<>{}#").find(c) != std::string::npos; } // 空白:空格、制表、回车、换行一律当分隔符丢弃 bool isBlank(char c){ return c == ' ' || c == '\t' || c == '\r' || c == '\n'; }

这五个函数是后面所有分支的判据,参数只有一个char,返回bool,没有副作用,可以随便组合。原报告把+ - * /单独抽成isP、其余抽成isJ,方向是对的,但isJ把:=<>也一起收进去了,后面又用while(isJ(ch))连续吞字符,等于给「`:=" 和 ");" 粘连」埋了雷。教训是:能合法组成多字符单词的字符,必须单独一个集合,并且匹配时要先试长的。

还有一处容易忽略:原代码的isBlank只认空格和\t,换行\n掉进了最后的else分支,被打成一行一行莫名其妙的ste串。换行在词法层就是空白,必须并进来一起丢。

2.2 类别码表:关键字 0~20、标识符 21、无符号数 22、界符 23~47

码表的排布不是随便写的,它决定了你后面能不能用「区间查找」而不是满表扫描。我沿用了原报告的布局,因为区间语义很清晰:

码值区间含义举例
0~20保留关键字and=0、begin=1、const=2 ……write=20
21标识符A、B、P、Q、X
22无符号数10、0、3.14
23~47运算符与界符","=23 ……"#"=47

表本身用一个定长二维字符数组就行,关键是顺序:关键字必须连续放在最前面,这样标识符识别完之后只需要在[0,20]里回查,查不到才判定为标识符 21。如果把关键字打散,回查范围就会变成整张表,既慢又容易误判。

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", "标识符","无符号数", ",",";",":",".","(",")","[","]","..","++","--","+","-","*","/", "=","<",">","<>","<=",">=",":=","{","}","#" }; // 在 [lo,hi] 区间里查串,命中返回码值,未命中返回 -1 int lookup(const std::string& s, int lo, int hi){ for (int i = lo; i <= hi; ++i) if (s == ktt[i]) return i; return -1; }

lookup的两个区间参数是关键设计:查关键字传(0,20),查双字符界符传(31,44),查单字符界符传(23,47)。同一个函数复用三次,不需要写三套比较逻辑。注意双字符界符在表里不是连续的(..、++、--在 31~33,<>、<=、>=、:=在 41~44),所以查双字符时区间要放宽到(31,44),中间那些单字符项永远不会被两字符串命中,不会误判。

2.3 主循环骨架:一次性读全文件,用长度控制而不是 EOF

原报告用fgetc+while(ch0 != EOF)读文件,这是 C 里最经典的写法,也是最容易多读一个字节的写法——fgetc返回 EOF 之后循环体已经执行过一次判断,如果中间还有src += ch0就会塞进一个0xFF。C++ 里有更省心的办法:

#include <fstream> #include <iterator> #include <vector> #include <string> int main(){ std::ifstream fin("pl0.txt"); if (!fin){ std::cerr << "pl0.txt 打不开,检查工作目录" << std::endl; return 1; } // istreambuf_iterator 一次性吃掉整个文件,不做任何字符解释 std::string src((std::istreambuf_iterator<char>(fin)), std::istreambuf_iterator<char>()); std::vector<std::pair<std::string,int>> tokens; if (!scan(src, tokens)) return 1; // scan 返回 false 表示遇到非法单词 for (size_t i = 0; i < tokens.size(); ++i) std::cout << "(" << tokens[i].first << ", " << tokens[i].second << ")\n"; return 0; }

istreambuf_iterator那行的含义是「从文件流缓冲区一直读到结尾」,构造出来的string就是完整源程序,不会多也不会少。scan的第二个参数是输出参数,用vector<pair<string,int>>存结果,键是单词原文,值是类别码——这就是实验要求里说的「二元组序列」,只不过用容器存起来而不是直接打印,好处是后面接语法分析时可以直接复用这份 token 流,不用再解析控制台输出。

提示:PL/0 源程序本身以.结尾,#在码表里只是占位用的结束符。不要拿while(ch != '#')当循环条件,否则源程序里一旦出现#就提前截断,而且文件末尾没有#时会直接越界。

3. 从源串到二元组:最长匹配、双字符界符与 2A 报错

切词这一步的核心原则只有一条:最长匹配。读头停在某个位置时,尽量吃进能构成合法单词的最长前缀,吃不动了再回退到上一个合法位置。PL/0 的单词长度都很短(标识符无长度上限但实际不会长,界符最多两个字符),所以不需要真正的回溯,只要「先试两个字符,不行再试一个」就够了。下面把scan拆成四段写。

3.1 数字分支:小数点、前导零与「数字后接字母」

数字分支要处理三件事:连续数字、可选的小数点、以及非法后缀。第三件是原报告漏掉的——它宣称能查出2A,但实际代码里数字读到非数字字符就停,2A会被切成(2,22)和(A,21)两个合法 token,错误检查形同虚设。

if (isDigit(c)){ std::string num; size_t start = p; while (p < n && isDigit(src[p])) num += src[p++]; // 整数部分 if (p < n && src[p] == '.'){ // 可选小数部分 num += '.'; ++p; while (p < n && isDigit(src[p])) num += src[p++]; } // 关键:数字后面紧跟字母或下划线,说明这是一个非法单词 if (p < n && (isLetter(src[p]) || src[p] == '_')){ std::cerr << "第 " << start << " 列: 非法单词 " << num << src[p] << std::endl; return false; } out.push_back(std::make_pair(num, 22)); continue; }

参数说明:p是全局读头,n是源串长度,start只用来报错定位。判断条件里之所以要加_,是因为2_A和2A属于同一类错误——数字开头后面跟了标识符字符。返回false而不是继续扫,是因为词法错误会让后续 token 流整体错位,早停比硬撑更有用;如果实验要求「尽量多报错」,把return false换成++p; continue;跳过这个字符即可。

小数点的处理有个隐含约定:10.这种「点后面没数字」的情况,上面的代码会接受并输出(10., 22)。严格的 PL/0 不允许这种写法,但实验环境的测试文件里通常不会出现,如果要求严格,在num += '.'之后加一句「必须至少跟一个数字,否则报错」的判断。

3.2 标识符分支:最长匹配之后再回查关键字表

标识符和关键字在字符层面完全一样,只能靠「切完之后查表」来区分。这个顺序不能反——先查表再切,遇到integerValue这种前缀是关键字的长标识符就会误判。

if (isLetter(c)){ std::string id; while (p < n && (isLetter(src[p]) || isDigit(src[p]))) id += src[p++]; int code = lookup(id, 0, 20); // 只在关键字区间里查 out.push_back(std::make_pair(id, code == -1 ? 21 : code)); continue; }

while条件里带上isDigit是必须的,否则X1、A2这类带数字的标识符会被切成两段。lookup的区间限定为(0,20),查不到返回-1,三元表达式把它映射成 21——这样「标识符 21」这个码值就只在一处出现,改起来方便。

原报告这段用了stringstream来回倒腾:ss << ste; ss << j; ss >> ste;,本质是想把码值拼到字符串后面。这种写法能跑,但拼出来的是" |21|5|"这样的长串,语义上已经不是二元组了,而且每拼一次都要重建流状态,扫一个大文件会很慢。用vector<pair<...>>就没这个问题。

3.3 双字符界符::='<= >= <> ..` 必须先于单字符匹配

这是整个词法分析里最容易翻车的地方。:=如果先按:匹配掉,剩下一个=就变成了赋值号,语法分析阶段会直接崩。正确顺序永远是「先看两位,再看一位」:

if (isOp(c) || isBd(c)){ // 第一优先:两字符组合 if (p + 1 < n){ std::string two = src.substr(p, 2); int code = lookup(two, 31, 44); // 只在双字符项里查 if (code != -1){ out.push_back(std::make_pair(two, code)); p += 2; continue; } } // 第二优先:单字符 std::string one(1, c); int code = lookup(one, 23, 47); if (code != -1){ out.push_back(std::make_pair(one, code)); ++p; continue; } std::cerr << "第 " << p << " 列: 无法识别的字符 " << c << std::endl; return false; }

substr(p, 2)之前必须判p + 1 < n,否则读到文件末尾两位时substr会抛out_of_range。这是我在调试时踩过的坑——单测文件最后一行以.结尾,正好卡在边界上,直接崩在std::string里,报错信息跟词法分析一点关系都没有。

把这三段拼回scan函数,整体结构就是:跳过空白 → 判数字 → 判字母 → 判运算符/界符 → 都不匹配就报错。每个分支末尾都要continue,让while(p < n)重新取字符,不要在分支内部再手动推进读头,否则很容易出现「一次循环吃了两个 token」的玄学问题。

3.4 输出格式:把拼接串换成真正的 (单词, 类别) 二元组

实验要求写的是「由(单词、类别)所组成的二元组序列」,那输出就该老老实实是这个形状。原报告最后打印的是一整行ste拼接串,类别码混在竖线里,肉眼根本对不上号。改成逐条打印:

for (size_t i = 0; i < tokens.size(); ++i) std::cout << "(" << tokens[i].first << ", " << tokens[i].second << ")" << std::endl;

对测试文件CONST A=10; VAR B,C;跑出来应该是:

(CONST, 2) (A, 21) (=, 38) (10, 22) (;, 24) (VAR, 18) (B, 21) (,, 23) (C, 21) (;, 24)

这个格式的好处是可核对:每个 token 单独一行,错在哪一行一眼能看出来。如果实验报告要求贴截图,这种输出也比一坨拼接串好看得多。

4. 递归子程序分析器:把 E→eBaA 逐行翻成四个函数

第二个实验给的文法 G[E] 是:E→eBaA、A→a|bAcB、B→dEd|aC、C→e|dC。递归子程序法的思路极其直接——一个非终结符对应一个函数,产生式右部的符号序列就是函数体里的调用顺序。但直接翻译之前必须先确认一件事:这个文法能不能这么翻。

4.1 先判 LL(1):FIRST 集两两不相交,所以不用消除左递归

递归下降要求文法是无左递归的,并且同一非终结符的各条候选式的 FIRST 集不相交。逐条检查:

产生式FIRST 集是否冲突
E → eBaA{e}单候选,无冲突
A → a{a}—
A → bAcB{b}与上一条不相交
B → dEd{d}—
B → aC{a}与上一条不相交
C → e{e}—
C → dC{d}与上一条不相交

四个非终结符里,E只有一条产生式,A、B、C各有两条且首字符不同。C→dC虽然是右递归,但右递归对递归下降完全无害——它只会在函数内部再调一次自己,深度等于输入里d的个数。真正致命的是左递归(C→Cd),那会直接无限递归爆栈,必须先用C→dC'、C'→ε|dC'改写。这份文法不需要改写,可以照抄。

注意:A和B之间存在互相调用(A→bAcB调B,B→dEd调E,E→eBaA又调A),所以四个函数必须先做前向声明,否则编译期会报「未定义标识符」。

4.2 四个函数的骨架:读头前进、失败标记、统一判错

递归下降的实现约定是:函数进入时读头指向该非终结符推导串的第一个字符,函数正常返回时读头指向推导串之后的位置。失败不抛异常,用一个全局ok标记,避免一层层写返回值判断。

#include <iostream> #include <string> std::string s; // 待分析串,末尾补 '#' 当结束符 size_t p = 0; // 读头 bool ok = true; // 全局成功标记 void E(), A(), B(), C(); void E(){ if (p < s.size() && s[p] == 'e'){ ++p; B(); } else { ok = false; return; } if (p < s.size() && s[p] == 'a'){ ++p; A(); } else { ok = false; return; } } void A(){ if (p < s.size() && s[p] == 'a'){ ++p; return; } // A → a if (p < s.size() && s[p] == 'b'){ // A → bAcB ++p; A(); if (p < s.size() && s[p] == 'c'){ ++p; B(); } else ok = false; return; } ok = false; } void B(){ if (p < s.size() && s[p] == 'd'){ // B → dEd ++p; E(); if (p < s.size() && s[p] == 'd'){ ++p; return; } ok = false; return; } if (p < s.size() && s[p] == 'a'){ ++p; C(); return; } // B → aC ok = false; } void C(){ if (p < s.size() && s[p] == 'e'){ ++p; return; } // C → e if (p < s.size() && s[p] == 'd'){ ++p; C(); return; } // C → dC ok = false; }

几处细节值得说清楚。第一,每个s[p]之前都判了p < s.size(),这是防越界的硬性要求——原报告里a[10]的定长数组加上无边界检查的s++,输入稍长就会读到栈上的垃圾值,表现是「有时候判对有时候判错」,非常难查。

第二,ok一旦置false就不再加判断直接返回,这是「快速失败」;如果想报出更精确的错误位置,把ok = false换成打印p的当前值即可,但注意别在递归深处反复打印,会刷屏。

第三,E()里两个if是串行的,中间不能省else——e后面必须紧跟B的推导串,B返回后必须紧跟那个终结符a,任何一环缺失都说明输入不匹配。

4.3 输入缓冲与结束符:定长数组换成 std::string 并补 '#'

原报告的main用char a[10]加scanf("%s", a),三个问题叠在一起:缓冲区太小(长句子直接溢出)、%s不检查长度、循环条件While(1)大小写错误根本编译不过。换成std::string+getline之后这三个问题一起消失:

int main(){ std::string line; while (std::getline(std::cin, line)){ if (line.empty()) continue; s = line + "#"; // 补哨兵,用于确认读头真的走到末尾 p = 0; ok = true; E(); if (ok && p < s.size() && s[p] == '#') std::cout << "句子结构正确" << std::endl; else std::cout << "分析失败" << std::endl; } return 0; }

补#这一步不是装饰。E()正常返回只能说明「从开头能推导出一个 E」,不能说明「整个串都被吃完了」。比如输入eaaaa,E()会成功匹配前三个字符(e+B→aC→ae中取a、e... 具体取决于分支),剩下两个a没人管,如果不检查s[p] == '#',会被误判为正确。加上这一句,等价于要求「读完整个串且读头停在哨兵上」,两个条件同时成立才判定成功。

5. 排查与避坑:五条把实验从「能跑」拖回「跑对」的记录

这两段代码我都按原样跑过,编译能过、程序能出结果,但结果对不对是另一回事。下面五条是调试过程中真实卡住的地方,每条按「现象 → 原因 → 解决」记,遇到类似症状可以直接对号入座。

5.1 词法侧的三条

第一条:结果里只有单词,看不到类别码。

现象是控制台刷出一长串CONST A VAR B C这样的原文,类别码要么没有,要么挤在行尾一串|21|5|里。原因是原代码用ste = ste + "|" + "22"这种方式拼接,然后stringstream反复回写,拼出来的字符串在语义上已经不是二元组了,而且只在遇到换行时才统一输出一次,中途根本看不出哪个单词对应哪个码。解决方法是把拼接串整体换成vector<pair<string,int>>,每切完一个 token 立刻push_back,最后统一遍历打印——顺序、配对关系都不会乱。

第二条:界符被"吃"在一起,:=" 变成:和=`。

现象是赋值语句D:=X;解析出来是(D,21) (:,25) (=,38) (X,21),语法分析阶段直接对不上。原因在while(isJ(ch))这个循环——只要下一个字符还在界符集合里,就一直吞,);、:=、..全会被合并成一个多字符单词,然后lookup查不到,直接跳过不输出,或者错误地落到别的分支。解决办法是按「先两位、后一位」重写界符分支:用substr(p,2)在双字符区间查一次,命中就前进两位,未命中再按单字符处理前进一位。

第三条:2A不报错,被切成两个合法 token。

现象是测试文件里塞了2BPROCEDURE这样的行,本意是测错误检测,结果程序输出(2,22) (B,21) (PROCEDURE,12),一路绿灯。原因是数字分支的while(isDigit(ch))读到B就停了,没有对后继字符做任何检查。解决办法是在数字扫完之后加一次前瞻:如果下一个字符是字母或下划线,说明这是个非法单词,打印位置并返回失败。这条改动只有三行,但它是实验报告里「具备一定检查错误的能力」这句话唯一的落地点。

顺带一提,换行没被算进空白类也是同一类问题:\n掉进else分支,会把累积的ste串打断成一行一行的碎片,看起来像是「每行末尾多输出了一段东西」。

5.2 语法侧的两条

第四条:递归子程序那段根本编译不过。

现象是g++直接报'While' was not declared in this scope。原因是 C++ 严格区分大小写,While(1)里的W是大写。这类错误在报告里很常见——手打代码时把关键字首字母大写了,IDE 又没开语法高亮。解决就是改成小写while(1);不过更建议顺手把无限循环换掉,用getline的返回值控制循环,输入结束自动退出,省得每次调试都要Ctrl+C。

第五条:长句子判错,短句子判对,且结果不稳定。

现象是输入eaeaa判定正确,输入稍长一点的edeaeaadaa就开始随机出错,有时候甚至程序直接崩。原因有两个:char a[10]装不下长串,scanf("%s", a)不做长度检查,写越界之后覆盖了相邻的栈变量,s这个读头索引就变成了随机值。解决办法是把定长数组换成std::string,读头用size_t,所有下标访问前统一判p < s.size()。改成这样之后,长度只受内存限制,edeaeaadaa这种十字符的串跑起来毫无压力。

6. 验收清单:三组用例压边界,顺手加个行列号

改完之后别急着交,用最小用例把两个分析器的边界压一遍。词法侧我一般跑这三组:

输入片段预期输出验证点
CONST A=10;(CONST,2)(A,21)(=,38)(10,22)(;,24)关键字回查、单字符界符
D:=X;(D,21)(:=,44)(X,21)(;,24)双字符界符优先匹配
2BPROCEDURE报错并指出列号数字后接字母的非法单词

语法侧对应三组:eaeaa判正确(走B→aC→ae最短路径)、edeaeaadaa判正确(走B→dEd嵌套)、eae判失败(E末尾缺A的推导串)。这三组覆盖了「最短合法」「递归嵌套」「长度不足」三种情形,跑通基本就能确认函数调用顺序没错。

最后加一个成本极低但很实用的小改造:给每个 token 带上行号。在scan里维护一个line变量,空白分支遇到\n就++line,push_back的时候把行号一起存进去。这样词法报错时能直接定位到源文件第几行,不用再拿列号去数。我在课程设计答辩时被问到「你怎么证明错误检测有效」,靠的就是这个——现场改一行测试文件制造2A,程序立刻报出「第 4 行第 3 列: 非法单词 2A」,比讲十句原理管用。

从那以后我每次交实验前都强制走一遍「最短合法 / 递归嵌套 / 长度不足」这三组用例,哪怕代码只是改了一个if条件。编译原理的实验代码有个特点:能跑和跑对之间隔着几十个字符的输入差异,靠肉眼读代码基本查不出来。这份报告里的两个实验骨架都是对的,把上面几处边界补齐,就是一份能直接交、也能拿来复习前端流水线的东西。希望帮到你。

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

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

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

立即咨询