☰
PL/0编译实验:保留字识别与ELSE跳转回填避坑指南
2026/10/29 2:48:34 网站建设 项目流程

简介:面向高校编译原理课程学习者与实验开发者,这份资料围绕教学型 PL/0 编译程序的修改扩充展开,可用于完成词法分析、语法分析与语义处理等课程实验,适合需要对照源码理解编译前端改造过程的学生。压缩包共 24 个文件,约 644KB,包含 PL/0 的 cpp 与 h 源码、实验报告文档,以及 dsp/dsw 工程配置、Debug 编译产物和临时文件,便于直接打开工程复现实验并核对输出。内容覆盖保留字 ELSE、FOR、TO、DOWNTO、RETURN 的加入,运算符 +=、-=、++、-- 的扩充,不等号由 # 改为 <>,以及条件语句 ELSE 子句的实现等要求,可帮助读者定位词法表、语法规则与语义动作的修改点。已有 790 人学习,资源中的源码、报告和工程文件可用于参考实验组织方式、检查实现思路与整理实验文档,适合在课程设计或验收前查漏补缺。

1. 一份 PL/0 实验包,为什么卡人的总是保留字和 ELSE

PL/0 的名声是“小”:十几个保留字、几个递归过程、百来行核心代码,看起来一天就能吃透。但某高校编译原理课程的这份实验包,真正让人返工两三轮的从来不是递归下降的主干,而是两件小活:往保留字表里塞进新词之后,标识符识别突然对不上号;把if-then扩成带 ELSE 语句的分支之后,跳转指令回填位置差一条指令,运行结果忽大忽小。这份实验包给的是能直接编译运行的 PL/0 骨架,词法扫描、语法分析、符号表管理和 P-code 解释执行四层齐全,附带若干示例程序,适合刚学完语法分析、想在一个晚上跑通完整链路的人,也适合需要交课程设计、想弄清楚“文法改一行、代码改一片”到底该改在哪的读者。下面按我拆包时实际动手的顺序记录。

2. 拆开 PL/0 实验包的骨架:四个模块的职责边界与改动点

很多人拿到包第一件事是瞎翻代码,翻到statement()就开始改,改完之后发现编译能过、运行结果错,再回头怀疑解释器。我的习惯是先画一张职责边界表,把“谁产生指令、谁消费指令、谁只读不写”钉死,后面的每一次改动都能落到具体文件上,排错范围立刻缩小一半。

2.1 文件职责表:先定位改动点再动手

不同版本的实验包文件切分方式不完全一样,有的拆成六个.c文件,有的合成一个pl0.c;但逻辑边界是一致的。把逻辑模块和物理文件对应清楚,再判断扩展 ELSE 语句需要触碰哪几处。

逻辑模块典型文件关键符号职责扩展 ELSE 是否必改
词法lexer.cgetsym、ch、id、num跳空白、切标识符/数字/界符、判保留字是,新增else词
语法parser.cblock、statement、condition、expression递归下降,边解析边调gen是,if 分支要重写
代码生成codegen.cgen、cx、code[]输出 P-code,回填跳转地址是,新增一条JMP
符号表symtab.ctable[]、btab[]、enter、position名字、层级、地址分配否
解释执行interpret.cbase、interp、s[]按code[]逐条执行否,除非新增操作符
公共头pl0.hSYM_*、OPR_*、外置全局量常量与全局状态是,加SYM_ELSE

表里“否”的那一行最值得注意:符号表和解释器在 ELSE 扩展中完全不动。这是 PL/0 结构带来的便利——ELSE 只是控制流,不引入新作用域、不引入新类型、不需要新的运行时原语。凡是有人告诉你“ELSE 要在解释器里加分支”,基本都是把JMP和JPC记混了,这两个操作码在原始解释器里本来就实现好了。

提示:改之前先把这份表抄进实验报告,评审时能直接说明改动范围,比事后回忆靠谱得多。

2.2 词法层:保留字、标识符、界符在一趟扫描里怎么分家

PL/0 的词法扫描是一趟式的,getsym()被语法分析反复调用,每次至少吃掉一个词法单元。核心判定顺序是固定的:先跳空白,再按首字符分三路——字母走标识符/保留字,数字走无符号整数,其余走界符表。第三路里最容易翻车的是双字符界符:=、<=、>=、<>,必须先读第二个字符再决定,否则:会被单独吐出去,语法分析端立刻报“缺少:=”。

/* lexer.c —— getsym 主流程(节选) */ void getsym(void) { while (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') getch(); /* 跳过空白,注意不要吞掉 EOF */ if (isalpha(ch)) { /* 字母开头:标识符或保留字 */ int n = 0; do { if (n < ID_MAX - 1) id[n++] = ch; /* 超长截断但继续扫描 */ getch(); } while (isalnum(ch)); id[n] = '\0'; sym = lookup_kw(id); /* 命中返回保留字编号,否则 SYM_IDENT */ } else if (isdigit(ch)) { /* 数字开头:无符号整数 */ num = 0; do { num = num * 10 + (ch - '0'); getch(); } while (isdigit(ch)); sym = SYM_NUMBER; if (num > NUM_MAX) error(31); /* 溢出要在词法阶段就拦住 */ } else if (ch == ':') { /* 双字符界符必须先看第二个字符 */ getch(); if (ch == '=') { sym = SYM_BECOMES; getch(); } else sym = SYM_COLON; } else if (ch == '<') { getch(); if (ch == '=') { sym = SYM_LEQ; getch(); } else if (ch == '>') { sym = SYM_NEQ; getch(); } else sym = SYM_LSS; } else { sym = SYM_NULL; /* 非法字符,交给上层报错 */ getch(); } }

几个参数要交代清楚。ID_MAX是标识符缓冲长度,一般取 32 就够,但循环里我用的是“超长截断但继续扫描”的写法——如果直接break,剩余字符会被下一轮当成新的标识符,错误信息会指向一个根本不存在的名字,排查时极其迷惑。NUM_MAX对应目标机整数上限,标准 PL/0 取 32767,超了必须报错而不是静默截断,否则运行期会得到一个莫名其妙的负数。lookup_kw是唯一与保留字表打交道的地方,第 3 章会专门重写它。

else这个词加进来之后,词法层面只需要动两处:保留字表里加一条记录,pl0.h里加SYM_ELSE枚举。但有个顺序问题——枚举值一旦插入中间位置,所有用数字硬编码的地方都会错位。安全做法是把SYM_ELSE追加到枚举尾部,或者干脆全部用符号名,不写数字。

2.3 语法层:表达式、语句、分程序的三级递归结构

PL/0 的语法分析是教科书式递归下降,三层嵌套:block处理分程序(常量、变量、过程声明 + 复合语句),statement处理语句,expression → term → factor处理表达式。三者的调用关系是单向的,statement调expression求条件,expression回头不会调statement,所以不存在相互递归的复杂度。

原始版本的 if 语句处理逻辑很简短,理解它是后面改 ELSE 的前提:

/* parser.c —— 原版 if 语句(无 else) */ case SYM_IF: getsym(); condition(); /* 生成条件表达式代码,结果留在栈顶 */ if (sym == SYM_THEN) getsym(); else error(16); /* 缺少 then */ { int jpc = cx; /* 记住 JPC 指令在 code[] 中的下标 */ gen(JPC, 0, 0); /* 占位:条件为假时跳转,地址待回填 */ statement(); /* then 分支 */ code[jpc].a = cx; /* 回填:跳到 then 分支之后 */ } break;

cx是“下一条指令将写入的位置”,也就是当前代码区指针;gen(op, l, a)往code[cx]写指令然后cx++。这里的关键动作是“先占位、后回填”——gen的时候还不知道跳转目标在哪,只能写 0,等 then 分支编译完,cx正好指向分支之后的指令,再把地址塞回去。这个模式会在 ELSE 扩展里用两次,一次给JPC,一次给JMP。

真正容易忽略的是condition()之后栈的状态:条件表达式执行完,栈顶恰好有一个 0/1 值,JPC的语义是“弹出栈顶,为 0 则跳转”。所以JPC必须紧跟在条件代码之后,中间不能插入任何其他指令。这一点决定了THEN之前不能做任何额外操作,包括“为了调试打印点东西”——加一条gen就破坏栈平衡。

2.4 符号表与运行时栈:为什么 ELSE 扩展可以完全不碰它们

符号表部分由table[](名字表)和btab[](分程序表)组成,enter负责登记名字,position负责在作用域链上查找。变量的地址不是绝对地址,而是“层差 + 偏移”的二元组,运行时由base()沿着动态链回溯到目标层,再加上偏移定位。

ELSE 扩展不涉及任何新名字、新作用域、新类型,所以这两个数据结构一行都不用改。语法分析进入statement时没有enter调用,level和dx保持不变,btab里也不会多一条记录。换句话说,if ... then ... else ...里的两条分支共享同一个分程序上下文,变量可见性完全一致,这也是 Pascal 系语言的标准行为。

运行时栈同理。JMP和JPC在标准解释器的switch里都有现成实现:JPC弹栈判断后修改pc,JMP直接修改pc。只要回填的地址正确,执行流就正确。所以排查运行结果异常时,第一优先级不是去读interpret.c,而是把code[]反汇编打出来,逐条核对跳转目标。这个习惯我保持了很久,能省掉大量猜测。

3. 保留字识别改造:从全表 strcmp 到分桶查表的落地

保留字识别在实验包里通常是一个十行左右的小函数,全表线性strcmp,命中即返回。教学代码这么写没问题,但只要你往里加词,就会开始暴露两个问题:一是每次标识符识别都要走完整张表,程序里变量越多越亏;二是新增保留字后如果忘了同步SYM_*枚举,编译器会把else当普通标识符放过去,语法分析端报出来的错误位置完全对不上真实原因。第 3 章就是把这个函数重做一遍,并把验证手段补齐。

3.1 三种组织方式的对比与选型

保留字表怎么存,取决于你更在意哪一点:改动量、查找速度,还是出错时的可诊断性。常见做法有三种,我在不同场合都用过。

方案平均比较次数改动量主要风险适用场景
线性数组 +strcmp约 n/2,n≈14 时约 7 次最小,加一行即可词越多越慢,容易漏改枚举课程实验原始形态
按首字母/长度分桶约 1~2 次中等,需要建桶桶下标算错导致漏查保留字 15~40 个
完美散列/查表1 次较大,需要生成器生成表与源码不同步工业级关键字集

对 PL/0 这种规模,我一般选分桶:比线性快一截,比完美散列省事,而且桶的构造逻辑本身可以写在源码里,评审时一眼能看懂。关键点是桶的下标必须由函数算,不能靠手写常量——手写常量就是把“表”和“哈希函数”两份真相分开维护,早晚不同步。

3.2 用拉链法把保留字表挂进桶里

下面这段是可直接替换的版本:静态表kw_pool是唯一真相,启动时由kw_init按哈希值挂链,lookup_kw只做“算下标、走链、比较”。

/* lexer.c —— 分桶 + 拉链的保留字表 */ #define KW_BUCKET 16 #define KW_MAX 32 typedef struct { const char *name; int sym; /* 必须与 pl0.h 中的 SYM_* 枚举严格一致 */ int next; /* 同桶内下一条下标,-1 表示链尾 */ } kw_entry; static const kw_entry kw_pool[] = { {"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 kw_entry kw_tab[KW_MAX]; /* 运行期副本,next 字段会被改写 */ static int kw_head[KW_BUCKET]; static int kw_cnt; /* FNV-1a 简化版:位运算够快,且不需要额外链接库 */ static unsigned kw_hash(const char *s) { unsigned h = 2166136261u; while (*s) { h ^= (unsigned char)*s++; h *= 16777619u; } return h % KW_BUCKET; } void kw_init(void) { int i; kw_cnt = (int)(sizeof(kw_pool) / sizeof(kw_pool[0])); if (kw_cnt > KW_MAX) error(99); /* 表溢出,编译期就该拦 */ for (i = 0; i < KW_BUCKET; i++) kw_head[i] = -1; for (i = 0; i < kw_cnt; i++) { unsigned b = kw_hash(kw_pool[i].name); kw_tab[i] = kw_pool[i]; kw_tab[i].next = kw_head[b]; /* 头插,顺序无所谓 */ kw_head[b] = i; } } int lookup_kw(const char *name) { int i = kw_head[kw_hash(name)]; while (i != -1) { if (strcmp(kw_tab[i].name, name) == 0) return kw_tab[i].sym; i = kw_tab[i].next; } return SYM_IDENT; /* 未命中即普通标识符 */ }

逻辑上有三点需要说明。第一,kw_pool用const修饰,把“源表”和“运行期表”分开,kw_init只写kw_tab,这样即使某个环节把链表写坏了,下次初始化依然能恢复正确状态,调试时少一个变量。第二,kw_hash用的是 FNV-1a,字符串短的时候性能差别不明显,但它分布均匀、实现只有三行,比“首字母减 'a' 再取模”这种写法稳得多——后者在大小写混排或新增以数字结尾的词时容易撞成一堆。第三,头插法意味着同桶内的顺序与声明顺序相反,这无所谓,因为查找只关心命中与否。

参数方面:KW_BUCKET取 16 对应 14 个保留字,平均每桶不到一条,比全表扫描少一个数量级的比较;如果后续扩展到 30 个以上,把它改成 32 或 64 即可,kw_hash里的取模会自动跟随,不需要改其他地方。KW_MAX是硬上限,比kw_pool实际条数大一些留余量;一旦超了就在初始化阶段报错,而不是运行时越界写内存——这种错误在实验环境里极难定位。

lookup_kw的返回值直接决定getsym里sym的取值,所以它的契约必须写死:命中返回对应保留字的SYM_*,未命中返回SYM_IDENT,绝不返回 0 或 -1。这一点在原始代码里经常被写成“未命中返回 0”,一旦SYM_NULL恰好也是 0,非法字符和普通标识符就分不开了。

3.3 编译期自检:一张覆盖表把回归挡住

改完词法最怕的是“看起来能用”。我一般会在实验包里加一个自检入口,把每个保留字单独写成一个最小程序,跑一遍确认都能被正确识别;再补几个“长得像保留字”的标识符,确认不会被误伤。

# 逐个保留字走查:每个词拼一个最小可编译片段 for kw in begin call const do else end if odd procedure read then var while write; do printf '%s\n' "$kw" > /tmp/probe.pl0 ./pl0c /tmp/probe.pl0 > /tmp/probe.out 2>&1 grep -q "SYM_$(echo $kw | tr a-z A-Z)" /tmp/probe.out \ && echo "[OK] $kw" \ || echo "[FAIL] $kw -> $(head -1 /tmp/probe.out)" done

这段脚本依赖编译器带一个-d之类的词法调试开关,把每次getsym的结果打出来。如果没有这个开关,就临时在getsym返回前加一行打印,验完再删。脚本本身不复杂,价值在于把“我记得改过”变成“每次都跑一遍”。

紧接着要跑反向用例:elsewhere、ifx、end1、do_2这类名字必须被识别为SYM_IDENT。原始线性表版本偶尔会在strncmp的长度参数上写错,导致前缀匹配——elsewhere被当成else后面跟一个where,报错位置飘到下一行,这种 bug 靠读代码很难发现,靠用例一秒钟暴露。

3.4 枚举与表的同步机制

最后一件事:SYM_*枚举和kw_pool的sym字段是两份数据,天然有不同步的风险。低成本的做法是在kw_init里加一次断言式检查——遍历kw_pool,确认每个sym值都在合法区间内且互不重复。重复检测用一个位图,几十行以内解决。

/* kw_init 末尾追加:枚举值唯一性检查 */ static void kw_check(void) { static unsigned char seen[SYM_MAX]; int i; for (i = 0; i < kw_cnt; i++) { int s = kw_pool[i].sym; if (s <= SYM_NULL || s >= SYM_MAX) error(98); /* 越界 */ if (seen[s]) error(97); /* 重复 */ seen[s] = 1; } }

这类自检不是“为了规范”,是实打实省时间的。血泪经验是:插入新枚举值到中间,导致if和then的编号整体后移,词法层照旧返回旧编号,语法层switch全部走空分支——编译器不报错,生成的代码是空的,运行结果一切正常(因为什么都没执行)。这种“静默错”最难查,加一次唯一性检查就彻底堵死。

4. ELSE 语句落地:文法、递归下降、P-code 回填的三处联动

ELSE 语句的扩展是这个实验包里改动链条最长的一处:文法要改、语法分析要改、代码生成要多一条JMP,三处任何一处不同步,表现都是“能编译、结果错”。我按依赖顺序拆成四步走,每一步都能单独验证。

4.1 文法改造与 FIRST/FOLLOW 快速自检

原始 if 语句的产生式只有一支,改成两支(带 else 和不带 else)之后,必须确认它不会和后续语句的首符集冲突。

<语句> ::= <赋值语句> | <条件语句> | <循环语句> | <复合语句> | <读语句> | <写语句> | ε <条件语句> ::= <if 子句> <then 子句> [ <else 子句> ] <if 子句> ::= IF <条件> <then 子句> ::= THEN <语句> <else 子句> ::= ELSE <语句>

用方括号表示可选,而不是拆成两条产生式,是因为递归下降实现里“可选”对应的是if (sym == SYM_ELSE)一次判断,拆成两条反而要在入口处做回溯或者提取公因子。检查点有两个:<then 子句>的 FOLLOW 集里含ELSE,所以<语句>不能以ELSE开头——PL/0 的语句首符集里确实没有ELSE,这一条安全;<条件语句>的 FOLLOW 集里含END、;、ELSE,其中ELSE的出现正是“悬挂 else”的来源。

4.2 递归下降里的悬挂 else 与向前看一个符号

PL/0 的语法分析器只维护一个当前符号sym,没有回溯。这在处理悬挂 else 时反而是优势:statement()解析完 then 分支返回后,sym恰好是紧随其后的那个词法单元,如果是ELSE,就归当前这个if;如果不是,就归外层。这天然实现了“else 就近匹配”,和 Pascal 的语义一致。

/* parser.c —— if 语句(含 else),先占位后回填 */ case SYM_IF: { int jpc, jmp = -1; getsym(); condition(); /* 结果 0/1 留在栈顶 */ jpc = cx; gen(JPC, 0, 0); /* 条件为假 → 跳到 else 段起点 */ if (sym == SYM_THEN) getsym(); else error(16); statement(); /* then 分支,sym 停在分支之后的符号上 */ if (sym == SYM_ELSE) { jmp = cx; gen(JMP, 0, 0); /* then 走完 → 跳过 else 段 */ code[jpc].a = cx; /* 回填:JPC 落到 else 段第一条指令 */ getsym(); statement(); /* else 分支 */ } code[jpc].a = (jmp >= 0) ? code[jpc].a : cx; /* 无 else 时 JPC 落到 if 之后 */ if (jmp >= 0) code[jmp].a = cx; /* 回填:JMP 落到 if 之后 */ break; }

这段代码里有三处细节值得展开。第一,jmp初始化为 -1 而不是 0,因为 0 是合法的指令下标,用它当“未设置”标志会在 if 语句位于程序第一条指令时误判。第二,code[jpc].a在带 else 和不带 else 两种情况下回填的值不同:带 else 时指向 else 分支起点,不带 else 时指向 if 之后——后者等价于“没有要跳过的内容”,所以可以省略回填,但显式写出来更清楚。第三,JMP的下标必须在statement()之前就记下来,因为 then 分支的编译会推进cx,事后再想找位置就得靠搜索,容易出错。

关于悬挂 else,还有一个容易踩的点:如果 then 分支里嵌套了一个不带 else 的 if,内层 if 会把外层想留给自己的ELSE抢走。这不是 bug,是“就近匹配”的必然结果,标准 Pascal 也是这个行为。如果你的实验要求里明确写了“else 与最近的未匹配 if 配对”,那这段代码就是对的;如果要求是“与外层配对”,就必须在 then 分支里禁止裸 if 语句,或者引入begin...end强制包裹。动手前先确认要求,别写完才发现语义不对。

4.3 目标代码生成:JPC 与 JMP 的回填格式

gen本身很朴素,作用就是把四元组写进code[]并推进cx。真正决定正确性的是回填时机和地址语义。

/* codegen.c —— 指令生成与地址回填 */ void gen(int op, int l, int a) { if (cx >= CODE_MAX) error(30); /* 代码区溢出 */ code[cx].f = op; /* 操作码 */ code[cx].l = l; /* 层级,跳转指令恒为 0 */ code[cx].a = a; /* 目标地址或立即数 */ cx++; }

参数含义需要明确:l对JMP/JPC没有意义,一律写 0;a在LIT里是立即数,在LOD/STO里是偏移,在JMP/JPC里是绝对指令下标。正因为a的语义随操作码变化,回填时最容易发生的错误是“把 bx 的相对偏移写进了 a”——JPC的a必须是绝对下标,解释器执行pc = code[pc].a是直接赋值,不做任何加法。

一个完整的 if-else 结构编译出来应该是这样的形态,用一组具体数字看得更清楚:

下标指令含义
10JPC 0 14条件为假,跳到 14(else 段)
11~12...then 分支代码
13JMP 0 18then 结束,跳过 else 段
14~17...else 分支代码
18...if 语句之后的代码

这张表是我的排错模板。每次结果不对,先把code[]打出来,找JPC和JMP,看它们的目标下标是不是恰好落在“else 段第一条”和“if 之后第一条”上。对不上就是回填次序错了,对上了还错就是解释器或者别的地方有问题,判断路径非常干脆。

4.4 用对照程序验证跳转链

验证不能只跑一个“结果是 1”的用例,必须覆盖四条路径:条件真 + 有 else、条件假 + 有 else、条件真 + 无 else、条件假 + 无 else。下面这个程序一次跑完前两条。

{ test_else.pl0 —— 覆盖 if-else 的两条分支 } var a, b; begin a := 1; b := 0; if a > 0 then b := 10 else b := 20; write(b); { 期望输出 10 } if a < 0 then b := 30 else b := 40; write(b) { 期望输出 40 } end.

两个write的输出必须分别是 10 和 40。如果第一个输出 20,说明JPC的目标指到了 else 段但条件判断反了;如果第一个输出 10、第二个输出 30,说明JMP没生效,then 分支执行完直接掉进了 else 段。这两种现象对应两种不同的回填错误,靠输出值就能把范围锁定到一行代码。

再把 else 分支换成嵌套 if,确认跳转链没有串位:

{ test_nest.pl0 —— else 分支里再嵌 if-else } var x, y; begin x := 2; y := 0; if x > 1 then if x > 5 then y := 1 else y := 2 else y := 3; write(y) { 就近匹配:期望输出 2 } end.

这个用例的价值在于它会同时考验“就近匹配”和三层跳转的回填。如果输出 3,说明内层 if 的 else 被外层抢走了,语法分析的实现与预期语义不一致;如果输出 1,说明条件比较方向错了。跑通这四个用例,ELSE 扩展的代码生成部分基本可以判定为正确。

5. 避坑排查:五条让实验结果飘掉的细节

下面五条是我在拆这类实验包时反复遇到的真实故障,每条都按“现象 → 原因 → 解决”记录,可以直接当排错清单用。

现象一:加了else之后,变量名elsewhere、else1报“非法符号”。原因多半是保留字判定用了前缀比较,strncmp(id, "else", 4)返回 0 就当命中,elsewhere被切成else+where。解决是把比较改成等长判定,strcmp或者“先比长度再比内容”,并在反向用例里加上三五个以保留字为前缀的标识符,作为固定回归项。

现象二:条件为真时走了 else 分支。先别怀疑JPC的实现,九成是condition()生成的代码把结果压反了,或者JPC被插在了条件代码中间。检查方法是看JPC是否紧跟在条件表达式的最后一条指令之后,中间只要夹了一条LOD或OPR,栈顶就不是条件值。这类错误在嵌套表达式里特别隐蔽,因为外层看起来完全正常。

现象三:then 分支只有一条语句时正确,有两条就崩。原因通常是语句之间的分号处理。PL/0 的复合语句是begin 语句; 语句; ... end,最后一个分号可有可无,如果statement()里没有正确吃掉分隔符,第二条语句会被当成新语句的开始,而cx已经推进,回填位置整体偏移。解决是回到block的语句循环,确认“分号在语句之间、不在语句之后”的处理逻辑。

现象四:JMP的目标地址总是差 1。这是最经典的回填错误:code[jmp].a = cx写在了cx++之前或之后的位置不对。cx的语义是“下一条待写指令的下标”,所以回填必须在“else 分支已经编译完、接下来要写下一条指令”的那一刻执行。如果提前一条,跳转会落在 else 分支的最后一条指令上,重复执行一次;如果晚一条,会跳过 if 之后的第一条语句。用 4.3 那张下标表对照,一眼能看出来。

现象五:词法改了、语法改了,编译通过,运行时“什么都不做”。这是枚举不同步的典型症状:SYM_ELSE插在枚举中间导致后续编号整体后移,词法层返回的是新编号,而某个switch里还写着旧编号的case,落到了default的空分支。解决是把枚举值全部改成显式赋值(SYM_IF = 20这种),或者在kw_init里加唯一性检查,让问题在启动阶段就暴露。这类“静默错”比崩溃难查十倍,值得多花十分钟做防御。

注意:以上五条里,前四条都能靠“打印code[]反汇编”快速定位,第五条只能靠自检。所以我在任何 PL/0 改造里都会保留一个-d编译开关,输出词法单元序列和 P-code 清单,验完再关掉。

6. 进阶验证:把 ELSE 扩成 ELSIF 并跑通三段式检查

ELSE 跑通之后,很多人会顺手想加ELSIF。这件事的难度不在语法,而在“少一层嵌套、多一次跳转”的等价变换是否彻底。我的做法是先把else if手写成嵌套形式,确认语义基线,再做词法层面的合并。

6.1 两种 ELSIF 实现路径的取舍

路径一是在词法层把elsif当成新保留字,语法层新增一条产生式,代码生成时把它展开成“else 分支里再套一个 if”。路径二是干脆不做新关键字,只允许else if连写,靠递归下降自然形成嵌套。后者零改动,前者能少一层JMP,但要多维护一个保留字和一条产生式。

维度新增elsif关键字复用else if嵌套
词法改动加一条表项 + 一个枚举无
语法改动新增产生式,需处理悬挂无
生成代码少一条JMP每层多一条JMP
出错风险枚举同步、优先级基本为零
适用场景分支链超过 4 层分支链 2~3 层

实验场景下我一般选第二条,因为 PL/0 的程序规模小,多几条JMP对解释执行没有可感知影响,而少一处改动就少一处可能出错的地方。真要练手,第一条更有价值——它逼你重新走一遍第 3 章和第 4 章的全部流程。

6.2 三段式检查:词法、P-code、运行结果缺一不可

不管选哪条路,验证都必须分三段做,不能只看最终输出。第一段查词法:把源程序喂给带调试开关的编译器,确认每个else、elsif都返回正确的SYM_*,elsewhere这类名字仍是SYM_IDENT。第二段查 P-code:人工核对每个JPC和JMP的目标下标,确保落在段边界上,用 4.3 那张表逐行比对。第三段才跑运行结果,并且至少覆盖“全真”“全假”“真假交替”三种输入。

# 三段式检查的落地脚本:词法 → P-code → 运行 ./pl0c -d test_else.pl0 > lex.log 2>&1 # 第一段:词法单元序列 grep -c "SYM_ELSE" lex.log # 期望等于源文件里 else 的个数 ./pl0c -p test_else.pl0 > pcode.log 2>&1 # 第二段:P-code 反汇编 awk '/JPC|JMP/ {print}' pcode.log # 人工核对目标下标 ./pl0vm pcode.log # 第三段:解释执行

脚本里的-d和-p需要自己在实验包里加,加起来不到三十行,但它把“凭感觉判断对错”变成了“看数据判断对错”。我吃过一次亏:某个版本的JMP回填在“else 分支是空语句”时会指到下一条指令的中间,输出碰巧正确,直到换了一个分支里有write的用例才暴露。从那以后我每次改完语法或代码生成,都强制走一遍这三段,一段不过就不往下走。

如果实验要求交报告,这三段日志本身就是最好的材料:词法日志证明保留字改造生效,P-code 日志证明跳转链正确,运行输出证明语义符合预期,三样东西放一起,比任何文字描述都有说服力。希望帮到你。

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

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

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

立即咨询