简介这是一份以C语言实现的PL/0编译器源码面向编译原理课程学习者、计算机专业学生以及对编译前端实现感兴趣的开发者。PL/0语言由N.Wirth设计功能精简而结构完整其编译器是经典的教学模型本资源可帮助读者对照源码系统掌握词法分析、语法分析、目标代码生成等核心环节的实现思路。压缩包共2个文件含1个C源文件与1个头文件体积仅11KB代码集中轻量、便于阅读调试适合作为课程设计或实验报告的参照。目前已有1208人学习下载。通过阅读这两个文件的源码结构可以直观了解符号表管理、递归下降解析及虚拟机执行等典型编译技术也可在现有基础上扩展PL/0语言能力是编译原理入门与实践结合的实用资料可直接用于分析与二次开发。1. pl0编译程序C语言版源码一门小语言串起编译器的完整工作流如果你是给 PL/0 这类教学编译器写课设的人光看教材里的伪代码肯定睡不着觉。前阵子我帮某导师带学生收作业班上有一半人是用 C 语言实现 PL/0 编译程序的拿到的资料却往往是个黑匣子源码能跑但不知道词法分析怎么扫、语法分析怎么递归、目标指令怎么解释。这套 C 语言版 PL/0 源码恰好把这些环节全部摊开了词法分析、递归下降语法分析、语法制导的代码生成、基于栈式虚拟机的解释执行五个模块加起来只有不到 2000 行 C 代码却能跑通“从源程序到执行结果”的完整链路。它适合两类人——刚接触编译原理的新手想找一个能单步跟踪的活体标本以及要交课设、需要在此基础上扩展 else 分支或循环语句的熟手。2. 系统结构总览PL/0 语言规格与 C 语言实现选型2.1 PL/0 语言一张 A4 纸能写完的语法规格PL/0 是某高校的编译原理教材里定义的一门 Pascal 子集设计目的就是去掉一切和生产编译器相关的噪音只保留编译器骨架。它的语法规格非常精简保留字只有begin、end、if、then、while、do、const、var、procedure、call、odd这 11 个数据类型只有整数语句种类只有赋值、if、while、复合语句begin...end、过程调用这五种。程序结构用 BNF 写出来不超过十行program :: block . . block :: constDecl varDecl procDecl statement . statement :: ident : expression | call ident | begin statement {; statement} end | if condition then statement | while condition do statement .这就是整个语言的全貌。正因为语法这么小代码生成和解释执行才能控制在可读范围内。拿到源码后我建议做的第一件事不是打开.c文件看代码而是先读它自带的pl0.txt示例程序用一段二十行的阶乘或最大公约数程序把流程跑通再回来对照 BNF 看语法——这样看代码时你能立刻知道每个函数在对应哪条产生式。2.2 为什么 C 语言版本最适合当教学底座PL/0 编译器的实现语言其实有很多版本Java 版、Python 版、JavaScript 版都有。但 C 语言版在课程设计里依然是绝对主流核心原因有三个。第一C 没有自带的高层数据结构符号表、指令序列全部要用数组或结构体手工管理这意味着你能看到“表驱动”和“手工索引”的真实形态而不是被dict或Map隐藏掉。第二C 的指针和数组语义直接对应内存模型解释执行器里那个base(level)寻址函数用 Java 写需要两三个对象才能讲清楚C 里就是一段十行的下标运算。第三编译环境极其简单一个.c文件加上stdio.h和stdlib.h就能编译运行不存在依赖地狱。这套源码也遵循了同样的哲学全部代码集中在单个 C 源文件里不依赖任何第三方库。我一般会建议拿到源码的人先不要急着读实现而是在自己的机器上把它编译好跑通自带示例确认识别输出和错误报错都能正常工作。这样后面改动任何模块时都有一个可以回退的基线。2.3 源码包模块划分一个主循环加五个核心函数整个编译器是一个前端分析 解释执行的单趟结构词法分析和语法分析交替进行边分析边生成目标代码最后交给解释执行器运行。函数层面的职责划分大致如下表。模块函数名职责词法分析getsym从源程序读下一个符号区分标识符、数字、保留字、界符语法分析/代码生成block处理 const/var/procedure 声明然后进入statement序列语句分析statement按当前符号分发到赋值、call、begin、if、while 分支表达式分析expression/term/factor处理算术表达式生成对应算术指令解释执行interpret读取指令数组用switch分发指令并维护运行时栈符号表table结构体数组记录标识符的名字、类型、层级、偏移量这套划分值得反复看的是“前端分析和代码生成是合并的”这一点。block函数在处理声明时一边往符号表登记属性信息一边为每个过程生成对应的指令序列过程体的指令采取先占位后回填的方式维护跳转地址。运行时栈和指令数组是共享同一片空间的这也是教学编译器里常见的内存复用策略。提示拿到源码后先把getsym和statement两个函数打印出来用笔标出每个分支配对使用的是哪个产生式这是最快建立全局观的做法。3. 词法与语法从字符流到语法树的两道关卡3.1 词法分析保留字识别、数字扫描与符号表登记词法分析器的核心诉求是把源程序字符流切分成符号流。PL/0 的词法分析函数通常命名为getsym每调用一次就往全局变量sym里放一个符号编号。保留字的处理是经典的表驱动法先预置一个对照表每次识别完标识符如果它在保留字表里就把sym改成对应的保留字编号否则按标识符处理并登记符号表。void getsym(void) { while (ch || ch \n || ch \t || ch \r) // 跳过空白注意 \r nextch(); if (isalpha(ch)) { // 字母开头标识符或保留字 char id[MAX_ID_LEN 1]; int len 0; while (isalpha(ch) || isdigit(ch) || ch _) { if (len MAX_ID_LEN) id[len] ch; nextch(); } id[len] \0; sym NORW; // 先默认是标识符 for (int i 0; i NORW_COUNT; i) { if (strcmp(keyword[i], id) 0) { sym i SYM_BEGIN; // 命中保留字映射到符号编号 return; } } strcpy(name, id); // 真标识符登记名字供符号表使用 return; } if (isdigit(ch)) { // 数字直接扫描成整型字面量 num 0; while (isdigit(ch)) { num num * 10 (ch - 0); if (num MAX_NUM) error(29); // 字面量溢出的容错 nextch(); } sym NUMBER; return; } switch (ch) { // 界符处理 case : sym PLUS; nextch(); break; case -: sym MINUS; nextch(); break; case : sym EQL; nextch(); break; case (: sym LPAREN; nextch(); break; case .: sym PERIOD; nextch(); break; default: error(0); break; } }这段代码里有几个容易踩的细节。第一空白字符集合里一定要包含\r否则在 Windows 下拿 CRLF 换行的源文件跑会报“非法字符”错误。第二保留字判断用的是先扫描整个标识符、再整体查表的方式比“边读边判断前几个字符”的做法更简洁代价是多记了一个临时缓冲区。第三数字没有做进制区分PL/0 只认十进制整型字面量所以扫描逻辑单纯得多。我实际调试时发现字符和的区分也在这里完成——单等号在 PL/0 里既是赋值号也是判断相等号语义上的差别由上下文决定词法阶段只负责给出EQL符号。3.2 递归下降分析文法产生式到 C 函数的一一映射语法分析用的是递归下降这在教学编译器里几乎是唯一选择不需要额外的文法改写产生式长什么样函数就长什么样。statement函数的写法是标准模板——按当前符号分派void statement(void) { switch (sym) { case IDENT: { // 赋值语句 int ix position(name); // 查符号表得到编号 if (ix 0) error(11); // 未声明标识符 getsym(); if (sym EQL) getsym(); else // 等号后跟表达式 error(3); expression(); emit(STO, level - table[ix].level, table[ix].addr); break; } case CALL: { // 过程调用 getsym(); int ix position(name); if (ix 0) error(11); if (table[ix].kind ! PROCEDURE) error(15); emit(CAL, level - table[ix].level, table[ix].addr); break; } case IF: { // if 语句JPC 先占位 getsym(); condition(); int cx cx; // 当前指令下标 emit(JPC, 0, 0); // 先写跳转地址稍后回填 getsym(); // 消费 then statement(); code[cx].a cx; // 回填条件为假时的跳转目标 break; } // ... WHILE、BEGIN、ODD 等分支 } }这段代码里最关键的是emit函数和“回填”机制。emit(f, l, a)把一个三地址指令写进全局指令数组code同时把指令指针cx向后推进。if语句生成JPC时并不知道 else 块从哪开始所以先把指令写进去等 then 分支后面的语句生成完再把真正的目标地址填回去。递归下降的天然优势在这里体现得很充分——产生式嵌套的层次天然对应函数调用的层次不需要额外的栈结构来追踪上下文。3.3 错误定位从报错信息反推输入程序的问题PL/0 的错误处理会输出编号和目标指令位置但它本身不输出行号。我在调试自己加的测试程序时最常做的是把错误编号和line变量一起打印出来这样定位快得多。常见错误编号里3是赋值语句缺11是标识符未声明14是赋值目标不是变量。如果你看到错误编号停在某个符号上但人工检查源程序又觉得语法没错那大概率是前一个符号的扫描越界了——比如begin...end块里少了个;导致statement函数在错误的符号上继续做了分派。排查方法是往回看上一个符号是不是;以及getsym里的name全局变量是否被后一个符号覆盖了。这种“错误定位到下一个符号而不是当前符号”的错位是手写递归下降编译器的通病了解之后看报错就不慌了。4. 语义与代码生成栈式虚拟机指令集与解释执行4.1 指令集设计LIT、LOD、STO、CAL、JMP、JPC、OPR 七种指令如何覆盖全部语义PL/0 目标代码不是汇编而是一套面向栈式虚拟机的指令整个指令集极小每条指令用三个整数表示f功能码、l层级差、a地址或子码。功能码总共不到十个其中LIT把字面量压栈LOD按层级和偏移取变量值压栈STO把栈顶值存进变量CAL调用过程INT调整栈顶指针JMP无条件跳转JPC条件跳转OPR是算数操作的集合用第三个字段a区分加减乘除、比较、读写和返回。指令字段含义例子LITa 字面量值LIT, 0, 5表示压入整数 5LODl 层级差a 变量偏移LOD, 1, 3表示取上一层第 3 个变量STOl 层级差a 变量偏移栈顶值写入变量CALa 过程入口指令下标跳入过程体INTa 栈帧大小进入过程时腾出局部空间JMPa 目标指令下标无条件跳转JPCa 目标指令下标栈顶条件为假时跳转OPRl 0a 子码OPR, 0, 2是加法OPR, 0, 6是小于这套指令集最大的特点是“不会产生寄存器分配问题”。所有运算都在栈上完成操作数先压栈指令执行时从栈顶弹出所需数量的元素算完再压回结果。正因为如此interpret函数里的逻辑可以写得非常短我对照着指令表看大概用了半个小时就能把解释执行器的每一条指令对应回源语言的语义。4.2 代码生成语法制导翻译在递归下降中的落地代码生成并不是独立的最后一个阶段而是嵌在语法分析函数调用的间隙里。每个语法分析函数在分析的路径上同时调用emit生成指令这就是“语法制导翻译”的通俗说法。比如表达式处理的expression函数在把term的结果生成完以后遇到号就生成一条OPR, 0, 2指令——不需要额外的语法树中转。void factor(void) { if (sym IDENT) { // 变量因子 int ix position(name); if (ix 0) error(11); emit(LOD, level - table[ix].level, table[ix].addr); getsym(); } else if (sym NUMBER) { // 字面量因子 if (level 0 num MAX_NUM) error(30); emit(LIT, 0, num); getsym(); } else if (sym LPAREN) { // 括号子表达式 getsym(); expression(); if (sym RPAREN) getsym(); else error(9); } else { error(8); } }这里有一个容易忽略的细节符号表里的level记录的是标识符声明时所在的嵌套深度而emit的第二个参数是“当前过程与目标过程之间的层级差”所以代码生成时要算level - table[ix].level。如果直接传table[ix].level作用域较深的过程在运行时寻址就会出问题。我自己第一次改源码时就踩过这个坑后面第 5 章会展开说。另外注意factor调用本身没有返回结果到指定寄存器它产生的LOD/LIT指令已经把值压进运行时栈了计算位置自然由栈顶决定。4.3 解释执行器switch分发与栈帧寻址解释执行器的核心是一个大循环不断从指令数组取指令、分发、执行。由于指令和运行时栈都在内存里interpret函数往往还要处理一个“栈顶指针越界”的问题否则错误的程序会把栈空间写穿。void interpret(void) { int pc 0; // 程序计数器 int stack[MAX_STACK_SIZE]; // 运行时栈 int top 0; // 栈顶指针 while (pc cx) { int f code[pc].f; int l code[pc].l; int a code[pc].a; pc; switch (f) { case LIT: stack[top] a; break; case LOD: stack[top] stack[base(l, stack) a]; top; break; case STO: top--; stack[base(l, stack) a] stack[top]; break; case CAL: stack[top] base(l, stack); // 保存动态链 stack[top 1] pc; // 保存返回地址 stack[top 2] a; // 保存入口地址未用可作调试 pc a; top top 3; break; case JMP: pc a; break; case JPC: top--; if (stack[top] 0) pc a; // 条件为假才跳 break; // OPR 子码分发加减乘除、比较、读写、返回 } } }base(l, stack)是解释执行里最反直觉的函数。它做的事情是从当前栈顶往上回溯l层动态链找到目标过程对应的栈帧基址。因为嵌套过程的局部变量偏移是相对当前过程基址算的调用链上跨了几层就要沿着动态链往上走几层。这个“动态链”不是高级语言里的概念而是过程调用时在栈里额外保存的链接信息。理解它以后LOD和STO的寻址行为也就彻底清楚了。提示调试解释器时我一般会在switch里加一行临时打印printf(%d: f%d l%d a%d\n, pc-1, f, l, a);跟踪几条指令的执行轨迹很快就能发现跳转地址和栈偏移错在哪。5. 避坑记录跑通 PL/0 源码包最常见的五个坑拿到这套源码后不管是直接编译运行还是在此基础上扩展功能下面几个坑基本是绕不开的。我按“现象 → 原因 → 解决”的方式整理出来每一条都是实际调过的血泪经验。5.1 源码文件在 Windows 下报“非法字符”错误现象用记事本或编辑器默认编码保存的pl0.txt示例程序运行时报error(0)而且错误位置永远指向第一行末尾。原因Windows 的文本文件默认 CRLF 换行词法分析器里跳过空白的逻辑只认\n读到行尾的\r就当成非法字符了。我在第 3 章的getsym代码里特别加上了\r就是这个原因。解决改getsym的空白判断把ch \r加进跳过集合即可。如果是团队项目也可以在读取文件后统一做一次换行符清洗——把\r\n替换成\n。我更推荐前者改一处地方就够不需要动文件读取逻辑。5.2 if 语句嵌套或带 else 时跳转地址错乱现象程序里写了两层if嵌套跑出来的结果总是像“跳过了一层”或“执行了不该执行的分支”。原因经典的回填问题。生成JPC指令时目标地址是未知的代码里必须先留一个占位符等 then 分支生成完再回填。嵌套时如果用了同一个局部变量来保存跳转地址后一层if的地址就会把前一层覆盖掉。解决给每一层if建立独立的跳转地址变量或者在statement函数的IF分支里不用全局变量保存cx而是用局部int变量。回填顺序也要正确先回填JPC的跳转目标再回填JMP如果有 else的目标地址。每次改完文法扩展这个回填步骤都必须从头到尾强制走一遍。5.3 while 循环变成死循环或一次也不执行现象循环体里改了循环变量但程序要么无限循环要么一次都不进入循环体。原因while语句生成的指令顺序是“条件求值 → JPC 跳过循环体 → 循环体 → JMP 回条件求值处”。错配最常见的是把JPC的条件判断写反了让条件为真时跳走或者是循环体结束后漏掉了JMP回跳指令导致循环体只执行一次然后顺序往下跑。解决写完之后对着指令序列检查排列顺序。我一般会打印前 20 条指令人工比对找循环体的JPC目标应该指向循环体结束后的第一条指令而循环体末尾的JMP目标应该指向条件求值的第一条指令。两者方向正确的前提下再检查OPR子码里“小于等于”这类比较条件的取反逻辑。5.4 过程嵌套层级加错局部变量被上一层覆盖现象写了一个两层过程调用的程序内层过程的局部变量和外层过程的局部变量互相串值甚至赋值以后马上读回来是旧值。原因符号表里每个变量的level字段记录它所属的嵌套层级。过程调用发生嵌套时block函数递归进入子过程level要加 1。我最初在符号表登记处把level固定成了当前过程自己的层级而不是“声明该变量的过程的层级1”结果内层过程寻址时算错了level差base(l, stack)沿动态链回溯到了错误的栈帧。解决逐行检查符号表登记代码确保table[ix].level使用的是声明环境对应的层级值。调试时在interpret的LOD/STO分支打印l和a对比动态链上每一层的基址很快能定位是差 1 还是差一个完整层级。5.5 程序稍微写长一点编译过程就崩溃现象示例程序都能跑一旦自己写了一个超过几十行的测试程序编译时直接程序崩溃不是报编译错误。原因最可能是指令数组或符号表数组的容量上限写死了。PL/0 源码通常会把code数组和table数组定义成固定大小的数组比如#define MAX_CODE 200。我在测试程序里加入了几十个语句之后cx越界写坏了内存。解决把MAX_CODE和MAX_TABLE改大或者干脆改成动态分配读入源文件后先统计文件大小再按比例分配指令数组。我实际用下来改成#define MAX_CODE 10000就足够覆盖所有课设规模的测试程序内存开销可以忽略不计。6. 进阶玩法给 PL/0 加一个 else 分支并验证全流程6.1 先改文法再改保留字表很多 PL/0 初始版本没有else只有if condition then statement。要加else第一步不是写代码而是把文法在注释里改写清楚statement :: if condition then statement [else statement]。然后去保留字表里补上else同时给SYM_ELSE这个符号编号分配一个枚举值。词法分析部分因为用的是表驱动加一个字符串到NORW表就够了不需要动getsym的扫描逻辑。6.2 代码生成规则与自测程序if...else的代码生成比单纯if多一轮回填核心思路是条件为假时跳向else块then块执行完要越过else块跳向整个 if 语句的出口。case IF: { getsym(); condition(); int cx1 cx; // JPC 占位跳向 else 块 emit(JPC, 0, 0); if (sym THEN) getsym(); else error(3); statement(); // then 分支 if (sym ELSE) { // 有 else 的情况 int cx2 cx; // JMP 占位then 结束后跳过 else emit(JMP, 0, 0); code[cx1].a cx; // 回填 JPC条件为假跳到这里 getsym(); statement(); code[cx2].a cx; // 回填 JMPthen 结束后跳到这里 } else { code[cx1].a cx; // 无 else直接把目标回填为 if 语句出口 } break; }这段代码的“先记录指令下标的变量必须是局部变量”这个细节就是第 5 章 5.2 节踩坑的正式解法。改完之后我自己写了一段测试程序用if x 10 then x : x 1 else x : x - 1跑三组输入把 x 的初值设为 9、10、11分别观察结果是否落在 10、9、10 上。验证通过后再嵌套一层赋值语句检查内层的else是否和外层if正确配对。加else是 PL/0 扩展里最简单的入门项。做完这一步再往while加repeat...until会顺手很多。我记得第一次做这个扩展时忘了回填 then 块末尾那条JMP结果else分支执行完以后直接顺序落进了if语句后面的代码整个控制流错乱。从那以后我每次改文法都强制走一遍“先画跳转示意、再写占位、最后回填两步”的流程再也没有因为这个翻过车。希望这几条记录能让你拿到这份源码包后少走几段弯路。本文还有配套的精品资源点击获取