804 lines
49 KiB
Markdown
804 lines
49 KiB
Markdown
# 序章
|
||
|
||
由于我的精力基本投入在 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 栈记变量变成符号表、递归遍历变成递归下降。我后来写的每一个"真正的"组件,都在这条路上有它粗糙的对应物。
|
||
|
||
路还很长,但方向已经清晰。
|