简介:这份面向编译原理课程实验的PL/0改造资料包,聚焦在经典教学编译程序上完成词法与语法扩充,适合正在做课程实验、需要参考实现思路与工程组织方式的高校学生。内容围绕新增ELSE、FOR、TO、DOWNTO、RETURN等保留字,以及+=、-=、++、--运算符和将不等号#改为<>展开,并重点实现条件语句的ELSE子句,帮助读者理解词法分析、语法分析与语义处理之间的衔接。压缩包共24个文件,约644KB,包含C++源码与头文件、Visual C++工程配置、实验报告文档、可执行程序及编译过程产生的中间文件,便于直接打开工程对照调试。已有791人学习,可据此梳理PL/0扩展的修改点、测试代码组织与实验报告撰写思路,适合作为课程实验的对照范本。
1. 从一行 ELSE 说起:加个分支语句为什么会牵动整条编译流水线
编译原理实验里最容易被低估的一步,是给 PL/0 加一条 ELSE。多数人第一次听到这个任务的反应是:不就是多认一个保留字、在 if 的代码后面补几行吗?真动手才会发现,保留字表里少一行,ELSE 就成了一个没声明的标识符;分析器里少一次判定,它会跟错误的内层 if 绑在一起;目标代码里少一条跳转,then 分支跑完会径直掉进 else 分支。一条分支语句牵动词法识别、文法匹配、地址回填三条链路,而这三条链路恰好是整个编译流水线最典型的三个截面,读懂它们,等于把 PL/0 的骨架摸了一遍。
2. 保留字表:词法分析器在第一遍扫描时如何决定身份
先看一个具体现象:同样一串字符,else被识别成保留字,elsewhere被识别成标识符,Else在多数实现里又被当成普通标识符。这三者的差别不在字符本身,而在扫描结束后那一次查表。词法阶段唯一真正需要决策的地方就在这里,理解了它,你就理解了为什么关键字通常不做成独立的 DFA 分支。
2.1 保留字不是“识别出来的”,而是“查表查出来的”
教科书里画词法 DFA,常常给每个关键字画一条独立路径:b走到e走到g走到i走到n接受为 begin。这种做法能跑,但代价明摆着——关键字越多,起始状态的分支越密,而且每个关键字都要单独维护一条前缀链。真正写编译器的人几乎不这么干,常见的实现思路是:把所有字母开头的单词一律按标识符规则读满,读到一个非字母数字字符为止,然后拿这个完整单词去关键字表里查一次。查到就返回对应的记号类型,查不到就当普通标识符塞进符号表。
这个策略的好处是把 N 条路径压缩成一条。不管语言有 20 个关键字还是 200 个,词法分析器的主循环长度不变,新增关键字只是往表里加一行数据,属于纯数据驱动。代价是每次识别标识符都要多一次查表,但这次查表本来就要做——标识符也要进符号表,只不过查的是另一张表。
PL/0 的词法主循环通常长这样:全局变量ch存当前字符,sym存当前记号类型,id存当前识别的名字,num存数值。getsym负责推进,遇到字母就进入标识符收集分支。
/* 字母开头:先读满整个单词,再决定它是保留字还是标识符 */ if (isalpha(ch)) { int k = 0; do { if (k < IDMAX - 1) id[k++] = ch; /* 超长截断,防止溢出 */ getch(); /* 取下一个字符 */ } while (isalnum(ch)); id[k] = '\0'; strcpy(id, id); /* 保留原始拼写,符号表要用 */ int p = keyword_lookup(id); /* 关键:整词查保留字表 */ if (p >= 0) { sym = keytab[p].sym; /* 命中,例如 SYM_ELSE */ } else { sym = SYM_IDENT; /* 未命中,走标识符流程 */ } return; }IDMAX是标识符最大长度,超过就截断而不是报错,这是 PL/0 这类教学编译器的常规做法——真正的编译器多半也截断,只是会给出警告。keyword_lookup返回表内下标或 -1。注意do-while的条件是isalnum,意味着数字可以作为标识符的后续字符但不能作为首字符,这个区分由外层isalpha保证。
这段代码里最关键的一行是循环结束后的整词查表。它同时解决了两件事:elsewhere因为整词是elsewhere而查不到,落回标识符;else因为整词精确匹配而命中。如果改成“边读边匹配前缀”,elsewhere会在读到第四个字符时被误判成else,这是词法分析里最经典的一类翻车。
2.2 有序表 + 二分:一次扫描如何挡住 beginx 这种边界
保留字表有三种常见组织方式,取舍各不相同。
第一种是线性数组,从头比到尾。表项只有二十来个的时候完全够用,代码短到可以写在实验报告里,缺点是每个标识符都要做二十次字符串比较。第二种是有序数组加二分查找,要求表按字典序排列,比较次数降到 log₂N,代价是加关键字时必须维持顺序,插错位置会让二分静默失效——这属于典型的“看着能跑,某个词就是查不到”的玄学 bug。第三种是哈希表,常数时间,但要做冲突处理,对实验规模来说属于过度设计。
PL/0 的实现里我一般建议用有序数组加二分,因为它逼着你把“表必须有序”这件事显式化,而这件事恰好是理解保留字机制的一个切口。
/* 表必须按 strcmp 字典序严格递增,否则二分失效 */ static const struct { const char *word; int sym; } keytab[] = { {"begin", SYM_BEGIN}, {"call", SYM_CALL}, {"const", SYM_CONST}, {"do", SYM_DO}, {"else", SYM_ELSE}, {"end", SYM_END}, {"if", SYM_IF}, {"odd", SYM_ODD}, {"procedure", SYM_PROCEDURE}, {"read", SYM_READ}, {"then", SYM_THEN}, {"var", SYM_VAR}, {"while", SYM_WHILE}, {"write", SYM_WRITE} }; static int keyword_lookup(const char *s) { int lo = 0, hi = (int)(sizeof(keytab)/sizeof(keytab[0])) - 1; while (lo <= hi) { int mid = (lo + hi) / 2; int c = strcmp(s, keytab[mid].word); if (c == 0) return mid; /* 命中 */ if (c < 0) hi = mid - 1; /* 待查词更小,往左半区 */ else lo = mid + 1; /* 待查词更大,往右半区 */ } return -1; /* 未命中,按标识符处理 */ }二分本身没有花招,值得说的是两个细节。一是strcmp的比较语义必须和表的排序语义完全一致,如果表是按长度排序的、查找却按字典序比较,那else和end的相对位置就会错乱,表现是某些关键字偶尔查不到、换个词又正常。二是 PL/0 里odd这种既是关键字又像函数名的词,必须进保留字表,否则会被当成未声明标识符而在语义阶段报错——把错误位置从语法阶段推到语义阶段,排查成本会翻好几倍。
2.3 保留字与符号表的分工:为什么 ELSE 绝不能进符号表
初学者常犯的一个结构错误,是把保留字也塞进符号表。表面看没毛病:反正都是名字,统一管理多省事。但符号表的语义是“这个名字绑定到某个存储位置或某个过程”,而保留字的语义是“这个记号类型决定了后面跟什么语法结构”,两者根本不是一类东西。PL/0 里tab[0]通常被保留给最外层 block 的名字(一个空串或$block),真正的用户标识符从tab[1]开始,这个约定本身就是“保留字不占符号表位置”的体现。
一旦把else塞进符号表,会连带出两个问题。第一,用户程序里万一写了var else: integer;,词法阶段已经把else判成SYM_ELSE了,根本走不到声明语句的分支,符号表里那个位置永远空着,但表里确实多了一条永远不会被引用的记录。第二,作用域管理会被污染,过程嵌套时符号表要按层回退,一个不绑定任何存储的名字混在里面,回退逻辑得多写一堆特判。
正确的分工是:保留字只在keytab里出现,符号表只记录用户声明的常量、变量、过程。两者通过sym这个记号类型变量在语法分析阶段交汇——sym == SYM_ELSE走 else 分支,sym == SYM_IDENT才去查符号表拿adr和level。这条分界线划清楚,后面的回填逻辑才不会写乱。
3. 递归下降里的 ELSE:悬挂分支到底归谁
保留字认出来了,接下来的问题是它该和谁配对。if a then if b then x else y里的else,是配内层if b还是外层if a?这个叫悬挂 else 的问题在文法层面是二义的,而在递归下降实现里,答案由代码的调用顺序直接决定——这既是它好写的原因,也是它容易写错的原因。
3.1 二义文法的两种改写方式,选错了就是连锁错误
原始 PL/0 文法里,语句产生式大致是:
statement → ident := expr | if condition then statement | while condition do statement | call ident | read ident | write expr | begin statement; statement; ... end | ε加 else 时最直觉的改法是在if后面直接补一个可选分支:
statement → if condition then statement [else statement]这个写法在纸面上没问题,但它对应的语言是二义的:else既可以归最近的if,也可以归外层。消除二义的标准做法是把语句分成“匹配语句”和“不匹配语句”两类,规定then后面若跟不匹配语句则不能带else。这套改写很严谨,但在手写递归下降里显得笨重,得额外引入两个非终结符、两套函数。
常见的实现思路是走另一条路:不修改产生式结构,而是在if分支处理完then语句之后,立刻检查当前sym是不是SYM_ELSE。因为then后面的statement是一次递归调用,内层if在它自己的递归帧里已经把能吃的else吃掉了,外层再检查时看到的sym必然不属于内层。这就实现了“else 绑定最近未匹配的 if”,而且不需要任何文法改写。
这个做法的实现依据是递归下降的调用栈天然对应语法树的嵌套层次:谁先递归,谁先消费输入。
3.2 statement 返回后立刻看 sym:就近吸收的实现级解释
把上面的思路落到代码,statement的if分支大致是这样:
static void statement(void) { if (sym == SYM_IF) { int cx1, cx2; /* 必须局部:嵌套时外层不能被内层覆盖 */ getsym(); /* 吃掉 if */ condition(&cond); /* 生成条件求值代码,结果留在栈顶 */ if (sym == SYM_THEN) getsym(); else error(16); /* 缺 then */ cx1 = cx; /* 记住 JPC 指令位置 */ gen(JPC, 0, 0); /* 条件为假跳到 else,目标待回填 */ statement(); /* 递归:then 分支,可能吃掉内层 else */ if (sym == SYM_ELSE) { /* 关键判定:此刻的 sym 属于本层 */ getsym(); cx2 = cx; gen(JMP, 0, 0); /* then 执行完必须跳过 else 体 */ code[cx1].a = cx; /* 回填 JPC:假跳到这里 = else 起点 */ statement(); /* 递归:else 分支 */ code[cx2].a = cx; /* 回填 JMP:跳到整个 if 之后 */ } else { code[cx1].a = cx; /* 没有 else,假直接跳到 if 之后 */ } return; } /* while / call / read / write / begin / 赋值 分支…… */ }condition负责把关系表达式编译成“压栈—压栈—OPR 比较”的序列,结果是一个 0/1 值留在栈顶,供JPC判断。JPC的语义是“栈顶为 0 时跳转到地址 a”,所以它正好对应“条件不成立走 else”。
这段代码最容易被忽略的是cx1、cx2声明在分支内部。如果图省事写成全局变量,嵌套if时内层会把外层的cx1覆盖掉,等外层回来执行code[cx1].a = cx时,改的其实是内层那条指令。表现出来就是:单层 if 全对,嵌套两层就跳飞,而且错得很随机——取决于编译顺序。
3.3 缺 then、空语句与错误恢复:分析器的边界在哪
PL/0 的statement允许空语句(ε 产生式),这是它和很多教学语言不一样的地方。begin a := 1; ; end是合法的,中间那个空分号对应一次空statement。这个设计对 else 的影响很直接:如果then后面紧跟else,比如if a then else x := 1,从文法上看then后面是一个空语句,然后才是else,分析器应当接受它。手写实现里这一条往往被漏掉,因为多数人的statement在遇到else这个记号时既不是 id 也不是 while、if、begin,会直接落到错误分支。
处理方式是在statement的入口加一条:如果当前sym属于语句的 FOLLOW 集合(else、end、;),直接返回,当作空语句。这不是偷懒,而是把 ε 产生式显式化。少了这一步,if a then else这种写法会报一个莫名其妙的语法错误,位置还指在else上,让人以为是保留字没认出来——实际上是语句层没接受空产生式。
另一个边界是缺少then。if a x := 1这种写法,报错位置应该在x上还是then该出现的位置上?稳妥的做法是报第 16 号错误并继续往下分析,而不是立刻终止。教学编译器的错误恢复策略通常是“报错但不中断”,依靠跳读记号把分析拉回正轨,这样一次编译能报出多个错误,否则改一个错重编译一次,效率极低。
4. 目标代码回填:THEN 与 ELSE 之间那条跳转链
语法树建对了,最后一步是生成 P-code。这一步的难点不在指令选择,而在地址。条件跳转指令生成的时候,else 体的起始地址还不知道,只能先填个占位值,等 else 体编译完再回来改。这个“先占位、后回填”的机制,是单遍编译器绕不开的核心技巧。
4.1 gen、cx 与 code 数组:一边生成一边补洞
PL/0 的目标代码存在一个数组code[]里,cx是当前可用的下一条指令下标,也是“已生成指令数”。gen做三件事:把指令写进code[cx],cx自增,顺便做越界检查。
#define CODEMAX 2048 typedef struct { int f; /* 操作码:LIT / LOD / STO / CAL / INT / JMP / JPC / OPR */ int l; /* 层次差 level difference,或 OPR 子码 */ int a; /* 地址或立即数,或跳转目标 */ } Instruction; static Instruction code[CODEMAX]; static int cx = 0; /* 下一条指令的下标 */ static int err = 0; /* 错误计数 */ static void gen(int f, int l, int a) { if (cx >= CODEMAX) { err++; return; } /* 溢出保护 */ code[cx].f = f; code[cx].l = l; code[cx].a = a; cx++; }f是操作码,l在LOD、STO、CAL里表示“跨了几层作用域”,在OPR里表示运算子码,在LIT里无用。a的含义随操作码变:LIT是立即数,LOD/STO是变量在活动记录里的偏移,JMP/JPC是目标指令下标。这种“一个字段多种含义”的设计是教学虚拟机的典型做法,省内存、便于打印,代价是可读性差,调试时必须对着操作码表看。
回填的前提是code数组可以被随机访问。生成JPC时先占位a = 0,记住下标cx1,以后用code[cx1].a = cx改写。这正是单遍编译能处理前向跳转的原因:指令序列是顺序生成的,但跳转目标可以事后填。
4.2 JPC 与 JMP 的两处回填,少一条就全错
拿if x > 0 then a := 1 else a := 2举例,假设x在偏移 3、a在偏移 4,最外层lev = 0,生成的指令序列是:
| 下标 | 指令 | 含义 |
|---|---|---|
| 0 | LOD 0 3 | 压入 x |
| 1 | LIT 0 0 | 压入 0 |
| 2 | OPR 0 12 | 比较,结果 1/0 入栈(编号依实现而定) |
| 3 | JPC 0 7 | 栈顶为 0(条件不成立)跳到 7 |
| 4 | LIT 0 1 | 压入 1 |
| 5 | STO 0 4 | 存入 a |
| 6 | JMP 0 9 | then 执行完,跳过 else 体 |
| 7 | LIT 0 2 | 压入 2 |
| 8 | STO 0 4 | 存入 a |
| 9 | … | if 之后的代码 |
这条序列里有两个地址是关键:下标 3 的JPC目标是 7,下标 6 的JMP目标是 9。前者在 else 体开始编译前未知,后者在 else 体结束前未知,两处都得回填。
漏掉JMP是最常见的一类错误:then 分支执行完,控制流顺着 pc 加一,直接落进 else 体,a先被赋 1 再被赋 2,结果永远是 2。这种 bug 特别隐蔽,因为语法通过了、语义通过了、解释器也不报错,只是结果不对。只跑一个if用例就能发现异常,但如果测试用例写的是if a then write(1) else write(2),输出“12”而不报错,很容易被当成打印格式问题放过去。
回填的顺序也有讲究:必须先回填JPC(它的目标是 else 起点,也就是生成JMP之后、else 体之前的那个位置),再生成 else 体,最后回填JMP(目标是 else 体之后的 cx)。顺序反了,JPC就会指到JMP上,then 分支不成立时先去执行一条无条件跳转,绕一圈还是掉进 else,但中间可能多压一层栈,导致后续表达式求值全乱。
4.3 嵌套 if 下 cx1/cx2 的作用域,一个血泪经验
前面强调过cx1、cx2必须是局部变量,这里把后果说透。假设写成全局:
static int cx1, cx2; /* 错误示范:全局 */编译if a then if b then x := 1 else x := 2 else x := 3时,外层先设cx1 = 3(外层 JPC 下标),递归进入内层,内层把cx1改成本层 JPC 下标,内层编译完回填自己的跳转,返回外层。此时外层手里的cx1已经被覆盖,回填时改的是内层那条指令的a字段,外层 JPC 的目标还停在 0。结果是条件为假时跳到指令 0,从程序开头重新执行,解释器要么死循环要么栈溢出。
这个错误的表现是“单层正常、嵌套炸裂”,而且炸裂方式取决于内层的指令数量,非常难通过读代码定位。正确写法是把cx1、cx2定义在if分支的块作用域里,每一层递归都有自己的副本,编译器保证栈帧独立。这条经验放到任何手写递归下降的编译器上都成立:凡是记录“待回填位置”的变量,一律局部化。
5. 排查清单:ELSE 实验里五类高频翻车现场
下面这些是我见过最多的五类问题,每一条都按现象、原因、解决写,能省掉不少对着一屏 P-code 发呆的时间。
5.1 现象:报“变量未定义”,位置指向 else。原因:保留字表没加 else
加上 else 语句处理逻辑、却忘了往keytab里加一行,是最常见的开局错误。词法阶段把else按标识符读入,keyword_lookup返回 -1,sym变成SYM_IDENT,语法分析走到statement的赋值分支,去符号表查这个名字,自然查不到,报 11 号错误“标识符未定义”。报错位置在else上,但问题根本不在那儿。
解决办法是同时检查三处:keytab有没有加{"else", SYM_ELSE}、枚举里有没有SYM_ELSE、statement里有没有处理分支。只加枚举不加表项,表现和没加一样;只加表项不加处理分支,会报语法错误而不是语义错误,错误号不同,这也是个快速区分点。
5.2 现象:elsewhere、else1被误识别成保留字。原因:前缀匹配而非整词匹配
如果词法分支写成“读到字母就逐个字符和关键字比较,一旦匹配成功就返回”,那elsewhere会在读到else时被截断成关键字,剩下的where变成下一个记号。这类 bug 的典型表现是:简单的测试用例全过,一旦变量名以关键字开头就崩,而且报错位置很怪,指向变量名中间。
根本原因是单词边界没有判据。正确做法是把整个字母数字串读满再查表,比较函数用完整的strcmp而不是strncmp。如果出于性能考虑用了长度加memcmp,也要先比长度再比内容,长度不等直接判不匹配,不能只比前缀。
顺带一提,大小写敏感也是同一类问题。若保留字表里全是小写,Else就该按标识符处理并报未定义,而不是做大小写折叠。折叠与否取决于语言规范,PL/0 通常敏感,别自作主张加tolower。
5.3 现象:then 分支执行完仍然进了 else 体。原因:漏了无条件跳转
前面说过,这是回填环节的第一号错误。症状是逻辑分支永远走 else,或者赋值结果被覆盖。定位方法很直接:让编译器把生成的 P-code 打印出来(PL/0 实现里一般有个listcode开关),肉眼检查JPC和JMP是否成对出现。一个if-else结构编译出来应该有一条JPC和一条JMP,只有JPC说明漏了跳转。
还有一种变体:JMP生成了,但回填的目标写成了 else 体的起点而不是终点,导致执行完 else 又跳回 else 起点,形成死循环。判断方法是看JMP的a字段是否大于JPC的a字段,正常应该严格大于。
5.4 现象:嵌套 if 时跳转地址错乱,单层测试却通过。原因:回填位置变量被覆盖
就是 4.3 讲的cx1/cx2全局化问题,这里补一个排查手法:在gen里加一条日志,打印cx、操作码和a,同时在每次回填时打印“回填位置 i 的目标改成 j”。正常嵌套的日志应该呈嵌套结构,内外层交替回填;如果看到内层回填之后外层又回填了同一个下标,基本可以确定变量被覆盖。
修复很简单,把变量声明挪进分支块内。但要注意,某些老式 C 编译器要求变量声明在块首,不能在语句中间声明,这时候用一层花括号{ int cx1, cx2; ... }把整个 if 分支包起来即可。
5.5 现象:if a then ; else x := 1报语法错误。原因:空语句没被接受
PL/0 允许空语句,但很多实现在statement入口只判断了标识符、if、while、call、read、write、begin 这几个记号,遇到;或else直接报错。then后面跟一个分号再接else的写法,理论上等价于then后面跟空语句,但从分析器的角度看,它在statement里看到了一个不属于任何语句开头的记号。
修法是在statement入口加一条提前返回:当前sym属于语句 FOLLOW 集合(SYM_SEMICOLON、SYM_END、SYM_ELSE)时直接return,不生成任何代码。这不会破坏其他分支,因为这些记号本来就不该出现在语句开头。
6. 设计取舍:为什么宁可手写分析器也不上生成器
把 else 加完,很多人会冒出一个念头:这套东西用 yacc 或者 bison 半天就写完了,为什么实验非要手写递归下降?答案不是“为了教学”,而是两种方案在可控性上的差别在编译器前半段非常真实。
用生成器,文法写好,冲突它会报给你,悬挂 else 这种移进-归约冲突会以一条警告的形式出现,你选择一个优先级就解决了。省事,但你不知道冲突为什么产生,也不知道选择之后分析栈长什么样。手写递归下降,冲突必须自己想清楚——比如else该在statement返回后立刻判断,还是放到外层语句序列里判断。后者写出来照样能编译,但if a then if b then x else y会把else配给外层,因为内层statement返回后没检查else,控制权交回外层循环才看到它。这个错误用生成器不会犯,用生成器也不会让你明白为什么不会犯。
代价也很明确:手写分析器的错误恢复要自己设计,文法的 LL(1) 性质要自己保证,左递归要自己消。PL/0 的文法足够小,这些成本都可控;换成表达式复杂、运算符优先级十几层的语言,手写下降的维护成本会迅速超过生成器。
另一处取舍是回填的粒度。PL/0 用单遍编译加回填,优点是内存省、结构简单,缺点是code数组必须可随机写,且跳转目标只能往后填、不能往前(往前跳的目标在生成时已知,直接填即可,不需要回填)。真实编译器里也有类似设计,只是回填列表(backpatch list)常常挂在语法树节点上而不是全局数组下标,好处是嵌套时天然隔离,不依赖程序员记得把变量声明成局部。
验证这套改动是否真的对,我一般用三个手段。一是打印目标代码人工核对,重点看JPC/JMP的配对和目标的相对大小关系。二是构造最小用例覆盖分支:单层 if-else、无 else 的 if、then 里套 if、else 里套 if、then 和 else 都套 while,跑一遍看输出是否符合预期。三是拿一个已有的 PL/0 解释器跑生成的 P-code,如果解释器本身没问题而结果错,那大概率是代码生成而不是分析阶段的问题,这个二分能把排查范围砍掉一半。
从那以后我每次给 PL/0 加新语句,都强制走一遍“先写词法表项、再写语法分支、最后确认跳转配对”的顺序,并且在gen里开日志跑最小用例,不再靠肉眼盯一屏 P-code 猜。希望帮到你。
本文还有配套的精品资源,点击获取