简介:PL/0编译程序是计算机科学家N.Wirth设计的经典教学编译器源码,适合编译原理课程学习、毕业设计参考及教师备课使用;其功能精简而结构完整,能清晰呈现词法分析、语法分析、语义处理与解释执行等高级语言编译器实现的基本技术与步骤。压缩包内共2个文件,主体为一个C源文件和一个C头文件;.c文件承载编译核心逻辑,.h文件声明关键数据结构与函数接口,整包仅11KB,代码量小,适合逐行精读与二次调试。目前已有1207人学习下载,实用性与认可度较高。获取后可获得可直接编译运行的完整源码,对照N.Wirth原书算法研读,可快速掌握递归下降分析、符号表管理与代码生成等核心环节;整体模块边界清晰,便于按需扩展或移植,也可作为课程设计与实验报告的代码基底,对编译原理入门者尤为友好。
1. 什么是pl0编译程序C语言版源码:一个晚上读懂编译器的最小入口
PL/0 编译程序是 Niklaus Wirth 设计的教学用编译器,用 C 语言重新实现后通常只有几百行到一千行源码,却完整包含了词法分析、递归下降语法分析、中间代码生成和解释执行四个核心环节。它不是拿来生产用的,而是让学编译原理的人看到一个完整编译器在真实代码里长什么样:全局符号表怎么建,token 怎么被吃进去,P-code 怎么一条条生成、再被虚拟机解释掉。这门课里最容易让人卡住的就是"理论知道但代码读不进去",而 PL/0 的 C 语言版源码正是打破这个卡点的最小样本。适合刚结束编译原理课程想补实践的学生、准备课程设计需要可改源码的人、想复用这套骨架做语言实验的开发者。
2. 从源码看懂PL/0的编译流水线:先建立四段处理框架
2.1 一段最小测试程序如何走完整条流水线
先给出一段后面反复要用的测试程序:
var x, y; begin x := 1; y := x + 2; write y end.这段程序能覆盖 PL/0 编译程序 C 语言版源码里的大部分数据结构:var触发声明处理,begin/end构成语句块,赋值语句的:=是词法分析里最容易写错的单元,表达式x + 2会同时产生变量引用、整数常量和加法运算的 P-code。整段程序的编译过程在源码里是顺序执行的:词法函数getsym不断产出symbol值,语法分析函数block、statement根据当前符号决定下一步,生成指令写入全局数组code,最后在虚拟机里逐条执行,把y的值打印出来,结果是 3。
PL/0 是单遍编译,边解析边生成代码,而不是现代编译器那种前端、优化、后端的多趟结构。也正因为单遍,源码里几乎每个语法子程序都在调用getsym向前看一个符号,这种"超前读取一个 token"的模式会在第 4 章反复出现。想读懂 C 语言版源码,第一步不是逐行读,而是先定位四个全局区域:符号表表项、保留字表、P-code 数组、虚拟机的栈和程序计数器。大多数经典实现会把源文件一次性读进内存,然后在一个全局char数组里用下标推进词法指针,这也让第 3 章的缓冲区边界问题变得很有代表性。
2.2 C语言源码的全局数据结构:从枚举到符号表
PL/0 的 C 语言版源码通常从一堆宏和枚举开始:
#define NORW 13 /* 保留字数量 */ #define ID_MAX 12 /* 标识符最大长度+1 */ #define MAX_CODE 512 /* P-code 数组大小 */ #define STACK_SIZE 2048 /* 虚拟机栈深度 */ enum symbol { nul, ident, number, plus, minus, times, becomes, lparen, rparen, eql, neq, lss, leq, gtr, geq, callsym, beginsym, semicolon, endsym, ifsym, whilesym, betweensym, dosym, readsym, writesym, oddsym };保留字和运算符都编进同一个枚举,词法分析在返回符号时只需要交给语法分析一个整数,语法分析再拿这个整数做分支跳转。这里容易产生误解的是callsym:它对应保留字call,而不是"函数调用"。枚举的顺序在多个版本里是固定的,因为后面关键字表要按这个顺序初始化,一旦改了顺序,整个保留字识别就会跟着错位。很多人第一次浏览源码时,会误以为ident、number是终端状态,其实它们是词法分析返回的 token 类型,属于编译器的"外部输入"侧。
符号表是一个结构体数组:
typedef struct { char name[ID_MAX]; int kind; /* 变量/常量/过程 */ int address; /* 在数据栈中的位置 */ } SymbolEntry; SymbolEntry table[1000];常量、变量、过程都混在同一张表里,靠kind区分,address对变量来说是栈位置,对过程来说是 P-code 入口地址。PL/0 没有作用域嵌套,所以这种扁平表是安全的。如果读者打算把源码扩展成带嵌套作用域的语言,这张表就必须换成哈希表加作用域链,这是后话。读这张表时还要注意:源码里所有对符号表下标的访问都要自己保证不越界,因为它没有像现代语言那样的容器边界检查。
2.3 为什么大量使用全局变量:教学源码的取舍
C 语言版 PL/0 源码里几乎没有封装,所有关键状态都是全局的:当前符号sym、当前标识符name、当前数字num、当前源文件位置pos、P-code 数组code、程序计数器pc、栈顶指针sp。这种风格在现代工程里会被批评,但它有一种特殊的可读性:每一个全局变量都对应编译过程的一个状态维度。调试时可以在任何位置打印pos、sym、pc看当前进展,这比把状态塞进结构体再层层传递更直观。我自己读这份源码时,最常用的调试手段就是在关键函数入口加一句printf("pos=%d sym=%d\n", pos, sym);,立刻能看到分析器推进到哪一步。
源码的主循环大致长这样:
int main(int argc, char *argv[]) { if (argc < 2) { printf("usage: pl0 source.pas\n"); return 1; } srcpos = 0; read_source(argv[1]); /* 一次把所有内容读入 buffer */ getsym(); block(0, 0, &stack_top); if (sym != periodsym) error(5); interpret(); return 0; }这段主循环把整个编译流程点清楚了:读第一个 token、进入顶层block、最后必须遇到点号.、然后执行。注意 PL/0 的源文件结束符号是period,不是 EOF,所以文件最后通常要有end.才能通过解析。这个约定是初学者最容易忽略的:很多人把end当成结束符,忘记了点号,结果语法分析一直报错。
2.4 P-code指令集:源码里的虚拟机到底执行什么
PL/0 的中间代码是一组极简的栈式指令,C 语言版源码一般用宏表示:
#define LIT 1 /* 压入常量 */ #define OPR 2 /* 算术运算 */ #define LOD 3 /* 读取变量 */ #define STO 4 /* 存储变量 */ #define CAL 5 /* 调用过程 */ #define INT 6 /* 开辟栈空间 */ #define JMP 7 /* 无条件跳转 */ #define JPC 8 /* 条件跳转 */ #define RED 9 /* read */ #define WRT 10 /* write */生成器在语法分析过程中不断发出这些指令,例如x := 1会先生成一个LIT 1,再产生一个STO x。解释执行器则是另一个大switch,逐条取指令,操作自己的数据栈。这里有个关键设计:操作数不是像很多虚拟机那样从操作数栈里取,而是直接以字面量形式跟在指令后面。比如LIT 1在数组里就是code[pc] == LIT、code[pc + 1] == 1,pc一次跳两格。这种"定长操作码 + 变长操作数"的模式让虚拟机循环非常短,也适合初学者看。
如果读者之前接触过 JVM 字节码,会立刻发现 PL/0 的OPR指令带一个子功能号,本质上是一族运算指令的入口。源码里一般会用一个独立的execute_opr函数处理opr的子功能,比如OPR 0 0表示过程返回,OPR 0 2表示乘法。这种设计让主循环短,但把复杂度转移到了execute_opr内部,读代码时要注意这层映射。
2.5 错误处理:源码里最容易被忽略的全局视角
C 语言版 PL/0 有一个统一的错误输出函数,形如:
void error(int n) { printf("line %d: error %d\n", line, n); }它只输出行号和错误编号,不做像现代 IDE 那样精确到字符位置的提示。原因很简单:错误编号对应的错误文本在语法分析函数里通过注释标明,例如error(4)对应"未声明的标识符"。这种设计适合教学,但实际调试时很不友好。我一般会改造成一个带错误码到文本映射的函数,比如error(4)直接输出identifier not declared,能让后面的排错效率高很多。
源码里对错误的恢复策略也很有限:大多数版本在遇到错误后是直接终止,而不是跳过一段继续解析。这意味着你一次运行只能看到一个错误,必须修完再跑。想拿它做课程设计的人,第一个可做的优化往往就是"支持错误恢复",这个主题够写一篇实验报告,也足够锻炼处理状态回退的能力。
3. 动手跑通词法分析器:关键字表、token识别与缓冲区边界
3.1 保留字表与枚举顺序的联动
词法分析的入口是getsym(),它每次被调用就更新全局sym值。首先要处理的是字母开头的单词,可能是保留字也可能是自定义标识符。源码里预先初始化一个保留字表:
char word[NORW][ID_MAX] = { "begin", "call", "const", "do", "end", "if", "odd", "procedure", "read", "then", "var", "while", "write" }; char name[ID_MAX];这个表必须和前面enum symbol里从callsym开始的顺序保持一致。初始化之后,识别单词的代码一般是:
void getsym(void) { int i, j; while (buffer[pos] == ' ' || buffer[pos] == '\n' || buffer[pos] == '\r') pos++; if ('a' <= buffer[pos] && buffer[pos] <= 'z') { j = 0; while (('a' <= buffer[pos] && buffer[pos] <= 'z') || ('0' <= buffer[pos] && buffer[pos] <= '9')) { if (j < ID_MAX - 1) name[j++] = buffer[pos]; pos++; } name[j] = '\0'; for (i = 0; i < NORW; i++) if (strcmp(name, word[i]) == 0) break; if (i < NORW) sym = (enum symbol)(i + callsym); else sym = ident; } else if ('0' <= buffer[pos] && buffer[pos] <= '9') { num = 0; while ('0' <= buffer[pos] && buffer[pos] <= '9') { num = num * 10 + (buffer[pos] - '0'); pos++; } sym = number; } else { switch (buffer[pos++]) { case '+': sym = plus; break; case '-': sym = minus; break; case '*': sym = times; break; case '(': sym = lparen; break; case ')': sym = rparen; break; case '=': sym = eql; break; case ',': sym = comma; break; case '.': sym = periodsym; break; case '<': if (buffer[pos] == '=') { sym = leq; pos++; } else if (buffer[pos] == '>') { sym = neq; pos++; } else sym = lss; break; case '>': if (buffer[pos] == '=') { sym = geq; pos++; } else sym = gtr; break; case ':': if (buffer[pos] == '=') { sym = becomes; pos++; } else sym = nul; break; default: sym = nul; break; } } }这个函数有几个决定行为的关键参数:ID_MAX控制标识符长度,buffer数组大小必须容纳整个源文件,NORW是保留字数量。sym = (enum symbol)(i + callsym);这一行是理解整个保留字映射的核心:枚举中callsym是第一个保留字对应的值,而word表中下标i从 0 开始,所以i + callsym能把begin、call、const…… 依次映射到callsym、beginsym、constsym……。如果你增删保留字,必须同步改NORW、word数组和枚举顺序,顺序一错,识别就整体偏位。
3.2 缓冲区边界:单词截断与指针移动的坑
C 语言版的 PL/0 通常把整个源文件读入一个char source[MAX_SRC]数组,然后只用pos下标来推进。这个设计跟常见命令行程序的逐行读取不同:一次性读入虽然简单,但缓冲区边界、跨行单词识别、行号统计都要自己处理。上面的while条件只检查字符范围,没有显式判断pos越界,靠的是文件末尾的'\0'作为终结符。一旦你把读文件方式改成按块读、或者在数组末尾丢掉了'\0',整个词法分析就会莫名其妙越界,这是很多移植版本翻车的第一现场。
另一个经典坑是if (j < ID_MAX - 1)这个保护:标识符长度超过缓冲时,多余字符被丢弃,但pos仍然继续推进。也就是说,丢弃的是名字的一部分,而词法分析并不会停顿。例如把variableNameTooLong当标识符时,name里只保存了前 11 个字符,后面的字符仍然被吃掉,最终查表结果可能不是预期标识符。这在 C 语言里尤其容易和数组越界混淆:你看到符号表里有个名字被截断了,却不知道截断来自词法层。我一般会在getsym里加一段if (j >= ID_MAX - 1) printf("warning: identifier too long\n");,至少能在第一次遇到超长标识符时给出提示。
还有一个和"数组、指针移动、指定位输出字符"相关的点:有些移植版会把pos改写成char *p,效果等价,但pos++和p++在调试时的表现不一样。源码里通常是pos++,这里隐藏了"超前回看"问题:识别:=时,switch里case ':'会先执行pos++,然后在分支里查看新位置的字符是不是=,也就是说 token 识别成功后pos已经指向 token 之后。理解这个推进节奏,才能在调试中判断当前sym到底消费了多少字符。很多人打印pos时发现数值比自己预测的大 1,就是因为把pos++的时机看错了。
3.3 词法层三个高频翻车点
第一个翻车点是:单独出现。如果源文件里把:=写成: =,中间多了一个空格,词法分析会先识别出:,因为下一个字符是空格而不是=,于是返回nul符号,语法分析立刻报错,但提示信息往往让人摸不着头脑。解决办法是在错误信息里把 token 名字映射成可读形式,而不是直接打印nul这个枚举值。对使用者来说,这个坑很难避开,只能靠写测试程序时保持赋值号紧凑。
第二个翻车点是大小写。很多经典版本只认小写字母,一旦源码里出现大写BEGIN就会变成ident,然后被当成未知变量。PL/0 语言本身没有规定大小写敏感,但 C 语言版源码的word表清一色小写,所以直接支持大写需要额外处理。我在自己改的版本里加了tolower转换,但如果你想先跑通原始源码,建议输入全部小写。
第三个翻车点是注释。原始 PL/0 通常不支持注释,所以源码文件里一旦出现{ }或//,会直接变成非法字符,导致后面所有语法错乱。很多拿到 C 语言版源码的人第一反应是"我加几行注释在源码里不行吗",这完全是用法错误。想支持注释,就需要在getsym里单独加状态:识别到/之后看下一个是不是*,是就一直跳到*和/配对为止。这个扩展代码量很小,但能很好地训练状态机思维。
4. 递归下降语法分析与P-code解释执行:从statement到虚拟机
4.1 语法子程序的调用链:每个函数各管什么
C 语言版 PL/0 的语法分析采用递归下降,核心函数有六个:block、statement、condition、expression、term、factor。调用关系可以概括为一张表:
| 函数 | 识别对象 | 会调用谁 |
|---|---|---|
| block | 声明部分 + 语句部分 | statement |
| statement | 赋值 / call / begin / if / while / read / write | expression、block |
| condition | 关系表达式 | expression |
| expression | 加减表达式 | term |
| term | 乘除表达式 | factor |
| factor | 变量 / 常量 / 括号表达式 | expression |
这张调用链有强烈的递归下降特征:statement里遇到标识符就按赋值处理,遇到begin就进嵌套语句块,遇到if则解析条件再解析statement;block又在处理过程声明时再次调用自己。因此代码里常出现if (sym == beginsym) { getsym(); do { statement(); } while (sym == semicolon); getsym(); }这样的结构,整段代码用递归表达文法,基本没有循环展开,读起来很像文法的直接翻译。
PL/0 的文法是 LL(1) 的,不需要回溯,向前看一个符号就足够。这个性质很重要:如果你试图改动文法,比如加入else,一定要保证新的文法仍然是 LL(1)。否则源码里这种if (sym == ...) getsym();的结构很可能会因为超前符号不足而在某个分支上选错,这也是很多人改 PL/0 改到一半翻车的原因。
4.2 从赋值语句看代码生成:STO指令怎么发出来
以赋值语句为例,statement中处理标识符的部分大致是:
if (sym == ident) { int idx = table_lookup(name); /* 查符号表 */ if (idx < 0) error(4); /* 未声明 */ if (sym == becomes) { getsym(); expression(); emit(STO, 0, table[idx].address); } else { error(2); /* 缺少 := */ } }注意这里的调用顺序:先识别出标识符、查表,再取得:=,然后调用expression()递归解析右边的表达式,最后发STO。这意味着整个表达式已经把计算结果压到栈顶,STO再从栈顶弹出写入变量地址。指令生成方式统一叫emit,函数很简单:
void emit(int op, int level, int value) { code[cx++] = op; code[cx++] = level; code[cx++] = value; }level参数在这个教学编译器里通常用来区分当前过程层次,但赋值和算术运算的level常常是 0,只有在访问非局部变量时才使用“当前层和目标层的静态差”。源码里cx是全局的"代码指针",每次 emit 后自动前进。这里要提醒一点:emit里连续三次向code数组写值,编译期不会做边界检查,所以如果生成代码量超过MAX_CODE,程序会静默越界。把MAX_CODE调大是运行更大测试程序的前提。
4.3 表达式解析的优先级如何实现
表达式处理是递归下降最优雅的部分。expression处理加减,term处理乘除,两个函数通过互相调用来实现运算符优先级,而不需要优先级表。代码大约如下:
void expression(void) { int op; if (sym == plus || sym == minus) { if (sym == minus) emit(LIT, 0, 0); /* 处理一元负号 */ getsym(); } term(); while (sym == plus || sym == minus) { op = (sym == plus) ? ADD : SUB; getsym(); term(); emit(OPR, 0, op); } }这段代码展示了单遍编译的核心:每读到一个运算符,就先生成右操作数的代码,再生成运算指令。由于栈式虚拟机天然支持后缀计算顺序,最后生成的指令序列与表达式求值顺序完全一致。term和factor的写法与expression对称,只是运算符换成times和除号。如果要给 PL/0 增加乘法优先级之外的运算符,只需要在更深的层次扩展对应函数,不需要额外建算符优先级表。
4.4 解释执行器:看 pc 和 sp 怎么动
语法分析结束后,cx里保存 P-code 总数,然后调用interpret()。解释执行器的核心围绕三个全局量:pc指向下一条指令在code数组中的下标,stack是操作数栈同时存放局部变量和帧信息,sp是栈顶指针。典型执行循环如下:
void interpret(void) { pc = 0; stack[0] = 0; /* 保留栈底 */ sp = 1; while (pc >= 0 && sp < STACK_SIZE) { int op = code[pc]; switch (op) { case LIT: stack[sp++] = code[pc + 1]; pc += 2; break; case LOD: stack[sp++] = stack[base + code[pc + 1] - 1]; pc += 2; break; case STO: stack[base + code[pc + 1] - 1] = stack[sp - 1]; sp--; pc += 2; break; case OPR: pc = execute_opr(code[pc + 1]); break; case JMP: pc = code[pc + 1]; break; case JPC: if (stack[sp - 1] == 0) pc = code[pc + 1]; else pc += 2; sp--; break; case WRT: printf("%d\n", stack[--sp]); pc++; break; default: printf("unknown opcode %d\n", op); return; } } }LIT直接把立即数压栈,LOD/STO操作的都是stack[base + offset],base是当前过程活动记录的基址。这里最容易写错的是每个指令的pc步长:emit时一条指令占 3 个元素,但有些操作码在解释执行时因为不需要操作数只占 1 个或 2 个元素,例如WRT只有一个 opcode,所以pc++就够了。很多运行到一半跳飞的 bug 都来自某个指令的步长写错,表现为"程序输出几个数之后突然跑到未知指令"。
解释器没有现代虚拟机里的 GC、方法区、异常处理,它只是用栈模拟变量和过程调用。想给语言加新语法时,这一层往往是改动最少的:新语法生成新指令后,只需在switch里加一个case。但要记住,STACK_SIZE很小,默认 512 或 2048,递归过程稍多就会栈溢出,调试时把STACK_SIZE调到 4096 以上能省很多事。
5. 本地编译与运行排查:在 VSCode 里跑通 PL/0 源码的常见坑
5.1 现象:VSCode 里按运行按钮,gcc 报error: 'getch' was not declared
很多旧版 C 语言 PL/0 源码为了在 Windows 终端里暂停,会调用getch()或getche()。在 macOS、Linux 或新版 MinGW 里,conio.h不存在,编译直接失败。原因是这个函数是 DOS 时代的终端函数,不是标准 C。解决办法是注释掉所有getch()调用,或者在文件顶部做兼容封装:
#ifdef _WIN32 #include <conio.h> #else #include <unistd.h> static inline void getch(void) { /* no-op */ } #endif但更重要的是想清楚:源码里调getch()只是为了在输出完 P-code 后暂停窗口,去掉它完全不影响编译逻辑。实际项目里我都是直接注释,因为后续要在管道或重定向环境下跑测试,任何挂起等待输入的函数都会干扰自动化脚本。
5.2 现象:输入文件名后程序没有任何输出,终端直接结束
在 VSCode 默认配置下,程序运行结束后终端可能会立刻关闭,所以看不到interpret()的输出。这个不是源码 bug,而是运行环境设置。解决方法是打开tasks.json/launch.json里的集成终端选项,或者直接在命令行手动执行生成的程序。想用 VSCode 跑 C 语言,最稳妥的路径是:安装 C/C++ 扩展,在终端里执行gcc pl0.c -o pl0,再执行./pl0 test.pl0。这类"编译正确但看不到运行结果"的问题,十有八九与编辑器把输出藏起来了有关。
5.3 现象:call被识别成变量,导致过程调用全部错乱
现象是源码里call foo一执行,符号表里就多出一个变量call,然后语法分析在statement里找不到callsym分支。原因是保留字表和枚举顺序没有对齐。比如你把call从保留字表里删掉,或者把枚举里callsym的位置动了,word表下标偏移就会错位。解决方法是借助调试器在getsym()的for循环里观察strcmp结果,确认"call"的返回值确实是callsym。改过这个的人都会养成习惯:改动保留字表的同时,把NORW、enum symbol和word数组三者一起改全,缺一不可。
5.4 现象:运行var x; begin x := 1; ... end.时报错"缺少正规声明"
这个常见于源码对声明部分的处理:PL/0 规定变量必须在var声明里列出,且声明部分结束后必须出现begin,否则block会认为声明还没结束。如果测试程序里用了const或procedure,但没有把它们放在正确位置,声明识别阶段就会提前退出。解决方法是把测试程序写成标准顺序:常量声明、变量声明、过程声明都放前面,然后begin。另外整个源文件必须以.结尾,很多人写end忘记加小数点,导致语法分析在最后卡住。
5.5 现象:解释执行到第几十条指令时栈溢出,sp超过 STACK_SIZE
常见于递归过程或嵌套循环比较深的程序。PL/0 的虚拟机栈非常小,很多版本默认只有 512 或 2048,一个简单的斐波那契递归就可能溢出。解决方法是把源码顶部的#define STACK_SIZE 2048调大,同时检查虚拟机代码里是否存在某条指令没有正确递减sp的 bug。一个很实用的技巧:在interpret的大循环里加入if (sp >= STACK_SIZE) { printf("stack overflow\n"); return; },花五分钟就能帮你定位是栈不够还是程序逻辑死循环。我在跑带递归的示例时,通常一上来就把栈改成 4096,否则无关的溢出错误会浪费很多时间。
6. 进阶:把 PL/0 改成你自己的编译器试验台
一个很好的起步改造是给 PL/0 增加repeat ... until循环。原版只有while,而repeat是"先执行循环体,最后判断条件,条件为假跳回入口"的语义。这个改动涉及三个层面:词法里加保留字,语法里加分支,代码生成里处理跳转地址。先改词法,在枚举和保留字表里加上repeatsym和untilsym,注意同步NORW。然后改语法:
if (sym == repeatsym) { getsym(); int cx1 = cx; /* 循环体入口地址 */ statement(); if (sym == untilsym) { getsym(); condition(); emit(JPC, 0, cx1); /* 条件为假则跳回 */ } else error(9); }这里cx1必须在解析循环体之前保存,因为后面的statement()会生成新代码,而JPC的目的地就是循环体起点。验证方式是用一个累加程序:
var i, sum; begin i := 1; sum := 0; repeat sum := sum + i; i := i + 1 until i > 10; write sum end.运行结果应为 55。如果跳转方向写反,程序会在第一次条件判断时就退出或死循环,这时打开虚拟机打印的 P-code 列表,对照JPC的跳转地址就能快速定位。我最初给 PL/0 加这个语句时,把JPC的条件写反,结果until i > 10变成"大于 10 就继续",排查了一个小时才在打印出的指令里发现问题。那次之后我养成了一个习惯:每次改完语法,先构造最小测试程序,再看生成的 P-code,最后才看运行结果。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取