Solana程序执行底层原理:从Rust代码到SBF字节码

本文深入探讨了Solana程序的开发生命周期,从Rust代码编写到在Solana运行时执行的整个过程。文章详细介绍了Solana的编译管线,Solana字节码格式(SBF),运行时的BPF虚拟机以及涉及的安全机制。通过将Solana的方法与更熟悉的EVM模型进行比较,以帮助读者建立直觉,从而更好的了解Solana的底层技术。

Solana 的编程模型允许开发者编写高级 Rust 代码,这些代码可以作为链上程序运行。但是,从你编写 Rust 代码到它在 Solana 运行时执行的那一刻,底层实际发生了什么?本文深入探讨了 Solana 开发生命周期以及程序编译和执行的技术内部原理

我们将从一个高级概述开始,逐步深入研究编译管道、Solana 字节码格式 (SBF)、运行时的 BPF (Berkeley Packet Filter) 虚拟机 (VM) 以及发挥作用的安全机制。

在此过程中,我们会将 Solana 的方法与更熟悉的 EVM(以太坊虚拟机)模型进行比较,以帮助建立直觉。

概述:Solana 程序开发生命周期

Solana 程序(智能合约)被部署到特定的链上账户,这些账户保存着程序编译后的可执行代码。与以太坊合约代码和状态位于同一账户下不同,Solana 明确地分离了代码状态。Solana 程序本身是无状态的(在调用之间没有长期存在的内存),但它可以操纵存储在它有权访问的其他账户中的状态。生命周期中的关键点包括:

image.png

编写 : 开发者编写 Rust 代码

编译 通过 LLVM 后端将 Rust 代码编译为 LLVM IR,再编译为 BPF/SBF 字节码

打包 将字节码和元数据打包到 ELF 中

部署 通过 BPF 加载器将 ELF 上传到 Solana 程序账户

完成 加载器验证并标记账户为可执行

执行 用户发送交易;运行时将字节码加载到 BPF VM 中并执行

编写程序:

开发者通常使用 Rust 编写 Solana 程序(通常使用 Solana SDK 或像 Anchor 这样的框架)。程序结构为一个库 crate,其中包含一个入口点函数(例如 process_instruction),运行时在调用程序时将调用该函数。

构建为 BPF 字节码:

Solana 程序不是生成原生机器代码,而是被编译成 BPF 字节码格式。使用 Solana 的工具(已弃用的 cargo build-bpf 或较新的 cargo build-sbf),你的 Rust 代码通过 LLVM 编译成包含 BPF 字节码的 可执行和可链接格式 (ELF)。这种特殊的字节码在 Solana 的 BPF 虚拟机上运行。

链上部署:

部署意味着将编译后的程序(包含 SBF 字节码的 ELF)上传到区块链上指定的程序账户中。Solana 运行时提供了一个BPF 加载器程序来处理部署:编译后的 ELF 被分成多个块,并通过一系列 Write 指令写入账户的数据中,然后完成。

一旦完成并验证,该账户将被标记为可执行。从那时起,Solana 节点将该账户识别为一个程序:任何以它为目标的交易都将执行其中包含的代码。请参阅 load_upgradeable_buffer

执行程序:

要调用 Solana 程序,交易需要包含一个指令,指定程序的账户以及程序可以读取或修改的其他账户的列表。当 Solana 运行时处理这样一个交易时,它会看到目标程序账户,加载字节码,并在安全的 BPF 虚拟机实例中调用程序的入口点。

运行时负责将输入序列化(程序 ID、账户、指令数据)到程序的内存中,然后跳转到入口点。程序执行(受计算限制和安全检查的约束),并返回一个结果或错误,之后运行时将任何更改写回账户数据。

请参阅 execute

可升级性:

默认情况下,Solana 程序可以部署为可升级的。在这种情况下,程序的数据存储在由 BPF 可升级加载器程序控制的单独缓冲区账户中。升级权限可以通过将新的 ELF 写入该缓冲区来替换程序的代码。

如果升级权限设置为 None,则程序变为不可变的。(为简单起见,我们将重点关注可升级和不可变程序共有的运行时机制。)

考虑到这种高级流程,让我们放大管道的每个阶段,从你的 Rust 源代码如何转换为 Solana 字节码开始。

编译管道:从 Rust 到 LLVM IR 再到 SBF

LLVM 概述:

image.png

在 LLVM 中,编译过程分为三个主要部分:前端、中端和后端。前端采用用 C、C++、Go 或 Rust 等语言编写的代码,并将其转换为称为中间表示 (IR) 的通用格式。

IR 是代码的简单、平台无关的版本,它保留了程序的结构和含义,但使编译器更容易处理和优化。它充当原始代码和最终机器代码之间的桥梁。

一旦代码采用 IR 形式,它就会经过中端,LLVM 在其中应用优化以使其更快、更高效。之后,后端将优化的 IR 转换为不同硬件平台(如 x86、ARM、RISC-V、BPF(在 Linux 和 Solana 中使用)或 WebAssembly)的机器代码。这种设置允许 LLVM 以统一的方式处理多种编程语言并针对许多不同的系统。

Solana 程序编译管道

Solana 利用 LLVM 编译器基础设施来生成其链上字节码。事实上,Solana 的底层执行环境构建在与 eBPF (Extended Berkeley Packet Filter) 相同的技术之上,eBPF 是一种最初在 Linux 内核中使用的沙盒字节码。

从 Rust 源代码到 Solana 就绪二进制文件的过程需要经过以下步骤:

image.png

  1. Rust 到 LLVM IR: 当你使用 Rust 编译 Solana 程序时,Rust 编译器前端会生成 LLVM 中间表示 (IR)。在此阶段,代码尚未特定于 Solana 或 BPF - 它是 LLVM 优化器可以使用的通用 SSA 形式 IR。

    理论上,任何可以针对 LLVM 的 BPF 后端的语言都可以用于编写 Solana 程序,尽管 Rust 是迄今为止最常见的选择。如果你要检查 IR,你会看到函数定义、控制流和运算的形式接近于简化的汇编语言,但与任何真实的机器无关。

  2. LLVM IR 到 BPF (SBF) 字节码: 接下来,LLVM 的 BPF 后端开始发挥作用。Solana 使用自定义 LLVM 后端来生成 SBF 字节码。SBF 代表 Solana 字节码格式——本质上是为 Solana 运行时量身定制的 eBPF 变体或子集。LLVM 后端采用 IR 并将其降低为 BPF 指令。这些指令在基于寄存器的虚拟机(带有 64 位寄存器,如 eBPF)上运行,而不是像 x86 或 ARM 那样的实际 CPU 上运行。

    此步骤的输出是遵循 BPF 指令集的原始字节码。重要的是要注意,Solana 的 BPF 是一种确定性的沙盒环境,因此 Rust 代码在正常操作系统上可能执行的某些操作(例如,使用超出允许限制的动态内存、发出系统调用或使用不受支持的 CPU 功能)要么被翻译成安全的 BPF 等效项,要么在编译时被拒绝。

    例如,如果你的 Rust 代码尝试使用线程或大段内存等功能,则编译要么失败,要么产生有关不受支持的操作的警告。

  3. 生成 ELF 模块: 编译后的 BPF 字节码被打包到一个 ELF(可执行和可链接格式)文件中——这是一种用于可执行二进制文件的标准格式。在本例中,ELF 充当 BPF 模块的容器,类似于编译后的共享对象 (.so)。此 ELF 文件是要部署在链上的工件。 有关 ELF 结构的更多详细信息将在后面的章节中介绍。

  4. SBF 与 eBPF: 值得注意的是,生成的字节码不是 Linux 中使用的 vanilla eBPF,而是 SBF——Solana 自己的风格。Solana 字节码格式是 eBPF 的变体,具有一些不同的架构要求(Solana 的需求在某些方面与 Linux 用例不同)。

    例如,Solana 的 BPF 具有更大的允许程序大小,并使用不同的堆栈方法,我们将在稍后讨论。实际上,这些差异意味着你应该始终使用 Solana 的工具进行编译,而不是通用的 eBPF 汇编器。Solana 运行时的 BPF 验证器将拒绝不符合预期格式和约束的程序。

一旦 Rust 编译为 SBF ELF,它就可以进行部署了。下一节将更深入地探讨什么是 SBF 字节码以及它与标准 eBPF 有何不同。

什么是 SBF? (Solana 字节码格式 vs 通用 eBPF)

image.png eBPF (Extended BPF) 最初是为 Linux 内核设计的,它定义了一个沙盒化的 64 位 RISC 类指令集,具有固定数量的寄存器(通常在 VM 中有 10 个通用寄存器,外加一个只读帧指针寄存器)和一个严格的执行模型。Solana 使用它作为其智能合约引擎的基础,但进行了自定义,通常统称为 SBF 或 sBPF。以下是主要区别和特征:

堆栈管理:

在 Linux eBPF 中,每个 eBPF 程序都有一个 512 字节的固定堆栈用于本地存储,帧指针寄存器 (R10) 用于访问堆栈槽。SBF 显着扩展了这一点。Solana 使用 堆栈帧,每个 4KB,而不是一个扁平的 512 字节堆栈。每次程序调用一个函数(即,一个 BPF 调用指令到其代码的另一部分)时,都会为该函数的局部变量分配一个新的 4KB 帧。

这意味着可以进行深层函数调用,并且每个调用都可以使用多达 4096 字节的堆栈空间,这对于典型的 Rust 程序来说更加灵活。缺点是 Solana 必须更改 BPF 模型以支持多个堆栈帧和动态帧分配。

在 SBF 中,堆栈帧位于从 0x200000000 开始的虚拟地址空间中。BPF VM 根据需要将每个 4KB 帧映射到该区域中。如果程序尝试访问超出其当前帧的内存(例如,不适合 4KB 的缓冲区),它要么在编译时被捕获(编译器会发出堆栈溢出的警告或错误),要么触发运行时访问冲突,如果它在执行过程中以某种方式发生。

这种设计允许 Rust 程序使用相对较大的本地数组和深层递归,这在 vanilla eBPF 限制下是不可能的。

程序大小和指令限制:

Linux eBPF 程序的大小受到限制(Linux 验证器通常将程序限制在大约 4096 条指令,并且不允许循环(自 Linux 5.3 以来,只要验证器证明它会终止,就允许有界循环),除非它们可以展开,以保证终止)。Solana 的模型没有以相同的方式施加静态指令限制——相反,Solana 使用动态的计算预算(以“计算单元”衡量)来确保程序终止。

默认情况下,Solana 交易允许执行一定数量的 BPF 指令(例如,20 万,每个交易的硬上限为 140 万 CU)。执行的每个 BPF 指令消耗 1 个单位的计算预算(对于某些较重的操作,则更多),如果预算耗尽,则程序停止。这意味着 SBF 程序可以包含循环和动态迭代(使其更具表现力),依靠运行时通过计算预算来停止任何无限循环。

相比之下,Linux eBPF 验证器会直接拒绝具有潜在无界循环的程序。通过将此责任转移到运行时,Solana 的 SBF 允许更自然的控制流(例如 Rust 中的 forwhile 循环),但代价是需要运行时检查执行限制。

内存访问和安全性:

eBPF 和 SBF 都被设计为在其沙盒中具有内存安全性。Solana BPF 验证器/加载器执行检查以确保程序的内存访问在允许的区域内。Solana 预定义了程序的虚拟内存映射:

  • 程序代码从地址 0x100000000 开始映射。
  • 堆栈帧位于 0x200000000
  • 堆(动态内存分配器区域)位于 0x300000000
  • 输入数据(序列化的交易账户和指令数据)位于 0x400000000

BPF 代码中的任何指针解引用都会被检查,以确保它落在程序有权访问的区域内。如果不是,则会触发 AccessViolation 错误,从而安全地终止程序。这在精神上与 Linux eBPF 验证器确保所有内存访问都在边界内(例如,在堆栈或数据包数据中)类似,但 Solana 的强制执行也可以在运行时进行。例如,Solana 程序不能随意读取或写入其给定结构之外的内存(例如,其输入账户或其自己的堆栈/堆),这可以防止它损坏运行时或其他程序的内存。

系统调用和助手:

Solana 的 BPF 不允许任意系统调用或函数调用主机环境;它只允许调用预定义的运行时提供的函数集(通常称为系统调用BPF 助手)。在 Linux eBPF 中,这些是用于操作数据包或映射的辅助函数。

在 Solana 中,系统调用包括用于记录消息 (sol_log)、执行 sha256 哈希、分配内存等的函数。当你的 Rust 程序调用 Solana SDK 函数(如 msg!()(用于日志记录)或 使用 Pubkey::create_program_address 时,这些最终会转换为对这些运行时函数的调用。在底层,ELF 的重定位条目或符号会将它们标记为外部调用。

在部署或加载期间,Solana 的 BPF 加载器会将这些符号映射到实际的 BPF 调用号码或地址。SBF VM 使用一个调用指令,该指令带有一个立即值,该值对应于已批准的系统调用表中的索引。

这样设置是为了当 BPF VM 命中该调用时,它会陷入本机运行时以执行操作(在扣除系统调用的适当计算单元成本后)。所有此类调用都经过审查——程序无法调用不在已批准列表中的任意内存地址或主机函数。这保持了沙盒。

确定性和限制:

与 eBPF 一样,SBF 程序必须是确定性的。它们无法访问传入内容之外的随机数据源(BPF 本身内部没有直接的网络、时间或随机预言机),并且无法执行会导致不同验证器获得不同结果的操作。

BPF 指令集没有直接的浮点支持(Solana 禁用 FP 以避免不确定性,并且因为典型的智能合约不需要)。此外,某些与用户级程序无关的 eBPF 指令或功能可能会受到限制。

总之,SBF 是 Solana 定制的 BPF 环境,它消除了 eBPF 的一些限制性限制(堆栈大小、不允许循环),同时通过运行时检查和有限的系统调用接口来强制执行安全性。现在,让我们看看编译后的程序(作为包含 SBF 字节码的 ELF)是如何被 Solana 运行时加载和执行的。

ELF 格式和 Solana 程序二进制文件

今天,当你构建 Solana 程序时,输出是一个 64 位的 ELF,它被标记为 SBF 架构和现代目标三元组 sbf-solana-solana。(旧的 bpfel-unknown-unknown 三元组仍然有效,但新的工具和文档已迁移。)了解此 ELF 中存在的内容可以揭示最终在链上结束的内容。

  • ELF 结构

该文件只保留加载器需要的内容:

Section Purpose Notes
.text 包含可执行的 SBF 字节码(程序指令) 始终存在。映射到从 0x100000000 开始的虚拟内存。这是 Solana VM 执行的主要部分。
.rodata 存储只读数据,如字符串字面量、常量数组和其他不可变值 .text 一起映射到 0x100000000。编译器将常量表移动到这里以保持它们的不可变性。
.data 包含初始化的可变全局变量 很少使用,因为 Solana 程序被设计为无状态的。这里的变量在每次调用时都会重置。
.bss 为未初始化的可变全局变量分配空间(在运行时零初始化) 存在与否取决于程序设计。Solana 运行时支持它,但由于程序的无状态性质,其使用受到限制。

只保留运行程序所需的重要部分。函数名称和一些特定的重定位类型(如 R_BPF_CALL 和 R_BPF_64_64)会被保留,因为 Solana 加载器需要它们来正确地链接和运行代码。其他所有内容,如 DWARF 调试信息和额外的节,都会被删除。这使得最终的 .so 文件更小,这很重要,因为在 Solana 上部署较小的程序更便宜、更快速。

  • 入口点地址

Rust 宏 entrypoint!(或 Anchor 的 #[program] 属性)将你选择的入口函数的起始地址写入 ELF 标头的 e_entry 字段中。在执行期间,BPF 加载器读取该字段并直接跳转到入口点地址。调用函数 process_instruction 是一种常见的做法,但你传递给 entrypoint! 的任何函数都可以工作,因为加载器只依赖于标头中记录的入口地址。

  • 重定位和系统调用

在 Solana 程序中,像 sol_log 这样的外部调用不能被硬编码,因为运行时服务的实际地址在编译时是未知的。为了处理这个问题,编译器在 ELF 中留下占位符——重定位——来标记需要在以后填充外部调用的位置。 当验证器第一次加载程序时,BPF 加载器会扫描这些重定位,并将每个重定位替换为 SBF VM 可以理解的相应系统调用 ID。然后将修补后的字节码缓存在内存中,因此所有未来的执行都使用已经解析的版本,而无需重新处理重定位。

  • 验证(在最终确定之前)

在最终确定(部署的最后一步)时,加载器会验证 ELF。它检查:

  • ELF 格式是否良好(正确的标头、节等)。
  • 程序是否超过大小限制或包含格式错误的指令。
  • 重定位是否仅适用于允许的符号(没有对随机地址的未解析调用)。
  • 字节码是否不使用任何禁止的指令序列。(例如,如果某些 eBPF 指令可能导致不确定性或安全问题,则可能会被禁止。此外,可能会扫描代码以确保没有直接跳转到指令的中间位置。)

只有当所有检查都通过时,加载器才会将程序账户标记为可执行。此验证步骤类似于 Linux 中 eBPF 验证器,但它是为 Solana 的规则量身定制的。这是在验证器上运行不受信任的代码之前的一个至关重要的安全步骤。

  • 账户数据中的 ELF

加载器 v3 将原始 ELF blob 存储在 ProgramData 账户中。加载器 v4 可以选择保留一个 zstd 压缩的图像以节省租金;这对调用者是透明的。在执行时,运行时仅将 .text 字节码(和一小部分元数据)流式传输到 SBF VM 中——将 ELF 视为一个运输箱,而不是以本机方式映射和执行的东西。

简而言之,ELF 容器以标准格式移动你的 SBF 字节码。一旦部署在链上,加载器会验证该箱子,修补系统调用重定位,并将控制权交给 e_entry 地址上的 VM。其他所有内容——内存映射(0x100000000 代码,0x200000000 堆栈,0x300000000 堆,0x400000000 输入)、计算计量和可选的 JIT——都发生在验证器内部,而不是 ELF 本身内部。

Solana BPF 虚拟机 (RBPF) 和运行时内部

Solana 的链上程序在 Solana 运行时中实现的 BPF 虚拟机 (VM) 中运行。这个 VM 通常被称为 SBPF(代表“Solana BPF”,Solana 用于 BPF 执行的 crate 的名称)。运行时的任务是加载程序的字节码,使用适当的输入执行它,并强制执行安全性和资源限制。让我们分解一下运行时机制:

加载和验证程序:

当一个交易调用一个程序时,Solana 运行时(在一个验证器上处理该交易)将从程序账户加载程序的字节码。在实践中,Solana 验证器在首次使用后将部署的程序缓存在内存中,以避免每次都重新读取和重新验证 ELF。

如果程序刚刚部署或更改(可升级程序场景),或者如果在该验证器上首次执行该程序,则运行时会将账户数据解析为 ELF,执行验证(如果尚未完成),并提取可执行字节码。

在这一点上,运行时还可以使用程序的代码初始化 VM。Solana 的运行时支持 BPF 的解释器以及 JIT 编译器。许多验证器运行 JIT 以提高性能:BPF 字节码可以动态地翻译成本机机器代码以加快执行速度。无论是解释的还是 JIT 编译的,语义都是相同的(并且实现非常小心地确保相同的结果,而 JIT 只是一个优化的路径)。

这个过程——加载、验证和(可选)JIT 编译程序——在初始化 ProgramCacheEntry 结构时处理,我们可以在 Agave 运行时 agave/program-runtime/src/loaded_programs.rs 中的 new_internal 中看到。

VM 中的内存映射:

Solana BPF VM 为程序设置一个带分段区域的虚拟地址空间(如前所述)。它映射:

  • 程序的代码到从 0x100000000 开始的地址空间中。这意味着 BPF 程序计数器对应于该范围内的地址。VM 将确保程序只能在该区域内执行。代码段中没有可写内存(不允许自修改代码)。
  • 一个位于 0x200000000堆栈 区域。VM 在此处分配一个初始的 4KB 帧,并相应地设置帧指针(寄存器)。当程序调用函数时,会分配新的帧(在较低的地址处)。

如果程序返回,则释放该帧。检查当前帧之外的任何访问——例如,VM 可以检查帧指针和帧指针减去 4096 之间的内存地址是否是唯一可访问的堆栈槽。

  • 一个位于 0x300000000,默认大小为 32KB 的 区域。Solana 程序可以使用 Rust 分配器 API(例如,Vec::push 将分配内存)。在底层,Solana 提供了一个非常简单的碰撞分配器,该分配器将此 32KB 堆的切片分配给程序的需求。

堆不支持解除分配(没有 free),这是一个有意的简化,以避免堆管理复杂性和潜在的非确定性。如果程序在运行时需要超过 32KB 的堆,它将耗尽内存(或者可以使用提供的区域实现自定义分配器)。

  • 位于 0x400000000输入数据 区域。在调用程序的入口点之前,加载器准备一个包含所有输入参数的连续内存块。这包括:
    • 账户数量。
    • 对于每个账户:一个描述符(签名者/可写/可执行标志,以及它是否是列表中另一个账户的副本)、账户的公钥、账户的所有者公钥、lamports(SOL 的最小单位)、数据大小、账户数据本身,以及一些用于未来增长的填充(特别是 ~10KB 的填充,用于潜在的 realloc 操作),以及对齐填充,以确保每个字段都对齐到 8 字节。
    • 指令数据的长度和指令数据字节。
    • 正在执行的程序的程序 ID。

所有这些都以小端字节序序列化。然后,加载器将指向此输入结构的指针放在适当的寄存器中,作为入口点的参数。

用 C 术语来说,你可以将入口点签名想象成类似于 entrypoint(const uint8_t *input),其中 input 指向这个打包的数据。在 Rust 中,Solana SDK 对此进行了抽象,并为你提供了好的 &[AccountInfo]&[u8] 用于指令数据,但在底层,它正在从此内存区域读取。 这些区域使用常量在 sbpf::src/ebpf.rs 中定义。

在运行时初始化期间,Solana 加载器使用这些地址在 VM 内部构建内存区域。负责此功能的函数是 agave::programs/bpf_loader/src/lib.rs 中的 create_memory_mapping

入口点调用和执行:

设置好内存后,VM 就可以开始执行了。加载器识别程序的入口点函数并将 BPF 指令指针设置为该函数的开头。它还根据 Solana BPF 调用约定设置 BPF 寄存器:

  • 通常,寄存器 R1 可能保存指向输入数据的指针(0x400000000 处的结构)。
  • 如果入口点有多个参数,则寄存器 R2、R3 等可用于其他参数(一些较旧的约定传递输入长度等)。但是,由于入口点实际上只需要一个指针(所有内容都在该结构中),因此 R1 是主要参数。程序 ID 包含在输入数据的末尾,而不是作为单独的参数。
  • 返回值(成功或错误)将在寄存器(通常为 R0)中返回。

然后,加载器将控制权交给 BPF VM 以执行程序指令。

执行期间——VM 强制执行:

当程序运行时,BPF VM/执行器会执行以下几项操作来维护沙箱:

  • 计算执行的指令。每个 BPF 指令(可以是算术、内存加载/存储、跳转、调用等)都会递增一个计数器。如果计数超过此程序调用允许的计算预算,VM 将中止执行并显示错误(本质上是“超出计算预算”失败)。

这可防止无限循环或过长的执行时间。值得注意的是,VM 默认情况下为每个 SBF 指令收取 1 个计算单元 (CU)。但是,系统调用(如 sol_log、invoke 等)会触发运行时计算预算模型定义的额外 CU 成本——这些成本与指令计数无关,而是在执行系统调用时立即扣除。

  • 它执行内存访问检查。例如,如果程序尝试从输入区域中的地址加载,VM 会检查它是否在该区域的边界内(并可能检查如果它对应于账户数据,该账户是否已标记为可读)。

如果尝试写入只读区域(例如,尝试修改未标记为可写的账户数据,或者更糟的是,尝试修改代码段),VM 将陷入并产生 AccessViolation。同样,如果指针已损坏或超出分配的范围,则会捕获到该错误。

  • 它处理系统调用调用。如果程序执行以已识别的系统调用为目标的 CALL 指令(如在重定位中设置的那样),VM 将暂停程序,切换到本机代码以执行系统调用,然后恢复。

例如,如果程序调用 sol_log,VM 会将控制权交给主机来执行日志记录(这可能会消耗计算预算成本中定义的固定数量的计算单元)。结果(如果有)通过在 BPF 上下文中设置一个寄存器来返回(例如,一些系统调用在 R0 中返回值)。

  • VM 可防止危险操作。例如,程序没有办法逃脱 VM 并任意运行本机代码——它无法发出系统调用来执行或打开文件等。它被锁定到 Solana 定义的接口。

VM 强制执行严格的调用深度限制以防止无限递归。对于跨程序调用 (CPI),Solana 强制执行最大深度为 4 级。对于程序内递归,该限制实际上由固定的 32 KiB 堆栈大小强制执行,每个堆栈帧通常消耗 4 KiB,这允许在达到堆栈溢出之前最多大约 8 个嵌套调用。

完成执行和写回更改:

当程序的入口点函数返回(正常返回或由于错误/panic)时,控制权将返回给加载器逻辑。解释返回值(Solana 程序返回 ProgramResult,这基本上是 Result<(), ProgramError>)。如果这是一个错误,运行时会将事务标记为失败,并将错误返回给客户端(并可能消耗费用或其中的一部分)。如果成功,运行时将继续。

一个关键步骤是应用程序对账户数据所做的任何更改。回想一下,程序被赋予了输入块中每个账户数据的指针。这些实际上指向 VM 中的复制内存。当程序写入账户数据时(例如,更新 SPL 代币账户中的代币余额),它正在写入 VM 的内存。

程序完成后,运行时将从 VM 的内存中获取已更改的数据,并将其写回到区块链上的实际账户数据仅针对标记为可写的账户。如果程序尝试写入只读账户,则要么被阻止,要么这些更改将被丢弃(并且可能已经发生了一个错误)。

如果程序使用了超过免费限制的计算量(通过计算预算费用机制),或者如果它调用了某些有费用的系统调用(例如,记录太多可能会产生费用),运行时也会扣除 lamports。但这些更多是关于费用,而不是执行的正确性。

如果程序因错误或违规而停止,运行时会回滚所有更改(因为在 Solana 上,所有更改都以事务方式发生)。仅在成功完成后才应用账户数据修改。这意味着如果程序中的一个漏洞导致崩溃(例如 panic 或非法内存访问),则所有账户的数据更改(如果在内存中部分完成)都不会持续存在。这种全有或全无的行为类似于以太坊的恢复语义。

到目前为止,我们只关注单个程序的执行。Solana 还支持跨程序调用 (CPI),其中一个程序在其执行期间调用另一个程序。接下来我们将介绍这一点,以及 Solana 账户模型,以完善运行时行为的画面。

账户模型、程序调用和安全检查

Solana 的账户模型以及从程序内部调用程序的能力为执行增加了另一层复杂性。以下是它们的工作方式以及正在实施的安全措施:

  • 账户和所有权: Solana 上的每个账户都有一个所有者(即管理它的程序)。对于大多数数据账户,所有者是一个程序(程序账户的公钥),该程序被允许修改其内容。

运行时强制执行 只有所有者程序可以修改账户的数据(有一些例外,例如用于账户创建和分配的系统程序)。在程序调用期间,运行时知道哪个程序正在运行,如果该程序尝试修改所有者不同的账户的数据(而不是特殊豁免情况之一),它将被阻止。

这是一个基本的安全规则:例如,代币程序不能随意更改名称服务账户中的数据,因为它不是该账户的所有者。

  • 指令中的账户访问: 当进行交易或 CPI 调用时,它指定一个被调用程序可以访问的账户引用列表。该程序对未传入的任何账户都是盲的

事实上,在 BPF VM 中,内存中存在的唯一账户数据是在 0x400000000 处的输入缓冲区中提供的数据。因此,程序无法读取或写入任何未作为指令的一部分明确提供给它的账户状态。这可以防止一整类问题——一个程序无法窥探或篡改它不应该处理的账户。

  • 只读与可写账户: 指令描述符将某些账户标记为该调用的只读可写。Solana 运行时使用这些标志相应地标记每个账户的内存区域。如果账户是只读的,程序仍然会收到其数据,但任何修改该数据的尝试都应导致错误 (AccessViolation),或者至少在更改后将其丢弃。

VM 可以通过不将内存映射为可写来强制执行此操作。这可以确保,例如,如果持有用户 SOL 余额 (lamports) 的账户作为只读账户传递(可能只是为了检查余额),程序不会意外或恶意地减少该余额,因为它没有被授予写入权限。

  • 程序调用 (CPI): 程序可以通过调用运行时的 CPI 机制(通过系统调用 invokeinvoke_signed)来调用另一个程序。这类似于以太坊合约调用另一个合约,但具有 Solana 的风格。当程序 A 调用程序 B 时:

    • 程序 A 准备一个新的指令(程序 B 的 id,以及要传递的账户列表)。这些账户必须是 A 自身有权访问的账户的子集(A 只能传递它知道的账户,可能权限减少,并且在某些情况下它也可以包括自身或其程序 id)。
    • 程序 A 调用系统调用,该调用暂停 A 的执行并将控制权转移到运行时以调用 B。运行时本质上为 B 设置一个新的 BPF VM上下文,其中包含 B 被赋予的账户和输入。
    • 程序 B 在从程序 A 继承的相同计算单元计量下执行。B(或更深层次的 CPI)运行的任何指令都会继续递减在交易开始时设置的一个计数器。B 无法查看或修改 A 未明确传递给它的账户。

    当 B 完成时,控制权返回到 A,A 然后可以继续。B 对账户所做的任何更改(可写且由 B 拥有或以其他方式允许)现在对 A 可见(因为内存中的那些账户数据区域已更新)。- 安全性检查确保 B 不能超出 A 的权限。例如,B 不能突然获得对 A 仅具有只读权限的帐户的写入权限(除非 A 有意将其作为可写权限传递,如果最初不是,A 就无法做到)。如果 B 尝试回调到 A 或其他地方创建循环,则运行时会强制执行 4 层嵌套 CPI 的硬深度限制,第 5 次尝试会触发 CallDepth 错误并中止交易。

    • invoke_signed: 如果程序 A 在调用 B 时需要为程序派生地址 (PDA) 帐户签名(PDA 是具有确定性地址的帐户,该地址从种子和程序 ID 派生,通常用于程序拥有的帐户),A 可以使用 invoke_signed 来展示 PDA 的种子。

    运行时将验证 PDA 的公钥是否确实与这些种子和调用程序的 ID 匹配。如果是,它将在 CPI 期间授权 PDA 作为签名者。这种机制可以防止恶意程序伪造签名:只有拥有 PDA(并且知道种子)的程序才能在 CPI 中将其用作签名者。B 在收到该 PDA 帐户后,可能需要签名(例如,如果 B 是尝试从该 PDA 扣除 lamport 的系统程序)。运行时通过 invoke_signed 进行的检查使这成为可能,而无需任何私钥。

  • 并发和并行性: Solana 的运行时专为并行性而设计(加粗SeaLevel加粗 运行时)。在不相交的帐户集上运行的不同交易(或 CPI)可以在不同的核心上并行执行。

BPF VM 实例在内存和状态中是完全独立的,因此一个正在运行的程序不会干扰另一个在不同核心上并行运行的程序,除非争夺调度程序等全局资源。

这与 Ethereum 不同,在 Ethereum 中,单线程 EVM 一次全局处理一个交易。为了安全起见,Solana 确保没有两个写入同一帐户的交易可以同时运行(这就是为什么交易指定帐户锁的原因)。因此,虽然不是 BPF 内部结构的直接组成部分,但这种设计会影响 VM 的使用方式 - 它必须是可重入且线程安全的,以允许多个实例。

  • 运行时检查总结: 总结 Solana 在运行时执行的关键安全性检查

    • 帐户所有者检查: 如果程序 X 尝试修改帐户 Y 的数据,则运行时确保 Y.owner == X(或明确允许 X,例如系统程序更改所有者)。
    • 可写检查: 如果帐户未在指令中标记为可写,则程序无法修改它。
    • 边界检查: 任何超出已分配缓冲区(帐户数据、堆栈、堆、输入)的内存访问都会被捕获。
    • 调用深度和递归检查: CPI 深度有限制(以防止循环或过度递归)。
    • 计算预算检查: 确保程序(包括任何 CPI)不超过分配的指令计数。
    • 签名检查: 验证 PDA 或其他编程签名者(invoke_signed 种子必须匹配)。
    • 不可变标志: 如果程序帐户是不可变的(已删除升级权限),则运行时将不允许对其进行任何部署或修改,从而确保代码不会在底层更改。

所有这些检查使 Solana 的链上执行能够抵御典型的攻击媒介,如缓冲区溢出、未经授权的状态更改或资源耗尽 - 这是安全研究人员审计 Solana 程序时要考虑的关键因素。

与 EVM (Ethereum) 执行模型的比较

对于那些熟悉 Ethereum 的 EVM 的人来说,将 Solana 的 SBF 执行进行类比和比较差异是很有用的:

  • 编译: 在 Ethereum 上,高级语言(Solidity、Vyper)编译为 EVM 字节码,这是一种基于堆栈的字节码。在 Solana 上,高级 Rust(或 C 等)编译为 SBF 字节码,这是一种基于寄存器的字节码。两者最终都在每个验证节点上的 VM 中运行。

Solana 选择 BPF(一种更通用的字节码)意味着编译后的程序在执行时可以更有效率(因为 BPF 比 EVM 更接近真实的 CPU 架构)。但是,这也意味着二进制文件更大(Solana 程序可能达到数十 KB,而 Ethereum 合约由于其紧凑的堆栈机器和高级操作码设计,往往在字节码大小上更小)。

  • 指令集和成本计算: EVM 有大约 200 个操作码(指令),专为 SHA3、余额查找等高级操作而设计,每个操作码都有固定的 gas 成本。在 Solana 的 BPF 中,指令集是低级的(加载、存储、加法、乘法、按位运算、分支、调用)。Solana 中的复杂操作(如哈希)被实现为系统调用(例如,调用 SHA256 系统调用),而不是单个操作码,但为它们收费的想法是相似的(系统调用将消耗与所做工作大致对应的计算单元)。

两种系统都有资源计量:EVM 使用 gas(每个操作码都有 gas 成本),并在 gas 耗尽时停止;Solana 使用计算单元(每个 SBF 指令 1 CU,当指令跳转到系统调用时,还有额外的 CU 量),并在预算耗尽时停止。一个区别是,Solana 的交易计算预算可以由开发人员增加(通过支付更高的费用或添加特殊的 ComputeBudget 指令),而 Ethereum 的每个区块 gas 限制是固定的,一个交易最多只能使用区块剩余的 gas。

  • 内存和存储: 在 EVM 中,每个合约都有一个持久存储(键值存储)和一个瞬态内存(每次调用时清除)和一个堆栈(限制为 1024 个槽)。在 Solana 中,程序没有内置的持久存储 - 你使用单独的帐户作为存储。Solana 程序在执行期间的内存包括堆栈和堆,如前所述,这更像是一台微型计算机的内存。

Solana 的帐户充当持久存储的角色(如文件或数据库条目),它们存在于程序之外,必须显式地读取/写入。这意味着 Solana 开发人员处理将数据序列化/反序列化到帐户中(通常使用 Borsh 或类似的工具),这类似于 Ethereum 开发人员读取/写入合约存储变量的方式。

一个关键的区别:在 Solana 中读取/写入帐户具有预付的固定成本(加载帐户会消耗一些计算单元),但随后通过 BPF 在其中读取/写入字节的成本很低(只是内存操作),而在 Ethereum 中,每次存储读取/写入每个 32 字节字的成本很高。这使得 Solana 更像是一种传统计算模型,一旦加载数据,而 Ethereum 的 gas 模型则大量计量存储访问。

  • 并行性: Ethereum 串行处理交易(每个交易的效果一次应用于全局状态)。Solana 的架构(SeaLevel 运行时)允许不冲突的交易在多个核心上并行运行。

这对智能合约代码是不可见的(智能合约代码仍然编写为在一次调用中是单线程的),但这意味着 Solana 的运行时在调度方面更复杂。好处是更高的吞吐量。

对于开发人员和安全研究人员来说,一个含义是你必须在更高的层次上考虑竞争条件:如果两个交易没有触及相同的帐户,它们可能会交错并都成功,这是可以的,但如果它们触及了相同的帐户,则一个将等待另一个。

  • 执行引擎: EVM 是一个解释器(尽管已经探索过像 Ethereum JIT 或 eWASM 这样的项目)。相比之下,Solana 的 BPF VM 有一个强大的 JIT 选项 - 许多 Solana 节点 JIT 将 BPF 编译为 x86 本机代码,以实现更快的执行。这是安全的,因为代码经过验证和沙盒处理,并且它可以导致比解释 EVM 字节码更高的执行速度。这部分解释了 Solana 在 400 毫秒的区块时间内处理每个交易数万条指令并仍然能够跟上的能力。

  • 开发经验: Solana 开发人员使用 Rust(一种系统语言),并且可以使用像结构体、泛型、错误处理等正常范式,这些范式由 LLVM->BPF 工具链处理。Ethereum 开发人员使用围绕 EVM 的约束设计的专用语言(Solidity、Vyper)(例如,256 位字,有限的堆栈)。

这种差异会影响出现的错误和安全问题的类型。例如,由于 Rust 的安全性(以及 BPF 的检查),缓冲区溢出在很大程度上在 Solana 中得到缓解,而在 EVM 中,整数溢出是一个经典问题(现在 Solidity 通过安全数学或内置的检查数学在很大程度上得到缓解)。另一方面,Solana 开发人员在处理帐户数据时必须小心显式内存管理(例如,帐户大小、手动序列化)和类似指针的逻辑,这在 Ethereum 中不太令人担心,在 Ethereum 中,存储由高级映射等抽象。

总而言之,Solana 的 SBF 和 Ethereum 的 EVM 都服务于相同的目的 - 在每个节点上安全地执行不受信任的智能合约代码 - 但它们以非常不同的设计来实现这一点。SBF 在设计上更接近真实的硬件 CPU,受益于现有的编译器技术 (LLVM),并为开发人员提供更通用的编程模型,而 EVM 是一种专门构建的具有更简单模型但具有更多内在约束的区块链 VM。

结论

Solana 的运行时是传统系统架构(LLVM、字节码 VM、调用帧)与区块链原则(确定性执行、基于帐户的状态和并发控制)的复杂结合。对于 Solana Rust 开发人员来说,SDK 抽象掉了很多这些细节 - 你编写 Rust,然后工具处理 LLVM 编译和格式化。

但是,了解底层发生的事情是无价的,特别是对于审计程序或优化程序的安全研究人员而言。你可以深入了解为什么某些事情(例如限制堆栈使用或避免没有理由的大循环)很重要,以及 Solana 运行时如何确保你的程序和网络的安全。

从你运行 cargo build-bpf 的那一刻到验证者执行你的程序的字节码的那一刻,都有一个复杂的管道确保你的高级代码被转换为安全、高效和确定性的形式。Solana 对 BPF(或者更确切地说,SBF)的使用是其性能的关键推动因素,允许重用成熟的编译器基础设施和高度优化的 VM。与此同时,Solana 不得不在 eBPF 的基础上进行创新以满足区块链的需求,从而产生了 SBF——Solana 自己的字节码风格,它消除了某些限制并增加了新的安全保障。

对于来自 Ethereum 背景的人来说,Solana 的方法提供了一组不同的挑战和优势:你可以与 Rust 一起使用强大的抽象,但你也可以更接近 VM 的底层,并且必须以帐户和字节缓冲区的形式进行思考。对 Solana 内部结构的深入研究揭示了一种专注于最大化吞吐量(通过并行性和 JIT)的设计理念,同时通过编译时和运行时验证来保持安全性。

最后,无论你是在 Solana 上编写一个简单的代币程序还是审计一个复杂的 DeFi 协议,从 Rust 代码到链上执行的加粗整个过程加粗 的心理模型都会使你成为一个更有效的开发人员和一个更有眼力的安全研究人员。Solana 底层的技术是现代编译器技术和区块链独创性的迷人融合 - 真正将我们从“Rust to SBF”带到前沿,并推动链上程序可以做的事情。

参考文献:

  • 原文链接: ubermensch.blog/under-th...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
ubermensch
ubermensch
Blockchain Security Researcher at @CyfrinAudits