简介NUAA编译原理课设的PL0编译器Python实现面向计算机专业学生及编译原理入门者。资源覆盖词法分析、语法分析、语义分析及后端代码生成等完整流程适合需要从零构建编译器的课程设计参考也可作为自学编译原理的动手范例。压缩包共8个文件以5个Python脚本为核心配合说明文档、README及课设报告整体仅792KB轻量便于阅读与调试。目前已有181人学习源码按前端到后端分层组织能清晰展示递归下降分析、抽象语法树构建及目标代码生成等关键实现思路。通过研读代码与报告读者可快速理解PL0编译器的整体框架与细节处理方式并基于现有模块进行功能扩展或性能优化兼具教学价值与实用参考性。1. 编译原理课设选 PL0 Python这门课到底在逼你做什么编译原理课设是所有计算机专业学生的分水岭光靠背文法定义和 LR(1) 项目集是过不了验收的。NUAA 把这门课设落在 PL0 上不是让你去研究工业级编译器而是逼迫你把「词法分析 → 语法分析 → 语义分析 → 代码生成」这条链路亲手跑通一遍。PL0 是经典教学语言语法体量小但五脏俱全有嵌套过程、有 if/while 控制流、有算术表达式足够展示递归下降和栈式虚拟机的核心思想。Python 版《Compile_Principle》的价值在于它把原本要用 Pascal 或 C 写几百行的结构压缩到可读性极强的千行以内让你把精力放在「编译原理」而不是「指针调试」上。这篇文章按我实际做过的一版方案来讲从跑通最小可运行代码到写出可验收的递归下降解析器再到避开那些让所有人翻车的细节。2. 先搞懂 PL0 的脾性为什么这版适合做课设骨架2.1 PL0 的语言子集到底有多小PL0 原本是 Pascal 之父 Wirth 在《Algorithms Data Structures Programs》里定义的教学语言。它只保留了三类语句赋值、过程调用、以及 if/while 条件控制。数据类型只有整数一种没有数组也没有字符串部分扩展版加了数组。看一个典型 PL0 源码就能感受到它的风格var x, y; procedure gcd; var a, b; begin a : x; b : y; while a b do if a b then b : b - a else a : a - b; x : a end; begin x : 24; y : 18; call gcd; write x end.注意三个细节变量必须先声明后使用语句用begin ... end包裹.表示程序结束。这些特征直接影响词法分析和语法分析的设计。整个过程没有类型推导、没有运算符重载、没有隐式转换解析器的每个 token 都可以被严格归类为保留字、标识符、数字、运算符、分隔符五类。这种小体量意味着你不需要引入任何文法分析器生成工具手写递归下降完全可行。而这也正是课设验收时老师最爱问的问题为什么这里的else匹配最近的if为什么while的条件判断放在循环体之前如果你没亲手实现过很难用「悬垂 else 匹配规则」「前置测试循环」这类术语去回答。2.2 选 Python 实现编译器的三个现实理由首先Python 对 token 的处理极其自然re模块按模式取 token、enum定义 token 类型、list当栈用这些数据结构的表达能力正好覆盖 PL0 的需求。其次递归下降解析器在 Python 里最难处理的语法分析表可以直接用if/elif链表达可读性远高于等价 C 代码。第三Python 没有指针符号表和栈帧可以用字典加列表实现避免了课设里最消耗时间的「野指针调试」环节。但这不意味着无脑用 Python。有位句话说在前面Python 的递归深度默认只有 1000 层如果你写的解析器在处理深度嵌套的begin ... end时递归调用过深直接抛RecursionError这在课设演示现场会很尴尬。后面我会给出处理方案。2.3 上下文件结构把源程序先扫成 token 流再谈语法PL0 编译器传统上分成三个程序词法分析器scanner、语法解析器parser、解释器interpreter。Python 版最常见的结构是下面三个模块compile_principle/ ├─ main.py # 入口读源文件调用词法→语法→解释 ├─ scanner.py # 词法分析源文本 → token 流 ├─ parser.py # 语法分析token 流 → AST / 直接生成指令 ├─ interpreter.py # 执行加载指令序列到虚拟机运行 └─ pl0_source.txt # 测试用例我在重写这类课设代码时一定会把三个模块分开而不是堆在一个文件里。原因是课设验收时老师会随机找一个函数问「这是干什么的」分文件的答案永远是「词法分析器的状态机」或「解释器的指令分发」而单文件堆砌的答案只能是「这是整个编译器」。3. 词法分析器落地从字符流到 token 流的最小可运行代码3.1 token 定义与保留字表一张字典解决全部问题词法分析器的本质是一个带状态的最小自动机。对 PL0 来说不需要像 Java/C 词法分析那样区分十六进制、浮点数、字符串字面量token 类型可以收敛为七类标识符、数字、保留字、运算符、分隔符、赋值符、结束符。用一个枚举把所有类型列出来from enum import Enum class TokenType(Enum): IDENT IDENT # 标识符变量名、过程名 NUMBER NUMBER # 整数常量 KEYWORD KEYWORD # 保留字begin/end/if/while 等 OPERATOR OPERATOR # 运算符 - * / ASSIGN ASSIGN # 赋值号 : SEPARATOR SEPARATOR # 分隔符, ; ( ) . EOF EOF # 文件结束 #保留字表用字典建立一个 token 被识别为标识符后先查这张表命中就改为关键字类型。注意:和是两种 token前者是赋值后者是比较运算符。PL0 没有新手最容易在这一步踩坑。3.2 写一个能用的 scanner识别与超前读字符策略下面这段代码是完整可运行的最小词法分析器支持 PL0 全集。设计上用self.pos指向当前读位置每次读一个字符就推进遇到空格和换行直接跳过。import re class Token: def __init__(self, type_, value, line): self.type type_ # TokenType 枚举 self.value value # 原始字符串或数字值 self.line line # 所在行号报错用 KEYWORDS { begin: True, end: True, if: True, then: True, else: True, while: True, do: True, call: True, const: True, var: True, procedure: True, odd: True, read: True, write: True, } class Scanner: def __init__(self, source: str): self.source source self.pos 0 self.line 1 self.tokens [] def peek(self, offset0): 超前读一个字符不移动游标。越界返回空串。 idx self.pos offset if idx len(self.source): return return self.source[idx] def advance(self): 消费当前字符维护行号。 ch self.source[self.pos] self.pos 1 if ch \n: self.line 1 return ch def skip_whitespace(self): while self.pos len(self.source): ch self.peek() if ch in \t\r\n: self.advance() else: break def scan_number(self): start self.pos while self.peek().isdigit(): self.advance() return int(self.source[start:self.pos]) def scan_ident(self): start self.pos while self.peek().isalnum(): self.advance() return self.source[start:self.pos] def tokenize(self): self.skip_whitespace() while self.pos len(self.source): ch self.peek() if ch.isdigit(): value self.scan_number() self.tokens.append(Token(TokenType.NUMBER, value, self.line)) elif ch.isalpha(): name self.scan_ident() if name in KEYWORDS: self.tokens.append(Token(TokenType.KEYWORD, name, self.line)) else: self.tokens.append(Token(TokenType.IDENT, name, self.line)) elif ch : and self.peek(1) : self.advance(); self.advance() self.tokens.append(Token(TokenType.ASSIGN, :, self.line)) elif ch in -*/;,().: self.advance() self.tokens.append(Token(TokenType.OPERATOR, ch, self.line)) else: raise SyntaxError(f第 {self.line} 行存在非法字符: {ch}) self.skip_whitespace() self.tokens.append(Token(TokenType.EOF, #, self.line)) return self.tokens逻辑说明peek(offset)和advance()是词法分析器的核心原语前者解决「超前读一个字符判断复合符号」的问题后者解决「消费字符并维护行号」的问题。:的识别依赖peek(1)超前读一个字符这是词法分析器与「逐字符手工处理」的最大区别——你必须时刻知道自己「只看不消费」和「消费」的区别。参数说明scan_number只支持非负整数PL0 标准如此scan_ident用isalnum()允许标识符中出现数字这符合 PL0 标识符必须以字母开头的约定KEYWORDS中的odd是 PL0 特有的单目运算符表示判断奇数Python 里没有对应关键字注意不要混淆。3.3 一个让所有课设翻车的非法字符检查上面代码中raise SyntaxError的那行是词法分析器里最容易被忽略却导致验收翻车的部分。PL0 源文件中如果出现、#、\、甚至中文字符你的编译器必须给出明确报错并指出行号而不是让程序崩溃在某个魔法位置。这是课设评分标准里「错误处理能力」的常见考察点。我见过最典型的一种写法是把非法字符直接忽略继续扫描下一个合法 token。这种做法表面上看「程序跑通了」但老师会立刻问「如果源码里写错了一个中文字符你凭什么忽略它」正确行为是立即报错。另一个反面典型是所有报错都写成print(fError at line {line})但不终止也不抛异常结果一个错误引发连续几十条误导信息。矛盾发生后我还是以「第一个错误立即终止」为正确行为标准只报第一个错就够了课程设计的解释器不追求错误恢复。4. 递归下降解析器用不到 300 行把文法变成可执行逻辑4.1 PL0 文法回顾为什么递归下降足够而 LR 分析属于杀鸡用牛刀PL0 文法基本上是 LL(1) 的意味着解析器永远只需要「看当前 token 决定下一步动作」。文法核心规则如下程序 :: 语句块 . 语句块 :: [ const 常量定义 {, 常量定义} ] [ var 变量声明 {, 变量声明} ] { procedure 过程声明 } 语句 常量定义 :: 标识符 数字 变量声明 :: 标识符 {, 标识符} 过程声明 :: procedure 标识符 ; 语句块 ; 语句 :: 赋值语句 | 调用语句 | 条件语句 | 循环语句 | 复合语句 | 读语句 | 写语句 | 空语句 赋值语句 :: 标识符 : 表达式 表达式 :: [ | - ] 项 { 加法运算符 项 } 项 :: 因子 { 乘法运算符 因子 } 因子 :: 标识符 | 数字 | ( 表达式 )这文法是教科书级别的经典算术优先级构造表达式在项的上一层项在因子的上一层。if语句在 PL0 中的条件部分用odd 表达式或表达式比较两种形式比较运算符只有、、、、、六种。递归下降的实现思路是「每一个非终结符对应一个函数」这天然就是 LL(1) 解析器。4.2 parser 最小实现符号表 四则运算的因子分析下面代码是 parser 的核心骨架重点展示因子、项、表达式三层递归如何映射到 Python 函数。对于课设验收这段代码也是老师最爱深挖的部分。class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 self.current self.tokens[0] # cur_token 永远指向待处理 token def advance(self): 消费当前 token并前进到下一个。 self.pos 1 if self.pos len(self.tokens): self.current self.tokens[-1] # EOF token else: self.current self.tokens[self.pos] def match(self, expected_token_type): if self.current.type expected_token_type: self.advance() else: raise SyntaxError( f第 {self.current.line} 行: 期望 {expected_token_type.value}, f实际得到 {self.current.value} ) def parse_factor(self): 因子 : 标识符 | 数字 | ( 表达式 ) if self.current.type TokenType.IDENT: name self.current.value self.advance() return (var, name) elif self.current.type TokenType.NUMBER: value self.current.value self.advance() return (num, value) elif self.current.type TokenType.OPERATOR and self.current.value (: self.advance() # 吃掉左括号 node self.parse_expression() self.match(TokenType.OPERATOR) # 期望右括号运算符类型 return node else: raise SyntaxError(f第 {self.current.line} 行: 无法识别因子起始 token) def parse_term(self): 项 : 因子 { (*|/) 因子 } node self.parse_factor() while (self.current.type TokenType.OPERATOR and self.current.value in (*, /)): op self.current.value self.advance() right self.parse_factor() node (op, node, right) # 二叉运算树 return node def parse_expression(self): 表达式 : [|-] 项 { (|-) 项 } # 一元正负号在 PL0 中没有下标定义教学版一般忽略 node self.parse_term() while (self.current.type TokenType.OPERATOR and self.current.value in (, -)): op self.current.value self.advance() right self.parse_term() node (op, node, right) return node逻辑说明parse_factor直接返回一个元组(var, name)或(num, value)而运算符则递归构造成(op, left, right)三元组这是一种极简的 AST 表示。match函数负责「验证当前 token 类型并消费」这是递归下降中保证不会越界的关键防线。参数说明self.tokens[-1]是 EOF tokenadvance在越界时回到最后一个 token 而不是抛 IndexError这是常见替代方案。如果你不这么写解析器读到文件尾部时很容易触发「列表索引越界」这个 bug 隐蔽且难以复现。4.3 语句级解析if / while / 过程调用的嵌套结构语句级解析是递归下降本质的体现——递归调用自身来处理嵌套结构。PL0 的语句解析可以用下面这个核心函数表达def parse_statement(self): 语句 : 赋值 | 调用 | if | while | begin复合 | read | write | 空 t self.current.value if self.current.type TokenType.IDENT: # 赋值语句: 变量名 : 表达式 var_name self.current.value self.advance() self.match(TokenType.ASSIGN) # 期望 : expr self.parse_expression() return (assign, var_name, expr) elif t begin: self.advance() stmt_list [self.parse_statement()] while self.current.value ;: self.advance() # 吃掉分号 stmt_list.append(self.parse_statement()) self.match(TokenType.KEYWORD) # 期望 end return (block, stmt_list) elif t if: self.advance() cond self.parse_condition() self.match(TokenType.KEYWORD) # 期望 then then_stmt self.parse_statement() else_stmt None if self.current.value else: self.advance() else_stmt self.parse_statement() return (if, cond, then_stmt, else_stmt) elif t while: self.advance() cond self.parse_condition() self.match(TokenType.KEYWORD) # 期望 do body self.parse_statement() return (while, cond, body) elif t call: self.advance() proc_name self.current.value self.match(TokenType.IDENT) return (call, proc_name) else: return None # 空语句这段代码是整份课设的灵魂。(if, cond, then_stmt, else_stmt)用统一四元组表示 if 语句else_stmt为None表示没有 else 分支。parse_statement在begin ... end内部反复调用自己这正是「递归下降」递归二字的由来。关键参数match(TokenType.KEYWORD)验证end/then/do等关键字时只检查 token 类型而不检查具体值。这里有一个隐患如果源码写begin if 1 then ... endmatch(KEYWORD)会接受end吗会接受。因为在 token 类型层面它们都是 KEYWORD数值层面的验证缺失会导致奇怪错误。我在实现时会在match里加重载match_keyword(end)精确匹配值。从工程角度来说这个函数命名上增强了意图表达建议你也这么做。4.4 符号表与作用域嵌套过程的关键数据设计PL0 支持过程嵌套因此符号表不能是单一的 dict必须支持分层的「当前层 外层」查找。最常见的实践方案是一个栈式符号表class SymbolTable: def __init__(self): self.scopes [{}] # 栈顶是当前作用域 def enter_scope(self): self.scopes.append({}) def leave_scope(self): self.scopes.pop() def declare(self, name, kind, valueNone): scope self.scopes[-1] if name in scope: raise SyntaxError(f重复声明变量: {name}) scope[name] {kind: kind, value: value} def lookup(self, name): # 从内层向外层逐层查找 for scope in reversed(self.scopes): if name in scope: return scope[name] raise NameError(f未声明的标识符: {name})这里最值得说的是lookup的查找顺序——从栈顶向内逐层查找这与 PL0 的词法作用域规则完全一致。如果你把作用域设计成「所有变量共享一个 dict」那么两个不同过程里同名变量就会互相覆盖这是课设中极常见的错误。另外注意declare对重复变量的检查。PL0 规定同一作用域内不允许重复声明但不禁止内层过程声明和外层同名变量。如果你省略这层检查老师只要在你代码里写var x, x;你就会挂。作用域模型在课设答辩中被问的概率非常高你得能说清楚「当前作用域查不到时为什么可以到外层找」。5. 目标代码生成与解释执行把 AST 变成可运行的指令流5.1 PL0 的栈式虚拟机指令集为什么用三元组PL0 传统实现有四种核心指令LIT装载常量、LOD装载变量值、STO存储值、OPR算术运算其中 OPR 又细分加减乘除、比较、读写等。每一条指令都可以抽象为一个三元组(f, l, a)f是功能码l是层级差变量所在作用域离当前作用域差几层a是偏移地址或操作数。Python 版常见的做法是用 tuple 表示指令指令表就是List[tuple]。这种设计的历史原因是 PL0 目标机是栈式虚拟机所有操作都围绕「数据栈」展开。LOD 把变量值压栈OPR 从栈顶弹出两个操作数做运算再压回去STO 把栈顶值存回变量。解释器执行时维护一个数据栈 list 作为运行栈。5.2 从 AST 生成指令给 parse_xxx 加 codegen 的惯用手法不引入新对象直接在语义分析函数里同时完成代码生成是课设最务实的方式。下面是表达式代码生成的核心class CodeGenerator: def __init__(self, symtab): self.symtab symtab self.code [] # 指令列表每项是 (f, l, a) self.data_offset 0 # 当前过程的数据长度 def emit(self, f, l, a): self.code.append((f, l, a)) def gen_expression(self, node): node 形式: (num, value) | (var, name) | (op, left, right) if node[0] num: self.emit(LIT, 0, node[1]) elif node[0] var: # 查找符号偏移量 info self.symtab.lookup(node[1]) self.emit(LOD, self.symtab.level_diff(info), info[offset]) else: op node[0] self.gen_expression(node[1]) self.gen_expression(node[2]) self.emit(OPR, 0, op) # OPR 的操作数直接存运算符符号 def gen_statement(self, ast): if ast[0] assign: _, name, expr ast self.gen_expression(expr) info self.symtab.lookup(name) self.emit(STO, self.symtab.level_diff(info), info[offset]) elif ast[0] while: _, cond, body ast start len(self.code) # 条件判断起始点 self.gen_condition(cond) jump_false len(self.code) # 记录跳转指令位置 self.emit(JPC, 0, 0) # 条件不满足时跳出循环 self.gen_statement(body) self.emit(JMP, 0, start) # 回到循环开始 self.code[jump_false] (JPC, 0, len(self.code)) # 回填跳转目标在这个设计里代码生成和执行被放在解释器里依次完成而不是先生成全部代码再执行。课设一般推荐「先生成全部代码到列表再执行」原因是方便在验收时「打印指令序列」。while的跳转目标是典型的回填问题——刚开始不知道循环体后面代码在哪先填 0等生成完再改。这个「回填」技巧在编译器领域是经典思想一定会在答辩时被问到。5.3 解释器执行指令分发的实现与一个经典陷阱解释器的核心是一个派发循环逐条执行self.code中的指令。下面给一个关键的 OPR 运算符分发片段def execute(self): pc 0 # 程序计数器 stack [] # 数据栈 while pc len(self.code): f, l, a self.code[pc] if f LIT: stack.append(a) elif f LOD: stack.append(self.get_var_value(l, a)) elif f STO: value stack.pop() self.set_var_value(l, a, value) elif f OPR: if a : right stack.pop(); left stack.pop() stack.append(left right) elif a -: right stack.pop(); left stack.pop() stack.append(left - right) elif a : right stack.pop(); left stack.pop() stack.append(1 if left right else 0) elif a : right stack.pop(); left stack.pop() stack.append(1 if left right else 0) elif a write: print(stack.pop()) # ... 其他运算符与读操作 elif f JMP: pc a continue elif f JPC: cond stack.pop() if cond 0: pc a continue pc 1经典陷阱在减法与除法OPR处理二元运算时栈顶是右操作数次顶是左操作数。如果你写成left stack.pop(); right stack.pop()那么对于10 - 3你会先弹 3右操作数再弹 10左操作数只是变量命名颠倒而已结果正确。但如果你写成right stack.pop(); left stack.pop()代码里又没有按变量名重新赋值运算时如果用错了 left/right就会得到3 - 10 -7这种反直觉结果。这种问题在课设调试里极度隐蔽因为语法、文法全对就是算错。参数说明PL0 中比较结果的表示约定 1 为真、0 为假。而JPC的语义是「条件弹出为 0 则跳转」——这个约定让 while 循环和 if 语句的指令序列都比较通畅。你还需要注意 Python 的and/or不能用在 OPR 的a判断上因为a是字符串或不是 Python 运算符。5.4 层级差 l 到底怎么算嵌套过程最难的一步LOD指令的l字段表示「变量所在层与当前层的差值」。比如主程序在第 0 层过程gcd在第 1 层那么gcd内部访问第 0 层的全局变量x时l等于 1当前层 1 减变量所在层 0。在符号表里level_diff的计算方式是当前作用域深度减去声明该变量时的深度。这个设计的等价实现方式是在符号表声明时记录作用域深度查找时计算差值。常见错误在于过程调用时的层差计算——调用一个过程后进入新作用域当前深度加 1但过程内部所有变量的 offset 应基于过程自己的基地址计算而不是基于调用者。如果做混了运行时会得到随机数或错误值。一个工程上的建议在 generator 里单独维护一个current_level变量进过程时加 1出过程时减 1所有 emit 使用它与符号表记录层级的差。用 try/finally 保证出过程时即使报错也能恢复层数否则错误路径上current_level会持续增长。6. 课设避坑指南5 个让代码跑不起来的经典翻车点6.1 现象读:时只消费了冒号留下一个悬空的原因在词法分析器中if ch :判断之后你只调用了advance()消费冒号却忘掉再消费一个。结果 token 流里出现单独一个OPERATOR 语法分析器期望 ASSIGN 却得到 OPERATOR报错位置远在赋值语句真正开始之后。解决用self.advance(); self.advance()先消费两个字符再向 tokens 追加 ASSIGN token。这个坑几乎是无差别攻击我在多个课设代码 review 中都见过。写完后用最小测试x : 5是否能产生 4 个 tokenx、:、5、EOF一测便知。6.2 现象递归下降解析begin a : 1; b : 2 end时莫名报错原因解析parse_statement的begin分支里分号是被「吃掉」了但代码写成while self.current.value ;判断当前 token 是分号才继续循环。问题在于 PL0 中分号是语句分隔符而不是语句结束符end前最后一个语句后面不能有分号。如果源码写成a : 1; b : 2; end就会出现解析完b : 2后当前 token 是;循环体再吃一个分号然后期望end却得到end之前什么都没有——具体表现千奇百怪。解决循环条件不直接判断分号而是判断「当前 token 不是end就继续解析语句」。这就是常见的 PL0 语法定义陷阱分号只做分隔不参与语句结束判断。最佳实践是解析完一个语句后如果当前 token 是分号就 consume 并继续循环如果是end就跳出循环。6.3 现象递归过深导致 Python 抛 RecursionError原因PL0 允许过程嵌套解析器递归调用与解释器执行都会导致 Python 栈深度积累。默认限深 1000 层遇到深度嵌套的begin ... end或大量过程定义时很容易超限。解决在入口处sys.setrecursionlimit(10000)。这是课设可接受的方案不是生产级程序设计。但注意设置过高的递归限制会导致 C 栈溢出崩溃一般不超过 100000。另一个配套做法是检查是不是解析器本身写出了无限递归——比如parse_statement在空语句时不做任何 token 消费直接递归调用自身会无限占用栈直到崩溃。6.4 现象打印用户输出时变量值全为 0 或全为随机数原因STO指令的地址计算错误几乎必然出在这个场景。比如var x; procedure p; var x; ...内外层同名变量时lookup查到的是内层变量但偏移量 offset 用的是外层符号表中记录的值。符号表实现时如果只是扁平 dict 而不是作用域栈内层声明直接覆盖外层同名变量解释执行时所有读写都作用在同一个槽位上。解决严格使用分层符号表并测试同名变量嵌套场景。作为验证写一段小程序全局变量 x过程内局部变量也叫 x过程内给 x 赋值主程序打印 x——如果输出的不是预期值就说明符号表有问题。6.5 现象odd运算符不认识被当标识符忽略原因odd是 PL0 保留字表示判断操作数是否为奇数。很多人词法分析器保留字表里漏写odd于是if odd n then ...中的odd被扫描为普通标识符解析器在 condition 分支里不知道如何处理它。解决在KEYWORDS中加入odd: True并在parse_condition中单独处理odd 表达式这种单目形式。condition的文法有两个分支odd 表达式和表达式 比较运算符 表达式。所以在 condition 的分派处判断当前 token 是否为odd关键字然后进入不同的解析路径。7. 验证与验收3 个可复现的测试用例让课设一次通过到这里你应该已经有了一版完整可运行的编译器。但能否通过验收取决于你如何验证它和对边界情况的把控。下面给出一组由浅入深、覆盖核心文法的测试用例以及对应的期望输出你可以直接复制到pl0_source.txt中运行。const c 5; var a, b; begin a : c 3; b : a * 2 - 1; write b end.期望输出15。这个用例验证常量声明、加法、乘法、减法以及 write 输出。如果你输出的不是 15优先检查 OPR 运算顺序和常量装载。常量在 PL0 中不占运行栈偏移它是编译期绑定到 LIT 指令的操作数。var x, y; procedure swap; var t; begin t : x; x : y; y : t end; begin x : 10; y : 20; call swap; write x; write y end.期望输出先输出 20 再输出 10。这个用例验证过程调用、局部变量、以及全局变量的跨层访问。swap过程里的t是局部变量x/y在第 0 层过程在第 1 层。如果你的 LOD 层级差计算有误这里会立刻暴露——输出要不全是 10 20要不就是随机数。var n, f; begin n : 5; f : 1; while n 0 do begin f : f * n; n : n - 1 end; write f end.期望输出120。这个用例验证 while 循环、复合语句 use 嵌套 begin...end、以及条件比较运算符. 它是经典的阶乘实现。输出 120 说明 JPC 回填和层级差完全正确如果你得到 0问题大概率出在 while 的跳转位置回填错误——循环体生成的指令覆盖了条件判断的结束位置。验收时把这三段代码逐一演示并说出每一步输出的来源就已经足以证明你掌握了下述全部能力符号表管理、递归下降、指令回填、栈式解释执行。我一般习惯在演示前先跑一遍第三段阶乘测试因为它是唯一一个同时覆盖三类语句和表达式优先级、且输出可心算验证的用例。如果你还想进阶一点给解释器加一个trace模式执行每条指令前打印(f, l, a)三元组和当前栈内容。调试嵌套过程时极其好用因为它能让你肉眼看清楚 LOD 每次从哪个级别装载了什么偏移量。这个技巧在答辩现场也会给老师留下深刻印象——中段运行数据能讲明白说明你的实现不是「抄来的」而是真正能调试的。实现方式很简单在解释器的while pc len(code)循环开头加一行print(self.code[pc], stack)。这一路做下来我的习惯是每改完一个模块先跑词法测试、再跑语法测试、最后跑完整程序而不是把所有模块一次拼完再调试。把三百万行编译器是谎言但千行的 PL0 课设一旦分层调试报错位置几乎总是立即明确的。词法层用x : 5.这种非法字符测试语法层用缺少分号的语句测试语义层用阶乘测试层层设防比最后集中翻车效率高太多。希望这些排错方法和验收用例能帮到你让你把时间花在真正理解编译原理而不是和报错信息搏斗上。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?