ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PL0编译器扩充实战:从for循环到数组的完整改造

PL0编译器扩充实战:从for循环到数组的完整改造 简介PL0语言及其编译器扩充与修改项目是一份面向编译原理课程设计或学习者实践的开源资料适合正在学习词法分析、语法分析、语义分析、中间代码生成、代码优化与目标代码生成等核心知识的学生使用。压缩包共含6个文件以C源程序.cpp/.h、说明文档.docx和测试用例.txt为主整体仅146KB结构紧凑便于对照源码理解PL0编译器的实现与扩充思路。目前已有1431人学习下载适合作为课堂项目参考或自学练手素材。资料中不仅提供了可运行的PL0编译器源程序还配套课程设计说明书详细阐述各模块设计要点并附带多个测试文件用于验证功能读者可借此深入理解符号表管理、递归下降解析等关键技术也能学习如何为PL0语言增加新语法特性并改进编译流程提升编译原理与工程实践能力。1. 为什么还要折腾PL0一门“教学语言”的扩充价值课程设计截止前三天我看到最多的求助是“我准备给PL0语言加for循环现在连while都编译不过了。”这不是个别现象。PL0语言和它的编译器是大多数计算机专业学生接触到的第一个能真正跑起来的白盒编译器它只有整型变量、if、while、过程嵌套和read/write五脏俱全但体积很小。标题里“对PL0语言及其编译器进行扩充和修改”这件事本质是把这个最小的教学编译器按自己的需求改造成一个更大的教学编译器。扩充的点可以是一个for循环也可以是一整套数组类型修改的难度从半天到两周不等取决于你动了哪一层。这篇文章写给正在做编译原理课程设计、准备复试机试、或者单纯想把递归下降编译器摸透的人按“先拆原版、再动手改、最后验证”的顺序讲明白真正的落地方案。2. 先拆原版PL0编译器单遍扫描、递归下降与P-code2.1 原版PL0的编译器骨架一个小而完整的前端加虚拟机PL0语言是Niklaus Wirth在1976年的《Algorithms Data Structures Programs》里为教学设计的Pascal子集。配套编译器最经典的形态是一个用Pascal写的单遍编译器边做词法分析、语法分析和符号表管理边生成一种面向栈的字节码P-code然后由一个几百行的解释器在整数栈上逐条执行。国内许多教材用C重写了这套东西但架构没有变大致是这条流水线源程序 - 词法分析 - 递归下降语法分析 - P-code生成 - P-code解释器执行 - 运行结果为什么这个结构对“扩充和修改”特别重要因为它没有独立的中间表示也没有独立的优化层所有语义动作都散落在递归下降的各个函数里。你加一个for循环至少会穿过词法、语法、符号表、代码生成四层你加一个数组类型还要再穿进解释器的寻址逻辑。很多人的翻车都不是因为某一步难而是因为改到一半就停手编译器能拐弯抹角地编译过去但P-code跑出来的结果全是错的。原版PL0的语法分析器核心是一组互相递归调用的函数expression调用termterm调用factorstatement分发到赋值、if、while、begin复合语句、call、read、write。符号表是一个静态数组每一项记录名字、种类常量/变量/过程、层级、地址和值。运行时通过display表维护静态作用域链。理解这几点以后扩充的方向就清楚了。2.2 四个常见扩充方向从考试题目到真实需求给PL0做扩充几乎所有课程设计的题目都能归进四个方向里。先把它们放在一张表里对照再决定做哪个。扩充目标主要改动层工作量典型风险新控制语句for、repeat、case、do-while词法、语法、代码生成小到中跳转回填出错新数据类型real、boolean、array、string符号表、语义、解释器中到大寻址方式和内存分配过程/函数形参与返回值词法、文法、符号表、运行时中到大display链和实参形参映射后端优化常量折叠、临时变量合并、寄存器式后端代码生成阶段重写大与解释器脱钩我的建议是如果不是老师硬性要求第一个扩充别选函数形参选控制语句第二个扩充再考虑数组。原因是控制语句的改动几乎不碰符号表和运行时寻址最小闭环能让你在两天内看到效果建立信心。数组则不一样表面上只是“变量后面跟个方括号”实际上要引入间接寻址这等于把原版P-code的哲学从“编译期固定地址”变成“运行期计算地址”跨度大很多。2.3 从哪一层动手词法、文法、符号表、P-code的改动顺序无论选哪个扩充动手顺序都建议按“词法 - 文法 - 符号表 - 代码生成 - 解释器”来走并且每走一步都保证程序能重新编译、能跑通原有测试。这个习惯能救你命。先说环境问题。经常有同学把编译器和编辑器混为一谈用vscode写了很多代码却发现“没有编译器可以用吗”或者报了“编译器未包含main类型”这种和环境有关的错。PL0的扩充不需要什么重型工具链你只需要一个能编译C的编译器Linux下用gccWindows下用mingw或msvc都行。比如我一般用gcc直接编译gcc -o pl0c pl0.c ./pl0c test.pl0这一步的作用是确认你拿到的底子能编译、能跑再开始改。接下来每改一层我都会先重新编译一次再跑一遍最小的测试程序。如果新关键字还没接进语法分析器测试程序会报错这很正常但如果连原有功能都崩了说明你动了不该动的地方需要立刻回退。用git管理这个项目比任何技巧都重要——后面你会知道这相当于后悔药。3. 给PL0加for循环改文法、改词法、改语法、改代码生成3.1 文法只在statement上多一个产生式PL0原有的语句产生式大概是这样的statement [ ident : expression | CALL ident | BEGIN statement { ; statement } END | IF condition THEN statement | WHILE condition DO statement | READ ( ident ) | WRITE ( expression ) ].注意方括号表示这条产生式整体可选也就是“空语句”是合法的。我们要加的for循环按照Pascal的写法是statement ... | FOR ident : expression TO expression DO statement关键点是for是一条语句不是表达式所以它只能出现在statement能出现的地方不能出现在factor里。很多第一次改的人把for当成一个“表达式结构”塞进factor结果文法冲突递归下降函数在所有需要表达式的地方都开始找FOR整个语法分析器乱掉。记住for属于语句层。3.2 词法注册FOR、TO、DO三个保留字词法分析器的改动是最机械的但也是最容易被低估的。PL0的词法分析器会把字母开头的字符流收集成标识符名字然后查一个关键字表命中就返回对应的符号枚举否则返回IDENT。要给for循环加东西至少要在枚举和关键字表里加三样typedef enum { IDENT, NUMBER, IF, THEN, ELSE, WHILE, DO, FOR, TO, /* 新增 */ CALL, BEGIN, END, READ, WRITE, VAR, PROCEDURE, ASSIGN, EQ, NEQ, LT, LEQ, GT, GEQ, PLUS, MINUS, TIMES, SLASH, LPAREN, RPAREN, SEMICOLON, PERIOD, COMMA } Symbol;然后在初始化关键字表的函数里补上initKeyword(FOR, FOR); initKeyword(TO, TO); initKeyword(DO, DO);需要特别检查原版实现里DO是不是已经被WHILE用掉了我见过不少C重写版把DO和WHILE共用同一个符号因为PL0只有while-do。如果你手里的版把DO当成WHILE的固定搭配没有单独符号那么加FOR的时候要先把DO拆出来否则parsing到“DO statement”时无法区分是WHILE的DO还是FOR的DO。这个细节不处理后面必然出现“语法错误”报在奇怪位置。3.3 递归下降用已有指令组合成for语法分析阶段的目标是把for循环展开成跳转和比较指令。经典的PL0解释器已经有LOD读取变量值、STO写入变量、LIT装载常量、OPR运算和比较、JMP无条件跳转、JPC条件跳转这些P-code指令。我们完全可以不新增任何指令只用已有指令组合出for的效果。下面是一段可参考的C语言示意代码void parseFor(void) { int iAddr, temp, start, jmpOut; getsym(); /* 吃掉 FOR */ if (sym ! IDENT) error(18); /* FOR 后必须是循环变量 */ int pos lookup(name, curLevel); if (pos 0 || table[pos].kind ! VARIABLE) error(19); iAddr table[pos].addr; getsym(); /* 吃掉循环变量名 */ if (sym ! ASSIGN) error(20); /* 必须是 : */ getsym(); expression(0); /* 计算初值压栈 */ gen(STO, 0, iAddr); /* i : 初值 */ getsym(); /* 吃掉 TO */ if (sym ! TO) error(21); getsym(); expression(0); /* 计算终值压栈 */ temp newTemp(); /* 申请一个临时变量 */ gen(STO, 0, temp); /* 保存终值到临时变量 */ getsym(); /* 吃掉 DO */ if (sym ! DO) error(22); getsym(); start pc; /* 循环头地址 */ gen(LOD, 0, iAddr); /* 取 i 当前值 */ gen(LOD, 0, temp); /* 取终值 */ gen(OPR, 0, OPR_LE); /* i 终值 */ jmpOut pc; gen(JPC, 0, 0); /* 条件不成立则跳出循环 */ statement(); /* 解析循环体 */ gen(LOD, 0, iAddr); gen(LIT, 0, 1); gen(OPR, 0, OPR_ADD); /* i 1 */ gen(STO, 0, iAddr); /* i : i 1 */ gen(JMP, 0, start); /* 跳回循环头 */ code[jmpOut].a pc; /* 回填跳出地址 */ }这段代码有几个关键参数要解释清楚。iAddr是循环变量在本层栈帧里的相对偏移原版PL0的符号表项里addr字段对变量来说就是这个偏移LOD和STO靠它找到变量。temp是临时变量的符号表项地址它的作用是把TO后面的终值表达式结果存下来而不是每次循环都重新计算终值这是Pascal for语义的核心。start保存循环头的P-code地址生成完循环体和自增指令后用一个JMP跳回去。jmpOut指向JPC指令的地址等整个循环体生成完再把真正要跳转的目标地址回填进那条JPC的a字段。回填是递归下降生成代码里最容易错的地方漏了回填就会出现“程序直接死循环”或“跳到一个随机地址”。注意PL0的语句之间用分号分隔而for的循环体是单条语句。如果循环体是BEGIN...END复合语句statement那里会自己处理内部的符号如果不是复合语句整个for语句结束后调用方会按照原有规则处理分号。不要在parseFor里擅自读分号否则后续的statement分发会丢一个符号。3.4 上界只求值一次for语义里最容易被忽略的点教科书上不会特意强调但Pascal的for i : 1 to n do里n是在循环开始前求值一次之后不管循环体怎么改都不再重新求值。如果把for直接翻译成“每次循环都拿当前变量去比较”一旦循环体里修改了终值相关的变量循环次数就会变得不可预测。这也是为什么上面代码里要先申请临时变量temp把终值固化下来。有的实现选择给P-code新增专门的for指令让解释器在循环内部自动处理终值和步长。我把三种方案放在一起对比你可以照着选实现方案解释器改动代码量语义风险适用场景临时变量保存终值翻译成while零改动少低课程设计求稳新增FOR/STEP指令解释器内部递增比较中等少低想展示对后端的理解不存临时变量直接在循环头比较零改动最少高不推荐我最推荐第一种。不是因为解释器改不了而是因为你在扩充语言时应该尽量让每一个新特性都建立在已经验证过的旧指令上这样排查问题时可以把“新语法”和“旧P-code”分开。等for跑通了再考虑像第二种那样把组合指令抽成一条新指令这也算是编译器优化的一种朴素实践。4. 把PL0的数据类型撑起来加入数组的完整改造4.1 符号表结构从“全是整型变量”到记录数组维度给PL0加数组第一刀要砍在符号表上。原版PL0的符号表项往往长这样typedef struct { char name[16]; int kind; /* CONSTANT/VARIABLE/PROCEDURE */ int level; int addr; int value; } Symbol;这个结构默认每个变量只占一个栈单元也没有任何维度信息。数组需要的字段包括这是不是数组、下标下界、上界、单个元素占几个单元、总共占几个单元。改造后大致是这个样子typedef enum { CONSTANT, VARIABLE, ARRAY, PROCEDURE } IdKind; typedef struct { char name[16]; IdKind kind; int level; int addr; /* 栈帧内相对偏移 */ int elemSize; /* 每个元素占用的单元数 */ int lowBound; /* 下标下界 */ int highBound; /* 下标上界 */ int totalSize; /* 数组总单元数 */ } Symbol;这只是个示意具体字段名可以随你手里的代码改。关键语义是addr仍然表示数组第一个元素的栈内偏移lowBound和highBound决定合法下标范围totalSize (highBound - lowBound 1) * elemSize。4.2 声明与分配VAR a[10]怎么变成栈空间文法的改动很直接在变量声明部分允许方括号variabledeclaration VAR ident [ [ number ] ] { , ident [ [ number ] ] } ;.真正麻烦的是运行时分配。PL0的过程入口处会生成一条INT 0, space指令用来在当前栈帧预留局部变量空间。原版的空间计算总是“本层变量个数”因为每个变量占一格。加入数组以后必须把数组的totalSize累加进去否则数组会覆盖调用者的现场。一个常见的做法是在block里扫完本层所有声明后统一计算int localSpace 3; /* display链和返回地址占用的固定槽 */ for (int i 0; i tableSize; i) { if (table[i].level curLevel table[i].kind ARRAY) { localSpace table[i].totalSize; } else if (table[i].level curLevel table[i].kind VARIABLE) { localSpace 1; } } gen(INT, 0, localSpace);localSpace是给本过程栈帧分配的单元总数INT指令的执行会在运行时把栈指针往上抬这么多格。数组在栈里是线性连续的a[0]到a[9]依次排列这样后续寻址才能用“首地址加偏移量”的方式计算。如果你偷懒没加这个空间程序一运行就会把返回地址写穿表现完全像玄学崩溃。4.3 数组元素寻址给P-code加三条指令数组元素和普通变量的本质区别是普通变量的地址在编译期就是固定的level addr可以直接写进LOD/STO指令里数组元素的地址必须到运行时用下标算出来所以需要一个“把数组首地址压栈再和偏移量相加”的机制。我的方案是给P-code增加三条指令LDA、LDI、STI。LDA level,addr和LOD类似但压入的是地址而不是值LDI弹出栈顶地址把该地址上的值压栈STI从栈顶取要写的值从次栈顶取目标地址把值写到目标地址。这样数组访问就能用一段很规整的P-code生成void genArrayLoad(Symbol *id) { /* 假设此时栈顶是下标表达式的值 */ gen(LDA, 0, id-addr); /* 压入数组首地址 */ gen(LIT, 0, id-lowBound); gen(OPR, 0, OPR_SUB); /* index - lowBound */ gen(LIT, 0, id-elemSize); gen(OPR, 0, OPR_MUL); /* 元素在数组内的偏移量 */ gen(OPR, 0, OPR_ADD); /* 首地址 偏移 元素地址 */ gen(LDI, 0, 0); /* 读该地址的值 */ }写数组时只需要把最后的LDI换成STI并且生成顺序变成“值压栈地址压在值下面”但整体思路完全一致。LDA、LDI、STI这三条指令在解释器里都不难实现LDA是走一遍静态链加上addr偏移后压栈LDI是栈顶出栈作为p压入stack[p]STI是两次弹出后写回。你可以把它们实现成OPR的子码也可以实现成独立指令区别只在于你手里P-code格式的f字段够不够用。有的同学会问能不能不用新指令只靠LIT和OPR组合出地址理论上可以但那样只能处理“数组正好在当前层”的情况一旦数组在外层过程里你就需要在地址计算时带上静态链跳转相当于把LDA的功能拆进每段代码生成里既啰嗦又容易错。加指令是更干净的做法也是真实编译器扩充后端时最常走的路径。4.4 边界数组形参和动态数组先不要碰数组加进来以后第一个诱人的扩充就是“让过程支持数组形参”。我劝你冷静。PL0原版过程连变量形参都没有只有无参过程display链已经很考验人。数组形参意味着实参要传一个地址形参要按引用来解析符号表里还要区分“数组变量”和“数组形参”。这一步的复杂度比加数组本身还高而且很容易把display表搞乱。如果你的课程设计要求是“实现数组”做到数组变量和数组元素读写这一步已经足够如果题目明确要求数组形参再去单独做引用传递不要混在同一个提交里。动态数组也就是声明时下标是变量而不是常量同样建议第一阶段不做。因为PL0的栈帧空间是编译期静态确定的动态数组需要另一套运行时栈管理涉及堆栈混合分配已经超出“扩充PL0”的合理范围了。5. PL0扩充的避坑记录五个让新手抓狂的坑5.1 现象关键字表一改用户程序里叫for的变量全废了我见过不止一个同学把for注册成保留字后原来源程序里写着var for;或者for : 5;结果编译器开始报“missing identifier”。原因很简单关键字表一旦命中词法分析器就会返回FOR符号不再把它当作变量名。解决也直接要么接受“新增关键字是保留字已有程序必须改名”的规则要么把FOR做成上下文关键字只在语句开头位置识别但这需要语法分析器配合改动更大。我的做法是在文档里写明“FOR、TO、DO为新增保留字”并把测试用例里所有重名标识符改名一劳永逸。5.2 现象词法把TO1读成标识符TO分支永远进不去写for i : 1 to 10 do时不小心写成to10编译器不报错而是把to10当成了一个变量名去查符号表然后报“未定义标识符”。如果你更倒霉写成FOR i : 1 TO10TO和10之间没有空格词法分析器会按最长匹配把TO10整个读出。根源在于词法分析器收集标识符时规则是“字母开头后接字母或数字都算标识符的一部分”关键字查表必须在整个字符串收集完之后进行。解决方式是在收集完名字后调用keywordLookup(name)如果命中才返回对应符号否则返回IDENT同时要接受一个事实关键字和数字之间最好用空格或括号隔开否则任何编译器都会遇到这种边界问题。5.3 现象for循环的临时变量没计入栈帧第二个循环像开盲盒用newTemp()给for终值申请临时变量后如果这个临时变量只是进了符号表却没有像普通局部变量一样被计入INT指令的分配空间那么执行到第一个循环结束时栈指针飘回过程入口第二个循环再用这个临时变量时取到的可能是别人的数据。现象是第一个FOR循环正常第二个FOR循环死循环或者循环次数莫名其妙翻倍。解决思路是临时变量必须占一个符号表位置并且在block的localSpace计算里把它也算进去。如果你把newTemp()实现成只分配符号表项但不分配栈空间那本质上就是一个“编译期变量”运行时没有落脚点必炸。5.4 现象数组越界写穿了返回地址程序开始乱跳给数组a[10]赋值a[15] : 1没有编译错误运行也不报错但程序执行完当前过程后直接跳到一个奇怪的地址或者干脆卡死。原因很直接数组空间在当前栈帧里越界写会写到栈帧之外的区域而P-code解释器不会做边界检查。解决分两层。第一层是运行时检查在生成长度计算时把下界比较和高界比较插进去比如算出index - lowBound后先和0比较小于0跳错误再和highBound - lowBound比较大于就跳错误。第二层是编译期检查如果下标是LIT常量可以直接在编译时报错。两层都做才算稳。很多教材的解释器故意不检查越界是为了保持代码简单所以这个坑几乎每届都会有人踩。5.5 现象ELSE和FOR同时加悬空else和回填互相踩如果课程设计要求不只加for还要加else两个改动堆在一起时最容易出幺蛾子。现象是if a 1 then if b 2 then ... else ...这样的嵌套语句里else的归属变得不可预测或者for循环体里碰到END就把回填地址补错。原因是递归下降的statement分发函数里if语句的else匹配规则和for循环体结束时的符号消费边界发生了冲突。解决原则是每个语法分支必须自己吃掉终止自己的符号调用方拿到当前sym后再决定下一步if采用“最近匹配”规则也就是else总是归属于最近的未匹配的if不要在statement返回后重新读一个符号。如果坚持原则还报错就打开你的P-code dump看JPC往哪跳通常一眼就能看出回填地址是不是落在了循环体中间。6. 怎么确认你的扩充没有把PL0改坏回归测试与P-code对拍6.1 一套随手可跑的回归脚本改编译器最怕的不是报错而是不报错但输出变了。我的习惯是先建一个tests目录把原版PL0能跑的经典测试程序全放进去再加上为新增功能写的for和数组测试每个用例配一个期待输出。然后写一个简单的bash脚本对拍#!/bin/bash # 用法./runtests.sh for src in tests/*.pl0; do name$(basename $src .pl0) ./pl0c $src out/$name.code 2 err/$name.txt ./pl0vm out/$name.code input/$name.txt out/$name.stdout 21 if diff -q expected/$name.stdout out/$name.stdout /dev/null 21; then echo PASS $name else echo FAIL $name fi done这个脚本的价值是让你每次改完一小步就跑一遍几百毫秒内知道有没有把旧功能弄坏。新增的for用例至少要覆盖一次都不执行的循环、执行多次的循环、循环体内修改循环变量的情况数组用例至少覆盖读取、写入、越界、数组在外层过程使用时。把这些用例塞进expected目录比什么都强。6.2 用P-code diff定位改动带来的副作用如果某个用例输出不对别急着看解释器。我会先给编译器加一个-d选项dump出P-code然后拿改动前后的两份P-code做diff。比如./pl0c -d tests/loop.pl0 loop.new.code ./pl0c_old -d tests/loop.pl0 loop.old.code diff -u loop.old.code loop.new.code这样能看到哪一段代码生成多了一条指令、少了一次回填、或者跳转目标变了。比起对着源代码猜diff能直接把嫌疑范围缩小到几行。把-d做成一个编译选项其实很便宜生成指令的函数里加一个if (debug) printf(...)就够但它能把“编译器改了但不知道哪里改坏”的时间缩短一半。6.3 一个帮我少踩坑的习惯改一步存一个可运行版本我做PL0扩充时最深的教训是永远不要连续改三处以上再编译一次。词法改完编译一次Parser改完跑通原版测试代码生成改完跑通新用例解释器改完再全量回归。每一步都留下一个可运行版本配合git提交翻车了立刻回退。这个习惯是从一次数组寻址的深夜调试里换来的那次我同时改了符号表、LDA指令和block分配结果面对两百行P-code dump完全不知道从何下手。希望帮到你也希望你把PL0的扩充当成一段持续的工程而不是最后一次性的提交——当你把discovery变成习惯你会发现编译器开发并没有那么玄学它只是需要你在每一层都留好后路。本文还有配套的精品资源点击获取
返回列表