☰
用Python从零实现一个编译器:词法、语法、字节码到虚拟机
2026/10/15 4:21:51 网站建设 项目流程

很多人一听到“编译器”三个字,第一反应就是编译原理课上的状态机、LL(1) 文法、LALR 分析表,总觉得这东西离日常开发很远,是少数大神才能碰的领域。但实际情况是,如果你有一点点 Python 基础,完全可以用一两千行代码,为自己设计的一门小语言写出一个能跑、能调、能用的编译器。我最近刚把折腾了很久的 CNSH 编译器 v1.0(Python 版)收尾,今天想把它从需求诞生、架构设计、核心实现,到测试踩坑的过程,全部摊开来讲一遍。这篇内容适合两类人:一类是对编译原理感兴趣但没下过手的新手,另一类是写过解释器但想看看“完整编译器工程”该怎么组织的朋友。我会尽量把每个关键决策背后的“为什么”也讲清楚,而不是只丢一段代码给你抄。

1. 为什么我会做 CNSH 编译器

1.1 CNSH 是一门什么语言

CNSH 这个名字是我自己起的,C 代表 C-like 的语法骨架,NSH 是 “Not a SHell” 的缩写,意思就是“看起来像脚本语言,但内核是一个正经的编译器项目”。v1.0 阶段我把它定位成一门极简的脚本语言,支持变量声明和赋值、数值与字符串运算、if / else 分支、while / for 循环、函数定义与调用,以及最基本的内建函数 println。

这个范围听起来不大,但真正实现起来你会发现,每一块都是完整编译器的零件。为了守住 v1.0 的边界,我故意砍掉了几个设计目标:不做类型系统、不做闭包、不做垃圾回收、不做面向对象。类型检查只做最浅层的一致性判断,变量的作用域规则也是“函数一级”的局部作用域加全局作用域。这样做的好处是能把核心流程跑通,不至于一上来就被类型推导和闭包捕获这种硬骨头劝退。

我设计 CNSH 的初衷其实特别务实:我在平时的工作里经常要写一些临时脚本,处理日志、拼接命令、批量生成文本。Python 本身当然够用,但我想尝试一种更“受限”的脚本语言是什么体验——它语法上像 C,写起来又像 Shell 一样直白。这个想法最终变成了一个完整的编译型脚本语言项目。

1.2 为什么选择用 Python 来写编译器

第一是开发效率。编译器最核心的难点在于数据结构设计与递归算法的组织,Python 确实高效。C 语言写 AST 要考虑内存分配释放,C++ 要用智能指针,而 Python 的 dataclass 天然适合描述语法树节点,一个类一棵树,字段清晰、结构直观。第二是标准库丰富。词法分析里的正则表达式用 re 搞定,命令行入口用 argparse 解决,单元测试直接用 unittest 或者 pytest 风格的脚本就能跑起来。第三是调试方便。编译器的任何一步都可能出错,Python 的 traceback 能直接告诉你某次递归调用链长什么样,这是快速定位语法分析 bug 的救命稻草。

我承认 Python 写出来的编译器在性能上不可能跟 C 相比,但 v1.0 的目标是“正确、清晰、可扩展”,而不是“极致性能”。如果未来想让 CNSH 真正跑得快,完全可以用 Python 生成 AST 后序列化,再交给另一个语言实现的后端去编译执行——事实上不少真实的编译器工具链就是这么设计的:前端用可读性好的语言写,后端单独优化。

1.3 为什么不直接解释 AST,而要搞字节码和虚拟机

刚开始我偷懒,直接对 AST 做递归求值。这种方式实现很快,跑几个测试用例也看不出问题。但一旦程序复杂起来,麻烦就来了:每次执行都要带上节点类型做判断,执行路径里全是 if branch,性能差还不好加调试信息。更关键的是,跳过字节码等于绕过了“中间表示”这层思想。

所以我后来推倒重来,采用了“源码 → 词法分析 → token 流 → 语法分析 → AST → 字节码生成 → 栈式虚拟机执行”的经典流水线。为什么用栈式虚拟机?因为它最简单。一条 LOAD_VAR x,push 就完了;一条 ADD,pop 两个数 push 一个结果。不需要操心寄存器分配,不用分析活跃区间,指令格式也能设计得非常规整。字节码还有一个额外好处:可以给每条指令附加源码行号,当用户脚本运行出错时,虚拟机能够告诉你“第 7 行,执行 ADD 时出错”,这个体验比直接跑 AST 好太多。

2. 整体架构与工作流水线

2.1 从源码到输出的完整链路

整个编译器的数据流只有一条单向的线:

源码文本 → 词法分析器 → Token 列表 → 语法分析器 → AST → 语义检查器 → 带调试信息的字节码 → 虚拟机执行

这套结构的核心思想是“逐步降级”。每一层都只做一件事:词法分析器把字符串切成有意义的块,语法分析器把块的序列变成一棵树,语义检查器在树上找明显的错误,代码生成器把树拍平成指令流,最后虚拟机把指令流变成实际的计算结果。我日常调试的时候也是这样,运行一个脚本报错了,先看错误在哪个阶段报出来,基本就能锁定问题范围。比如报“未知 token '#'”,那一定在词法阶段;报“expect ')'”,那是语法阶段;报“未声明的变量”,那是语义阶段。

很多没有系统学过编译原理的人会误以为“代码总是一行一行翻译执行的”,但从这个流水线就能看出来,真实的编译器读入的是全量源码,先整体理解,再逐步生成低级表示。用个生活类比:你读一篇文章并复述中心思想,不是看一个字翻译一个字,而是先看完、建立结构、再用自己的话表达。编译器也是这样。

2.2 项目目录结构设计

我写 v1.0 的时候刻意保持了单层模块结构,目录非常简单:

cnsh/ ├── __init__.py # 包入口 ├── cli.py # 命令行入口 ├── lexer.py # 词法分析器 ├── parser.py # 递归下降语法分析器 ├── ast_nodes.py # AST 节点定义 ├── analyzer.py # 语义检查器 ├── codegen.py # 字节码生成器 ├── vm.py # 虚拟机执行器 ├── errors.py # 统一错误类型 └── runtime.py # 内建函数与运行时环境

在真实项目里,这个结构会进一步拆分,词法分析器内部可能有独立的 token 定义文件,语法分析器可能拆成表达式子模块和语句子模块。但 v1.0 的目的不是写一个企业级代码仓库,而是把架构思路展示清楚,所以单文件职责很清晰就够用了。

依赖方面,我全程只用了 Python 标准库,没有装任何第三方包。这其实是我想强调的一个点:写编译器不需要依赖魔法库,parser 生成器那些东西是可选的,手写递归下降完全可行,而且更有掌控感。

2.3 各模块之间的接口约定

模块之间通过简洁的数据结构衔接,我定义了三个核心接口:

  • 词法分析器输出:List[Token],其中 Token 有 type、value、line、column 四个字段。
  • 语法分析器输出:Program节点,下面是函数定义列表和语句列表。
  • 字节码生成器输出:List[Instruction],每条指令由 opcode、arg、line 组成。

约定越简单,模块之间的耦合越小。比如语义检查器和代码生成器都不直接碰源码文本,它们只认 AST 结构;虚拟机完全不知道 AST 的存在,它只处理指令流和操作数栈。这种分层带来的可测试性特别高,我后面在测试阶段可以针对每个模块单独输入输出做断言,定位问题非常快。

3. 核心实现细节与关键代码拆解

3.1 词法分析与 Token 定义

一句话概括词法分析器的工作:把字符串按规则切块,忽略空白注释,记录行号列号。这里最容易踩的坑是“匹配优先级”。我一开始怎么方便怎么来,正则表达式列表顺序很随意,结果数字被前缀匹配切成了1和2两个 token,表达式12+3直接语法报错。

后来我把规则表按“长度优先”和“特例优先”重新排序。像==、!=、<=、>=这种双字符操作符,必须排在=、<、>之前;在循环匹配时,每次都在当前位置尝试所有正则,取匹配结果最长的那条。这种办法叫“最长匹配”,是绝大多数真实词法分析器采用的标准策略。

TOKEN_RULES = [ ("NUMBER", r"\d+(\.\d+)?"), ("STRING", r'"(?:[^"\\]|\\.)*"'), ("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"), ("OP2", r"==|!=|<=|>=|&&|\|\|"), ("OP1", r"\+|-|\*|/|=|<|>|\(|\)|\{|\}|;|,"), ("COMMENT", r"//.*"), ("WS", r"\s+"), ]

规则表的顺序仍然重要,但这次排序的目的是“已经足够安全的场景下快速命中”,真正兜底靠匹配长度对比。比如==和=同时都能匹配当前字符串时,最长匹配会选==,这就解决掉了比较运算表达式的词法歧义。

Token 结构也非常关键。我用 dataclass 保存语义信息:

@dataclass class Token: type: str value: str line: int column: int

其中 line 和 column 看起来只是辅助字段,但后面写任何报错信息都要用到它们。我见过好几个初学者项目,词法分析阶段不记录位置,等语法分析报错的时候只能给出一句干巴巴的“parse error”,排查成本瞬间翻倍。

3.2 递归下降语法分析器

递归下降是我这次项目里最花心思的部分,也是最有趣的部分。核心规则是“每个语法成分对应一个函数”。表达式优先级通过函数嵌套调用来体现。

CNSH 的表达式的优先级从低到高依次是:比较运算、加减运算、乘除运算、一元运算、基本单元。于是我的解析函数也按这个层级排列:

def parse_expr(self): return self.parse_compare() def parse_compare(self): node = self.parse_addsub() while self.check_op2(("==", "!=", "<", ">", "<=", ">=")): op = self.previous_token().value right = self.parse_addsub() node = BinOp(node, op, right) return node def parse_addsub(self): node = self.parse_muldiv() while self.check_op1(("+", "-")): op = self.previous_token().value right = self.parse_muldiv() node = BinOp(node, op, right) return node def parse_muldiv(self): node = self.parse_unary() while self.check_op1(("*", "/")): op = self.previous_token().value right = self.parse_unary() node = BinOp(node, op, right) return node def parse_unary(self): if self.match_op1("+"): operand = self.parse_unary() return UnaryOp("+", operand) if self.match_op1("-"): operand = self.parse_unary() return UnaryOp("-", operand) return self.parse_primary()

这种写法最大的优点是“代码即文法”,看函数名你就能知道这条语法长什么样:表达式是一串比较,比较由加减构成,加减由乘除构成。我给对应的基础教程补一句经验:不要在递归下降里强行套 EBNF 的花哨规则,老老实实写函数,一个非终结符一个函数,出错的概率最低。

语法分析阶段的另一个难点是“语句类型的分派”。我用了最简单的办法:看当前 token 的类型和值,决定解析哪条语句。看到if就解析 if 语句,看到{就解析语句块,看到def就解析函数定义。这种分支分派虽然朴素,但配合“同步错误恢复”(Panic Mode),写出来的分析器既好读又不容易出逻辑漏洞。

3.3 语义检查与作用域分析

AST 建好之后,距离正确执行还差一步:语义检查。v1.0 的语义检查我重点做了三件事。

第一是变量引用检查。在函数体内引用了既没在全局声明、也没在函数内声明的变量,直接报“未声明变量”。这个检查需要遍历 AST,并在不同作用域边界重置符号表。我用了一个很朴素的方式:每进入一个函数体,就分配一个新的符号字典;遇到变量声明节点就把名字插进去。

第二是函数定义重复检查。同一作用域里两个函数不能同名。这个检查在遍历顶层语句时自动完成。

第三是返回语句检查。CNSH v1.0 的函数允许省略 return,所以这一项只是检查“return 是否只出现在函数体内”,如果出现在顶层就报错。

作用域链的设计我放在了运行时,而不是语义阶段。语法上我只区分“全局变量”和“局部变量”,局部变量只在当前函数内部可见。后续 v2.0 如果要支持嵌套块作用域,只需要把“符号表栈”引入语义分析器就行,整体架构完全不用推翻。

3.4 字节码设计思路与指令集

CNSH 的字节码指令集非常精简,一共 26 条指令。每条指令都是一个对象:

@dataclass class Instruction: opcode: str arg=None line: int = -1

核心指令如下:

指令作用参数含义
LOAD_CONST压入常量常量值
LOAD_VAR压入变量值变量名
STORE_VAR将栈顶写入变量变量名
ADD / SUB / MUL / DIV二元算术运算无
EQ / NE / LT / GT / LE / GE二元比较运算无
JUMP无条件跳转目标指令序号
JUMP_IF_FALSE栈顶为假则跳转目标指令序号
CALL函数调用函数名或函数对象
RET函数返回返回值数量
PRINT打印无
HALT终止执行无

你可能会问:为什么不在字节码阶段就把变量名替换成“地址”或者“作用域槽位索引”?原因是 CNSH v1.0 允许代码在运行时通过某种动态方式修改环境,保留变量名会让 VM 的调试输出更可读。代价是查找变量时需要查字典,性能比数组索引慢一些。但 v1.0 追求的是简单和可靠,这个取舍我认为值得。

3.5 栈式虚拟机的执行循环

虚拟机的核心就是一条指令分发循环。我用的是最经典的“取指-译码-执行”模型:

class VM: def __init__(self, code): self.code = code self.pc = 0 self.stack = [] self.global_env = {} self.env_stack = [self.global_env]

执行指令时,常见的操作就几大类:常量直接入栈;变量从当前作用域链查找;二元运算先弹出右操作数和左操作数,计算再压回结果;跳转指令直接修改程序计数器;调用指令则另开一个新的栈帧,把当前环境压入环境栈。

这里最容易写坏的地方是“RET 之后环境怎么恢复”。我的做法是在函数调用开始时,把调用现场的 pc、函数栈深度快照、环境深度快照都保存在一个调用帧里。RET 执行时,弹出返回值保留在栈上,再把 pc 恢复到调用位置的下一条指令,把环境栈清理到原来的深度。这个机制听起来简单,但是分支多、边界条件多,我前前后后调了许多次才把所有路径都覆盖到。

4. 测试、调试与常见问题实录

4.1 我的测试方法论

编译器项目相比普通 Web 项目有一个天然优势:输入输出状态明确,非常适合写自动化测试。我每天都会跑三套测试:

第一套是单元测试,覆盖层面到单个函数。比如断言“给定字符串if(x==1){ y=2; },词法分析器输出的 token 数量、类型序列是否符合预期”。这类测试的价值在于,每次重构之后能立刻知道哪个模块被破坏。

第二套是集成测试,直接跑编译后的字节码,对比最终输出值。我在测试目录下维护了一堆.cnsh文件,每个文件头几行用注释标注期望输出。跑集成测试时,等于是把整条编译流水线从头到尾检验一遍。

第三套是简单的 fuzz 测试。我从一个很小的随机脚本生成器里不断生成随机 token 序列,喂给语法分析器,看它会不会抛异常。fuzz 测试不追求通过率,只追求“不能崩溃”。有一次还真被它找到了一个递归下降的栈溢出——某个错误输入导致parse_unary无限递归。从那以后我就加上了递归深度上限。

4.2 实测踩过的 5 个坑与排查思路

第一个坑:词法分析的正则匹配偏短。<=被切成了<和=两个 token,表达式直接崩掉。解决方式就是前面说到的“最长匹配”。我加了一个max逻辑,取当前位置所有可匹配结果中字符串值最长的一条,从根上解决了问题。

第二个坑:字符串字面量的转义处理。输入"hello\nworld"时,词法分析器如果只按引号识别字符串,会把后面真正的换行符也吞进来,字符串永远闭合不上。正确的做法是在正则里加入转义分支,匹配\\.这种模式。随后我还特意补了\"和\\两个转义序列的处理,否则字符串里一个引号或反斜杠就够你调试一整晚。

第三个坑:运算符优先级导致的悬挂 if。我最初写 if 语句时没注意词法层面的“而”,导致else总是优先配给最近的未配对if。虽然 v1.0 没有 else if 结构,但if(a) if(b) x=1; else y=2;这种嵌套还是存在歧义。解决方式是在语法分析器里用 state 明确记录当前 block 的匹配状态。

第四个坑:语法分析器中的“空字符串”死循环。当某个循环结构的条件为空时,例如 while 循环后直接出现右括号,我的parse_expr返回了一个 Empty 节点,然后在生成 AST 时又生成了一条什么也不做的语句,导致后续代码无法推进。最后我在语法分析的每个入口处都加了前置检查,不允许解析出空表达式。

第五个坑:作用域与变量生命周期理解错误。v1.0 规定函数参数属于函数局部作用域,但初期我误把它们挂到了全局环境里,结果两次调用同一个函数会叠加污染。解决方式是每次调用函数都新建一个全新的局部环境字典,函数结束后直接丢弃。这个方法很直接,也让我对“调用栈帧”有了更直观的理解——局部环境就是每次调用时临时创建、用完即弃的一张纸。

4.3 性能表现与一次优化记录

v1.0 的性能我没有特别优化过。用最简单的 benchmark:一个从 1 加到 100000 的循环脚本,在普通笔记本电脑上执行大约需要 0.09 秒。如果直接在 Python 里跑同样的逻辑,只要几毫秒。差距主要在字节码分发开销、字典查询开销和 Python 本身的解释开销。

我做了一次成本最低的优化:把 VM 指令分发从 if-elif 链条改成一个dispatch_table字典,key 是 opcode 字符串,value 是处理函数。改动不到二十行,同样的 benchmark 缩短到 0.06 秒左右。这种优化思路很简单:查字典一次 O(1) 比走一串 if 判断要快。真实语言虚拟机(比如早期版本的某些动态语言)也用过类似的查表法,原理不外乎如此。

如果想要更进一步的优化,后面可以做的方向包括:把变量查找从“字典 + 名字”改为“栈帧索引 + 偏移量”,把常量池变成统一的数组,甚至把字节码翻译成 Python 函数对象再执行。但这些都放到 v2.0 再去考虑。

5. 后续扩展方向与实战建议

5.1 从 v1.0 走向 v2.0 的候选清单

CNSH v1.0 已经是一个完整的编译流水线,但离“通用脚本语言”还差得远。我整理了一份 v2.0 候选清单:

  • 支持闭包。这是最大的工程改动,需要引入 Upvalue(捕获外部变量)机制。好在栈式虚拟机的架构足够清晰,给每个函数对象加上 captured_env 字段就能搭出雏形。
  • 支持数组。会引入新的指令 LOAD_INDEX、STORE_INDEX 和对应的数组堆内存管理。
  • 支持 continue 和 break 语句。目前我的循环只支持条件跳转,客户如果写死循环只能靠手动引爆。
  • 更完整的类型体系。比如支持“弱类型比较”和字符串与数字的自动转换。

优先级最高的还是闭包,因为它能让函数变成一等公民,CNSH 的灵活性能上一个台阶。紧接着是数组和表结构。

5.2 给新手的实战开发路径建议

如果你也想写一个自己的编译器,我的建议是从最小可运行版本开始,不要按教科书从头到尾“完美设计”一遍。我推荐的路径是:

  1. 先写一个只支持整数加减法的表达式求值器,不拆词法语法,直接解析字符串里的数字和运算符。
  2. 把求值器改造为“token 流 + 递归下降语法树”,理解 AST 和求值的分离。
  3. 加上变量,支持全局变量声明、赋值、读取,理解“符号表”这个概念。
  4. 加上 if 和 while,理解控制流在 AST 里表现为“条件子树 + 分支子树”,执行过程用递归天然实现。
  5. 加上函数,理解调用栈、参数传递、返回值压栈。
  6. 最后再上手字节码生成和虚拟机,你会发现前面所有步骤在这个阶段都只是不同形式的“指令序列化”。

千万别一上来就啃完整的 C 语言编译器。心里设定一个小目标:让println(1 + 2 * 3);输出 7,这就已经超过 90% “想写编译器却还没开始动手”的人了。

5.3 项目沉淀与经验复盘

这次 CNSH v1.0 的整个开发周期大概持续一个多月,其中有几次大改:从直接解释 AST 改成生成字节码;把错误处理从裸异常改成带 line 信息的自定义异常;把 VM 的分发结构从 if 链改成查表。每一次重构其实都对应一个实际痛点,改完代码的清晰度和可维护性都有肉眼可见的提升。

我强烈建议编译器项目的日志记录一定要“结构化”。我在每次运行脚本时都会打印当时的编译阶段,以及 token 数量、AST 节点数量、指令数量。当你积累了几十条日志之后,就能自然意识到“某个语法特性会导致 token 数量爆炸,需要调整文法结构”这类高层信息。

最后说几句个人的体会

写编译器最大的收获并不是“我能编译一门新语言”这个结果,而是你真的理解了计算机运行代码的层次感。从前写高级语言,函数调用来得那么自然,根本不会想背后发生了什么。现在看到任何一门语言的函数调用、作用域、递归,都会下意识地用“栈帧、环境、返回地址”这套模型去看它。

最后再分享一个小技巧:调试语法分析器的时候,不要对着报错信息发呆,先加一个dump_token_stream函数,把当前 token 流的类型、值和行号全部打印出来。大多数语法问题在词法阶段就已经能够看出端倪。这个习惯救过我无数次,到现在写任何解析类工具我都默认保留这个功能。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询