
简介面向高校编译原理课程学习者与课程实验备赛者这份广工编译原理实验资料以教学型PL/0编译程序为基础聚焦词法分析、语法分析与语义处理的修改扩充任务适合需要完成保留字与运算符扩展、条件语句增强的读者参考。压缩包共24个文件约644KB包含cpp与h源代码、doc实验报告、dsp/dsw工程文件以及exe、pdb、obj等编译生成与调试产物另有txt、tmp等辅助记录便于对照工程结构和运行结果理解实现过程。围绕ELSE、FOR、TO、DOWNTO、RETURN等保留字、-、、--等运算符以及将不等号#改为、为条件语句补充ELSE子句等要求资料可用于梳理词法表扩充、语法规则调整和语义动作衔接的关键环节。已有790人学习适合希望从源码与实验报告入手快速定位PL/0改造点并复盘编译流程的读者。1. 从一段跑不通的 PL/0 程序说起验收现场最容易被问住的一句话是这段程序为什么跑不了。手里这段 PL/0 源码只有一句if x 2 then write(x) else write(0)丢进原版编译器词法分析把else当成了普通标识符语法分析读完 then 后面的语句抬头看见一个 identifier直接落到error(16)屏幕上只剩一个错误号。原因很简单——原版 PL/0 的保留字表里没有elseif 语句的语法规则也只写到 then 分支为止。编译原理实验里这道经典改造题就是围绕保留字表、语法规则、目标代码三处做加法加一个 ELSE 语句顺带把词法、递归下降、P-code 回填串起来跑通一遍。适合正在做编译原理实验、要交一份能现场演示完整链路的读者也适合想把 PL/0 当玩具扩成自己小语言的人。2. 工程全貌一份 PL/0 实验工程由哪几块拼起来一个能交差的 PL/0 实验工程规模其实很小通常两三千行 C但它的价值在于链路完整字符流进去P-code 出来再喂给一个三十行的解释器跑出结果。改造 ELSE 语句之所以能当实验题就是因为它把这条链路上的四个环节全串了一遍——你不改词法else进不来不改语法else没处放不改代码生成跳转地址是错的不改测试用例你根本发现不了前三个错。2.1 目录结构与文件职责多人协作或后期要交报告的话别把全部代码塞进一个pl0.c。按编译阶段拆文件的版本更好改也更容易在报告里讲清楚每一段在干什么pl0-lab/ ├── Makefile ├── src/ │ ├── pl0.h # 符号常量、保留字表声明、指令集、符号表结构 │ ├── lex.c # getch / getsym字符流 → token 流 │ ├── parse.c # 递归下降block / constdecl / vardecl / statement / expression │ ├── codegen.c # gen / cx / 回填辅助函数 │ ├── main.c # 打开源文件、初始化、调 block()、落盘 .pcode │ └── vm.c # P-code 解释器一个 while 循环加一个栈 └── test/ ├── pass_if_then.pl0 ├── pass_if_else.pl0 ├── pass_dangling.pl0 ├── fail_no_then.pl0 └── run_all.sh文件主要职责改 ELSE 时是否要动pl0.h符号常量、指令枚举、结构体要加符号常量和保留字声明lex.c切词、查保留字表要加表项和整词比较parse.c语法规则、调用 gen 生成指令要if 语句分支重写codegen.c生成指令、维护 cx、地址回填一般不动靠返回值回填vm.c执行 P-code不用动JMP/JPC 已经够用test/正例与反例要至少补 3 个用例我一般会先看pl0.h和codegen.c这两个文件定了全局约定指令长什么样、地址怎么数。约定清楚了改语法的时候心里才有底知道自己填的那个数字到底是第几条指令。2.2 一遍扫描的架构为什么 PL/0 把词法、语法、代码生成揉在一起PL/0 最反直觉的地方是它没有抽象语法树。教科书里讲编译分前端后端、中间表示但 PL/0 的参考实现是典型的一遍扫描getsym()往前挪一个 tokenparse.c里的递归下降函数就地判断判断完立刻调gen()吐一条指令。省掉了建树和遍历代价是所有回填都得手工做出错时也没有中间结构可以打印出来看。/* pl0.h —— 指令与符号表的最小结构 */ typedef enum { LIT, LOD, STO, CAL, INT, JMP, JPC, OPR } OpCode; typedef struct { OpCode f; /* 操作码 */ int l; /* 层差用于跨过程访问变量 */ int a; /* 参数常量值、数据区偏移、跳转目标地址 */ } Instruction; typedef struct { char name[MAX_ID_LEN 1]; int kind; /* SYM_CONST / SYM_VAR / SYM_PROCEDURE */ int val; /* 常量值或变量在数据区里的偏移 */ int level; /* 声明所在层主程序为 0 */ int addr; /* 过程入口地址供 CAL 使用 */ int size; /* 过程数据区大小供 INT 使用 */ } SymEntry;Instruction只有三个字段l和a的含义随操作码变化LOD 3 5表示向上跨 3 层、取偏移 5 的变量JPC 0 13表示条件为假时跳到第 13 条指令。这种“一码多用”的设计让指令表很紧凑也埋了个坑——你改a字段的时候必须清楚当前这条指令的a是地址还是数值。/* codegen.c —— 生成一条指令把下标返回给调用方用于回填 */ int cx 0; /* 当前指令下标同时也是下一条的地址 */ Instruction code[MAX_CODE]; int gen(OpCode f, int l, int a) { if (cx MAX_CODE) { error(30); return -1; } code[cx].f f; code[cx].l l; code[cx].a a; return cx; /* 关键返回刚生成的下标 */ }gen()返回下标这个细节是整个回填机制的基础。有了它你可以在生成跳转指令时先把a填 0 占位记下返回的下标等目标位置的cx确定了再回头改code[下标].a。如果gen()写成void你就得在调用处自己算cx - 1嵌套语句一多算错是迟早的事。2.3 三个增量点保留字、语句规则、目标代码把 ELSE 语句拆开看它落在三个具体位置而且有严格的先后顺序增量点位置判断标准是否影响解释器保留字else入表lex.c的reserved_words[]token 流里出现symSYM_ELSE否if 语句规则扩展parse.c的statement()能解析if..then..else..否跳转地址回填parse.c里两处code[x].a cx汇编清单里地址连续且指向正确否解释执行vm.c已有 JMP / JPC不需要新增指令顺序不能乱。词法没对齐就改语法报错会混在一起你看到error(16)缺 then可能根本不是语法写错了而是else被当成了标识符让语法分析在错误的位置停下来。血泪经验是先把词法层单独跑通把 token 流打出来确认else的符号码正确再往下动语法。3. 词法分析改造保留字表怎么加才不出错词法分析这一层的活看着最轻——不就是往表里加个字符串吗。但实验里翻车的绝大多数都在这儿因为它牵动两件事一是保留字和标识符的边界二是符号常量的数值对齐。这两件事一旦错位后面语法分析的所有if (sym SYM_XXX)判断都会失效而表现出的现象往往离真正的原因很远。3.1 一张表定生死保留字和标识符的边界先说清楚保留字的判定规则读一个完整的单词去表里整词比较命中就是保留字没命中就是标识符。规则简单实现上有两个细节必须掰扯清楚。第一必须把整词读完再查表。有人图快读到第一个字符就去表里找前缀结果elsewhere会被切成elsewheredoit会被切成doit然后语法分析报一堆莫名其妙的错误。第二别用“数组下标 1”来绑定符号码。这是最隐蔽的坑reserved_words[]的顺序变了符号常量就整体错位而所有判断语句用的还是旧常量编译器不会报警只会静默地判错分支。稳妥做法是在表里显式写出 code 列/* pl0.h —— 保留字表结构名字和符号码显式配对 */ typedef struct { const char *name; int code; } ReservedWord; extern const ReservedWord reserved_words[]; /* pl0.h —— 符号常量集中定义数值固定不随表顺序变化 */ enum { SYM_NULL 0, SYM_CONST 1, SYM_VAR 2, SYM_PROCEDURE 3, SYM_BEGIN 4, SYM_END 5, SYM_ODD 6, SYM_IF 7, SYM_THEN 8, SYM_ELSE 9, SYM_WHILE 10, SYM_DO 11, SYM_CALL 12, SYM_READ 13, SYM_WRITE 14, SYM_IDENT 15, SYM_NUMBER 16 };参数说明name全部小写因为词法阶段会统一转小写再比较code与pl0.h里的枚举值一一对应新增一项只影响它自己数组末尾用{NULL, 0}作哨兵扫描循环靠它收尾不需要额外维护长度常量。新手常问为什么不直接用#define加if-else链条答案是可维护性——十四个保留字写成十四段strcmp加一个就得改结构而表驱动只需要加一行。/* lex.c —— 保留字表本体新增 else 只加一行 */ const ReservedWord reserved_words[] { {const, SYM_CONST}, {var, SYM_VAR}, {procedure, SYM_PROCEDURE}, {begin, SYM_BEGIN}, {end, SYM_END}, {odd, SYM_ODD}, {if, SYM_IF}, {then, SYM_THEN}, {else, SYM_ELSE}, /* ELSE 语句改造的第一步 */ {while, SYM_WHILE}, {do, SYM_DO}, {call, SYM_CALL}, {read, SYM_READ}, {write, SYM_WRITE}, {NULL, 0} };3.2 getsym 的完整改动读整词、转小写、再查表getsym()是词法分析的总入口改造点集中在前半段的标识符分支上。下面是能直接抄的版本注释标了每一段的意图/* lex.c —— 标识符 / 保留字识别 */ static void getsym(void) { /* 1. 跳过空白含 \rWindows 下换行是 \r\n */ while (ch || ch \t || ch \r || ch \n) getch(); /* 2. 字母开头读完整词 */ if (isalpha((unsigned char)ch)) { int k 0; do { if (k MAX_ID_LEN) id[k] (char)tolower((unsigned char)ch); getch(); } while (isalnum((unsigned char)ch) || ch _); id[k] \0; strcpy(sym_name, id); /* 3. 先假定标识符再查表覆盖 */ sym SYM_IDENT; for (int i 0; reserved_words[i].name ! NULL; i) { if (strcmp(id, reserved_words[i].name) 0) { sym reserved_words[i].code; break; } } return; } /* 4. 数字常量累加超上限报 31 */ if (isdigit((unsigned char)ch)) { int num 0; do { num num * 10 (ch - 0); if (num MAX_NUM) error(31); getch(); } while (isdigit((unsigned char)ch)); sym SYM_NUMBER; num_val num; return; } /* 5. 运算符与界符: 双字符要先看下一个字符 */ /* ... 略 ... */ }几个参数和细节值得单独说。tolower()是防呆措施——实验源码常手写ELSE或者If不转小写就会漏判成标识符然后你在语法层找半天。k MAX_ID_LEN的判断是防越界超长标识符直接截断而不是崩掉实验环境里够用。循环条件里加了ch _是因为很多实验版本允许下划线如果课程规定只能用字母数字这一项可以去掉但不能两边不一致——词法认为a_b是一个词、符号表按两个词处理后面查表就会找不到。数字分支里的error(31)是 PL/0 经典错误码“数太大”。这个位置必须检查因为num * 10在int上溢出后是未定义行为测出来的结果跟编译器优化级别都有关系属于典型的玄学现场。3.3 单独验证词法把 token 流打出来再往下走改完词法不要急着编整个项目先加一个调试开关把每个 token 打出来肉眼过一遍。这一步花五分钟能省掉后面两小时的猜谜。/* lex.c —— 编译时打开 -DDEBUG_TOKENS 就打印 token 流 */ #ifdef DEBUG_TOKENS fprintf(stderr, line%-4d sym%-3d text%s\n, line_no, sym, sym_name); #endif# 只编词法层打开调试开关 make clean make CFLAGS-DDEBUG_TOKENS -g # 跑一个带 else 的小程序把 token 流重定向到文件 ./pl0 -d test/pass_if_else.pl0 2 token.log # 确认 else 被识别成 9 号符号而不是 15 号标识符 grep -n textelse token.log正常情况下应该看到sym9 textelse。如果打出来是sym15说明表里没这一项或者比较时大小写没统一如果压根没有这一行说明else被前面的标识符吸收掉了那就是整词读取的循环条件写错了。这一步做完词法层就封版了后面所有问题都可以归到语法或回填上排查范围瞬间缩小一半。4. 语法与代码生成ELSE 语句的完整改法语法层的改造量比词法大但逻辑更线性先把原版 if-then 的处理流程读明白再往里插两条跳转、填两个地址。这段代码是整个实验最值得反复看的地方因为它把“递归下降 手工回填”这套配合演示得非常干净。4.1 先看懂原版 if-then 的骨架原版statement()处理 if 的部分只有这么几行先逐行拆开/* parse.c —— 原版只有 then 分支 */ static void statement(void) { if (sym SYM_IF) { getsym(); condition(); /* 生成比较指令结果 0/1 压在栈顶 */ int jpc cx; gen(JPC, 0, 0); /* 条件为 0 时跳到 a此刻 a 还不知道 */ if (sym SYM_THEN) getsym(); else error(16); /* 缺 then */ statement(); /* then 分支只吃一条语句 */ code[jpc].a cx; /* 回填跳过 then 分支 */ } /* ... 赋值、while、call、复合语句、空语句 ... */ }condition()执行完栈顶是一个 0 或 1 的布尔值。JPC的语义是“弹出栈顶为 0 就跳到a指定的地址否则顺序执行”。因为生成JPC的那一刻还不知道 then 分支有多长目标地址只能先填 0 占位把下标存在jpc里等 then 分支解析完、cx已经指向 if 之后的第一条指令时再回头把地址补上。这就是回填没有它就写不出一遍扫描的编译器。4.2 加 else两处跳转、两次回填有了上面的骨架加 else 就是在合适的位置再插一条无条件跳转并把原来那一个回填点拆成两个/* parse.c —— 扩展后支持 if-then-else */ static void statement(void) { if (sym SYM_IF) { getsym(); condition(); int jpc cx; gen(JPC, 0, 0); /* 占位①条件假 → 待定 */ if (sym SYM_THEN) getsym(); else error(16); statement(); /* then 分支 */ if (sym SYM_ELSE) { int jmp cx; gen(JMP, 0, 0); /* 占位②then 执行完要跳过 else */ code[jpc].a cx; /* 回填①条件假落在 else 入口 */ getsym(); statement(); /* else 分支 */ code[jmp].a cx; /* 回填②else 执行完落在 if 之后 */ } else { code[jpc].a cx; /* 没有 else条件假直接落到 if 之后 */ } return; } /* ... 其余语句 ... */ }对着代码看地址怎么长出来的一段if a b then write(a) else write(b)生成的指令大概是这样地址指令说明10LOD 0 0取 acondition 的第一条11LOD 0 1取 b12OPR 0 12大于比较栈顶变 0/113JPC 0 16占位①回填为 else 入口14LOD 0 0then 分支15OPR 0 14write16JMP 0 18占位②跳过后面的 else17LOD 0 1else 分支起点18OPR 0 14write19...if 之后的语句注意第 16 行那条JMP的位置——它是在 then 分支解析完之后、else 分支开始之前生成的所以它的地址恰好就是 else 分支的起点。这个“恰好”不是巧合而是一遍扫描里唯一能把控制流接上的方式你先跑完 then才知道 else 从哪儿开始你先跑完 else才知道 if 到哪儿结束。参数上还有两处容易写错。gen(JMP, 0, 0)的第二个参数l填 0表示不跨层跳转指令不看层差第三个参数a是绝对指令下标不是相对偏移量跟LOD/STO的a含义完全不同。有人在vm.c里按相对偏移实现JMP编译出来的地址就对不上现象是程序跳飞或者死循环。4.3 悬空 else 与嵌套递归下降天然就近匹配加了 else 之后最容易出事的不是基本用法而是嵌套if a 1 then if b 1 then write(10) else write(20) else write(30)两个else该配哪个if语言设计上通常规定“就近匹配”也就是第一个else归内层 if第二个归外层。好消息是用上面那套递归下降写法这个行为是天然正确的不需要任何特殊处理——外层statement()调用内层statement()解析 then 分支时内层自己会把离它最近的else吃掉等内层返回外层再检查sym是不是SYM_ELSE是的话就配给外层。真正会踩的坑是写法上把它破坏掉。比如有人为了结构清晰把 if 语句抽成独立的if_statement()函数然后在 then 分支解析完之后立刻return外层根本拿不到机会看else第二个else就会变成“多余 else”。要么就在statement()的分支末尾统一处理要么在函数拆分时把else的检查留给调用方二者只能选一个。还有一个细节statement()最后的兜底else error()里错误码要区分场景。如果当前sym是SYM_ELSE说明这里有个没配上 if 的 else报“多余 else”比报“语法错误”有用得多如果当前是SYM_SEMI多半是上一条语句忘写分号。调试的时候这两个错误码能帮你直接定位到行不用一段一段注释代码。4.4 解释器不用改但用例必须压住边界很多人第一反应是“加了新语句解释器得加一条新指令吧”。实际上JMP和JPC这两条指令早就在vm.c里了ELSE 只是把它们组合起来用/* vm.c —— P-code 主循环里与分支相关的两条指令 */ case JMP: pc code[pc].a; /* 无条件跳pc 直接改 */ break; case JPC: if (pop() 0) pc code[pc].a; /* 栈顶为假才跳 */ else pc; /* 为真顺序执行下一条 */ break;pc是指令指针pop()从运行栈取栈顶。这两条实现不用动opr指令表也不用动所以改 ELSE 的工作量全在编译端。理解了这一点你就知道为什么改完语法之后解释器跑出来结果不对——问题一定出在生成的地址上跟执行引擎无关。验收用例按“能覆盖每个分支”来设计四条就够(* 用例一条件为真走 then 分支期望输出 3 *) var x; begin x : 3; if x 2 then write(x) else write(0) end.(* 用例二条件为假走 else 分支期望输出 2 *) var a, b; begin a : 1; b : 2; if a b then write(a) else write(b) end.(* 用例三嵌套加悬空 else就近匹配期望输出 30 *) var a, b; begin a : 0; b : 1; if a 1 then if b 1 then write(10) else write(20) else write(30) end.(* 用例四then 分支要用复合语句期望输出 4 *) var x; begin x : 5; if x 0 then begin x : x - 1; write(x) end else write(0) end.用例三验证的是绑定关系用例四验证的是begin..end和 if 的配合。还有一个反例别忘了故意删掉then跑一遍确认报的是error(16)而不是别的错误码。反例能证明你的错误检测没被 else 的改动破坏这在验收时很加分。如果你的实验版本不支持(* *)注释把每段第一行删掉即可不影响逻辑。5. 常见问题排查编译通过但结果不对的五种现场改完 ELSE 之后最折磨人的不是编译报错而是编译通过、跑起来结果不对或者干脆卡死。下面这几条都是我实际踩过并记在本子上的按“现象 → 原因 → 解决”整理对着抄能省不少时间。5.1 词法与符号表两个静默错位现象一源程序里明明写了else语法分析却报error(16)缺 then或者报“多余标识符”。原因保留字表里漏了这一项else被当成普通标识符返回语法分析读完 then 分支的语句抬头看到SYM_IDENT落到兜底错误分支。解决打开-DDEBUG_TOKENS看 token 流确认else对应的符号码是 9 而不是 15如果表里确实漏了加一行{else, SYM_ELSE}再make clean重编。现象二单独测else是对的但加上while循环之后行为诡异循环体只执行一次就退出。原因用了“数组下标 1”绑定符号码的写法插入else之后所有后续保留字的数值整体后移而语法分析里if (sym SYM_DO)用的还是旧常量导致while后面的do被判成了别的东西。解决改成 3.1 那种显式 code 列的写法符号常量集中定义在一处改表不动常量改完必须make clean增量编译下的目标文件不会感知到头文件里的枚举变化这一类问题十有八九是没清干净。5.2 语法与地址回填两个跳转陷阱现象三条件为假的程序不但没跳过 then 分支还把所有分支都执行了一遍或者输出一串重复结果直到栈溢出。原因JPC的占位地址没回填还是 0解释器执行到那里就把pc设成 0从第一条指令重新跑等于死循环。多半是只在if (sym SYM_ELSE)分支里写了code[jpc].a cxelse分支没有 else 语句的路径漏了这一行。解决把两条路径的回填都写全更稳的做法是在gen(JPC, 0, 0)之后立刻在纸上记下这个jpc解析完整个 if 结构再统一检查每个占位点是否都被改过可以在gen里加个“未回填”的哨兵值比如先把a填-1最后统一assert(code[i].a 0)。现象四if x 0 then x : 1; write(x)这种写法write(x)每次都执行跟预期不符。原因PL/0 的statement()只吃一条语句分号是语句分隔符不是语句块。你以为的“then 后面两句”在语法上只有第一句属于 then。解决要写多条语句就用begin ... end包起来如果是语言设计上想允许那得改语法规则风险比加 else 大得多实验作业里不建议动。5.3 运行期过程数据区没算够现象五主程序能跑但一进过程就吐出乱七八糟的巨大数值或者解释器直接跑飞。原因过程体入口那条INT 0 n的n算少了栈帧分配的数据空间不够局部变量写到了返回地址上CAL返回时跳到错误位置。解决进过程时按“3 个固定槽静态链、动态链、返回地址 局部变量个数”生成INT局部变量个数在vardecl()里统计后存进符号表的size字段不要硬编码。这一条跟 ELSE 改造本身无关但因为你改了语法很容易本文还有配套的精品资源点击获取