Files
TransPyC/wiki/01-overview.md
T

19 KiB
Raw Blame History

01 - 语言概述与编译流程

语言定位

Viper 是一种 系统级编程语言,其语法基于 Python,但编译目标为 LLVM IR,最终生成原生机器码。Viper 不是 Python 的超集或子集,也不是"C 的语法糖"——它是一门拥有独立类型系统、独立编译模型、独立模块体系的语言。Viper 选择 Python 语法作为表达形式,是因为 Python 是人类公认的可读性最高的语言。

当前编译器 TransPyC(用 Python 实现)使用 Python 标准库 ast 解析源文件,将精力集中在语义翻译而非词法/语法分析上。但项目已在 includes/ast 中实现完全自研的 Python 解析器(词法器 + 语法器 + AST 构造,约 4176 LOC,AstTest 综合测试通过),覆盖 CPython 几乎全部语法。这是向 TransPyV(用 Viper 重写编译器自身)完全自举的关键准备——自举后编译器将脱离 Python 运行时,解析、类型检查、IR 生成都由原生机器码完成。详见 13-bootstrapping.md。

而实际上,下文中所有提到的 C 语言都是为了便于使用 C 的底层开发者理解。由于历史原因,在旧版本中我们使用 C 来作为中间语言,如同旧版本的 C++ 一样,但现在 target="c" 的路径已经完全删除,C 路径永久不再受维护。

核心设计决策

  1. Python 语法,LLVM 语义:源文件是完全合法的 Python 语法,但通过 t 模块的类型注解系统赋予完全不同的语义 —— t.CInt 不是 Python 的 int,而是 LLVM 的 i32
  2. 类型注解即编译指令:Viper 的类型注解不是可选的提示,而是编译器生成 LLVM IR 的决定性依据。x: t.CInt 生成 i32,x: t.CInt | t.CPtr 生成 i32*,但无注解时会自动推导,详见下文。
  3. 两阶段编译:先从源文件提取声明接口(.vpi + .stub.ll),再使用声明接口翻译源文件为含代码的 .ll。这是 Viper 区别于简单"Python 转 C"工具的关键架构
  4. SHA1 命名空间:每个源文件按内容 SHA1 哈希命名,非导出函数和结构体自动加上 SHA1 前缀,从根本上消除跨模块的符号冲突
  5. 万物皆数据:为便于底层开发,实际上所有的变量和类型一般并不适用于鸭子类型,但在语义层面我们会尽可能贴近鸭子类型。

与 Python 的关系

特性 Python Viper
类型系统 动态类型 静态类型(通过 t 模块注解,编译时确定)
内存管理 GC 自动回收 手动管理,无 GC,无运行时
运行时 解释执行 + 字节码 编译为原生机器码(通过 LLVM)
对象模型 万物皆对象 @t.Object 启用 OOP,@t.CVTable 为多态(vtable),@t.NoVTable 为非多态继承(C++ 风格,零运行时开销),普通 class 仅为结构体布局
标准库 Python 标准库 Viper 标准库(includes/)+ ViperOS SDK
空值 None None(编译为 NULL/零值)
模块系统 运行时 import 编译时解析,插入 .stub.ll,SHA1 命名空间隔离

与 C 的关系

Viper 编译后的代码在底层等价于 C 编译后的机器码(都经过 LLVM),但 Viper 在语言层面提供了 C 所没有的能力:

  • SHA1 命名空间:自动消除符号冲突,无需手动管理 static/命名前缀
  • 类型组合语法:t.CConst | t.CInt | t.CPtr 比 const int* 更具组合性
  • 声明式内联汇编:c.Asm(f"mov {c.AsmOut(x, t.ASM_DESCR.OUTPUT_REG)}, rdi") 比裸 __asm__ 更安全,也更具可读性。
  • 结构化预处理:c.CIfdef/c.CEndif() 替代 #ifdef/#endif。
  • 面向对象:@t.Object + @t.CVTable 提供 vtable 支持的多态 OOP;@t.NoVTable 提供 C++ 风格的非多态继承(零 vtable 开销);PEP 695 泛型 + 递归泛型继承实现强类型自引用结构
  • 存根驱动的模块系统:.vpi 存根文件实现跨模块类型解析,无需头文件
  • 更多内容:额外更多的不依赖操作系统的函数和语法

两阶段编译流程

Viper 的编译由 Projectrans.py 驱动,分为两个阶段。这是 Viper 编译模型的核心,理解两阶段编译是理解 t.CDefine、t.CExport、t.CInline 等关键概念的前提。

源文件 (.vp) ──────────────────────────────────────────────────────
    │                                                              │
    │  ┌─────────────── 阶段一:声明提取 ───────────────┐          │
    │  │                                               │          │
    │  │  1. 计算源文件 SHA1                            │          │
    │  │  2. 生成 <SHA1>.vpi(签名存根)                │          │
    │  │  3. 构建结构体注册表                           │          │
    │  │  4. 生成 <SHA1>.stub.ll(LLVM IR 声明)        │          │
    │  │                                                │          │
    │  └────────────────────────────────────────────────┘          │
    │                                                              │
    │  ┌─────────────── 阶段二:代码翻译 ───────────────┐          │
    │  │                                                │          │
    │  │  1. 加载所有 .vpi 和 .stub.ll                  │          │
    │  │  2. 构建共享符号表                             │          │
    │  │  3. 收集内联函数符号                           │          │
    │  │  4. 翻译源文件 → <SHA1>.ll(含代码)           │          │
    │  │  5. llc 编译 → <SHA1>.o(目标文件)            │          │
    │  │  6. ld.lld 链接 → 可执行文件                   │          │
    │  │                                                │          │
    │  └────────────────────────────────────────────────┘          │
    │                                                              │
    └──────────────────────────────────────────────────────────────┘

阶段一:声明提取(Phase1Generator)

阶段一的目标是从每个源文件提取声明接口,使其他模块在阶段二翻译时能够正确解析跨模块引用。

步骤 1:可达文件发现与拓扑排序

从入口文件(main.vp 或 project.json 指定)出发,通过 import 语句递归遍历,找出所有可达的 .vp 源文件。然后对文件进行拓扑排序,确保被依赖的模块先被处理。

main.vp → import serial → import drivers.serial.uart.serial → ...

步骤 2:生成 .vpi 签名存根

对每个源文件,计算其内容的 SHA1 哈希(16位),然后调用 PythonToStubConverter 生成签名存根文件 <SHA1>.vpi。

SHA1 计算方式:

sha1 = hashlib.sha1(content.encode('utf-8')).hexdigest()[:16]

存根文件的内容规则:

源文件元素 存根文件中的表示
t.CDefine 常量 保留原样(含赋值):MAX_SIZE: t.CDefine = 1024
t.CDefine 函数(返回 t.CDefine 的函数) 保留完整函数体(宏展开需要)
普通函数 仅保留签名,添加 `
类定义 保留成员类型注解和方法签名
全局变量 添加 t.CExtern:`x: t.CExtern
c.CIfdef/c.CEndif() 等预处理指令 保留原样
import 语句 保留原样

关键设计:t.CDefine 常量和 t.CDefine 函数在存根中保留完整定义(包括赋值和函数体),因为它们是编译时常量/宏,其他模块引用时需要完整展开。普通函数只保留签名并标记 c.State(仅声明),因为函数体在阶段二生成。

步骤 3:构建结构体注册表

扫描所有已生成的 .vpi 文件,提取:

  • 结构体名称集合:所有非枚举、非异常的 class 定义
  • 枚举名称集合:继承 t.CEnum 的类
  • 异常名称集合:继承 Exception 的类
  • 结构体→SHA1 映射:记录每个结构体定义在哪个模块中

这个注册表用于阶段一的声明生成和阶段二的类型解析,确保跨模块的结构体引用使用正确的 SHA1 命名空间。

步骤 4:生成 .stub.ll LLVM IR 声明

对每个 .vpi 文件,由 DeclarationGenerator 生成对应的 LLVM IR 声明文件 <SHA1>.stub.ll。

声明生成规则:

元素 生成的 LLVM IR
普通函数 declare void @"<SHA1>.foo"(i32) — 非 CExport 函数加 SHA1 前缀
CExport 函数 declare i32 @main() — CExport 函数不加前缀,全局可见
结构体 %"<SHA1>.ClassName" = type { i32, i32* } — 类型名加 SHA1 前缀
全局变量 @varname = external global i32
t.CDefine 常量 不生成声明(编译时常量,直接内联展开)
t.CTypedef 别名 不生成声明(类型别名,在类型解析时展开)
枚举 为每个枚举成员生成 @__config_EnumName_member = external global i32

增量编译:如果 <SHA1>.vpi 或 <SHA1>.stub.ll 已存在,则跳过生成(缓存命中)。只有源文件内容变化导致 SHA1 变化时才重新生成。

阶段二:代码翻译(Phase2Translator)

阶段二使用阶段一生成的声明接口,将源文件翻译为包含完整代码的 LLVM IR。

步骤 1:加载声明接口

加载 temp/ 目录中所有的 .vpi 和 .stub.ll 文件,构建:

  • sig_files:SHA1 → .vpi 文件路径映射
  • stub_files:SHA1 → .stub.ll 文件路径映射
  • sha1_map:SHA1 → 源文件相对路径映射

步骤 2:构建共享符号表

一次性构建所有文件共享的符号表数据,避免每个文件重复加载。将所有 .vpi 存根和 .stub.ll 声明中的类型信息注册到统一的 SymbolTable 中。

步骤 3:收集内联函数符号

扫描 includes/ 目录中被引用的模块,收集标记为 t.CInline 的函数。内联函数的完整 AST body 被保存,在翻译调用点时直接展开。值得注意的是,和 C 语言不同,t.CInline 不是优化建议,而是强制性的。

步骤 4:翻译源文件

对每个源文件,使用 TransPyC 翻译器将 Python AST 翻译为 LLVM IR:

  1. 将对应模块的 .stub.ll 声明嵌入到生成的 .ll 文件头部
  2. 所有被引用模块的 .stub.ll 声明也被嵌入
  3. 翻译 AST 节点为 LLVM IR 指令
  4. 输出 <SHA1>.ll 文件

步骤 5:编译与链接

  • llc:将每个 .ll 编译为 .o 目标文件
  • ld.lld:将所有 .o 链接为最终可执行文件

SHA1 命名空间机制

SHA1 命名空间是 Viper 解决跨模块符号冲突的核心机制。

问题

在 C 语言中,多个模块可能定义同名函数或结构体,导致链接时符号冲突。C 的解决方案是 static(限制可见性)和命名前缀(手动避免冲突)。

Viper 的解决方案

Viper 使用源文件内容的 SHA1 哈希作为命名空间前缀:

源文件 A.vp(SHA1 = a1b2c3d4e5f6g7h8)中定义:
    class Point: x: t.CInt; y: t.CInt
    def draw(p: Point) -> t.CVoid: ...

源文件 B.vp(SHA1 = i9j0k1l2m3n4o5p6)中也定义:
    class Point: x: t.CFloat; y: t.CFloat  # 不同的结构体!
    def draw(p: Point) -> t.CVoid: ...

编译后:
    A.vp 的结构体 → %"a1b2c3d4e5f6g7h8.Point" = type { i32, i32 }
    A.vp 的函数   → declare void @"a1b2c3d4e5f6g7h8.draw"(%"a1b2c3d4e5f6g7h8.Point"*)

    B.vp 的结构体 → %"i9j0k1l2m3n4o5p6.Point" = type { float, float }
    B.vp 的函数   → declare void @"i9j0k1l2m3n4o5p6.draw"(%"i9j0k1l2m3n4o5p6.Point"*)

没有符号冲突——即使两个模块定义了同名类型和函数,它们的 LLVM IR 符号也是不同的。即便有两个内容完全相同的文件也不会冲突,他们会被识别为同一个文件,然后进行单次编译。

SHA1 前缀的豁免:t.CExport

标记为 t.CExport 的函数不加 SHA1 前缀,保持原始函数名。这是模块向外部暴露 API/ABI 的机制:

# main.vp — 入口函数,必须全局可见
def main() -> t.CInt | t.CExport:
    return 0

# 编译后:define i32 @main() — 无 SHA1 前缀
# serial.vp — 驱动接口,对外暴露
def init() -> t.CVoid | t.CExport:
    serial_puts("init\n")

# 编译后:define void @init() — 无 SHA1 前缀,其他模块可直接调用

SHA1 与增量编译

SHA1 同时服务于增量编译:

  • 源文件内容不变 → SHA1 不变 → .vpi 和 .stub.ll 缓存命中,跳过阶段一
  • 源文件内容变化 → SHA1 变化 → 重新生成声明接口
  • temp/_sha1_map.txt 记录 SHA1 → 源文件路径的映射,供阶段二加载

理论上,如果一个未经变动的模块的依赖路径上存在变动的模块,暂时的方法是将其简单做 SHA1 替换,暨将旧 SHA1 替换为新 SHA1,但不适用于签名改变的情况,不过签名改变这个未经变动的模块必然也需要改变,但是由于宏展开的存在,不能完全保证不会发生错误情况。且依赖路径的 SHA1 替换成功性未经完整测试,可能出现未定义行为。

t.CDefine 深度解析

t.CDefine 是 Viper 中最特殊的类型——它不是数据类型,而是编译时元指令,控制编译器的代码生成行为。

t.CDefine 常量

MAX_SIZE: t.CDefine = 1024
PAGE_SIZE: t.CDefine = 4096
FA_READ: t.CDefine = 0x01
CPU_FEATURE_FPU: t.CDefine = (1 << 0)

编译行为:

  1. 阶段一:存根生成器保留 t.CDefine 常量的完整定义(包括赋值值),因为其他模块可能引用此常量
  2. 阶段一:.stub.ll 声明生成器跳过 t.CDefine 常量,不生成 external global 声明
  3. 阶段二:翻译器遇到 t.CDefine 常量的引用时,直接内联其值,不生成任何加载指令

这意味着 t.CDefine 常量在 LLVM IR 层面不存在——它们在编译时被完全展开,等价于 C 的 #define 宏。

t.CDefine 函数

当函数的返回类型注解包含 t.CDefine 时,该函数被编译器视为宏函数:

def MAKE_FLAG(bit) -> t.CDefine:
    return (1 << bit)

def GET_PAGE_ORDER(size) -> t.CDefine:
    return size // PAGE_SIZE

编译行为:

  1. 阶段一:存根生成器保留 t.CDefine 函数的完整函数体(不像普通函数那样只保留签名)
  2. 阶段二:翻译器遇到 t.CDefine 函数的调用时,将调用内联展开为函数体的计算结果

t.CDefine 函数在 LLVM IR 中不生成函数定义——它们是纯粹的编译时宏。

t.CDefine 与类型组合

t.CDefine 可以与具体类型组合使用,为常量指定底层类型:

CODE_SEG: t.CDefine | t.CUInt16T = 0x08
DATA_SEG: t.CDefine | t.CUInt16T = 0x10

这表示常量在类型检查时被视为 t.CUInt16T(uint16_t),但在代码生成时仍然内联展开。

t.CExport 深度解析

t.CExport 控制函数的符号可见性,是 SHA1 命名空间的豁免机制。

语义

修饰 LLVM IR 函数名 链接可见性 用途
无修饰 @<SHA1>.funcname 模块内部 模块私有函数
t.CExport @funcname 全局可见 对外暴露的 API
t.CExtern @funcname(仅声明) 外部定义 引用外部函数

使用场景

# 入口函数 — 必须全局可见,链接器需要找到它
def _start() -> t.CInt | t.CExport:
    return kernel_main()

# 驱动接口 — 其他模块/应用需要调用
def init() -> t.CVoid | t.CExport:
    uart_init()

# 内部辅助函数 — 不需要全局可见,自动加 SHA1 前缀
def _helper() -> t.CInt:
    return 42

t.CExport 在存根中的表现

在 .vpi 存根文件中,t.CExport 函数与普通函数一样只保留签名(添加 c.State,表示单纯声明),但 t.CExport 标记被保留在返回类型注解中。阶段一的 DeclarationGenerator 检查 t.CExport 标记来决定是否添加 SHA1 前缀。

t.CInline 深度解析

t.CInline 标记函数为内联函数,编译器在调用点直接展开函数体。

语义

def fast_add(a: t.CInt, b: t.CInt) -> t.CInt | t.CInline:
    return a + b

编译行为:

  1. 阶段二预收集:_collect_inline_symbols 扫描 includes/ 目录中被引用的模块,收集所有 t.CInline 函数的 AST body
  2. 翻译时展开:遇到内联函数调用时,将函数体的 AST 直接嵌入调用点,替换参数为实际值
  3. 不生成独立函数:内联函数不生成 LLVM IR 函数定义(除非也被非内联调用)

t.CInline 与 t.CDefine 函数的区别

特性 t.CInline t.CDefine 函数
展开时机 阶段二翻译时 阶段二翻译时
类型检查 完整的参数和返回类型检查 返回类型为宏,类型检查较弱
存根表示 签名 + c.State 完整函数体
LLVM IR 可能生成函数定义(如果有非内联调用) 不生成函数定义
适用场景 性能关键的短函数 编译时常量和宏计算

目标平台

默认目标三元组:x86_64-none-elf

数据布局:e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128

Hello World 示例

import t, c

def main() -> t.CInt | t.CExport:
    print("Hello, ViperOS!\n")
    return 0

编译流程:

  1. 阶段一:计算 main.vp 的 SHA1,生成 .vpi 存根(def main() -> t.CInt | t.CExport | c.State: pass),生成 .stub.ll 声明(declare i32 @main(),因为是 CExport 不加前缀)
  2. 阶段二:翻译 main.vp 为 <SHA1>.ll,嵌入 print 对应的 puts/printf 声明,生成 define i32 @main() 函数体
  3. 编译:llc 将 .ll 编译为 .o
  4. 链接:ld.lld 链接为可执行文件