自制一个系统级的编程语言?分享我个人的感悟。
序章
项目:
由于我的精力基本投入在 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语言。
大致上差不多接近于:
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 的地基:
AST 里,哪些节点是”名字”,哪些是”操作符”,哪些是”字面量”?——这是语义分析要认的基本元素。
嵌套的结构(if 里套 if)在 AST 里怎么表示,遍历时怎么保证配对正确?
一个变量从声明到使用,中间要跨过哪些作用域?
我当时的答案都很粗糙:节点靠 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' | 双引号、单引号、三引号 |
| 运算符 | +、==、-> | 单字符与多字符运算符 |
| 分隔符 | (, ), ,, : | 括号、逗号、冒号 |
我当时的做法很朴素:用一个主循环扫描字符,遇到字母就连续吸收成一个标识符,遇到数字就吸收成数值字面量,遇到引号就进入字符串扫描:
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 的扫描循环,都必须显式地停在它应该停的地方,绝不靠”循环末尾自增”兜底。
行号跟踪也不能马虎。报错信息里的”第XXX行第YYY列”,靠的是词法阶段逐字符维护行号。我踩过一个特别隐蔽的坑:三引号字符串内部扫描时,如果跳过换行的逻辑写错,行号表就会整体偏移,后面的所有报错都指向错的位置,那么一个错误也就不知道需要浪费多久了。
语法分析
语法分析——把线性 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 枚举区分节点种类,字段按需复用。这牺牲了一点类型安全,换来了分配和遍历上的统一:
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)。
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*。
这里的类型表示法有一个我定的约定,后来写文档时专门澄清过:在类型注解里,| 表示”和”(联合类型),在右值表达式里,| 表示”或”(按位或)。同一个符号两种语义,听起来像设计缺陷.....emmm.....他确实是一个缺陷,实际上就是 C 的平铺读法直接生搬硬套到了 Python 语法上,比如 char* 到这里就变为 t.CChar|t.CPtr了,而代码里写 a | b 是在做位运算。语言层面不区分,是因为注解本身也是表达式,只是它被求值的位置和时机不同。这也是一个很大的问题,在后期会改。
隐式转换规则
类型转换是系统级语言的雷区。我给自己定的隐式转换规则,比 C 语言保守:
- 整数/浮点之间:允许所有方向,高位向低位可安全降级,低位向高位无损
- 整数 → 浮点:要求浮点位宽 ≥ 整数位宽(无损)
- 浮点 → 整数:禁止隐式(有损,必须显式 cast)
- 指针:同层级指针类型允许转换,不同层级不允许
- 显式转换:任何方向都允许
这条规则的核心是”无损优先”:隐式转换只发生在不丢信息的场景,有损转换必须显式写出来,让程序员看到自己在做什么。一个隐式的 f64 → i32 截断,可能在浮点驱动里造成难以追踪的数值错误。
泛型特化
泛型是最难的一块。list[int]、list[str] 是同一个模板的不同实例,但在 LLVM 里它们是完全不同的类型。我的实现是特化(单态化):遇到泛型实例化时,把模板里的类型参数 T 全部替换成具体类型,生成一份独立的类定义与函数体。
class GSListNode[T]:
value: T
next: GSListNode[T] | t.CPtr
# 实例化 list[int] 时,T → int,生成一份独立的 “list[int]”
# 实例化 list[str] 时,T → str,生成一份独立的 “list[str]”
特化的过程,本质上是一次代换:把类型参数从抽象符号替换成具体类型。形式上可以写成:
也就是说,实例化 (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 字节对齐,结构体整体大小要对齐到最大字段的对齐值。
字段偏移的计算公式很简单,但必须每一步都正确:
即每个字段的起点,是前一个字段末尾向上对齐到本字段对齐值的整数倍。这里的关键是:
对齐值必须是 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]。虚分派让”编译时不知道具体类型”的代码也能正确工作——代价是多一次内存间接寻址。
%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 指针调整。编译器必须为每个继承来的方法记录”从对象头到该方法所属基类子对象的偏移量”,调用时加上这个偏移。这个偏移我在编译期算好,硬编码进调用序列,运行时零成本:
%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 链,规则是:
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 算法的价值在于它是确定性的——同样的继承关系,永远得到同一条链,不会因为查找顺序的实现细节而产生歧义。
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 分派:
match Val:
case LLVMType.Int(Bits): # 命中"整数"变体,绑定宽度字段
...
case LLVMType.Ptr(Pointee):# 命中"指针"变体,绑定所指类型
...
case _: # 兜底分支
...
match 的模式匹配,编译后就是一次标签比较加几次跳转。它把”类型分派”从运行时反射(isinstance 链)变成了编译期可分析的静态跳转——这对我至关重要,因为自举时我不能依赖 Python 的反射,而 REnum + match 给了我一套完全静态、可验证的分派机制。LLVM 类型系统、AST 节点、指令操作码,全都用 REnum 表达。
match 的编译本质是一个多路分支。标签是整数,所以它可以编译成一张跳转表或者一串比较指令:
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 命名空间:
def ComputeSha1(content: str) -> str:
digest: str = hashlib.sha1(content).hexdigest()
return digest[:16] # 前 16 位十六进制,64 位,工程上够用
哈希表:从理论到实现
哈希表本身也是我最早用这门语言重写的标准库之一。选型时我对比了几种方案:
- 链地址法:每个桶是一个链表。实现简单,但每个节点要额外分配,缓存局部性差。
- 开放寻址 + 线性探测:冲突时往后找下一个空位。实现紧凑,全部数据在同一个数组里,缓存友好。缺点是对负载因子敏感。
我最终选了开放寻址 + 线性探测,因为编译器里的哈希表负载都不高(符号表、缓存),而缓存局部性很重要——遍历符号表时,整个表都在连续内存里。
_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 的处理。
负载因子与数学直觉
相关的数学直觉我后来才补上。均匀散列假设下,开放寻址表的平均探测次数是:
其中 (\alpha) 是负载因子(已占用槽位 / 总槽位)。代入几个值:
| 负载因子 α | 平均探测次数 |
|---|---|
| 0.50 | 2 |
| 0.75 | 4 |
| 0.90 | 10 |
| 0.99 | 100 |
看到这张表就明白了:0.9 到 0.99 之间,探测次数从 10 暴涨到 100,曲线在 (\alpha \to 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
队列: []
…… 依次类推,出队顺序即拓扑序
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^2)),差了一个量级。
这个差异在数据上非常直观。假设 100 个模块、300 条依赖边:
- 朴素全扫:每轮扫描 100 个模块,最坏 100 轮 → 10 000 次判断
- Kahn:300 条边各松弛一次 → 300 次操作
这背后的直觉是:拓扑排序不必每轮重新评估所有节点,只需要追踪”刚刚变得就绪”的那部分——这正是 BFS 工作列表(worklist)思想的雏形。
工作列表与不动点
依赖图排序我后来又用到了一个更一般的工具:工作列表算法。它的模式是——
维护一个"待处理"集合 (W) 重复: 从 (W) 中取出一个元素处理 处理结果可能产生新的待处理元素 → 加入 (W) 直到 (W) 为空
拓扑排序的队列、可达性分析的 BFS 队列、数据流分析的传播队列,本质都是这个模式的不同实例。工作列表的价值在于”惰性求值”:只有变化波及到的部分才需要重新处理,而不是全局重算。
这与不动点理论相通。定义”状态空间”上的一个变换 (F),反复迭代:
如果 (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 的价值在于它把”程序的执行可能”变成了一张图,于是图论算法——可达性、环检测、拓扑排序——全部可以应用到程序分析上。这就是”图论转换”最直接的体现:把代码变成图,用图论的工具去理解代码。
数据流分析
数据流分析回答”某个值在程序里如何流动”的问题。经典框架是数据流方程:
每个基本块”生成”一些事实、”杀死”一些事实,输入事实由所有前驱的输出并集而来,输出事实在输入基础上应用本块的转换。
用工作列表求解:从乐观的初始值出发,反复应用方程直到所有块的值不再变化。又是一个不动点:
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 分配一个状态号:
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 把异常码写进槽,恢复时检查槽决定跳到哪段。异常消息用单独的槽存字符串指针。
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 用 TransPyV 编译了 TransPyV。从这一刻起,它真正成为了一门独立的语言。
尾声:未竟的路
自举之后,形态变了:不再需要 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 栈记变量变成符号表、递归遍历变成递归下降。我后来写的每一个”真正的”组件,都在这条路上有它粗糙的对应物。
路还很长,但方向已经清晰了吧。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)