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

发布时间:2026/10/27 2:12:38
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、procedure21标识符A、B、sum22无符号数10、323–33单/双字符界符,;:.()[]..--34–37算术运算符-*/38–47关系运算符、括号与哨兵:{}#这张表有两个设计取舍值得说。第一关键字用字符串数组而不是枚举匹配时直接strcmp省了哈希或查树的代码代价是每识别一个标识符要顺序比 21 次。实验规模下无所谓放到真实编译器里这就是性能热点常见做法是建 Trie 或完美哈希。第二双字符界符和单字符界符混在同一段区间匹配逻辑必须自己做「最长优先」否则:会被拆成:和两个 token。原代码在界符分支里用while(isJ(ch))连续吃字符本意是处理双字符但这个循环没有长度上限属于后面要讲的翻车点。还有一个边界细节ktt有 48 项合法下标是 0 到 47但原代码里所有遍历关键字的循环写的是j21界符循环写的是j47匹配运算符写的是j34;j38。j47意味着 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_castchar(c)); } fclose(fp); src.push_back(#); // 哨兵主循环靠它收尾 return src; }这里有两个参数级细节。第一fgetc的返回值是int正常字符返回 0–255结束返回EOF通常是 -1。如果写成char ch fgetc(fp)在char为有符号的平台上古0xFF会被解释成 -1循环提前结束源文件里出现某些扩展字符时就会神秘截断。第二哨兵#是附加在文件内容之后的如果源文件本身以#结尾主循环会提前停后面的内容全丢。稳妥做法是用\0当哨兵或者记录src.size()用下标边界判断不依赖特殊字符。读入之后原代码还有一行coutsrcendl;把整个源程序打出来。调试阶段有用能确认文件读对了正式跑的时候建议注释掉几千行的源文件刷屏会盖住后面的分析结果。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的推进时机。原代码在分支里写chsrc[pos]读的是「当前位置」然后自增主循环开头也写chsrc[pos]。只要每个分支都保证推进一次游标就不会卡住漏掉任何一条continue前的推进就会死循环。我踩过一次原因是isBlank分支里忘了写chsrc[pos]空格一出现程序就挂住加个打印才定位到。3.3 类别码拼接与输出格式的对齐原代码用stringstream往ste里拼类别码stringstream ss; ss ste | j; ss ste;这段等价于ste | to_string(j)绕一圈没有额外收益。stringstream在这里的隐藏成本是每次构造/析构都带一次缓冲区分配词法分析动辄几万个 token累积起来不可忽略。实验报告里这么写大概是为了练 IO 流生产代码里直接字符串拼接或者预分配vectorint更合适。输出格式也值得对一下。实验要求是「单词类别二元组序列」原代码实际输出的是「单词 一串竖线分隔的类别码」中间还夹着源程序回显和ktt表打印跟要求有偏差。我一般会在循环里把结果同时写进两个容器一个vectorpairstring,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 还是 bBB→dEd / B→aC{d} / {a}看首字符是 d 还是 aCC→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–4747 号是#。关键字循环写j21没问题界符循环写j47只到 4647 号被排除在外。同时主循环用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_castint(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表示游标应前进 2false表示按单字符处理、前进 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加长度限制然后在每轮循环开头s0最后统一在读取后补#。一次改完再跑能省掉反复编译的时间。5.5 现象程序跑起来只输出一个#没有任何 token。原因文件打开失败没判空fopen(pl0.txt,r)之后直接fgetc文件不存在时fp是空指针fgetc行为未定义常见结果是立刻返回EOFsrc里只剩补上的哨兵。这个坑在 IDE 里尤其隐蔽工作目录和源码目录经常不是同一个。解决fopen后立刻判空打印strerror(errno)或者至少打印尝试的路径让人一眼看出是路径问题而不是分析逻辑问题。6. 进阶验证用最小测试集给词法器和语法器做体检改完代码别急着交先构造一组最小测试用例每个用例只盯一个特性跑出来的结果和手推结果对上才算真的通过。下面这张表是我常用的起手式覆盖关键字匹配、非法单词、小数点歧义、双字符界符和语法分析的对错两条路径输入期望结果观察点CONST A10;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里某个关键字的索引改错一位看词法分析器是否立刻在对应用例上失败。如果改了还能通过说明那条用例根本没覆盖到这个关键字测试集是虚的。这招比逐个读代码快也比盲目加用例有效。从那以后我每次拿到这类实验代码都强制先跑一遍最小用例集再动任何一行逻辑——不然改一个分支、崩三个分支排错成本翻倍。这套流程对词法分析和语法分析都适用换成别的语言文法也一样只是把表里的输入串替换掉。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询