PL/0词法分析与递归子程序语法分析实验代码拆解及避坑

发布时间:2026/11/4 20:17:52
PL/0词法分析与递归子程序语法分析实验代码拆解及避坑 简介东北大学秦皇岛分校计算机与通信工程学院编译原理课程实验报告面向正在学习词法分析、需要完成 PL/0 语言词法分析程序设计与实现的学生。内容围绕词法分析器的设计与实现展开涵盖读取源程序字符串、按字母数字运算符和界符分类字符、匹配关键字表、输出单词类别二元组序列以及识别“2A”等非法单词的错误检测并附 Windows PC 环境下使用 C 编写的实验代码、函数说明与处理流程。压缩包内共 1 个 doc 文件约 122KB集中呈现实验目的、实验内容、程序源码和结果展示便于直接查阅和对照。已有 102 人学习适合用于课程实验复盘、理解 PL/0 词法分析流程以及复习单词构建、运算符与界符判断、错误处理等关键环节。1. 两份程序、两条链路这份编译原理实验报告到底能跑出什么很多人找编译原理实验报告是想拿一份能直接编译通过的模板交差打开却发现是两段风格完全不同、还带着明显 bug 的 C 源码扭头就走。这份资源恰好相反它的价值不在排版而在两段能独立编译运行的程序——一个 PL/0 词法分析器把源程序切成单词类别码二元组一个递归子程序语法分析器用 E、A、B、C 四个函数实现四个非终结符判断输入串是不是给定文法的句子。前者让你看清最长匹配和关键字表怎么配合后者让你看清产生式怎么机械地翻译成函数。适合正在上编译原理课、既要交实验又想讲清原理的人也适合工作几年后回头补编译器前端基本功的开发者。2. 资源构成与技术选型两个独立程序加一张 48 项关键字表这份材料不是一个大工程而是两次实验的拼装实验二做词法分析实验四做语法分析彼此不共享代码各自一个main各自一套输入方式。好处是拆得干净坏处是如果你想把它串成一条完整的前端流水线中间那层适配要自己写。先把家底摸清楚后面改造才知道从哪下手。2.1 两份实验的分工一个切词一个判句从功能上看两个程序处理的是编译前端最前面两段。词法分析器的输入是待分析的源程序字符串输出是单词串语法分析器的输入是任意符号串输出是「结构正确 / 分析失败」的结论。维度词法分析程序递归子程序分析程序输入源pl0.txt文件控制台scanf输入输入内容PL/0 风格源程序由 e/a/b/c/d 组成的符号串输出单词 类别码二元组句子结构是否正确核心数据结构ktt[48][20]关键字表 全局游标pos全局字符数组a[10] 全局游标s分析方法最长匹配 查表递归下降按 FIRST 集分支语言/平台CWindows 控制台CWindows 控制台两边都用了「全局游标 逐字符推进」这套最朴素的写法。词法那边游标叫pos语法那边叫s本质是同一个东西一个指向当前读到哪里了的整数。这种写法在课程实验里够用但也埋了两个共同的雷——游标只能前进不能回退出错之后没有任何定位信息。后面避坑那章会专门算这笔账。从技术选型上说2012 年前后的课程环境基本就是 Windows Visual C 6.0 或者 Dev-C所以代码里能看到#include iostream、using namespace std、FILE*和fgetc混着用的痕迹。这在今天用 g 或者 clang 一样能编过只是会冒出一堆「不建议使用」的告警不影响跑。2.2 关键字表 ktt 的四段分区整个词法分析器的骨架就是一张 48 项的二维字符数组ktt。这张表的排列顺序不是随便写的它严格按类别码从小到大铺开每一段对应一种 token 类型后面所有分支的循环边界都从这张表里来类别码区间内容数量含义0 ~ 20and、begin、const … write21PL/0 保留字21标识符1用户自定义名字22无符号数1整数或小数常量23 ~ 33,;:.()[]..--11单/双字符界符34 ~ 37-*/4算术运算符38 ~ 47:{}#10关系/赋值/注释符这张表的设计思路值得学把「类别」和「码值」压成同一个维度查表时命中的下标直接就是类别码省掉一次映射。代价是表的顺序不能乱动插入一个关键字就得整体挪位所有硬编码的循环边界j21、j23;j47、j34;j38都要跟着改。我在实际项目里更倾向于拆成enum TokenType加一张unordered_mapstring, TokenType但作为理解原理的教具这张平铺的表反而更直观——你能一眼看出编译器是怎么把字符串变成数字的。有个小细节打印表的那行循环写的是for(int j0;j47;j)而数组有 48 项最后那个#永远打不出来。不影响分析逻辑但对着输出核对类别码时会疑惑一下。2.3 环境与文件依赖Windows 控制台加一个 pl0.txt词法分析器对文件路径是硬依赖的。源码里直接写死fpfopen(pl0.txt,r)用的是相对路径所以pl0.txt必须和可执行文件在同一目录或者和工程文件在同一目录取决于你从哪启动。如果fopen返回NULL代码没有做任何判空下一句fgetc(fp)就会直接崩这在实验课上是高频翻车点。测试用的源文件内容长这样CONST A10; VAR B,C; 2BPROCEDURE P; VAR D; 2E PROCEDURE Q; VAR X; BEGIN READ(X);D:X;WHILE X0 DO CALL P; END; BEGIN WRITE(D); CALL Q; END; BEGIN CALL P; END.里面故意塞了2B和2E两个非法串就是想看分析器能不能报错。这一点很关键——它决定了这个程序是「能跑」还是「能检查」。遗憾的是按现在的代码逻辑2B会被拆成数字2码 22和标识符B码 21两个合法 token一个错都不报跟实验要求里写的「发现 2A 这类不能作为单词的字符串」有差距。具体原因放在第 5 章展开。语法分析器那边反倒没有文件依赖纯控制台交互scanf(%s, a)读一个串就分析一个串。3. 词法分析器拆解从字符流到单词类别二元组的完整链路这段代码一共不到 150 行但把词法分析的几个核心动作都摆出来了字符分类、最长匹配、查表定类、输出二元组。想看懂它别从头顺着读先抓住主循环那个while(ch ! #)剩下的都是它内部的分支。3.1 五个字符判定函数和主循环骨架开头那五个bool函数是整个程序的判断基础每个只干一件事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; }这五个函数的参数都只有一个char返回布尔值作用是给主循环做「这个字符该进哪个分支」的分流。注意isP和isJ有语义重叠的风险只在isP里只在isJ里但isJ不收所以这个双字符界符永远走不到界符分支——它会被isP先截成两个单独的。表里明明留了码 32和--码 33实际一条都用不上这是典型的「表设计超前于分支实现」。主循环的结构是这样FILE *fp fopen(pl0.txt, r); ch0 fgetc(fp); while (ch0 ! EOF) { // 第一步把整个文件读进字符串 src src ch0; ch0 fgetc(fp); } src #; // 末尾补哨兵作为结束标志 ch src[pos]; // 初始化当前字符 while (ch ! #) { // 第二步逐字符扫描直到哨兵 // 数字 / 标识符 / 运算符 / 界符 / 空白 五个分支 }这里的参数含义要说清src是完整源程序字符串pos是全局读指针ch是当前待处理字符。每进一个分支ch会被更新成下一个字符pos同步前移。用哨兵法#结束是课程实验的惯用做法好处是不用处理pos src.length()的边界判断坏处是源程序里不能合法出现#——巧的是ktt表里#本身就占了一个类别码 47语义上打架了。3.2 数字与标识符最长匹配的落地写法词法分析的核心思想是「最长匹配」读到1不能立刻断定它是常量得继续往后看2和.直到遇到第一个不属于常量的字符才收手。数字分支就是这么写的if (isDigit(ch)) { // 首字符是数字进入常量分支 int i 0; while (isDigit(ch) || ch .) { // 贪心地吃掉所有数字和小数点 str[i] ch; // 暂存到局部缓冲 ch src[pos]; // 游标前移读下一个 } str[i] \0; // 手动补字符串结束符 ste ste | 22; // 类别码 22 无符号数 cout str; // 输出单词本体 continue; // ch 已经是新字符直接下一轮 }str是长度 20 的局部数组i是写入位置循环结束后手动补\0是 C 风格字符串的标准动作。continue的作用是跳过后面的分支判断因为此时ch已经指向第一个不属于常量的字符正好是下一轮的起点。标识符分支的结构一样只是判断条件换成isLetter(ch) || isDigit(ch)允许字母开头、后续接数字else if (isLetter(ch)) { // 首字符是字母 int i 0, j; while (isLetter(ch) || isDigit(ch)) { // 字母数字混合最长匹配 str[i] ch; ch src[pos]; } str[i] \0; for (j 0; j 21; j) { // 只在保留字区 0~20 里查 if (strcmp(str, ktt[j]) 0) { ste | to_string(j); // 命中保留字用下标当类别码 break; } } if (j 21) ste |21; // 全没命中就是标识符 cout str; continue; }这段的关键是循环变量j在循环外还能用。循环正常走完j 21说明 21 个保留字都没匹配上中途break出来j就是命中的关键字下标。这个小技巧在 C 里很常见但可读性一般二次开发时建议换成函数加返回值。ste是最终输出的二元组累加器每识别一个 token 就追加一段|类别码换行时才整体打印。所以最终看到的输出形如CONST|2 A|21 |38 10|22单词和码混在一行靠|分隔。这套拼接用stringstream也能做代码里有几处绕了一圈ss ste; ss ste;其实是多余的直接to_string更清楚。3.3 关键字匹配与类别码拼接保留字识别本质是「字符串相等 查表」没什么算法含量但有一个坑必须提strcmp是区分大小写的而 PL/0 的保留字全小写。所以源程序里写CONST匹配的是ktt表里第 2 项const吗不是。strcmp(CONST, const)返回非零CONST会被当成标识符码 21。测试文件恰好就是大写写的CONST A10;所以跑起来你会看到CONST输出成 21 而不是 2。这不是 bug是 PL/0 语言本身规定关键字小写测试数据写大写属于「故意制造差异」。但如果你的实验要求里说「关键字不区分大小写」那就得在strcmp前先做统一大小写转换常见做法是std::transform(str.begin(), str.end(), str.begin(), ::tolower)。另外类别码拼接的方式直接影响输出可读性。现在的输出把单词和码挤在一行中间只有|遇到:这种双字符符号时会出现:|44肉眼扫一遍还行想拿去做后续语法分析的输入就不行了。我在自己项目里会改成每行一个(token, type)方便管道传给下一个阶段。3.4 运算符与界符分支的边界运算符分支最短因为它只处理单字符else if (isP(ch)) { // 只可能是 - * / 之一 int i 0, j; str[i] ch; str[i] \0; // 直接当单字符符号处理 for (j 34; j 38; j) { // 在 34~37 里查表 if (strcmp(str, ktt[j]) 0) { ste | to_string(j); break; } } cout str; ch src[pos]; // 手动推进游标 continue; }问题在于它完全不看后一个字符。里的不走这里走的是界符分支但里的第一个会走这里于是被拆成两个单独的。同理--也被拆成两个-。界符分支反过来是「贪心过头」else if (isJ(ch)) { int i 0, j; while (isJ(ch)) { // 问题所在只要还是界符就一直吃 str[i] ch; ch src[pos]; } str[i] \0; for (j 23; j 47; j) { // 在 23~46 里查表 if (strcmp(str, ktt[j]) 0) { ste | to_string(j); break; } } cout str; // 无论匹配成功与否都输出 continue; }因为while(isJ(ch))是无条件连续吃遇到)(这种相邻的两个合法界符会被拼成)(一个串然后 23 到 46 里找不到匹配for循环走完没有else分支什么码都不追加这个符号就这么静默消失了。测试源文件里有READ(X);X)里的)后面跟;);会被拼成);一样匹配失败。这是这段代码最隐蔽的一个坑——它不报错只是少输出你不逐字核对根本发现不了。正确的写法应该是「先看两个字符能不能组成双字符界符不能就退化成单字符」。双字符集合是固定的..、、、、:判一次就行。4. 递归子程序语法分析G[E] 文法翻译成 E/A/B/C 四个函数第二个程序换了个完全不同的思路不再逐字符切分而是直接把文法产生式写成函数。给定文法G[E]: E → eBaA A → a | bAcB B → dEd | aC C → e | dC四个非终结符对应四个函数每个函数负责「识别一个非终结符能推导出的串」。这就是递归下降分析法最朴素的形态。4.1 产生式到函数的机械映射翻译规则只有三条遇到终结符就比对并前移游标遇到非终结符就调用对应函数遇到|就在函数里用if-else分支。E 的产生式只有一个右部所以函数里只有一条路char a[10]; // 输入缓冲区只能装 9 个字符加结束符 int s 0; // 全局游标指向当前待匹配字符 void E() { if (a[s] e) { // 匹配终结符 e s; // 游标前移 B(); // 递归进入非终结符 B if (a[s] a) { // 匹配终结符 a s; A(); // 递归进入非终结符 A } } }参数说明a是全局输入串s是全局下标。两个都是全局变量函数没有参数也没有返回值靠副作用推进状态。这种写法在实验里省事但完全没法做错误定位——出错时你只知道「失败了」不知道失败在第几个字符。B 有两个右部所以是if-else ifvoid B() { if (a[s] d) { // 第一个右部 B → dEd s; E(); if (a[s] d) s; // 尾部终结符 d } else if (a[s] a) { // 第二个右部 B → aC s; C(); } }分支依据是当前字符看到d走第一条看到a走第二条。这就是 FIRST 集在代码里的直接体现——FIRST(dEd) {d}FIRST(aC) {a}两个集合不相交所以看一眼就能决定走哪条不需要回溯。C 是右递归形式写成循环和递归都行这里用的是递归void C() { if (a[s] d) { // C → dC s; C(); // 递归吃掉后续所有的 d } else if (a[s] e) { // C → e s; } }A 稍微复杂一点因为bAcB的第一个符号是b第二个右部是avoid A() { if (a[s] b) { // A → bAcB s; A(); // 递归可能嵌套多层 b...c if (a[s] c) { s; B(); } } else if (a[s] a) { // A → a s; } }四个函数加起来不到 40 行但完整覆盖了四条产生式。这种「一条产生式一个分支」的写法就是编译原理课上讲的递归子程序法的标准形态。手写解析器时如果文法不大这套方法比查表驱动好调试得多。4.2 全局游标 s 与 FIRST 集判断s这个全局游标是整段代码的命门。它只增不减所有函数共享任何一步匹配失败都不会回退。这意味着这套分析器只对 LL(1) 文法有效——每一步都能靠当前字符唯一确定走哪条产生式。给定的 G[E] 恰好满足这个条件非终结符FIRST 集是否有交集E{e}单元素无冲突A{a, b}两元素不相交B{d, a}两元素不相交C{e, d}两元素不相交如果文法里出现A → aB | aC这种两个右部 FIRST 集相交的情况这套「看一眼就分支」的写法立刻失效必须提取左公因子或者改成回溯式递归下降。所以判断一个文法能不能用这种写法第一步就是算 FIRST 集。游标只前进不后退的另一个后果是一旦在深层递归里匹配失败整个分析过程就废了没法像回溯法那样换个分支重试。代码里所有的if都没有配else报错匹配不上就静默返回让上层继续往下走最后靠主函数里的一次判断兜底。这也是课程实验的典型做法——能判对错但说不清错在哪。4.3 主程序输入与结果判定主函数的意图是用一个死循环反复读串、反复分析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)遇到空白字符就停而且不会把#读进数组。所以提示语里说的「以 # 键结束」在实际运行时根本不会发生——用户敲eaeaa#a里存的是eaeaas走到 5 时a[5]是\0而不是#判断恒为假永远输出「分析失败」。这个 bug 让整个程序看起来能跑实际一次都没判对过。修法有两种要么用fgets读整行再去掉换行符、手动补#要么干脆把判断改成a[s] \0。第三char a[10]只有 10 字节。scanf不做长度检查输入超过 9 个字符就越界写栈上的其他变量会被覆盖。测试串短于 9 个字符时看不出来一旦输入长串就是玄学崩溃。拿一个最小合法串验证一下推导过程。取eaeaaE ⇒ e B a A ⇒ e a C a A ⇒ e a e a A ⇒ e a e a a对应代码执行路径E()见es到 1调B()B()见as到 2调C()C()见es到 3回到E()a[3]是as到 4调A()A()见as到 5。此时a[5]应该是结束符判断通过。把判断条件从#换成\0这个串立刻就能跑出「句子结构正确」。这也是我建议先做的最小验证用例。5. 避坑与排查这段实验代码里最容易翻车的五处代码短不代表坑少。这两份程序有几个问题属于「能编译、能运行、结果悄悄错」不逐字核对输出根本发现不了。下面五条是我实际编译调试时踩到的按现象、原因、解决拆开写你对着改能省不少时间。5.1 词法侧2A 不报错、界符被吞、匹配失败静默丢弃第一条非法单词2A被拆成两个合法 token一个错都不报。现象是输入含2A的源文件输出里出现2的类别 22 和A的类别 21没有任何错误提示。原因是数字分支的循环条件只认isDigit(ch) || ch .读到A就停然后continue把控制权交回主循环此时ch正好是A下一轮走标识符分支顺理成章地把它当成一个新单词。整个过程没有任何「单词边界校验」。解决办法是在数字扫描结束后加一次后继检查如果ch是字母或下划线说明这是个非法数值常量直接报(行号, 列号, 非法数值常量)并跳到下一个分隔符。同理标识符扫描结束后如果紧跟数字以外的非法字符也该报错。第二条连续的界符被贪心拼成一个串然后静默消失。现象是源文件里的);、)(、]这类相邻符号在输出里完全找不到。原因是界符分支写的是while(isJ(ch))只要当前字符还属于界符集合就继续吃把两个独立符号拼成一个两字符串再去 23 到 46 的范围里查表当然查不到而for循环没有配else分支匹配失败就什么都不输出。这个坑最阴因为它不抛异常、不打印错误只是少几个 token。修法是改成「先窥探两个字符」读当前位置和下一个位置先去双字符表里查..:命中就吃两个字符没命中就退化成单字符处理。这样):会被正确拆成)和:。第三条关键字匹配失败时没有任何兜底输出。现象是某些符号在最终结果里凭空蒸发。原因和上一条同源三个查表分支标识符、运算符、界符都只在strcmp 0时追加类别码匹配失败的分支没有else。标识符分支靠if (j 21)兜住了运算符分支因为集合封闭只可能是那四个基本不会漏界符分支则是重灾区。修法很简单查表循环结束后统一加一句「未匹配则报非法符号」把str和位置打出来。5.2 语法侧数组越界、#永远读不到、无回溯误判第四条a[s] #这个判断永远为假程序从来没判对过一次。现象是无论输入什么串包括手工推导过的合法串eaeaa输出一律是「分析失败」。原因是scanf(%s, a)遇到空白就终止且不会把提示语里说的#存进数组数组里实际只有有效字符加一个\0。于是s走到末尾时读到的是\0#比较恒为假。更麻烦的是char a[10]容量太小输入超过 9 个字符直接越界写可能覆盖s本身导致游标乱跳。修法是换成fgets(a, sizeof(a), stdin)去掉尾部换行把判断条件改成a[s] \0数组至少放大到 256给正常输入留余量。第五条While(1)大写加上无break编译不过或者死循环。现象是编译期报While was not declared in this scope改小写之后能编过但程序进去就出不来只能 CtrlC。原因是 C 关键字区分大小写而且这个循环体内没有break、没有return、没有退出条件。修法是把While改成while并在读入失败fgets返回NULL或者读到特定退出符时break。另外这个循环体里没有任何清空s的动作第二轮分析会接着上一轮的游标往下走第二次输入必然判错——这是很多人在实验课上「第一次对、第二次错」的真实原因s必须在每次分析前归零。6. 二次开发把课程作业改成能进工程的扫描器与预测分析器真要把这两段代码用到项目里第一件事不是加功能而是把「隐式约定」变成「显式结构」。词法侧最该抽的是 Token 结构体和错误列表语法侧最该抽的是把全局游标换成带位置的游标对象。我给个最小改造骨架struct Token { std::string lexeme; // 单词本体比如 CONST、:、42 int type; // 类别码沿用 ktt 的下标0~47 int line; // 行号报错时定位用 int col; // 列号 }; // 双字符界符优先匹配先看两个字符再看一个 static const std::setstd::string kDouble {.., , , , :}; Token nextToken(const std::string src, size_t pos, int line, int col) { while (pos src.size() isBlank(src[pos])) { // 跳过空白并维护行列 if (src[pos] \n) { line; col 1; } else { col; } pos; } if (pos 1 src.size()) { std::string two src.substr(pos, 2); if (kDouble.count(two)) { // 双字符优先 Token t{two, lookup(two), line, col}; pos 2; col 2; return t; } } // 单字符、数字、标识符分支照旧但每个分支都要填 line/col }这段的关键改动只有两处一是把line、col塞进 Token后面语法分析报错才能说清「第几行第几列期望什么」二是把双字符界符的判定提到最前面彻底绕开while(isJ(ch))那个贪心吞并的坑。lookup就是查ktt那张表返回下标。语法侧建议把四个函数从「无参无返回」改成「返回 bool 接受游标引用」bool parseE(const std::string s, size_t p, std::string err);这样失败时能沿着调用栈把「在哪个非终结符、期望什么字符、实际读到什么」一层层带回来而不是像现在这样所有函数都静默返回、最后靠主函数一次判断兜底。改完之后E()里那两个if都应该有else分支写清err E: 期望 e实际读到 s[p]。再往下走一步如果文法规模变大手写递归下降就不划算了。常见做法是先算 FIRST 和 FOLLOW 集生成一张预测分析表用显式栈代替函数调用栈。好处是错误恢复好做——栈顶和当前输入不匹配时可以弹栈跳过、继续分析而不是整棵树作废。这一步不需要一步到位先把 Token 和错误定位做好后面的改造都是顺水推舟。有个验证习惯值得养成改完扫描器先跑三个最小用例——2A必须报非法常量):必须拆成两个 token:必须识别成一个赋值符。这三个过了再拿完整的pl0.txt跑一遍对比 token 数量和类别码分布。我当年第一次交这个实验就是因为没跑):这个用例答辩时被问「你数过输出里少了几个符号吗」当场翻车。从那以后我每次写完扫描器都强制先走一遍这几个边界串再谈功能。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询