☰
PL/0词法分析器手写实战:保留字表与ELSE语句扩展全解析
2026/10/1 23:18:06 网站建设 项目流程

简介:面向高校编译原理课程学习者,该资料围绕PL/0教学编译器的改造任务,提供保留字ELSE、FOR、TO、DOWNTO、RETURN及运算符+=、-=、++、--等扩充后的完整实验代码,并包含不等号#改为<>、条件语句增加ELSE子句的实现,可直接对照理解词法、语法与语义处理的关键改动。资源共24个文件,压缩包644KB,以C++源文件、头文件、实验报告文档及可执行程序为主,另有调试过程中生成的辅助文件,便于运行测试与阅读回溯。实验报告与核心代码相互配合,能清晰展示从修改单词表到扩展语法分析的具体步骤,帮助学习者快速掌握在PL/0基础上进行功能扩充的方法。已有789人学习下载,适合正在完成编译原理实验或想深入理解编译程序基本实现方法的本科学生参考。

1. 广工编译原理实验:手写PL/0词法分析器,才是保留字和ELSE语句的真正考点

广工的编译原理实验,核心从来不是背概念,而是要你在两周内把一个能跑的词法分析器交上去。我拆过这套实验资源,最卡人的地方不是状态机画不画得对,而是保留字的识别策略和ELSE语句的扩展。很多同学把教材里的PL/0代码抄下来,结果一跑就翻车:else被当成标识符、IF嵌套时分支匹配错乱、行号定位不准——这些坑不踩一遍,根本不知道编译器的第一道工序有多少细节。这篇笔记就是冲着这三个痛点去的:保留字表怎么建、ELSE语句的词法到语法怎么改、测试时怎么快速定位问题。适合正在做广工编译原理实验、或者任何用PL/0练手词法分析的人,直接对着改代码就行。

2. 词法分析器骨架:保留字表的建立与两种识别方案

2.1 为什么要单独建保留字表,而不是直接写在状态机里

PL/0的保留字不多,教材里常见的有begin、end、if、then、while、do、const、var、call、procedure、odd、read、write。加上要扩展的else,总共十四个左右。有人图省事,直接在状态转移图里给每个保留字画一条路径,比如识别到b就进begin分支,识别到e就进end分支。这种写法在实验报告里看着很唬人,状态图画满一整页,但代码维护起来就是灾难。

我用过一段时间的经验是:保留字识别必须走查表法,不能走状态机逐字匹配。原因有二。第一,状态机方案每加一个保留字,就要改状态转移图、改case分支、改报错信息,三处联动,漏一处就出诡异的bug——比如输入是"begin1"这种标识符,状态机可能把它拆成"begin"和"1"两个token,而查表法天然规避这个问题。第二,实验评分时老师会翻代码问问题,查表法的回答逻辑非常清晰:先按标识符规则读完整串,再去保留字表里二分查找,命中就是保留字,没命中就是用户自定义标识符。

查表法的核心是一张结构体数组,每个元素存保留字的拼写和它的符号种类码。符号种类码要和词法分析器返回的token类型一一对应,这是整个实验的地基。PL/0教材里通常用一个枚举或者常量定义来做这件事,比如SYM_BEGIN、SYM_END、SYM_IF、SYM_THEN、SYM_ELSE这种命名方式,编译原理实验报告里写着也规范。

2.2 预处理读入与保留字表初始化代码

词法分析器起步要做两件事:把源程序读进缓冲区,把保留字表初始化好。广工实验通常要求用C或者C++写,我这里给一份可以直接抄的初始化代码。

#include <stdio.h> #include <string.h> #define MAX_RESERVED 14 #define MAX_ID_LEN 16 #define MAX_SOURCE_LEN 4096 typedef enum { SYM_BEGIN, SYM_END, SYM_IF, SYM_THEN, SYM_ELSE, SYM_WHILE, SYM_DO, SYM_CONST, SYM_VAR, SYM_CALL, SYM_PROCEDURE, SYM_ODD, SYM_READ, SYM_WRITE, SYM_IDENT, SYM_NUMBER, SYM_PLUS, SYM_MINUS, SYM_TIMES, SYM_SLASH, SYM_EQ, SYM_NEQ, SYM_LT, SYM_LEQ, SYM_GT, SYM_GEQ, SYM_LPAREN, SYM_RPAREN, SYM_COMMA, SYM_SEMICOLON, SYM_PERIOD, SYM_ASSIGN } SymbolType; typedef struct { char spelling[MAX_ID_LEN]; SymbolType sym; } ReservedWord; ReservedWord reservedTable[MAX_RESERVED]; char sourceCode[MAX_SOURCE_LEN]; int sourceLen = 0; void initReservedTable() { strcpy(reservedTable[0].spelling, "begin"); reservedTable[0].sym = SYM_BEGIN; strcpy(reservedTable[1].spelling, "end"); reservedTable[1].sym = SYM_END; strcpy(reservedTable[2].spelling, "if"); reservedTable[2].sym = SYM_IF; strcpy(reservedTable[3].spelling, "then"); reservedTable[3].sym = SYM_THEN; strcpy(reservedTable[4].spelling, "else"); reservedTable[4].sym = SYM_ELSE; strcpy(reservedTable[5].spelling, "while"); reservedTable[5].sym = SYM_WHILE; strcpy(reservedTable[6].spelling, "do"); reservedTable[6].sym = SYM_DO; strcpy(reservedTable[7].spelling, "const"); reservedTable[7].sym = SYM_CONST; strcpy(reservedTable[8].spelling, "var"); reservedTable[8].sym = SYM_VAR; strcpy(reservedTable[9].spelling, "call"); reservedTable[9].sym = SYM_CALL; strcpy(reservedTable[10].spelling, "procedure"); reservedTable[10].sym = SYM_PROCEDURE; strcpy(reservedTable[11].spelling, "odd"); reservedTable[11].sym = SYM_ODD; strcpy(reservedTable[12].spelling, "read"); reservedTable[12].sym = SYM_READ; strcpy(reservedTable[13].spelling, "write"); reservedTable[13].sym = SYM_WRITE; }

这段代码的逻辑很直白:枚举类型SymbolType把PL/0语言的所有符号种类全部列出来,保留字属于类型的一部分,这样做的好处是后续语法分析拿到的token类型统一,不需要额外区分“这是保留字还是标识符”。ReservedWord结构体把字符串拼写和符号类型绑在一起,初始化函数负责填充这个表。

有一点要注意:src数组的长度是16,procedure这个单词长度是9,没超,但保险起见建议留到20。实验里如果把MAX_ID_LEN设得太小,写了个稍长的变量名就溢出了,程序直接崩,这是很冤的丢分点。初始化表是按字母顺序排的,如果实验要求用二分查找,这个顺序刚好能用;如果要求顺序查找,也无所谓,14个词遍历一次的开销可以忽略。

2.3 标识符与保留字的查找逻辑

初始化表只是第一步,真正决定词法分析器质量的是getToken函数里识别标识符的那段逻辑。我见过不少同学的写法是:读一个字符判断一次,读到字母就拼串,拼完直接返回IDENT类型。这样做的后果是把"if"也当成普通标识符返回了,语法分析阶段根本不知道这是个IF关键字。

正确流程是先拼完整串,再查保留字表:

char currentChar; int currentPos = 0; char getChar() { if (currentPos < sourceLen) { return sourceCode[currentPos++]; } return '\0'; // EOF marker } SymbolType lookupReserved(char* word) { for (int i = 0; i < MAX_RESERVED; i++) { if (strcmp(reservedTable[i].spelling, word) == 0) { return reservedTable[i].sym; } } return SYM_IDENT; } SymbolType getToken() { char tokenBuffer[MAX_ID_LEN]; int bufPos = 0; // 跳过空白、换行、制表符 while (currentChar == ' ' || currentChar == '\n' || currentChar == '\t') { if (currentChar == '\n') { // 行号计数逻辑放在这里 } currentChar = getChar(); } // 标识符与保留字识别 if ((currentChar >= 'a' && currentChar <= 'z') || (currentChar >= 'A' && currentChar <= 'Z')) { while ((currentChar >= 'a' && currentChar <= 'z') || (currentChar >= 'A' && currentChar <= 'Z') || (currentChar >= '0' && currentChar <= '9')) { tokenBuffer[bufPos++] = currentChar; currentChar = getChar(); } tokenBuffer[bufPos] = '\0'; return lookupReserved(tokenBuffer); } // 数字识别(略) // 运算符与分隔符识别(略) return SYM_PERIOD; // 占位 }

代码里lookupReserved返回SYM_IDENT是整个词法分析的关键决策:如果查表没命中,说明这是用户自定义的标识符,返回IDENT类型交给语法分析层处理。这里有个隐藏逻辑容易踩坑——PL/0的标识符不能以数字开头,但标识符中间可以含数字。所以上面那段识别循环里,判断条件把数字也包含进去了,这是教材的明文规定。

getChar函数里的EOF处理也要留意,返回'\0'表示源码读完。很多同学在这里返回EOF宏,但EOF在stdio.h里是-1,char类型装不下,往上抛的时候可能被截断成'\xff',判断逻辑全乱。我一般习惯用'\0'做文件结束标记,简单不出错。

3. 给PL/0加ELSE语句:词法、语法、语义三处都要动

3.1 词法层:加一个保留字不只是加一行表项

扩展ELSE语句,看着是在保留字表里加一行strcpy(reservedTable[4].spelling, "else")的事,但实际牵一发动全身。这里有个关键点:PL/0教材原版里有一个坑——保留字表是按字母顺序排的,加了else之后,表的有序性如果没保持,二分查找就废了。

再往下想一层:有些实验的PL/0实现是保留字优先匹配。比如输入"elsex",词法分析器读到e就试图匹配else,结果后面跟的是x,有的实现会直接报错"非法字符",有的实现会回退成标识符"elsex"。这两种行为哪种对?按教材定义,elsex应该被识别为标识符,而不是报错。所以正确的做法是先读完整串再查表,而不是边读边匹配。我见过有的同学的实现是把else单独拎出来特判,先看第一个字符是不是e,是的话往后读三个字符比对是不是else,再往后读一个字符看是不是分隔符——这种写法在这个场景下是work的,但它破坏了词法分析器的通用性。保留字多了以后,每加一个就要写一个特判分支,代码结构很快变成一坨烂泥。

词法层真正要改的地方有三个:

  1. 保留字表加else,MAX_RESERVED从13改成14
  2. 枚举类型加SYM_ELSE
  3. 如果你在语法分析里有token类型到字符串的映射表(比如报错时要打印"当前遇到else关键字"),那张表也得同步加

第三点特别容易被漏。PL/0的实验框架里通常有一个symToString之类的函数,或者一个switch case把token类型转成字符串用于调试。只加了枚举和表项,忘了映射表,一调试就打印出"Unknown symbol",排查半天。

// 调试辅助函数,报错时打印token类型 const char* symbolToString(SymbolType sym) { switch (sym) { case SYM_BEGIN: return "begin"; case SYM_END: return "end"; case SYM_IF: return "if"; case SYM_THEN: return "then"; case SYM_ELSE: return "else"; case SYM_WHILE: return "while"; case SYM_DO: return "do"; default: return "unknown"; } }

这段代码看着简单,但它是实验排错的利器。词法分析器跑完,把每个token的类型和内容打印出来,用symbolToString输出,一眼就能看出else到底有没有被正确识别。我自己的习惯是每加一个保留字,第一件事就是跑一遍token流打印,确认else这个token的类型是SYM_ELSE而不是SYM_IDENT,再往下做语法层。

3.2 语法层:递归下降里else子句的位置决定一切

PL/0的语法分析用的是递归下降,语句的BNF大致是:

statement := ident ":=" expression | "call" ident | "begin" statement {";" statement} "end" | "if" condition "then" statement | "while" condition "do" statement

不加else的时候,IF语句的BNF是"if" condition "then" statement。加了else之后变成"if" condition "then" statement ["else" statement],方括号表示可选。

这里最大的坑来了:else子句到底挂在哪个statement后面。看下面这段PL/0代码:

if a > b then if b > c then write(1) else write(2)

这个else按语义应该匹配内层那个if b > c,也就是最近的未匹配if。但如果你的递归下降实现是下面这种写法,就会出问题:

void parseStatement() { if (sym == SYM_IF) { getToken(); parseCondition(); expect(SYM_THEN); parseStatement(); if (sym == SYM_ELSE) { getToken(); parseStatement(); } } }

这段代码正好是"就近匹配"。问题出在另一种写法——如果你在parseStatement的外层做else的扫描,比如写了一个parseStatementSequence函数,它循环解析多个statement直到遇到end或period,那么这个else可能被当成下一条语句的起始符。else不是语句的开头,它不在statement的FIRST集合里,于是解析器报错"非法语句开始"。

正规的教科书写法是:IF语句的解析过程里,在解析完THEN后面的statement之后,立刻检查当前token是不是SYM_ELSE,是就消费掉再解析一个statement。这样else天然绑定到最近的IF,符合大多数语言的语义。具体代码可以嵌进上面那段里,不用额外写。

另一个容易翻车的地方是expect(SYM_THEN)之后直接parseStatement(),如果THEN后面跟的是BEGIN...END语句块,parseStatement会递归进parseBlock,把整个语句块消费完,然后再检查else。这个过程里,如果有分号分隔多个语句,else会被卡在分号后面。比如:

if a > b then begin write(1); write(2) end else write(3)

这里else在end的后面,跟的是整个begin块。parseStatement在解析完begin...end块之后,控制权回到IF的解析分支,这时候当前token是else,正确匹配。但如果分号被错误地放在了end后面——即end; else——那就不行了。PL/0的BNF里,begin块内部用分号分隔语句,end后面不需要分号,end后面直接跟else才是合法的。这个问题在语法分析段的报错信息上会表现为"期望THEN"或者"意外的分号",排查时先看源码里end后面有没有多余分号。

3.3 语义层:符号表与ELSE分支的作用域处理

PL/0的语义分析相对简单,主要是符号表的建立和查找。加ELSE语句,本质上不引入新的作用域——else子句里的变量和THEN分支共享同一个作用域。这句话看着理所当然,但有些同学的实验里,IF语句被当成一个块来处理,用了独立的符号表,结果else分支里访问不到外层变量,报"未定义标识符"。这是方案设计的问题,不是代码bug。

正确的做法是:IF-ELSE不开启新作用域,它们只是控制流语句,变量作用域仍然由PROCEDURE和BEGIN块决定。PL/0的经典实现里,符号表是用一个栈来管理的,进入BEGIN块时压栈,退出时弹栈。IF语句不压栈,所以THEN和ELSE分支看到的符号表是同一个。

有一个实验里常见的边界情况:ELSE分支里声明变量(比如var x;),这在PL/0里是不合法的,因为var声明只能出现在块的头部。如果你的解析器按parseBlock的流程走,ELSE后面接的statement里如果出现var,会直接报语法错误。这个报错是对的,但报错信息要写得清楚,不然同学会以为是else扩展出了问题。我在实验指导里看到过不少人把"语法错误:此处不能声明变量"当成"else实现有bug"来查,查了两天才发现是自己测试用例写错了。

语义层的另一个细节是类型检查。PL/0的条件表达式里有odd操作符,也有比较运算,ELSE分支同样可能包含这些。如果你做的实验要求带简单的类型检查,那ELSE分支也要走一遍同样的检查流程。大多数广工实验只要求到语法树生成或者解释执行,类型检查这一步一般不做硬性要求,但如果你想把实验做到优秀档,可以补上。

4. 避坑手册:PL/0实验里四个高频翻车现场

4.1 现象:else被当成普通标识符,报错"未定义符号"

  • 现象:写好了if a > b then write(1) else write(2),词法分析一跑,else被识别成IDENT类型,语法分析直接报"标识符未定义",或者"缺少THEN"。
  • 原因:保留字表里没加else,或者加了但MAX_RESERVED没同步改,表项越界或者初始化被跳过。还有一种骚操作是把strcpy写成了strcat,把else拼在了procedure后面,查表永远查不到。
  • 解决:先检查initReservedTable里有没有reservedTable[4]这一行,再检查枚举类型里有没有SYM_ELSE,两个都对上了还报错,就在lookupReserved里加个printf,把每次查表的单词打出来,看看tokenBuffer里实际存的是什么字符。我遇到过一种情况:源码文件里有不可见字符,else末尾跟着一个\r(Windows换行符),strcmp当然不相等,查表失败。用xxd或者文本编辑器的二进制模式看源码文件,去掉\r就好了。

4.2 现象:IF嵌套时else挂错分支,执行结果不符合预期

  • 现象:if a then if b then write(1) else write(2),当a为真b为假时,预期是输出2,实际什么都没输出。
  • 原因:else的匹配逻辑写反了,你的解析器把else挂到了外层IF上。具体来说,如果你在parseStatement外层的某个循环里处理else,而不是在IF解析分支内部紧跟着检查,就会出现这个问题。
  • 解决:把else的消费逻辑移到IF分支内部,严格按"if" condition "then" statement ["else" statement]的顺序来。&&在递归下降里,IF分支的代码应该是:解析完THEN后面的statement后,立即检查当前token是不是ELSE,是就继续解析下一个statement。这样else永远匹配最近的IF。写完可以用嵌套三层的测试用例验证:if a then if b then if c then write(1) else write(2),c为假时输出2,就说明else挂在了最内层IF上,正确。

4.3 现象:报错信息里的行号比实际出错行晚一行或者早一行

  • 现象:明明第10行的代码有问题,报错信息指着第11行,或者第9行。用了调试器也查不出逻辑问题。
  • 原因:行号计数和预读(lookahead)机制打架。词法分析器通常用getChar读一个字符,然后ungetChar把一个字符退回缓冲区。如果行号计数放在getChar里,每次读到\n就lineNum++,而预读操作会把\n读走再退回来,行号就多算了一次。反过来,如果行号计数放在主循环里,预读时提前读了\n,主循环没感知到,行号就少算了一次。
  • 解决:统一行号计数策略——只在真正消费\n的时候加行号,预读只读不消费。常见的做法是维护一个peekChar变量,预读时存起来,下次直接读peekChar而不是再调getChar。我在自己的实验代码里就是把行号计数放在getChar函数里,预读用单独的peekChar机制,彻底避开这个坑。

4.4 现象:程序编译通过,但运行时ELSE分支访问变量返回垃圾值

  • 现象:语法分析全对,token流也打印过了,没有报错。但一运行,ELSE分支里访问某个变量,拿到的是随机数。
  • 原因:符号表的作用域管理有漏洞。IF-ELSE没有正确沿用当前作用域,可能在解析THEN分支时意外地压栈或者弹栈了。或者更隐蔽的问题:变量声明被重复处理了,同一级别的两个变量声明,第一个被第二个覆盖,导致ELSE分支访问第一个变量时,符号表里存的是第二个变量的地址或者偏移。
  • 解决:如果是自己维护符号表,最有效的排查方式是把符号表的dump函数跑一遍——在解析到THEN分支之前dump一次,在解析到ELSE分支之前再dump一次,对比两份符号表,看变量是否一致。如果符号表的实现是指针传递,检查进入IF解析前是否保存了符号表栈顶指针,解析完THEN分支后是否恢复。PL/0的经典实现里这句话是精髓:IF语句不改变符号表栈的深度,栈顶指针在IF语句解析前后必须保持一致。

5. 验证实验成果:三分法自查,十分钟确认ELSE扩展无死角

词法分析器和ELSE扩展写完以后,直接拿老师给的测试程序跑一遍,大概率是能过的。但如果你想确认自己是真的写对了而不是碰巧能跑,我建议按三个维度自查——层级覆盖、token流审查、错误注入。

第一个维度是层级覆盖。写一个包含三层嵌套IF-ELSE的PL/0测试程序,每一层都有独立的THEN和ELSE分支,每个分支里放不同的write输出。然后手动推演一遍每个条件的真假组合,确定预期输出序列。这个过程不要偷懒,纸上推演一步都不能跳。推演完再跑程序对比输出。嵌套测试能一次性暴露else就近匹配的问题,也是老师最喜欢在验收时考的。

第二个维度是token流审查。写一个脚本或者直接用调试模式跑一遍简单程序,把词法分析器输出的每个token按顺序打印出来,人工核对每一个token的类型和值。重点检查else是否全部被识别为SYM_ELSE,有没有哪个else混入了IDENT;还有THEN语句块结束后,else前面的分号、end的判断是否正确衔接。这一步能提前拦截掉那些"语法分析凑巧能跑,但token本身是错的"隐形问题。

# Linux下用diff对比实际输出与预期输出 ./compiler test_cases/nested_if.pl0 | diff - expected/nested_if.out

第三个维度是错误注入。故意构造几个非法的else用法,确认你的编译器会给出清晰报错而不会崩溃。比如:then后面直接跟else(缺少THEN后语句)、else后面跟end(缺少ELSE后语句)、else if嵌套时少写一个then。正常情况下编译器应该在精确的行号位置报出明确的语法错误。如果你的编译器对这些非法输入的表现是"挂死"或者"无限循环",说明错误恢复机制没做好——这在实验评分里是扣分大项,因为老师会专门拿错误程序来测试编译器的健壮性。

拿我自己来说,每次写完词法分析器,我都会强制自己走一遍"三查"流程——查保留字表、查token流、查错误注入,一步都不省。这套流程帮我提前拦下了不下五个只在特定输入下才会触发的隐蔽bug。如果你照着这篇笔记的代码走完,建议也保留这个习惯,你的编译原理实验基本就到收尾阶段了,希望帮到你。

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

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

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

立即咨询