ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PL/0词法分析与递归子程序语法分析避坑指南

PL/0词法分析与递归子程序语法分析避坑指南

简介:这份编译原理实验报告面向东北大学秦皇岛分校计算机与通信工程学院学生及初学编译原理的读者,围绕PL/0语言词法分析程序的设计与实现展开,可用于课程实验参考、C++词法分析练习与实验报告撰写对照。压缩包共1个doc文件,约122KB,内容包含实验目的、实验环境、提交要求、完整源代码与输出说明,重点呈现字符分类、关键字匹配、二元组输出及“2A”等非法单词检测思路。报告以Windows PC和C++为实验环境,给出isLetter、isDigit、isP、isJ、isBlank等函数及主程序流程,便于读者理解从读取源程序到逐字符构建单词、匹配关键词表、输出类别并检查错误的完整过程。已有98人学习,适合需要完成词法分析实验、梳理PL/0词法规则或借鉴实验报告结构的学生参考。

1. 一份 PL/0 词法分析实验代码:从「能跑」到「讲得清」差哪几步

很多人拿到编译原理实验报告的第一反应是照着敲一遍、交差、忘掉。但这份材料里的两段代码——一个 PL/0 词法分析器、一个递归子程序语法分析器——恰好覆盖了编译前端最核心的两件事:把字符流切成单词,把单词流按文法走一遍推导。它不依赖任何第三方库,Windows 下用 C++ 就能编译,适合刚学完词法、语法章节、想动手验证「FIRST 集到底怎么用」的人。原代码能跑,但有几处隐蔽的坑:类别码表边界差一位、界符贪婪匹配吞字符、语法分析主函数大小写写错。下面按「先拆骨架、再跑主循环、最后做验证」的顺序走一遍,把这份实验报告里的代码变成能复现、能排错的东西。

2. 词法分析器的骨架:ktt 类别码表怎么排、字符判定函数怎么分工

词法分析器的本质是一个带状态的字符串扫描器:读一个字符,判断它属于哪一类,再决定往后吃几个字符组成一个单词。这份实现把「类别」编码成整数,用一张字符串数组ktt同时存关键字和界符的原文,靠下标当类别码。理解这张表的排布,后面所有匹配逻辑才看得懂。

2.1 从 PL/0 单词集反推 ktt 表:21 个关键字与 25 个界符的排布

PL/0 的单词分五类:关键字、标识符、无符号数、运算符、界符。原代码的ktt[48][20]把前三类和后两类混在一张表里,索引就是类别码。拆开看是这样:

索引区间含义示例
0–20关键字begin、end、if、while、procedure
21标识符A、B、sum
22无符号数10、3
23–33单/双字符界符,;:.()[]..++--
34–37算术运算符+-*/
38–47关系运算符、括号与哨兵=<><><=>=:={}#

这张表有两个设计取舍值得说。第一,关键字用字符串数组而不是枚举,匹配时直接strcmp,省了哈希或查树的代码,代价是每识别一个标识符要顺序比 21 次。实验规模下无所谓,放到真实编译器里这就是性能热点,常见做法是建 Trie 或完美哈希。第二,双字符界符和单字符界符混在同一段区间,匹配逻辑必须自己做「最长优先」,否则:=会被拆成:和=两个 token。原代码在界符分支里用while(isJ(ch))连续吃字符,本意是处理双字符,但这个循环没有长度上限,属于后面要讲的翻车点。

还有一个边界细节:ktt有 48 项,合法下标是 0 到 47,但原代码里所有遍历关键字的循环写的是j<21,界符循环写的是j<47,匹配运算符写的是j=34;j<38。j<47意味着 47 号元素#永远参与不了匹配。这不是笔误就是设计上把#只当哨兵用,但表里又留着它,读代码的人很容易在这里卡住。我一般会在表定义旁边加一行注释,写明「47 号为哨兵占位,不参与 token 匹配」,省得后来人猜。

2.2 isLetter/isDigit/isP/isJ 的分工:判定顺序决定切词对错

五个判定函数各管一段字符集:

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冲突;但.同时出现在界符集和数字的小数点判断里,:和=组合成:=,<、>组合成<=、>=、<>。如果先判界符再判数字,3.14会在3之后被.截断,得到3、.、14三个 token,语义全错。

更麻烦的是isJ的集合里包含#,而#又是主循环的结束哨兵。原代码在while(ch!='#')处先拦了一道,所以#不会进界符分支;但如果有人把哨兵改成\0或别的字符,#就会突然变成合法 token 并被isJ吃掉。这类「一个字符承担两种角色」的设计是血泪经验里最常见的返工来源,改的时候要连表带循环一起动。

我通常会把判定函数改成一张静态的字符集表加一个查表函数,把「是不是运算符」「是不是界符首字符」拆成两个位标记,顺序判断变成按位与。代码长一点,但加新符号时只改表不改逻辑。实验报告里的写法胜在直白,适合第一次写词法分析器的人对照文法看,但别直接搬进要维护的项目。

3. 主循环切词:从 pl0.txt 读入到(单词,类别)二元组输出

骨架搭好后,真正干活的是主循环。它要完成三件事:把源文件读成一个大字符串、用游标pos逐个字符推进、每识别出一个单词就往结果串里追加类别码。原代码把这三件事揉在一个main里,读起来费劲,拆成三段看就清楚了。

3.1 读文件:fgetc 循环、哨兵 # 与字符串拼接

原代码用fgetc逐字符读文件,拼进string src,末尾手工补一个#当结束标记:

#include <cstdio> #include <iostream> #include <string> using namespace std; string readSource(const char* path) { FILE* fp = fopen(path, "r"); if (fp == nullptr) { // 必须判空,否则后面读到空串 cerr << "open " << path << " failed" << endl; return ""; } string src; int c; // 用 int 接,char 会截断 EOF while ((c = fgetc(fp)) != EOF) { src.push_back(static_cast<char>(c)); } fclose(fp); src.push_back('#'); // 哨兵,主循环靠它收尾 return src; }

这里有两个参数级细节。第一,fgetc的返回值是int,正常字符返回 0–255,结束返回EOF(通常是 -1)。如果写成char ch = fgetc(fp),在char为有符号的平台上古,0xFF会被解释成 -1,循环提前结束,源文件里出现某些扩展字符时就会神秘截断。第二,哨兵#是附加在文件内容之后的,如果源文件本身以#结尾,主循环会提前停,后面的内容全丢。稳妥做法是用'\0'当哨兵,或者记录src.size()用下标边界判断,不依赖特殊字符。

读入之后原代码还有一行cout<<src<<endl;把整个源程序打出来。调试阶段有用,能确认文件读对了;正式跑的时候建议注释掉,几千行的源文件刷屏会盖住后面的分析结果。

3.2 四条分支的切分:数字、标识符、运算符、界符

主循环的骨架是一个「读当前字符 → 分派到对应分支 → 分支内推进游标 → continue」的状态机:

int pos = 0; char ch = src[pos++]; string codes; // 类别码序列,形如 "|2|21" while (ch != '#') { if (isDigit(ch)) { // 分支一:无符号数 string num; while (isDigit(ch) || ch == '.') { num.push_back(ch); ch = src[pos++]; } codes += "|22"; cout << "(" << num << ",22) "; continue; } if (isLetter(ch)) { // 分支二:标识符或关键字 string id; while (isLetter(ch) || isDigit(ch)) { id.push_back(ch); ch = src[pos++]; } int j = 0; for (; j < 21; ++j) { if (id == ktt[j]) break; // 命中关键字,j 就是类别码 } if (j == 21) codes += "|21"; // 没命中,按标识符处理 else codes += "|" + to_string(j); cout << "(" << id << "," << (j == 21 ? 21 : j) << ") "; continue; } if (isP(ch)) { // 分支三:算术运算符,单字符 string op(1, ch); int j = 34; for (; j < 38; ++j) if (op == ktt[j]) break; codes += "|" + to_string(j); cout << "(" << op << "," << j << ") "; ch = src[pos++]; continue; } if (isJ(ch)) { // 分支四:界符,可能双字符 string jf; while (isJ(ch)) { // 问题在这:没有长度上限 jf.push_back(ch); ch = src[pos++]; } int j = 23; for (; j < 47; ++j) if (jf == ktt[j]) break; if (j < 47) codes += "|" + to_string(j); else cout << "[lex error] " << jf << " "; cout << "(" << jf << "," << j << ") "; continue; } if (isBlank(ch)) { // 空白直接跳过 ch = src[pos++]; continue; } ch = src[pos++]; // 未识别字符,跳过并计数 }

四条分支的共同约定是:进入分支时ch是当前未消费字符,离开分支前必须把ch推进到下一个未消费位置,然后continue回到循环条件。数字分支和标识符分支用内层while连续吃同类字符,运算符分支只吃一个,界符分支理论上该吃一到两个。

参数上最需要注意的是pos的推进时机。原代码在分支里写ch=src[pos++],读的是「当前位置」,然后自增;主循环开头也写ch=src[pos++]。只要每个分支都保证推进一次,游标就不会卡住;漏掉任何一条continue前的推进,就会死循环。我踩过一次,原因是isBlank分支里忘了写ch=src[pos++],空格一出现程序就挂住,加个打印才定位到。

3.3 类别码拼接与输出格式的对齐

原代码用stringstream往ste里拼类别码:

stringstream ss; ss << ste << "|" << j; ss >> ste;

这段等价于ste += "|" + to_string(j),绕一圈没有额外收益。stringstream在这里的隐藏成本是每次构造/析构都带一次缓冲区分配,词法分析动辄几万个 token,累积起来不可忽略。实验报告里这么写大概是为了练 IO 流,生产代码里直接字符串拼接或者预分配vector<int>更合适。

输出格式也值得对一下。实验要求是「(单词,类别)二元组序列」,原代码实际输出的是「单词 + 一串竖线分隔的类别码」,中间还夹着源程序回显和ktt表打印,跟要求有偏差。我一般会在循环里把结果同时写进两个容器:一个vector<pair<string,int>>存结构化结果,一个string存便于肉眼看的文本。调试看文本,后续语法分析直接吃结构化结果,不用再从字符串里反解析。这一步多写十行,能省掉后面接语法分析器时的格式转换。

4. 递归子程序分析器:把文法 G[E] 的四条产生式翻成四个函数

词法分析输出的是扁平 token 流,语法分析要判断这串 token 能不能由文法推导出来。这份实验给的是一个只有四个非终结符的小文法,用递归下降写非常合适——每个非终结符对应一个函数,函数体就是产生式右部的展开。

4.1 文法 G[E] 的产生式与 FIRST 集:为什么这个文法能直接递归下降

先把文法抄清楚:

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

四个非终结符,终结符是a b c d e。递归下降能不能不用回溯,取决于每个非终结符的候选产生式 FIRST 集是否两两不交:

非终结符产生式FIRST 集选择依据
EE→eBaA{e}唯一候选,无需判断
AA→a / A→bAcB{a} / {b}看首字符是 a 还是 b
BB→dEd / B→aC{d} / {a}看首字符是 d 还是 a
CC→e / C→dC{e} / {d}看首字符是 e 还是 d

四行里没有一行出现两个候选 FIRST 集相交,所以这是 LL(1) 文法,递归下降时只要看当前输入字符就能唯一确定走哪条产生式,不需要回溯。这也是这份实验选这个文法的原因:代码短,逻辑清晰,能把「FIRST 集决定分支」这件事直接映射成if-else。

顺带说一个容易忽略的点:E 的产生式里B后面跟终结符a,而 B 自己也能以a开头(B→aC)。这不构成冲突,因为 E 只有一个候选,不存在「选哪条」的问题。判断 LL(1) 只看同一非终结符的多个候选之间是否相交,不看跨非终结符的跟随关系。新手常在这里过度推导,把 FOLLOW 集也拉进来,反而把自己绕晕。

4.2 E/A/B/C 四个函数的写法与「匹配即前进」约定

递归下降的每个函数遵循同一个约定:进入时游标s指向待匹配字符,匹配成功则s前进,匹配失败则原地不动、交给上层判断。四个函数直译产生式:

#include <cstdio> #include <cstring> char a[256]; int s = 0; void E(); void A(); void B(); void C(); void E() { if (a[s] == 'e') { // E → e B a A ++s; B(); if (a[s] == 'a') ++s; // 终结符 a,匹配即前进 A(); } } void A() { if (a[s] == 'b') { // A → b A c B ++s; A(); if (a[s] == 'c') ++s; B(); } else if (a[s] == 'a') { // A → a ++s; } } void B() { if (a[s] == 'd') { // B → d E d ++s; E(); if (a[s] == 'd') ++s; } else if (a[s] == 'a') { // B → a C ++s; C(); } } void C() { if (a[s] == 'd') { // C → d C ++s; C(); } else if (a[s] == 'e') { // C → e ++s; } }

参数说明只有两个:全局字符数组a存输入串,全局游标s是当前匹配位置。函数没有返回值,靠s的最终位置和当前字符判断成败。这种写法在实验里够用,但有两个结构性缺陷。

第一,没有错误处理。E()里如果a[s]不是e,函数什么都不做直接返回,上层完全不知道这里失败了。整棵树走完,只能靠主函数检查a[s]=='#'来判断,错误位置信息全丢了。真实场景里我一般给每个函数加一个bool返回值,匹配失败时返回false并带上出错位置,主函数打印「期望 e,实际读到 x,位置 n」。

第二,递归深度等于输入串长度。A→bAcB和C→dC都是直接左递归以外的形式,每读一个字符就多一层栈帧。输入几百个字符没问题,几万个字符就可能栈溢出。这是递归下降的固有代价,换表驱动 LL(1) 分析或者加深度上限可以缓解。

4.3 输入缓冲与结束判定:a[s]=='#' 这个约定怎么落地

原代码的主函数是这样的:

int main() { While(1) { // 大写 W,编译不过 printf("请输入算数表达式(以#键结束):"); scanf("%s", a); // 无长度限制 E(); if (a[s] == '#') printf("句子结构正确\n"); else printf("分析失败\n"); } return 0; }

这段要改的地方比语法分析器本身还多。While不是 C++ 关键字,编译器直接报错;scanf("%s", a)不限长度,输入超过 10 个字符就越界写;全局s不重置,第二轮输入从上一轮的失败位置继续,永远判不对;输入不带#时,a[s]=='#'会读到数组末尾之外。

修正版把这几件事一次做掉:

int main() { while (true) { printf("请输入符号串(以#结束): "); if (scanf("%255s", a) != 1) break; // 限长,防止越界 s = 0; // 每轮必须重置游标 size_t len = strlen(a); if (len == 0 || a[len - 1] != '#') { strcat(a, "#"); // 补哨兵,保证结束判断有效 } E(); if (a[s] == '#') { printf("句子结构正确\n"); } else { printf("分析失败,停在位置 %d,字符 %c\n", s, a[s]); } } return 0; }

%255s配合char a[256]留一个字节给结束符,strcat追加#时刚好不溢出。错误分支里把位置和字符打出来,是递归下降最省事的排错手段——不用调试器,看输出就知道卡在哪个终结符上。参数上唯一的取舍是a的长度,实验用例都是个位数长度,256 绰绰有余;如果真要把输入拉到几千字符,就得改成动态分配,同时考虑前面说的递归深度问题。

5. 避坑排查:这份实验代码里五处一跑就翻车的地方

代码能编译不等于能正确跑。下面五条是按「现象 → 原因 → 解决」整理的实际踩坑记录,每条都能在原代码里指出具体行。

5.1 现象:#在类别码表里永远匹配不到。原因:循环边界差一位

ktt数组 48 项,下标 0–47,47 号是#。关键字循环写j<21没问题,界符循环写j<47只到 46,47 号被排除在外。同时主循环用while(ch!='#')又把#当结束符,导致这个表项从设计上就是死代码。如果哪天有人把哨兵换成别的字符,#会进界符分支但匹配失败,输出一个没有类别码的 token。解决:要么从表里删掉 47 号并把数组长度改成 47,要么在注释里写明「47 号为哨兵占位」,主循环用\0或长度边界判断,把两种角色分开。

5.2 现象:输入();只得到一个 token,而不是(、)、;三个。原因:界符分支贪婪匹配

while(isJ(ch))不区分单字符和双字符界符,(、)、;都在isJ集合里,连续出现时会被一口气吃成();。这个字符串在ktt里查不到,原代码没有 else 分支,于是输出一个没有类别码的 token,后续语法分析直接对不上。解决:双字符优先匹配,每次先试两字符,命中就前进两格,否则退一格按单字符处理:

static const char* kTwoChar[] = {"..","++","--","<>","<=",">=",":="}; bool matchTwoChar(const string& src, int pos, string& out) { if (pos + 1 >= static_cast<int>(src.size())) return false; string two = src.substr(pos, 2); for (const char* t : kTwoChar) { if (two == t) { out = two; return true; } } return false; }

参数src是源串,pos是当前游标,out带回命中的双字符界符;返回true表示游标应前进 2,false表示按单字符处理、前进 1。

5.3 现象:[1..2]被当成一个数字 token。原因:数字分支把.无条件算进数字

while(isDigit(ch) || ch == '.')对小数点没有任何约束,1..2会被完整读成一个串,.5、1.也能通过。词法上1..2应该是1、..、2三个 token,..是数组下标范围符。解决:读到.时预读下一位,下一位不是数字就回退,把这个.留给界符分支:

while (isDigit(ch)) { num.push_back(ch); ch = src[pos++]; if (ch == '.' && isDigit(src[pos])) { // 预读确认是小数点 num.push_back(ch); ch = src[pos++]; } }

这里pos指向的是ch的下一位,预读判断的就是「小数点后是否跟数字」,跟了才消费。

5.4 现象:语法分析器编译报错,或输入长一点就崩,或第二次输入永远失败。原因:四处硬伤叠加

While大写导致编译不过;char a[10]装不下超过 9 个字符的串;全局s不重置,第二轮从上一轮位置继续;输入不带#时结束判断越界。四个问题互相掩盖,改一个可能暴露下一个。解决顺序建议:先改while让它能编译,再把缓冲扩到 256 并给scanf加长度限制,然后在每轮循环开头s=0,最后统一在读取后补#。一次改完再跑,能省掉反复编译的时间。

5.5 现象:程序跑起来只输出一个#,没有任何 token。原因:文件打开失败没判空

fopen("pl0.txt","r")之后直接fgetc,文件不存在时fp是空指针,fgetc行为未定义,常见结果是立刻返回EOF,src里只剩补上的哨兵。这个坑在 IDE 里尤其隐蔽,工作目录和源码目录经常不是同一个。解决:fopen后立刻判空,打印strerror(errno)或者至少打印尝试的路径,让人一眼看出是路径问题而不是分析逻辑问题。

6. 进阶验证:用最小测试集给词法器和语法器做体检

改完代码别急着交,先构造一组最小测试用例,每个用例只盯一个特性,跑出来的结果和手推结果对上,才算真的通过。下面这张表是我常用的起手式,覆盖关键字匹配、非法单词、小数点歧义、双字符界符和语法分析的对错两条路径:

输入期望结果观察点
CONST A=10;CONST/2、A/21、=/38、10/22、;/24关键字是否区分大小写
2A数字 2 后跟标识符 A,或报词法错误非法单词检测能力
[1..2][、1、..、2、]五个 token小数点与范围符的切分
:=单个 token,类别码 44双字符界符最长匹配
eaeaa#句子结构正确E→eBaA 的基本路径
eaa#分析失败,位置 2 附近错误定位是否可用

语法分析那两条用例的推导过程值得手推一遍再上机。eaeaa对应E ⇒ e B a A,其中B ⇒ a C、C ⇒ e,代回去得到e a e a A,最后A ⇒ a,整串正好五个字符。eaa少了一个e,在B匹配a之后C期望e却读到a,失败位置应该在第三个字符。手推过一遍,程序输出位置对不上时你就知道是自己代码错了,而不是用例设计错了。

我一般还会做一个「反向验证」:故意把ktt里某个关键字的索引改错一位,看词法分析器是否立刻在对应用例上失败。如果改了还能通过,说明那条用例根本没覆盖到这个关键字,测试集是虚的。这招比逐个读代码快,也比盲目加用例有效。

从那以后我每次拿到这类实验代码,都强制先跑一遍最小用例集,再动任何一行逻辑——不然改一个分支、崩三个分支,排错成本翻倍。这套流程对词法分析和语法分析都适用,换成别的语言文法也一样,只是把表里的输入串替换掉。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进