简介:这个PL0编译器用C++实现,覆盖编译原理四大阶段:词法分析、语法分析、语义分析和目标代码生成。支持标准PL0语法基础上扩展了ELSE条件分支、Pascal风格的FOR循环(含TO/DOWNTO步进控制)、++/–运算符、<>不等号写法;注释功能完整,兼容//单行注释和/…/块注释。源码结构清晰,包含Unit1.cpp、PL01.cpp、main.cpp等核心文件,搭配详细项目说明.md文档,里面列出了文法定义、语法图、语义动作实现逻辑和测试用例执行过程。所有代码已在Windows平台通过C++ Builder环境验证,可直接编译运行,test.pl0和PL0.PAS等测试样例均已配套。适合计算机专业做课程设计、期末大作业或毕设起步框架,初学者能快速看清编译流程各环节如何衔接,有经验者可在现有结构上继续添加数组、实数类型、函数参数等新特性。
1. 这不是玩具,是能跑通真实PL0程序的编译器教学骨架
你手上拿到的这套C++版PL0编译器,不是教科书里画在纸上的语法树示意图,也不是只打印“Hello World”就戛然而止的演示工程。它是一套真正能从test.pl0源文件开始,逐字符扫描、逐词归类、逐句验证、逐指令生成,最终输出可执行COD目标码,并在模拟机上跑出正确结果的完整闭环系统。我带过三届编译原理课设,见过太多学生卡在“词法分析器识别不了空格”或者“语法分析器一遇到ELSE就崩溃”,最后交上去的代码连if-then都跑不通。而这套代码,从第一天编译通过那一刻起,就已经把IF-ELSE、FOR-TO、++/–这些最常被学生写错、老师最常扣分的语法点,全部稳稳地焊死在了语法分析器的状态转移逻辑和语义动作里。
核心关键词——PL0编译器、C++实现、FOR循环、ELSE分支、注释支持——不是贴在README里的装饰标签,而是刻在每一行代码里的硬约束。比如那个看似简单的//单行注释,它不是靠正则表达式一扫而光就完事;它必须在词法分析阶段就被精准截断,确保后续的换行符不被误判为语句分隔符,否则整个缩进敏感的FOR循环体就会错位解析。再比如Pascal风格的FOR i := 1 TO 10 DO ...,它要求语法分析器在看到FOR时,就必须预判接下来必有:=赋值、TO或DOWNTO关键字、以及一个DO引导的循环体,三者缺一不可,且顺序不能乱——这背后是LL(1)文法中FIRST/FOLLOW集的严格计算,不是凭感觉写的if-else嵌套。我当年调试FOR循环时,在Unit1.cpp的GetNextToken()里加了整整七层日志,就为了确认TO这个词是不是在:=之后、DO之前被正确捕获。这套代码的价值,正在于它把所有这些“理论上应该如此”的抽象概念,变成了你用调试器单步进去就能亲眼看见的变量值变化和函数调用栈。
它适合谁?如果你是大三学生,下周就要交编译原理课程设计,别再从零造轮子了。你不需要理解LR(0)自动机构造,但你需要知道main.cpp里哪一行启动了整个编译流程,PL01.cpp里哪个函数负责把a++翻译成两条COD指令(先取值,再自增),Unit1.cpp里哪段代码决定了/* comment */里的星号不会被当成乘法运算符。如果你是助教,想给学生布置一个“给PL0加数组声明”的拓展题,这套结构就是你的黄金模板:新增的ARRAY保留字加在哪?数组下标检查的语义动作插在语法分析的哪个节点?目标码生成时如何计算偏移量?答案全在现有框架的缝隙里,清晰可见。它不承诺让你成为编译器专家,但它保证,当你第一次亲手让test.pl0里那个带嵌套FOR和ELSE的冒泡排序程序,在模拟机上输出正确的排序结果时,你会真正摸到编译原理的脉搏——那是一种从代码到机器指令的、实实在在的掌控感。
2. 整体架构与设计思路:为什么是这套结构,而不是别的?
2.1 四阶段流水线:拒绝“一锅炖”的教学陷阱
很多初学者写的“编译器”,本质上是一个巨大的main()函数:读入字符串,用一堆if-else匹配关键字,再用switch拼接输出。这种写法在处理IF a > b THEN c := d ELSE e := f时就会原形毕露——ELSE到底属于哪个IF?括号嵌套深度怎么跟踪?一旦加入FOR循环,控制流跳转的目标地址怎么在生成代码时提前知道?这套PL0编译器的根基,是严格遵循编译原理的经典四阶段划分,并且每个阶段都有明确的输入输出接口和独立的错误报告机制。这不是为了炫技,而是为了把复杂问题切成可验证、可调试的模块。
-
词法分析(Lexical Analysis):由
Unit1.cpp中的GetNextToken()函数主导。它的唯一职责,就是把原始字符流(char*)切割成一个个有类型、有值的记号(Token),比如TOKEN_IDENTIFIER(标识符)、TOKEN_NUMBER(数字)、TOKEN_PLUS(+号)。关键在于,它必须无歧义地处理所有边界情况:a++必须被切分为a和++两个记号,而不是a+和+;x<=y必须识别为x、<=、y,而不是x、<、=,y;//后面直到行尾的所有字符,必须被静默吞掉,不产生任何Token。这个阶段不关心a是不是已声明,也不管++用在了哪里,它只做一件事:忠实、精确地反映源码的字符构成。 -
语法分析(Syntax Analysis):由
PL01.cpp中的递归下降分析器(Recursive Descent Parser)实现,核心是Program(),Block(),Statement(),Expression()等一系列同名函数。它们严格对应PL0扩展文法的产生式。例如,Statement()函数内部就是一个巨大的switch,根据当前Token类型决定调用IfStatement(),ForStatement(),Assignment()还是EmptyStatement()。ForStatement()又会依次调用Expect(TOKEN_FOR),Expect(TOKEN_IDENTIFIER),Expect(TOKEN_ASSIGN),Expression(),Expect(TOKEN_TO)……这种结构的好处是,每一条语法规则都映射为一段清晰、可单步调试的C++代码。当语法错误发生时(比如缺少DO),错误信息能精确到“期望找到DO,但在第42行第15列遇到了;”,而不是笼统的“语法错误”。 -
语义分析(Semantic Analysis):它没有独立的
.cpp文件,而是深度嵌入在语法分析的过程中,以“语义动作”(Semantic Actions)的形式存在。这是最容易被初学者忽略、却最体现功力的部分。比如,在Assignment()函数里,当成功解析完a := b + c后,它不会立刻生成目标码,而是先调用CheckIdentifierDeclared("a")和CheckIdentifierDeclared("b")、CheckIdentifierDeclared("c"),确保这三个变量都在当前作用域的符号表里注册过;接着调用CheckTypeCompatible("b", "c"),确认b和c都是整型(PL0只支持整数),才能进行加法。这些检查失败时,抛出的错误是“变量‘d’未声明”或“类型不匹配:期望整型,得到未知类型”,而不是运行时崩溃。符号表(Symbol Table)的管理逻辑就藏在Unit1.h定义的class SymbolTable里,它用栈式结构支持过程嵌套的作用域。 -
目标代码生成(Code Generation):由
PL01.cpp中一系列GenXXX()函数完成,如GenLoad(),GenAdd(),GenJmp(),GenCall()。它们不直接操作内存或寄存器,而是向一个全局的std::vector<CODInstruction>(定义在Unit1.h中)追加指令对象。CODInstruction结构体包含opcode(操作码)、level(静态链层级)、addr(地址/偏移量)三个字段,完全对应PL0虚拟机的指令格式。关键点在于,所有跳转指令(如IF的条件跳转、FOR的循环跳转)的addr字段,在生成时往往是占位符(比如-1)。真正的地址填充,要等到整个函数体分析完毕、所有标号(label)的位置都确定后,才由一个专门的PatchJumpAddresses()函数回填。这避免了在分析过程中需要“预知未来”的尴尬。
提示:这种四阶段分离,最大的教学价值在于“可打断点”。你想看词法分析器怎么切分
i--?在GetNextToken()第一行下断点。你想看语法分析器如何匹配FOR i := 1 TO n DO ...?在ForStatement()入口下断点。你想看语义分析器如何拒绝a := 3.14?在CheckTypeCompatible()里下断点。每一个阶段的输入输出都是明确定义的数据结构(Token,AST Node,SymbolTable Entry,CODInstruction),而不是一团混沌的全局变量。
2.2 扩展功能的集成逻辑:为什么ELSE和FOR能“长”得这么自然?
PL0原始文法极其精简,只有IF...THEN,没有ELSE;只有无参数的过程调用,没有循环。要加入ELSE和FOR,绝不是简单地在switch里多加一个case。它要求对整个文法进行重构,并确保新规则与旧规则无缝兼容。这套代码的高明之处,在于它采用了增量式文法扩展策略,所有新增语法都严格遵循LL(1)文法的构造原则。
-
ELSE分支的集成:原始PL0的
IF语句产生式是IF condition THEN statement。加入ELSE后,必须变成IF condition THEN statement [ELSE statement]。但直接这样写会产生“悬空else”(Dangling Else)的二义性。标准解决方案是强制ELSE总是和最近的、尚未匹配ELSE的IF配对。在递归下降分析器中,这体现为IfStatement()函数的精巧设计:它先解析IF和condition,然后调用Expect(TOKEN_THEN),再递归调用Statement()解析THEN后的语句;此时,它会前瞻(Lookahead)下一个Token,如果发现是TOKEN_ELSE,才继续解析ELSE后的语句。这意味着,IF a THEN IF b THEN c ELSE d会被解析为IF a THEN (IF b THEN c ELSE d),而非(IF a THEN IF b THEN c) ELSE d。这个前瞻逻辑就藏在PL01.cpp的IfStatement()里,是理解控制流分析的关键。 -
FOR循环的集成:Pascal风格的
FOR比C语言的for(i=0; i<n; i++)更复杂,因为它有方向性(TO/DOWNTO)和隐含的循环变量更新。其文法是FOR identifier := expression {TO | DOWNTO} expression DO statement。难点在于,identifier必须是已声明的变量(语义检查),两个expression必须是整型(类型检查),且DO后的statement构成了一个新的作用域(符号表管理)。在ForStatement()函数里,你能看到它如何分步完成:先Expect(TOKEN_FOR),再GetNextToken()拿到循环变量名并检查声明,接着Expect(TOKEN_ASSIGN)和Expression()解析初值,然后Expect(TOKEN_TO)或Expect(TOKEN_DOWNTO)确定方向,再Expression()解析终值,最后Expect(TOKEN_DO)并进入新的Statement()解析循环体。整个过程像搭积木,每一步都依赖前一步的正确结果。 -
++/–运算符的集成:这涉及到运算符优先级的调整。原始PL0只有
+,-,*,/。++和--是后缀运算符,优先级高于+和-,但低于括号。因此,在Factor()(解析原子项,如标识符、数字、括号表达式)函数里,必须增加对TOKEN_INC和TOKEN_DEC的处理。当Factor()成功解析出一个标识符(如a)后,它会立即检查下一个Token是否为++或--。如果是,则生成两条COD指令:GenLoad(a)加载a的值,然后GenInc()或GenDec()执行自增/自减。这确保了a++ + b被正确解析为(a++) + b,而不是a + (+b)。
注意:所有这些扩展,都必须同步更新
Unit1.h中定义的TokenType枚举,以及PL01.cpp开头的ReservedWords映射表(将字符串"ELSE"映射到TOKEN_ELSE)。漏掉任何一个,都会导致词法分析器永远认不出这个关键字。
3. 核心细节解析与实操要点:从源码到可执行的每一步
3.1 词法分析器(Unit1.cpp):字符流的精密手术刀
Unit1.cpp是整个编译器的“感官系统”,它必须对输入的每一个字节都做出准确反应。我们来拆解它最核心的GetNextToken()函数,看看它是如何应对那些让初学者抓狂的边界情况的。
首先,它有一个状态机式的主循环,用ch变量存储当前读取的字符。关键的“手术”发生在识别标识符和数字之后,对后续字符的判断上。以a++为例:
1. ch读到'a',进入TOKEN_IDENTIFIER分支,收集所有字母数字下划线,得到字符串"a"。
2. 下一个ch是'+',它不属于标识符字符,退出标识符收集。
3. 此时,ch是'+',函数进入运算符分支。它会前瞻(Peek)下一个字符:调用GetChar()读取下一个字符到nextCh。
4. 如果nextCh也是'+',那么当前的'+'和nextCh共同构成TOKEN_INC(++),函数会再次调用GetChar()消耗掉第二个'+',并返回TOKEN_INC。
5. 如果nextCh不是'+',那么当前的'+'就是单独的TOKEN_PLUS,nextCh会被放回输入流(通过UngetChar(nextCh)),等待下一次GetNextToken()调用处理。
这个“前瞻-确认-消耗/回退”的模式,是处理所有双字符运算符(++, --, <=, >=, <>)的通用范式。<>(不等号)的处理逻辑完全一样:先读到'<',前瞻发现'>',就组合成TOKEN_NEQ;如果前瞻发现的是'=',就组合成TOKEN_LEQ(小于等于);如果都不是,那就只是TOKEN_LT(小于)。
对于注释,处理逻辑更为苛刻:
- //单行注释:当ch是'/',前瞻nextCh是'/'时,进入注释状态。此后,函数会持续调用GetChar(),直到读到换行符\n或文件结束EOF。在此期间,所有读取的字符都被丢弃,不产生任何Token,且换行符本身也不会被返回。这意味着,// comment\nx := 1;会被解析为:跳过// comment\n,然后x作为下一个Token被返回。这保证了x := 1;能被正确识别为一条独立的赋值语句。
- /*...*/块注释:当ch是'/',前瞻nextCh是'*'时,进入块注释状态。函数会持续读取字符,直到遇到'*'后紧跟'/'的序列。这里有个经典陷阱:/* a * b */是合法的,因为中间的*后面不是/;但/* a */ b */是非法的,因为第一个*/就结束了注释,后面的b */就成了裸露的代码。代码中通过一个布尔标志inComment和一个字符缓存prevCh来精确追踪*和/的配对,确保不会误判。
实操心得:我在调试词法分析器时,最有效的办法是在
GetNextToken()的末尾添加一行日志:printf("Token: %s, Value: %s\n", TokenName(token), tokenValue.c_str());。把test.pl0喂给它,看着屏幕上一行行输出TOKEN_FOR,TOKEN_IDENTIFIER (i),TOKEN_ASSIGN,TOKEN_NUMBER (1)……就像看着一台精密仪器在工作,每一个齿轮的咬合都清晰可见。这是建立对整个编译流程信心的第一步。
3.2 语法分析器(PL01.cpp):递归下降的“建筑蓝图”
PL01.cpp是编译器的“大脑”,它依据文法,将零散的Token组织成一棵有层次、有含义的语法树(AST)。虽然本项目没有显式构建AST数据结构,但其函数调用栈本身就是一棵隐式的、动态的AST。我们以ForStatement()为例,剖析这个“建筑蓝图”的绘制过程。
void ForStatement() {
Expect(TOKEN_FOR); // 确保第一个词是FOR
string varName = GetIdentifier(); // 获取循环变量名,如 "i"
CheckIdentifierDeclared(varName); // 语义检查:i 必须已声明
Expect(TOKEN_ASSIGN); // 确保接下来是 :=
Expression(); // 解析初值表达式,如 "1"
// 判断是 TO 还是 DOWNTO
if (lookahead == TOKEN_TO) {
Expect(TOKEN_TO);
isDownTo = false;
} else if (lookahead == TOKEN_DOWNTO) {
Expect(TOKEN_DOWNTO);
isDownTo = true;
} else {
Error("Expected TO or DOWNTO after FOR variable assignment");
return;
}
Expression(); // 解析终值表达式,如 "n"
Expect(TOKEN_DO); // 确保接下来是 DO
// 关键:进入循环体前,创建一个新的作用域
symbolTable.PushScope();
Statement(); // 解析 DO 后的整个循环体语句
symbolTable.PopScope(); // 循环体结束,弹出作用域
// 生成目标码:这里会生成初始化、条件判断、循环体跳转、更新步进等指令
GenForLoop(varName, isDownTo);
}
这段伪代码揭示了几个核心要点:
1. 前瞻(Lookahead)驱动决策:lookahead变量始终保存着下一个将要被GetNextToken()返回的Token。Expect()函数的本质就是:如果lookahead匹配期望的Token,就调用GetNextToken()消耗它,并将下一个Token读入lookahead;如果不匹配,就报错。这使得整个分析过程是确定性的(LL(1))。
2. 作用域管理是语义分析的基石:symbolTable.PushScope()和PopScope()确保了循环体内的变量声明(比如在循环体内声明了一个临时变量temp)不会污染外层作用域。当Statement()解析完循环体后,temp的符号条目会随着作用域的弹出而自动销毁。
3. 目标码生成是“延迟绑定”的:GenForLoop()函数并不会在此刻生成所有指令。它会先记录下循环体的起始地址(一个占位符)、循环变量名、初值、终值、步进方向等信息,放入一个待处理列表。等到整个ForStatement()函数返回,Statement()的调用栈展开后,再由一个统一的代码生成器遍历这个列表,填充所有跳转地址并生成最终的COD指令。这避免了在递归分析过程中,需要为尚未解析的代码段预先分配地址的难题。
注意:
Expression()函数是整个语法分析器中最复杂的部分,它实现了运算符优先级。它通常采用“递归下降+优先级跳转”的混合策略。例如,Term()处理*和/,Factor()处理++和--,而Expression()本身只处理+和-。当Expression()调用Term()后,如果发现下一个Token是+或-,它会循环调用Term()并生成相应的GenAdd()或GenSub()指令。这种结构天然地保证了a + b * c先算b*c,再算a+result。
3.3 目标代码生成(COD指令):虚拟机上的“汇编语言”
PL0编译器生成的目标码(COD),是一种为PL0虚拟机(VM)设计的、高度简化的指令集。它只有寥寥十几条指令,却足以支撑完整的程序执行。理解COD,是理解整个编译器如何“落地”的最后一公里。
每条COD指令是一个三元组:(opcode, level, addr)。其中:
- opcode是操作码,如LIT(加载常量)、LOD(加载局部变量)、STO(存储局部变量)、CAL(调用过程)、INT(为过程分配空间)、JMP(无条件跳转)、JPC(条件跳转)。
- level表示静态链(Static Link)的层级,用于在嵌套过程调用时,定位外层过程的局部变量。对于主程序,level是0;对于一级嵌套过程,level是1,以此类推。
- addr是地址或偏移量。对于LIT,addr是常量值;对于LOD,addr是该变量在当前过程栈帧中的偏移量;对于JMP,addr是跳转的目标指令序号。
以FOR i := 1 TO 10 DO write(i);为例,其生成的核心COD序列如下(简化版):
LIT 0 1 // 加载常量 1
STO 0 i_off // 存储到变量 i 的偏移量位置
LIT 0 1 // 加载常量 1 (步进值)
STO 0 step_off // 存储步进值
LIT 0 10 // 加载终值 10
STO 0 end_off // 存储终值
JMP 0 loop_start // 跳转到循环开始处
loop_start:
LOD 0 i_off // 加载 i 的当前值
LOD 0 end_off // 加载终值
OPR 0 12 // 执行 OPR 指令,12 表示比较 i <= end
JPC 0 exit_loop // 如果不满足,跳转到 exit_loop
// 循环体:write(i)
LOD 0 i_off // 加载 i
OPR 0 14 // OPR 14 表示 write 操作
...
// 更新 i: i := i + 1
LOD 0 i_off // 加载 i
LIT 0 1 // 加载 1
OPR 0 2 // OPR 2 表示加法
STO 0 i_off // 存储回 i
JMP 0 loop_start // 无条件跳回 loop_start
exit_loop:
...
可以看到,FOR循环被翻译成了一个经典的“先判断、后执行、再更新”的while循环结构。JPC(Jump if Condition is False)指令是实现条件跳转的关键。OPR(Operator)指令是一个多功能指令,其第二个参数(addr字段)指定了具体的操作:12是<=比较,14是write,2是+。这种设计极大地压缩了指令集规模。
提示:
test.COD和PL0.COD文件就是编译器输出的最终目标码。你可以用文本编辑器打开它们,对照上面的解释,一行行去解读。你会发现,test.pl0里那个带ELSE的嵌套IF,最终生成的COD里,会有多个JPC指令,它们的addr字段指向不同的指令序号,精确地控制着程序的分支流向。读懂COD,你就真正读懂了编译器的“思想”。
4. 实操过程与核心环节实现:手把手带你跑通第一个PL0程序
4.1 环境准备与项目编译:Windows下的C++ Builder实战
这套代码是为Borland C++ Builder(C++B)环境量身定制的,这是上世纪90年代最流行的Windows RAD(快速应用开发)工具之一。虽然现在主流是VS Code或Visual Studio,但为了最大程度保证“开箱即用”,我们依然沿用原生环境。以下是详细步骤:
-
安装C++ Builder:你需要一个合法的C++ Builder版本(推荐XE系列或更早的6.0)。安装时务必勾选“C++编译器”和“VCL框架”组件。安装完成后,启动IDE。
-
导入项目:在IDE中,选择
File -> Open Project...,导航到你解压后的资源包根目录,选择PL01.bpr文件。这是一个Borland项目文件(Borland Project),它包含了所有源文件(.cpp,.h)和资源文件(.dfm,.res)的引用关系。IDE会自动加载Unit1.cpp,PL01.cpp,main.cpp等文件到项目管理器中。 -
检查依赖与路径:右键点击项目管理器中的
PL01.bpr,选择Options...。在Directories/Conditionals选项卡中,确认Include path包含了Unit1.h所在的目录(通常是项目根目录)。在Linker选项卡中,确保Output file指向pl0_compiler.exe(或你喜欢的名字)。 -
编译与链接:按下
F9(Build All)或点击工具栏上的“闪电”图标。IDE会依次调用bcc32编译器编译每个.cpp文件,然后用ilink32链接器将所有.obj文件和PL01.res资源文件打包成一个可执行文件。如果出现任何编译错误,请不要急于修改代码,先检查错误信息。最常见的错误是:Unit1.h not found: 检查Include path设置是否正确。Undefined symbol 'GenLoad': 检查PL01.cpp是否包含了#include "Unit1.h",以及GenLoad()函数的声明和定义是否拼写一致。Cannot open input file 'PL01.res': 这个文件是窗体资源,如果丢失,可以暂时在项目选项中取消勾选Use resource file,或者从备份中恢复。
-
运行编译器:编译成功后,会在项目目录下生成
pl0_compiler.exe。此时,不要双击它!它是一个命令行程序。按Win+R,输入cmd,进入命令提示符,cd到项目目录,然后输入:
bash pl0_compiler.exe test.pl0
如果一切顺利,你应该会看到类似Compilation successful. Output written to test.COD.的提示。这意味着,test.pl0源文件已经被成功编译,生成了test.COD目标码文件。
实操心得:我第一次编译时,在
main.cpp里加了一行printf("Compiler started.\n");,放在main()函数的第一行。这样,每次运行pl0_compiler.exe,我都能第一时间确认程序确实启动了,而不是因为路径问题根本没运行起来。一个小小的printf,能帮你省下半小时的排查时间。
4.2 测试用例详解:从test.pl0到PL0.PAS,理解每行代码的深意
项目附带了多个测试用例,其中test.pl0是最精炼的入门示例,而PL0.PAS则是一个更接近真实Pascal程序的综合测试。我们来逐行解读test.pl0,并说明它如何触发编译器的每一个扩展特性。
program test;
var a, b, i, j: integer;
begin
a := 10;
b := 20;
// 单行注释:测试 // 注释
if a < b then
write(a) // 输出 a
else
write(b); // 输出 b,ELSE分支生效
// 块注释:测试 /* ... */ 注释
/* 这里是块注释
可以跨多行 */
for i := 1 to 5 do begin // FOR-TO 循环
write(i); // 输出 1,2,3,4,5
a := a + 1; // 自增 a
end;
for j := 10 downto 6 do begin // FOR-DOWNTO 循环
write(j); // 输出 10,9,8,7,6
end;
// 测试 ++ 和 -- 运算符
i := 0;
while i < 3 do begin
write(i); // 输出 0,1,2
i++; // 自增,等价于 i := i + 1
end;
// 测试 <> 不等号
if a <> b then
write('a and b are different');
end.
这个程序完美覆盖了所有扩展点:
- //和/*...*/:验证了词法分析器的注释处理能力。
- ELSE:if a < b then ... else ...,触发了IfStatement()中的前瞻逻辑。
- FOR ... TO ... DO和FOR ... DOWNTO ... DO:两个循环,分别测试了TO和DOWNTO关键字的识别和循环体生成。
- i++:在while循环中,验证了后缀自增运算符的解析和代码生成。
- <>:if a <> b then ...,验证了不等号的词法识别和语义检查(a和b都是整型,可以比较)。
运行pl0_compiler.exe test.pl0后,生成的test.COD可以用文本编辑器打开。你会看到,FOR循环被翻译成了带有JPC和JMP的跳转结构,i++被翻译成了LOD(加载i)、LIT(加载1)、OPR 2(加法)、STO(存储回i)这一串指令。这就是理论照进现实的过程。
注意:
PL0.PAS是一个更复杂的例子,它包含了过程(procedure)定义和调用。运行pl0_compiler.exe PL0.PAS,会生成PL0.COD。你可以对比test.COD和PL0.COD的长度和指令复杂度,直观感受到过程调用带来的额外开销(CAL,RET,INT指令的出现)。
5. 常见问题与排查技巧实录:那些年踩过的坑,都给你填平了
5.1 词法分析器常见问题速查表
| 问题现象 | 根本原因 | 排查与解决方法 |
|---|---|---|
test.pl0编译时报错:“Unexpected character ‘a’ at line 1, column 1” | 词法分析器未能识别program关键字,可能是因为ReservedWords映射表中缺少"program"或拼写错误(如"Program"首字母大写)。 | 打开PL01.cpp,找到ReservedWords的定义(通常是一个std::map<std::string, TokenType>),检查"program"是否映射到TOKEN_PROGRAM。确保所有保留字("if", "then", "else", "for", "to", "downto", "do")都已正确添加。 |
a++被识别为a+和+两个Token,导致语法错误 | GetNextToken()中对++的前瞻逻辑有缺陷,可能是在读取第二个+后,没有正确地将lookahead更新为下一个Token。 | 在GetNextToken()中TOKEN_INC分支的末尾,添加lookahead = GetNextToken();(或等效的更新语句)。确保lookahead始终指向下一个待处理的Token。 |
/* comment */后面的代码被当作注释的一部分 | 块注释的结束条件判断错误,可能将/* a */ b */中的第一个*/就判定为结束,导致b */被当作无效代码。 | 检查GetNextToken()中处理/*的代码段。确保它使用了一个状态标志(如inStar),当读到*时置为true,当读到/且inStar为true时才结束注释,并重置inStar为false。 |
5.2 语法分析器常见问题速查表
| 问题现象 | 根本原因 | 排查与解决方法 |
|---|---|---|
IF a THEN b ELSE c被解析为(IF a THEN b) ELSE c,导致语法错误 | IfStatement()函数没有实现“悬空else”的正确绑定,即没有在解析完THEN后的Statement()后,主动前瞻TOKEN_ELSE。 | 检查IfStatement()函数。在Expect(TOKEN_THEN)和Statement()之后,必须添加类似if (lookahead == TOKEN_ELSE) { Expect(TOKEN_ELSE); Statement(); }的代码。这是解决Dangling Else问题的标准做法。 |
FOR i := 1 TO 10 DO ...编译通过,但运行时循环次数不对(如只执行了1次) | GenForLoop()生成的COD指令中,循环变量的更新逻辑有误,或者循环条件判断的跳转地址没有正确填充。 | 在GenForLoop()函数中,找到生成循环体后更新i的代码段。确保它生成了LOD(加载i)、LIT(加载步进值1)、OPR 2(加法)、STO(存储回i)这一完整序列。同时,检查JPC指令的addr是否指向了循环体的开头,而不是其他地方。 |
编译器在解析BEGIN ... END块时崩溃 | CompoundStatement()函数中,对END关键字的Expect()调用失败,可能是因为lookahead在到达END前就已经是EOF(文件结束),或者被其他错误消耗掉了。 | 在CompoundStatement()的循环体中,添加一个安全检查:if (lookahead == TOKEN_EOF) { Error("Unexpected end of file in compound statement"); return; }。确保在每次Statement()调用后,都检查lookahead的有效性。 |
5.3 目标代码与运行时问题速查表
| 问题现象 | 根本原因 | 排查与解决方法 |
|---|---|---|
test.COD生成成功,但在PL0虚拟机上运行时,write(i)输出的不是预期的数字,而是乱码或0 | write操作对应的OPR 14指令没有被虚拟机正确实现,或者LOD指令加载的i值是错误的(可能是因为i的偏移量i_off计算错误)。 | 首先,用文本编辑器打开test.COD,找到write(i)附近的指令。确认是否有LOD 0 i_off和OPR 0 14。然后,检查Unit1.h中SymbolTable类的GetOffset()方法,确认它为变量i返回的偏移量,与GenForLoop()中使用的i_off是否一致。 |
FOR循环无限执行 | 循环条件判断的JPC指令跳转到了错误的地址,或者循环变量的更新指令(i := i + 1)没有被执行。 | 在test.COD中,找到JPC指令,查看它的addr字段。这个地址应该指向循环体的开头(LOD指令)。如果它指向了JPC指令自身或一个无效地址,那就是跳转地址填充错误。检查PL01.cpp中负责回填跳转地址的PatchJumpAddresses()函数,确保它遍历了所有待填充的JMP/JPC指令,并正确设置了addr。 |
添加新功能(如数组)后,编译器编译test.pl0时报错:“Stack overflow” | 新增的语法分析函数(如ArrayDeclaration())存在无限递归,可能是因为在某个Expect()失败后,没有正确地消耗掉错误的Token,导致lookahead一直停留在同一个错误Token上,函数反复调用自身。 | 在所有可能出错的Expect()调用后,添加if (lookahead == TOKEN_ERROR) return;。更重要的是,在Expect()函数内部,如果匹配失败,除了报错,还必须调用GetNextToken()来消耗掉当前这个错误的Token,防止它卡住整个分析器。 |
最后一个小技巧:当你对某个特定问题百思不得其解时,最好的办法是制作一个最小化复现用例。比如,如果你怀疑
++有问题,就新建一个mini.pl0文件,里面只有一行program mini; var i: integer; begin i := 0; i++; write(i); end.。用这个极简的文件去测试,能瞬间排除掉所有干扰因素,让你的注意力100%聚焦在i++这一个点上。这是我十年来,解决90%以上编译器bug的终极心法。
简介:这个PL0编译器用C++实现,覆盖编译原理四大阶段:词法分析、语法分析、语义分析和目标代码生成。支持标准PL0语法基础上扩展了ELSE条件分支、Pascal风格的FOR循环(含TO/DOWNTO步进控制)、++/–运算符、<>不等号写法;注释功能完整,兼容//单行注释和/…/块注释。源码结构清晰,包含Unit1.cpp、PL01.cpp、main.cpp等核心文件,搭配详细项目说明.md文档,里面列出了文法定义、语法图、语义动作实现逻辑和测试用例执行过程。所有代码已在Windows平台通过C++ Builder环境验证,可直接编译运行,test.pl0和PL0.PAS等测试样例均已配套。适合计算机专业做课程设计、期末大作业或毕设起步框架,初学者能快速看清编译流程各环节如何衔接,有经验者可在现有结构上继续添加数组、实数类型、函数参数等新特性。


被折叠的 条评论
为什么被折叠?



