序章

项目:

git.gvsds.com/GVSDS/TransPyV/

由于我的精力基本投入在 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 类型字节数
CBooli11(位)
CChar / CInt8Ti81
CInt16Ti162
CInt32T / CInti324
CInt64Ti648
CUInt*TiNN/8
CFloatfloat4
CDoubledouble8
CPtri8*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]”

特化的过程,本质上是一次代换:把类型参数从抽象符号替换成具体类型。形式上可以写成:

\operatorname{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 字节对齐,结构体整体大小要对齐到最大字段的对齐值。

字段偏移的计算公式很简单,但必须每一步都正确:

 \operatorname{offset}(f_i) = \operatorname{align_up}\big(\operatorname{offset}(f_{i-1}) + \operatorname{size}(f_{i-1}),\ \operatorname{align}(f_i)\big) 

即每个字段的起点,是前一个字段末尾向上对齐到本字段对齐值的整数倍。这里的关键是:

\operatorname{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]。虚分派让”编译时不知道具体类型”的代码也能正确工作——代价是多一次内存间接寻址。

%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 链,规则是:

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 算法的价值在于它是确定性的——同样的继承关系,永远得到同一条链,不会因为查找顺序的实现细节而产生歧义。

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 的处理。

负载因子与数学直觉

相关的数学直觉我后来才补上。均匀散列假设下,开放寻址表的平均探测次数是:

E[\text{probes}] = \frac{1}{1-\alpha}

其中 (\alpha) 是负载因子(已占用槽位 / 总槽位)。代入几个值:

负载因子 α平均探测次数
0.502
0.754
0.9010
0.99100

看到这张表就明白了: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),反复迭代:

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 分配一个状态号:

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 栈记变量变成符号表、递归遍历变成递归下降。我后来写的每一个”真正的”组件,都在这条路上有它粗糙的对应物。

路还很长,但方向已经清晰了吧。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐