☰
PL/0编译器扩展ELSE:保留字、语法分析与回填跳转
2026/10/29 2:48:36 网站建设 项目流程

简介:面向高校计算机专业编译原理课程学习者与实验开发者,这份广工编译原理实验资料围绕教学型 PL/0 编译程序展开,重点处理词法分析、语法分析与语义处理程序的局部修改扩充。内容覆盖新增 ELSE、FOR、TO、DOWNTO、RETURN 等保留字,补充 +=、-=、++、-- 运算符,将不等号 # 改为 <>,并加入条件语句的 ELSE 子句,涉及保留字表、运算符识别和语法规则调整,适合对照实验要求定位改动位置并验证运行结果。压缩包共 24 个文件、约 644KB,含 cpp 与 h 源码、doc 实验报告、exe/pdb 等调试生成文件以及 dsp/dsw 工程配置,还带有 txt、tmp 等辅助记录,能够帮助读者观察原工程结构、代码组织与编译运行痕迹。已有 790 人学习下载,可作为课程实验复现、源码比对、报告整理和排错分析的参考材料,也便于在 PL/0 小规模代码上理解保留字扩充与 ELSE 子句实现的基本路径。

1. 从一份 PL/0 教学编译器源码看起:保留字和 ELSE 语句各自要动哪几处

编译原理实验做到语法分析这一步,多半绕不开两个固定动作:往保留字表里补一个词,给 IF 语句挂上 ELSE 分支。这份 PL/0 教学编译器源码把词法扫描、递归下降、类 P-code 生成和栈式解释器串在一条链路上,改一个保留字、加一条分支,前后端立刻给反应——要么跑通,要么跳飞。它适合正在做课程设计、需要交出一份能跑还能讲清原理的编译器的同学,也适合想把编译前端从头串一遍的开发者。真正的难点不是写多少行,而是清楚每处改动牵动哪几个结构:保留字数组 word、符号枚举、同步集合 FSYS、指令数组的 a 域。下面按词法、语法、代码生成、排错、验证拆开讲。

2. 词法层动手:把 else 塞进保留字表,一个字符都不能挪错位置

词法层是整个改动的地基。保留字认错,后面语法分析再正确也没用——你写else,扫描器回你一个 ident,递归下降立刻在 IF 分支上卡死,报出来的错还离现场十万八千里。

2.1 getsym 的骨架:一个字符缓冲区撑起整个扫描器

PL/0 的扫描器只有一个入口getsym,全局变量ch保存当前字符,getch负责从源文件读下一个字符并跳过回车的处理。整个函数按"跳空白 → 收标识符 → 收数字 → 收运算符"的固定顺序走,先看首字符落在哪个字符集,再决定后续分支。标识符和保留字走的是同一条路:先把连续的小写字母和数字收进字符数组a,转成字符串id,再拿id去保留字表里比对,比中就是保留字,比不中才落到 ident。

procedure getsym; var i, j, k: integer; begin while ch = ' ' do getch; { 1. 跳过所有空白 } if ch in ['a'..'z'] then { 2. 字母开头:标识符或保留字 } begin k := 0; repeat if k < al then begin a[k] := ch; k := k + 1 end; getch until not (ch in ['a'..'z', '0'..'9']); a[k] := chr(0); id := a; { 补结束符,转字符串 } i := 1; j := norw; { 3. 在 word 上二分查找 } while i <= j do begin k := (i + j) div 2; if id <= word[k] then j := k - 1; if id >= word[k] then i := k + 1 end; if (i - 1) > j then sym := wsym[j + 1] { 命中保留字 } else sym := ident { 否则是普通标识符 } end else if ch in ['0'..'9'] then ... { 数字分支 } else begin ... end { 运算符与界符分支 } end;

这段代码有三个参数必须盯住。al是标识符最大长度,原版取 10,procedure正好 9 个字符加结束符占用 10,再加词就得同步调大。norw是保留字个数,加 else 后从 13 变成 14,它是数组上下界的来源,漏改就会数组越界。word和wsym是两张平行数组,下标一一对应,前者存字面量、后者存符号枚举,插入位置必须两边一致,否则二分比对命中后拿到的是别人的符号值。

2.2 保留字表用有序数组还是哈希:PL/0 的量级下二分最省事

这里的选型值得说清楚。原版用的是"有序数组 + 二分查找",注释里通常写着按字典序排列。为什么不用哈希表?保留字总共十几个,二分最多四次比较,跑完整份测试程序也就几千次比较,开销可以忽略;哈希表还得额外维护桶数组,实验报告里多一段无关代码,反而不好讲。为什么不改成线性扫描?线性扫描对顺序没有要求,看上去更不容易出错,但它把"数组必须有序"这个隐式约束藏了起来,等你后面再加三五个保留字时,二分版会因为顺序错乱产生极难定位的间歇性故障——有的词认得,有的词认不出。

我一般推荐保留二分,然后把数组按字典序排好再插入 else:

const norw = 14; { 13 + else } al = 10; { 'procedure' 正好用满 } type symbol = (..., elsesym, ...); { 枚举里新增 else } const wsym: array[1..norw] of symbol = (beginsym, callsym, constsym, dosym, elsesym, endsym, ifsym, oddsym, procsym, readsym, thensym, varsym, whilesym, writesym); word: array[1..norw] of alph = ('begin', 'call', 'const', 'do', 'else', 'end', 'if', 'odd', 'procedure', 'read', 'then', 'var', 'while', 'write');

关键点只有一句:else必须插在do和end之间,而不是追加到数组末尾。追加到末尾时,二分查找的中间点全部偏移,会出现while认得出、read认不出的怪现象,排查时让人想摔键盘。枚举symbol里elsesym放哪都行,它和其它枚举值一样只是个序号,真正决定识别结果的是wsym的下标对齐。

另外提醒一个容易忽略的地方:原版二分里的if id <= word[k] then j := k-1和if id >= word[k] then i := k+1是两个独立的 if,不是 if-else。命中时两条都会执行,一次把区间压到k两侧,循环自然收敛。改写成 if-else 会让查找逻辑变成"找到即停",找不到时区间不收敛,直接死循环。

2.3 前缀与大小写:elsif、else_、ELSE 分别会被判成什么

三个测试用例能一次性验证词法层的正确性,也是我做这个实验时必写的自检项。

第一个是前缀词。写elsif时,收集循环会把e-l-s-i-f全部收进id,得到elsif,与else不相等,二分查不到,正确落到 ident。这说明"整词匹配"本身是安全的,不会把elsif误判成else加一个多余字符。真正的风险在于有人图省事,把id = word[k]改成copy(id,1,4) = 'else',那就彻底崩了。

第二个是下划线。原版标识符字符集是['a'..'z','0'..'9'],下划线根本进不来,写else_时扫描器在_处停下,返回else保留字,紧接着_进入符号分支报错。想让下划线合法,得在字符集里补上,这属于实验扩展题,改动前先想清楚是否影响已有测试用例。

第三个是大写。原版只收小写字母,源程序里写ELSE,E落在字母判断之外,会走进运算符分支的兜底 error,报出来的错误号对不上任何语法错误。要么统一规定测试用例全小写,要么在getch里加一句归一化:

procedure getch; begin if ll = cc then begin { 当前行读完,读下一行 } ... end; ch := line[ll]; ll := ll + 1; if ch in ['A'..'Z'] then ch := chr(ord(ch) + 32) { 统一转小写 } end;

把这句放在getch里而不是getsym里,是因为运算符分支里的字符比较也要受益,BEGIN和begin走完全相同的路径。有些实现把它放进getsym的字母分支,结果字符串字面量或者注释里的内容被误改,属于好心办坏事。

3. 语法层改造:IF 语句挂上 ELSE 之后,递归下降的分支怎么走

词法层放行elsesym只是第一步。真正的改造在statement过程里——PL/0 没有语法树,语句的代码生成是就地内联的,所以 ELSE 分支的插入位置直接决定生成的跳转指令对不对。

3.1 文法改写:把可选的 else 部分放进 statement 的哪里

原版的条件语句产生式是这样的:

<语句> ::= <赋值语句> | <条件语句> | <当型循环语句> | <过程调用语句> | <读语句> | <写语句> | <复合语句> | <空> <条件语句> ::= if <条件> then <语句>

最直觉的改法是给<条件语句>补一个可选尾:

<条件语句> ::= if <条件> then <语句> [ else <语句> ]

文法没错,但落到递归下降里会出问题:PL/0 的statement是一个大 if-else 链,if分支的判断和代码生成是写在一起的,没有一个独立的condstmt过程给你调用。所以实际做法是把可选部分直接摊在statement的ifsym分支末尾:

<语句> ::= if <条件> then <语句> [ else <语句> ] | ...

这个改写带来一个必须处理的性质:else只能紧跟在 THEN 分支的语句之后,中间不能有分号。写成if c then s1; else s2是语法错误,因为分号在 PL/0 里是语句分隔符,s1;结束之后statement已经返回,外层看到的else出现在不允许的位置。第一次做这个实验的同学十有八九会踩这一脚,测试用例里务必包含带分号的错误写法,观察报错位置是否符合预期。

另一个性质是悬挂 else。if a then if b then x:=1 else x:=2里,else按就近原则属于内层 IF。递归下降天然满足这条规则:外层调用statement处理 THEN 分支,内层的statement在它自己的ifsym分支里消费掉else,外层回来时sym已经是分号或 end,不会再去抢。这套行为不需要额外代码,但需要测试用例锁住,防止有人把else的检查提到外层循环里。

3.2 statement 里的 IF 分支:回填点、同步集合与悬挂 else

改造后的 IF 分支代码如下,注释标出了新增的三处:

procedure statement(fsys: symset); var i, cx1, cx2: integer; begin if sym = ident then ... { 赋值语句 } else if sym = ifsym then begin getsym; condition(fsys + [thensym, dosym]); { 条件表达式,结果压栈 } if sym <> thensym then error(16) else getsym; cx1 := cx; gen(jpc, 0, 0); { 新增点 1:假分支占位 } statement(fsys + [semicolon]); { THEN 分支 } if sym = elsesym then { 新增点 2:可选的 ELSE } begin getsym; cx2 := cx; gen(jmp, 0, 0); { THEN 结束跳过 ELSE } code[cx1].a := cx; { 回填假分支入口 } statement(fsys + [semicolon]); { ELSE 分支 } code[cx2].a := cx { 回填跳出点 } end else code[cx1].a := cx { 新增点 3:无 ELSE 也要回填 } end else if sym = whilesym then ... end;

cx1和cx2是两个"未来的地址"。生成jpc的时候,编译器还不知道条件为假该跳到哪,只能先占一条指令,把 a 域填 0,等 THEN 分支生成完、ELSE 入口确定下来再回填。这套手法在编译原理课上叫拉链或者回填,cx2是同样的道理:THEN 分支执行完必须跳过 ELSE,跳转目标此刻未定,先占位后补。

三处新增点里,第三处最容易被漏。只写if sym = elsesym then ...分支内部的回填,无 ELSE 的 IF 语句就走不到回填代码,jpc的 a 域永远停在 0。程序能编译通过,跑起来条件一旦为假就跳回地址 0,解释器从头再执行一遍,表现成死循环或者莫名其妙的变量值。这个 bug 我见过不止一次,症状极像解释器写错了,实际上是语法分析的锅。

statement的fsys参数也值得说一句。它是错误恢复用的同步集合,正常情况下不参与任何判断,只在error之后的跳读循环里决定"读到哪些符号就停下来"。THEN 分支调用statement时传的集合里不含elsesym,一旦 THEN 分支内部有个笔误触发错误恢复,跳读会一路吃掉else和后面整段 ELSE 分支,报错行号飘到几十行开外。稳妥的做法是在传给 THEN 分支的集合里补上elsesym:

statement(fsys + [semicolon, elsesym]);

这样即使出错,跳读也会在else处停住,后续的错误提示能落在正确的位置上。

4. 代码生成与解释器:多出来的跳转谁来执行

语法层把jpc和jmp塞进指令数组之后,剩下的问题是解释器认不认这两条指令。答案是认——但前提是回填的地址没错。

4.1 指令集够不够:jmp 与 jpc 的分工

PL/0 的类 P-code 只有八条指令:lit(压常量)、lod(取变量)、sto(存变量)、cal(调用)、int(调整栈顶)、jmp(无条件跳转)、jpc(栈顶为 0 时跳转)、opr(算术与关系运算)。IF-ELSE 是两路分支,条件求值后栈顶必然是一个 0 或 1,用jpc消费它并决定走哪条路,THEN 分支末尾用jmp跳过 ELSE,两条现成指令刚好够用。

不需要新增 opcode,这一点值得在实验报告里写明。有些同学一上来就想加一条jz或者jf,改完解释器发现行为和老指令重复,白白多出一段维护成本。判断标准很简单:新语句的控制流如果只是"条件跳转 + 无条件跳转"的组合,就复用现有指令;只有当它需要访问新的运行时结构(比如异常处理的栈展开、迭代器的状态保存),才考虑扩展指令集。

具体到一条if x > 0 then y := 1 else y := 2;,假设符号表已经算出 x 的层差是 0、偏移 3,y 是 0、偏移 4,生成的指令序列长这样:

地址fla含义
0lod03取 x 压栈
1lit00压常量 0
2opr012大于运算,栈顶为 x>0
3jpc07条件为假跳到地址 7(ELSE 入口)
4lit01THEN 分支:压 1
5sto04存入 y
6jmp09跳过 ELSE
7lit02ELSE 分支:压 2
8sto04存入 y
9...IF 语句之后的第一条指令

这张表建议手画一遍。地址 3 的 a 域是回填点一,地址 6 的 a 域是回填点二,两处都指向"下一条尚未生成的指令"。理解这张表之后,任何跳转类 bug 都能靠 dump 指令数组定位。

4.2 gen 与拉链回填:把 a 域先填 0 再补

gen是代码生成的唯一出口,所有指令都从这里进数组:

procedure gen(x: fct; y, z: integer); begin if cx > cxmax then begin writeln('程序太长'); halt(1) end; with code[cx] do begin f := x; l := y; a := z end; cx := cx + 1 end;

cx是下一条待生成指令的下标,也是所有回填操作的目标值。回填语句code[cx1].a := cx的含义就是"把占位指令的跳转目标改成当前生成位置"。注意回填的时机:code[cx1].a := cx必须写在生成 ELSE 分支语句之前,此刻cx指向 ELSE 的第一条指令;如果写在其后,cx已经越过了整个 ELSE 分支,条件为假时会直接跳到 IF 语句之后,ELSE 整段被跳过。

cxmax是指令数组的容量常量。原版给的值在复杂程序上刚好够用,加上 ELSE 分支每条语句平均多 1 到 2 条指令,三层嵌套的 IF 就能顶到上限,触发程序太长并halt(1)。改完语法之后第一件事就是把这个常量翻一倍,或者在进入编译前根据源文件大小估算一个更宽松的值。这个错报出来的信息只有一行,不告诉你是哪条语句撑爆的,很容易误判成死循环。

4.3 解释器主循环:一行都不用改,但要加断言

解释器是一个repeat ... until pc = 0的主循环,取指令、pc自增、按f域分派。跳转相关的只有两条:

jmp: pc := i.a; { 无条件跳转 } jpc: begin if s[t] = 0 then pc := i.a; { 栈顶为 0 才跳 } t := t - 1 { 无论跳不跳都弹栈 } end;

这两条不需要任何改动,ELSE 分支的语义完全落在它们身上。jpc无论是跳还是不跳都会把条件值弹出栈,保证进入分支体时栈顶干净。如果回填出错导致pc落到一个非法地址,解释器不会报错,它会继续读code[pc]里残留的旧指令,表现成变量被莫名改写或者程序提前结束——这类问题靠读代码找不到,只能靠断言。

我一般在jmp和jpc分支里各加一行范围检查:

jmp: begin if (i.a < 0) or (i.a >= cx) then begin writeln('跳转越界: ', i.a); halt(1) end; pc := i.a end;

cx在编译结束后就是有效指令总数,拿它当上界刚好。这条断言把"跳飞"这种玄学问题变成一行明确的报错,省下的调试时间远超写它的成本。

5. 避坑与排查:五条翻车记录,每条都能定位到具体行

5.1 编译能过、运行跳飞:无 ELSE 的 IF 漏了回填

现象:源程序里只有if x > 0 then y := 1;,没有 else。编译零错误,运行后 y 恒等于 1,或者解释器卡在某个 pc 上反复执行同一段。

原因:三处新增点里只写了if sym = elsesym then分支内部的code[cx1].a := cx,无 ELSE 的路径没有回填,jpc的 a 域保持 0,条件为假时pc直接回到地址 0,整个程序从头再来一遍。

解决:把回填语句放到if sym = elsesym的 else 分支里,或者更稳的写法是在整个 IF 分支末尾统一回填一次:

if sym = elsesym then begin getsym; cx2 := cx; gen(jmp, 0, 0); code[cx1].a := cx; statement(fsys + [semicolon, elsesym]); code[cx2].a := cx end; code[cx1].a := cx; { 统一收口,有 ELSE 时这句是幂等的 }

有 ELSE 时第二次赋值覆盖同一个值,不影响结果;无 ELSE 时正好补上。这种"统一收口"的写法比在两个分支里各写一遍更容易检查。

5.2 报错停在 else 上:FSYS 同步集合没跟着改

现象:if c then s1 else s2里 s1 写错一个分号,编译器报的不是"分号附近出错",而是一路跳到程序末尾报"缺 end",中间所有语句被当成错误恢复吞掉。

原因:statement的fsys参数不含elsesym,error之后的跳读循环不认识 else 这个符号,把它和后面的内容一起跳过。

解决:给 THEN 分支传集合时补上elsesym,同时检查condition的调用是否也带了足够的同步符号。这条改动不影响正确程序的编译结果,只影响错误程序报错的位置,但排查体验差别巨大。

5.3 有的保留字认得、有的认不出:word 数组没按字典序插

现象:加完 else 之后,while、if照常工作,read、write突然被当成了普通标识符,源程序里read(x)报错"应为赋值号"。

原因:为了省事把'else'追加到 word 数组末尾,二分查找依赖的字典序被破坏。中间点偏移后,某些词再也落不进比较路径,而另一些词碰巧还能命中,所以表现为"一半正常一半坏"。

解决:把else插到do和end之间,wsym数组同步在相同下标插入elsesym。改完写一个包含全部保留字的测试程序,每个词各用一次,跑通再往下做。

5.4 数组越界不报错,行为却开始诡异

现象:加了elsesym,也改了wsym数组,但忘了改norw。编译照常进行,偶尔出现"某些保留字识别成 ident"或者完全随机的结果,换一次编译又变了。

原因:norw是wsym和word的数组上界,还参与二分查找的初始区间j := norw。它停在 13 而数组实际有 14 个元素时,二分区间覆盖不到最后一个词;数组元素数量反过来少于norw时,越界读到的是相邻内存,Pascal 在关闭范围检查的情况下不报错,值是不确定的。

解决:norw、wsym长度、word长度三者必须同时改。经验做法是把它们写成norw = 14加注释说明构成,改的时候一次性三处过一遍,别指望编译器提醒。

5.5 大写 ELSE 报出莫名其妙的错误号

现象:测试用例里写了IF x > 0 THEN ... ELSE ...,报的错误号是 0 或者一个不存在的语法错误,位置指向E所在列。

原因:原版getsym的标识符分支只认['a'..'z'],大写字母走进运算符兜底分支,触发的是"非法字符"级别的错误。

解决:在getch里做大小写归一化,把A到Z统一转成小写再返回。放在getch而不是getsym是因为运算符分支同样需要这个转换,BEGIN、:=前后的判断都走同一套逻辑。归一化之后,测试用例里大小写混写的程序应当和全小写版本生成完全相同的指令序列,这一点可以直接用 dump 比对。

6. 验证:用嵌套 IF 和一段手算指令序列把编译器逼到边界

跑通一个if x > 0 then y := 1 else y := 2说明不了什么,编译器真正的问题都在嵌套和边界上。我习惯用下面四组用例过

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

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

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

立即咨询