
简介面向高校编译原理课程学习者与实验开发者这份资料围绕教学型 PL/0 编译程序的修改扩充展开可用于完成词法分析、语法分析与语义处理等课程实验适合需要对照源码理解编译前端改造过程的学生。压缩包共 24 个文件约 644KB包含 PL/0 的 cpp 与 h 源码、实验报告文档以及 dsp/dsw 工程配置、Debug 编译产物和临时文件便于直接打开工程复现实验并核对输出。内容覆盖保留字 ELSE、FOR、TO、DOWNTO、RETURN 的加入运算符 、-、、-- 的扩充不等号由 # 改为 以及条件语句 ELSE 子句的实现等要求可帮助读者定位词法表、语法规则与语义动作的修改点。已有 790 人学习资源中的源码、报告和工程文件可用于参考实验组织方式、检查实现思路与整理实验文档适合在课程设计或验收前查漏补缺。1. 一份 PL/0 实验包为什么卡人的总是保留字和 ELSEPL/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弹栈判断后修改pcJMP直接修改pc。只要回填的地址正确执行流就正确。所以排查运行结果异常时第一优先级不是去读interpret.c而是把code[]反汇编打出来逐条核对跳转目标。这个习惯我保持了很久能省掉大量猜测。3. 保留字识别改造从全表 strcmp 到分桶查表的落地保留字识别在实验包里通常是一个十行左右的小函数全表线性strcmp命中即返回。教学代码这么写没问题但只要你往里加词就会开始暴露两个问题一是每次标识符识别都要走完整张表程序里变量越多越亏二是新增保留字后如果忘了同步SYM_*枚举编译器会把else当普通标识符放过去语法分析端报出来的错误位置完全对不上真实原因。第 3 章就是把这个函数重做一遍并把验证手段补齐。3.1 三种组织方式的对比与选型保留字表怎么存取决于你更在意哪一点改动量、查找速度还是出错时的可诊断性。常见做法有三种我在不同场合都用过。方案平均比较次数改动量主要风险适用场景线性数组 strcmp约 n/2n≈14 时约 7 次最小加一行即可词越多越慢容易漏改枚举课程实验原始形态按首字母/长度分桶约 12 次中等需要建桶桶下标算错导致漏查保留字 1540 个完美散列/查表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 21 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没有意义一律写 0a在LIT里是立即数在LOD/STO里是偏移在JMP/JPC里是绝对指令下标。正因为a的语义随操作码变化回填时最容易发生的错误是“把 bx 的相对偏移写进了 a”——JPC的a必须是绝对下标解释器执行pc code[pc].a是直接赋值不做任何加法。一个完整的 if-else 结构编译出来应该是这样的形态用一组具体数字看得更清楚下标指令含义10JPC 0 14条件为假跳到 14else 段1112...then 分支代码13JMP 0 18then 结束跳过 else 段1417...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被切成elsewhere。解决是把比较改成等长判定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 层分支链 23 层实验场景下我一般选第二条因为 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 21 # 第一段词法单元序列 grep -c SYM_ELSE lex.log # 期望等于源文件里 else 的个数 ./pl0c -p test_else.pl0 pcode.log 21 # 第二段P-code 反汇编 awk /JPC|JMP/ {print} pcode.log # 人工核对目标下标 ./pl0vm pcode.log # 第三段解释执行脚本里的-d和-p需要自己在实验包里加加起来不到三十行但它把“凭感觉判断对错”变成了“看数据判断对错”。我吃过一次亏某个版本的JMP回填在“else 分支是空语句”时会指到下一条指令的中间输出碰巧正确直到换了一个分支里有write的用例才暴露。从那以后我每次改完语法或代码生成都强制走一遍这三段一段不过就不往下走。如果实验要求交报告这三段日志本身就是最好的材料词法日志证明保留字改造生效P-code 日志证明跳转链正确运行输出证明语义符合预期三样东西放一起比任何文字描述都有说服力。希望帮到你。本文还有配套的精品资源点击获取