简介: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 源码虽然不起眼,但把编译器从“玄学”变成了可调试的工程代码。希望这篇拆解能帮你在自己的编译器之路上少走几步弯路。本文还有配套的精品资源,点击获取
简介: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 源码虽然不起眼,但把编译器从“玄学”变成了可调试的工程代码。希望这篇拆解能帮你在自己的编译器之路上少走几步弯路。本文还有配套的精品资源,点击获取