新消息:Helius 收购 Light Protocol
如何使用 Solana 伯克利包过滤器(sBPF)汇编构建 Solana 程序
博客/开发

如何使用 SBPF 汇编编写 Solana 程序

Blueshift 联合创始人兼内容与战略负责人X 上的 Leonardo DonatacciLinkedIn 上的 Leonardo Donatacci
阅读需 11 分钟

目前,Solana 生态系统正同时展开一场程序优化的底层竞赛。 

从宏观来看,Pinocchio 等库正在彻底改变 Rust 开发,将计算效率提升了数个数量级。与此同时,在最底层,一群因共同蔑视编译器而团结起来的专注开发者更进一步。他们不再使用 Rust 或 C 等编译型语言编写 Solana 程序,而是专注于精心手写字节码,从每一条指令中榨取极致性能。

只有使用 VM 的原生语言直接向其下达指令,才能实现这些底层性能提升。这种语言就是 sBPF 汇编,它是 Solana 自己的扩展伯克利包过滤器(eBPF)变体,也是每个链上程序使用和执行的字节码。

编写 sBPF 汇编让开发者能够直接访问 Solana 虚拟机的最底层接口。尽管 Rust 编译器和 LLVM 会尝试优化,但由于语言语法提供的信息不够丰富,或缺少做出更优编译选择所需的上下文,它们生成的字节码通常不如熟练开发者利用汇编提供的完整指令级控制所编写的字节码高效。

虽然这种额外的控制会牺牲易用性,但它能显著减少计算单元用量和二进制文件大小,从而降低租金。在竞争激烈、对性能至关重要的操作中,这些节省尤其重要。

与此同时,也有理由认为,并非所有程序都应该用汇编编写。

尽管情况已有显著改善,但过去相关工具一直有限。更重要的是,性能提升往往伴随着重大取舍:需要手动验证正确性,并承担更高的审计成本。这是因为缺乏自动化工具,而且汇编语法更难阅读、编写和理解。 

反过来,也可以说编译型语言就像黑箱,掩盖了编译器所做的选择。因此,汇编提供的额外透明度和控制力往往能揭示使用编译型语言时难以察觉的问题。事实上,我们近期在 Rust SDK 中取得的绝大多数性能突破,正是源于意识到我们可以手写比编译器更高效的字节码。

本文将介绍:

  • 什么是 sBPF 汇编,以及它如何提供对虚拟机的直接控制
  • 从伯克利包过滤器到 eBPF 的演变,以及 Solana 采用它的原因
  • sBPF 的虚拟机架构、指令集和内存模型
  • 如何设置开发环境并构建 sBPF 程序
  • 通过实用的 memo 示例逐步学习汇编编程
  • 编写底层代码时必须考虑的安全事项

什么是汇编?

汇编是机器码的一种人类可读形式,也是直接对应 CPU 或 VM 指令集的最低级编程语言。

汇编不使用变量和函数,而是操作寄存器(CPU 中高速的临时存储位置)、内存地址(RAM 或磁盘中的物理位置),以及加载(从内存读取)、存储(持久化到内存)、算术和跳转(控制流)等基本操作。

每条汇编指令都与一条等效的机器码指令一一对应。 

这种一一映射意味着程序员可以精确控制处理器执行的内容,包括由哪些寄存器保存数据、如何访问内存,以及操作的确切顺序。 

高级语言中的一个函数可能会生成数十条指令,而汇编没有不透明的抽象,能够完全透明地展示并控制机器行为。

什么是伯克利包过滤器(BPF)和 eBPF?

伯克利包过滤器(BPF)诞生于 1992 年,最初是用于在 Unix 内核中高效过滤网络数据包的虚拟机。原始 BPF 使用简单的指令集和基于寄存器的架构,可以在内核中安全运行沙盒代码。

扩展伯克利包过滤器(eBPF)对这一概念进行了现代化改造,将其从数据包过滤器扩展为通用虚拟机。eBPF 引入了 64 位架构、更多寄存器和更丰富的指令集,使复杂程序能够在内核空间中安全运行,用于网络、安全和系统监控。

Solana 采用 eBPF,是因为它提供了经过验证且安全的执行环境,并内置沙盒机制。沙盒机制可防止程序访问系统资源、导致节点崩溃或干扰其他程序,而确定性执行则确保所有验证者生成相同的结果。 

此外,基于寄存器的架构和成熟的工具链非常适合高性能链上执行,而现有的 LLVM 后端也允许开发者从 Rust 等高级语言进行编译。

sBPF 虚拟机架构

执行 Solana 程序时,运行时会将 sBPF 字节码加载到内存中,执行静态验证以确保安全性(检查无限循环、无效内存访问和指令使用是否正确),然后在虚拟机中执行字节码。 

VM 提供受控的 64 位执行环境,程序在其中与宿主系统及其他程序完全隔离运行,所有资源访问都由运行时协调。

sBPF 指令集架构

sBPF 使用 11 个 64 位寄存器(r0-r10),其中 r10 用作只读帧指针,r0 用作返回寄存器。 

指令采用统一格式,其中操作码指定操作(算术、逻辑、内存访问、跳转),操作数指明源/目标寄存器、偏移量和/或立即数。 

主要指令类别包括 ALU 操作(加法、减法、按位运算)、内存操作(加载/存储)和控制流(条件/无条件跳转)。

sBPF 内存模型

sBPF 程序在结构化内存布局中运行:用于局部变量和函数调用的 4KB 栈、用于动态分配的堆、包含字节码和常量的只读程序数据,以及映射到 Solana 账户且程序可在执行期间访问的账户数据区域。

所有内存访问都会进行边界检查,程序无法访问指定区域之外的内存。

sBPF 中的 Solana 系统调用

sBPF 程序无法直接访问系统资源或执行 I/O 操作。它们需要通过系统调用请求服务。系统调用是一类将控制权转交给 Solana 运行时的特殊指令。 

在 sBPF 汇编中,系统调用使用 call 指令和调用符号发起,编译器会在汇编时将该符号修改为调用目标。目前,系统调用通过基于文本的动态重定位发起;这套复杂的字符串查找表系统会在 JIT 编译时将符号映射到 32 位 Mumur3 哈希。不过,目前有一项活跃提案,计划用静态系统调用取代这一机制,从而大幅简化调用约定。发起系统调用时,参数通过寄存器 1 至 5 传递,其中寄存器 5 有时用作栈溢出空间,返回值则写回 r0。

常见的系统调用包括内存操作(sol_memcpy、sol_memcmp)、加密函数(哈希、签名验证)、日志记录和跨程序调用。

SBPF 汇编教程 

设置环境 

过去,编写 sBPF 汇编需要完整的 Solana 工具链,这个过程臃肿、复杂且依赖平台。

因此,Dean Little 创建了 sBPF SDK,为 sBPF 程序的初始化、构建、编译、测试和部署提供完整的端到端解决方案。

你可以使用 Cargo 在任何操作系统上安装 SDK:

代码
cargo install --git https://github.com/blueshift-gg/sbpf.git

在开始编写代码之前,还建议安装 VS Code sBPF 汇编扩展,以获得语法高亮、自动补全和错误检测功能。

设置项目 

使用以下命令创建新的项目脚手架:

代码
sbpf init <name_of_the_project>

这会创建一个包含 Mollusk Rust 测试的项目。

如果你希望创建包含 TypeScript 测试的脚手架,可以使用以下命令初始化:

代码
sbpf init <name_of_the_project> --ts-tests 

Memo sBPF 汇编示例

很难想象还有比 memo 更简单的程序,因此它非常适合作为 sBPF 汇编的入门示例。 

这个程序只做一件事:接收你发送的任何指令数据,并将其记录到区块链中。没有账户,没有复杂逻辑,只有对 Solana 虚拟机纯粹的指令级控制。

首先查看完整程序:

代码
.equ NUM_ACCOUNTS, 0x00
.equ DATA_LEN, 0x08
.equ DATA, 0x10
.globl entrypoint
entrypoint:
  ldxdw r0, [r1+NUM_ACCOUNTS]
  ldxdw r2, [r1+DATA_LEN]
  add64 r1, DATA
  call sol_log_
  exit

定义常量

程序首先定义三个常量,它们映射到 Solana 运行时序列化输入区域的结构:

代码
.equ NUM_ACCOUNTS, 0x00 // Account count offset
.equ DATA_LEN, 0x08     // Data length offset  
.equ DATA, 0x10          // Data start offset

这些偏移量对应 Solana 运行时在内存中封装指令数据的位置。

VM 调用程序时,会通过寄存器 r1 向我们传递一个结构化缓冲区,而这些常量可帮助我们定位该结构中的数据。 

sbpf.xyz 等工具可以根据你的账户和指令数据布局自动计算这些偏移量。

创建入口点

接下来,为程序创建入口点和验证逻辑。

代码
.globl entrypoint
entrypoint:
  ldxdw r0, [r1+NUM_ACCOUNTS]

.globl 入口点指令告诉链接器将 entrypoint 符号设为全局可见。Solana 运行时会查找此符号,以确定从何处开始执行程序。有趣的是:虽然我们通常将其命名为 entrypoint,但实际上可以使用任何名称!

第一条指令采用了一种巧妙的汇编专用验证技术:由于 r0 是返回寄存器,且任何非 0 返回值都会作为错误码,因此将账户数量直接加载到 r0 后,只要传入的账户超过 0 个,该程序就会以非零错误码退出。 

这样就不必手动跳过任何输入账户,也能验证指令数据在 VM 中的偏移量。

发起 sol_log 系统调用

最后即可执行 sol_log_ 系统调用:

代码
ldxdw r2, [r1+DATA_LEN]   ; Load memo length
add64  r1, DATA           ; Point r1 to memo data
call sol_log_             ; Log the memo
exit                      ; Exit with r0 value

sol_log_ 系统调用要求 r2 保存消息长度,r1 保存指向待记录消息的指针。幸运的是,在序列化输入区域中,运行时会自动在指令数据前添加一个 64 位长度计数器。因此,我们只需执行以下操作即可发起 sol_log_ 系统调用:

  • 将 DATA_LEN 偏移量处的值加载到 r2
  • 让 r1 指向指令数据起始位置的偏移量

两个寄存器指向正确的值后,只需发起系统调用,然后使用 r0 中的值退出。

构建并部署程序

你可以使用 SBPF 自带的、由 Claire Fan 使用 Rust 编写的内置汇编器来构建程序。这个 5MB 的 Rust 二进制文件取代了使用 Solana 平台工具时所需的 >2GB LLVM 工具链。 

要运行构建,只需执行:

代码
sbpf build

构建程序后,可使用以下命令进行部署:

代码
sbpf deploy

与程序交互

要使用脚手架生成的测试来测试程序,可以运行 sbpf test;也可以运行 sbpf e2e,通过一条命令完成构建、部署和测试。

sBPF 汇编的安全注意事项

编写汇编代码意味着要对安全性承担全部责任:没有编译器替你发现错误。每条指令都会直接影响程序安全,因此必须遵循以下核心安全原则。

输入验证

汇编程序必须手动验证所有输入。使用前务必验证账户数量、数据长度和缓冲区大小。

例如,在 memo 程序中,将账户数量加载到 r0 即可实现自动验证:如果错误地传入账户,程序就会失败。处理数据时,应在访问内存前检查长度是否处于预期范围内。

内存边界检查

sBPF 不提供自动边界检查。访问数组或缓冲区之前,必须手动验证读写操作是否位于已分配的边界内。在访问内存前执行简单的边界检查,可以防止崩溃和数据损坏:

代码
jgt r2, MAX_LENGTH, error    # Check if length exceeds limit
ldxb r3, [r1+r2]            # Safe to load if check passes

寄存器管理

寄存器保存着关键的程序状态。调用函数或系统调用时,应将重要值保存到栈或其他寄存器中。 

帧指针(r10)和 r0 中的返回值需要特别注意:破坏这些值可能导致程序崩溃或产生安全漏洞。

算术安全

手动检测溢出对于算术运算至关重要。在较大的值相加之前,应检查结果是否可能超出 64 位限制。 

除法运算需要显式检查除数是否为零,以避免运行时错误。

系统调用参数验证

系统调用要求参数有效,无效输入会导致调用失败。调用系统调用前,应确保寄存器包含正确的指针、长度和值。 无效参数不仅会导致失败,还会无谓地消耗计算单元。

总结

sBPF 汇编并不适合所有人,而这正是它的意义所在。 

大多数开发者应该继续使用 Rust,让编译器负责优化。但对于那些需要榨干每一个计算单元或构建性能关键型基础设施的人来说,汇编能提供任何高级语言都无法实现的能力:完全控制。

本文介绍了相关基础知识,从理解 sBPF 如何融入 Solana 架构,到构建你的第一个 memo 程序。 

memo 示例看似简单,却展示了在更复杂程序中同样会用到的核心原则:

  • 直接操作寄存器
  • 手动管理内存
  • 显式处理系统调用

这些收益也有代价:你用安全保障换取速度,用抽象换取控制。只有当你已经优化过 Rust 代码但仍需更高性能、正在构建每一微秒都至关重要的基础设施,或需要完成编译器根本无法充分优化的任务时,才应该选择汇编。 

其他情况下,别给自己找麻烦,继续使用 Rust。

工具正在不断改进,社区正在持续壮大,性能优势也不言自明。但请记住:“能力越大,确保不把东西搞坏的责任就越大”。

如果你想进一步了解如何使用 sBPF 汇编,请阅读 Blueshift 上的汇编入门课程,并通过其中的挑战检验自己的技能!

订阅 Helius

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