ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手把手拆解PL/0编译程序源码:从词法分析到P-code虚拟机

手把手拆解PL/0编译程序源码:从词法分析到P-code虚拟机 简介pl0编译程序C语言版源码是一份面向编译原理课程学习者的经典教学源码由著名计算机科学家N.Wirth设计的PL/0语言编译程序实现功能精简、结构清晰适合高校学生、教师及自学者理解高级语言编译器从词法分析、语法分析到解释执行的整体流程。压缩包共2个文件包含1个C源文件和1个头文件核心代码集中、无冗余框架便于逐行阅读和断点调试整体大小仅11KB非常轻量可快速导入常见C开发环境直接编译运行。目前已有1205人学习下载常用于编译原理实验、课程设计参考以及二次开发扩展。通过研读这份源码读者可以直观掌握递归下降分析法、符号表管理、错误处理等关键实现技巧并可将PL/0作为基础逐步扩展出支持更多数据类型与控制结构的教学编译器是理论教材之外极具可操作性的补充材料。 我琢磨着编译原理这门课劝退了不知道多少人。上课听老师讲文法、讲自动机、讲中间代码PPT翻得飞快自己在下面听得云里雾里转头打开电脑又不知道从哪一行代码开始写。如果非要在这门课里找一个能“救命”的突破口我的建议永远是同一个把PL/0编译程序源码老老实实读懂、亲手敲一遍、跑通它。这玩意儿虽然是个简化到不能再简化的教学用编译器但它五脏俱全——词法分析、语法分析、符号表管理、中间代码生成、目标代码解释执行一个不少。更难得的是整套编译程序C语言版源码规模很小通常几百行到上千行就能拿下完全能在一两天内读完不像GCC那种巨无霸源码光目录结构就能把人绕晕。这篇文章就来拆一拆经典的PL/0编译程序C语言实现从整体结构到各个模块的核心逻辑再到我实际调试过程中踩过的坑。你要是正在学编译原理或者想找一个小而美的开源项目练手顺着这篇文章走一遍收获不会小。1. PL/0这门“教学机”到底解决了什么问题1.1 它是怎么诞生的为什么选它入门PL/0是Pascal之父Niklaus Wirth在《Algorithms Data Structures Programs》这本经典书里设计的一个教学语言专门用来讲编译过程。它本身是Pascal的一个子集语言功能被砍到只剩常量定义、变量定义、过程定义、赋值语句、if-while控制流和简单的算术表达式连数组、字符串、指针、函数返回值这些统统没有。正是因为功能少才能在几百行C代码里把编译器从文本读取到指令执行这条完整链路塞进去。想研究真正的C语言编译流程贸然去读GCC源码基本等于劝退但先在一个几百行的编译器里把词法、语法、语义、代码生成、虚拟机这些概念全部对齐之后再去碰LLVM、GCC地基就是稳的。经典的PL/0示例程序长这样算最大公约数var x, y, q, r; procedure gcd; begin while r # 0 do begin q : x / y; r : x - q * y; x : y; y : r end end; begin x : 96; y : 42; while x # y do if x y then y : y - x else x : x - y end.你如果初次见到这段代码可能会嫌弃它落后连个return都没有语法还这么别扭。但它刚好是学习编译器的最佳尺寸——程序结构简单却覆盖了“声明区加语句区”这种几乎所有高级语言都逃不掉的组织方式。1.2 一个完整的编译程序由哪几块拼起来我习惯把PL/0编译程序源码划分成六个大件词法分析器Scanner、语法分析器Parser、符号表管理Symbol Table、代码生成Code Generator、运行时解释器Interpreter/VM以及错误处理模块。模块之间的数据流是单向的源程序文本先经过词法分析器被切分成一个个token标识符、数字、保留字、运算符、分隔符语法分析器按照EBNF文法一边识别程序结构一边生成中间代码P-code指令序列符号表负责记录每个变量/常量/过程的名字、类型、层级、地址最后目标代码送到解释器里按栈式虚拟机的方式逐条执行这套设计跟现代编译器相比缺了优化器但整体骨架一致。读懂PL/0后你再看“前端/中间表示/后端”这套专业话术脑子里立刻就有具体的代码映像了。2. 词法分析模块的日常从字符流到token流2.1 三种token类型的处理方式完全不同词法分析器输入的是整个源程序文件输出是一串带类型的token。PL/0里token基本上分三类保留字/标识符如begin、end、if、procedure、x、y、数字常量、运算符和界符如、-、*、/、、#、、、:、:、,、;、.、(、)。我在读源码时发现很多C语言版PL/0的词法分析器对三类token的处理逻辑是分开的读到字母就持续收集拼成一个字符串然后查保留字表。查到了就是保留字如begin查不到就是标识符用户自定义的变量名或过程名读到数字就持续收集拼成数值。PL/0只支持整数所以没有小数点这个概念读到运算符和界符就走单独的case分支。这里面最需要注意的就是“”。单独读到“”是一个token等于号读到“:”又是一个token赋值号必须先读下一个字符才能确定经典代码是这样switch (ch) { case : sym PLUS; break; case -: sym MINUS; break; case *: sym TIMES; break; case /: sym SLASH; break; case : sym EQL; break; case #: sym NEQ; break; case : getch(); if (ch ) { sym LEQ; } else { sym LSS; ungetch? } break; case :: getch(); if (ch ) { sym BECOMES; } else error(...); break; ... }这里有一个细节需要特别小心读“”符号后如果下一个字符是“”说明是“”如果不是那么刚刚读到的那个字符不能被吞掉要允许程序把它“退回”到输入流里下次再读。C语言版源码通常用一个名为ungetch的缓冲区或专门的getch缓冲队列来实现字符回退。不处理这个细节词法分析会被相邻token之间的边界搞到发疯。2.2 保留字表用线性查找就够了有同学可能会想标识符查保留字是不是得用哈希或二分查找其实不必。PL/0的保留字总共就十几个比如const、var、procedure、call、begin、end、if、then、while、do、odd。十几个字符串用最简单的数组存起来for循环线性比较就完了速度完全不是问题。这也是一种值得学习的设计哲学——教学编译器追求的是清晰优先不是性能优先。词法分析器输出的token会一直存在一个全局变量sym里语法分析器每次需要新token就调用getsym()不需要的时候就继续拿着当前的sym做判断。这种“一个当前符号一个取符号函数”的模式就是递归下降分析法里经典的“向前看一个token”机制。3. 递归下降语法分析从文法到C函数的一一映射3.1 EBNF文法就是函数的“施工图”PL/0的语法定义通常写成EBNF扩展巴科斯范式比如程序 分程序 .分程序 [常量定义部分][变量定义部分][过程定义部分] 语句语句 赋值语句 | 调用语句 | 复合语句 | 条件语句 | 循环语句 | 空语句赋值语句 标识符 : 表达式表达式 [正号|负号] 项 { (加号|减号) 项 }项 因子 { (乘号|除号) 因子 }因子 标识符 | 数字 | ( 表达式 )条件 odd 表达式 | 表达式 关系运算符 表达式关系运算符 | # | | | | 递归下降的核心思路极其直白每个非终结符对应一个C函数遇到什么样的token就调用什么样的函数。EBNF里的方括号表示可选对应if判断花括号表示重复对应while循环竖线表示分支对应switch或多路if。我摘一段解释表达式优先级的核心逻辑。表达式由项组成项由因子组成因子可以是数字、变量或括号表达式。层层嵌套恰好保证了乘除优先级高于加减、括号优先级最高void expression(void) { if (sym PLUS || sym MINUS) { // 一元正负号 getsym(); term(); if (sym PLUS) { ... } } else { term(); while (sym PLUS || sym MINUS) { getsym(); term(); // 生成加法或减法指令 } } } void term(void) { factor(); while (sym TIMES || sym SLASH) { getsym(); factor(); // 生成乘法或除法指令 } } void factor(void) { if (sym IDENT) { // 查符号表生成读取变量的指令 } else if (sym NUMBER) { // 生成压入常量的指令 } else if (sym LPAREN) { getsym(); expression(); if (sym ! RPAREN) error(...); } }这种“编译器结构跟着文法走”的做法是递归下降法最迷人的地方。你不需要一张复杂的转移表只需要照着文法把每个函数填满编译器的骨架就出来了。3.2 语句层的框架设计语句部分比表达式麻烦一点因为要支持好几种不同结构的语句。但设计模式是一样的先看当前token是什么再决定进哪个分支。void statement(void) { switch (sym) { case IDENT: // 赋值语句如 x : 5 getsym(); if (sym BECOMES) { ... } break; case CALL: // 过程调用如 call gcd ... case BEGIN: // 复合语句 begin ... end ... case IF: // 条件语句 if cond then statement ... case WHILE: // 循环语句 while cond do statement ... default: break; // 空语句 } }条件语句的编译逻辑尤其能体现“代码生成跟着控制流走”的思想。完整的if-then在不带else的教学版里生成的P-code序列大致是“计算条件”“条件为假则跳转”“执行then分支”。注意这里的跳转地址在生成时还不知道需要留一个口子等then分支的语句生成完毕后再回填。具体来说源码里会有类似这样的操作case IF: getsym(); condition(); // 生成条件判断指令栈顶留下0/1 if (sym THEN) getsym(); cx1 cx; // 记录跳转指令所在位置 gen(JPC, 0, 0); // 先生成一个假跳转地址留空 statement(); // 编译then分支的语句 code[cx1].a cx; // 回填把跳转地址改成当前指令位置这种“先占位、后回填”的做法本质上是编译原理教科书里“回填(backpatching)”思想的最朴素实现。理解了这个小细节以后你学任何编译器的控制流处理都会轻松很多。4. 符号表编译器和运行时的“关系数据库”4.1 符号表里存什么符号表在PL/0编译程序里扮演了两个角色。编译阶段它负责判断变量、常量、过程有没有声明、有没有重复声明、引用时能不能找到运行阶段它的信息被P-code解释器用来定位变量的存储位置。经典C语言版PL/0用三个数组来组织程序运行时的数据存储区table数组编译期用记录每个标识符的名字、类型常量/变量/过程、层级和地址stack数组运行期用当作栈帧存放数值code数组存放生成后的P-code指令序列符号表项在C语言里通常这样定义#define TABMAX 100 struct tab { char name[10]; enum object_kind kind; // CONSTANT / VARIABLE / PROCEDURE int val; // 常量值或变量的地址 int level; // 层级嵌套深度 int adr; // 过程的入口地址 } table[TABMAX];PL/0支持过程嵌套所以变量和过程都带着一个level字段。这直接对应现代语言里的作用域概念最外层是第0层嵌套的过程每往里一层level加1。4.2 嵌套作用域是怎么实现的我当初看源码时最迷惑的就是嵌套过程作用域的处理。PL/0的实现方式其实比现代语言简单粗暴符号表就一张全局数组编译到某个时刻当前可以访问的符号就是这个数组里“尚未被覆盖”的那些项。当进入一个过程体时新声明的变量直接追加到符号表尾部离开这个过程中把符号表“指针”退回到进入过程之前的位置。换句话说符号表是用栈的语义来管理的后进的作用域先出。这跟C语言标准的块级作用域有差异PL/0不允许同名遮蔽这也是它的简化之处但作为学习实现绰绰有余。在代码生成阶段变量访问指令LOD和STO里包含两个关键参数level差值和变量在栈帧里的偏移地址。运行期解释器拿到这两个参数后沿着静态链找到正确的栈帧再根据偏移地址存取值。这里也是理解“静态作用域”和“动态链”区别的好教材——PL/0用静态链访问外层变量而不是动态链。5. P-code虚拟机目标代码长什么样怎么跑起来5.1 指令集做成了栈式虚拟机PL/0的目标代码不是真正的机器码而是一套为教学设计的栈式虚拟机指令很多教材称这套指令为P-code。P-code指令意味着真正执行的时候需要有一个虚拟机Interpreter来逐条读取执行。常用的P-code指令不多就十几个指令含义示例LIT 0, a把常量a压入运行栈LIT 0, 10LOD l, a把地址为l层差a偏移的变量值压栈LOD 1, 3STO l, a把栈顶值存到地址l层差a偏移STO 0, 5CAL l, a调用过程l为层差a为过程入口CAL 1, 123INT 0, a栈指针增加a相当于为局部变量腾空间INT 0, 5JMP 0, a无条件跳转到aJMP 0, 88JPC 0, a栈顶为假时跳转到aJPC 0, 90OPR 0, a执行内置运算a表示具体运算add/sub/mul/div等OPR 0, 2RET返回OPR 0, 0O指令的a参数含义通常是固定的比如RET用0表示取负用1表示加法用2减法用3乘法用4除法用5奇偶判断用6等于用8不等于用9小于用10大于等于用11大于用12小于等于用13。不同教学版本的编号略有差别但逻辑一致。P-code解释器的核心就是一个巨大的switch这种结构想必写过解释器的朋友一眼就能认出来p 0; while (p cx) { switch (code[p].f) { case LIT: stack[top] code[p].a; break; case LOD: stack[top] stack[base(base_l, code[p].l) code[p].a]; break; case STO: stack[base(base_l, code[p].l) code[p].a] stack[top]; break; case ADD: top--; stack[top] stack[top 1]; break; ... } p; }这种设计其实就是JVM虚拟机的最粗糙原型指令紧凑、栈式操作数、一条指令至少一个操作数或固定编码在指令里。理解P-code之后再去看JVM的字节码规范你会发现很多概念是相通的比如局部变量表、操作数栈、wide指令等只是JVM把完整的高层语言表达能力都做上去了。5.2 生成中间代码的辅助函数PL/0源码里通常有一个叫gen的函数负责往code数组里追加指令顺手检查指令区是否溢出void gen(enum opcode f, int l, int a) { if (cx MAXCODE) { error(program too long); } else { code[cx].f f; code[cx].l l; code[cx].a a; cx; } }为什么指令里带l和a这两个经典字段因为P-code是定长三字段的。虽然有些指令用不到层差或操作数但统一格式可以简化生成器和解释器的实现毕竟面对的是教学场景可读性比空间效率更重要。运行时栈的管理有一点容易绕晕过程调用时不仅要压返回地址、保存动态链还要根据符号表里的size字段过程局部变量的数量执行INT指令来给局部变量腾空间。这个过程理解透彻了你在调试任何栈式语言时都会对“栈帧指针”“返回地址”“局部变量偏移”这三个概念格外敏感。6. 调试、踩坑、扩展把教学机变成自己的工具6.1 我第一次动手时遇到的三个拦路虎代码写完了编译链接通过但一运行就出错。这是绝大多数人都会经历的阶段。我把自己排错的过程完整复现一下供大家参照。第一个坑是“:”和“”混淆。PL/0里赋值用“:”判断相等用“”。词法分析器读到“”时返回EQL读到“:”时返回BECOMES。曾经有个同学把if语句的条件写成if x : 5 then语法分析器立刻报错。这个错误在词法层其实不容易暴露真正受苦的是语义检查——普通变量被当作常量处理符号表类型对不上。调试时看到这类错误不要先怀疑语义分析先回头查词法层是不是把token类型搞混了。第二个坑是行号计数。PL/0的C语言实现通常用getch函数逐个读字符有些版本的源码读一个字符就line导致换行符被算进行号里。错误提示的行号永远比真实位置差几行。排错的时候对着错误信息找半天找不到最后发现是行号计错了。处理办法很粗暴在读文件时用一个辅助状态遇到\n才行号递增不要每读一个字符就算一行。第三个坑是“跳转地址回填”。我最初照着书抄if语句的编译代码把code[cx1].a cx;这句回填代码给漏了结果生成的JPC指令跳转地址永远是0程序一执行就到无限循环里。调试这类问题的方法很土但有效在解释器里加打印语句把每条指令的序号、操作码、跳转目标打出来跑一遍立刻就能看出跳转地址不对。6.2 运行P-code时常见的问题与对策P-code解释器最常见的运行时错误是栈溢出程序嵌套调用太深或局部变量分配太多、操作数栈下溢指令序列不合法以及除数为零。一个合格的教学用解释器必须在这些位置做检查否则一条非法指令就可能让宿主进程崩溃。我给自己的解释器加了三种保护栈指针top在压栈前检查是否超出STACKMAX除法指令OPR 0, 5执行前检查栈顶值是否为0遇到非法操作码直接终止并打印指令位置加上这些保护之后调试体验好了非常多几乎所有语法层面的逻辑错误都能在几分钟内定位。6.3 值得自己动手加上去的几个小扩展如果你只把PL/0源码读一遍就跑那收获只有一半。我强烈建议你在读懂之后在它上面做几个小扩展。这几个扩展难度循序渐进做完了基本上就对编译器前端的每个细节都有肌肉记忆了。给if语句加上else分支。改动点在语法分析器的if分支需要额外处理一个ELSE token和一条JMP指令的回填难度不大但能逼你彻底搞懂控制流给语言加上for循环。相比else这更考验对循环体和条件跳转的理解因为要处理“初始化—条件—步长—循环体—跳回”五段结构增加一个取模运算符%或者逻辑非not。改词法分析器加保留字、改表达式文法、改代码生成的OPR指令链路不长但很完整把打印语句print加进去。现代编程语言的调试输出到教学编译器里反而成了奢侈品加上后能大幅提升调试效率尝试生成真正的x86汇编而不是P-code。这一步会逼你把栈式代码翻译成寄存器操作做完这个编译原理里的“指令选择”一章你就拥有了体感根据我的经验能把if-else和for循环这两个扩展独立实现一遍的人再回去读任何编译器源码都会觉得亲切很多。因为阅读最大的障碍不是语法而是“不知道这段代码在编译哪个文法规格”。当你亲手为文法里的某个分支写过代码生成逻辑你就能在别人的源码里瞬间认出它在干什么。6.4 这个项目之后往哪个方向走PL/0是一条非常棒的“第一个编译器”路径但不是终点。建议你走完PL/0后可以按这个顺序进阶先用Lemon或Flex/Bison做一个小语言的解析器熟悉生成器工具再接触一下LLVM的Kaleidoscope教程理解现代编译器如何把AST变成中间表示再到机器码。这个过程会非常长但起点就是几百行C代码的PL/0没有比这更平滑的起步了。我个人在实际操作中的体感是——编译原理课程里那些抽象名词递归下降、LL(1)、回填、作用域、静态链没有一个是在课堂上真正学会的全都是被PL/0源代码“喂”明白的。代码不会撒谎当你亲眼看见“if语句文法”变成“生成一条JPC指令再回填地址”的操作时知识才第一次从纸面站到了屏幕上。本文还有配套的精品资源点击获取
返回列表