ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写Pascal子集编译器:从EBNF到栈式虚拟机完整实践

手写Pascal子集编译器:从EBNF到栈式虚拟机完整实践 简介面向编译原理课程设计环节这套基于Pascal文法的编译器工程提供了一套完整可运行的实现方案适合正在学习词法分析、语法分析及代码生成的学生参考。包内共140个文件涵盖C/C源码.h/.cpp、CMake/Makefile构建配置、可直接运行的.exe程序以及大量txt/md格式的学习笔记还包含汇编输出、符号表等中间产物压缩包约8.18MB便于按模块查看工程结构和运行效果。已有288人学习下载。编译器覆盖词法分析器、语法分析器、语义分析器和代码生成器四大部分并扩展了if/while语句、类型定义、过程函数及嵌套定义等Pascal特性文法设计遵循BNF/EBNF规范涉及标识符、常量、变量、表达式与多种控制结构。课程设计中常见单分支/双分支if、while循环、case选择结构等均可在工程中找到对应实现借助YACC、LEX工具思路和CMake构建文件读者既能对照源码理解各模块如何串联也可直接修改文法规则或扩充语句类型完成课程设计并深入掌握编译器实现细节。1. 基于Pascal文法的编译器能解决什么一条链路从EBNF走到虚拟机基于Pascal文法的编译器听起来是编译原理课的大作业实际做下来它是最能帮你建立编译器全局观的练手路线文法裁剪、词法分析、递归下降、符号表、中间表示、虚拟机每一层都能亲手验证。和直接用现成框架相比手写一遍的代价是几天时间收获是你再看任何语言前端都不会发怵。这个方案能落地的最小形态是一个能跑排序、递归、嵌套过程的 Pascal 子集输出到一台几百行实现的栈式虚拟机。适合正在学编译原理、投编译器开发方向、想在嵌入式环境里做 DSL 的从业者也适合被 C 语言编译器源码劝退后想找一条更平滑路线的同学。我下面按自己实际走过的路线讲先剪一个够用的 Pascal 子集用手写递归下降解析过语义检查翻译成一套中间指令最后在虚拟机上跑起来。2. 先定义Pascal文法子集EBNF怎么裁、递归下降怎么写才不绕2.1 按Turbo Pascal风格裁剪文法保留哪些语法单元标题只说“基于Pascal文法”没有说要实现完整的 ISO 7185 标准。现实里大多数人接触的是 Turbo Pascal 风格方言过程、函数、数组、记录、VAR 参数、短路求值这些都和标准 Pascal 有细微差别。真去啃完整标准光一个 PROCEDURE 的类型扩展就能耗掉你两个周末。所以我一般先定一个可执行子集的边界原则是语法上能跑通排序、递归、嵌套作用域语义上能暴露类型检查的存在感规模控制在几千行以内。我保留的语法单元是PROGRAM 头、VAR 全局与局部变量、ARRAY 与 RECORD、PROCEDURE 和 FUNCTION 的嵌套声明、IF/WHILE/FOR 三种控制流、四则运算与比较、DIV/MOD/AND/OR 操作符、赋值和过程调用。省略的是 GOTO、WITH、CASE、PACKED、文件类型、指针和字符串格式化。这些省略项不是不重要而是它们会引入独立的语义规则CASE 需要跳转表语义WITH 会改变作用域解析方式GOTO 需要标签集合对“把编译器跑通”这个核心目标来说全是干扰。下面是一份简化到能直接实现的 EBNFprogram :: PROGRAM IDENT ; block . block :: decls compound_stmt decls :: (VAR var_decl | PROC_DECL | FUNC_DECL)* var_decl :: ident_list : type ; type :: simple_type | ARRAY [ const .. const ] OF type compound :: BEGIN stmt_list END stmt :: IDENT ASSIGN expr | IF expr THEN stmt (ELSE stmt)? | WHILE expr DO stmt | FOR IDENT ASSIGN expr TO expr DO stmt | compound expr :: simple_expr (relop simple_expr)? simple_expr :: term (addop term)* term :: factor (mulop factor)* factor :: sign? (NUMBER | IDENT | ( expr ) | call)这里 PROCEDURE/FUNCTION 的递归点是decls里嵌套block这正好让 Pascal 的文法结构比 C 更规整。裁剪之后文法层仍然保留了最核心的两个难点表达式优先级和嵌套作用域。后面所有解析工作都是从这份 EBNF 出发的。需要注意的是这里的stmt没有把分号写进产生式因为分号在 Pascal 里是语句分隔符不是终止符这个差别会在第 5 章的坑里重点展开。2.2 词法分析保留字、标识符与大小写归一化先把编译器和编辑器的区别想清楚编辑器维护的是文本状态编译器第一步要把文本流变成 token 流。常见做法是手写扫描器而不是上 flex原因很简单token 数量少、错误信息可控、不引入额外构建依赖。Pascal 和 C 语言编译器在词法上最大的差异就是大小写规则Pascal 不区分大小写BEGIN、Begin、begin是同一个保留字标识符MaxValue和maxvalue也是同一个名字。这个规则必须在词法阶段统一不然后面符号表会到处碰壁。class Token: def __init__(self, kind, value, line, col): self.kind, self.value, self.line, self.col kind, value, line, col KEYWORDS {PROGRAM, BEGIN, END, IF, THEN, ELSE, VAR, WHILE, DO, FOR, TO, PROCEDURE, FUNCTION, ARRAY, OF, RECORD, DIV, MOD, AND, OR, NOT} def tokenize(src): tokens [] i, n, line, col 0, len(src), 1, 1 def advance(k): nonlocal i, col i k col k while i n: c src[i] if c in \t: advance(1); continue if c \n: line 1; col 1; i 1; continue if c {: # 花括号注释简化处理不计行号 while i n and src[i] ! }: i 1 i 1; col 1; continue if c.isalpha() or c _: j i while j n and (src[j].isalnum() or src[j] _): j 1 word src[i:j].upper() # 关键字统一转大写 tokens.append(Token(word if word in KEYWORDS else ID, word, line, col)) advance(j - i); continue if c.isdigit(): j i while j n and src[j].isdigit(): j 1 tokens.append(Token(NUMBER, int(src[i:j]), line, col)) advance(j - i); continue two src[i:i2] if two in (:, , , ): tokens.append(Token(two, two, line, col)); advance(2); continue if c in -*/,.;:()[]: tokens.append(Token(c, c, line, col)); advance(1); continue raise ValueError(f无法识别的字符 {c!r} at {line}:{col}) tokens.append(Token(EOF, , line, col)) return tokens这段代码里有两个参数值得细看。第一word.upper()是刻意做的保留字表全是大写标识符也统一转成大写这样Foo和foo在 token 流里就是同一个ID符号表不需要再猜大小写。第二两字符操作符:必须先于单字符:匹配否则:会被拆成:和两个 token赋值语句直接崩。还有NUMBER直接转成int意味着本子集只支持十进制整数real 字面量、十六进制、科学计数法都留在了裁剪范围之外。出错信息里带行列号也是刻意为之后端的语法错误报告复用 token 的位置否则调试递归下降时你会被“第 0 行”的报错气死。2.3 手工递归下降解析表达式优先级与语句嵌套词法分析出的 token 流还只是一维数组语法分析要把它变成树。选择递归下降而不是 yacc/bison核心原因有两个一是生成的代码可调试断点可以落在每个 parse 函数里二是错误信息完全受控不会出现生成器给出的天书式冲突报告。Pascal 表达式优先级从低到高是比较运算、加减和 OR、乘除和 DIV/MOD 及 AND、一元负号。递归下降的惯例就是每个优先级一层函数层层调用。class Parser: def __init__(self, tokens): self.tokens tokens self.pos 0 def peek(self): return self.tokens[self.pos] def next(self): tok self.tokens[self.pos]; self.pos 1; return tok def expect(self, kind): tok self.next() if tok.kind ! kind: raise SyntaxError(f第{tok.line}行第{tok.col}列: 期望{kind}, 得到{tok.kind}) return tok def parse_expr(self): # 比较 left self.parse_simple() if self.peek().kind in (, , , , , ): op self.next().kind return (BINOP, op, left, self.parse_simple()) return left def parse_simple(self): # 加减 and or left self.parse_term() while self.peek().kind in (, -, OR): op self.next().kind left (BINOP, op, left, self.parse_term()) return left def parse_term(self): # 乘除 div mod and left self.parse_factor() while self.peek().kind in (*, /, DIV, MOD, AND): op self.next().kind left (BINOP, op, left, self.parse_factor()) return left def parse_factor(self): tok self.peek() if tok.kind NUMBER: self.next() return (NUM, tok.value) if tok.kind ID: self.next() return (VAR, tok.value) if tok.kind (: self.next() e self.parse_expr() self.expect()) return e raise SyntaxError(f第{tok.line}行第{tok.col}列: 表达式里出现意外的 {tok.kind})AST 我这里直接用元组表示(BINOP, op, left, right)省去一堆类定义。元组的好处是递归函数解构方便调试打印也直观缺点是字段名不明确节点一多容易记错所以建议给项目里每个节点类型写一行注释或者用namedtuple。parse_expr的处理方式是 PASCAL 文法里常见的“当前优先级仅允许一个比较操作符”因为a b c在 Pascal 里不合法不需要像其他语言那样搞成链式比较。语句解析的核心是parse_stmt分派根据当前 token 类型进入赋值、IF、WHILE、FOR 或复合语句。IF 的解析要特别留意 ELSE 的就近匹配Pascal 和 C 一样有不写括号的悬空 else 问题递归下降天然选择“就近匹配”因为解析 THEN 分支时会直接吃掉后面紧跟的 ELSE。这个行为符合大部分人的直觉但会给第 5 章的“分号坑”埋下伏笔。递归下降对左递归文法是无解的所以expr - expr term这种规则必须改写成上面代码里用的 while 循环这也是 EBNF 里用*的原因。3. 语义分析是分水岭符号表、类型检查与程序入口怎么落地3.1 作用域栈式的符号表嵌套过程与变量可见性递归下降只能回答“语法对不对”回答不了“标识符存不存在、类型合不合法”。这一步需要符号表和类型检查。Pascal 的语义重点在嵌套作用域过程里可以再声明过程内层能访问外层变量外层不能访问内层变量这就是词法作用域。符号表常见做法是作用域栈每个Scope对象带一个parent指针查表时从内向外。class Symbol: def __init__(self, name, kind, typNone): self.name name # 统一大写 self.kind kind # VAR | PROC | FUNC | PARAM self.typ typ # INTEGER | REAL | BOOLEAN | array type class Scope: def __init__(self, parentNone, nameglobal): self.parent parent self.name name self.symbols {} def define(self, sym): key sym.name.upper() if key in self.symbols: raise SemanticError(f{sym.name} 当前作用域重复声明) self.symbols[key] sym return sym def lookup(self, name): key name.upper() s self while s: if key in s.symbols: return s.symbols[key] s s.parent return Nonedefine里的查重是刻意保留的Pascal 允许内层遮蔽外层同名变量但不允许同一层里重复声明。比如外层有个i内层再声明i完全合法这属于隐藏而非重复。而lookup沿 parent 链向上查找正好对应词法作用域。这里的name.upper()与词法分析的大小写归一化是配套设计词法层不归一化符号表再努力也会出现一个变量两个槽位的诡异问题。给每个变量分配槽位的动作我一般放在语义分析的最后一步也就是类型检查通过后这样运行期不需要保存名字只要保存整数 slot 下标即可。3.2 赋值兼容与类型检查把错误拦在运行之前Pascal 的类型规则比 C 严格也比 C 清晰INTEGER 可以隐式转成 REALREAL 不能隐式转回 INTEGERBOOLEAN 只和 BOOLEAN 兼容数组类型要求维度完全一致才能赋值。这个严格性反而让类型检查容易落地。没有类型检查的编译器运行期会出现“把 3.14 塞进整数变量”这种隐蔽错误。def check_assign(target_typ, expr, env): et infer_type(expr, env) if et is None: return if target_typ REAL and et INTEGER: return if target_typ et: return raise SemanticError(f类型不兼容: 表达式是 {et}, 不能赋给 {target_typ}) def infer_type(expr, env): if expr[0] NUM: return INTEGER if expr[0] VAR: sym env.lookup(expr[1]) return sym.typ if sym else None if expr[0] BINOP: lt infer_type(expr[2], env) rt infer_type(expr[3], env) if expr[1] in (, -, *): if lt INTEGER and rt INTEGER: return INTEGER return REAL if expr[1] in (DIV, MOD): return INTEGER if expr[1] in (, , , , , ): return BOOLEAN return Noneinfer_type返回的其实就是一个简化版推导结果整数和实数混算时结果提升为 REAL比较运算结果永远是 BOOLEAN。这套规则在语义分析阶段把大多数类型错误拦截住后面中间代码生成就不需要再关心类型提升了。有一点要注意/在 Pascal 里是实数除法两个整数相除结果也是 REAL所以expr[1] /的情况不应该返回 INTEGER。这个细节很多人会在代码生成阶段踩坑第 5 章会专门讲。3.3 程序入口检查避免出现“编译器未包含main类型”很多初学者问“写完的 Pascal 程序从哪开始执行”答案不是第一个声明的变量也不是第一个过程而是 PROGRAM 块里的主语句体。这个模型和 C 语言的 main 函数不同Pascal 主块本身也是一个块可以声明局部变量可以调用前面定义的过程。入口检查放在语义分析末尾最合适因为此时符号表完整能确认 PROGRAM 头声明的程序名和实际主块对应。新手在 IDE 里看到“编译器未包含 main 类型”这类报错本质是程序入口检查被推迟到了链接或加载阶段报错位置离真正的问题很远。自研编译器应该在语义分析末尾做一个显式检查PROGRAM 头解析成功后必须存在一个主 block 的入口记录否则直接报“缺少程序主块”。这个入口记录在中间代码生成阶段会被翻译成虚拟机的初始 IP。常见做法是把入口作为一个特殊符号__program__登记进全局作用域类型标注为PROGRAM_ENTRY代码生成时从它的 block 开始发指令。这样入口检查是本子集编译器里最便宜也最值得做的一道语义校验。4. 从语法树到栈式虚拟机三地址码生成、运行期检查与参数调优4.1 中间表示为什么选栈式三地址指令拿到一棵合法的 AST 之后有两类做法直接在 AST 上解释执行或者先生成中间表示再执行。直接解释 AST 的优点是省事缺点是每个节点类型要写一个求值分支递归调用的开销完全不可控而且你永远看不出程序在“哪条指令”上跑偏了。另一个极端是一步到位生成 x86 机器码这在教学编译器里几乎必翻车寄存器分配、指令选择、调用约定全是坑。常见做法是生成一段栈式指令流它带有三地址码的标签跳转结构但运算指令不指定目标寄存器统一靠栈传递数据。这样的中间表示同时具备可读性和可优化性后端换到真实平台时改动也最小。4.2 指令集与栈帧布局这一节定义虚拟机要支持的最小编码。指令集刻意做成定长或接近定长避免早期版本在指令解码上花太多无用功指令操作数语义PUSH立即数常量压栈LOADslot把局部或全局变量值压栈STOREslot弹出栈顶并写入变量槽ADD / SUB / MUL / IDIV / FDIV / MOD无弹出右操作数再弹左操作数结果压回LT / LE / GT / GE / EQ / NE无比较栈顶两值结果以 0/1 压栈NEG无弹出栈顶取负再压回JMP标签无条件跳转JZ标签弹出栈顶为假则跳转CALL函数编号压入返回地址进入新栈帧RET无弹出返回值并返回调用点HALT无结束执行为什么用 slot 而不是直接用变量名编译期已经给每个变量分配了固定下标运行期查字典找变量太慢而且栈帧本质上就是一块连续内存用下标可以精确计算地址偏移。栈帧的典型布局是返回地址 局部变量区 参数区。全局变量不进栈帧单独放进全局区。递归调用时每层生成一个新帧RET 负责弹出当前帧并恢复调用者的帧基址。连接这些帧的是动态链也就是每个帧里存一份上一帧的基址。4.3 表达式与控制流的代码生成表达式生成是典型的后序遍历先递归生成左操作数再生成右操作数最后发射一条运算指令。栈式虚拟机的求值顺序完全由指令序列决定。下面这段生成逻辑最需要留意的就是“先左后右”的顺序反了最终结果全错。def gen_expr(node, code, env): if node[0] NUM: code.append((PUSH, node[1])) elif node[0] VAR: sym env.lookup(node[1]) code.append((LOAD, sym.slot)) elif node[0] BINOP: gen_expr(node[2], code, env) gen_expr(node[3], code, env) op_map {: ADD, -: SUB, *: MUL, /: FDIV, DIV: IDIV, MOD: MOD, : LT, : LE, : GT, : GE, : EQ, : NE, AND: AND, OR: OR} code.append((op_map[node[1]],))gen_expr对AND/OR的处理是直接映射成AND/OR指令这个映射在简单表达式里没问题但一旦遇到IF (i 0) AND (a[i] 0)这种带数组越界风险的代码就会出大事第 5 章会详细讲。控制流的生成逻辑建议单独抽函数因为里面对标签的处理很容易乱def gen_while(node, code, env, ctx): L_start ctx.new_label() L_end ctx.new_label() ctx.label_at(code, L_start) gen_expr(node[1], code, env) # 生成条件表达式 code.append((JZ, L_end)) # 条件为假则跳出循环 gen_stmt(node[2], code, env) # 生成循环体 code.append((JMP, L_start)) ctx.label_at(code, L_end)这段生成逻辑和 AST 结构一一对应先用ctx.new_label()创建两个符号标签label_at负责把标签位置填进 code 数组。标签在生成时只是一个字符串最终要遍历一遍把标签替换成绝对 IP这是所有跳转指令的共同机制。JZ 的语义是“弹栈若值为假则跳转”所以条件表达式求完值后栈顶就是判断依据不需要额外的比较指令。4.4 虚拟机运行期检查除零、数组越界与栈溢出虚拟机是最后一个兜底层所有编译期没拦住的问题都会在这里暴露。最常见的是除零、数组越界和无限递归。除零在编译期可以做常量折叠拦截一部分但A DIV B这种运行时变量只能由虚拟机检查。数组越界同理LOAD指令带入下标参数后虚拟机在地址计算前先判断范围。def run(self): while self.ip len(self.code): op self.code[self.ip] if op[0] PUSH: self.stack.append(op[1]); self.ip 1 elif op[0] ADD: b self.stack.pop(); a self.stack.pop() self.stack.append(a b); self.ip 1 elif op[0] IDIV: b self.stack.pop(); a self.stack.pop() if b 0: raise RuntimeError(f除零错误 at ip{self.ip}) self.stack.append(a // b); self.ip 1 elif op[0] JZ: v self.stack.pop() self.ip op[1] if not v else self.ip 1 elif op[0] CALL: self.call_stack.append(self.ip 1) self.ip op[1] elif op[0] RET: self.ip self.call_stack.pop() else: self.ip 1注意IDIV在执行a // b前先做了b 0的检查而不是依赖宿主语言抛异常。这是因为 Python 自身的异常信息里没有“Pascal 源码第几行”这个概念虚拟机里包一层才能把错误定位到指令序号再通过指令到源码行的映射表还原位置。栈深度建议在CALL指令里做限制递归无穷层时尽早报“栈溢出”而不是吃掉整个宿主进程的内存。这个上限我习惯设成 1024对教学子集足够又不至于让宿主环境假死。5. 写Pascal编译器最容易翻车的5个坑现象、原因、解决下面五个坑里有的是 Pascal 方言埋的雷有的是中间表示设计留下的债。它们的共同点是报错点离出错点很远最远一次我一度怀疑是不是“编译器的堆空间不足”最后发现是作用域栈泄漏。所以我把排查路径写出来照着看能省半天时间。5.1 标识符大小写不敏感查表却失败现象源码里定义MaxValue函数里写maxvalue语义分析直接报“未声明标识符”。原因词法分析在判断保留字时把所有字母转成了大写但 Token 里存的 value 仍是原始拼写符号表的define和lookup一个做了upper()一个没做两边对不上看起来是同一个名字却查不到。解决统一在词法层做归一化标识符的 value 只保留大写形态。所有查表入口也强制upper()。更彻底的做法是在Scope.define里直接检查“仅大小写不同的标识符”是否已存在发现即报重名。测试集里专门放一个 mixed_case 用例比如PROGRAM Sample; VAR Count: INTEGER; BEGIN count : 1; END.能过这一关词法阶段才算踏实。5.2 else前加分号AST被多包一层现象IF ab THEN x:1; ELSE x:2;解析后ELSE 分支成了独立的语句语义分析没问题运行结果完全错。原因Pascal 的ELSE前不允许分号写惯了 C 语言编译器的人会习惯性加分号。递归下降解析时parse_stmt在分号处结束 THEN 分支回到语句列表后看到 ELSE已经不知道它该挂到哪个 IF 上。解决解析 IF 时不要先收 statement list让 THEN 分支的parse_stmt负责吃掉紧跟的 ELSE换句话说ELSE 的匹配发生在parse_if内部而不是外层语句列表。我一般会在parse_if里加一段注释# ELSE 前遇到分号直接报错并提示去掉分号比允许它再悄悄跳过要好因为宽容解析会让用户写出靠直觉无法判断的程序。5.3 作用域栈忘了弹调试器里出现幽灵变量现象过程 A 里有局部变量 temp过程 B 里也声明了 temp。A 执行完进入 BB 里居然还能查到 A 的 temp运行期拿到一个不存在的槽位。原因符号表在进入函数时压入新作用域函数返回时没有弹出。最常见的是解析过程中抛异常跳过弹栈路径或者代码里提前return但忘记配对exit_scope。解决把 enter/exit 包成一个上下文管理器而不是让解析器自己手动配对with self.enter_scope(proc name): self.parse_proc_body_and_gen_code()不管中间是正常返回还是抛异常with都能保证弹栈执行。我早期的实现里就是在一个解析异常后漏了弹栈导致后续整个模块的变量解析全乱排查了一天才找到是符号表的问题。测试时可以在语义分析结束后断言全局作用域的栈深度回到初始值这个检查成本极低。5.4 布尔运算没做短路数组越界在if里先炸现象IF (i 0) AND (a[i] 0)在i0时先报数组越界而不是走进 if 的 false 分支。原因代码生成对AND直接生成“先求左、再求右、执行逻辑与”的指令序列两个操作数都会被求值。Turbo Pascal 默认对AND/OR做短路求值在这个语义点上必须对齐否则带副作用的条件表达式会跟预期完全不同。解决把AND和OR翻译成跳转序列而不是生成一条AND/OR指令def gen_short_circuit_and(left, right, code, env, ctx): L_false ctx.new_label() L_end ctx.new_label() gen_expr(left, code, env) # 左操作数先求值 code.append((JZ, L_false)) # 左假则结果就是假 gen_expr(right, code, env) # 左真才继续求右 code.append((JZ, L_false)) # 右假则结果假 code.append((PUSH, 1)) # 全真推真值 code.append((JMP, L_end)) ctx.label_at(code, L_false) code.append((PUSH, 0)) ctx.label_at(code, L_end)这样右操作数只在左操作数为真时才求值数组越界和函数副作用都被正确抑制了。我建议在虚拟机里保留原生的AND/OR指令但不在代码生成中使用等以后支持位运算时再启用。5.5 div与/映射到同一个opcode整数除法悄悄变成实数现象7 DIV 2结果应该是 3运行出来却是 3.0或者7 / 2赋给 INTEGER 变量没有报错。原因词法层区分了DIV和/但代码生成时图省事把它们映射到同一个 opcode。Pascal 的DIV是整数除法/是实数除法两者在类型系统里是严格分开的。前端类型检查里没做 REAL 到 INTEGER 的拒绝隐患就泄露到了运行期。解决DIV映射IDIV/映射FDIV在语义分析阶段再补一条规则REAL 类型的表达式不能直接赋给 INTEGER 变量必须显式调用取整函数。这两个修复合起来类型系统才算真正闭合。测试时可以写一段带混合运算的用例比如x : 1 2 / 3正确行为是先算2/3得 REAL再加上整数1得到 REAL最后赋给 REAL 变量。这种用例跑通说明类型提升和运算映射同时都是对的。6. 把编译器做到可交付golden测试、两层优化与下一步6.1 golden测试集怎么组织才不烦人编译器是重资产改词法组件很容易影响后面所有阶段。我一般建一个pass/目录每个.pas文件配一个.stdout和一个.exitcode用一段脚本全量比对。这个脚本就是编译器的“后悔药”改坏了立刻知道哪里坏了不用靠肉眼找回滚点。for f in pass/*.pas; do base${f%.pas} if ! ./pascalc $f /tmp/out 21; then echo FAIL compile: $f continue fi ./pascalc $f /tmp/prog.bin || exit 1 ./pascalm /tmp/prog.bin /tmp/run.out 21 diff -u $base.stdout /tmp/run.out || echo FAIL run: $f done注意exitcode也要比对因为很多语义错误要靠非零退出码暴露。带交互输入或用时间函数的程序不要进 pass 目录golden 测试必须确定性。每加一个语法特性先写一个失败用例再动解析器这是最稳的节奏。6.2 常量折叠与死代码消除先做这两个优化不要一上来就做 SSA 和寄存器分配。对教学编译器来说性价比最高的是两层优化常量折叠和死代码消除。常量折叠在中间代码生成之后做遍历指令序列发现连续的PUSH加上可折叠运算指令就合并成一次PUSHdef fold(code): out [] i 0 while i len(code): if i 2 len(code) and code[i][0] PUSH \ and code[i1][0] PUSH and code[i2][0] ADD: out.append((PUSH, code[i][1] code[i1][1])) i 3 else: out.append(code[i]) i 1 return out这段代码只演示优化 pass 的形态真正的落地要考虑三个连续 PUSH 的链式合并、SUB 和 MUL 的折叠、标签引用位置不能受 fold 影响。死代码消除更简单编译期发现IF TRUE THEN直接跳过 false 分支的代码生成函数里声明了但从未引用的局部变量语义分析阶段就不分配 slot。这两层优化做完递归求斐波那契这类例子的性能提升是非常直观的但不要为了性能提前加复杂度。6.3 下一步的两种选择扩展文法还是接真实后端如果往语言方向走加入 CASE、WITH、字符串类型和类型定义语义层需要重写符号表和类型系统如果往工程方向走更值得做的是把栈式指令流翻译成 C 语言再交给平台 C 编译器这样能直接获得真实机器码和平台调试器支持比直接写汇编快得多。我现在的习惯是每次改动后先跑 golden再加一行用例验证新语法最后才考虑优化。这个习惯让我的 Pascal 子集在扩展过程中没有翻过车。希望帮到你。本文还有配套的精品资源点击获取
返回列表