Files
TransPyC/WORKOUT.MD
T

804 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 序章
由于我的精力基本投入在 Coding 中,在文学方面的造诣并不深入,如有语法错误请见谅,如有语义、概念错误请随时指出。
2022 年冬,在 Python 已有一定基础后,我希望向更深层的编程领域探索,于是开始学习 C 语言与汇编。
2023 年春,我拜读了川合秀实的《30 日でできる!OS 自作入門》(中译名《30 天自制操作系统》),自制操作系统的想法自此在我的心中生根。同年夏季,从 Python 的语法范式转向 C-LIKE 体系的过程中,我对 C 语言的诸多特性感到难以适应,由此萌生了设计一门自定义编程语言的念头。
2023 年秋,我在贴吧发布了题为 "pyos" 的帖子,介绍自己写的一款工具,能够把精简的 Python 子集语法做转译。现在回头看,彼时的认知尚浅,甚至略显稚嫩可笑:我当时并不清楚 AST、编译前后端这类基础概念,整个工具只是一套简陋的手动递归下降逻辑,仅能勉强输出类 C 格式的字符串。但正是这次粗糙的尝试,在心底埋下了自研语言与底层系统的种子。
# 入门
2024 年初,在维护这个"字符串拼接工具"很长时间之后。我意识到,我的能力尚且还不能支撑我的理想,我也清楚,我所做的也并不是一个真正意义上的编译器,而只是一个初学者的小实践。同年夏,我阅读了清华大学出版社王爽老师的《汇编语言》第四版以及多本竞赛 C++ 书籍后深感收获颇丰。同时我也创建了自己的一个网络工作室。24年末左右,我遭遇了人生重大的一次滑铁卢,24年中旬我确诊了重度抑郁,暂在家休整一段时间。
好的一方面呢,在整个25年,我都有时间将精力投入到编程学习和实践当中:亲身实践做了很多 C 和 汇编混 编的小项目,随手做各种小实验验证想法。正是这些积累,让我在 26 年能快速制作一个接近完整的系统级编程语言。
# 契机与初步实践
2026 年中旬,终于有时间写点什么东西了。上半年,我完成了一个SAAS系统的落地和投入生产,有了自己一点微薄的收入。突然发现似乎没什么东西可写了,想到了三年前我所做的一个基于30day的小系统,于是便打算再次实践一下验证自己的能力。
一开始,并没有想那么多,仍然局限在Python To C的框架中。或者说,原本的我并没想要去做一个语言来着,准确的来说,应该是只想做一个类似 C 但语法是 Python 的 DSL 吧。我当时想的是,C的语法过于不简洁了,如果能够用一个披着 C 壳的 Python(ABC-LIKE) 去写操作系统该多好啊,于是写了一个似乎算语言但不太像语言的东西——TransPyC(翻译器名),Trans是Translate(中文:翻译)的拉丁语前缀,Py就是Python的缩写,C显而易见了,就是C语言。我对于这个语言原本的想法就是能够将Python含义等价转为C语言。
大致上差不多接近于:
```viper
import t, c
import stdio
# t是类似于typing的注解,c是包含标准C的Python无法实现的操作
@t.CExport
def main() -> int:
stdio.printf("Hello, World!")
return 0
```
# 初试啼声
PyL 是这个语言最早的形态,显然,这个名字表达它是:一个 Python DSL,把 Python 子集翻译成 C。它并不是从零写的编译器——解析靠的是 Python 自带的 `ast` 模块,输出靠的是一个被我大幅改造过的 pycparser,中间的语义分析是我自己写的。说它是"翻译工具",并不冤枉它,但它的骨架已经是一台正经编译器的样子了。
但这一次它和贴吧那个 pyos 有一个不同的地方:它有了目标。我把之前我照着《30 天自制操作系统》写的一个纸娃娃操作系统翻版翻了出来——这是一个原本用 C 写的迷你系统,能在虚拟机里打印字符、响应中断——我决定用 PyL 把它重写一遍。用一门刚有雏形的语言去写内核,这个实验谈不上稳妥,但当时我只想验证一件事:这条路到底走不走得通。
过程是漫长的循环:写一点内核代码,编译失败,修编译器,再编译,遇到下一个错误。语言缺什么特性就补什么,编译器哪里错了就改哪里。一个下午往往只够前进一步甚至可以说是徒劳。
突然有一天,我在执行编译后没有报出编译错误,而是弹出了 QEMU,QEMU 里打出那行来自 Python 语法写成的内核的启动信息时我的激动难以言表。第一次用自己写的语言驱动一个能启动的系统,这种验证感很难用别的东西替代。
回头看,PyL 的"编译器"是这么工作的:先用 Python 的 `ast` 模块把源码解析成 AST——这一步是借来的;然后我在这棵 AST 上做语义分析,把注解、类型、作用域这些信息标注上去;最后用一个被我大幅改造过的 pycparser,把带语义的 AST 重新 dump 成 C 代码。原版 pycparser 的 dump visitor 很弱,输出的代码不完整、也不带类型信息,所以我几乎重写了它的输出逻辑,让它按我的语义标注去生成 C。
它没有自己的词法/语法分析器(借 `ast` 的),符号表也是临时的,但它让我第一次摸清了"AST → 语义分析 → 代码生成"这条链。后来 TransPyC 换掉 pycparser、再把解析器也收归己有时,这条链的大致形状我已经很熟了。它也逼我回答了三个问题,这三个问题的答案后来成为 TransPyC 的地基:
1. AST 里,哪些节点是"名字",哪些是"操作符",哪些是"字面量"?——这是语义分析要认的基本元素。
2. 嵌套的结构(if 里套 if)在 AST 里怎么表示,遍历时怎么保证配对正确?
3. 一个变量从声明到使用,中间要跨过哪些作用域?
我当时的答案都很粗糙:节点靠 `ast.dump` 看结构,配对靠递归遍历,作用域用一个 dict 栈。粗糙,但方向是对的——这些问题本身就是词法、语法、语义三个阶段的雏形。
# 与 LLVM 相遇
纸娃娃跑起来之后,我却开始不满意了。
Py→C 的路径本质上还是"把 Python 文本翻译成 C 文本"。编译器对我而言仍然是个黑箱:作用域怎么处理、内存怎么布局、指令怎么组织,我都看不到,只能指望 gcc 在另一端替我兜底。这离我想要的"我写的每一行代码我都知道它变成了什么"还差得很远。
刚好同期,我在做一个项目,简单来说就是用 Java 去写一个语言的虚拟机。借此我了解到 LLVM IR 的存在。基本上同一时间,我也在系统地读编译原理的文献。读到 LLVM 时让我将前后联系起来,前端把源码翻译成 IR,后端把 IR 变成机器码,寄存器分配、指令选择、优化这些都可以交给 LLVM。更重要的是,IR 是结构化、可验证的——我输出的每一句 IR 都可以逐条检查,错误不再潜伏在"另一端的 gcc"里,而是暴露在我眼皮底下。
于是目标从"C"换成了"LLVM IR"。这个转变把 TransPyC 从翻译器变成了真正的编译器前端。
但转变是痛苦的。C 语言本身有隐式转换、有指针算术、有结构体布局规则,gcc 帮我兜住了大半复杂性。换到 IR 之后,那么一切都得我自己来:类型必须显式,内存布局必须精确,函数调用约定必须匹配 ABI。坏消息是工作量陡增,好消息是——我终于能看见每一个细节了。
# 改造之路:编译器的骨架
真正的工程从这里开始。
首先要有一个基本的抉择,命令式还是函数式?我从 C 转型,那就用命令式吧,函数式之后做扩展吧。内存分配和安全性?这是一个许多语言都要面对的问题。Python 用了 GC,坏处就是速度慢,好处是内存管理简单。碰不到裸指针,也没有野指针。我呢...从 C 转型过来,那就手动管理吧。
## 词法分析
写编译器的第一步,是把一串字符变成一串 token。这一阶段是词法分析,输入是源码文本,输出是 `(类型, 值, 行列号)` 的三元组流。
token 的类型不多,但分类必须严谨:
| token 类型 | 例子 | 说明 |
|-----------|------|------|
| 标识符 | `count`、`Point` | 变量名、函数名、类名 |
| 关键字 | `if`、`while`、`def` | 语言保留字,与标识符区分 |
| 数字字面量 | `42`、`3.14`、`0xFF` | 整数、浮点、十六进制 |
| 字符串字面量 | `"hi"`、`'c'` | 双引号、单引号、三引号 |
| 运算符 | `+`、`==`、`->` | 单字符与多字符运算符 |
| 分隔符 | `(`, `)`, `,`, `:` | 括号、逗号、冒号 |
我当时的做法很朴素:用一个主循环扫描字符,遇到字母就连续吸收成一个标识符,遇到数字就吸收成数值字面量,遇到引号就进入字符串扫描:
```viper
for Pos in range(Length):
Ch = Source[Pos]
if IsLetter(Ch): # 吸收成标识符/关键字
while IsLetterOrDigit(Source[Pos]):
Pos += 1
YieldToken(TOKEN_NAME, Slice(...))
elif IsDigit(Ch): # 吸收成数字
while IsDigit(Source[Pos]):
Pos += 1
YieldToken(TOKEN_INT, Slice(...))
```
词法分析本质上是个有限状态自动机:每个状态回答"当前字符把机器带到哪里"。三引号字符串、f-string 的嵌套、转义字符,都是在这个自动机上追加状态。
这里有个数学上很有意思的点:**正则语言恰好是有限状态自动机能够识别的语言**。也就是说,凡是能用正则表达式描述的东西——标识符、数字、字符串——都能用一个状态机来扫描。而嵌套结构(括号配对、if 嵌套)是正则语言做不到的,必须交给下一层语法分析用递归解决。这个"哪层该做什么"的分界,是编译原理教给我的第一课。
早期我最容易在词法上翻车——字符串里的引号对没闭合,行号就偏了一位,后面的报错全部错位。后来我给 lexer 加了一句话作为纪律:**每一类 token 的扫描循环,都必须显式地停在它应该停的地方,绝不靠"循环末尾自增"兜底**。
行号跟踪也不能马虎。报错信息里的"第 X 行第 Y 列",靠的是词法阶段逐字符维护行号。我踩过一个特别隐蔽的坑:三引号字符串内部扫描时,如果跳过换行的逻辑写错,行号表就会整体偏移一,于是后面所有报错都指向错的位置,排查一个语法错误要浪费一晚上。
## 语法分析
语法分析——把线性 token 流还原成树。我用的是递归下降:为每一条语法规则写一个函数,函数之间互相调用,函数调用的深度就是语法嵌套的深度。比如一个赋值语句的规则可以写成:
```
assignment := target "=" expr
expr := term ("+" term)*
term := factor ("*" factor)*
factor := NUMBER | NAME | "(" expr ")"
```
这条规则同时规定了**优先级**:`expr` 比 `term` 低一层,`term` 比 `factor` 低一层。`a + b * c` 之所以解析成 `a + (b*c)` 而不是 `(a+b)*c`,不是因为什么神奇机制,只是因为 `expr` 调 `term` 时,`term` 会贪婪地先把 `b * c` 吃掉。
递归下降的好处是直白——代码结构和语法结构一一对应;坏处是左递归必须手工消除。比如 `expr := expr "+" term` 这种左递归规则,直接写成函数会无限递归。标准解法是把左递归改写成右递归加循环:
```
expr := term ("+" term)*
```
这里 `("+" term)*` 用循环实现,语义上等价于原来的左递归,但不会无限套娃。
解析器产出 AST。我最开始用一整套相互继承的节点类,后来发现语法太复杂时,光节点类型就有几十种,继承树越深,改起来越痛苦。最终我采用了"胖节点"设计:所有节点共用一个结构体,用一个 `VType` 枚举区分节点种类,字段按需复用。这牺牲了一点类型安全,换来了分配和遍历上的统一:
```viper
class AST: # 胖节点
vtype: ASTVType # 节点种类:Assign/If/While/Call...
value: t.CPtr # 通用数据指针,按 vtype 解释
left: t.CPtr[AST] # 通用子节点
right: t.CPtr[AST]
lineno: t.CInt
```
AST 本质上是一棵树,而树是图的一种特例——无环连通图。后面我会反复用到这个视角:语法树、符号表、依赖图、控制流图,全是图论里的同一套东西。
## 语义构建与符号表
语法分析只回答"这句话合法吗",语义分析回答"这句话什么意思"。核心是符号表:一个从名字到"这个东西是什么"的映射。
```
符号表: 名字 → { 种类: 变量/函数/类/模块, 类型: ..., 作用域: ..., 是否已初始化: ... }
```
作用域用链式结构组织:函数体内查不到名字,就向外层函数查,再向模块顶层查。这其实是"最近嵌套作用域"规则的直接实现。查找过程是这样的:
```
查找 name:
从最内层作用域开始
for 每个外层作用域:
if name 在本层存在:
return 本层定义
return 未找到
```
我在这上面定了一条后来被证明很关键的规则:**全局变量可以读,但写必须先声明 `global`**。这不是语法洁癖,而是底层语言的安全要求:一个函数里不小心给全局变量赋了值,如果编译器不报错,数据被悄悄改掉,内核算错账,可能过十几个小时才在某个诡异的地方爆炸。fail-fast 报错,比运行时灵异事件好一万倍。
类型检查也在这一阶段做。因为注解即指令,我的类型检查比 Python 严格得多:函数调用的实参类型、运算符两侧的类型、赋值的兼容性,都在这里逐一验证,不通过就直接 fail-fast 报错,绝不静默兜底。
# 类型系统:语言的骨架
类型是这门语言真正的骨架。
## 注解即指令
Python 是动态类型,LLVM 是强类型的静态世界,两者之间必须有人替每个值给出确定的类型。我最终的选择是:类型注解从"给人看的提示"升级为"给编译器的指令",直接决定生成什么样的 IR。为了让语言保持简洁,我把它分成两个模块——`t` 提供类型定义(类似 typing),`c` 提供 C 层操作(指针、内联汇编、FFI)。
```viper
import t, c
from stdint import *
def Main() -> CInt | CExport:
Count: CInt = 0 # CInt → i32
Ptr: CChar | CPtr = ... # CChar|CPtr → i8*(字节指针)
return 0
```
为什么不依赖类型推断?因为系统级语言必须在编译期就确定每一块数据的字节布局。推断只能覆盖一部分场景,一旦失败就会退化成运行时检查——那等于把类型问题推给运行时,让错误变得更难定位。注解即指令,让语义显式、可查、可验证。我后来对"语言该保留多少魔法"的答案,很大程度上是在这里定下来的:魔法越少,错误越好找。
## 基本类型映射
语言的基本类型直接对应 LLVM 类型,一一映射,不玩花活:
| Viper 类型 | LLVM 类型 | 字节数 |
|-----------|----------|-------|
| `CBool` | `i1` | 1(位) |
| `CChar` / `CInt8T` | `i8` | 1 |
| `CInt16T` | `i16` | 2 |
| `CInt32T` / `CInt` | `i32` | 4 |
| `CInt64T` | `i64` | 8 |
| `CUInt*T` | `iN` | N/8 |
| `CFloat` | `float` | 4 |
| `CDouble` | `double` | 8 |
| `CPtr` | `i8*` | 8 |
指针类型在语言里统一为 `CPtr`(`i8*`),具体的所指类型通过联合类型表达,比如 `CChar | CPtr` 表示"指向 CChar 的指针"也就是字节指针 `i8*`,`CInt32T | CPtr` 表示 `i32*`。
这里的类型表示法有一个我自己定的约定,后来写文档时专门澄清过:**在类型注解里,`|` 表示"和"(联合类型),在右值表达式里,`|` 表示"或"(按位或)**。同一个符号两种语义,听起来像设计缺陷,但它恰好贴合了需求:注解里写 `CChar | CPtr` 是在声明一个"既是字符又是指针"的复合类型,而代码里写 `a | b` 是在做位运算。语言层面不区分,是因为注解本身也是表达式,只是它被求值的位置和时机不同。
## 隐式转换规则
类型转换是系统级语言的雷区。我给自己定的隐式转换规则,比 C 语言保守:
- 整数/浮点之间:允许所有方向,高位向低位可安全降级,低位向高位无损
- 整数 → 浮点:要求浮点位宽 ≥ 整数位宽(无损)
- 浮点 → 整数:**禁止隐式**(有损,必须显式 cast)
- 指针:同层级指针类型允许转换,不同层级不允许
- 显式转换:任何方向都允许
这条规则的核心是"无损优先":隐式转换只发生在不丢信息的场景,有损转换必须显式写出来,让程序员看到自己在做什么。写内核时这是救命稻草——一个隐式的 `f64 → i32` 截断,可能在浮点驱动里造成难以追踪的数值错误。
## 泛型特化
泛型是最难的一块。`list[int]`、`list[str]` 是同一个模板的不同实例,但在 LLVM 里它们是完全不同的类型。我的实现是特化:遇到泛型实例化时,把模板里的类型参数 T 全部替换成具体类型,生成一份独立的类定义与函数体。
```viper
class GSListNode[T]:
value: T
next: GSListNode[T] | t.CPtr
# 实例化 list[int] 时,T → int,生成一份独立的 list_int
# 实例化 list[str] 时,T → str,生成一份独立的 list_str
```
特化的过程,本质上是一次**代换**:把类型参数从抽象符号替换成具体类型。形式上可以写成:
$$\text{Instantiate}(C[T], \tau) = C[T \mapsto \tau]$$
也就是说,实例化 `C[T]` 为具体类型 $\tau$,等于把 $C$ 的定义里所有出现 $T$ 的地方替换成 $\tau$。这套逻辑和数学里的函数代入是一回事,和 C++ 模板实例化也是一回事,只是我显式地、静态地做——在编译期生成完整的特化代码,而不是留给运行时。
特化的关键问题是名字:`list[int]` 和 `list[str]` 必须映射到两个不同的符号,否则链接时就串了。于是特化类名直接拼上具体类型——`list[int]`、`list[str]`,再套上模块的 SHA1 前缀。这也是我的类型系统能支撑递归泛型继承的原因:`class GNode(GSListNode[GNode])` 这种 C++ 里要写模板技巧的写法,在这里直接能表达。
递归泛型继承有个微妙点:特化 `GSListNode[GNode]` 时,`next` 字段的类型还是 `GSListNode[GNode]`,即它自己。这要求特化过程能处理"自引用",我靠惰性求值解决:先注册特化类的骨架(名字、父类),再填充字段类型,这样自引用总能找到已经注册的骨架。
# 面向对象的地基
类型系统就位后,面向对象成了下一个硬仗。
## 结构体布局
类在内存里就是一个结构体,字段按声明顺序排列,每个字段的偏移量由前面所有字段的大小和对齐决定。这是最朴素的"结构体布局":
```
class Point:
x: f64 # 偏移 0
y: f64 # 偏移 8
tag: i32 # 偏移 16
```
编译器要做的,就是精确计算每个字段的偏移量,保证 C 语言级的 ABI 兼容——内核里要跟 C 结构体互操作,偏移错一个字节就是灾难。对齐规则也不能偷懒:`f64` 要求 8 字节对齐,结构体整体大小要对齐到最大字段的对齐值。
字段偏移的计算公式很简单,但必须每一步都正确:
$$\text{offset}(f_i) = \text{align\_up}(\text{offset}(f_{i-1}) + \text{size}(f_{i-1}),\ \text{align}(f_i))$$
即每个字段的起点,是前一个字段末尾向上对齐到本字段对齐值的整数倍。这里的关键是 `align_up`:
$$\text{align\_up}(x, a) = (x + a - 1) \ \&\ \sim(a - 1)$$
对齐值必须是 2 的幂,这个公式才成立——所以所有基本类型的对齐都是 2 的幂,这也是为什么 `align_up` 可以用位运算一条指令搞定。
继承时的布局就更复杂了。子类在父类字段后面追加自己的字段,但基类子对象可能因为对齐而需要"填充":
```
class Base:
a: i8 # 偏移 0
# 填充 7 字节(对齐到 8)
b: f64 # 偏移 8
class Derived(Base):
c: i32 # 偏移 16(紧接 Base 末尾)
```
`@t.NoVTable` 类继承时,字段直接展平:子类布局 = 父类布局 + 子类新字段,没有任何指针间接层。这让派生类对象能直接当基类对象用——指针无需调整,C 风格的结构体扩展。
## 虚函数分派
多态的核心是虚函数表(vtable)。每个多态类在对象头放一个 vtable 指针,vtable 是一个函数指针数组,下标就是方法的槽位:
```
对象 (Object)
┌─────────────┐
│ vtable* │ ──→ ┌──────────────────┐
│ 字段0 │ │ slot 0: Draw() │
│ 字段1 │ │ slot 1: Update() │
└─────────────┘ └──────────────────┘
```
调用 `obj.Draw()` 时,编译成:load obj 的 vtable 指针 → 按槽位 load 函数指针 → 间接调用。这就是 `call *vtable[0]`。虚分派让"编译时不知道具体类型"的代码也能正确工作——代价是多一次内存间接寻址。
```llvm
%vt = load %VTable*, %VTable** %obj.vtable
%fn = load void (%VTable*)**, %void (%VTable*)*** %vt, align 8
call void %fn(...)
```
vtable 的构建也是编译期算好的:每个多态类在模块里生成一个静态的 vtable 全局,里面按 MRO 顺序填好各方法的函数指针。子类覆盖父类方法时,覆盖的是同一槽位;子类新增方法时,追加到末尾。
如果类不需要多态,我用 `@t.NoVTable` 关掉 vtable:字段直接展平进子类,省掉指针和间接调用。这个选择对应 C++ 里"非虚继承"的语义,是内核代码里最常见的形态——性能敏感,不需要多态。
## 多继承与 this 指针调整
多继承带来一个微妙的问题:对象在内存里可能有多个基类子对象,而每个基类方法期望的 this 指针,指向的是它自己那一段,不是对象头。
```
class Combined(BaseA, BaseB): ...
布局:
0: BaseA 子对象 ← BaseA 方法期望 this 在此
N: BaseB 子对象 ← BaseB 方法期望 this 在此
M: Combined 自己的字段
```
当调用 `combined.BaseBMethod()` 时,实际传给函数的 this 指针必须是 `&combined + N`——这就是 **this 指针调整**。编译器必须为每个继承来的方法记录"从对象头到该方法所属基类子对象的偏移量",调用时加上这个偏移。这个偏移我在编译期算好,硬编码进调用序列,运行时零成本:
```llvm
%this_adjusted = getelementptr i8, i8* %this, i64 N ; 加上基类子对象偏移
call void @BaseB_Method(%this_adjusted, ...)
```
多继承 + vtable 的组合是最棘手的:BaseB 的 vtable 槽位在 Combined 的 vtable 里,但 BaseB 的方法需要 this 指向 BaseB 子对象。所以调用 BaseB 的虚方法时,要从 Combined 的 vtable 里取函数指针,同时把 this 调整到 BaseB 段。这两件事必须打包在一起做,缺一个就是运行时崩溃。
## 重载决议与 MRO 线性化
Python 允许多继承,而多继承最麻烦的就是方法查找顺序。`class C(A, B)` 里调用一个 A、B 都有的方法,到底用谁的?如果 A 和 B 又都继承自同一个基类,菱形继承下顺序就更微妙了。
```
Base
/ \
A B
\ /
C
```
菱形继承下,`C` 的方法查找必须保证:C 自己最优先,A 与 B 按声明顺序,Base 只出现一次且在最后。这个顺序不能有歧义,否则同一个方法名在不同上下文解析出不同结果,程序行为就不可预测了。
我参考了 C3 线性化算法,它给每个类算出一条唯一的 MRO 链,规则是:
$$L(C) = C + \text{merge}\big(L(P_1), L(P_2), \dots, L(P_n), P_1, P_2, \dots, P_n\big)$$
merge 的约束是:每次取第一个列表的头部,只要这个头部不出现在任何其它列表的尾部,就把它并入结果。这个规则保证了"父类声明顺序优先、子类优先于父类、菱形重复去重",且局部优先级不被破坏。
用上面的菱形例子:
```
L(Base) = [Base]
L(A) = [A, Base]
L(B) = [B, Base]
L(C) = C + merge([A, Base], [B, Base], [A, B])
= C + A + merge([Base], [B, Base], [B])
= C + A + B + merge([Base], [Base])
= C + A + B + Base
```
所以 `C` 的 MRO 是 `[C, A, B, Base]`。方法查找沿这个链从上到下,第一个命中即返回。C3 算法的价值在于它是**确定性的**——同样的继承关系,永远得到同一条链,不会因为查找顺序的实现细节而产生歧义。
```viper
def MroChain(ClassName) -> list[str]:
# 深度优先 + 声明顺序 + 去重,输出含自身的线性化链
Chain = []
Visited = {}
Stack = [ClassName]
while Stack:
Cur = Stack.pop()
if Cur in Visited:
continue
Visited[Cur] = True
Chain.append(Cur)
for Parent in Reversed(Parents[Cur]): # 保持声明顺序
Stack.append(Parent)
return Chain
```
重载决议则做的是另一件事:同一个方法名可能有多个候选实现(父类、子类、接口),决议就是按 MRO 链从上往下找第一个定义。遇到类型参数重载时,还要比较实参类型与形参类型的兼容度,选择最匹配的一个。
# REnum 与模式匹配
读完 Rust 的手册,我对它的 enum 念念不忘。Rust 的 enum 是"带标签的联合"(tagged union):一个值在内存里占一块空间,但可以承载多种形态,靠一个标签字段区分当前是哪种。它比 C 的 union 安全,比"一堆相互继承的类"轻量。
C 的 union 危险在于:它不记录"当前存的是哪种",程序员必须靠约定记住,一旦记错就读取到垃圾数据。Rust 的 enum 把标签和值放在一起,编译器帮你保证"读之前必须匹配标签"。
我把它搬进了 Viper,命名为 REnum——Rust 风格的 enum。它的内存布局是:标签 + 各变体的数据(取最大者),或者标签 + 指针。调用方用 match 分派:
```viper
match Val:
case LLVMType.Int(Bits): # 命中"整数"变体,绑定宽度字段
...
case LLVMType.Ptr(Pointee):# 命中"指针"变体,绑定所指类型
...
case _: # 兜底分支
...
```
match 的模式匹配,编译后就是一次标签比较加几次跳转。它把"类型分派"从运行时反射(isinstance 链)变成了编译期可分析的静态跳转——这对我至关重要,因为自举时我不能依赖 Python 的反射,而 REnum + match 给了我一套完全静态、可验证的分派机制。LLVM 类型系统、AST 节点、指令操作码,全都用 REnum 表达。
match 的编译本质是一个多路分支。标签是整数,所以它可以编译成一张跳转表或者一串比较指令:
```llvm
switch i32 %tag, label %default [
i32 0, label %case_int
i32 1, label %case_ptr
...
]
```
switch 的查找既可以用跳转表(O(1)),也可以用二分搜索(O(log n)),具体哪个取决于标签分布。LLVM 后端会帮我选,我只需要把 match 翻译成 switch 即可。这又是一个"把语言特性映射到中间表示"的例子——语言层面的 match,就是 IR 层面的 switch,中间只隔着一层语义转换。
# 哈希:从表到命名空间
编译一多模块的项目,最先撞上的问题就是符号冲突。两个文件各定义一个 `init`,链接器就分不清谁是谁。传统做法是给符号加前缀或用 static 限制可见性,但手动维护一张前缀表既繁琐又容易漏。
直到某一天,我在写标准库的哈希表时突然想通了:哈希表能把任意长度的键映射成定长的桶下标,同一个键永远落在同一个桶。那模块的名字为什么不能同样处理?**把源文件的内容哈希成一个定长的标识,用它作为模块名**,这就是 SHA1 命名空间:
```viper
def ComputeSha1(content: str) -> str:
digest: str = hashlib.sha1(content).hexdigest()
return digest[:16] # 前 16 位十六进制,64 位,工程上够用
```
## 哈希表:从理论到实现
哈希表本身也是我最早用这门语言重写的标准库之一。选型时我对比了几种方案:
**链地址法**:每个桶是一个链表。实现简单,但每个节点要额外分配,缓存局部性差。
**开放寻址 + 线性探测**:冲突时往后找下一个空位。实现紧凑,全部数据在同一个数组里,缓存友好。缺点是对负载因子敏感。
我最终选了开放寻址 + 线性探测,因为编译器里的哈希表负载都不高(符号表、缓存),而缓存局部性很重要——遍历符号表时,整个表都在连续内存里。
```viper
_FNV_Offset: t.CUInt64T = 0xcbf29ce484222325
_FNV_Prime: t.CUInt64T = 0x100000001b3
def Fnv1aHash(key: str) -> t.CUInt64T:
h: t.CUInt64T = _FNV_Offset
k: str = key
while k[0] != 0: # 逐字节混合
h = h ^ t.CUInt64T(k[0])
h = h * _FNV_Prime
k += 1
if h < 2:
h = 2 # 0/1 保留给空槽与墓碑
return h
```
FNV-1a 的数学原理很简单:用一个质数作为乘法因子,把每一个字节"混"进累积值。异或保证每个字节都影响结果,乘法把低位的差异扩散到高位。它不是一个密码学哈希(可以构造碰撞),但对于"把键均匀分布到桶里"这个用途,它简单、快、效果够好。
开放寻址需要处理删除:直接清空一个槽会打断后续元素的探测链。标准解法是**墓碑(tombstone)**——删除时把槽标记为"曾经占用过"而不是"空",查找时跳过墓碑,插入时可以复用墓碑。我在哈希值里用 0 表示空槽、1 表示墓碑,所以散列结果必须避开这两个值——这就是上面代码里 `h < 2` 的处理。
## 负载因子与数学直觉
相关的数学直觉我后来才补上。均匀散列假设下,开放寻址表的平均探测次数是:
$$E[\text{probes}] = \frac{1}{1-\alpha}$$
其中 α 是负载因子(已占用槽位 / 总槽位)。代入几个值:
| 负载因子 α | 平均探测次数 |
|-----------|-------------|
| 0.50 | 2 |
| 0.75 | 4 |
| 0.90 | 10 |
| 0.99 | 100 |
看到这张表就明白了:0.9 到 0.99 之间,探测次数从 10 暴涨到 100,曲线在 α→1 时趋于无穷。几乎所有的哈希表都在 0.75 附近扩容,不是惯例,是这条曲线决定的——0.75 是在"空间浪费"和"探测开销"之间取的一个平衡点。
## 哈希去重与缓存
符号表里我同样用哈希做去重:同名符号插入时先查重,重复定义直接报错;SHA1 缓存用哈希表记住"内容 → 摘要",同一份源码不会被重复哈希。编译器的很多地方都在用哈希去重:
- 符号表:名字 → 定义,插入时查重
- SHA1 缓存:内容 → 摘要,避免重复哈希
- 字符串池:字符串字面量 → 地址,相同字符串只分配一次
- 特化缓存:`list[int]` → 特化代码,同一种特化只生成一次
哈希在这里不只是数据结构,它成了整个编译器的一种思维方式:**任何"重复计算"都可以用哈希缓存掉**。
# 依赖图与图论
模块一多,"先编译谁"就成了问题。`A import B` 意味着 B 必须先于 A 编译。把每个模块看成节点、把 import 看成一条有向边,整个项目就是一张有向图 $G = (V, E)$:
```
main
/ \
sched devfs
/ \
timer fat32
\ /
common_lib
```
边 $u \to v$ 表示"u 依赖 v"。一个合法的编译顺序,就是满足"每条边都从后往前"的顶点排列——这正是一张有向无环图(DAG)的拓扑排序。若图里有环(A import B,B import A),排序就不存在,必须先报告循环依赖。
## 依赖图的构建
依赖图不是凭空来的,它是从每个文件的 import 语句收集出来的:
```
对每个文件 f:
deps(f) = 解析 f 的 import 语句得到的模块名集合
for d in deps(f):
在图上加一条边 f → d
```
构建时有一个容易忽略的细节:import 的模块名要映射到文件路径。`import fat32` 到底对应哪个文件?`includes/fat32.vp`?还是项目自己的 `fat32.vp`?这需要一个"模块名 → 文件路径"的查找表,而查找本身又是哈希表的应用。映射错了,依赖图就画错了,编译顺序跟着错,链接期报一堆莫名其妙的 undefined symbol。
## Kahn 算法
最初的实现很朴素:每轮从头扫一遍全部模块,把依赖都已就绪的挑出来编译,直到编译完。每轮 O(V) 的扫描,模块一多就慢得明显。后来我翻出算法书,改用 Kahn 算法——它是 BFS 的一种应用。
定义每个节点的入度 $d^-(v)$ 为"指向它的边数",即它还有多少个依赖没编译。入度为 0 的节点意味着可以开工。把入度为 0 的全部入队,每次出队一个节点,就把它所有后继的入度减一,减到 0 再入队:
```
初始:
入度: main=2 sched=2 devfs=2 timer=1 fat32=1 common_lib=0
队列: [common_lib]
出队 common_lib → timer/fat32 入度减一 → 均变 0 → 入队
队列: [timer, fat32]
出队 timer → sched 入度减一 → 变 1
出队 fat32 → devfs 入度减一 → 变 1
队列: []
…… 依次类推,出队顺序即拓扑序
```
```viper
Indegree: CArray = 计算各模块入度 # A import B => Indegree[A] += 1
QHead: t.CSizeT = 0
QTail: t.CSizeT = 0
for I in range(ModuleCount):
if Indegree[I] == 0:
Queue[QTail] = I
QTail += 1
while QHead < QTail: # 动态队列,必须 while 而非 for
Cur: int = Queue[QHead]
QHead += 1
Order[OrderLen] = Cur
OrderLen += 1
for Nxt in Successors[Cur]: # 出队后,后继入度减一
Indegree[Nxt] -= 1
if Indegree[Nxt] == 0:
Queue[QTail] = Nxt
QTail += 1
```
这段代码里藏着一个我实际踩过的坑:最初主循环写成 `for Cur in range(QTail)`,而循环体里 QTail 还在增长。for-range 的上界只求值一次,于是后入队的模块永远轮不到——队尾在涨,队头却停在原地,拓扑漏项,链接期一片 undefined symbol。改成 `while QHead < QTail` 才跑通。
这个坑的根源是"**for 循环的上界在进入循环时就被冻结了**"。很多语言(Python、Viper)的 `range` 语义都是这样:循环次数在开始时确定,循环体里改上界不影响迭代次数。这不是 bug,是语义,但我在写"动态队列"时把它当 bug 踩了。
## 复杂度与直觉
Kahn 算法的复杂度是 O(V+E):每条边被访问一次(松弛一次入度),每个节点入队出队各一次。相比每轮全扫的 O(V²),差了一个量级。
这个差异在数据上非常直观。假设 100 个模块、300 条依赖边:
- 朴素全扫:每轮扫描 100 个模块,最坏 100 轮 → 10,000 次判断
- Kahn:300 条边各松弛一次 → 300 次操作
这背后的直觉是:**拓扑排序不必每轮重新评估所有节点,只需要追踪"刚刚变得就绪"的那部分**——这正是 BFS 工作列表(worklist)思想的雏形。
## 工作列表与不动点
依赖图排序我后来又用到了一个更一般的工具:工作列表算法。它的模式是——
```
维护一个"待处理"集合 W
重复:
从 W 中取出一个元素处理
处理结果可能产生新的待处理元素 → 加入 W
直到 W 为空
```
拓扑排序的队列、可达性分析的 BFS 队列、数据流分析的传播队列,本质都是这个模式的不同实例。工作列表的价值在于"惰性求值":只有变化波及到的部分才需要重新处理,而不是全局重算。
这与不动点理论相通。定义"状态空间"上的一个变换 $F$,反复迭代:
$$x_0 = \bot, \quad x_{n+1} = F(x_n)$$
如果 $F$ 是单调的且状态空间有界,序列 $\{x_n\}$ 必然收敛到某个不动点 $x^* = F(x^*)$。拓扑排序收敛到一个合法序、数据流分析收敛到所有块的值稳定、自举收敛到三代产物一致——全是同一个数学结构:**单调迭代收敛到不动点**。
# 控制流与数据流
## 控制流图
函数体翻译成 IR 之前,我先把语句块组织成控制流图(CFG):基本块是"顺序执行、中间无跳转"的指令序列,块与块之间用边连接,边代表跳转。每个块有一个或两个出口——条件跳转(if/while)有两个,普通块只有一个。
```
┌────────────┐
│ entry │
│ i = 0 │
└─────┬──────┘
│
┌─────▼──────┐ true
│ while cond ├──────────► body
└─────┬──────┘
│ false
┌─────▼──────┐
│ after │
└────────────┘
```
构建 CFG 的过程叫"图论转换":把语法树(AST)按控制流结构拆成基本块,块之间用跳转边连接。这本质上是把**树变成图**——AST 是树,CFG 是图,树里隐含的执行顺序被显式化为图中的边。
一个 if 语句转换成 CFG:
```
AST: CFG:
if cond: [entry]
A │
else: ▼
B [test cond] ──true──► [block A]
C │false
▼
[block B]
│
▼
[after: C]
```
CFG 的价值在于它把"程序的执行可能"变成了一张图,于是图论算法——可达性、环检测、拓扑排序——全部可以应用到程序分析上。这就是"图论转换"最直接的体现:**把代码变成图,用图论的工具去理解代码**。
## 数据流分析
数据流分析回答"某个值在程序里如何流动"的问题。经典框架是数据流方程:
$$\text{IN}[B] = \bigcup_{P \in \text{pred}(B)} \text{OUT}[P]$$
$$\text{OUT}[B] = \text{gen}[B] \cup \big(\text{IN}[B] \setminus \text{kill}[B]\big)$$
每个基本块"生成"一些事实、"杀死"一些事实,输入事实由所有前驱的输出并集而来,输出事实在输入基础上应用本块的转换。
用工作列表求解:从乐观的初始值出发,反复应用方程直到所有块的值不再变化。又是一个不动点:
```
for B in blocks:
OUT[B] = ∅
W = all blocks
while W 非空:
B = W.pop()
IN[B] = ∪ OUT[P] for P in pred(B)
OUT'[B] = gen[B] ∪ (IN[B] − kill[B])
if OUT'[B] != OUT[B]:
OUT[B] = OUT'[B]
W = W ∪ succ(B)
```
我在编译器里用数据流分析做过**变量活性分析**(决定变量何时可复用寄存器/槽位):变量 v 在点 p 处是"活的",如果从 p 出发存在一条路径,在这条路径上 v 被使用且未被重新定义。活性分析的结果决定变量能否复用存储,对内核这种内存敏感场景很重要。
前端这一层也用它提前暴露很多问题——比如"函数掉出末尾没有 return"这类缺陷,靠检查每个块的终止状态就能抓出来。LLVM 后端自己会做一遍更精细的优化,但前端这一层相当于自己的健康检查。
## 图论视角的统一
写到这里,我想停下来整理一下:这个编译器里到处都是图。
| 结构 | 图的类型 | 节点 | 边 |
|------|---------|------|-----|
| AST | 树 | 语法节点 | 子节点关系 |
| 符号表 | 树/链 | 作用域 | 外层作用域 |
| 依赖图 | 有向图 | 模块 | import 关系 |
| 继承图 | 有向图 | 类 | 父类关系 |
| CFG | 有向图 | 基本块 | 跳转 |
| 数据流 | 有向图 | 基本块 | 值流动 |
| 调用图 | 有向图 | 函数 | 调用关系 |
图论给我提供了一整套统一的分析语言:可达性(哪些模块参与编译)、拓扑排序(编译顺序)、环检测(循环依赖)、BFS/DFS(遍历策略)、不动点(迭代收敛)。学会从"图"的角度看问题之后,很多一开始看起来无关的东西突然就联系起来了。
# 协程:把函数变成状态机
协程是我在编译器里写的最得意的部分之一。`async def` 的函数本质上是一个可挂起/恢复的状态机:每个 `await` 点都是一个挂起点,恢复时从上次挂起的地方继续执行。
## 状态机的视角
一个含 `await` 的函数,它的执行轨迹不是一条直线,而是被挂起点切成了一段一段。每一段是一个"状态",恢复时从状态对应的段继续执行:
```
async def Task():
A() # 段 0:初始
await S(1) # 挂起点 0
B() # 段 1
await S(2) # 挂起点 1
C() # 段 2:结束
```
翻译成状态机:
```
state 0 → A() → 保存现场,state=1,挂起
state 1 → B() → 保存现场,state=2,挂起
state 2 → C() → 置 done 标志,返回
```
实现方式是把函数体翻译成一个主循环 + 一个大 switch,每个 await 分配一个状态号:
```viper
def Resume(帧):
switch 帧.state:
case 0: # 初始状态
... 执行到第一个 await ...
帧.state = 1 # 记录挂起点
保存所有跨挂起存活的局部变量到帧
return 暂停
case 1: # 恢复点 1
... 继续执行 ...
帧.state = 2
...
```
## 帧布局与 SSA 的限制
协程帧(coroutine frame)在堆上分配,存三样东西:状态号、参数、跨挂起存活的局部变量。帧布局是我精心排的:
```
帧: { state, done, ret, exc, 参数槽..., 局部槽..., 额外槽... }
```
这里有一个编译原理层面的深刻原因:**SSA 值不能跨挂起点存活**。在 SSA 形式里,每个值只被定义一次,而恢复点是全新的执行上下文——挂起时寄存器里的值在恢复时可能已经变了。所以所有需要跨挂起存活的变量都必须写进帧(内存),恢复时从帧读回来。这和我前面讲的数据流分析是同一枚硬币的两面:SSA 强调"值定义一次",协程强调"值必须落在挂起前后都可见的位置",唯一安全的位置就是帧。
控制流结构在协程里也要特殊处理:`if`/`while`/`for` 里出现 `await`,状态机必须能把"我在循环的第几次迭代"也存进帧。`for x in range(n)` 在协程里被翻译成 `while idx < n`,循环变量和上界都要存进帧,否则恢复时不知道循环进行到哪了。
## 异常与并发
协程的异常传播也是个状态机问题。`try/except` 在协程里被翻译成"异常码检查 + 跳转到对应处理块"。帧里固定一个异常槽,`raise` 把异常码写进槽,恢复时检查槽决定跳到哪段。异常消息用单独的槽存字符串指针。
`coro_gather` 可以同时推进多个协程——每轮把每个协程 resume 一次,直到它们全部完成:
```
def CoroGather(协程列表):
while 还有未完成的协程:
for c in 协程列表:
if not c.done:
Resume(c)
```
这是一个非常干净的状态机模型。一旦想通"协程 = 状态机",整个实现就水到渠成。而且这个模型是完全静态的——所有状态号在编译期分配,运行期只有一个 switch 和几次帧读写,没有魔法,没有解释器。
# 名字与转变
回头整理这段经历,语言经历了三次形态上的转变,名字也跟着换了两轮。
第一轮是 **PyL**。它只是一门 Python 子集 DSL,做 Python 到 C 的翻译,工具属性大于语言属性。那时它甚至没有一个正式的名字,PyL 是我事后补的标签。
第二轮是 **Viper**,编译器叫 **TransPyC**。当目标从 C 换成 LLVM IR,类型注解从提示变成指令,语言真正有了自己的语义,不再是谁的方言。名字也在那时定了下来——语言叫 Viper,编译器叫 TransPyC。两者分离,因为编译器的名字表达的是"它做什么"(翻译 Python),语言的名字表达的才是"它是什么"。
第三轮是 **Viper 写的编译器 TransPyV**。当编译器本身用 Viper 重写、并且能编译自己的时候,语言和工具第一次由同一种语言书写。这一步的意义不在于性能,不在于部署形态,而在于:这个语言终于证明了它足够表达它自己。
# 自举之梦
一门语言能不能自举,是它成熟与否的一个硬指标。C 用自己的编译器编译自己,Rust 用 rustc 编译 rustc。如果 TransPyC 连自己都编译不了,那它始终只是"一个能把 Python 翻译成原生代码的工具",而不是"一门独立的语言"。
自举意味着要用这门语言重写编译器本身。当时的编译器核心有近三万行代码,动态反射、Python 标准库依赖——ast、llvmlite、json、subprocess,每一个都是拦路虎。
## 夺回前端:includes/ast
我决定一步步来,先写最硬的两块。
`includes/ast`——用这门语言自己写的 Python 语法解析器。四千多行,从词法到递归下降,覆盖 CPython 几乎全部语法:装饰器、async/await、match、推导式、f-string。这是把编译器的前端从 Python 手里夺回来。
写这个解析器,等于把我前面做的所有词法、语法、语义工作用这门语言自己重做一遍。它要解析的对象就是 Python/Viper 源码本身——包括它自己。这个自我指涉很有意味:一个用 Viper 写的解析器,去解析写 Viper 编译器的 Viper 源码。
## 夺回后端:includes/llvmlite
`includes/llvmlite`——自己写的 LLVM IR 生成库。一千五百行,类型系统、值表示、IRBuilder、模块输出。这是把后端也夺回来。
它实现了 IR 的构建接口:`BuildAdd`、`BuildLoad`、`BuildStore`、`BuildCall`……每个函数在内存里构造一个 IR 指令对象,最终把整个模块打印成 .ll 文本。打印是最容易出错的部分——IR 文本的语法必须精确匹配 LLVM 的解析器,一个空格、一个类型名错了,llc 就报错。
## 收敛:不动点
有了这两块,剩下的就是重写编译器主体。这是一个漫长的过程,自举实验失败过无数次。2026 年 7 月底那次失败我记得很清楚:C1 编译 Test 已经成功了,但离自举收敛还差得远。
"自举收敛"这个概念,数学上对应不动点:把"用编译器编译编译器"看成变换 F,自举成功就是找到满足 $F(x)=x$ 的 x——编译器的语义在自己的输出上不再漂移。自举链 TPC → tpv → tpv2 → tpv3 的每一步都是一次迭代,一旦某一步的输出与输入语义一致,链条就冻结了。那段时间我每天都在等这个冻结。
自举的难点在于:**你无法用编译器调试编译器**。如果用 gcc 编一个 C 程序,出错了可以用 gdb、可以看汇编。但如果用 tpv 编 tpv,出错了——谁来编 tpv?只有 tpv 自己。这就像一个人试图揪着自己的头发把自己提起来。唯一的办法是分层:先确保 TPC(Python 版)能编出正确的 tpv.exe,然后让 tpv.exe 编自己,出了问题回去改 TPC 生成的代码,再重新编。每一步的错误,都可能是上一层的 bug 传导下来的。
## 那一刻
转机出现在不断的试错之后。当 TPC 编译 TransPyV 生成 tpv.exe,tpv.exe 编译自己生成 tpv2,tpv2 编译自己生成 tpv3——Phase A、B、C 全部跑通,三代产物行为一致。
那天其实很平静。我核对了几处 IR,跑了一遍回归,确认不是巧合,然后坐了一会儿。没有想象中那种情绪的顶点,更像走完一条长路之后站在路口回头看:路很长,但走通了。
TransPyC 用 TransPyC 编译了 TransPyC。从这一刻起,它真正成为了一门独立的语言。
# 尾声:未竟的路
自举之后,形态变了:不再需要 Python 运行时,不再依赖 CPython 的 ast 和 llvmlite。一个可执行文件加一个 LLVM 工具链,就是全部。
纸娃娃也早已脱胎换骨。最初用 PyL 重写的那套迷你系统,如今是 ViperOS——一个用 Viper 写成的、能在 QEMU 里启动的操作系统:UEFI 引导、GDT/IDT/中断、分页、内存管理、自研 FAT32、键盘鼠标、VESA 图形界面。内核、驱动、用户程序、库、SDK,都用同一种语言写成。
回望这几年,从 2022 年冬学 C 与汇编,到 2023 年春读《30 天自制操作系统》,到贴吧那个稚嫩的 pyos,再到 PyL、TransPyC、TransPyV 和 ViperOS,路径不算清晰,但每一步都算数。
如果要说心得,大概是:不要小看任何一次粗糙的尝试。贴吧那个 pyos 只有手动递归下降和字符串拼接,PyL 学会了借 `ast` 的解析器和 pycparser 的输出器,TransPyC 才真正开始自研词法、语法、语义。每一步都不是凭空跳出来的——正则匹配的直觉变成词法、`ast.dump` 教会我树结构、dict 栈记变量变成符号表、递归遍历变成递归下降。我后来写的每一个"真正的"组件,都在这条路上有它粗糙的对应物。
路还很长,但方向已经清晰。