简介:编译器是连接高级语言与机器执行的桥梁,其核心原理包括词法分析、语法分析、语义分析与代码生成。Python凭借灵活的数据结构和递归能力,成为学习编译原理、实现教学型编译器的理想语言。从正则表达式切分Token到递归下降构造AST,再到用字典模拟作用域、以栈帧实现函数调用,这一系列过程并不神秘。本文以C语言子集为目标,用可复现的最小代码演示如何构建一个能执行递归函数的解释器,并进一步升级为字节码虚拟机,为深入理解编译技术提供工程化路径。Python实现虽然慢,却能让开发者专注于算法逻辑而非内存管理,适合课程设计、原理验证以及快速原型开发。
1. 用Python写C语言编译器:这活儿图什么,谁会真去干
拿Python写C语言编译器,乍一听像用自行车拉货。Python慢,C要贴近机器,两者似乎拧着来。但确实有人这么干,而且能跑通变量、函数、递归——这不是要替代GCC、Clang那种工业级编译器,而是用Python把词法分析、语法分析、作用域这些“黑匣子”一个个拆开,让人亲手摸一遍编译过程。对想弄懂编译原理、做课程设计或练Python项目的人来说,用Python写C编译器比用C写少踩一半内存的坑,翻车也翻得快、看得清。这篇就从选型讲起,把能复现的最小代码和真实踩坑一条条列出来。
2. 为什么“Python版的C编译器”值得写:定位、原理和选型
2.1 编译器骨架:词法分析、语法分析、语义分析和代码生成各管什么
先把骨架立起来。一个完整的C编译器,在概念上可以分成四段,每一段对应一个清晰的数据转换步骤。
词法分析,把源码按字符规则切成Token。比如把int a = 42;切成int、a、=、42、;这五段。Token不是字符串数组,而是带类型的二元组,比如("IDENT", "a")、("NUMBER", "42"),这样语法分析阶段才能判断语义。语法分析,根据C的语法规则把Token流组织成一棵抽象语法树(AST)。比如a = b + c * 2会生成一个根节点为赋值、右子树为加法、加法右子树为乘法的树形结构。语义分析,负责检查类型是否匹配、变量是否声明、函数调用参数个数对不对。教学用编译器通常会简化甚至跳过。代码生成,把语法树翻译成目标代码,目标代码可以是机器码、汇编、C代码,也可以是自定义字节码。
用Python写编译器,这四个阶段的代码量远小于用C写。原因不复杂:Python的列表、字典、字符串处理,恰好对应词法分析和语法树操作需要的容器;递归函数写语法分析,比在C里手动维护栈要省力得多。这也是“用Python写C编译器”这类项目能成为热门Python练习的原因。我会把编译过程直接落成三种数据结构:Token是(kind, value)二元组,AST是嵌套tuple,符号表是字典。后面所有代码都围绕这三种结构展开。
2.2 为什么选Python而不是C、Go、Java来写编译器
选型这件事,不少人一上来就抱着“编译器必须用C写”的执念,结果被指针折腾得半途而废。我见过太多课程设计,到答辩前一周还在调内存释放,程序越改越乱。用Python写编译器的理由,是把精力集中在编译算法的逻辑上,而不是内存管理上。
列个对比表更清楚:
| 实现语言 | 写前端(词法/语法)的体验 | 写后端的体验 | 运行速度 | 适合谁 |
|---|---|---|---|---|
| C | 要手写动态数组、指针、字符串处理 | 可以直接生成机器码 | 快 | 想做真编译器、追求性能 |
| Go | 内置垃圾回收,切片好用 | 可以生成汇编 | 中 | 想兼顾性能和开发速度 |
| Rust | 所有权让树形结构有点绕 | 强类型适合做IR | 快 | 愿意跟生命周期较劲的人 |
| Python | 列表字典随手用,调试方便 | 只能解释执行或生成中间码/文本 | 慢 | 学习原理、做课程设计、快速原型 |
我一般建议第一次写编译器的人选Python。它慢,但慢在Python层面,不在你的思考层面。一个正常的教学项目,几千行Python就能把C的子集跑起来,换成C可能要三倍以上的代码量,还容易在字符串处理上莫名翻车。
这里要补一个常见误用:有人为了省事,直接把C源码转换成某种Python代码再用exec执行,这不能叫编译器,只能叫源码翻译器,而且翻译后的语义跟C差得十万八千里。真正值得做的是自己写前端解析出AST,再对AST做求值或翻译。否则你根本没有在写编译器,只是在写字符串替换脚本。
2.3 用什么形态输出:AST解释器、字节码虚拟机,还是生成C再调用GCC
“编译器”这个词在标题里,换成落地形态,通常有三种做法。
第一种,AST解释器。解析出AST后,直接在树上做求值。这一步最人畜无害,几周就能跑通if、while、函数。代价是慢,递归调用时Python调用栈也跟着增长,太深的递归会触顶。第二种,字节码虚拟机。把AST编译成一套自定义指令,再用虚拟机循环执行。比AST解释器快,也更接近真实编译器“编译到中间表示”的做法,后面第6章会给出具体迁移路径。第三种,生成C代码再交给系统编译器。这条路把后端直接甩给别人,只能当玩具,因为产出的代码其实还是“源码转换”,跟“写出一个编译器”的目标差得很远。
我自己的选择是:第一版一定做AST解释器,跑通后再往字节码方向演进。理由很简单:AST解释器能让你更快看到“解析器到底对不对”,而解析正确是一切后端工作的前提。等AST解释器稳定了,再把求值从“在树上递归”改成“先编译成指令再执行”,这时候你已经知道该验证什么,不会被一堆新概念糊住。
3. 把词法分析和语法分析跑起来:最小可复制的Token与递归下降代码
3.1 词法分析:用正则把C源码切成Token
前提是Python环境能跑就行,用VSCode配好解释器,不用装任何第三方库,标准库的re足够撑起整个前端。词法分析器就是干一件事:把字符串变成Token序列。教学项目不需要覆盖C的全部词法,能处理整数、变量名、运算符、括号、分号,再加注释跳过,就够用了。
下面是能直接跑的最小词法分析器:
import re # Token类型定义,顺序有讲究 token_spec = [ ('COMMENT', r'//.*'), # 行注释,必须在运算符前面 ('SKIP', r'\s+'), # 空白 ('NUMBER', r'\d+(\.\d+)?'), # 整数或小数 ('IDENT', r'[A-Za-z_][A-Za-z0-9_]*'), # 标识符和关键字 ('OP', r'[+\-*/=<>!]+'), # 运算符,注意减号要转义 ('PUNCT', r'[(){};,]+'), # 标点 ] token_re = re.compile('|'.join('(?P<%s>%s)' % (name, pattern) for name, pattern in token_spec)) def tokenize(code): tokens = [] for match in token_re.finditer(code): kind = match.lastgroup value = match.group() if kind in ('SKIP', 'COMMENT'): continue tokens.append((kind, value)) return tokens if __name__ == '__main__': code = "int a = 42; // 这是注释\n a = a + 1;" for tok in tokenize(code): print(tok)逻辑说明:这里用了re.finditer配合命名分组,一次扫描就把所有Token归到对应类别。COMMENT规则必须放在OP前面,否则//会被先匹配成两个除号,注释就漏进去了。SKIP吞掉空格和换行,COMMENT吞掉注释,两者都不进入Token序列。NUMBER的正则允许小数,如果C子集只支持整数,把(\.\d+)?去掉就行。
参数说明里最值得留意的是匹配顺序。正则引擎从左到右扫描,每个位置依次尝试规则列表里靠前的模式。把SKIP放太靠后,空格虽然最终也能被匹配,但效率差一点;把COMMENT放太靠后,//就被OP抢走,注释处理直接失效。这个顺序表建议照着用。
跑上面样例,输出应该是:
('IDENT', 'int') ('IDENT', 'a') ('OP', '=') ('NUMBER', '42') ('PUNCT', ';') ('IDENT', 'a') ('OP', '=') ('IDENT', 'a') ('OP', '+') ('NUMBER', '1') ('PUNCT', ';')注意int在词法阶段只是普通标识符。关键字判断留到语法分析阶段做,词法器就不用维护一套关键字表,减少视觉噪音。
提示:正则规则顺序不是可选项。COMMENT必须在OP之前,这是词法器最容易翻车的细节。
3.2 语法分析:用递归下降解析表达式优先级
递归下降是写C语言语法分析最顺手的方案:把语法规则的每一个非终结符写成Python函数,函数之间互相调用,天然构造一棵树。表达式优先级靠“向下分层”解决:加减一层、乘除一层、因子一层。
下面是最小可用的表达式解析器:
class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): if self.pos < len(self.tokens): return self.tokens[self.pos] return (None, None) def next(self): token = self.tokens[self.pos] self.pos += 1 return token def expect(self, kind, value=None): token = self.next() if token[0] != kind or (value is not None and token[1] != value): raise SyntaxError(f"期望 {value or kind},实际拿到 {token}") return token def parse_expr(self): # 加减法的优先级最低,所以先调用乘除法 left = self.parse_term() while self.peek()[1] in ('+', '-'): op = self.next()[1] right = self.parse_term() left = ('binop', op, left, right) return left def parse_term(self): left = self.parse_factor() while self.peek()[1] in ('*', '/'): op = self.next()[1] right = self.parse_factor() left = ('binop', op, left, right) return left def parse_factor(self): kind, value = self.peek() if kind == 'NUMBER': self.next() return ('num', value) if kind == 'IDENT': self.next() return ('var', value) if value == '(': self.next() node = self.parse_expr() self.expect('PUNCT', ')') return node raise SyntaxError(f"无法解析的因子: {value}")逻辑说明:parse_expr和parse_term分别对应加减、乘除两个优先级,parse_factor处理最小单位的数字、变量和括号。整个解析过程返回的AST是嵌套tuple。例如1+2*3会得到('binop', '+', ('num', '1'), ('binop', '*', ('num', '2'), ('num', '3')))。乘法被正确包在加法下面,说明优先级正确。
这里有一个容易翻车的点:while循环里判断运算符时,如果只判断self.peek()[1]而不检查是不是None,遇到文件结尾就会出现“NoneType不可下标”或者无限循环。上面peek在越界时返回(None, None),规避了这个问题。所有递归下降的while循环都应该按这个习惯写。
3.3 打印AST验证解析结果:先证明前端没写错
写完解析器,最急迫的不是继续写执行器,而是先验证AST结构正确。这一步能把解析错误尽早暴露,省得后面查问题查到深夜。
def dump_ast(node, indent=0): if not isinstance(node, tuple): print(' ' * indent + repr(node)) return print(' ' * indent + node[0]) for child in node[1:]: dump_ast(child, indent + 1) # 组合测试:tokenize -> parse -> dump code = "return 1 + 2 * 3;" tokens = tokenize(code) parser = Parser(tokens) expr = parser.parse_expr() dump_ast(expr)逻辑说明:dump_ast递归打印tuple的每个子节点,缩进表示层级。用1+2*3做测试,如果看到乘法嵌套在加法的右子节点里,说明优先级分层生效。这个验证函数会伴随项目后面所有改动,每加一个新语法,都应该给它补一条dump检查。
为什么要把“打印AST”当成正式工具来做?因为编译器状态多,出错时很难直接猜是词法、语法还是后面的执行问题。AST dump等于给整个项目加了一个黑匣子记录仪,跑一遍样例就能定位到前端还是后端的问题。这也是我的职业习惯:前端没验证之前,绝不往后端赶进度。
3.4 给解析器加上赋值、if、while和块语句
表达式解析只能处理单个表达式,要让编译器能读程序,还得把“语句”这个概念加进来。C语言里,语句可以是一条表达式加一个分号,也可以是if、while、return,还有用花括号包起来的复合语句。
下面把解析器扩到语句级,为了控制篇幅,摘最关键的部分:
def parse_statement(self): kind, value = self.peek() if value == 'if': self.next() self.expect('PUNCT', '(') cond = self.parse_expr() self.expect('PUNCT', ')') then_stmt = self.parse_statement() else_stmt = None if self.peek()[1] == 'else': self.next() else_stmt = self.parse_statement() return ('if', cond, then_stmt, else_stmt) if value == 'while': self.next() self.expect('PUNCT', '(') cond = self.parse_expr() self.expect('PUNCT', ')') body = self.parse_statement() return ('while', cond, body) if value == '{': self.next() stmts = [] while self.peek()[1] != '}': stmts.append(self.parse_statement()) self.expect('PUNCT', '}') return ('block', stmts) # 赋值语句 if kind == 'IDENT': name = self.next()[1] self.expect('OP', '=') expr = self.parse_expr() self.expect('PUNCT', ';') return ('assign', name, expr) raise SyntaxError(f"无法解析的语句: {value}") def parse_program(self): stmts = [] while self.pos < len(self.tokens): stmts.append(self.parse_statement()) return ('program', stmts)逻辑说明:parse_statement按开头的Token类型分发。if和while都以括号里的条件表达式开头,后面递归调用parse_statement解析分支或循环体。花括号块通过循环不断消费语句直到遇到},把多条语句包成一个block节点。赋值语句的解析被简化成“标识符 = 表达式;”,真实C的赋值左边还可以是*p或a[i],教学阶段先不处理。
参数说明:这里用self.peek()[1]直接判断关键字,因为if、while、else在词法阶段是IDENT类型。如果你希望在词法阶段就把它们标记成KEYWORD,可以在tokenize返回时对IDENT的值做一次集合判断。两种做法都能跑,区别是后者让语法分析代码更清晰,但多一张关键字表。我倾向于后者,因为C的关键字接近40个,早点区分对后面做类型检查有帮助。
4. 从AST到能跑的程序:解释器、作用域模型与函数调用怎么做
4.1 解释器的核心循环:用Python字典当C的内存
解析器把源码变成AST,解释器要做的是从AST根节点开始,按语义递归执行。在Python里,最直接的做法就是写一个方法,按节点类型分发,走到哪、执行到哪。
先看执行表达式和赋值的部分:
class Interpreter: def __init__(self): self.globals = {} self.functions = {} def exec(self, node, env=None): if env is None: env = self.globals kind = node[0] if kind == 'num': return int(node[1]) if kind == 'var': if node[1] not in env: raise NameError(f"未定义的变量: {node[1]}") return env[node[1]] if kind == 'binop': left = self.exec(node[2], env) right = self.exec(node[3], env) op = node[1] if op == '+': return left + right if op == '-': return left - right if op == '*': return left * right if op == '/': return int(left / right) # 注意C的整数除法 if kind == 'assign': name = node[1] value = self.exec(node[2], env) env[name] = value return value raise NotImplementedError(f"还不支持的节点: {kind}")逻辑说明:这个执行函数是典型的AST解释器。num返回常量,var去作用域字典里取值,binop先递归算出左右子节点再运算。这里最需要说明的是赋值语句:把右边表达式的求值结果存进env字典,字典在Python里是引用传递,所以改的是调用者传进来的那本字典。这跟C里“函数内修改参数会影响调用方”的直觉有点相反,注意区分。
参数说明:env参数是整个解释器的核心。最外层调用时传None,自动使用self.globals;调用函数时传新建的字典作为局部环境。在这个模型里,C的全局变量和局部变量都只是不同字典里的键。你会在调试时发现一个反直觉的点:Python字典的键顺序被保留了,但C标准不保证变量在内存里按声明顺序排列,所以别依赖字典顺序去模拟结构体,那是另一套活。
4.2 块作用域和控制流:进入新块时切一本新字典
C语言里,一对花括号会打开一个新的作用域;块内声明的变量在块结束后消失。这个行为用Python字典来模拟非常直接。
def exec_block(self, body, env): # C语言的块作用域:进入block时新建环境,离开即丢弃 new_env = dict(env) for stmt in body: result = self.exec(stmt, new_env) if result is not None and isinstance(result, tuple) and result[0] in ('return', 'break'): return result return None def exec_if(self, cond, then_stmt, else_stmt, env): condition = self.exec(cond, env) if condition != 0: self.exec(then_stmt, env) elif else_stmt is not None: self.exec(else_stmt, env) def exec_while(self, cond, body, env): # 注意:C的while允许循环体内修改条件变量 while self.exec(cond, env) != 0: result = self.exec(body, env) if isinstance(result, tuple) and result[0] == 'break': break逻辑说明:exec_block里用dict(env)做浅拷贝。拷贝出来的新字典里,往里面写变量不会影响外层,但读取时仍然能读到外层变量。这就是“块作用域”最朴素的模拟。它有个已知边界:如果块里给外层已存在的变量赋值,Python的拷贝方案会修改副本而不是外层——这在C里会修改外层变量,所以本节只适配了“块内声明新变量”的教学场景。要完全对齐C的语义,需要给每个变量记录“定义于哪个环境”,这是后续扩展点。
exec_if把C的布尔语义简化成“非零即真”,符合C的整数条件表达。exec_while用同一个条件表达式反复求值,每次求值都反映最新变量状态,跟C的执行逻辑一致。
这里需要说明传回控制信号的做法:return和break这些“改变执行流”的节点,不能只返回一个值,否则外层无法区分“正常返回一个整数”和“要跳出循环”。上面用tuple包一层作为控制信号,是一个实用的约定。
注意:用dict(env)模拟块作用域,会让“块内修改外层同名变量”失效。遇到这种需求,先明确自己是在做“教学子集”还是在做“完整C语义”,别让边界问题拖垮进度。
4.3 函数调用:用栈帧模拟C的函数执行
有了作用域和控制流,函数就是水到渠成的事。函数定义在AST里长这样:('funcdef', 'fib', ['n'], body)。注册函数的代码可以挂在program节点上:
def exec_program(self, node, env=None): for stmt in node[1]: if stmt[0] == 'funcdef': name, params, body = stmt[1], stmt[2], stmt[3] self.functions[name] = {'params': params, 'body': body} else: self.exec(stmt, env) def call(self, name, args, env): if name not in self.functions: raise NameError(f"未定义函数: {name}") func = self.functions[name] frame = dict() for i, param in enumerate(func['params']): frame[param] = args[i] frame['__return__'] = None self.exec(func['body'], frame) return frame['__return__']逻辑说明:函数调用时,先按形参列表把实参值逐一放进新的字典frame里,然后在新环境里执行函数体。每个函数调用都会创建一本新字典,所以递归调用时每一层都有独立变量,不会互相覆盖。函数体执行完后,从frame['__return__']取出返回值。这样实现的一个隐含约束是:函数返回值最多一个,符合C的语义。
参数说明:返回值的存放键__return__是预留的内部变量名,理论上用户程序里也可能会声明同名变量。教学项目可以约定“以双下划线开头的标识符是内部保留,用户程序不能用”。真实编译器会用一个独立的返回槽,不会跟用户变量混在一起。
函数调用是AST解释器里性能最差的一环,每次都做整棵子树递归求值。C源程序里调用1000次递归fib,解释器可能要经历数万层Python函数调用,基本就是教学项目能接受的极限。对性能有要求的做法,是把函数体先翻译成指令再放进一个迭代循环里执行,这个思路放到第6章展开。
4.4 支持哪些C子集:控制范围比控制性能重要
动手写大规模C语言支持之前,最好先明确边界。这里列一下“通常第一版会做的范围”和“必须往后放的硬骨头”:
| 功能点 | 第一版是否建议 | 备注 |
|---|---|---|
| int变量声明与赋值 | 建议 | 先不做类型系统,统一按int处理 |
| 四则运算和括号 | 建议 | 优先级是关键 |
| if/else、while | 建议 | 配合break |
| 函数定义与调用 | 建议 | 栈帧模型 |
| 多变量声明、数组 | 建议后置 | 需要先设计内存模型 |
| 指针、结构体、类型检查 | 不建议第一版做 | 模型复杂度陡增 |
| printf等库函数 | 建议后置 | 没有库函数也能验证算法本身 |
把表里的“第一版建议”做完,一个能跑通递归fib的迷你C编译器,本质是解释器,就可以交差或继续演进。“边界感”在我做这类项目时特别重要。一个常见失败路径是:一开始想完整支持C11,结果三个月过去连预处理器都没写完,最后不了了之。反过来,先把int、if、while、函数这四个子集做好,你会发现后面加任何特性都有了一个稳定的测试底座。
5. 避坑与排查:让C编译器Python版翻车的5个真实原因
这章集中记录我见过和踩过的实际问题。每一条都按“现象、原因、解决”展开,照抄能少走弯路。
5.1 现象:解析a = b * c + d时,加法把乘法“吃掉”了
现象:解析表达式时,运行结果明显不对。比如2 + 3 * 4被算成(2 + 3) * 4 = 20而不是14。
原因:把加法和乘法放在同一个递归函数里平级处理,循环里先遇到哪个运算符就拼接哪个,优先级没分层。
解决:严格分三层。parse_expr只消费加减号并调用parse_term;parse_term只消费乘除号并调用parse_factor;parse_factor处理数字、变量、括号。优先级靠“函数调用层级”而不是“特殊判断”来实现。改完之后用dump_ast验证一棵表达式树,乘法一定在加法的子节点位置。
5.2 现象:Python里3/2等于1.5,但C语言里应当等于1
现象:同样的int a = 7 / 2;在GCC编译结果是3,在Python版解释器里得到3.5。
原因:CPython的/是真正的浮点除法,而C对两个整型操作数执行整数除法,结果向零截断。
解决:对整数除法显式做int(left / right),这里用int()而不是//是有讲究的。C语言向零截断,Python的//是向下取整。负数运算里两者不同:-7 // 2在Python里是-4,int(-7 / 2)才是-3。所以不要图省事直接写left // right,除非你已经确认不需要支持负数。
这条坑也提醒我们:就算你对Python基础语法很熟,Python和C之间的“类型转换”也不是简单镜像,除法舍入方向、溢出行为都是要单独写的规则。
5.3 现象:块结束后,块内变量还能在外层被访问到
现象:执行完{ int x = 5; }之后,再在后面的代码里读取x,解释器居然没有报“未定义变量”,而是返回5。
原因:块内语句和块外语句执行时共用同一本env字典,赋值语句直接写进了这本字典,没有产生作用域隔离。
解决:在解释block节点时,新建一本new_env = dict(env),块内所有语句都传new_env。块结束以后,新字典被丢弃,块内新增的键自然消失。这属于最简单的词法作用域模拟。真实C还有“内层变量遮蔽外层同名变量”的话题,在这套模型里同样生效:外层变量x存在,内层也创建了x,块结束后内层的x被丢弃,读回外层x。这是Python字典浅拷贝带来的附带行为。
5.4 现象:递归函数一调用就报RecursionError
现象:在解释器里跑fib(20),Python抛出RecursionError: maximum recursion depth exceeded。
原因:AST解释器每执行一层C函数,都要经过“Python函数调用 + 解释器exec递归”的叠加,递归深度放大数倍。20层C递归看起来不多,但对应Python调用深度可能已经上百。
解决:最直接的临时方案是调高递归上限:
import sys sys.setrecursionlimit(100000)但它的本质是治标不治本,递归深度仍然受限于Python调用栈。真正的出路是把函数体先编译成指令序列,再用迭代循环跑指令,让每一次C函数调用消耗的是堆上的帧对象而不是调用栈深度,这个方法在第6章给出雏形。第二个方案是限制教学项目的递归深度,比如要求用例不超过30层,并把这个限制写进项目说明,不让学生跑100层递归然后来问为什么崩。
5.5 现象:行注释//被当成两个除号,程序行为直接错乱
现象:源码里写了// 注释,程序却报语法错误或计算出错误结果。比如a = 10 // 2本来是想写C的整除注释,结果被当成运算符,整行源码就乱了。
原因:词法分析的正则规则里,OP排在COMMENT之前,正则引擎先匹配到了//运算符,注释永远不会被识别。
解决:把COMMENT规则放在token_spec列表最前面,正则按定义顺序尝试,//.*能抢在OP之前匹配整行注释。调试技巧:单独打印tokenize的输出,检查有没有('OP', '//')混进Token流。凡是文档、注释、调试输出里看到奇怪的运算符,第一反应应该是去看词法规则顺序,而不是去改语法分析。
6. 把解释器改成字节码:用一套虚拟机指令锁定行为
最后一章的进阶做法:把第4章的AST解释器改造成“先编译后执行”的字节码虚拟机。这既是性能改进,也是让项目从“解释器”朝“真正编译器”靠近一步。
字节码的指令集可以做得非常小。对第4章的子集,只需要这些指令:PUSH把常量压入栈,LOAD把变量值压入栈,STORE把栈顶值写入变量,BINARY从栈上弹出两个值做运算再压回结果,后续再加JUMP_IF_FALSE和JUMP处理控制流,CALL和RETURN处理函数调用。
先看编译表达式:
class Compiler: def __init__(self): self.ops = [] def compile_expr(self, node): kind = node[0] if kind == 'num': self.ops.append(('PUSH', int(node[1]))) elif kind == 'var': self.ops.append(('LOAD', node[1])) elif kind == 'binop': self.compile_expr(node[2]) # 左操作数先入栈 self.compile_expr(node[3]) # 右操作数后入栈 self.ops.append(('BINARY', node[1]))逻辑说明:表达式编译是深度优先遍历,先编译左子树、再编译右子树、最后发一条BINARY指令。执行时栈里先有左值、再有右值,BINARY从栈顶弹出两个值,计算后压回结果。这个顺序跟AST递归求值的顺序完全一致,所以只要AST解释器已经验证过语义,字节码编译器的正确性很容易对照验证。
参数说明:BINARY指令里的运算符直接在指令中携带,虚拟机执行时再做一次分派。这样指令数量最少,利于调试。真实的编译器会把+和*映射成不同opcode,省掉运行时的字符串比较,但这已经是优化话题,教学项目先保住正确性。
然后写执行器:
class VirtualMachine: def __init__(self): self.stack = [] self.env = {} def run(self, ops, env=None): self.env = env or {} ip = 0 while ip < len(ops): op = ops[ip] code = op[0] if code == 'PUSH': self.stack.append(op[1]) elif code == 'LOAD': self.stack.append(self.env[op[1]]) elif code == 'STORE': name = op[1] value = self.stack.pop() self.env[name] = value elif code == 'BINARY': right = self.stack.pop() left = self.stack.pop() if op[1] == '+': self.stack.append(left + right) elif op[1] == '-': self.stack.append(left - right) elif op[1] == '*': self.stack.append(left * right) elif op[1] == '/': self.stack.append(int(left / right)) ip += 1 return self.stack[-1] if self.stack else None逻辑说明:虚拟机用一个ip指令指针和一个stack值栈循环执行指令,不再递归。原本递归调用栈的深度压力转移到了堆上的栈帧里,递归的C函数不再受Python递归深度限制。这也解释了第5章那条RecursionError的长期解法是字节码而不是调recursionlimit。
参数说明:这个虚拟机目前是单环境的,只演示了表达式和赋值。函数调用需要再加一个frame_stack,每次CALL时压入新环境、返回时弹出,思路跟第4章的frame字典一样,只是从递归换成了显式栈。改造成这一步,项目就已经从AST解释器真正长出了“编译器后端”的骨架。
验证方法也明确:同一段C子集代码,分别喂给AST解释器和字节码虚拟机,对比两者的输出。我习惯准备二十个小用例,从1+2到fib(10),用断言锁定结果。每次改动解析器或编译器,先跑全部用例,哪条挂了就说明哪块行为被破坏,而不是凭感觉说“应该没问题”。
写到这里,你应该能把手上的项目从“能解析”推到“能执行”,再推到“有后端的样子”。我自己每次写这类东西的习惯是:所有测试代码用纯文本保存,不依赖IDE,换机器也能一条命令跑完,这样前后端闹别扭时能快速回归。希望帮到你。
本文还有配套的精品资源,点击获取