简介:这份资源是面向计算机专业学生与编译原理学习者的Pascal文法编译器课程设计完整实现,围绕词法分析、语法分析、语义检查与代码生成等核心环节展开,适合正在做课程设计或希望动手理解编译器构造流程的中高级学习者。压缩包共140个文件,约8.18MB,以cpp与h源码、txt与md说明文档、cmake与make构建脚本为主,另含o、exe、bin等编译产物及少量xml、pptx、xls辅助材料,覆盖从源码到可执行文件的完整工程结构。资源重点实现了if条件判断、while循环、类型定义、过程与函数调用以及嵌套定义等扩展功能,并涉及符号表管理与目标代码生成,可帮助读者对照Pascal文法梳理词法规则、上下文无关文法与解析算法,理解类型检查与错误检测机制。目前已有288人学习下载,适合作为课程设计参考与编译器入门实践素材。
1. 基于Pascal文法的编译器:从文法规则到可运行前端的完整路径
很多人第一次接触「基于Pascal文法的编译器」这个题目,是在编译原理课设或者自研脚本引擎的场景里。需求很具体:给定一套Pascal子集的文法规则,要做出词法分析、语法分析,最好还能生成中间代码或直接解释执行。Pascal 的文法结构清晰、关键字固定、类型声明规整,是练手编译器前端的理想素材,比直接啃 C 语言文法要友好得多。这篇文章面向的是想真正把「文法」变成「能跑的编译器」的工程师和学生,我会按词法、语法、语义、代码生成这条主线,把每一步的参数、工具选型和踩坑点讲清楚。读完你应该能自己搭出一个支持赋值、表达式、if、while 和过程调用的 Pascal 子集编译器前端,并知道后面该往哪扩。
2. 先把文法立住:Pascal 子集该砍掉什么、保留什么
2.1 为什么不能直接照搬完整 Pascal 文法
完整 Pascal 文法包含嵌套过程、变体记录、集合类型、文件类型、指针、标签跳转等大量特性,如果一开始全盘接收,语法分析器的状态机会爆炸,调试成本极高。我一般会先定义一个「教学子集」,保留最能体现编译器核心机制的部分:程序头、常量与变量声明、赋值语句、算术与逻辑表达式、if-else、while、write/writeln 输出、简单过程声明与调用。砍掉的是:嵌套过程、指针、集合、文件、goto、with 语句。这样文法规模控制在 40 条产生式左右,LL(1) 或 LR(1) 都能处理。
选型上,递归下降适合手写、可读性好、报错信息容易定制;Yacc/Bison 适合产生式多、想快速验证的情况。如果你是要理解「文法如何驱动编译器」,我建议手写递归下降,因为每一步都能看到文法符号是怎么被消费的。
2.2 用 EBNF 把子集文法写清楚
下面是我常用的 Pascal 子集 EBNF,直接可以转成递归下降代码或者喂给 ANTLR:
program := 'program' ID ';' block '.' block := declPart statementPart declPart := (constDecl | varDecl | procDecl)* constDecl := 'const' (ID '=' constValue ';')+ varDecl := 'var' (ID (',' ID)* ':' type ';')+ type := 'integer' | 'real' | 'boolean' procDecl := 'procedure' ID '(' paramList? ')' ';' block ';' paramList := ID (',' ID)* ':' type (';' ID (',' ID)* ':' type)* statementPart := 'begin' statement (';' statement)* 'end' statement := assignStmt | ifStmt | whileStmt | compoundStmt | procCall | ioStmt assignStmt := ID ':=' expression ifStmt := 'if' expression 'then' statement ('else' statement)? whileStmt := 'while' expression 'do' statement compoundStmt := 'begin' statement (';' statement)* 'end' procCall := ID '(' argList? ')' ioStmt := 'write' '(' expression ')' | 'writeln' '(' expression ')' expression := simpleExpr (relOp simpleExpr)? simpleExpr := term (addOp term)* term := factor (mulOp factor)* factor := ID | NUMBER | '(' expression ')' | 'not' factor relOp := '=' | '<>' | '<' | '<=' | '>' | '>=' addOp := '+' | '-' | 'or' mulOp := '*' | '/' | 'div' | 'mod' | 'and'这份文法的关键点在于:expression用经典的优先级分层(expression → simpleExpr → term → factor),保证1+2*3解析成1+(2*3);statement里if的 else 悬挂问题通过「else 就近匹配」在递归下降里自然解决;procDecl的参数只支持值传递,砍掉 var 参数降低符号表复杂度。
提示:EBNF 里的
?和*在转递归下降时要手动展开成 if 和 while,不要指望工具自动帮你处理所有边界。
2.3 文法验证:先跑通 anbn 这类最小集合
在写完整编译器之前,我习惯先用一个最小文法验证解析框架是否正确。热搜里常出现「构造文法 anbn 集合 n 大于一」,这其实就是用a^n b^n检验解析器能否处理「数量匹配」的上下文无关结构。Pascal 里的begin...end配对、括号配对,本质是同一类问题。你可以先写一个只认a和b的递归下降函数,确认栈式匹配逻辑没问题,再套到 Pascal 的 block 上。
# 最小验证:解析 a^n b^n (n>=1) def parse_anbn(s): pos = 0 def match_a(): nonlocal pos count = 0 while pos < len(s) and s[pos] == 'a': pos += 1 count += 1 return count def match_b(n): nonlocal pos for _ in range(n): if pos >= len(s) or s[pos] != 'b': raise SyntaxError(f"位置 {pos} 期望 b") pos += 1 n = match_a() if n < 1: raise SyntaxError("至少需要一个 a") match_b(n) if pos != len(s): raise SyntaxError("多余字符") return True print(parse_anbn("aaabbb")) # True这段代码里match_a返回 a 的个数,match_b按这个个数消费 b,位置指针pos是唯一的全局状态。参数n就是匹配数量,改成 Pascal 的begin/end计数逻辑完全一样。跑通这个,你对「文法驱动的栈式匹配」就有手感了。
3. 词法分析器:把字符流切成有类型的 Token
3.1 Token 类型设计与关键字表
词法分析的目标是把源程序字符串变成 Token 序列,每个 Token 带类型、值、行号列号。Pascal 的关键字是大小写不敏感的,Program、PROGRAM、program等价,这一点和 C 不同,必须在词法层统一转小写再查表。我一般把 Token 类型定义成枚举:PROGRAM、ID、NUMBER、PLUS、MINUS、STAR、SLASH、ASSIGN、SEMI、COLON、LPAREN、RPAREN、DOT、COMMA、EQ、NEQ、LT、LE、GT、GE、BEGIN、END、IF、THEN、ELSE、WHILE、DO、VAR、CONST、PROCEDURE、WRITE、WRITELN、EOF。
关键字表用一个字典映射字符串到 Token 类型,标识符识别出来后先转小写查这个表,命中就是关键字,否则是普通 ID。
3.2 手写词法分析器的核心循环
KEYWORDS = { 'program': 'PROGRAM', 'begin': 'BEGIN', 'end': 'END', 'if': 'IF', 'then': 'THEN', 'else': 'ELSE', 'while': 'WHILE', 'do': 'DO', 'var': 'VAR', 'const': 'CONST', 'procedure': 'PROCEDURE', 'write': 'WRITE', 'writeln': 'WRITELN', 'div': 'DIV', 'mod': 'MOD', 'and': 'AND', 'or': 'OR', 'not': 'NOT', 'integer': 'INTEGER', 'real': 'REAL', 'boolean': 'BOOLEAN' } class Lexer: def __init__(self, text): self.text = text self.pos = 0 self.line = 1 self.col = 1 def peek(self): return self.text[self.pos] if self.pos < len(self.text) else None 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 skip_ws_and_comments(self): while self.peek() is not None: if self.peek().isspace(): self.advance() elif self.peek() == '{': while self.peek() is not None and self.peek() != '}': self.advance() if self.peek() == '}': self.advance() else: break def next_token(self): self.skip_ws_and_comments() if self.peek() is None: return ('EOF', None, self.line, self.col) ch = self.peek() if ch.isalpha() or ch == '_': return self.read_ident() if ch.isdigit(): return self.read_number() return self.read_operator() def read_ident(self): start_line, start_col = self.line, self.col buf = [] while self.peek() is not None and (self.peek().isalnum() or self.peek() == '_'): buf.append(self.advance()) word = ''.join(buf).lower() ttype = KEYWORDS.get(word, 'ID') return (ttype, word, start_line, start_col) def read_number(self): start_line, start_col = self.line, self.col buf = [] while self.peek() is not None and self.peek().isdigit(): buf.append(self.advance()) if self.peek() == '.': buf.append(self.advance()) while self.peek() is not None and self.peek().isdigit(): buf.append(self.advance()) return ('REAL_NUM', float(''.join(buf)), start_line, start_col) return ('INT_NUM', int(''.join(buf)), start_line, start_col) def read_operator(self): start_line, start_col = self.line, self.col ch = self.advance() two = ch + (self.peek() or '') if two == ':=': self.advance(); return ('ASSIGN', ':=', start_line, start_col) if two == '<>': self.advance(); return ('NEQ', '<>', start_line, start_col) if two == '<=': self.advance(); return ('LE', '<=', start_line, start_col) if two == '>=': self.advance(); return ('GE', '>=', start_line, start_col) single = { '+': 'PLUS', '-': 'MINUS', '*': 'STAR', '/': 'SLASH', '=': 'EQ', '<': 'LT', '>': 'GT', ';': 'SEMI', ':': 'COLON', ',': 'COMMA', '(': 'LPAREN', ')': 'RPAREN', '.': 'DOT' } if ch in single: return (single[ch], ch, start_line, start_col) raise SyntaxError(f"第 {start_line} 行第 {start_col} 列出现非法字符: {ch}")skip_ws_and_comments处理空白和{ }注释,Pascal 的注释就是花括号,不支持嵌套。read_ident里word.lower()是关键,保证关键字大小写不敏感。read_number区分整数和实数,遇到小数点就转 float。read_operator先看双字符运算符:=、<>、<=、>=,再看单字符。每个 Token 都带行号和列号,后面报错定位全靠它。
注意:Pascal 里
:=是赋值,=是相等比较,这两个在词法层必须分开,否则语法分析会把赋值当表达式。
3.3 词法阶段的常见翻车点
第一个坑是注释未闭合。{开始后如果到文件尾都没遇到},skip_ws_and_comments会静默吃掉后面所有代码,导致 Token 流突然 EOF。解决方法是加一个标志,循环结束后检查是否真的遇到了},没遇到就抛「注释未闭合」错误。
第二个坑是数字后紧跟字母,比如123abc。Pascal 标准里这是非法的,但有些实现会切成123和abc。我一般选择报错,因为静默切分会让后续语法错误更难定位。
第三个坑是行号列号在跨行字符串或注释里更新不及时。上面代码里advance统一处理了\n,只要所有字符消费都走advance,行列号就不会错。如果你在某个分支里直接self.pos += 1,行列号就会漂移,这是血泪经验。
4. 递归下降语法分析:把 Token 流变成 AST
4.1 AST 节点设计与优先级处理
语法分析的产物是抽象语法树(AST)。我一般定义这些节点:ProgramNode、BlockNode、VarDeclNode、ConstDeclNode、ProcDeclNode、AssignNode、IfNode、WhileNode、CallNode、WriteNode、BinOpNode、UnaryOpNode、NumNode、VarNode。每个节点用 Python 的类或者字典表示,带line、col方便报错。
优先级处理靠文法分层:parse_expression调parse_simple_expr,后者调parse_term,parse_term调parse_factor。这样1+2*3在parse_term里先把2*3合成一个 BinOpNode,再回到parse_simple_expr和1相加。递归下降的优先级是「越深的函数绑定越紧」。
4.2 核心解析函数与错误恢复
class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def cur(self): return self.tokens[self.pos] def eat(self, ttype): tok = self.cur() if tok[0] != ttype: raise SyntaxError( f"第 {tok[2]} 行第 {tok[3]} 列期望 {ttype},实际 {tok[0]} ({tok[1]})" ) self.pos += 1 return tok def parse_program(self): self.eat('PROGRAM') name = self.eat('ID')[1] self.eat('SEMI') block = self.parse_block() self.eat('DOT') return ('Program', name, block) def parse_block(self): decls = [] while self.cur()[0] in ('CONST', 'VAR', 'PROCEDURE'): if self.cur()[0] == 'CONST': decls.append(self.parse_const_decl()) elif self.cur()[0] == 'VAR': decls.append(self.parse_var_decl()) else: decls.append(self.parse_proc_decl()) stmts = self.parse_statement_list() return ('Block', decls, stmts) def parse_statement_list(self): self.eat('BEGIN') stmts = [self.parse_statement()] while self.cur()[0] == 'SEMI': self.eat('SEMI') if self.cur()[0] == 'END': break stmts.append(self.parse_statement()) self.eat('END') return stmts def parse_statement(self): t = self.cur()[0] if t == 'ID': return self.parse_assign_or_call() if t == 'IF': return self.parse_if() if t == 'WHILE': return self.parse_while() if t == 'BEGIN': return ('Compound', self.parse_statement_list()) if t in ('WRITE', 'WRITELN'): return self.parse_io() raise SyntaxError(f"第 {self.cur()[2]} 行无法识别的语句起始: {t}") def parse_expression(self): left = self.parse_simple_expr() if self.cur()[0] in ('EQ', 'NEQ', 'LT', 'LE', 'GT', 'GE'): op = self.cur()[0] self.pos += 1 right = self.parse_simple_expr() return ('BinOp', op, left, right) return left def parse_simple_expr(self): node = self.parse_term() while self.cur()[0] in ('PLUS', 'MINUS', 'OR'): op = self.cur()[0] self.pos += 1 right = self.parse_term() node = ('BinOp', op, node, right) return node def parse_term(self): node = self.parse_factor() while self.cur()[0] in ('STAR', 'SLASH', 'DIV', 'MOD', 'AND'): op = self.cur()[0] self.pos += 1 right = self.parse_factor() node = ('BinOp', op, node, right) return node def parse_factor(self): tok = self.cur() if tok[0] == 'INT_NUM': self.pos += 1 return ('Num', tok[1]) if tok[0] == 'REAL_NUM': self.pos += 1 return ('Num', tok[1]) if tok[0] == 'ID': self.pos += 1 return ('Var', tok[1]) if tok[0] == 'LPAREN': self.pos += 1 node = self.parse_expression() self.eat('RPAREN') return node if tok[0] == 'NOT': self.pos += 1 return ('UnaryOp', 'NOT', self.parse_factor()) raise SyntaxError(f"第 {tok[2]} 行第 {tok[3]} 列期望表达式,实际 {tok[0]}")eat是核心断言函数,类型不匹配立刻抛错并带上行列号。parse_statement_list处理begin...end里的分号分隔,注意最后一个语句后面可以没有分号,所以遇到END要 break。parse_expression到parse_factor四层函数对应文法里的优先级分层,这是递归下降最标准的写法。parse_factor里NOT是右结合的一元运算符,直接递归调自己。
4.3 悬挂 else 与左递归消除
Pascal 的if...then...else存在悬挂 else 问题:if a then if b then s1 else s2里 else 到底配哪个 if。递归下降的天然行为是「else 就近匹配」,也就是配内层 if,这和 Pascal 标准一致,所以不用额外处理。但如果你用 Yacc 写,就要用优先级声明%nonassoc来强制。
左递归方面,表达式文法expression := expression '+' term是左递归,递归下降会无限递归。解决办法就是上面那样改写成expression := term ('+' term)*,用 while 循环代替左递归。这是手写解析器必须过的坎。
5. 语义分析与符号表:类型检查、作用域和过程调用
5.1 符号表结构:栈式作用域
符号表用栈式结构,每进入一个 block 压一层,退出弹一层。每层是一个字典,键是变量名,值是{type, kind, slot}。kind区分变量、常量、过程。slot是后面代码生成时的栈偏移或寄存器编号。
Pascal 的作用域规则是:内层可以遮蔽外层同名变量,但过程内部不能访问调用者的局部变量(除非是嵌套过程,我们砍掉了)。所以查找变量时从栈顶往下找,找到第一个就返回。
class SymbolTable: def __init__(self): self.scopes = [{}] def push(self): self.scopes.append({}) def pop(self): self.scopes.pop() def declare(self, name, info): if name in self.scopes[-1]: raise SyntaxError(f"重复声明: {name}") self.scopes[-1][name] = info def lookup(self, name): for scope in reversed(self.scopes): if name in scope: return scope[name] return Nonedeclare只在当前层查重,允许内层遮蔽外层。lookup从最内层往外找。这个结构简单但够用,支持过程调用时的参数作用域。
5.2 类型检查与隐式转换规则
Pascal 是强类型语言,integer和real不能直接混用,但允许 integer 隐式提升为 real。布尔类型不能参与算术运算。类型检查在遍历 AST 时做,每个表达式节点返回一个类型,父节点检查子节点类型是否合法。
规则表:
| 运算 | 左类型 | 右类型 | 结果类型 | 说明 |
|---|---|---|---|---|
| + - * | integer | integer | integer | 整数运算 |
| + - * | real | real | real | 实数运算 |
| + - * | integer | real | real | 隐式提升 |
| / | integer/real | integer/real | real | 除法总是 real |
| div mod | integer | integer | integer | 仅整数 |
| and or not | boolean | boolean | boolean | 仅布尔 |
| = <> < <= > >= | 同类型 | 同类型 | boolean | 可比较 |
赋值语句检查右边类型能否赋给左边:integer 可以赋给 real,反之不行。if 和 while 的条件必须是 boolean。write/writeln 接受 integer、real、boolean。
5.3 过程调用的参数匹配
过程声明时记录参数个数和类型列表。调用时检查实参个数和类型是否匹配。我们只支持值传递,所以实参可以是任意表达式,类型按上面的规则检查。返回值方面,教学子集里过程不返回值,函数可以后面扩展。
def check_call(self, node, symtab): name = node[1] info = symtab.lookup(name) if info is None or info['kind'] != 'procedure': raise SyntaxError(f"未定义的过程: {name}") params = info['params'] args = node[2] if len(args) != len(params): raise SyntaxError(f"过程 {name} 期望 {len(params)} 个参数,实际 {len(args)} 个") for arg, ptype in zip(args, params): atype = self.check_expr(arg, symtab) if atype != ptype and not (atype == 'integer' and ptype == 'real'): raise SyntaxError(f"参数类型不匹配: 期望 {ptype},实际 {atype}")这段逻辑先查符号表确认过程存在,再比对参数个数,最后逐个检查类型,允许 integer 到 real 的提升。报错信息带上过程名和期望/实际类型,调试时能省很多时间。
6. 避坑与排查:编译器前端最容易翻车的 5 个地方
6.1 现象:解析到一半报「期望 SEMI 实际 END」
原因:parse_statement_list里分号处理逻辑没考虑最后一个语句后无分号的情况。Pascal 允许begin a:=1; b:=2 end,也允许begin a:=1; b:=2; end,但begin a:=1; b:=2; end里最后一个分号后直接 END,如果循环里无条件 eat SEMI 再 parse_statement,就会在 END 上报错。
解决:在 eat SEMI 之后先检查当前 Token 是不是 END,是就 break,不再解析语句。上面parse_statement_list里的if self.cur()[0] == 'END': break就是干这个的。
6.2 现象:变量查找返回 None,但明明声明了
原因:符号表 push/pop 时机不对。常见错误是在 parse_block 开始时 push,但 parse_block 里先解析声明再解析语句,声明阶段 declare 到当前层没问题,可如果 parse_proc_decl 里又 push 了一层却没 pop,退出过程后外层变量就被遮蔽了。
解决:push 和 pop 必须严格配对,用 try/finally 保证异常时也能 pop。我一般把 push/pop 放在 parse_block 的入口和出口,过程声明的参数在 push 之后 declare 到新层。
6.3 现象:1/2结果是 0 而不是 0.5
原因:代码生成或解释执行时用了整数除法。Pascal 里/永远返回 real,div才是整数除法。如果解释器里/直接用了 Python 的//,就会截断。
解决:在 eval 或代码生成时,SLASH对应浮点除法,DIV对应整数除法。类型检查阶段也要把/的结果标成 real,这样赋值给 integer 变量时会报类型错误。
6.4 现象:if 嵌套时 else 配错 if
原因:如果手写解析器里 parse_if 没有正确处理 else 的可选性,或者用了错误的优先级,if a then if b then s1 else s2可能把 else 配给外层 if。
解决:递归下降里 parse_if 解析完 then 分支后,检查当前 Token 是不是 ELSE,是就消费并解析 else 分支。因为内层 if 先返回,else 自然配内层。如果你用 Yacc,用%nonassoc ELSE并调整优先级。
6.5 现象:报错行号总是 1
原因:Token 的行列号在词法阶段没更新,或者语法分析报错时用了错误的 Token 索引。常见的是self.tokens[self.pos]越界后取了默认值,或者 Lexer 里某些分支直接操作self.pos没走 advance。
解决:所有字符消费必须走 advance,所有报错必须用self.cur()返回的 Token 里的行列号。可以在 Parser 里加一个error(msg)方法统一格式化第 X 行第 Y 列: msg,避免各处拼字符串。
7. 从 AST 到可执行:解释执行与代码生成的两条路
走到 AST 之后,你有两条路:一是写一个树遍历解释器,直接 eval AST;二是生成中间代码(三地址码或栈式字节码),再写虚拟机执行。教学场景我推荐先写解释器,因为快、好调试;想深入编译器后端再上代码生成。
解释器核心是一个eval_node(node, env)函数,env 是变量名到值的字典。BinOp 节点递归求左右值再按 op 计算,Assign 节点求右值写 env,If 节点求条件决定走哪个分支,While 节点循环直到条件为假。过程调用稍微麻烦,需要把参数值绑定到新 env,执行过程体,再恢复调用者 env。我一般用 env 链或者栈式 env 实现。
def eval_node(node, env): kind = node[0] if kind == 'Num': return node[1] if kind == 'Var': if node[1] not in env: raise RuntimeError(f"未定义变量: {node[1]}") return env[node[1]] if kind == 'BinOp': op = node[1] l = eval_node(node[2], env) r = eval_node(node[3], env) if op == 'PLUS': return l + r if op == 'MINUS': return l - r if op == 'STAR': return l * r if op == 'SLASH': return l / r if op == 'DIV': return l // r if op == 'MOD': return l % r if op == 'EQ': return l == r if op == 'NEQ': return l != r if op == 'LT': return l < r if op == 'LE': return l <= r if op == 'GT': return l > r if op == 'GE': return l >= r if op == 'AND': return l and r if op == 'OR': return l or r if kind == 'Assign': val = eval_node(node[2], env) env[node[1]] = val return val if kind == 'If': if eval_node(node[1], env): return eval_node(node[2], env) elif node[3] is not None: return eval_node(node[3], env) return None if kind == 'While': while eval_node(node[1], env): eval_node(node[2], env) return None if kind == 'Compound': result = None for stmt in node[1]: result = eval_node(stmt, env) return result if kind == 'Write': val = eval_node(node[1], env) print(val, end='') return None if kind == 'Writeln': val = eval_node(node[1], env) print(val) return None raise RuntimeError(f"未知节点类型: {kind}")这段解释器覆盖了赋值、表达式、if、while、复合语句和输出。SLASH用/返回浮点,DIV用//返回整数,和类型检查规则一致。过程调用需要额外处理 env 的保存和恢复,可以用一个 env 栈,调用时 push 新 env,返回时 pop。
如果你想走代码生成,三地址码是常见选择:每条指令形如t1 = a + b、if t1 goto L1、param x、call f, n。生成时遍历 AST,为每个中间结果分配临时变量,控制流用标签和跳转。栈式字节码则更接近 JVM,用操作数栈代替临时变量,指令如LOAD x、PUSH 1、ADD、STORE y。两条路都值得走一遍,但先跑通解释器能让你快速验证前端正确性。
最后说一个我自己的习惯:每加一个文法特性,先写三个测试用例——一个正常、一个边界、一个错误。正常用例确认功能,边界用例确认不崩,错误用例确认报错信息可读。这个习惯帮我省了无数调试时间。希望帮到你。
本文还有配套的精品资源,点击获取