资讯详情

Pascal子集编译器实战:从词法分析到栈式虚拟机生成

📅 2026/10/9 11:55:18 | 华诺云谱 👁 阅读
Pascal子集编译器实战:从词法分析到栈式虚拟机生成
简介一份面向编译原理课程设计的小型Pascal子集编译器设计报告完整呈现词法分析、语法分析、语义分析、中间代码生成及目标代码生成等核心模块的实现思路适合计算机专业学生及需要在编译方向动手实践的开发者参考。压缩包内仅含1个doc文档大小952KB内容为北京邮电大学课程设计报告包含总体设计、接口定义、详细算法说明、团队分工与测试结果可直接作为课程设计模板或实现思路蓝本。已有240人学习下载。文档以文字说明为主重点展示了词法分析器与语法分析器接口设计、LL语法分析方法、四元式中间代码生成以及符号表构建等关键环节并附有源程序示例与错误处理方式对理解编译器各阶段衔接和撰写规范均有实际帮助。1. 小型Pascal子集编译器一门课怎么从“能跑”做到“讲得清”“小型Pascal子集编译器”这类题目大多数人的第一反应是先找代码。但真正拉开差距的往往不是那几千行源码而是一份能把“为什么这样选、边界在哪里、测试怎么证明它正确”讲明白的设计报告。代码可以复原报告里体现出来的子集裁剪、符号表策略、运行时栈布局、错误定位方式才是答辩和课程评审真正会追问的地方。这篇文章按一条能完整落地的链来讲子集怎么选、词法与递归下降怎么做、语义检查和代码生成怎么衔接、以及哪几个坑会让整个项目翻车。适合正在写这门课设计报告的人也适合打算把编译器当毕业设计方向的同学。2. 先定边界Pascal子集选型与整体架构2.1 让报告先立住子集选型的三个原则任何“子集编译器”的第一步都不是写代码而是写清楚“为什么这个子集是这样”。很多同学栽在“什么功能都想要”结果词法、语法、语义各有半截报告变成功能清单。常见做法是先定三个原则类型系统最小化、控制流覆盖典型、过程与函数要保留一个。类型系统里我只保留了 INTEGER 和 BOOLEAN去掉 REAL、CHAR、数组和枚举。不要小看这个裁剪——它让类型检查、表达式求值、栈帧设计都变成“两个类型 一个错误类型”的二元问题。控制流保留 IF、WHILE、FOR 和 BEGIN…END 复合语句覆盖了分支、循环、嵌套三类最典型的语法结构。过程与函数建议至少保留函数这是整个报告中最有深度的部分参数传递、局部变量、返回值、作用域遮蔽全都要靠它支撑。把这几样做扎实报告已经比单纯“词法语法分析器”高一个量级。子集定下来之后建议在报告开头用一张表交代“保留了什么、为什么保留”。这张表既是给评审看的也是给自己划红线的。我实际用的裁剪范围如下功能块保留内容裁剪理由类型INTEGER、BOOLEAN两类型让语义检查工作量可控常量数字、TRUE、FALSE不做常量折叠变量声明VAR 区块可嵌套声明支撑作用域讨论表达式算术、比较、逻辑优先级齐全对应递归下降的经典难点语句IF、WHILE、FOR、赋值、复合语句覆盖分支与循环函数无参或少参函数值传递讲清运行时栈与符号表输入输出保留 WRITE不做 READ测试时直接赋值避免交互2.2 前端、符号表、目标代码的整体链路架构上我建议明确分成三段词法分析、语法语义分析、代码生成。不要在语法分析阶段顺手生成目标代码——表面上省了一步实际上把“语义错误定位”和“指令生成顺序”两类问题搅在一起后期调试成本翻倍。比较干净的递进是词法产出 Token 流语法分析直接构建 AST语义检查在 AST 上做最后遍历 AST 生成指令。语法分析器用递归下降写法。Pascal 子集的文法足够小手写递归下降比引入 Yacc 之类的生成器更好讲清楚也更容易在报告里展示“每个非终结符对应一个函数”。AST 节点用一个简单类层次就够了常见的做法是每个节点类型带 line/column 字段所有语义错误都能指向源码位置。中间表示我选择栈式虚拟机指令而不是直接生成汇编或目标机器码。理由很现实其一栈式指令与表达式的后序遍历天然对应生成逻辑几乎不用额外设计其二可以附带写一个不到一百行的解释器让“编译器运行时”成为闭环测试时能看到程序真实输出其三如果课程要求最终生成 MIPS 汇编栈式指令可以作为中间层后面再做一次指令映射即可。整条链在报告中的顺序建议是文法定义 → Token 设计 → AST 定义 → 符号表与作用域 → 语义检查 → 指令集与生成 → 解释器 → 测试方案。3. 词法与语法分析递归下降里的两个关键实现3.1 词法Token设计与数字溢出检查词法分析器的输入是源码字符串输出是带位置信息的 Token 列表。我的实现用 Python因为代码量小、方便在报告里画图说明。核心是把“读一个字符、判断类型、决定是否回退”这类操作封装成一眼能看懂的流程避免直接在循环里堆状态判断。下面的代码保留了一个在课程项目里经常被忽略的点数字溢出检查。class Lexer: def __init__(self, text): self.text text self.pos 0 self.line 1 self.col 1 self.keywords {PROGRAM, VAR, BEGIN, END, IF, THEN, ELSE, WHILE, DO, FOR, TO, FUNCTION, WRITE, INTEGER, BOOLEAN, TRUE, FALSE} def peek(self): return self.text[self.pos] if self.pos len(self.text) else def advance(self): ch self.text[self.pos] self.pos 1 if ch \n: self.line 1 self.col 1 else: self.col 1 return ch def tokenize(self): tokens [] while self.pos len(self.text): ch self.peek() if ch.isspace(): self.advance() elif ch.isalpha(): tokens.append(self._read_ident()) elif ch.isdigit(): tokens.append(self._read_number()) else: tokens.append(self._read_operator()) tokens.append(Token(EOF, , self.line, self.col)) return tokens def _read_ident(self): line, col self.line, self.col buf while self.peek().isalnum(): buf self.advance() kind KEYWORD if buf in self.keywords else IDENT return Token(kind, buf, line, col) def _read_number(self): line, col self.line, self.col buf while self.peek().isdigit(): buf self.advance() value int(buf) # 子集约定整数范围超出直接报词法错误 if value 32767: raise LexError(整数越界, line, col) return Token(NUMBER, value, line, col)这个实现里有三个参数值得说。第一peek 和 advance 分离是词法分析的老规矩peek 只看不取决定下一步动作advance 才真正消费字符。第二关键字用集合判定的前提是先按标识符扫描再查表顺序不能反过来否则会把 IF 拆成 I 和 F。第三溢出检查放在词法阶段而不是语法阶段目的是让“整数太大”这种错误在最早的位置暴露错误信息里不带任何 AST 上下文定位更直接。TRUE 和 FALSE 在这里按关键字处理归到 BOOLEAN 常量比把布尔值留给语法阶段再判断更省事。3.2 语法递归下降处理表达式与悬空ELSE问题递归下降解析器的核心是“每个文法非终结符写一个函数函数之间互相调用”。表达式文法需要分多层处理优先级我按先乘除后加减、比较再逻辑的顺序写成 expression 系列函数。下面的片段展示了语句层最关键的一个坑ELSE 的归属问题。class Parser: def parse_stmt(self): tok self.peek() if tok.kind IF: return self.parse_if() elif tok.kind WHILE: return self.parse_while() elif tok.kind BEGIN: return self.parse_compound() elif tok.kind IDENT: return self.parse_assign() elif tok.kind in (ELSE, END): # 语句列表的结束符不构成语法错误 return None else: self.error(f非法的语句起始符 {tok}) def parse_if(self): if_tok self.advance() # 吃掉 IF cond self.parse_expr() self.expect(THEN) then_stmt self.parse_stmt() else_stmt None if self.peek().kind ELSE: self.advance() else_stmt self.parse_stmt() return IfNode(if_tok.line, cond, then_stmt, else_stmt)如果只给 parse_stmt 按错误处理 ELSE 和 END那么在解析 THEN 分支时若遇到 ELSEparse_if 已经提前看到了 ELSE 并消费它语义就是“ELSE 属于最近的 THEN”这正好是 Pascal 和多数块结构语言的规则。陷阱在另一个方向先写了通用语句解析遇到 ELSE 直接抛错就会导致“IF … THEN IF … ELSE …”里内层 ELSE 被错当成外层的部分等真正想匹配外层 ELSE 时已经消费完毕。解决方式就是在语句列表的分隔循环里“把 ELSE 当成合法结束符号”。表达式的代码基本是固定套路parse_expr 处理等号和比较parse_term 处理加减parse_factor 处理乘除和括号。每个函数都以“取一个操作数的函数 while 循环 二元运算符”为骨架注意不要在循环里反复调用 peek 导致位置推进错乱。递归下降最容易血泪的地方是“丢失一个 Token”所以我的经验是每个函数头先明确它消费几个 Token例如 parse_if 消费 IF、condition、THEN、then_stmt、可选 ELSE、else_stmt测试时就按这个计数检查。3.3 符号表作用域嵌套与变量遮蔽符号表最直接的实现是“作用域栈”每个过程或函数对应一层字典查找变量从栈顶往栈底找。Pascal 块结构的重点在于内层可以遮蔽外层同名变量过程退出后内层名字不能残留。这里有个很容易翻车的做法——把所有变量放进同一个大字典后来发现函数参数和外层变量重名时不知道该读哪个。class ScopeStack: def __init__(self): self.stack [{}] # 最底层是全局作用域 def push_scope(self): self.stack.append({}) def pop_scope(self): self.stack.pop() def declare(self, name, info): if name in self.stack[-1]: raise SemanticError(f变量 {name} 重复声明) self.stack[-1][name] info def lookup(self, name): for scope in reversed(self.stack): if name in scope: return scope[name] return Nonedeclare 只写栈顶保证重复声明只检查当前层lookup 从栈顶向下找实现遮蔽语义。真正要写细节的是 lookup 失败后的报错“未声明的标识符”必须由语义检查阶段抛出而不是在代码生成阶段才发现。我在早期版本里图省事让 lookup 直接返回 None 然后生成空地址导致一段代码既不知道错在哪一行也不知道是符号问题还是生成的指令本身有问题。正确的顺序是AST 构建完成后先做语义检查包括类型检查、未声明检查、函数参数个数检查全部通过再进入代码生成。4. 语义检查与代码生成从AST到栈式虚拟机指令4.1 为什么选择栈式虚拟机而不是直接生成汇编很多课程要求里写着“生成目标代码”但没说必须是真实 CPU 的指令。栈式虚拟机在编译器设计里属于经典且安全的选择指令短、语义简单、和表达式树天然同构。一个算术表达式 a b * c 在栈机上就是 PUSH a、PUSH b、PUSH c、MUL、ADD后序遍历的顺序。这意味着代码生成模块可以做成“递归遍历 AST 时遇到二元运算就先生成左子树指令、右子树指令、最后输出运算符指令”逻辑几乎不会出错。另一个理由是验证成本低。写一个解释器执行这些指令只要四五十行代码就能让整个编译流程跑起来看到输出。如果改成生成 MIPS 汇编要么靠交叉工具链要么手工计算寄存器分配这两件事都会把调试时间翻几倍。等栈机版本稳定之后再补一层“栈机指令到 MIPS”的映射报告里能多写一章“目标代码迁移”比一开始就硬写汇编更可控。4.2 指令集设计与生成策略指令集要覆盖表达式计算、变量读写、跳转、函数调用四类需求。我最终保留的指令集如下每条指令都配了操作数含义报告里建议把这张表放在“中间表示设计”一节指令操作数语义PUSHvalue把常量压栈LOADname把变量的当前值压栈STOREname弹出栈顶写入变量ADD / SUB / MUL / DIV无弹出两个操作数结果压栈LT / GT / EQ无比较两个操作数结果压入布尔值AND / OR / NOT无逻辑运算JMPlabel无条件跳转JZlabel弹出栈顶为 0 或 FALSE 时跳转CALLlabel跳转到函数入口保存返回地址RET无返回并恢复调用方地址HALT无结束程序生成策略按语句类型区分。赋值语句是“先算右值再 STORE 到目标变量”IF 语句的翻译是典型的前向跳转问题先生成条件表达式然后 JZ else_label再生成 THEN 分支最后 JMP end_labelELSE 分支放在 else_label 处。WHILE 则是“循环开始处放条件条件假则 JZ 跳出循环体末尾 JMP 回到开始”这要求代码生成器维护一个 label 计数器边生成边编号。最容易写错的是 label 的“先声明后定义”跳转指令可能出现在被跳转地址之前所以生成器应该返回“需要回填的 label 名”而不是立刻计算行号。4.3 函数调用、参数和返回值处理函数是设计报告里最值得展开的部分。参数我采用“值传递 栈上传参”的简化方案调用方先把实参压栈然后 CALL 跳入函数函数体内部的参数名绑定到栈上对应槽位。实际实现时为了不引入完整的活动记录我用了一个更朴素但足够教学的做法函数表单独存一份每个函数有自己的局部作用域CALL 指令携带函数名解释器负责切换作用域并把实参列表灌入参数名。这样对生成器来说函数调用就是“生成各实参表达式的指令然后输出 CALL func 指令”。def gen_expr(self, node, out): if node.kind NUMBER: out.append((PUSH, node.value)) elif node.kind VAR: out.append((LOAD, node.name)) elif node.kind BINOP: self.gen_expr(node.left, out) self.gen_expr(node.right, out) out.append((node.op,)) # ADD/SUB/MUL/DIV/LT/... elif node.kind CALL: for arg in node.args: self.gen_expr(arg, out) out.append((CALL, node.func_name))RET 在栈机里要实现两层意义一是把返回值压栈二是让解释器回到调用前的指令序列。为了设计报告可读我实现里规定“每个函数的最后一条语句自动生成返回值 0”需要显式返回值的函数就用一个特殊语句 EXIT(expr) 表示。解释器维护一个 CALL_STACK执行 CALL 时把当前指令指针压栈RET 时弹出它并接上调用方下一条指令。这个方案不是完整的活动记录但已经能演示“实参计算顺序、局部变量隔离、多层嵌套调用”三个关键概念。报告里能画一张运行时栈状态图胜过写十句话。4.4 类型检查必须放在代码生成之前类型检查放在语义分析阶段而不是生成阶段。规则只有三条整型运算的操作数必须是 INTEGER比较运算结果是 BOOLEANAND、OR、NOT 的操作数必须是 BOOLEAN。任何不匹配直接报错并终止编译。不要试图在生成阶段悄悄做类型转换那会让错误信息失去源码行号也会让后面的测试结果无法解释。检查时机在 AST 构建完成后、代码生成前报告里可以用一张“错误类型→错误信息→定位方式”的表来归纳。5. 排错实战小型Pascal子集编译器最容易翻车的5个坑5.1 ELSE 前被错误消费掉的分号现象源码里写 IF a THEN b ELSE c语法分析直接报“缺少语句”或者 ELSE 被当成单独语句。 原因语句列表用分号分隔解析器在解析完 THEN 分支的 b 后看到下一个 Token 是分号而解析 IF 时已经走到“判断是否为 ELSE”的逻辑此时分号被通用语句解析吞掉后面真正的 ELSE 没了归属。 解决在语句分隔循环里显式加上“当前 Token 是 ELSE 时停止消费分号”。正确顺序是解析完一条语句后先 peek 判断下一个 Token如果是分号则消费并继续如果是 ELSE 或 END 则直接结束当前列表把控制权交还给外层。5.2 作用域弹出后变量遮蔽失效现象两个函数各自声明同名局部变量 m第一个函数结束后第二个函数里读到 m 却得到第一个函数遗留的值或类型信息。 原因作用域栈 pop 时只删了当前层的字典对象某些 AST 节点仍然持有旧的符号引用更隐蔽的是 lookup 缓存了变量地址函数切换后地址没有跟着切换。 解决不要在语义分析阶段缓存“变量在栈上的槽位号”所有变量访问都通过 lookup 实时解析。作用域栈 pop 时做一个不变量检查当前函数的局部变量名集合必须为空或者显式清空。简单做法是给每个函数调用都 push 全新作用域调用结束 pop 并断言没有外部引用指向该层。5.3 逻辑运算缺少短路求值导致运行时异常现象WHILE (i 0) AND (a[i] 0) 在 i 等于 0 时仍然读取了 a[0]甚至越界。 原因AND 指令实现成“先算两个操作数再与”没有短路语义。换成普通表达式没问题但一旦条件里第二个表达式可能无效就翻车。 解决在代码生成阶段把 AND 翻译成“先算左操作数JZ 直接跳转到结果为 FALSE 的 label再算右操作数跳到结果 label”。这实际上是布局控制流而不是一条指令。报告里这是一个非常好的“中间表示语义”讨论点能体现生成器对指令语义的精准控制。我最终让 AND/OR 在指令级保留但生成时展开成 JZ 块测试里专门放一条 i0 的用例验证。5.4 栈不平衡成功的编译与错误的运行结果现象所有语法、语义检查都通过但程序输出在某个分支里全是错的而且错误值像是上一步的残留。 原因条件分支里某条路径多生成了一次 PUSH 少了一次 POP栈机运行时没有类型检查等下次 LOAD 时读到的栈顶根本不是目标变量。 解决两种手段配合。第一代码生成后加一个静态栈深度检查按指令逐行模拟栈深度变化遇到语句结束或函数边界要求深度必须归零。第二解释器里开启 DEBUG 断言执行 HALT 前检查栈深度为 0。这两步可以把“找不到错”变成“定位到具体指令”修复效率高很多。5.5 错误信息没有行列号测试全靠人肉盯输出现象程序输入中有一个错误编译卡在某处打印 Python 异常堆栈完全看不出源码里的位置。 原因Token 和 AST 节点没有从一开始就带 line/col异常到不了用户层面。 解决在 Lexer 里给每个 Token 记录行列AST 节点构造时从起始 Token 复制位置语义检查的所有报错都拼行号。测试用例里专门检查“FIRST_ERROR_LINE”字段保证任何错误都携带位置。这个坑虽然不是编译器逻辑本身却是设计报告评审老师最常看的“工程素养”指标。6. 验证方法用差分测试把设计报告写扎实6.1 自动生成测试用例做回归手写的测试用例永远覆盖不全我建议写一个随机的程序生成器专门生成不涉及副作用的表达式和赋值语句。生成器按固定随机种子运行控制表达式深度在 3 到 6 之间变量从预先声明的集合里取类型根据操作符要求挑选。对每个生成的程序先用自己写的参考解释器用 AST 直接求值不经编译器算出结果再编译并运行最后逐行比对输出。只要两者有一个差异就缩小生成规则定位到具体语句。python gen_test.py --seed 2024 --cases 1000 --depth 6 python compile_run.py --input test_cases/001.pas --output /tmp/out.txt diff /tmp/out.txt test_cases/001.expected这个流程能在半小时内找出大部分隐蔽问题而且生成用例本身就是报告里最有说服力的“测试章节”素材。差分测试的关键是控制边界INTEGER 的上下界、嵌套 IF 层数三层、函数调用嵌套两层。生成器保留一套“可复现种子”所有结果都允许别人重新跑一遍。6.2 把测试结果变成报告的“证据链”设计报告不能只写“测试通过”。我习惯输出四类数据用例总数、通过率、按错误类型分组的功能点报告、以及一项“错误定位准确率”。错误定位准确率统计的是语义错误信息里的行列号是否与用例中预设的报错点一致。有了这些数字评审问的不是“实现得好不好”而是“怎么证明它好”这时候可以直接把测试表指给对方看。整个项目到这里才有闭环子集边界清楚、实现可复现、行为有证据。写这份报告的过程里我最大的教训是宁可砍功能也不要砍语义检查。早期版本只做了词法和代码生成看起来跑得快实际任何一行输入都能把编译器打穿。后来老老实实把类型检查和栈平衡断言补上调试时间反而降了一半。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑