简介PL/0 编译程序是计算机科学家 N.Wirth 设计的经典教学案例这份 C 语言版源码适合编译原理课程学习者和高校学生反复研读用于理解词法分析、语法分析、符号表管理等编译前端核心环节。压缩包仅含 2 个文件一个 C 源文件实现主要编译流程一个头文件组织数据结构与接口声明整体体积 11KB结构小巧、便于逐行分析。目前已有 1208 人学习下载是编译原理教学中的高频参考资料。资源可直接在常见 C 编译器下阅读代码保留了 PL/0 语言简洁的递归下降分析与目标代码生成思路读者既能对照教材验证经典算法也能在其基础上扩展错误处理或中间代码优化适合作为课程设计、实验报告或自学入门的起点。1. pl0编译程序一份 C 语言源码把“编译器”这个黑匣子彻底打开很多学编译原理的人都有过这种感觉教材把词法分析、语法分析、语义分析讲得头头是道但真要自己写一个能跑起来的编译器却不知道从哪里下手。pl0编译程序C语言版源码的价值就在这里——它是经典教学编译器 pl0 的 C 语言实现整个编译器压缩在单个 C 源文件里从读取源码文本到输出执行结果词法分析、递归下降语法分析、栈式解释执行全部呈现在你面前。我当初拿到这份源码时第一反应是“这么短”读完才明白编译器的骨架本来就不复杂复杂的是工程化包装。这份资源适合两类人一类是刚学完编译原理、想对照教材看真实代码的学生另一类是工作几年想复习编译基础、甚至想自己设计 DSL 的开发者。2. 整体架构一份 C 文件如何装下完整的编译解释器2.1 资源包里有什么拿到资源包后目录结构大致如下可能因为整理的版本不同有些微差异但核心文件不会少pl0/ ├── pl0.c # 编译器主体源码全部逻辑都在这里 ├── Makefile # 可选的构建脚本 ├── example/ # 示例 pl0 程序 │ ├── gcd.pl0 # 求最大公约数 │ ├── fib.pl0 # 斐波那契数列 │ └── prime.pl0 # 素数判断 └── README.md # 简要说明与运行指引pl0.c 是整个资源的心脏。没有把源码拆成多个模块而是单文件实现这对学习来说是件好事——你不需要在十几个文件之间跳转顺着一个文件的函数调用就能看到全貌。示例程序是 pl0 语言本身写的测试用例语法类似简化版 Pascal适合用来验证编译器行为。2.2 三段式流水线词法、语法、解释执行如何串起来pl0 编译器的整体结构是一条清晰的流水线main 函数里大致是这样组织的int main(int argc, char* argv[]) { if (argc 2) { printf(usage: pl0 source file\n); return -1; } init(); // 初始化符号表、指令区等全局状态 if (!open_source(argv[1])) { printf(cannot open source file\n); return -1; } getsym(); // 预读第一个 token block(0, NULL); // 从最外层 block 开始语法分析 if (sym ! PERIOD) // 程序必须以 . 结束 error(9); interpret(); // 语法分析通过后解释执行生成的指令 return 0; }这里的关键点在于pl0 不像现代编译器那样先构建显式的抽象语法树AST而是采用“边分析边生成指令”的方式。语法分析函数在识别到一个语句结构时立刻往指令数组里写入对应的指令分析结束时指令代码也同时生成完毕随后直接进入解释执行阶段。这种设计的妙处在于省去了中间表示的内存管理整个编译器的状态只有几个全局数组和数据指针。你不需要理解复杂的树形结构遍历只需顺着递归调用往下钻就能看到一条语句从源码变成字节码再到实际运行的完整链路。2.3 符号表与指令区贯穿全程的两张状态表编译器运行期间有两个核心数据结构需要先记住后面的代码全都在跟它们打交道。第一个是符号表一个结构体数组#define MAX_SYMBOL_TABLE_LENGTH 100 typedef struct { char name[16]; // 标识符名字 int kind; // 0常量, 1变量, 2过程 int value; // 常量值或过程入口指令地址 int level; // 声明所在层0 为最外层 int address; // 相对当前层基址的偏移量 } symbol;第二个是指令区同样是一个数组#define MAX_CODE_LENGTH 300 typedef struct { int op; // 操作码LIT/LOD/STO/CAL/INT/JMP/JPC/OPR int level; // 层差调用者与被调用者的层数差 int arg; // 参数或操作数 } instruction;符号表解决的是“名字从哪来”标识符在哪一层声明、是常量还是变量还是过程、内存中该往哪放。指令区解决的是“运行时怎么执行”每条指令对应栈式虚拟机的一个操作比如 LIT 把常量压栈、LOD 从指定层取变量值压栈、OPR 执行算术或比较运算。理解这两张表之后读代码的路径就很清晰了词法分析填充符号表的 name 和 kind语法分析决定 value、level 和 address解释执行则逐条读取指令区的记录并修改运行栈。整个编译器的数据流方向是单向的任何时刻你都知道数据从哪来、到哪去。3. 核心代码拆解词法分析、递归下降与解释执行的完整链路3.1 词法分析保留字匹配与符号识别词法分析的核心函数是 getsym()它从源文件读取一个字符序列根据首字符的类型分发到不同的处理分支。这个函数在编译过程中会被反复调用每次调用得到一个 token 并存入全局变量 sym。先看保留字的处理方式。pl0 语言有一批保留字代码里用了一个字符串数组和线性查找char* keyword[16] { begin, call, const, do, end, if, odd, procedure, read, then, var, while, write }; void getsym() { // ... 跳过空白字符 ... if (isalpha(ch)) { /* 标识符或保留字 */ int i 0; while (isalnum(ch)) { name[i] ch; ch getchar_from_source(); } name[i] \0; sym IDENT; /* 先按标识符处理 */ for (int j 0; j 13; j) { if (strcmp(name, keyword[j]) 0) { sym j BEGIN; /* 命中保留字 */ break; } } return; } // ... 数字、运算符、分隔符的处理 ... }这段代码的逻辑很直白先囤积字母开头的字符序列到 name 数组然后逐个与保留字表比对。这里有个值得注意的细节保留字表中没有把 write 和 read 设计成特殊语法而是把它们当作普通过程调用处理只是在符号表初始化时预置了这两个过程符号。这意味着后续语法分析里read 和 write 的调用走的是过程调用语句的路径不需要单独写语法规则。数字的识别同样在 getsym 里完成遇到数字开头就累加计算数值并检查是否有越界。运算符的识别则是一连串 if-else 分支特别要注意的是赋值符号 : 是两字符操作需要额外读取一个字符才能确定if (ch :) { ch getchar_from_source(); if (ch ) { sym BECOMES; /* : 赋值号 */ ch getchar_from_source(); } else { sym NUL; /* 单独冒号报错 */ } }两个字符组成的运算符容易踩坑比如 和 代码里用 peek 下一个字符的方式区分。词法分析结束后sym 全局变量保存当前 token 类型name 保存标识符名num 保存数字值。3.2 递归下降语法分析block 与 statement 的相互调用语法分析是这份源码里最值得反复读的部分。pl0 采用递归下降分析法每个非终结符对应一个函数block、statement、condition、expression、term、factor。这些函数互相调用形成一棵隐含的语法树。先看 block 函数它负责处理声明部分和语句部分void block(int lev, int* tx) { int cur_tx *tx; int entry code_count; /* 记下过程入口地址 */ if (sym CONST) { /* 常量声明 */ getsym(); do { getsym(); /* 常量名 */ if (sym ! IDENT) error(4); getsym(); if (sym ! ) /* 等号 */ error(5); getsym(); if (sym ! NUMBER) /* 常量值 */ error(7); enter_const(cur_tx, name, num); getsym(); } while (sym ,); // ... 处理分号与 var 声明 ... } // ... var 声明处理 ... getsym(); statement(lev, tx); /* 分析语句部分 */ if (sym ! PERIOD) /* 过程必须以 . 结束 */ error(9); }block 的执行顺序完全按照文法产生式展开先处理 const 声明再处理 var 声明有 procedure 就嵌套调用 block最后进入 statement 分析。注意参数 lev 表示当前所在层数嵌套过程的层数随之加一符号表里记录的 level 就来自这里。statement 函数则负责处理语句部分它根据当前 token 类型分派到不同分支void statement(int lev, int* tx) { if (sym IDENT) { /* 赋值语句 */ getsym(); if (sym BECOMES) { getsym(); expression(lev, tx); gen(STO, lev - cur_level, addr); } } else if (sym IF) { /* if 语句 */ getsym(); condition(lev, tx); int q code_count; gen(JPC, 0, 0); /* 条件为假时跳转 */ getsym(); /* then */ statement(lev, tx); code[q].arg code_count; /* 回填跳转地址 */ } // else if (sym WHILE) { ... } // else if (sym BEGIN) { ... } }这里最值得学习的是跳转指令的回填技巧。以 if 语句为例condition 分析完成后编译器先生成一条 JPC 指令但跳转目标地址此刻还不知道于是先把位置记下来等 then 后面的 statement 分析完再把目标地址写回那条 JPC 的参数里。这种“先占位、后回填”的手法在代码生成里非常常见理解了它你再看 while 循环里的 JMP 和 JPC 就会豁然开朗。expression、term、factor 三个函数处理表达式它们形成了一个经典的优先级层次expression 处理加减term 处理乘除factor 处理常数、变量和括号表达式。factor 里还会检查变量是否已声明以及是否可以读取void factor(int lev, int* tx) { if (sym IDENT) { /* 变量或常量 */ int pos lookup(name); /* 查符号表 */ if (pos 0) { error(11); /* 未声明 */ return; } if (table[pos].kind CONST) { gen(LIT, 0, table[pos].value); } else { gen(LOD, lev - table[pos].level, table[pos].address); } getsym(); } }factor 的这段代码把“符号表查名字 生成取数指令”两件事合并完成。常量直接生成 LIT 指令把值压栈变量则生成 LOD 指令按层差和偏移量取数。层差的计算方式是现行层减去声明层运行时栈式虚拟机根据这个差值找到对应的活动记录。3.3 解释执行栈式虚拟机逐条跑指令语法分析完成后code 数组里装满了指令。interpret 函数是一个大 while 循环逐条取出指令执行用一个栈数组和两个指针stack_top 和 base模拟运行时环境void interpret() { int pc 0; /* 程序计数器 */ int stack[MAX_STACK_SIZE]; int stack_top 0; /* 栈顶指针 */ int base 0; /* 当前层基址 */ stack[0] 0; stack[1] 0; stack[2] 0; base 0; stack_top 3; while (pc code_count) { instruction ins code[pc]; switch (ins.op) { case LIT: /* 把常量压栈 */ stack[stack_top] ins.arg; break; case LOD: /* 取变量值压栈 */ stack[stack_top] stack[base_of(ins.level) ins.arg]; break; case STO: /* 栈顶存回变量 */ stack[base_of(ins.level) ins.arg] stack[--stack_top]; break; case OPR: /* 算术、比较、返回等 */ // ... 按 arg 值分派运算 ... break; case JMP: /* 无条件跳转 */ pc ins.arg; break; case JPC: /* 条件跳转 */ if (stack[--stack_top] 0) pc ins.arg; break; } } }LIT、LOD、STO 三条指令是变量读写的基础设施理解它们就能明白 pl0 的作用域规则。LOD 执行时通过 base_of 函数计算指定层的基址再加上符号表里记录的偏移量 address这条路径对应着“从第 n 层活动记录的某个位置取值”的语义。OPR 指令是运算中枢arg 值从 1 到 9 分别对应加、减、乘、除、取模、奇数判断、等于、小于等于等运算。减法比较特殊因为它要做“取相反数相加”的操作除法还要处理除数为零的异常。这部分代码虽然简单但对运行时的正确性至关重要。4. 从编译到运行三条命令跑通 pl0以及五个常见踩坑现场4.1 构建与启动gcc 一条命令完成这个 C 语言版本没有复杂的依赖不需要安装第三方库只需要系统里有 gcc。构建命令如下gcc -o pl0 pl0.c如果资源包里附带了 Makefile也可以直接敲 make。构建成功后运行./pl0 example/fib.pl0程序会先执行语法分析如果源码有语法错误会在终端打印错误信息格式类似ERROR: line 3: missing semicolon如果全部通过则立刻进入解释执行阶段程序的输出直接打印在终端。注意区分pl0 编译器是“解释执行”而不是“生成目标文件”它不会产生 .o 或可执行文件作为编译产物而是自己作为虚拟机把指令跑完。4.2 测试用例斐波那契与最大公约数示例目录里的 fib.pl0 大概是下面这个样子var n, result; procedure fib(n); if n 1 then result : n else result : fib(n - 1) fib(n - 2) ; begin read(n); call fib(n); write(result) end.这段递归求斐波那契的代码用到了过程声明、条件语句、递归调用和赋值语句。如果你的编译器实现正确输入 10 会输出 55。这类测试用例的价值在于覆盖了递归下降分析的全部核心路径过程嵌套、参数传递通过符号表地址实现、表达式求值。还有一个值得测试的是最大公约数程序它用 while 循环和取模运算用来验证循环控制流的跳转指令是否回填正确。初次跑通后建议自己手写几个测试用例比如嵌套 if 语句、多层过程嵌套看编译器能否正确完成层差计算。4.3 遇到问题先看这里五个高频踩坑记录问题一编译通过但运行毫无输出。现象执行 ./pl0 test.pl0 后终端没有任何打印。原因源码里没有调用 write 过程或者 write 的符号表入口没有被正确初始化。解决检查源码是否包含 write 语句再检查解释器里对 write 的 OPR 分支是否有 printf 输出。常见做法是在模板源代码的预置符号表部分把 write 和 read 的入口补齐。问题二报错信息里出现 “error 9” 或 “error 12” 但看不出是哪一行。现象错误编号有了但不知道源码中哪一行触发的。原因pl0 错误报告只给错误码不给具体行号。解决我在调试时给 getsym 加了一个全局变量记录当前行号遇到错误时打印行号这比盲猜快得多。你可以照做int current_line 1; // 在读取到 \n 时 current_line然后 error() 里打印 current_line问题三递归过程深度稍大就栈溢出。现象运行 fib(15) 时没问题fib(25) 直接崩溃。原因解释器运行栈数组固定大小且递归调用每层会占用多个槽位。解决把 MAX_STACK_SIZE 从 500 改成 2000同时确认每次过程调用有对应的返回地址和局部变量空间释放逻辑。问题四赋值语句生成的 STO 指令把值存到了错误的符号表地址。现象变量计算结果完全不对像是互相覆盖。原因符号表 address 分配逻辑有问题或者 LOD/STO 的层差计算漏掉了当前层。解决在 interpret 里临时打印每条 LOD/STO 指令的 level 和 arg 值对照手算栈布局检查。问题五while 循环变成死循环。现象程序卡死或无限打印。原因JPC 条件的真假逻辑与 while 语句的执行语义不一致。解决检查 condition 求值后栈顶是否为布尔值0 或 1再确认 JPC 的跳转目标是否回填到 while 结束后的下一条指令地址而不是 while 体内。这五个问题是我实际调试时踩过的坑每一条都能从源码的某一段定位到具体实现细节。调试 pl0 这类教学编译器的通用思路是先确认词法分析输出的 token 是否正确再确认语法分析的跳转目标是否回填正确最后盯解释器里栈指针的增减是否对称。5. 二次开发玩法给它加上 else 分支与 for 循环把这份源码跑通之后最有意思的部分就来了。pl0 原版语法里其实没有 else 和 for只有 if-then 和 while-do。顺着现有代码的结构给 pl0 扩展语法是理解编译器改造路径的最佳练习。加 else 分支的思路很清晰。第一步词法分析不用动else 还不是保留字它会被识别为标识符。第二步在 statement 函数的 IF 分支里添一个处理 else 的环节。核心逻辑是分析完 then 分支的 statement 后如果当前 token 是 IDENT 且名字为 else因为还没把 else 加入保留字表所以它现在是标识符就分支处理。关键在于跳转指令的双回填if (sym IDENT strcmp(name, else) 0) { int jump code_count; gen(JMP, 0, 0); /* then 分支结束后跳过 else 分支 */ code[q].arg code_count; /* 回填 JPC 目标到 else 入口 */ getsym(); statement(lev, tx); code[jump].arg code_count; /* 回填 JMP 目标到整个 if 的出口 */ }这段逻辑的核心是两条跳转配合JPC 负责“条件为假时跳到 else 分支”JMP 负责“then 分支执行完跳出整个 if 语句”。如果你不回填第二个 JMP就会出现 then 分支执行完继续往下执行 else 分支的串行错误。再加个 for 循环会更过瘾。for i : 1 to n do 的语义可以翻译成 while 循环节点。第一步在词法分析里把 for 和 to 加入保留字表第二步在 statement 里仿照 while 的跳转结构生成代码初始化赋值语句 → 条件判断 → 循环体 → 递增语句 → 跳回条件判断。递增语句用现有指令即可普通赋值加常量加一gen(LOD, 0, loop_var_addr); /* 取循环变量当前值 */ gen(LIT, 0, 1); /* 常量 1 */ gen(OPR, 0, 2); /* 加法 */ // 再生成 STO 存回循环变量跑通改造后把原先 fib.pl0 的递归版本改写成 for 循环版本对比两者输出是否一致。如果一致说明你的扩展没有破坏原有控制流的语义如果不一致优先怀疑跳转目标回填的地址是否正确。我一般会在 interpret 的 switch 里加一个 debug 宏遇到 JMP/JPC 时打印 pc 和跳转目标逐个核对跳转关系。从那以后我每拆一个教学编译器源码都会先写一个包含 if、while、过程调用的程序跑通原版再动刀改语法强制自己画一遍跳转目标清单改完只信测试输出不信“应该没问题”。这份 pl0 源码虽然不起眼但把编译器从“玄学”变成了可调试的工程代码。希望这篇拆解能帮你在自己的编译器之路上少走几步弯路。本文还有配套的精品资源点击获取