简介:面向编译原理课程学习与课程实验验收场景,这是一份基于PL/0教学型编译程序的改造实验资料,适合需要完成词法、语法与语义处理部分修改扩充的高校学生参考。内容围绕保留字ELSE、FOR、TO、DOWNTO、RETURN的加入,运算符+=、-=、++、--的识别,以及不等号由#调整为<>、条件语句增加ELSE子句等任务展开,能帮助读者理解编译程序的基本结构与实现方法。压缩包共24个文件、约644KB,包含cpp与h源码、doc实验报告、dsp/dsw工程文件,以及exe、obj等编译产物和opt、ncb等开发环境辅助文件,便于直接查看工程组织与调试痕迹。目前已有790人学习下载,可作为课程实验的对照样本,辅助梳理PL/0改造点、代码修改位置和实验报告撰写思路。
1. 加一个 else 只要七行代码?真正动手才知道它牵动四层
编译原理实验里最容易被低估的一道题,是给 PL/0 加一条 ELSE 语句。看上去不过是往保留字表里塞一个单词、在语句分派里补一个分支,可真跑起来,报错的位置往往离你改的地方十万八千里:语法分析明明改对了,目标代码却跳错行;有 else 的 if 跑得好好的,没 else 的 if 反而全部崩掉。根子在于 PL/0 是一个单遍扫描、边分析边生成目标代码的编译器,保留字查找、词法状态、递归下降的分派顺序、跳转指令的地址回填,这四层共享同一套游标和同步契约,动一处就必须让另外三处跟着对齐。把这条链路摸清楚,才算真正看懂编译器前端是如何持续运行并保证各层一致的。
2. 保留字识别:为什么 else 必须插在 do 和 end 之间
词法分析最先撞上的问题是边界:一段字母序列到底是关键字还是用户自定义的变量名。PL/0 里没有“高亮”这种便利,全靠 getsym 里那几十行代码决定。保留字表加一项看着最简单,恰恰是编译原理实验里最容易出错的地方,因为它依赖了一个你根本没写进注释的不变式。
2.1 保留字不是查出来的,是“二分”出来的
标准 PL/0 的保留字一共十几个,数量太少,用哈希表反而笨重,教科书里的做法是把它们按字典序排好,二分查找。表长这样:
#define NRW 13 /* 保留字个数,加一个 else 就从 12 变 13 */ #define AL 10 /* 标识符最大长度,超长会截断 */ /* 保留字表:必须严格按字典序升序,二分查找隐含依赖这个前提 */ char *word[NRW] = { "begin", "call", "const", "do", "else", "end", "if", "odd", "procedure", "read", "then", "var", "while" }; /* 与 word[] 按下标一一对应,二分只返回下标,靠下标取类别 */ int wsym[NRW] = { beginsym, callsym, constsym, dosym, elsesym, endsym, ifsym, oddsym, procsym, readsym, thensym, varsym, whilesym };查找发生在标识符收集完之后,不是边读边判:
void getsym(void) { while (ch == ' ' || ch == '\t' || ch == '\n') getch(); if (isalpha(ch)) { /* 字母开头:标识符或保留字 */ int k = 0; do { if (k < AL - 1) id[k++] = ch; /* 超长截断,但循环继续,保证读完整词 */ getch(); } while (isalnum(ch)); id[k] = '\0'; int low = 0, high = NRW - 1; /* 二分前提:word[] 严格升序 */ while (low <= high) { int mid = (low + high) / 2; int cmp = strcmp(id, word[mid]); if (cmp == 0) { sym = wsym[mid]; return; } if (cmp < 0) high = mid - 1; else low = mid + 1; } sym = ident; /* 走到这里才是普通标识符 */ return; } /* 数字、运算符、界符的处理略 */ }几个细节值得单独说明。id缓冲必须先读完整单词再比对,因为el既是else的前缀,也可能是用户变量element的开头,任何“边读边用 strncmp 试探”的写法都会把element误判成保留字。AL和NRW是两个必须同步维护的常量:改了标识符长度上限却不改缓冲声明,超长变量名会被截断成另一个合法名字,符号表里两个不同的变量指向同一个位置,属于最隐蔽的一类 bug。strcmp的字典序在全部小写字母的前提下等价于字母序,一旦表里混进大写或者下划线,排序假设就破了。
2.2 表序错一格,现象是“只有 else 不认”
把else追加到表尾而不是插到do和end之间,是最典型的改动失误。13 个元素的表,else躺在下标 12,此时查else的二分路径是这样的:mid 先落 6(odd),else小于odd,high 收到 5;mid 落 2(const),else大于它,low 抬到 3;mid 落 4(end),else仍然大于它,low 抬到 5;mid 落 5(if),else小于if,high 收到 4,low 已经大于 high,查找失败,返回ident。
而另外 12 个保留字碰巧还能查得到,因为它们的位置没动。于是你看到的现象非常有迷惑性:写if a then x:=1 else x:=2;时,else被当成普通标识符,statement 走进赋值语句分支,去符号表里查一个叫else的变量,查不到,报出“标识符未定义”,错误位置正好指在 else 这个词上。很多人第一反应是去改语法,其实问题出在词法层的一个排序不变式上。
提示:凡是用二分查找的表,插入元素前先跑一次自检,比事后对着报错猜半小时划算。
/* main 开头自检:保留字表必须严格升序,否则二分查找静默失效 */ for (int i = 1; i < NRW; i++) { if (strcmp(word[i - 1], word[i]) >= 0) { printf("保留字表失序: %s >= %s (下标 %d)\n", word[i - 1], word[i], i); exit(1); } }这段自检只有五行,但它把“编译期不变式”变成了“运行期断言”,代价是启动时多几十纳秒,收益是后面所有关于“为什么 else 识别不出来”的猜测全部省掉。
2.3 保留字不进符号表,进的是 sym
还有一个容易混淆的点:保留字从来不进符号表。table[]里只存 const、var、procedure 三类,保留字全靠全局变量sym携带类别、id携带原文两个通道传递。语法分析只读sym分派,从不回头比对id字符串,所以新增一个保留字,只需要改word[]、wsym[]和枚举三处,符号表一行动不用动。
这个设计的代价是sym变成了全局状态,getsym的调用时机就成了一种隐式契约:每判定一个语法成分,就得提前把下一个 token 读进来,属于典型的 LL(1) 风格。任何一处多读或者漏读getsym,sym就会整体错位一格,后面所有分支判断全部失准,而且报出来的错误号通常指向完全无关的位置。第 3 章要处理的悬挂 else,本质上也是这条契约的一部分。
3. ELSE 的语法落地:递归下降里那个“提前返回”的分支
词法层把else正确识别成elsesym之后,语法层的活才是真正需要思考的地方。PL/0 用的是递归下降,语句识别集中在statement()一个过程里,靠一串 if-else 按sym分派。ELSE 的特殊之处在于,它不是语句的起始符,而是 if 语句的延续,这个区别决定了它应该写在哪个分支里面。
3.1 statement() 的分派结构就是 FIRST 集
statement()开头那段条件,形式上看是代码,本质上是语句的 FIRST 集:赋值语句以ident开头,read 语句以readsym开头,复合语句以beginsym开头,if 语句以ifsym开头。这个集合必须互不相交,否则递归下降无法只看一个 token 就决定走哪条路。
void statement(void) { if (sym == ident) { /* 赋值语句 */ int i = position(id, tx); /* 查符号表,找最近一层声明 */ if (i == 0) error(11); /* 标识符未定义 */ else if (table[i].kind != variable) error(12); getsym(); if (sym == becomes) getsym(); else error(13); /* 缺 := */ expression(); if (i != 0) gen(sto, table[i].level, table[i].adr); } else if (sym == readsym) { /* read 语句 */ } else if (sym == writesym) { /* write 语句 */ } else if (sym == callsym) { /* call 语句 */ } else if (sym == beginsym) { /* begin ... end 复合语句 */ } else if (sym == whilesym) { /* while 语句 */ } else if (sym == ifsym) { /* if 语句,下面展开 */ } else error(14); /* 语句起始符不对 */ }注意最后那个error(14):它兜住了“当前 token 不可能是任何语句开头”的情况。elsesym如果出现在这里,说明出现了一个悬空的 else,正确做法是报错而不是默默接受。
3.2 if 分支:两次回填,一处出口
if的处理是整个实验的核心,写法如下:
else if (sym == ifsym) { getsym(); condition(); /* 生成条件求值代码,结果留在栈顶 */ if (sym == thensym) getsym(); else error(16); /* 缺 then:只报错,不吞 token,继续往下解析 */ int jpcIdx = cx; /* 先记游标,gen 之后 cx 就变了 */ gen(jpc, 0, 0); /* 占位:条件为假时跳哪,先空着 */ statement(); /* then 分支,递归进入解析一条语句 */ if (sym == elsesym) { /* 只有真看到 else 才走这条路 */ int jmpIdx = cx; gen(jmp, 0, 0); /* then 分支执行完要越过 else 分支 */ code[jpcIdx].a = cx; /* 回填假跳转:目标 = else 分支首指令 */ getsym(); statement(); /* else 分支,同样只解析一条语句 */ code[jmpIdx].a = cx; /* 回填 then 末尾跳转:目标 = endif */ } else { code[jpcIdx].a = cx; /* 没有 else:假跳转直接落到 endif */ } }四个参数层面的要点。jpcIdx必须在gen之前取,因为gen内部会把cx自增,取晚了就指向下一条指令,回填位置整体偏一格。gen(jpc, 0, 0)的第三个参数先写 0 是占位语义,不是“跳到 0 号指令”,在回填之前这条指令是无效的。code[jpcIdx].a = cx里的cx是“下一条待生成指令的地址”,也就是 else 分支的第一条指令,不是 else 关键字所在的位置——目标代码里根本没有关键字这个概念。else 分支体只解析一条statement(),所以else begin ... end才是标准写法,写成else a:=1; b:=2时只有a:=1属于 else,b:=2会被外层 block 当成下一条语句,这个语义陷阱在测试用例里特别容易被忽略。
3.3 悬挂 else:递归下降“顺手”做对了就近匹配
if a then if b then x := 1 else x := 2;这句的 else 该绑哪个 if?主流语言的答案是最内层那个:b 为假时执行x:=2。递归下降天然给出这个结果,原因就藏在代码结构里——statement()处理 then 分支时会递归调用自己,内层 if 的这次调用先返回,返回时sym正好停在elsesym,于是内层 if 自己就把 else 消费掉了。外层 if 再恢复执行时,sym已经是;,走的是无 else 的那条分支。
反过来,如果在statement()的分派里加一条else if (sym == elsesym) { getsym(); statement(); },事情就坏了。这时 else 变成了“任何位置都能出现的语句引导符”,a := 1 else b := 2这种明显非法的序列也能通过语法检查;更麻烦的是 else 的归属被打乱,可能变成最远匹配。血泪经验是:else 的处理只能写在 if 分支内部,一旦它出现在语句分派的第一层,语言的语法就被悄悄放宽了,而放宽语法往往不会立刻报错,只会在某组测试用例上给出一个看起来“差不多对”的结果。
3.4 与 then 的同步:错误恢复为什么不抛异常
error(n)在 PL/0 里只做两件事:错误计数加一,按行号打印一条信息。它不中断编译,这是单遍编译器的必然选择——只有把整个源文件扫完,才能一次性告诉你所有错误。代价是每个错误点之后,sym必须处于一个“还能继续往下解析”的状态。
缺 then 的处理就是这条契约的体现:报error(16)之后不消耗任何 token,直接把后面的内容当作 then 分支继续解析。如果源码写的是if a x := 1;,编译器会报“缺 then”,然后老老实实把x:=1编译成 then 分支的目标代码,最后统一报告错误总数。这种“局部修复、全局继续”的策略意味着:程序能编出目标代码,不等于它是对的。加 else 的时候最容易破坏这条契约的地方,是 else 后面直接跟分号——statement()会一路走到error(14)然后返回,分号被外层 block 吃掉,编译照样成功,错误照样报出,别被“能跑”骗过去。
4. 目标代码生成:if-else 为什么需要两次回填
语法树建好之后生成代码,和一边解析一边生成代码,是两种完全不同的思维方式。后者在遇到 if 的那一刻,根本不知道 else 分支会生成在第几条指令上,唯一能做的就是先占个坑,等位置确定了再回来填。这个“占位再回填”的动作,就是 if-else 翻译的全部难点。
4.1 jpc 与 jmp 的语义契约
先看解释器里这两条指令怎么执行:
case jpc: /* 条件跳转:先弹栈,栈顶为 0 才跳 */ top--; if (s[top + 1] == 0) pc = i.a; break; case jmp: /* 无条件跳转:直接把 pc 改成 a */ pc = i.a; break;jpc是“假跳”还是“真跳”,不同版本的 PL/0 实现并不统一,有的写成栈顶非零才跳。动手改 else 之前必须先读解释器源码确认语义,否则两个分支会整体反过来:条件成立时执行 else 分支,条件不成立时执行 then 分支,程序不报任何错,只是结果全错,这种 bug 排查起来格外费劲。
第二点是jpc必须无条件弹栈。条件求值结束后栈顶压着一个布尔结果,jpc消费掉它,栈才能回到平衡状态。如果只在跳转时弹栈,那么条件为真的路径上栈会一直长,循环体里套 if 的场景跑几十次就溢出了。
4.2 cx 游标与两次回填的完整轨迹
拿if a < b then x := 1 else x := 2;走一遍,假设进入 if 语句时cx是 0,a、b、x都是主程序层(level 0)的变量:
| cx | 生成的指令 | 说明 |
|---|---|---|
| 0 | lod 0 a | 条件求值,压入 a |
| 1 | lod 0 b | 压入 b |
| 2 | opr 0 10 | 小于运算,结果留栈顶 |
| 3 | jpc 0 ? | 占位,jpcIdx = 3 |
| 4 | lit 0 1 | then 分支:常量 1 |
| 5 | sto 0 x | 存回 x |
| 6 | jmp 0 ? | 占位,jmpIdx = 6 |
| 7 | lit 0 2 | else 分支入口 |
| 8 | sto 0 x | 存回 x |
| 9 | — | endif,此时cx = 9 |
回填之后,code[3].a = 7、code[6].a = 9。两个数字的指向必须精确:jpc的目标是 else 分支的第一条指令,不是 else 关键字、也不是 jmp;jmp的目标是 if 语句之后的第一条指令,不是 if 语句的最后一条。差一位的后果是少执行或多执行一条指令,而少执行的那条很可能是sto,于是变量停留在上一次的值上,输出结果看似随机。
回填顺序也有讲究。必须先把jpc填成 else 的入口,再解析 else 分支,否则 else 分支的语句会覆盖掉还没填的占位指令;而jmp的填充必须在 else 分支解析完之后,因为它的目标是 endif,只有 else 分支生成完才知道 endif 在哪。
4.3 没有 else 的路径也必须回填
最容易漏的是else那半边的反面:只有 then 分支、没有 else 的时候。此时code[jpcIdx].a如果保持占位的 0,条件为假时pc被设成 0,程序从头开始重新执行,表现是死循环或者反复输出同一段内容。这类 bug 的恶心之处在于测试覆盖:只测条件为真的用例,全部通过;一旦条件为假就崩溃。
把出口统一成一处就能根治:
/* 不管有没有 else,if 语句的出口都收敛到同一个 cx,代码形状一致 */ if (sym == elsesym) { int jmpIdx = cx; gen(jmp, 0, 0); code[jpcIdx].a = cx; /* 假 → else 入口 */ getsym(); statement(); code[jmpIdx].a = cx; /* then 末尾 → endif */ } else { code[jpcIdx].a = cx; /* 假 → endif,和上面同一个 cx */ }这种“单一出口”的形状还有个副作用好处:后面如果要继续扩展成elsif链,只需要在 else 分支里再递归一层 if,出口依旧收敛到同一个cx,生成逻辑不用推倒重来。
5. 避坑与排查:五个真实翻车现场
改动集中在一个小函数里,出问题的位置却可能分布在整个前端。下面五条是编译原理实验里出现频率最高的现象,按“现象 → 原因 → 解决”逐条拆开。
5.1 现象:else 全被当成变量名,报“标识符未定义”
现象是带 else 的 if 一律编译失败,错误号 11,位置指向 else 这个词本身;把 else 换成任意一个已声明的变量名,错误消失。
原因在词法层:else被追加进了word[]的末尾而不是do与end之间,二分查找的表序前提被破坏,else永远查不到,回落到ident。其余保留字位置未动,仍然查得到,所以只有 else 出问题。
解决办法是把else插到正确位置,并在main开头加上 2.2 节那段顺序自检。自检比事后调试便宜得多,它把“我没写错”这个假设变成了可执行代码。
5.2 现象:嵌套 if 的 else 绑到了外层,结果和预期差一个分支
现象是if a then if b then x:=1 else x:=2;在 a 为真、b 为假时 x 没有被赋值,或者 a 为假时反而执行了x:=2。
原因是把elsesym加进了statement()第一层的分派条件,else 变成了独立的语句引导符。这样一来内层 if 返回后 else 没有被就近消费,被外层或者更远的调用者拿走,形成最远匹配。
解决是删掉分派里的elsesym分支,只保留 if 分支内部那一处if (sym == elsesym)。改完用两组用例验证:a 真 b 假时 x 应为 2,a 假 b 真时 x 不应被赋值。
5.3 现象:不写 else 的 if 让解释器跑飞,死循环或重复输出
现象是带 else 的程序正常,去掉 else 之后解释器不终止,输出反复刷屏,ctrl-c 才能停。
原因是code[jpcIdx].a在无 else 路径上没有回填,占位的 0 被当成跳转目标,pc归零从头再跑一遍。因为栈没有变化,程序进入稳定循环。
解决是把两条路径的出口统一到同一个cx,如 4.3 节所示。验证方法是单独构造一个只有 then 分支、且条件恒定不成立的用例,跑通了再加 else。
5.4 现象:大写 ELSE 一律不识别,报“语句起始符错误”
现象是IF a THEN x:=1 ELSE x:=2;全部报 14 号错误,小写版本完全正常。
原因是getsym用strcmp直接比对,大小写敏感,而 PL/0 的原始定义只认小写。有的实现把关键字写成大写,词法层不统一,行为就跟着源码大小写飘。
解决是二选一:要么在收集完id之后先整体转小写再查表(用tolower逐字符处理,别用非标准函数),要么明确把语言规范限定为小写并在词法层给出针对性提示。两条路都行,怕的是文档写“大小写敏感”而代码里悄悄做了一半转换。
5.5 现象:错误提示错位,缺 then 却报“表达式错误”
现象是明明少写了 then,编译器报出来的却是表达式相关的错误号,错误信息和你眼睛看到的源码对不上。
原因是err_msg[]字符串表与error(n)的编号没有对齐。加 else 的时候顺手插入了一个新的错误码,编号整体后移一位,字符串表却还在原来的位置,于是错误号 16 打印出 15 号的信息。
解决是把错误码写成enum,字符串表用指定初始化器按名字填,让编译器帮你检查漏项:
enum errcode { e_nul, e_ident, e_const, /* ... */ e_missing_then, e_missing_semi, e_max }; /* 指定下标初始化,插入新码时不会整体错位,漏项编译器会提示 */ const char *err_msg[e_max] = { [e_ident] = "标识符未定义", [e_const] = "常量说明中应为等号", [e_missing_then] = "if 语句缺少 then", [e_missing_semi] = "语句之间缺少分号", };6. 设计取舍:为什么单遍编译器宁愿“占位再回填”
第一次看到code[jpcIdx].a = cx这种写法,我本能的反应是“太土了”:先写个 0 进去,等会儿再回来改,这不是把状态搞乱了吗?建一棵语法树,遍历两遍,第二遍生成代码时所有位置都确定,不是更干净?后来把 PL/0 的四段源码连起来读了一遍才明白,回填不是妥协,而是单遍架构下唯一自洽的选择——它在“位置未定”这件事上做了显式记录,而不是把依赖关系藏在遍历顺序里。
| 维度 | 单遍扫描 + 回填 | 语法树 + 两遍生成 |
|---|---|---|
| 内存占用 | 一条线性的code[],长度正比于指令数 | 树节点 + 符号表 + 代码,通常是数倍 |
| 扩展语法 | 改一处分派,必要时补回填点 | 改 AST 构造和代码生成两处,容易漏 |
| 优化空间 | 几乎没有,地址在生成时就定死 | 有,可做常量折叠、公共子表达式消除 |
| 出错恢复 | 边扫边报,一次扫完给出全部错误 | 通常先建树,语法错误会中断 |
| 调试难度 | 错一位就跳飞,但指令序列可肉眼核对 | 结构清晰,但生成逻辑分散 |
对一门教学用的极小语言来说,单遍的优势很明显:源码到目标代码的映射是逐一对应的,你能拿着一张指令表把每个cx手动数一遍,任何跳转错误都能定位到具体某一行。代价是地址一旦写死就没有优化余地,而且回填点必须手工维护,漏一处就是一个静默的死循环。真要做带优化的编译器,就得换成中间表示加多遍处理,那时候回填这套东西会被更完整的控制流图取代。
值得强调的是,PL/0 里这个取舍还有一个隐藏收益:jpc和jmp已经够用了,不需要为 if-else 新增任何指令,解释器一行都不用改。新语法能不能落到已有指令集上,是判断语法扩展是否“正交”的一个好标准——加 else 只是多了一条跳转,加 for 循环可能就得引入新的循环指令或者写成 while 的语法糖。
从那以后,我每次给这门课的语言加东西,都强制自己走一遍那条五步链:保留字表与wsym对位、sym枚举补项、statement()的 FIRST 集有没有被污染、跳转指令的每个占位有没有对应回填、解释器语义是否支持新生成的指令序列。五步缺任何一步,都不写测试、不跑用例。这份 PL/0 实验资源里正好把这五层拆成了对应的源码段和一组扩展用例,从词法表一路对照到解释器,比自己凭空改一遍省下大量对着错误号猜的时间,值得拿来逐段过一遍。
希望帮到你。
本文还有配套的精品资源,点击获取