本文介绍了 Solana 生态系统中一种极致的程序优化方法:直接编写 sBPF 汇编。文章解释了 sBPF 汇编的原理、优势、适用场景以及安全注意事项,并提供了一个简单的 memo 程序的示例,展示了如何使用 sBPF 汇编直接控制 Solana 虚拟机,从而实现极致的性能优化。
目前,Solana 生态系统中正在进行一场程序优化的平行 逐底 竞赛。
从较高的层面来看,诸如 Pinocchio 这样的库正在彻底改变 Rust 开发,在计算效率上实现了数量级的改进。与此同时,在绝对最低的层面上,一群专注的开发者,因为他们彼此之间对编译器的不屑而团结在一起,正在更进一步。他们没有用像 Rust 或 C 这样的编译语言编写 Solana 程序,而是将重点放在精心手动编写字节码上,以便从每一个最后的指令中榨取最大的性能。
只有当我们用 VM 的原生语言直接指示 VM 时,这些底层的收益才有可能实现:sBPF Assembly,Solana 自己的扩展伯克利包过滤器(extended Berkeley Packet Filter, eBPF)变体,这是在每个链上程序中使用的和执行的字节码。
编写 sBPF 汇编程序使开发者能够直接访问 Solana 虚拟机(Virtual Machine)的最低层接口。虽然 Rust 编译器和 LLVM 尝试进行优化,但由于语言语法的冗长性不足,或者缺乏足够的上下文来进行更好的编译选择,与熟练的开发者相比,他们最终生成的字节码通常不是最佳的,因为熟练的开发者可以完全控制汇编所能实现的指令级别。
虽然这种额外的控制级别是以人体工程学为代价的,但计算单元使用和二进制大小(以及租金)的节省是显著的。在高度竞争、竞争激烈、对性能至关重要的操作中,这些节省尤其重要。
与此同时,也有人认为并非所有程序都应该用汇编编写。
虽然情况已经有了显著改善,但从历史上看,工具一直有限,更重要的是,性能的提升往往伴随着手动验证正确性和增加审计成本的重大权衡。这是由于缺乏自动化工具,并且语法更难阅读、编写和理解。
相反,也可以说编译语言是一个黑盒子,掩盖了编译器所做的选择。因此,汇编的额外透明性和控制通常可以揭示在使用编译语言时不容易看到的东西。事实上,我们 Rust SDK 中最近的绝大多数性能突破实际上都是通过意识到我们可以手动编写比编译器更高效的字节码而发现和实现的。
在本文中,你将学习:
汇编是机器代码的一种人类可读的变体:直接对应于 CPU 或 VM 指令集的最低级编程语言。
汇编不是变量和函数,而是操作寄存器(CPU 中快速的临时存储位置)、内存地址(RAM 或磁盘上的物理位置)和基本操作,例如加载(从内存读取)、存储(持久化到内存)、算术和跳转(控制流)。
汇编中的每个指令都与机器代码中的等效指令一一对应。
这种一一对应的关系意味着程序员可以精确地控制处理器执行的内容,包括哪些寄存器保存数据、如何访问内存以及操作的确切顺序。
与高级语言不同,在高级语言中,一个函数可能会生成几十条指令,而汇编则提供了对机器行为的完全透明和控制,而没有不透明的抽象。
伯克利包过滤器(Berkeley Packet Filter, BPF)起源于 1992 年,是一种用于在 Unix 内核中高效过滤网络数据包的虚拟机。最初的 BPF 使用简单的指令集和基于寄存器的架构,可以在内核中安全地运行沙盒代码。
扩展伯克利包过滤器(Extended Berkeley Packet Filter, eBPF)对这个概念进行了现代化改造,从数据包过滤器扩展到通用虚拟机。 eBPF 引入了 64 位架构、更多的寄存器和更丰富的指令集,使复杂的程序能够在内核空间中安全地运行,用于网络、安全和系统监控。
Solana 采用 eBPF 是因为它提供了一个经过验证的安全执行环境,具有内置的沙盒功能。沙盒可以防止程序访问系统资源、崩溃节点或干扰其他程序,而确定性执行可确保所有验证器产生相同的结果。
此外,基于寄存器的架构和成熟的工具链使其成为高性能链上执行的理想选择,而现有的 LLVM 后端允许开发者从 Rust 等高级语言进行编译。
当 Solana 程序执行时,运行时会将 sBPF 字节码加载到内存中,执行静态验证以确保安全(检查无限循环、无效的内存访问和正确的指令使用),然后在虚拟机中执行它。
VM 提供了一个受控的 64 位执行环境,程序在该环境中完全独立于主机系统和其他程序运行,所有资源访问都通过运行时进行协调。
sBPF 使用 11 个 64 位寄存器(r0-r10)进行操作,其中 r10 用作只读帧指针,r0 用作返回寄存器。
指令遵循一致的格式,其中 操作码 指定操作(算术、逻辑、内存访问、跳转),操作数 指示源/目标寄存器、偏移量和/或立即值。
关键指令类别包括 ALU 操作(加、减、按位运算)、内存操作(加载/存储)和控制流(条件/无条件跳转)。
sBPF 程序在结构化的内存布局中运行:一个 4KB 的堆栈用于局部变量和函数调用,一个堆用于动态分配,一个只读程序数据包含字节码和常量,以及映射到 Solana 账户的账户数据区域,程序可以在执行期间访问这些区域。
所有内存访问都经过边界检查,程序无法访问其指定区域之外的内存。
sBPF 程序无法直接访问系统资源或执行 I/O 操作。相反,它们通过系统调用请求服务,系统调用是将控制权转移到 Solana 运行时的特殊指令。
在 sBPF 汇编中,系统调用使用 call
指令和一个调用符号来调用,该调用符号在汇编时由编译器修改为调用目标。目前,系统调用是通过基于文本的动态重定位来调用的;这是一个复杂的字符串查找表系统,它在 JIT 编译时将符号映射到 32 位的 Mumur3 哈希值。但是,目前有一个 active proposal 提案,用静态系统调用来替换它,从而大大简化了调用约定。当调用系统调用时,参数通过寄存器 1 到 5 传递,其中寄存器 5 有时充当堆栈溢出,返回值写回到 r0。
常见的系统调用包括内存操作(sol_memcpy
、sol_memcmp
)、加密函数(哈希、签名验证)、日志记录和跨程序调用。
虽然常规的 sBPF 指令的 CU 成本都为 1,但系统调用的计算单元计算方式却大相径庭。在其添加到协议时,每个系统调用都会进行基准测试,从而告知基本调用成本(例如,对于 CPI 调用,基本调用成本为 1000),在某些情况下,还会根据消耗的数据量应用额外的可变成本。
编写 sBPF 汇编传统上需要完整的 Solana 工具链:这是一个臃肿、复杂、依赖于平台的过程。
因此,Dean Little 创建了 sBPF SDK,为引导、构建、编译、测试和部署 sBPF 程序提供了一个完整的端到端解决方案。
你可以使用 Cargo 在任何操作系统上安装 SDK:
cargo install --git https://github.com/blueshift-gg/sbpf.git
在深入研究代码之前,建议你还安装 VS Code sBPF 汇编扩展,以便进行语法高亮显示、自动完成和错误检测。
使用以下命令创建一个新的项目框架:
sbpf init <项目名称>
这将创建一个带有 Mollusk Rust 测试的项目。
如果你想创建一个带有 TypeScript 测试的框架,你可以使用以下命令初始化一个带有 TypeScript 测试的新框架:
sbpf init <项目名称> --ts-tests
很难想象有比 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 // 账户数量偏移量
.equ DATA_LEN, 0x08 // 数据长度偏移量
.equ DATA, 0x10 // 数据起始偏移量
这些偏移量对应于 Solana 运行时在内存中打包指令数据的位置。
当 VM 调用我们的程序时,它会在寄存器 r1 中传递给我们一个结构化的缓冲区,这些常量让我们能够导航该结构。
诸如 sbpf.xyz 之类的工具可以根据你的账户和指令数据布局自动计算这些偏移量。
然后,我们继续创建入口点和程序的验证。
.globl entrypoint
entrypoint:
ldxdw r0, [r1+NUM_ACCOUNTS]
.globl
入口点指示符告诉链接器使入口点符号全局可见。 Solana 运行时会查找此符号以了解从哪里开始执行你的程序。有趣的事实:虽然我们通常称它为入口点,但实际上它可以被命名为任何名称!
第一条指令使用了一种巧妙的汇编特定验证技术:由于 r0 是我们的返回寄存器,并且任何不是 0 的返回值都用作错误代码,因此将账户数量直接加载到 r0 中会强制此程序在传入超过 0 个账户时以非零错误代码退出。
这使我们可以跳过手动传递任何输入的账户来验证 VM 中指令数据的偏移量。
然后,我们终于可以执行 sol_log_
系统调用:
ldxdw r2, [r1+DATA_LEN] ; 加载 memo 长度
add64 r1, DATA ; 将 r1 指向 memo 数据
call sol_log_ ; 记录 memo
exit ; 使用 r0 值退出
sol_log_
系统调用需要 r2 中的消息长度和 r1 中指向要记录的消息的指针。幸运的是,在序列化的输入区域中,运行时会自动在指令数据前面加上 64 位长度计数器。因此,我们可以通过简单地执行以下操作来调用 sol_log_
系统调用:
一旦我们的两个寄存器指向正确的值,我们只需调用系统调用,然后使用 r0 中的任何值退出。
你可以使用 SBPF 内置的汇编器构建你的程序,该汇编器由 Claire Fan 编写;这是一个 5mb 的 Rust 二进制文件,它可以替代你必须与 Solana 平台工具一起使用的 >2gb 的 LLVM 工具链。
要运行构建,只需执行:
sbpf build
构建程序后,你可以使用以下命令部署它:
sbpf deploy
要使用 scaffolded 测试来测试你的程序,你可以运行 sbpf test
,或者,如果你愿意,你可以运行完整的管道 sbpf e2e
,以在一个命令中构建、部署和测试。
编写汇编代码意味着对安全性承担全部责任:没有编译器来捕获你的错误。每条指令都会直接影响程序的安全性,因此这些核心安全原则至关重要。
汇编程序必须手动验证所有输入。始终在使用前验证账户数量、数据长度和缓冲区大小。
例如,在我们的 memo 程序中,将账户数量加载到 r0 中创建了自动验证:如果账户传递不正确,程序将失败。对于数据处理,在访问内存之前,请根据预期范围检查长度。
sBPF 不提供自动边界检查。在访问数组或缓冲区之前,手动验证你的读/写操作是否停留在分配的边界内。在内存访问之前进行简单的边界检查可以防止崩溃和数据损坏:
jgt r2, MAX_LENGTH, error # 检查长度是否超过限制
ldxb r3, [r1+r2] # 如果检查通过,则可以安全加载
寄存器保存关键的程序状态。在调用函数或系统调用时,通过将重要值保存到堆栈或其他寄存器来保留它们。
帧指针 (r10) 和 r0 中的返回值需要特别注意:损坏这些值可能会导致程序崩溃或创建安全漏洞。
手动溢出检测对于 arithmetic operations 至关重要。在添加大值之前,检查结果是否可能超过 64 位限制。
除法运算需要显式的零检查以防止运行时错误。
系统调用需要有效的参数,否则将因无效输入而失败。在调用系统调用之前,请确保寄存器包含正确的指针、长度和值。无效的参数不仅会导致失败,还会不必要地消耗计算单元。
sBPF 汇编并不适合所有人,而这正是关键所在。
大多数开发者应该坚持使用 Rust,让编译器处理优化。但是对于那些optimizing for every last compute unit或构建性能关键型基础设施的人来说,汇编提供了一种高级语言无法提供的功能:完全控制。
我们在这里介绍了基础知识,从了解 sBPF 如何适应 Solana 的架构到构建你的第一个 memo 程序。
memo 示例可能看起来微不足道,但它演示了你将在更复杂的程序中使用的核心原则:
这些收益是有代价的:你正在用安全网换取速度,用抽象换取控制。当你已经优化了 Rust 代码并且仍然需要更多性能时,当你构建基础设施时,每一微秒都很重要,或者当你需要做一些编译器无法很好地优化时,你应该选择汇编。
对于其他一切,请省去麻烦并坚持使用 Rust。
工具越来越好,社区正在发展,性能优势不言而喻。但请记住:“能力越大,责任越大,不要破坏东西”。
如果你想阅读有关如何使用 sBPF 汇编的其他内容,请阅读 Blueshift 上的 Introduction to Assembly Course,并通过其中存在的一些 challenges 测试你的技能!
- 原文链接: helius.dev/blog/sbpf-ass...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!