从零手写小型Pascal子集编译器:词法分析、语法分析、语义分析与代码生成全链路实战
发布时间:2026/10/2 19:32:24来源:尧图网络
简介这份资源是面向计算机专业学生与编译原理学习者的Pascal子集编译器课程设计报告围绕词法分析、语法分析、语义分析、中间代码生成与目标代码生成五个阶段展开帮助读者理解手工实现编译程序的完整流程与关键数据结构。资源包内共1个doc文档约952KB内容涵盖需求分析、总体结构、接口描述、文法规则、符号表设计、三地址码与四元式表示、类型检查及寄存器分配策略等并附有课程设计分工与成绩评定说明。文档以北京邮电大学课程设计为背景详细记录了词法分析器接口、关键字与标识符判断函数、符号表插入等实现细节适合作为课程设计参考或编译原理实践补充材料。目前已有240人学习下载可为需要完成类似编译器设计任务、梳理各阶段设计资料与实现成果的读者提供较完整的思路与文档范例。1. 从零手写小型 Pascal 子集编译器为什么它比刷十道编译原理题都管用很多人学编译原理教材翻到语法分析就卡住了LL(1) 分析表能算但真给一段 Pascal 代码让它跑起来输出结果完全不知道从哪下手。小型 Pascal 子集编译器就是解决这个问题的——它把词法分析、语法分析、语义分析、代码生成串成一条完整链路代码量控制在两三千行以内一个人一周能写完。Pascal 子集的好处是语法干净program声明、var变量定义、begin...end块、if/while控制流、四则运算和write输出没有指针、没有类、没有泛型刚好够你把编译器的骨架搭起来。适合两类人一是刚学完编译原理想找个能跑通的项目练手的学生二是工作中需要写 DSL 解析器或配置语言处理工具的工程师。下面按“理论先立住、再动手能复现”的节奏把设计报告里该有的东西全部拆开讲。2. 词法分析与语法分析把 Pascal 源码切成 Token 再拼成语法树2.1 词法分析器要处理的 Token 类型与优先级Pascal 子集的词法单元不算多但有几个容易翻车的地方。先看完整的 Token 分类表Token 类型示例说明关键字programvarbeginendifthenelsewhiledowrite大小写不敏感标识符xcountmyVar字母开头后跟字母或数字整数常量420100只支持非负整数运算符-*/:/为整除比较符表示不等于分隔符;:,.().同时是程序结束符注释{ ... }花括号注释不嵌套词法分析器的核心是一个next_token()函数每次调用返回下一个 Token。实现上用一个全局位置指针扫描字符数组跳过空白和注释然后根据首字符分派。这里有个血泪经验Pascal 的:和:必须区分开扫描到:时要多看一个字符如果是就返回赋值号否则回退返回冒号。同理和、也要做前瞻。# 词法分析器核心next_token 的前瞻逻辑 def next_token(self): self.skip_whitespace_and_comments() if self.pos len(self.src): return Token(TokenType.EOF, None) ch self.src[self.pos] if ch.isalpha(): return self.read_identifier_or_keyword() if ch.isdigit(): return self.read_number() # 双字符运算符的前瞻处理 if ch :: if self.peek() : self.pos 2 return Token(TokenType.ASSIGN, :) self.pos 1 return Token(TokenType.COLON, :) if ch : if self.peek() : self.pos 2 return Token(TokenType.NEQ, ) if self.peek() : self.pos 2 return Token(TokenType.LEQ, ) self.pos 1 return Token(TokenType.LT, ) # 其余单字符运算符直接映射 ...skip_whitespace_and_comments负责跳过空格、换行和{...}注释。注意 Pascal 注释不嵌套遇到第一个}就结束如果源码里写了嵌套注释这里会直接吞掉后面的代码——这是新手最容易踩的坑之一。read_identifier_or_keyword读完标识符后查关键字表大小写不敏感意味着要先转小写再查。read_number只处理连续数字不支持负号负号在语法分析阶段作为一元运算符处理。2.2 递归下降语法分析从 Token 流构建 AST语法分析用递归下降是最直观的选择每个非终结符对应一个函数。Pascal 子集的文法可以写成 EBNFprogram → program ID ; block . block → var decl_list begin stmt_list end decl_list → (ID : type ;)* type → integer stmt_list → (stmt ;)* stmt → assign_stmt | if_stmt | while_stmt | write_stmt | block assign_stmt→ ID : expr if_stmt → if expr then stmt (else stmt)? while_stmt → while expr do stmt write_stmt → write ( expr ) expr → term (( | -) term)* term → factor ((* | /) factor)* factor → ID | NUMBER | ( expr )对应的 AST 节点类型有ProgramNode、BlockNode、VarDeclNode、AssignNode、IfNode、WhileNode、WriteNode、BinOpNode、NumNode、VarNode。每个 parse 函数返回对应的 AST 节点。# 递归下降解析 if 语句处理可选的 else 分支 def parse_if(self): self.expect(TokenType.IF) cond self.parse_expr() self.expect(TokenType.THEN) then_branch self.parse_stmt() else_branch None if self.current.type TokenType.ELSE: self.advance() else_branch self.parse_stmt() return IfNode(cond, then_branch, else_branch) # 解析表达式处理左结合的二目运算符 def parse_expr(self): node self.parse_term() while self.current.type in (TokenType.PLUS, TokenType.MINUS): op self.current.type self.advance() right self.parse_term() node BinOpNode(op, node, right) return nodeparse_if里else的悬挂问题dangling else通过“就近匹配”解决else总是绑定到最近的未匹配if。递归下降天然支持这个规则因为parse_stmt遇到if就递归进去else在那一层就被消费掉了。parse_expr和parse_term的分层是为了处理运算符优先级/-优先级低于*//所以expr调termterm调factor。左结合通过 while 循环实现每次把左边的节点作为新的左子树。注意parse_stmt里遇到begin要递归调用parse_block这意味着 Pascal 支持嵌套块。嵌套块里的变量作用域需要语义分析阶段处理语法分析阶段只管建树。3. 语义分析与符号表类型检查、作用域管理和错误恢复3.1 符号表的数据结构与作用域链符号表是语义分析的核心数据结构。Pascal 子集的作用域规则很简单program层是全局作用域每个begin...end块可以声明自己的变量内层块可以访问外层变量但外层不能访问内层。实现上用栈式符号表进入块时压入一个新作用域退出时弹出。class SymbolTable: def __init__(self): # 栈式作用域每个元素是一个 dictkey 是变量名 self.scopes [{}] def enter_scope(self): self.scopes.append({}) def exit_scope(self): self.scopes.pop() def declare(self, name, var_type): # 检查当前作用域是否已声明同名变量 if name in self.scopes[-1]: raise SemanticError(f变量 {name} 重复声明) self.scopes[-1][name] var_type def lookup(self, name): # 从内到外逐层查找 for scope in reversed(self.scopes): if name in scope: return scope[name] raise SemanticError(f变量 {name} 未声明)declare只在当前作用域检查重复lookup从最内层往外找。这个设计有个边界情况如果内层块声明了和外层同名的变量内层会遮蔽外层lookup返回内层的类型。Pascal 标准里这是允许的但很多教学子集选择禁止看你的设计报告怎么定。我一般会允许遮蔽但加一条警告日志方便调试。3.2 类型检查与语义错误恢复策略Pascal 子集的类型系统只有integer一种所以类型检查主要做两件事变量使用前必须声明赋值号两边类型一致这里都是 integer所以实际上只检查左边是变量。但语义分析还要处理更隐蔽的问题write的参数必须是已声明的变量或常量表达式if和while的条件表达式必须能求值。# 语义分析遍历 AST检查变量声明和使用 def check_expr(self, node): if isinstance(node, NumNode): return integer if isinstance(node, VarNode): # lookup 会抛出未声明异常 return self.symtab.lookup(node.name) if isinstance(node, BinOpNode): left_type self.check_expr(node.left) right_type self.check_expr(node.right) if left_type ! integer or right_type ! integer: raise SemanticError(运算符操作数必须为整数) return integer raise SemanticError(f未知表达式节点: {type(node)}) def check_stmt(self, node): if isinstance(node, AssignNode): var_type self.symtab.lookup(node.name) expr_type self.check_expr(node.expr) if var_type ! expr_type: raise SemanticError(f赋值类型不匹配: {var_type} vs {expr_type}) elif isinstance(node, IfNode): self.check_expr(node.cond) self.check_stmt(node.then_branch) if node.else_branch: self.check_stmt(node.else_branch) elif isinstance(node, WhileNode): self.check_expr(node.cond) self.check_stmt(node.body) elif isinstance(node, WriteNode): self.check_expr(node.expr) elif isinstance(node, BlockNode): self.symtab.enter_scope() for decl in node.decls: self.symtab.declare(decl.name, decl.type) for stmt in node.stmts: self.check_stmt(stmt) self.symtab.exit_scope()错误恢复策略上我一般用“恐慌模式”遇到语义错误时不立即退出而是记录错误信息跳过当前语句继续检查下一条。这样一次编译能报出多个错误而不是改一个报一个。具体做法是在check_stmt外面包一层 try-catch捕获SemanticError后把错误加入列表然后advance到下一个语句边界分号或end。提示符号表的作用域链和 AST 的块结构必须严格对应。如果enter_scope和exit_scope不配对会出现变量“泄漏”到外层作用域的问题这种 bug 在递归下降里特别隐蔽建议在exit_scope里加断言检查栈深度。4. 代码生成与解释执行从 AST 到可运行结果的最后一步4.1 三种执行方案对比解释器、栈式虚拟机、目标代码生成小型 Pascal 子集编译器做到语义分析之后有三种落地方式方案实现难度可移植性调试便利性适合场景直接解释 AST低高好教学演示、快速验证编译到栈式虚拟机字节码中高中想体验完整编译流程生成 C 代码或汇编高低差研究代码生成与优化我一般推荐先做 AST 解释器因为代码量最少一晚上能跑通。等解释器稳定了再改成字节码生成这样能对比两种方案的差异。设计报告里如果只写一种选 AST 解释器最稳妥。4.2 AST 解释器的实现环境映射与表达式求值解释器的核心是一个evaluate函数递归遍历 AST用字典模拟运行时环境。变量存储用dictkey 是变量名value 是整数值。class Interpreter: def __init__(self): # 运行时环境变量名 - 整数值 self.env {} def eval_expr(self, node): if isinstance(node, NumNode): return node.value if isinstance(node, VarNode): if node.name not in self.env: raise RuntimeError(f运行时未定义变量: {node.name}) return self.env[node.name] if isinstance(node, BinOpNode): left self.eval_expr(node.left) right self.eval_expr(node.right) if node.op TokenType.PLUS: return left right if node.op TokenType.MINUS: return left - right if node.op TokenType.MUL: return left * right if node.op TokenType.DIV: if right 0: raise RuntimeError(除零错误) return left // right # Pascal 的 / 是整除 raise RuntimeError(f未知表达式: {type(node)}) def exec_stmt(self, node): if isinstance(node, AssignNode): self.env[node.name] self.eval_expr(node.expr) elif isinstance(node, IfNode): if self.eval_expr(node.cond) ! 0: self.exec_stmt(node.then_branch) elif node.else_branch: self.exec_stmt(node.else_branch) elif isinstance(node, WhileNode): while self.eval_expr(node.cond) ! 0: self.exec_stmt(node.body) elif isinstance(node, WriteNode): print(self.eval_expr(node.expr)) elif isinstance(node, BlockNode): for stmt in node.stmts: self.exec_stmt(stmt)eval_expr里除零检查是必须的Pascal 标准里除零是运行时错误。//整除和 Python 的//行为一致但要注意负数情况Pascal 的整除是向零截断Python 的//是向下取整-7 // 2在 Python 里是-4Pascal 里应该是-3。如果子集支持负数这里要改成int(left / right)。exec_stmt里IfNode的条件判断用! 0因为子集里布尔值用整数表示0 为假非零为真。4.3 从解释器到字节码栈式虚拟机的指令设计如果设计报告要求生成目标代码栈式虚拟机是折中方案。指令集设计如下指令操作数栈效果说明PUSHnpush n压入常量LOADnamepush env[name]加载变量STOREnamepop - env[name]存储变量ADD-pop a, pop b, push ab加法SUB-pop a, pop b, push a-b减法MUL-pop a, pop b, push a*b乘法DIV-pop a, pop b, push a//b整除JMPaddr-无条件跳转JZaddrpop cond条件为假时跳转PRINT-pop val输出生成字节码就是后序遍历 AST遇到BinOpNode先递归生成左右子树再追加对应运算符指令。跳转指令的回填是难点if和while需要先预留跳转地址等目标位置确定后再回填。常见做法是用一个 patch 列表记录待回填的指令索引。# 字节码生成if 语句的跳转回填 def gen_if(self, node): self.gen_expr(node.cond) jz_idx self.emit(JZ, None) # 预留地址 self.gen_stmt(node.then_branch) if node.else_branch: jmp_idx self.emit(JMP, None) self.patch(jz_idx, len(self.code)) # else 分支起始地址 self.gen_stmt(node.else_branch) self.patch(jmp_idx, len(self.code)) # if 结束地址 else: self.patch(jz_idx, len(self.code))emit返回指令在 code 列表中的索引patch把索引处的操作数改成目标地址。这个模式在while里也适用循环开始地址先记下来条件为假时跳到循环结束循环体末尾加JMP跳回开始地址。5. 避坑与排查小型 Pascal 编译器最容易翻车的 5 个地方5.1 词法分析把:拆成:和现象赋值语句x : 1解析时报“意外的 Token”。原因next_token扫描到:后没有前瞻下一个字符直接返回了冒号 Token。解决在:分支里检查peek()是否为是则消费两个字符返回ASSIGN否则回退返回COLON。同理检查后面的和。5.2 递归下降解析表达式时左递归导致栈溢出现象解析123时程序卡死或栈溢出。原因文法写成expr → expr term这种左递归形式递归下降会无限递归。解决改成expr → term (( | -) term)*用 while 循环处理左结合把递归深度从 O(n) 降到 O(1)。5.3 符号表作用域未正确弹出导致变量泄漏现象内层块声明的变量在外层块也能访问语义分析没报错。原因enter_scope和exit_scope不配对或者exit_scope在异常路径上被跳过。解决用try/finally保证exit_scope一定执行或者在exit_scope里加断言检查栈深度是否回到预期值。5.4 解释器除零检查遗漏导致程序崩溃现象运行write(10 / 0)时 Python 抛ZeroDivisionError整个解释器挂掉。原因eval_expr里DIV分支没有检查右操作数是否为零。解决在除法前加if right 0: raise RuntimeError(除零错误)并在顶层try-catch里捕获输出友好错误信息而不是堆栈。5.5 字节码跳转地址回填错误导致死循环现象while循环生成的字节码执行一次就退出或者无限循环。原因JZ的目标地址回填到了错误位置或者JMP跳回了条件判断之前。解决在gen_while里先记录循环开始地址start len(self.code)生成条件表达式后JZ到循环结束循环体生成完后JMP回start最后patch结束地址。建议每生成一条跳转指令就打印当前 code 列表肉眼核对地址。6. 进阶技巧用差分测试验证编译器正确性写完编译器最怕的是“看起来能跑但某些边界情况结果不对”。我一般用差分测试同一段 Pascal 代码分别用我的编译器和 Python 手写等价逻辑跑一遍对比输出。具体做法是准备一组测试用例每个用例包含 Pascal 源码和期望输出用脚本批量跑。# 差分测试框架批量运行 Pascal 子集程序并对比期望输出 import subprocess test_cases [ (program test; var x: integer; begin x : 1 2 * 3; write(x) end., 7), (program test; var x: integer; begin x : 10; while x 0 do begin write(x); x : x - 1 end end., 10\n9\n8\n7\n6\n5\n4\n3\n2\n1), (program test; var x: integer; begin if 1 2 then write(100) else write(200) end., 100), ] for src, expected in test_cases: result subprocess.run( [python, pascal_compiler.py, --run, src], capture_outputTrue, textTrue ) actual result.stdout.strip() status PASS if actual expected else FAIL print(f[{status}] 期望: {expected!r}, 实际: {actual!r})这个框架的关键是测试用例要覆盖运算符优先级12*3应为 7 不是 9、循环边界while x 0从 10 到 1、条件分支if 1 2走 then 分支、嵌套块作用域内层变量遮蔽外层。每加一个新特性就往test_cases里加一条跑一遍全绿再继续。我自己的习惯是编译器每通过一个测试用例就在代码注释里标记# TEST: xxx这样以后改代码时能快速定位哪些逻辑被测试覆盖过。注意差分测试的期望输出必须手工验证过不能直接用编译器自己的输出当期望值否则测试就变成了“自己测自己”毫无意义。最后一个技巧如果设计报告要求生成目标代码可以在字节码解释器里加一个--trace开关每执行一条指令就打印指令名和当前栈状态。调试跳转回填错误时这个 trace 比任何断点都好用。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网