简介:这份资源面向学习编译原理、需要完成课程实验的高校学生,以经典教学型编译程序PL/0为改造对象,帮助读者在词法分析、语法分析与语义处理三个环节上动手实践,深入理解编译过程的基本原理与实现方法。压缩包共24个文件,约644KB,包含cpp与h源码、dsp与dsw工程文件、exe可执行程序、doc实验报告以及obj、pdb、ilk等编译中间产物,覆盖从源码到可运行程序的完整工程结构。实验要求明确:新增ELSE、FOR、TO、DOWNTO、RETURN等保留字与+=、-=、++、--运算符,将不等号#改为<>,并为条件语句补充ELSE子句,读者可据此对照修改词法与语法分析程序。目前已有789人学习下载,适合作为课程实验的参考实现与排错对照,也可用于复习编译前端各阶段的处理流程。
1. 编译原理实验:为什么一个 ELSE 语句能让整个编译器翻车
很多人以为编译原理实验就是照着语法规则写几个递归下降函数,把 PL/0 的保留字表抄一遍就能跑通。但真正动手之后你会发现,最让人抓狂的不是词法分析,而是当你试图给 PL/0 加上 ELSE 语句时,整个语法分析器突然开始“精神分裂”——明明输入看起来完全合法,解析器却报出一堆莫名其妙的错误。这背后的原因,是编译原理实验里最值得搞懂的一个底层机制:语法分析的本质不是“识别合法句子”,而是在一个存在歧义的文法空间里,用确定性的策略做出正确的推导决策。PL/0 原本没有 ELSE,它的条件语句文法天然无歧义;一旦引入 ELSE,悬空 ELSE 问题立刻浮现,LL(1) 分析表出现多重入口,递归下降函数不知道该在哪个分支返回。这份实验资源之所以值得深挖,是因为它逼着你从“会写代码”走到“理解为什么这么设计”,而这一步,恰恰是编译原理从课本走向真实实现的分水岭。
2. 保留字与词法分析:为什么关键字不能靠字符串匹配硬扛
2.1 保留字表的本质是一个优先级仲裁器
PL/0 的保留字包括const、var、procedure、begin、end、if、then、while、do、call、read、write、odd等。初学者最常见的做法是:读到一个标识符后,拿它去和保留字列表逐个比较,匹配上了就改 token 类型。这个做法在 PL/0 这种小语言里能跑,但它掩盖了一个关键设计问题——词法分析器在读到if的时候,它并不知道后面会不会跟着(或数字,它只能根据字符序列本身做出判断。
常见的实现思路是维护一张静态的保留字哈希表,在标识符识别完成后做一次 O(1) 查表。但这里有个容易翻车的细节:PL/0 的标识符规则是“字母开头,后跟字母或数字”,而保留字全部由字母组成。如果你在扫描时先贪婪地吃掉所有字母数字,再查表,那么iff会被正确识别为标识符,if会被识别为保留字——这没问题。但如果你在扫描到i的时候就急着判断是不是if,就会把iff误判。所以词法分析器的状态机必须完整走完标识符的接受状态,再做保留字仲裁。
// 典型的 PL/0 词法分析器片段:标识符与保留字处理 // 先完整读取标识符,再查保留字表 int getsym() { // 跳过空白 while (isspace(ch)) getch(); if (isalpha(ch)) { char id[MAX_ID_LEN]; int len = 0; // 关键:必须完整读取整个标识符 while (isalnum(ch)) { if (len < MAX_ID_LEN - 1) id[len++] = ch; getch(); } id[len] = '\0'; // 查保留字表,命中则返回对应 token,否则为标识符 int keyword = lookup_reserved(id); if (keyword != 0) { sym = keyword; // 保留字 token } else { sym = IDENT; // 普通标识符 strcpy(id_name, id); } } // ... 其他字符处理 }这段代码的核心逻辑在于:保留字识别不是“前缀匹配”,而是“全等匹配”。lookup_reserved必须用完整字符串做精确比较,不能用strncmp只比前几个字符。参数MAX_ID_LEN决定了标识符最大长度,PL/0 标准实现通常取 10 或 16,超长截断而不是报错,这是为了保持词法分析的确定性——截断后仍然是一个合法标识符,只是语义上可能冲突,这个锅交给语法分析或语义分析去背。
2.2 保留字扩展的连锁反应
当你为了支持 ELSE 而新增保留字时,词法分析器本身改动很小,但语法分析器的改动是结构性的。else加入保留字表后,所有原本把else当作普通标识符处理的路径全部失效。更隐蔽的问题是:如果你的保留字表用线性查找,新增一个词只是多一次比较;但如果用哈希表,哈希函数的设计必须保证else和其他保留字不冲突。我一般会建议在实验里直接用有序数组加二分查找,代码量小且行为可预测,调试时一眼就能看出哪个环节出了问题。
注意:保留字表的大小和内容直接决定了词法分析器的“词汇量”,但语法分析器的复杂度取决于文法结构,不是保留字数量。新增 ELSE 带来的麻烦不在词法层,而在语法层。
3. ELSE 语句的语法分析:悬空 ELSE 到底悬在哪
3.1 PL/0 原始文法为什么没有歧义
PL/0 原始的条件语句文法大致是这样的:
statement -> if condition then statement | while condition do statement | begin statement {; statement} end | ...注意if condition then statement的statement部分可以递归地又是一个if语句。比如:
if a > 0 then if b > 0 then x := 1这个句子在原始文法下只有一种解析方式:外层if的then后面跟的是整个内层if语句。因为原始文法里没有else,所以不存在“内层 if 抢走 else”的问题。语法分析器在递归下降时,遇到if就调用parse_if,parse_if解析完then后面的statement后直接返回,没有任何歧义。
3.2 加入 ELSE 后歧义从哪来
一旦文法变成:
statement -> if condition then statement [else statement] | ...句子if a > 0 then if b > 0 then x := 1 else y := 2就有了两种合法的推导树:
- 推导一:
else属于外层if,即if a>0 then (if b>0 then x:=1) else y:=2 - 推导二:
else属于内层if,即if a>0 then (if b>0 then x:=1 else y:=2)
这两种推导在语法上都合法,但语义完全不同。这就是经典的悬空 ELSE 问题。对于递归下降分析器来说,当parse_if解析完then后面的statement后,它面临一个选择:是继续看下一个 token 是不是else,还是直接返回让外层处理?如果内层parse_if贪婪地吃掉了else,那么else就绑定到内层;如果内层不吃,外层才有机会。
3.3 递归下降的确定性策略:就近匹配
绝大多数编译器实现采用“就近匹配”原则:else绑定到最近的未匹配if。在递归下降里,这意味着内层parse_if在解析完then分支后,必须主动检查并消费else,而不是留给外层。
// 递归下降中 if 语句的解析逻辑(就近匹配策略) void parse_if() { expect(IF); parse_condition(); expect(THEN); parse_statement(); // 解析 then 分支 // 关键:主动检查 else,就近匹配 if (current_token == ELSE) { next_token(); // 消费 else parse_statement(); // 解析 else 分支 } // 如果没有 else,直接返回,不报错 }这段代码的机制说明:parse_statement()在解析then分支时,如果遇到嵌套的if,会递归调用parse_if。内层parse_if执行到检查else的那一步时,如果当前 token 是else,它就会消费掉。外层parse_if再检查时,else已经被内层吃掉了,所以外层不会误判。参数方面,current_token是词法分析器当前返回的 token,next_token()推进到下一个 token。这个策略的代价是:语法分析器必须严格按顺序单 token 前瞻,不能回溯。如果文法设计不当,单 token 前瞻不够用,就需要改写成 LL(1) 或使用更复杂的分析策略。
3.4 LL(1) 分析表的冲突与解决
如果你用的是 LL(1) 预测分析表而不是递归下降,悬空 ELSE 会直接导致分析表出现多重入口。具体来说,在statement的 FOLLOW 集中包含else,而if的 FIRST 集中也包含if,当栈顶是statement且当前输入是else时,分析表里会有两个候选产生式:一个是用if产生式继续推导,另一个是用空产生式(如果文法里有的话)。解决方法是重写文法,消除左递归和公共前缀,并显式规定 ELSE 的优先级。常见的改写方式是把statement拆成matched_statement和unmatched_statement,确保每个else都有明确的归属。
| 策略 | 适用场景 | 代价 |
|---|---|---|
| 就近匹配(递归下降) | 手写分析器,PL/0 实验常用 | 无法处理需要回溯的文法 |
| 文法重写(LL(1)) | 自动生成分析表 | 文法可读性下降 |
| 优先级声明(Yacc/Bison) | 使用生成工具 | 依赖工具特性,黑盒程度高 |
4. 从语法树到目标代码:ELSE 分支怎么落地
4.1 语法树节点的设计
在递归下降过程中,每解析完一个if语句,就应该生成一个语法树节点。对于带else的if,节点结构通常包含四个字段:条件表达式、then分支语句、else分支语句(可能为空)、以及一个标记位表示是否有else。
// 语法树中 if 节点的典型结构 typedef struct ASTNode { NodeType type; // 节点类型:IF_STMT struct ASTNode* cond; // 条件表达式 struct ASTNode* then_branch; // then 分支 struct ASTNode* else_branch; // else 分支,无 else 时为 NULL } ASTNode;这个结构的核心设计取舍是:用 NULL 表示“没有 else”,而不是用一个特殊的空语句节点。这样做的好处是代码生成阶段可以简单地判断else_branch != NULL来决定是否生成跳转指令;坏处是遍历树的时候需要处处判空。我一般会建议在实验里保持 NULL 语义,因为 PL/0 的语句种类少,判空成本可以接受。
4.2 目标代码生成中的跳转链
PL/0 的目标代码是一种简单的栈式虚拟机指令,常见的指令包括JMP、JPC(条件跳转)、LIT、LOD、STO等。对于if condition then S1 else S2,代码生成的基本模式是:
<生成 condition 的代码> JPC L1 ; 条件为假则跳到 L1 <生成 S1 的代码> JMP L2 ; 跳过 else 分支 L1: <生成 S2 的代码> L2: ...如果没有else,则简化为:
<生成 condition 的代码> JPC L1 <生成 S1 的代码> L1: ...这里的关键机制是回填(backpatching)。在生成JPC指令时,L1的具体地址还不知道,因为S1的代码还没生成。所以需要先记下这条指令的位置,等S1生成完毕后,再把L1的实际地址回填进去。同理,JMP L2的L2也要等S2生成完才能确定。回填是单遍编译器的核心技术,它避免了生成中间代码再二次遍历的开销。
// 回填示例:生成 if-else 的目标代码 int gen_if_else(ASTNode* node) { int jpc_pos = gen_code(JPC, 0); // 生成 JPC,地址待回填 gen_code_for(node->then_branch); // 生成 then 分支 int jmp_pos = gen_code(JMP, 0); // 生成 JMP,地址待回填 int else_start = current_code_index(); // 记录 else 分支起始位置 patch(jpc_pos, else_start); // 回填 JPC 的目标地址 if (node->else_branch != NULL) { gen_code_for(node->else_branch); // 生成 else 分支 } int after_else = current_code_index(); // 记录整个 if 之后的位置 patch(jmp_pos, after_else); // 回填 JMP 的目标地址 return after_else; }参数说明:jpc_pos和jmp_pos是代码数组中指令的索引,patch函数把索引处的指令操作数修改为目标地址。current_code_index()返回下一条待生成指令的位置。这个实现里有一个容易忽略的细节:即使没有 else 分支,JMP指令仍然生成了,只是跳转目标就是下一条指令的位置。这看起来浪费了一条指令,但简化了回填逻辑,避免了条件判断。在 PL/0 这种教学语言里,代码体积不是瓶颈,逻辑清晰更重要。
4.3 符号表与作用域在 ELSE 分支中的表现
PL/0 的if语句不引入新的作用域,then和else分支里声明的变量(如果有var声明的话,但 PL/0 的var只能在过程开头)实际上属于同一个作用域。这意味着在else分支里访问then分支里赋值的变量是合法的。符号表在进入if语句时不需要压栈,退出时也不需要弹栈。这一点和begin...end块不同,后者在某些扩展实现里会引入新作用域。如果你的实验要求支持块作用域,那么else分支的符号表处理就需要额外小心:then分支和else分支是互斥的,但它们共享同一个外层作用域,不能因为分支互斥就把符号表也隔离了。
5. 避坑与排查:ELSE 实验里最常见的五个翻车现场
5.1 现象:输入if a>0 then x:=1 else y:=2报“意外的 else”
原因:词法分析器的保留字表里没有加else,导致else被识别为普通标识符,语法分析器在then分支结束后期待;或end,却看到了一个标识符,于是报错。
解决:检查保留字表,确保else已经加入,并且查表逻辑用的是全等匹配而不是前缀匹配。同时确认词法分析器在读取else时完整消费了所有字符,没有把e后面的lse留给下一个 token。
5.2 现象:嵌套if时else绑定到了外层
原因:递归下降的parse_if在解析完then分支后没有主动检查else,而是直接返回,导致外层parse_if看到了else并消费掉。
解决:在parse_if的then分支解析完成后,立即检查current_token == ELSE,如果是就消费并解析else分支。这个检查必须在返回之前执行,不能留给调用者。
5.3 现象:目标代码执行时else分支被无条件执行
原因:生成then分支代码后忘记生成JMP跳过else分支,或者JMP的目标地址回填错误,跳到了else分支内部而不是跳过它。
解决:检查gen_if_else中jmp_pos的回填位置。JMP应该跳过整个else分支,目标地址是else分支结束后的下一条指令。如果else_branch为 NULL,JMP的目标地址和JPC的目标地址相同,此时可以优化掉JMP,但保留它也不会出错。
5.4 现象:LL(1) 分析表出现多重入口,程序崩溃
原因:文法没有消除悬空 ELSE 歧义,statement的 FOLLOW 集和if产生式的 FIRST 集冲突。
解决:重写文法,把statement拆分为matched和unmatched两类,确保每个else都有唯一的归属。或者在递归下降里用“就近匹配”策略绕过分析表冲突。如果实验要求必须用 LL(1) 分析表,那就老老实实改文法。
5.5 现象:else分支里的变量在then分支里不可见
原因:错误地在then和else分支分别创建了独立的作用域,导致符号表隔离。
解决:确认if语句不引入新作用域。符号表的压栈和弹栈只发生在procedure和begin...end块(如果实现了块作用域的话)。then和else共享外层符号表,变量声明和查找都走同一张表。
提示:排查语法分析问题时,最有效的手段是在
parse_if入口和出口打印当前 token 和调用深度。很多“玄学”错误其实是 token 流和预期不一致,看一眼日志就真相大白。
6. 设计取舍:为什么 PL/0 实验值得用 ELSE 折磨自己
PL/0 是一个极简的语言,它的文法小到可以在一页纸上写完。但正是这种极简,让每一个扩展都变得“牵一发而动全身”。加入 ELSE 语句,表面上是多了一个关键字和一个产生式,实际上它逼着你面对三个核心问题:词法层的保留字仲裁、语法层的歧义消解、代码生成层的跳转回填。这三个问题在真实编译器里同样存在,只是规模更大、工具更复杂。
我一般会这么理解:PL/0 实验的价值不在于写出一个能跑的编译器,而在于让你亲手体验“文法设计如何决定实现复杂度”。如果你把 ELSE 的优先级用 Yacc 的%prec声明一下,问题似乎消失了,但你也失去了理解悬空 ELSE 本质的机会。递归下降加就近匹配的策略虽然土,但它把决策逻辑暴露在代码里,每一行都能对应到文法规则上。这种透明性,是自动生成工具给不了的。
从设计取舍的角度看,PL/0 的 ELSE 扩展有三种典型路线:一是纯递归下降加就近匹配,代码直观但文法受限;二是重写文法为 LL(1),分析表驱动但可读性下降;三是用 LR 分析器加优先级声明,能力强但黑盒程度高。实验里选哪条路,取决于你想让学生理解哪一层。如果目标是理解“分析器如何做决策”,递归下降最合适;如果目标是理解“文法如何影响分析表”,LL(1) 重写更有价值。
验证你的实现是否正确,最直接的方法是构造一组边界用例:嵌套if不带else、嵌套if带else、else后面跟begin...end、else分支里再嵌套if。把这几种组合的目标代码打印出来,手工模拟栈式虚拟机的执行过程,看看跳转地址是否和预期一致。如果目标代码的跳转链能手工走通,基本就不会有大问题。
从那以后我每次改文法,都强制自己先写出 FIRST 集和 FOLLOW 集,再动手写解析函数。这个习惯帮我省下了大量调试时间,也让我真正理解了“编译原理不是背规则,而是算集合”这句话的含义。希望帮到你。
本文还有配套的精品资源,点击获取