新消息:Helius 收购 Light Protocol
什么是 Solana 虚拟机(SVM)?
博客/基础知识

什么是 Solana 虚拟机(SVM)?

Developer Experience EngineerX 上的 0xIchigoLinkedIn 上的 0xIchigoGitHub 上的 0xIchigo
阅读需 67 分钟

非常感谢 Lostin、Alessandro、Brian、Brady 和 Daniel Cumming 审阅本文的早期版本。 

可付诸实践的要点

  • SVM 涵盖整个交易执行栈,不同于含义明确指向字节码执行器的 EVM。
  • 要求交易在执行前声明将访问哪些账户,由此实现跨 CPU 核心的并行执行和本地化费用市场。
  • Rust 源码由 rustc 编译为 LLVM IR,再经 LLVM 的 eBPF 后端(具体来说是 Solana 的分支 sBPF)降级为 sBPF 字节码。这意味着任何拥有 LLVM 前端的语言(例如 C、C++、Zig)都可以用于编写 Solana 程序。
  • sBPF 是 Solana 基于 Linux eBPF 创建的分支。实际上,两者基本相同,只是 sBPF 在历史上引入了一些扩展,不过这些扩展将被回滚。唯一有实际意义的区别是,上游 eBPF 函数最多只能有 5 个参数,而 Solana 的分支可以有更多参数。
  • 编译后的 sBPF 字节码存储在 ELF 文件中,其中包含指令与常量区段以及重定位表。链接器会将 syscall 引用解析为确定性的 32 位 Murmur3 哈希,并将内部函数重写为相对跳转,以实现可移植性。 
  • BPF Loader Upgradeable 使用双账户模型进行原地升级和部署,而即将推出的 Loader V4 将其简化为支持可选压缩的单账户模型。所有字节码都必须经过静态验证,之后才能标记为可执行。
  • 交易包含一个带读写权限的账户地址数组,以及指令和签名。这种结构化格式能够检测冲突,并对互不冲突的交易进行并行调度。
  • TPU 的 Banking Stage 负责并行调度互不冲突的交易。Bank 从 AccountsDB 加载账户状态。BPF Loaders 会配置具有有限内存区域和计算预算的隔离 sBPF VM。成功的状态变更会以原子方式提交,失败时则会完全回滚。
  • SVM ISA 是唯一的正式规范——除了 Alpenglow 白皮书和 Toly 最初撰写的一系列文章外,并不存在一份统一的“SVM 规范”。运行时由 Bank、调度器、BPF Loaders 和 sBPF VM 本身共同作用而成。

简介

Solana 虚拟机(SVM)是当今区块链领域最常被误解的系统之一。Ethereum 虚拟机(EVM)明确指向操作码执行器,而 SVM 一词涵盖整个交易执行流水线,从 Banking Stage 调度器一直到 sBPF 字节码解释器本身。这种歧义反映了 Solana 在架构上的不同:并没有一份传统规范单独定义“SVM”。最接近的规范是 Solana 虚拟机指令集架构(SVM ISA),它描述了 sBPF 字节码应当如何执行,却没有涉及更广泛的运行时。

本文旨在以 Anza 的 Agave 验证者实现为视角,全面阐释 SVM 是什么、如何工作,以及它为何存在根本差异。Firedancer 客户端如何运作,以及其遵循 SVM ISA 的自定义虚拟机实现,不在本文讨论范围内。

我们关注的是实际代码库,而非某种抽象规范。我们将追踪完整的执行流水线——Rust 源码如何经 LLVM 编译为 sBPF 字节码、程序如何部署和验证、运行时如何为并行执行配置隔离的执行环境,以及交易如何与已部署的字节码交互。

开头几个章节将解释 SVM 为何存在定义上的歧义,并概述其工作原理。本文其余内容面向希望深入理解 Solana 执行层的技术读者。

一个有争议的定义

“Solana 虚拟机”(SVM)一词在社区内引发了激烈争论,尤其是在网络扩展和构建于 Solana 之上的其他层级区块链出现之后。争议源于这个术语的范围:SVM 是否仅指底层 sBPF 解释器,还是涵盖完整的交易执行栈? 

狭义观点认为,SVM 类似于传统虚拟机(VM),例如 EVM 的操作码执行器。更具体地说,它是解释字节码并进行 JIT 编译、源自 eBPF 的虚拟机(原为 rBPF,现为 sBPF)。这种观点强调 SVM 是一个基于寄存器的沙盒执行器,负责处理 ALU 运算或 Solana 特有的系统调用等指令。本质上,SVM 借鉴了 Linux eBPF 的安全模型,但针对区块链基础设施进行了定制。这与验证者代码中的 SVM ISA(指令集架构)等表述一致,其中 SVM 仅指 VM 层。

广义观点将 SVM 定义为 Solana 验证者的整个交易执行层。它不仅包括运行字节码,还涵盖上游组件,例如 Banking Stage 调度器、计算单元预算,以及通过账户数据库(通常称为 AccountsDB)完成的状态更新。这个“运行时”会把原始交易转化为经过验证的状态变更。

之所以会产生歧义,是因为 Solana 官方表述中交替使用“运行时”和“SVM”,却没有一个统一的固定定义。Anza 为这场争论带来了亟需的明确解释,不仅明确认可了这种歧义,还提出了立足工程、务实且注重行动的视角。他们将 SVM 描述为由 Bank 驱动、负责配置 eBPF VM 的运行时。这种定义更为宽泛,涵盖整个流水线,可用于形成恰当的 SVM 定义。

Anza 的官方 SVM 规范将其正式定义为“负责执行交易的组件”,并将其打包成供验证者、欺诈证明、sidecar 等场景使用的独立库。

在本文中,我们可以将 Solana 虚拟机定义为:

Solana 验证者内部由 Bank 组件驱动的解耦运行时接口和交易处理流水线。它负责协调指令与链上程序的并行执行,并配置基于 eBPF 定制的虚拟机,以安全地解释字节码、进行 JIT 编译和计量资源。

SVM 概览

Solana 虚拟机(SVM)是一个执行环境,用于处理在整个网络中与链上程序交互的交易。它是代码与状态交汇的运行时层——将经过加密签名的交易转化为已验证状态变更的执行环境。 

要真正理解 SVM,首先必须理解虚拟机在区块链语境中的含义。

虚拟机

虚拟机(VM)是一种将计算机系统虚拟化或模拟出来的软件,可提供行为类似物理硬件的隔离执行环境。这一概念源于 IBM 在 20 世纪 60 年代对大型主机系统的研究,使多个用户能够在同一台物理机器上运行不同的操作系统。VM 主要分为两类:系统 VM 和进程 VM。前者用于替代真实机器,后者则用于在与平台无关的环境中执行程序。本文关注的是系统虚拟机,下文将其称为“虚拟机”或简称“VM”。

虚拟机解决了几个基础问题。首先,它们提供了一层硬件抽象。也就是说,为某个 VM 编写的程序可以在任何支持该 VM 的物理硬件上运行,无需重写程序。Java“一次编写,随处运行”的理念就是典型例子:只要安装了 Java 虚拟机(JVM),Java 字节码就能在 Windows、macOS、Linux 等系统上以相同方式运行。

VM 还提供隔离与安全保障。每个 VM 实例都在沙盒中运行,也就是说,除非获得明确许可,否则无法访问宿主系统资源或其他 VM。因此,即使程序崩溃或包含恶意代码,损害也会被限制在该 VM 实例内。正是由于这种隔离原则,Google Cloud 和 AWS 等云服务商才使用 VM 来分隔客户工作负载。

VM 还能提供可预测的输出。也就是说,无论底层硬件是什么,在 VM 提供的受控环境中,相同输入始终会产生相同输出。这种可预测性对于调试、测试以及让分布式系统达成共识至关重要。

VM 还可以拥有极高的性能。现代 VM 使用即时(JIT)编译来尽量降低性能开销。JIT 编译会在运行时将 VM 字节码转换为原生机器码,在保持可移植性和上述安全保障的同时,实现接近原生代码的性能。

区块链中的虚拟机

区块链采用 VM 概念,是为了解决一个独特挑战:全球数千台相互独立的计算机如何执行不受信任的代码,并得出完全相同的结果?VM 充当确定性的运行时环境,负责执行智能合约(即 Solana 上的程序),并管理网络状态(即全网所有账户、余额及其他数据的当前状态)。

当一笔交易提交到区块链时,VM 负责:

  • 从存储中加载必要的账户数据。
  • 执行交易中指定的程序字节码。
  • 计量资源消耗,防止无限循环或拒绝服务(DoS)攻击。
  • 验证所有状态变更是否遵循网络预先定义的共识规则。
  • 将更新后的状态提交回永久存储(即账本)。

状态转换的具体规则由 VM 的指令集架构和运行时约束定义。

SVM 如何工作

SVM 是由多个子系统组成的流水线,这些子系统相互协作,以安全、高效地执行交易。Bank 负责协调特定 slot 的执行,管理账户状态、执行共识规则,并协调 Banking Stage 与持久化存储(即 AccountsDB)。每个 Bank 都代表特定 slot 上所有账户的状态,并依次经历三种生命周期:活跃(即接受新交易)、冻结(即 slot 已结束,不再接受新交易)和已生根(即已成为规范链的一部分)。

Banking Stage 是验证者的交易处理单元(TPU)内实际执行交易的位置。它接收来自 SigVerify 阶段的已验证交易,将其缓冲,并通过账户锁冲突检测来调度并行执行。Banking Stage 中的工作线程会处理互不冲突的交易批次,调用 Bank 的执行方法来加载账户、为每条指令配置 sBPF VM 实例、执行程序字节码并收集结果。Banking Stage 会持续处理互不冲突的交易批次,直到 Bank 在 slot 边界被冻结。需要注意的是,批次不同于条目;条目是写入账本、用于复制和共识的交易记录单元。

BPF Loaders 管理程序的整个生命周期:部署、JIT 编译、升级和执行。当一条指令指向某个程序时,系统会为其配置一个拥有独立内存区域和计算预算的 sBPF VM,然后将执行权交给该程序的字节码。

sBPF VM 是程序字节码实际运行的沙盒执行环境。它源自 Linux eBPF,采用基于寄存器的架构,拥有 11 个通用寄存器。VM 通过五个不同的内存区域实现内存隔离,每个区域都有明确的边界和权限。VM 还会计量计算单元消耗,防止执行失控,并为加密、日志记录或跨程序调用(CPI)等特权操作分派系统调用。

AccountsDB 是存放所有账户数据的持久状态层。执行前会加载账户状态,并利用缓存避免频繁访问的账户被重复从磁盘读取。执行成功后,更新会提交回 AccountsDB。如果执行失败,所有状态变更都会以原子方式回滚。

这些组件共同构成了 SVM——一个解耦且可复用的执行引擎。

SVM 的独特之处:预先声明账户

SVM 最关键的架构决策是:所有交易都必须在执行开始前明确声明它们将读取和写入哪些账户。这项写入交易格式本身的简单要求,带来了两项令 Solana 脱颖而出的变革性能力:并行执行和本地化费用市场。

并行执行(Sealevel)

Ethereum 虚拟机(EVM)会按顺序逐笔处理交易,必须等一笔交易完成后才能处理下一笔;与之不同,SVM 可以同时在多个 CPU 核心上执行多笔交易,从而实现横向扩展。之所以能够并行,是因为所有 Solana 交易都必须在执行之前明确声明将读取和写入哪些账户。 

声明交易将读取和写入哪些账户后,运行时便可以分析账户依赖关系、检测冲突,并调度互不冲突的交易:

  • 访问完全不同账户的交易可以并行运行,不产生任何协调开销。
  • 只从相同账户读取数据的交易也可以并行运行,因为读取操作不会冲突。
  • 试图写入相同账户的交易会按顺序运行,以避免竞态条件并确保状态一致性。 

本地费用市场

由于运行时在执行前就准确知道每笔交易将访问哪些账户,因此费用竞争可以局限于特定账户,而不必在整个网络中进行全局竞争。这个概念称为本地费用市场。 

在 Ethereum 和其他 EVM 链上,每笔交易都在同一个全局费用市场中竞争——无论是向朋友发送 ETH、铸造 NFT,还是在 Uniswap 上交易,都会争夺相同的区块空间。某个领域的活动激增会推高所有人的费用,即使他们要做的事情完全不同。 

在 Solana 上,只有访问相同账户的交易才会相互竞争。用户在两个账户间转移 SOL 时,不应因为同时发生热门 NFT 铸造活动而受到影响。一笔交易的优先费只取决于账户竞争情况。正是这种本地化机制,让 Solana 交易即使在活动高峰期也能保持低成本。

例如,10 月 10 日,加密货币市场经历了史上规模最大的清算事件。尽管活动量创下纪录,Solana 交易仍然相对便宜:交易费中位数为 $0.007,平均费用一度达到 $0.10,而费用最高的 1% 交易峰值也仅略高于 $1.00。同期,Ethereum 和 Arbitrum 的费用中位数均飙升至 $100 以上,而 Base 的费用峰值超过 $3。

范式转变

方面EVMSVM
架构基于栈的 VM基于寄存器的 VM(源自 eBPF)
执行顺序执行并行执行(冲突检测)
费用市场全局本地化(按账户竞争情况)
账户声明无需预先声明必须预先声明
ISA~140 个操作码、栈操作~100 个操作码、类 RISC 寄存器
JIT 编译可选(取决于客户端)标准功能(原生级性能)
状态模型合约存储费用扁平账户数据库
语言Solidity/Vyper → EVM 字节码Rust/C/C++ → LLVM → sBPF

SVM 代表了一种截然不同的区块链执行方式。Bitcoin 引入了可编程货币。Ethereum 引入了通用智能合约和任意链上执行。然而,两者都受限于顺序执行和全局费用市场——这些架构决策从根本上限制了吞吐量和成本。

SVM 摆脱了传统约束,在不牺牲可编程性、也不迫使用户参与高昂费用竞价的前提下,让网络能够处理高吞吐量。要求预先声明账户的决定简单却有力,因为它既能跨 CPU 核心并行执行,也能将费用竞争局限在账户级市场。

当然,与其他区块链相比,这些并非 Solana 提供的全部优化。Solana 奉行提高带宽,降低延迟的理念,并执着于实现互联网资本市场的愿景,由此催生了多种性能优化、设计选择和实现,共同打造高吞吐量网络。

本文接下来将具体探讨这一切如何运作——Rust 源码如何编译为字节码、字节码如何部署和验证,以及运行时如何配置隔离的执行环境,在维持严格确定性和安全保障的同时,安全地并行运行数千个程序。 

从 Rust 源码到 sBPF 字节码:编译流水线

Rust

Rust 是 Solana 程序开发的通用语言。Anchor 等框架提供了一套强大且主张明确的方法,让开发者能够高效构建安全程序。solana_program 旨在成为所有链上程序的基础库。最近,高度优化且零依赖的库 Pinocchio 已成为开发者构建原生 Solana 程序时的首选。 

无论使用何种框架或库,所有程序都有一个入口点,程序被调用时,运行时会调用该入口点。solana_program 的宏 entrypoint 会生成程序开始执行所需的标准样板代码,包括反序列化输入、设置全局分配器和 panic 处理程序。Pinocchio 导出的入口点宏功能类似,但将入口点与堆分配器和 panic 处理程序的设置解耦,为开发者提供更多选择。 

程序的基本结构 

程序是一种能够运行代码的账户。更具体地说,程序是一个可执行账户,它拥有唯一公钥,并在由 BPF Loader 所有的账户中存储一段 sBPF 字节码。程序在设计上是无状态的:所有持久数据都存放在单独的账户中,程序被调用时可以读取或写入这些账户。

SVM 要求所有程序都具备特定框架——一个接受三项输入的入口点:

  • 程序 ID:程序自身的地址,用于自引用检查(例如所有权检查)。
  • 账户:账户元数据数组(即公钥、lamport 余额、数据缓冲区、所有者和标志)。这些是程序需要读取和写入的“状态”。
  • 指令数据:来自交易的任意数据字节切片。

程序应通过入口点处理这些输入,修改相关的可写账户,发出日志或事件,然后返回成功状态,表明是否顺利完成所有操作。归根结底,这就是一个 process_instruction 函数。

使用 solana_program crate 编写的简单 Rust 程序如下:

simple_rust_program.rs
use solana_program::{
    account_info::AccountInfo,
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    pubkey::Pubkey,
};

entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Pubkey,
    _accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    msg!("Hello, Solana!");

    Ok(())
}

在底层,这一切都由 SVM 的应用二进制接口(ABI)定义,我们稍后会详细介绍。

Rust 编译器与 LLVM IR

与许多其他编程语言一样,Rust 是建立在汇编语言之上的二阶抽象。它让人类能够编写安全、并发且易读的代码,而不必精细管理每一个微小的硬件交互。然而,计算机不懂 Rust,也不懂任何其他高级语言。 

计算机理解的是机器码——针对特定架构或虚拟机定制的二进制指令。所有程序最终都会转换为二进制代码,而这个翻译过程最终也是由计算机完成的。编译是一个多步骤的转换过程,它会去除高级抽象、进行效率优化,并输出可执行字节码。

rustc 是 Rust 的官方编译器。大多数开发者通常不会直接与 rustc 交互,而是通过 Rust 的包管理器 Cargo 调用它。尽管如此,rustc 在输出可执行字节码前,会让 Rust 源码经历三个主要阶段。每个阶段都会进一步去除抽象、执行安全规则,并为下一步转换做好准备:

  • 解析与展开
  • MIR(中级中间表示)
  • LLVM IR(低级虚拟机中间表示)

解析与展开

编译器会将扩展名为 .rs 的 Rust 文件作为纯文本读取。它会在词法分析过程中查找特定词法单元(例如 use、fn、None、impl、&[u8])。词法标记化是将文本转换为有意义的词法单元的过程,这些词法单元分属不同类别(例如标识符、运算符、分隔符、字面量、关键字)。 

rustc 获取这些词法单元,并将它们转换为一种称为抽象语法树(AST)的数据结构。这种树状结构表示 Rust 源码的嵌套层次关系。也就是说,函数包含代码块,代码块包含表达式,表达式包含运算符,依此类推。虽然仍处于较高抽象层级,但 AST 能够忠实表示源码及其底层逻辑。

构建 AST 后,编译器会执行几项关键转换:

  • 宏展开:entrypoint! 和 println! 等宏会展开为原始 Rust 代码,使所有宏都转换为普通 AST 节点。
  • 降级:为提高代码可读性而设计的高级简写语法会被重写为更基础的形式,这个过程称为降级。例如,for 循环会转换为带手动迭代的 loop。降级的结果是高级中间表示(HIR)。
  • 借用检查与安全分析:Rust 会获取 HIR 并执行类型检查、trait 解析和类型推断。此过程的结果是类型化高级中间表示(THIR)。

在此阶段,编译器会处理 unsafe 代码。unsafe 代码允许开发者执行绕过 Rust 安全保障的操作(例如解引用裸指针、调用外部函数、实现 unsafe trait)。它有意为底层控制提供一个逃生舱口,在放宽特定规则的同时,仍要求代码符合 Rust 语义并成功编译。这对 Solana 程序至关重要:在零拷贝账户反序列化等性能关键操作中,可能会谨慎使用 unsafe 代码来提升性能(例如 Pinocchio 针对 Account 的包装结构体)。    

词法分析完成后,构建 AST 时会首先识别 unsafe 代码,因为 unsafe 词法单元会被识别并标记为特殊节点。随后,在 AST 展开后的类型检查和借用检查阶段处理这些代码。编译器会确保 unsafe 操作仅出现在 unsafe 上下文中,否则就会报错(例如“无法在 unsafe 外部解引用裸指针”)。但编译器不会检查 unsafe 代码是否会破坏内存或对内存进行错误管理。

此阶段结束时,展开后的 AST 已成为 THIR,即经过验证和降级的 Rust 源码表示。

MIR

随后,THIR 会降级为中级中间表示(MIR)。这是一种以 Rust 为中心的形式,会将源码表示为简化的控制流图(CFG)。所有 Rust 特有的语法糖和复杂结构(例如模式匹配、trait、闭包)都会用基本块表示,其中包括赋值和分支。这些基本块通过跳转连接,更具体地说,是通过 Goto 和分支连接,让程序流程易于分析。 

MIR 并非绝对必要。编译器完全可以直接将 THIR 降级为 LLVM IR。然而,MIR 提供了一个理解 Rust 语义的中间层,使编译器能在应用 LLVM 的通用优化前,执行 Rust 特有的规则和优化。因此,MIR 非常适合进行对 LLVM 而言层级过高、对 THIR 而言层级又过低的检查和转换,包括:

  • 借用检查:初始语义检查发生在对 THIR 的类型分析阶段。不过,MIR 简化后的 CFG 可以进行完整、精确的借用检查,强制执行所有所有权、借用和生命周期规则。
  • 移动与释放检查:编译器确保所有值都按照 Rust 的内存安全保障进行移动和释放,防止释放后使用错误。
  • 初始化分析:编译器确保所有变量都在使用前完成初始化。
  • 内联与早期优化:编译器可以内联小型函数、简化算术表达式并消除不可达代码。

例如,我们之前在示例 Rust 程序中调用了 msg!(“Hello, Solana!”)。这是 solana-program crate 中定义的一个宏。对于静态字符串这样的单一表达式,它会在宏展开阶段展开为直接调用 sol_log($msg),其中 $msg 是该表达式。sol_log syscall 接收指向字符串数据的指针及其长度,并将其记录到 SVM 的输出中,无需承担格式化开销。在 MIR 中,它可能被简化为:

代码
bb0: {
  _0 = const "Hello, Solana!"; // Constant string allocation
  _1 = len(_0); // Compute length
  sol_log(move _0, move _1); // Syscall invocation with explicit moves for ownership
  return = Ok(());
}

其中:

  • _0 = const “Hello, Solana!”;——MIR 会为中间值引入临时变量(即 _0)。该字符串被视为常量切片,分配在只读数据中。
  • _1 = len(_0);——MIR 会公开一个针对切片的简单长度操作,以便进行潜在的常量折叠。
  • sol_log(move _0, move _1);——这是 syscall 调用,它会转换为一组 sBPF 指令,用于加载寄存器并调用 syscall ID。后续章节会详细解释其含义,但这里需要注意的是,这些移动操作与 Rust 的所有权语义相连,并在编译时强制执行这些语义。
  • Return = Ok(());——用终止符结束该块,向 SVM 表示执行成功。

MIR 专注于 Rust 语义,因此非常适合发现低效之处和调试 CU 消耗较高的模式。例如,如果日志包含动态字符串,MIR 可能会识别出可以优化的额外分配或循环。开发者可以使用命令 cargo rustc -- -Z dump-mir=all 导出 MIR。

MIR 确保代码语义正确、经过优化,并在最终降级为 LLVM IR 前去除所有 Rust 特有的规则。

请注意,这一过程通常称为代码生成阶段,不一定使用 LLVM。不过,LLVM 最为常见,也是大多数人谈到 Rust 代码生成时首先想到的方案。Rust 编译器还附带 GCC 和 Cranelift 后端,分别输出 GIMPLE 和 CLIF。本文在 Solana 语境下重点讨论 LLVM IR,但需要注意,一般而言 Rust 并非总是使用 LLVM IR。

LLVM IR

LLVM 原意为“低级虚拟机”,现在指一种模块化编译器框架。LLVM 不是单一编译器,而是一套用于构建编译器、优化器和代码生成器的可复用组件工具包。许多语言(例如 Rust、C、C++、Julia、Swift、Brainfuck、Zig)都使用 LLVM,借助它支持从 x86 CPU 到虚拟 ISA 等不同目标架构的能力。 

rustc 将 MIR 转换为 LLVM IR(低级虚拟机中间表示),它是连接 Rust 语义与最终部署到 Solana 上的字节码之间的桥梁。它更接近机器码,包含明确的内存分配(即 alloca)、存储、加载和函数调用。它不再具有所有权、生命周期或 trait 的概念,因为这些 Rust 抽象已经展开并不复存在——之前阶段提供的保障则会继续保留。

此阶段会应用多种优化,包括:

  • 常量折叠(即在编译时计算常量)。
  • 内联(即用函数体替换调用)。
  • 死代码消除(即删除不影响结果的指令)。
  • 循环展开与向量化(即重写循环以加快执行速度)。

因此,LLVM 为我们提供:

  • LLVM IR:一种可移植、类似汇编的中间格式。
  • 优化遍次:为创建 LLVM IR,LLVM 使用静态单赋值(SSA)形式,确保每个变量仅赋值一次,从而支持内联和消除死代码等优化。
  • 代码生成器:将 LLVM IR 降级为实际机器码的目标后端(即 x86_64、ARM、WebAssembly、eBPF)。

Rust 程序通常会针对 x86_64 或 ARM 等硬件目标进行编译。然而,Solana 程序并不直接在硬件上运行,而是在 Solana 虚拟机内运行。因此,LLVM 后端会将 LLVM IR 降级为 BPF 字节码;在 Solana 上,它会成为 sBPF 字节码(即 eBPF 的一个分支,移除了非确定性功能,并引入 Solana 特有的 syscall)。

需要注意的是,虽然 Rust 是 Solana 程序开发的通用语言,但任何能够以 LLVM BPF 后端为目标的语言(例如 C、Nim、Swift、Zig)都可以使用。

eBPF

LLVM IR 会降级为 eBPF,即构成 Solana 运行时基础的寄存器型 ISA。eBPF(扩展伯克利包过滤器)源自伯克利包过滤器(BPF)。BPF 由 Steven McCanne 和 Van Jacobson 于 1992 年在劳伦斯伯克利实验室为 Berkeley Software Distribution(BSD)Unix 系统开发。本质上,BPF 是一种网络分流与数据包过滤器,可以借助限定符,在操作系统层面捕获和过滤网络数据包,而无需复制数据。 

此后,eBPF 不断演进(即不断扩展),成为 Linux 内核中的通用沙盒 VM。它带来的突破类似于 JavaScript 对 Web 开发的推动——它是面向内核的安全脚本引擎。eBPF 允许开发者使用受限的指令集,直接在 Linux 内核中运行小型、经过验证的程序,以完成性能监控、可观测性、安全和网络等任务。

这一点很重要,因为开发者可以获得:

  • 沙盒执行:eBPF 程序在内核内的受限虚拟机中运行,因此无法导致内核崩溃或破坏内核内存。
  • 安全保障:eBPF 字节码在加载前会经过静态验证,确保不会发生无效内存访问、越界跳转或其他特权操作,在不增加运行时开销的情况下提供安全性。
  • 高效:eBPF 基于寄存器(而非像 EVM 那样基于栈),并且可以通过 JIT 编译为机器码。由于其轻量化设计(即没有完整操作系统的开销),性能可以接近原生代码。
  • 灵活:eBPF 提供系统调用,也称为 syscall,本质上是接入内核功能的钩子。syscall 可以扩展新能力,无需重新设计指令集。

Solana 需要一种确定、安全且高性能的 VM,以便在整个验证者集合中运行不受信任的程序。eBPF 提供了经过验证的安全模型、为运行数千个轻量程序而设计的可移植高效 ISA,以及提高性能所需的 JIT 支持。因此,Solana 没有发明全新的 VM,而是基于 eBPF 创建了 sBPF 分支。

sBPF 

最初,Solana Labs 基于 Quentin Monnet 的 rBPF 创建了 Solana 版本的 rBPF,确保每个验证者都拥有一种字节码格式,可以在执行给定程序和输入时产生完全相同的结果。 

当时认为,Solana 需要一个 eBPF 分支,因为其共识机制要求确定性执行和有界资源使用。虽然 eBPF 本身具有确定性,但 Solana 还需要额外保障和区块链特有的功能:

  • 固定的指令执行时间和成本。
  • 用户空间执行。
  • 确定性运行时。

值得注意的是,它被设计为在用户空间而非内核中运行,因此无需内核权限或修改内核。这样便可以在多种操作系统环境中部署,无需 root 访问权限或自定义内核模块。对 Solana 而言,用户空间是一个务实选择,能够提升可移植性、测试便利性并简化部署。尽管在用户空间运行,JIT 仍可实现接近原生代码的性能。此外,用户空间执行还允许在没有内核访问权限的情况下进行测试和模糊测试。

rBPF 已不再使用。Anza 成立后,团队基于 rBPF 创建了 sBPF(Solana Berkeley Packet Filter)分支。由 Solana Labs 拥有的 rBPF GitHub 仓库已于 2025 年 1 月 10 日归档。

SVM ISA

SVM ISA(Solana 虚拟机指令集架构)是核心规范,定义了兼容 Solana 的 VM(例如 Agave 的 sBPF 或 Firedancer 的重新实现)应如何执行程序。它不是 VM 本身,而是一项标准或契约,用于确保不同 SVM 实现的一致性和协议合规性。正是 SVM ISA 对 eBPF 施加了这些安全性和确定性约束,在移除以内核为中心的功能的同时,增加区块链特有的功能。

ISA 规定了寄存器、指令编码、操作码、类别、验证规则、panic 条件和应用二进制接口(ABI)。对 SVM ISA 的任何更改都必须通过 SIMD 实施,以支持指令集的受控演进,并确保所有验证者执行结果确定一致。

寄存器

寄存器是 VM 内部的微型存储槽,在指令运行时保存数字或地址,类似于工作台上的变量或带标签的盒子。SVM ISA 定义了一种 64 位寄存器架构,包含 11 个通用寄存器(R0-R10)和一个隐藏程序计数器。用于存储整数和地址的寄存器宽度为 64 位,因此能够高效处理较大的值或指针。R0 保存函数返回值;R1-R5 像参数一样传递前五个函数参数;R6-R9 由被调用方保存,并在函数调用间保持不变;R10 是只读帧指针,用于标记当前栈帧。隐藏程序计数器用于跟踪执行进度,指明接下来要执行哪条指令。

指令

指令是 VM 能够执行的单项操作,例如“将这两个数字相加”或“跳转到这行代码”。它采用类似 RISC 的设计,约有 100 个操作码;相比之下,x86 等 CISC 架构拥有数千个操作码。较少的操作码使验证更快,JIT 编译也更高效。

指令以小端格式编码为 64 位值,其结构如下:

  • opcode:8 位
  • dst_reg:4 位
  • src_reg:4 位
  • offset:16 位(有符号)
  • immediate:32 位(有符号)

opcode 表示要执行什么操作,dst_reg 表示结果写入何处,src_reg 表示输入来自何处,offset 表示要查看哪个内存偏移,而 immediate 是可以包含在指令中的额外常量。

lddw 即加载双字,是唯一占用两个 64 位槽位的宽指令,用于支持完整的 64 位立即数值。 

指令被分为多个类别,包括内存操作、算术或逻辑操作、条件与无条件分支、函数调用与返回,以及字节序转换。

内存区域

ISA 定义了五个内存区域,每个区域都有明确边界(即 [addr, addr+len]),规定程序可以从何处读取或向何处写入:

  • 程序代码:编译后的指令本身(读取 + 执行)。
  • 栈:供函数使用的临时工作区(读取 + 写入,通常每个栈帧为 4KB)。
  • 堆:程序可以申请的动态内存(读取 + 写入)。
  • 输入数据:随交易传入的只读字节。
  • 只读数据:常量和不可变值。

程序具有预先定义的虚拟内存映射:根据编译版本,程序代码从地址 0x000000000 或 0x100000000 开始;栈帧从 0x200000000 开始;堆从 0x300000000 开始;输入数据从 0x400000000 开始。

验证器

验证器会在执行前进行静态分析——无需运行程序便检查每一条可能的代码路径——从而在加载时而非运行时确保安全性。这包括检查:

  • 不存在未知或不受支持的指令。
  • 所有跳转目标都落在有效的指令边界上,并正确处理向后跳转
  • 不存在不可达代码路径。
  • 强制执行函数调用深度限制。
  • 静态拒绝除数或模数为零的操作。
  • 程序必须符合最大大小限制。

验证器虽然有用,却不能阻止开发者引入意外行为。也就是说,开发者仍有可能在 Solana 程序中引入释放后使用和缓冲区溢出等错误。 

Panic 条件

Panic 条件是 SVM ISA 定义的运行时错误情形列表,包括:

  • 无效或不受支持的指令。
  • 除数或模数为零。
  • 越界内存访问。
  • 对不同内存区域进行无效内存访问(即违反权限)。
  • 栈溢出。
  • 超出调用深度。
  • 超出允许的最大指令数量。
  • 程序返回错误代码。

ABI

应用二进制接口(ABI)是 Solana 程序与 SVM 之间的格式契约。前面的程序的基本结构一节展示了它在 Rust 中如何工作(即具有三个输入的 process_instruction),而 ABI 则规定这些输入和输出如何在内存中表示,确保每个验证者都能以确定性方式执行程序。

从较高层面看,ABI 定义了三项内容:入口点约定、调用约定与寄存器,以及内存布局。

每个 Solana 程序都必须公开一个入口点函数。loader 会按规范顺序将程序输入序列化到 VM 的内存空间中:程序 ID、账户数组和指令数据。然后,VM 会在程序入口点中传递指向这些区域的指针。

前五个寄存器(即 R1-R5)保留用于入口点参数,而返回寄存器(即 R0)保存程序退出代码。退出代码为零表示成功,非零值表示失败,并映射到特定的 InstructionError。这可确保所有程序都以一致方式返回状态码。此外,前五个参数之外的其他参数通过栈传递。R6-R9 遵循被调用方保存约定,也就是说,函数使用这些值时必须保留它们。

账户和数据会作为字节切片序列化到 VM 的线性内存中。程序必须将其反序列化为更高层级的 Rust 类型(例如 AccountInfo、Pubkey)。ABI 会强制执行严格的边界限制,确保程序无法访问已分配区域之外的内存。

这些规则共同让 ABI 成为连接高层开发者体验与底层 ISA 的“黏合剂”。它确保简单的 Rust 函数签名能够编译为正确的寄存器用法、内存布局和返回码,让每个验证者每次都以完全相同的方式解释给定程序。

系统调用

ISA 有意保持精简,没有内置账户或状态。它也不直接提供日志记录、哈希或跨程序调用等更高层功能,而是公开系统调用——这些是 VM 内置的特殊函数,允许程序与外部世界交互。这些调用通常称为 syscalls。

可以将系统调用视为 VM 提供的 API。程序无需各自重新实现特定的加密原语或账户逻辑,系统调用会公开安全、标准化的操作,并保证其在所有验证者上都具有确定性行为。

常见的系统调用类别包括:

  • 日志记录与调试(例如,sol_log 系统调用会向程序日志写入 UTF-8 字符串,msg! 在底层使用它)。
  • 跨程序调用(CPI)(例如,sol_invoke_signed 允许程序调用另一个链上程序,并传入账户和指令数据,这对 Solana 的可组合性至关重要)。
  • 密码学(例如,sol_sha256、sol_keccak256 和 sol_ed25519_verify 系统调用都提供确定且快速的加密原语,无需开发者自行实现)。
  • 内存与账户工具(即提供账户数据借用、内存重新分配或程序自有堆分配辅助功能的系统调用)。
  • 计算预算与计量(即每个系统调用都会消耗 CU,并由运行时的计量系统强制执行)。

系统调用通过带有唯一哈希标识符的特殊 CALL_IMM 指令调用。当程序调用系统调用时,sBPF VM 会捕获执行,在系统调用注册表中查找该哈希,并分派给运行在特权运行时代码中的原生实现。系统调用在沙箱外执行,可以访问运行时状态,这与程序内普通函数调用的执行方式完全不同。

系统调用遵循与普通函数相同的 ABI:前五个参数通过寄存器 R1 到 R5 传递,返回值放在 R0 中。每个系统调用都有固定的计算单元成本,以确保资源消耗具有确定性。例如,每次调用 secp256k1_recover 系统调用都会消耗 25,000 CU。

系统调用构成了受控的安全边界,因为每个系统调用都会先验证输入并检查相关权限,再执行任何特权操作。例如,跨程序调用(CPI)系统调用会验证调用方是否拥有与传入账户相关的适当权限。

请注意,可以通过功能门控添加新的系统调用,而无需修改 ISA 本身。这使 Solana 能够扩展其 VM 能力,包括支持新的加密原语,同时保持与现有程序的向后兼容性。  

程序二进制文件

编译结束时,从 Rust 源代码到 LLVM IR,再到 eBPF、sBPF 以及对 SVM ISA 的遵循,所有阶段都会生成一个输出:程序二进制文件。这个二进制文件才是实际部署到 Solana 上的内容。 

ELF

Solana 程序会编译为可执行与可链接格式(ELF)文件,这是类 Unix 系统广泛使用的标准二进制格式。ELF 格式相当于一个容器,封装 VM 执行给定程序所需的一切,同时保持平台无关性。

ELF 文件通常包含以下区段:

  • 字节码区段:在 .text 区段中包含编译后的 sBPF 指令。
  • 只读数据区段:在 .rodata 区段中保存常量、静态字符串和不可变值。
  • BSS 与数据区段:分别在 .bss 和 .data 区段中包含全局或静态可变变量。请注意,Solana 不允许可变数据。也就是说,ELF 可以有 .rodata,但不能有 BSS 和数据区段。 
  • 符号表与重定位表:定义加载时如何解析函数调用、系统调用和内存引用;符号位于 .symtab 和 .strtab 区段,重定位条目位于 .rel.dyn 和 .rela.dyn 区段。

每个 ELF 文件还包含一个头部,用于描述架构、指令宽度(即 64 位)、字节序(即小端序)和入口点地址。

链接与重定位

将编译器输出转换为单个可执行 ELF 文件还需要最后一个组件:链接器。链接器负责将多个已编译代码单元组合成一个完整的二进制文件。它还会解析编译器遗留的所有符号引用(即占位符)。 例如:

  • 当程序调用 sol_log 等函数时,编译器不知道该函数在内存中的位置,因此会使用占位符。
  • 链接器会将该符号引用替换为系统调用的唯一哈希标识符(即确定性的 32 位 Murmur3 哈希)。
  • 同样,内部函数之间的调用会改写为指向 .text 区段内指令偏移量的相对跳转。

将符号引用改写为具体地址或哈希系统调用 ID 的过程称为重定位。不过需要注意,重定位很大程度上是早期工具构建方式的产物,而非基本要求。事实上,未来版本的工具链计划完全移除重定位,以简化部署流程。

这个重定位步骤可确保同一个 ELF 二进制文件在所有验证者上以完全相同的方式运行,因为其中没有嵌入绝对内存地址或系统特定符号。 

此外,已完成这些重定位的字节码会缓存在内存中,因此后续所有执行都直接使用更新后的字节码,无需再次处理重定位。

链接器生成完全重定位的 ELF 文件后,程序即可部署。最终得到的二进制文件具有以下特性:

  • 可移植:它可以在任何验证者或 SVM 实现上以相同方式运行。
  • 确定性:它不包含非确定性系统调用或操作系统依赖项。
  • 自包含:它携带执行所需的全部字节码和元数据。

如何将字节码上传到 Solana

Solana 程序编译并链接为有效的 ELF 文件后,下一步是将其上传到区块链,以便验证者执行。这个称为程序部署的过程涉及多个协同工作的组件:BPF Loader、账户模型、字节码验证和状态管理。

BPF Loader 程序

BPF Loader 是一个原生程序,负责验证和重定位 ELF 文件,并将其标记为可执行。本质上,它管理已部署程序的生命周期:处理初始化账户、写入字节码和部署程序的指令,并负责升级。

Solana 的加载器已经历多次迭代,每个版本都在前一版本基础上改进:

  • BPF Loader:最初用于静态、不可升级程序的加载器,现已不再受支持。 
  • BPF Loader V2:没有管理指令的简化加载器。
  • BPF Loader Upgradeable:当前使用的加载器,引入了程序可升级性。
  • BPF Loader V4:最新迭代,提供更好的部署功能,并将当前的双账户模型简化为单账户模型。

部署架构:账户模型

当前账户模型

当前加载器采用双账户架构,将程序逻辑与程序数据分离。因此,一个程序会有两个账户:Program 账户和 ProgramData 账户。

Program 账户是一个约 36 字节的小型账户,用于保存元数据并被标记为可执行。它通过 UpgradeableLoaderState::Program { programdata_address } 存储对 ProgramData 的引用。

ProgramData 账户较大,通过 UpgradeableLoaderState::ProgramData 存储实际的 ELF 字节码和部署元数据(例如槽位、升级权限地址)。

将两个账户分离可以实现原地升级。也就是说,Program 账户的地址保持不变,而 ProgramData 账户中的字节码可以替换。

未来账户模型

Loader V4 旨在通过单账户模型简化部署流程。其思路是让程序账户直接存储元数据和字节码,不再需要单独的 ProgramData 账户。它还允许开发者存储经过 zstd 压缩的镜像,以节省租金成本。

Solana 程序如何部署

部署过程包括上传已编译的 ELF 二进制文件,并由 BPF Loader 验证、缓存并将其标记为可执行。由于上一节介绍的部署架构不同,可升级加载器与 V4 的流程略有差异。

BPF Loader Upgradeable

当前使用可升级加载器的部署流程首先要初始化一个缓冲区账户,以暂存 ELF 字节码。部署者向 BPF Loader Upgradeable 发送一条 InitializeBuffer 指令。该指令会创建一个由加载器拥有的新账户,将账户状态设为 UpgradeableLoaderState::Buffer { authority_address },并记录有权写入缓冲区的地址。 

编译后的 ELF 二进制文件使用 Write { offset, bytes } 指令分块上传到缓冲区。每条写入指令都会验证签名者是否与缓冲区权限方一致,检查缓冲区是否仍可变(即尚未部署),并在元数据头之后的指定偏移量写入字节。请注意,由于交易大小限制,大型程序需要多条 Write 指令才能上传完整的 ELF 文件。

缓冲区包含完整 ELF 后,部署者会发送一条 DeployWithMaxDataLen { max_data_len } 指令。这是整个部署流程中最复杂的步骤,因为它编排了从账户验证到状态最终确定的实际部署过程。

加载器首先验证部署流程中的所有账户,并确认:

  • 程序账户尚未初始化且免租。
  • 缓冲区包含有效数据,且初始化缓冲区的权限方已签署交易。
  • max_data_len 足以容纳缓冲区数据。
  • 总大小不超过 MAX_PERMITTED_DATA_LENGTH(即 10MiB,也就是 10,485,760 字节)。 

随后,加载器创建 ProgramData 账户,并使用程序 ID 和加载器 ID 将其地址派生为 PDA。接着,它会将缓冲区中的 lamport 返还给付款方,因为部署完成后不再需要该缓冲区账户。 

此外,它会通过对 System Program 的 CPI 创建 ProgramData 账户,为元数据和 max_data_len 字节分配足够空间。随后,加载器使用 PDA 的 bump seed 为 CPI 签名。 

deploy_program! 宏用于确保字节码可安全执行。它首先解析 ELF 文件结构,验证 ELF 魔数(即 0x7f ‘E’ ‘L’ ‘F’)和头部(即 64 位、小端序),提取程序区段,处理重定位表,并验证区段边界和对齐方式。如果 ELF 格式错误或使用了不受支持的功能,加载会立即失败。

随后,RequisiteVerifier(即 sBPF 的验证器)会在不运行程序的情况下,对所有可能的执行路径进行静态分析,确保程序在执行任何指令前就能被证明是安全的。验证器还会强制执行前述 SVM ISA 约束。如果验证失败,部署会以 InstructionError::InvalidAccountData 被拒绝,程序也绝不会被标记为可执行。

验证通过后,字节码会被编译并缓存以供执行。load_program_from_bytes 函数会创建一个 ProgramCacheEntry,其中包含:

  • JIT 编译的可执行文件:sBPF 字节码会通过即时(JIT)编译转换为适用于验证者 CPU 架构的原生机器码。这既能提供接近原生的执行速度,又能保持安全性。
  • 槽位元数据:程序的部署时间和可见时间分别记录为 deployment_slot 和 effective_slot。这一延迟可防止程序在部署所在的同一槽位中被使用。
  • 运行时环境:对系统调用注册表的引用,用于说明哪些系统调用可用;以及程序执行时采用的执行配置。 

缓存条目存储在 program_cache_for_tx_batch 中,使程序可供后续交易执行。 程序成功验证并缓存后,加载器会更新账户状态以完成部署。ProgramData 账户的状态会更新,以记录程序的部署时间和有权升级程序的主体。ELF 字节码也会从缓冲区复制到账户中。Program 账户的状态也会更新,以链接至 ProgramData 账户,并被标记为可执行。最后,缓冲区的数据长度会设为元数据大小,从而实际清除字节码并回收空间。

程序现已完全部署,可以由交易调用。

BPF Loader V4

BPF Loader V4 不再需要单独的 ProgramData 账户,而是允许程序账户直接存储字节码,从而简化部署。它还支持存储经过 zstd 压缩的 ELF,可显著降低租金成本,并在加载时按需解压。

部署者调用 SetProgramLength { new_size },为程序的元数据和字节码分配空间。对于新程序,这会以 LoaderV4State::Retracted 状态初始化账户,记录权限方并将账户标记为可执行,但此时还不能调用。

随后,部署者通过 Write { offset, bytes } 指令将 ELF 二进制文件直接写入程序账户。只有当程序处于 Retracted 状态时才允许这些写入操作。还可以使用 Copy 指令从另一个程序复制字节码,无论加载器版本为何,这对迁移很有帮助。

接着使用 Deploy 指令将程序从 Retracted 状态转换为 Deployed 状态。本质上,这条指令会从程序账户的相应偏移量提取字节码,并运行与 BPF Loader Upgradeable 完全相同的验证流水线(即 ELF 解析、静态验证、JIT 编译和缓存)。如果验证成功,程序状态会更新为 LoaderV4Status::Deployed,并记录部署槽位。

程序现已完全部署,可以由交易调用。

Loader V4 还会在状态转换(即部署和撤回)之间强制执行冷却期,以防止重新部署攻击。程序在上次部署后的一个槽位内不能再次部署或撤回。这有助于防止恶意行为者快速更新程序以利用竞态条件或迷惑用户,从而确保每槽位原子性,而非引入跨多个槽位的延迟。请注意,此冷却期同时适用于 Deploy 和 Retract 指令。

程序还可以通过 Finalize 指令设为不可变。该指令会将程序从 Deployed 状态转换为 Finalized 状态,这意味着程序无法再撤回或升级。权限字段会改用于指向“下一版本”的程序地址,从而在保持程序不可变性的同时提供明确的升级路径。

SVM 中的执行机制

SVM 是验证者内部的交易处理引擎,负责执行程序调用并相应地更新状态。 

交易到达验证者后,会经过一个多阶段流水线:验证、账户加载、在隔离的 sBPF VM 中执行程序、不变量验证和状态提交。如果所有指令均成功,账户变更就会写入 AccountsDB。如果任何指令失败,整个交易都会以原子方式回滚。

SVM 作为解耦的执行引擎运行。也就是说,它不管理共识、网络或账本历史,而是专注于安全、确定且高效地执行程序。 

Bank 编排 SVM 的执行,提供运行时上下文(例如区块哈希、租金和功能集),并将结果提交到持久化存储。SVM 管理程序执行,从加载字节码到强制执行计算预算。这种关注点分离让 SVM 能够在验证者之外复用。

交易 

交易是 Solana乃至任何区块链的命脉——它们通过调用程序来实现状态变更。 

一笔交易是一组指令,用于说明应对哪些账户执行什么操作,以及这些操作是否具备必要权限。 

一条指令是针对单次程序调用的指示。它是执行逻辑的最小单位,也是 Solana 上最基本的操作单元。

程序会解释指令传入的数据,并对指定账户进行操作。一条指令包含程序 ID(即被调用的程序)、要读取和写入的账户列表,以及传给程序的输入。

交易始于用户定义目标,例如向另一个账户转账 10 SOL。该意图会转换为一条指令,要求 System Program 将 10 SOL 从账户 A 转到账户 B。账户 A 会作为可写签名者传入交易,账户 B 则作为可写账户传入。随后,该指令会被封装到一笔交易中,交易还会指定费用支付方、签名者和近期区块哈希。

随后,交易通常会发送给 Helius 等 RPC 提供商。接收交易的 RPC 节点会验证所有必需签名是否存在且有效、交易是否已处理、提供的近期区块哈希是否仍然有效,以及交易是否超过最大大小(即 1232 字节)。

然后,RPC 会将交易转发给当前领导者的交易处理单元(TPU)。 

交易处理单元(TPU)

交易处理单元(TPU)是 Solana 验证者内部的交易接收与处理流水线。它包含多个阶段,在交易提交到 Solana 账本之前负责接收、验证、调度和执行交易。

为便于说明,我们会详细研究获取阶段、签名验证阶段和 Banking 阶段,然后再介绍 Bank 和 sBPF VM 的配置。 

如需更详细地了解 TPU,请参阅 权益加权服务质量:你需要知道的一切。

获取阶段

获取阶段是 TPU 流水线的第一阶段。它通过 QUIC 连接接收所有传入交易。这些连接使用 UDP 套接字作为底层传输层,并将交易分批送往下游处理。

系统会创建三个 UDP 套接字:

  • tpu:代币转账、NFT 铸造和程序交互等普通交易。
  • tpu_vote:来自验证者的投票交易——随着 Alpenglow 移除投票交易,这一点将发生变化。
  • tpu_forwards:上一任领导者因未能及时处理而转发的未处理交易。

这些套接字会注册到验证者的 gossip 服务中,并存储在 ContactInfo 结构体内,使其他验证者和 RPC 节点能够发现交易的发送位置。

获取阶段会为每个套接字生成一个线程,所有线程都会持续执行以下操作:

  • 轮询 UDP 套接字以获取传入数据包。
  • 创建一批 64 个数据包。
  • 通过其无界通道发送该批次。

目前使用无界通道向下游阶段传递批次,这意味着通道容量不受限制。使用无界通道还意味着获取阶段可以独立于下游处理速度运行。 

这虽然可以避免流量激增时立即丢包,却可能导致内存问题:如果下游阶段跟不上获取阶段,通道会无限增长,可能造成系统变慢或因内存不足(OOM)而崩溃。 

目前正在实现带有适当背压的有界通道,使系统能够发出拥塞信号并防止内存无限增长。

获取阶段还会创建另一个专门处理转发数据包的线程。这些数据包会标记 FORWARDED 标志,并根据领导者调度保留或丢弃:

  • 接受:如果当前验证者即将成为领导者,转发的数据包会通过普通 TPU 通道处理并发送到下一阶段。
  • 丢弃:如果当前验证者近期不会成为领导者,则丢弃转发的数据包,以避免浪费处理资源。 

获取阶段使用 PacketBatchRecycler 预分配 1,000 个数据包批次,每批包含 1,024 个数据包。这样做是为了降低内存分配开销,因为数据包批次的内存可以复用,无需每次分配新批次。过去,回收器用于 CUDA 内存固定,但该功能现已失效,基本可以视为技术债。 

签名验证阶段

签名验证阶段是 TPU 流水线的第二阶段,顾名思义,它负责验证签名。之所以在流水线早期执行此操作,是因为 Ed25519 签名验证的计算成本较高,尽管仍低于执行交易的成本。在执行前验证交易,使验证者能够拒绝欺诈交易、防止拒绝服务攻击,并确保只有格式正确的交易进入 Banking 阶段。

签名验证阶段作为单线程运行,持续从获取阶段的通道接收数据包,并通过验证流水线处理。尽管只有一个线程,其内部仍存在大量并行操作。

默认情况下,签名会使用并行迭代器在 CPU 上验证。这样可以将验证工作分配到所有可用 CPU 核心,让每个核心独立验证一部分签名。 

如果通过 perf_libs::api() 检测到性能库,也可以将签名验证卸载到 GPU。只有当数据包至少有 64 个且预计 90% 有效时,才能使用 GPU。原因是 GPU 的设置和传输开销约为 ~15-20ms,而 CPU 验证 64 个签名约需 ~10-20ms。诚然,由于延迟开销,这项功能在生产环境中并不实用;面对实际工作负载时,它比 CPU 验证慢得多。目前已计划移除这条未使用的代码路径。

验证过程相对简单:

  • 从获取阶段的无界通道接收数据包批次。
  • 如果数据包数量超过 165,000,则通过负载削减随机丢弃交易。
  • 移除重复交易。
  • 丢弃多余数据包,防止单个 IP 独占验证带宽。
  • 预先缩减并重组批次,以改善缓存局部性并减少内存浪费。
  • 验证签名。

所有有效数据包都会进入 Banking 阶段。 

Banking 阶段

交易在 Banking 阶段执行。在这里,交易会被缓冲、调度,并由并行工作线程执行。

系统采用中央调度器模式,分别处理投票交易和非投票交易:

  • 一个工作线程处理投票交易和 gossip 投票交易。
  • 四个工作线程处理所有非投票交易。
  • 一个线程协调工作线程之间的任务分配。

传入的数据包会被反序列化,并缓冲最多 100,000 笔交易。调度器在管理此缓冲区的同时持续接收新交易,并在清理队列操作期间丢弃过期或无效交易,每次最多检查 10,000 笔交易。

调度器使用冲突检测确定交易执行顺序(即检测希望为相同账户获取读写锁的交易)。默认提供两种调度器实现:

  • PrioGraphScheduler:构建优先级图以检测账户冲突的调度器。
  • GreedyScheduler:默认调度器,使用更直接的 FIFO 方式安排交易顺序。

随后,调度器会选择互不冲突的交易并分派给工作线程。每个工作线程接收一个批次并开始处理。

Bank 编排

Bank 表示特定槽位中所有账户的状态。它是管理账户数据、强制执行运行时规则的核心数据结构,并在 Banking 阶段工作线程与 sBPF VM 之间编排交易执行。 

生命周期

每个 Bank 都会经历三种状态:

  • 活跃:新创建且开放接收交易的 Bank。Banking 阶段的工作线程会应用交易,直到 Bank 达到目标 tick 数,或槽位中的所有条目均处理完毕。
  • 冻结:达到 tick 数或处理完所有条目后,Bank 会被冻结,不能再应用交易。此时,交易费用会累计给区块领导者,sysvar 账户会更新,并计算最终的 Bank 哈希。
  • 已确立:冻结的 Bank 从验证者获得足够票数后,会成为根。此时状态已最终确定,并成为链上账本的一部分。

除创世 Bank 外,每个 Bank 都指向一个父 Bank,形成代表账本不同分叉的树状结构。 

执行流程

Banking 阶段的工作线程在当前工作 Bank 上运行(即为当前槽位构建的活跃、未冻结 Bank)。工作线程从调度器接收交易批次后,Bank 会编排执行过程:

  • 账户锁定:工作线程调用 prepare_sanitized_batch_with_results() 函数,锁定交易引用的所有账户,防止并发修改。
  • 账户加载:Bank 从 AccountsDB 获取所有已锁定账户的数据。
  • 费用扣除:Bank 在执行前从费用支付方的账户中扣除交易费用。
  • 验证:Bank 验证区块哈希是否为近期哈希,检查 nonce 账户状态,并验证账户所有权。
  • 移交 VM:Bank 调用 load_and_execute_transactions(),将已加载的账户交给 sBPF VM。
  • 执行:sBPF VM 执行每条指令的程序字节码。
  • 结果:sBPF VM 返回所有执行输出(即 LoadAndExecuteTransactionOutput)。
  • 提交:Bank 通过 bank.commit_transactions() 将更新后的账户状态写回 AccountsDB。

现在,执行正式进入 SVM(即 sBPF VM)。Bank 调用 load_and_execute_transactions() 后,交易会经过一个多阶段流水线。每条指令都使用一个全新且隔离的 sBPF VM 实例处理,该实例经过配置,可执行程序的字节码。

一笔交易中的指令按顺序执行。每条指令的流水线如下:

  • 定位程序:根据指令的程序 ID 查找程序账户。
  • 从缓存加载:检查相关程序是否已在程序缓存中完成 JIT 编译。 
  • 配置 VM:创建具有特定内存区域和计算预算的隔离 sBPF VM 实例。
  • 执行字节码:使用指令输入运行程序入口点。
  • 验证不变量:检查执行是否违反任何运行时规则。
  • 收集结果:打包执行结果。

程序加载

程序必须先从链上账户加载、验证并编译为原生机器码,之后才能执行。该过程通过程序缓存和 JIT 编译流水线完成。

程序缓存是一项性能优化,可避免每次调用时重新加载和编译程序。它在交易批次级别维护,并存储 ProgramCacheEntry 对象,其中包含:

  • JIT 编译的可执行文件:适用于验证者 CPU 架构的原生机器码。
  • 部署元数据:程序部署和生效时的槽位。
  • 运行时环境:对系统调用注册表和执行配置的引用。

当交易引用某个程序 ID 时,会遵循以下查找顺序:

  • 检查交易批次缓存:查找已缓存、经过 JIT 编译的版本。
  • 检查全局程序缓存:如果批次缓存中没有,则检查验证者的全局缓存。
  • 从账户加载:如果所有缓存均未命中,则从 AccountsDB 加载程序账户。
  • 解析 ELF:从程序账户数据中提取字节码。
  • 验证字节码:运行静态验证器以确保安全。
  • JIT 编译:将 sBPF 字节码转换为原生机器码。
  • 缓存条目:存储编译后的程序,供后续调用使用。

这意味着,新部署程序的首次调用需要承担加载、验证和编译的全部成本,后续调用则会直接执行缓存的原生代码。 

缓存未命中时,必须从程序的链上账户加载程序。对于由 BPF Loader Upgradeable 拥有的程序,程序账户包含对 ProgramData 账户的引用,实际从 AccountsDB 加载的是后者。在 Loader V4 的单账户模型中,字节码直接从程序账户加载;如果采用 zstd 压缩,还可能需要解压。

系统会解析提取出的 ELF 字节,以定位可执行字节码和前述区段(即 .text、.rodata、.data / .bss、.symtab / .strtab)。成功解析 ELF 后,RequisiteVerifier(即 sBPF 的静态分析器)会按前文所述,在不实际运行程序的情况下验证所有可能的执行路径。

JIT 编译

验证通过后,字节码会通过即时(JIT)编译转换为原生机器码。JIT 编译器会将每条 sBPF 指令转换为适用于验证者架构的等效原生 CPU 指令。 

JIT 编译让 sBPF VM 获得足以处理 Solana 高吞吐量的性能。如果没有 JIT,VM 就需要逐条解释 sBPF 字节码指令,从而产生大量开销。 

每条字节码指令都需要获取、解码并分派到处理程序代码,这会增加解释开销。此外,解释执行的代码无法利用流水线、分支预测或乱序执行等 CPU 级优化。因此,每条 sBPF 指令在解释器中都会变成一次函数调用。

JIT 编译通过生成可直接在 CPU 上运行的原生机器码,彻底消除这些成本。由此可实现接近原生的性能、优化的边界检查、内联计算计量、硬件级优化和清晰的寄存器分配映射。

JIT 编译器会将 sBPF 字节码单遍转换为原生机器码。对于每条 sBPF 指令:

  • 解码指令以提取操作码、寄存器、偏移量和立即数值。
  • 设置栈帧,并保存被调用者保存的寄存器。
  • 将 sBPF 操作映射到等效的 CPU 指令,使每项操作都编译为原生指令。例如,在 x86-64 映射中,rax 映射到 R0,rbp 映射到 R10。
  • 为内存访问生成边界检查和软件地址转换——由于开销较大,将客户机地址转换为宿主机地址是 VM 最慢的操作之一。
  • 保持栈隔离(即客户机栈位于堆分配的缓冲区中,而非宿主机栈中)。
  • 插入 CU 扣减和预算检查,以实现计算计量。
  • 恢复寄存器并清理栈。

指令转换

JIT 编译器会以特定方式转换不同类型的指令(即算术、内存访问、存储操作、条件分支和系统调用分派)。 

算术操作会直接映射为单条原生 CPU 指令,没有额外开销,因为 sBPF 的寄存器可以很好地映射到验证者对应的硬件寄存器。 

内存访问操作需要进行边界检查,以防止越界读写。JIT 编译器会生成验证代码,根据有效区域边界检查每次内存访问的下限和上限。 

这通常需要 3 到 6 条原生指令,用于计算有效地址、验证其是否位于预期边界内,然后执行实际加载。由于边界违规很少发生,分支预测可以相对高效地处理此过程。 

存储操作包括边界检查和写入权限验证。编译后的代码会在写入内存前,验证目标地址是否位于边界内,以及该内存区域是否已启用写入权限。

条件分支会编译为原生条件跳转指令。JIT 编译器会在编译期间解析所有跳转目标,将 sBPF 的相对指令偏移量转换为原生代码中的绝对地址。 

系统调用分派需要保存 VM 的状态——全部 11 个寄存器——并调用原生系统调用处理程序。之后,VM 状态会连同返回值一起恢复。正是这种状态管理开销,使系统调用具有高于普通指令的固定 CU 成本。

计算单元计量

JIT 编译器将计算单元跟踪直接内联到生成的代码中。每条 sBPF 指令都会包含预算检查;指令会从剩余 CU 数量中扣减,以确保预算不会耗尽。 

这种内联方式避免了函数调用开销,并可通过分支预测、乱序执行(即 CU 检查和操作可以并行执行)以及指令级并行高效完成。

对于成本可变的系统调用(例如成本随数据长度扩展的 sol_sha256 系统调用),成本计算会在返回 VM 之前于原生系统调用实现内部完成。

缓存编译后的代码

JIT 编译完成后,原生可执行文件会存储在 ProgramCacheEntry 中,其中包含:

  • JIT 编译后的代码
  • 部署槽位和生效槽位元数据
  • 系统调用注册表引用
  • 运行时环境配置

缓存条目会放入交易批次缓存和全局程序缓存。前者可供当前批次中的所有指令使用,后者则可供之后的所有交易使用。 

程序升级(即部署新字节码)、程序账户关闭、功能门控改变系统调用可用性,或验证者决定清空缓存时,缓存条目都可能失效。

生效槽位延迟

请注意,在槽位 n 部署的程序要到槽位 n + 1 才能调用。这一延迟确保所有验证者都能观察到部署、程序缓存在网络中完成同步,并强制执行每槽位原子性。 

配置 sBPF VM

程序完成加载和 JIT 编译或从缓存取回后,每次执行指令都会配置一个新的 sBPF VM。该配置过程在 BPF Loader 中进行,包括设置五个不同的内存区域、初始化计算预算和注册系统调用。

内存区域

如 SVM ISA 一节所述,VM 会创建五个不同的内存区域。这些区域共同构成 Solana 程序运行的隔离沙箱。 程序内存区域通常从地址 0x100000000 开始。它包含要执行的 JIT 编译原生代码——具体机器码取决于验证者的 CPU 架构。如果为调试而禁用 JIT,该区域则包含解释执行的 sBPF 字节码。此区段的权限仅限读取和执行。

0x100000000 中还包含只读数据。它包含程序加载期间从 ELF .rodata 区段提取的常量和静态字符串。该内存区域旨在高效访问编译时常量,无需分配堆内存。 

栈从 0x200000000 开始,包含局部变量、函数调用帧和返回地址。执行期间的临时计算在此进行。它从区域顶部向下增长,寄存器 R10(即帧指针)标记当前帧的边界。该区域允许读写,每个调用帧的大小固定为 4KB。超出此限制会抛出 StackAccessViolation 错误,表示发生栈溢出。较小的栈空间会促使开发者使用堆,或者更理想地将数据存储在账户中,而不是依赖基于栈的存储。

堆从 0x300000000 开始,包含为无法放入栈或账户的运行时数据结构动态分配的内存。其大小默认为 32KB,最大为 256KB。过去,程序可以通过 sol_alloc_free 系统调用扩展堆。不过,sol_alloc_free 系统调用已弃用,并对新部署的程序禁用。程序必须在部署时指定所需堆大小,而不能动态扩展。 

注意:堆增长会根据公式 (heap_size / 32KB) * 8,000 CUs 消耗计算单元,默认堆成本为 8 CU。

输入数据内存区域从地址 0x400000000 开始。它包含程序被调用时接收的序列化入口点参数。这是一个只读内存区域,实际大小因交易而异。三个序列化组件分别是被调用程序的 32 字节 pubkey、账户数组和指令数据。

内存访问强制规则

VM 会对每条内存加载和存储指令进行边界检查。每次访问内存前,VM 都会验证地址是否位于该区域的有效范围内,以及该区域是否允许相应的访问类型(即读取或写入)。 

任何一项检查失败,执行都会立即中止并返回 AccessViolation 错误。整笔交易会回滚,不会提交任何状态变更。 

这种强制检查的运行时成本接近于零,因为 JIT 编译器会将检查编译为 CPU 可直接执行的高效原生代码。由于违规情况很少,现代分支预测可以高效处理这些检查。 

计算预算初始化

每个 VM 实例都会使用计算单元预算进行初始化,以限制给定程序可以执行的总工作量。这种有界执行模型确保程序无法无限运行,并让所有验证者都能在可预测的时间窗口内执行交易。

当前预算参数如下:

每笔交易的默认 CU 数为 min(1_400_00, (200_000*non_reserve_instructions + 3_000*reserve_instructions))。本质上,它会根据提供的指令,在最大计算单元上限与各指令类型的默认成本之间取较小值。

计算预算会在整个执行过程中跟踪剩余单元。每执行一条 sBPF 指令,就会从剩余预算中扣除其 CU 成本。如果程序完成前预算已降至零,执行会立即停止并返回 InstructionError::ComputationalBudgetExceeded。该交易会失败,不会提交任何状态变更,但费用支付者仍需支付交易费,以补偿验证者处理该交易的成本。 

系统调用注册表

在 VM 配置过程中,还会建立每个系统调用的唯一 32 位 Murmur 哈希标识符与其原生 Rust 实现之间的映射,从而注册所有可用的系统调用。 

当程序使用系统调用哈希执行 CALL_IMM 指令时,会发生以下过程:

  • VM 暂停 sBPF 指令流。
  • 系统调用分派器使用哈希在注册表中查找对应实现。
  • 系统调用验证调用者是否具备必要权限(例如执行 CPU 操作、访问账户所有权)。
  • 系统调用的 Rust 实现在沙箱外执行,并拥有完整的运行时访问权限。
  • 从剩余计算预算中扣除该系统调用的固定成本。
  • 结果写入寄存器 R0,随后恢复 sBPF 执行。 

程序执行

sBPF VM 配置完成后,程序会从入口点函数开始执行。对于经 JIT 编译的程序,VM 会直接跳转到原生机器码,由验证者的 CPU 原生执行。如上一节所述,经 JIT 编译的代码包含所有必要的插桩,包括内存边界检查、计算计量和控制流验证,并全部内联以实现最高性能。

寄存器 R1 包含一个指向输入数据区域的指针,三个序列化参数(即被调用程序的公钥、账户数组和指令数据)就存放在该区域中。 

注意:账户数据通过指针访问,而不是复制。使用指针可让程序直接读取和修改账户数据,这对性能至关重要。

每次函数调用都会分配一个新的 4KB 栈帧,并更新寄存器 R10,使其指向新栈帧。计算量按照前述计算预算进行计量。

跨程序调用(CPI)

在执行期间,程序可以通过跨程序调用(CPI)来调用其他程序,这是 SVM 可组合性的基础。 

CPI 通过 sol_invoke_signed 系统调用发起,其成本为 1,000 CUs,此外还需根据传入的序列化账户数据支付额外成本。账户数据和指令数据序列化的成本均为每 CU 250 字节。

当程序发起 CPI 时,系统会创建一个带有独立指令栈帧的新执行上下文。截至本文撰写时,指令栈最大深度为 5;启用 SIMD-0268 后则为 9。这意味着程序可以调用另一个程序,被调用程序还可继续调用其他程序,直至达到深度上限。每一层嵌套调用都会维护各自的可写账户集合和签名者权限。一个 CPI 最多可以有 16 个签名者,并传入 128 个 AccountInfo 结构体。 

调用方会序列化目标程序 ID、账户和指令数据,然后发起系统调用。当前程序会暂停执行,并按照前述相同的配置流程为被调用程序配置一个新的 sBPF VM 实例。随后,被调用程序开始执行。其计算预算来自调用方的剩余预算,也就是说,CPI 调用共享该交易的总计算预算。

程序可以通过程序派生地址(PDA)代表其拥有的账户签名。使用 sol_invoke_signed 调用时,调用方需提供能够证明其拥有该 PDA 的种子。系统会先验证 PDA 派生结果,再向被调用方授予签名权限。 

被调用方执行完成后,控制权会返回调用方。被调用方对账户所做的修改对调用方可见,因此状态可以沿调用链传递。如果 CPI 链中的任何程序失败,整个交易都会中止,所有状态变更都会回滚。

注意:sol_invoke 是一个辅助函数,它会在不提供种子的情况下调用 sol_invoke_signed。

执行后验证

程序执行完成后,无论是直接执行还是作为 CPI 链的一部分,系统都会进行多项执行后检查,以确保状态一致性和安全不变量。 

例如,运行时会验证所有标记为可写的账户确实由该程序拥有或已正确签名。程序无法修改不属于自己的账户,除非这些账户被明确标记为可写且所有者已授予权限,从而防止未经授权的状态修改。

运行时还会检查交易中所有账户的 lamport 总和是否保持不变,除非 lamport 是通过 System Program 指令明确转移的。这项守恒检查可防止程序创建或销毁 lamport,确保 SOL 的总供应量保持不变。

运行时会验证所有带有可执行标志的账户(即程序)均未被修改。程序数据不能在正常执行期间更改,只能通过 BPF Loader 的升级权限机制进行升级。

执行结果

执行后验证完成后,执行结果会返回给 Bank 的交易处理器。结果包含成功或错误状态(分别为零或非零结果)、消耗的计算单元数量,以及对账户状态所做的任何修改。 

对于执行成功的交易,Bank 会以原子方式提交所有账户修改。更新后的账户数据、lamport 余额和元数据会写入 AccountsDB,并在后续交易中可见。消耗的计算单元会记录下来,用于交易费计算和网络指标统计。

对于失败的交易,不会提交任何状态变更。Solana 不存在部分回滚,交易会被整体回滚。不过,交易费仍会从费用支付者的账户中扣除,以补偿验证者已完成的计算工作。错误代码和消耗的计算单元会记录在交易元数据中,用于调试和分析。

执行结果会返回 Banking Stage 调度器,后者更新内部指标并继续处理下一笔交易。成功和失败的交易都会记录到历史证明流中,并纳入当前正在构建的区块。失败交易也会被纳入,以帮助防止重放攻击并维护完整的交易历史。

当 slot 结束并达到最大 tick 数时,Bank 会转入冻结状态。冻结是不可逆操作,会阻止提交新交易并计算 Bank 的哈希。注意,冻结并不意味着最终确认,该 slot 仍可能位于最终被丢弃的分叉上。 

当验证者调用 BankForks::set_root(),将 Bank 指定为规范链的一部分时,该 Bank 就会成为根。设为根会触发 squash 操作,将已设为根的 Bank 的账户状态扁平化到 AccountsDB 中,合并所有父级状态,并从验证者的角度将其永久保存。未设为根的分叉会被修剪并丢弃。由于 Solana 存在不同的承诺级别,即使已设为根的 Bank,从集群角度看也尚未最终确认。

展望未来

Solana Virtual Machine 代表了一种截然不同的区块链执行方式。凭借并行处理、本地费用市场,以及高性能、确定性的 eBPF 衍生运行时,它让可扩展的区块链能够服务大众。 

要理解 SVM,需要考察完整的执行管线:从 Rust 源代码编译到 LLVM、sBPF,再到隔离 VM 实例的配置。 

没有任何单一“规范”能够定义 SVM。相反,它由 Bank、调度器、BPF Loader、sBPF VM 和 SVM ISA 之间的交互共同形成。

随着 SVM 持续演进,未来前景可期。 

Solana 工具链正在进行彻底改造,旨在淘汰多年来一直阻碍开发者上手的自定义 LLVM 基础设施。 

当前方案要求开发者通过特定于平台的脚本安装自定义工具链。解决方案是采用 Rust eBPF 库 Aya 所使用的同一套工具链。开发者只需运行两条简单命令,即可直接编译为 eBPF 字节码:

代码
rustup toolchain install nightly
cargo build --target=bpfel-unknown-none

无需脚本,无需自定义 LLVM 分支。只需使用标准 Rust 工具,借助上游 bpfel-unknown-none 目标直接编译为 eBPF 字节码,并充分利用 Linux 内核多年开发成果与 LLVM 基础设施的持续改进。

SVM ISA 也将通过 SIMD-0377 进行更新。该提案建议让 Solana 的 eBPF 实现(即 sBPF)与现代 eBPF 标准保持一致,包括引入 JMP32 指令变体、有符号除法和取模运算、间接跳转以及动态栈帧。这些变更将有助于降低程序的计算成本、提高与上游 LLVM 基础设施的兼容性,并实现更高效的代码生成。 

SVM 本质上是一个通过计算预算进行计量的系统。SIMD-0370 有望通过移除区块级计算上限,并可能移除交易上限,改变这一机制。移除这些计算上限后,区块生产者将能根据自身硬件能力最大化吞吐量,而不再受人为限制。结合 Alpenglow 的超时机制,这项变更将让市场力量而非协议级约束来决定最佳区块大小。当然,这仍是非常超前的设想,因为 Anza 希望先将 CU 上限提高到 100m+,然后再移除这些上限。

所有这些变更背后,都贯穿着一种构建者精神:在不牺牲安全性、确定性或去中心化的前提下,不断突破区块链能力的边界。 

SVM 不只是一个字节码解释器,而是一套完整的执行管线,彻底革新了区块链的能力。它汇集了一系列优先考虑吞吐量和低延迟的架构决策。随着 Solana 日趋成熟,SVM 将持续演进,以支持高性能且资本效率更高的应用。

要实现互联网资本市场的愿景,需要能够满足全球金融系统在吞吐量、延迟和成本方面要求的基础设施。Solana Virtual Machine 是迈向这一愿景的关键一步。

其他资源

订阅 Helius

及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新

放大图片