正如在 compute units tutorial 中所讨论的,Solana 程序调用所消耗的计算单元等于执行的 SBF(Solana Bytecode Format)指令数量加上任何 syscalls 的运行时成本。本文将深入探讨 SBF 指令集,并演示如何使用执行追踪(execution traces)和 agave-ledger-tool 来分析这些指令。
从 Rust 到 SBF 教程中我们了解到,Solana 程序被编译为 SBF(Solana Bytecode Format),它运行在 sBPF virtual machine, 上,这是一个衍生自 eBPF 的 Solana 专用虚拟机(VM)。SBF 指令类似于 x86 或 ARM 等传统汇编语言,其外观如下所示:
mov64 r0, 1 ; move 1 (64 bit padded) to register 0
mov64 r1, 2 ; move 2 (64 bit padded) to register 1
add64 r0, r1 ; add register 1 to register 0, store result in register 0
先决条件
本文假设您已完成:
- Compute Units tutorial - 了解 Solana 计算单元的工作原理
- Rust 到 SBF 编译教程 - 了解 Solana 程序的编译流水线
- 还需要基本熟悉汇编概念(寄存器、内存寻址、跳转)。
在 Rust 到 SBF 的编译教程中,我们解释了 Solana 程序如何经历三个主要阶段:Rust 到 LLVM IR,再到 SBF 字节码,最后到 native code。本文涵盖了 Solana 的 VM 架构,并展示了如何在实践中分析 SBF 字节码。
Solana 虚拟机架构
Solana VM 是基于寄存器的(register-based),这与基于栈的(stack-based)Ethereum VM 不同。在基于寄存器的 VM 中,指令对一组固定数量、固定大小的被称为寄存器(registers)的存储槽进行操作,并且每条指令都会显式地指定它读取和写入的寄存器。在基于栈的 VM 中,指令隐式地对栈数据结构的顶部进行操作,因此操作数必须被压入(pushed)栈中或从栈中弹出(popped)才能被使用。
eBPF 定义了 11 registers(R0–R10),均为 64 位宽。Solana 的 sBPF VM 实现了相同的 11 个寄存器,但在内部维护了一个隐藏的第十二个寄存器 R11,用于程序计数器(program counter)的跟踪。由于在执行期间 R11 既不能被程序读取也不能被写入,因此只有原始的 11 个 eBPF 寄存器对程序代码是可见的。
根据 eBPF 规范,这些寄存器有以下用途:
R0:该寄存器保存函数调用的返回值和程序的退出值R1-R5:这些寄存器保存函数调用的参数R6-R9:这些是被调用者保存(callee-saved)的寄存器,意味着它们必须在函数调用期间被保留R10:这是一个只读的帧指针(frame pointer),指向当前的栈帧(stack frame)
R0-R5 是暂存寄存器(scratch registers),函数可以直接覆盖它们而无需保存。R6-R9 是被调用者保存寄存器(callee-saved registers),必须在函数调用期间被保留。这意味着当函数 foo() 调用 bar() 时,foo() 在调用后需要的任何值都必须保存在 R6–R9 中。如果 bar() 需要使用这些寄存器,它会在进入时将它们的内容保存到自己的栈帧中,并在返回 foo() 之前恢复它们。这个过程被称为溢出(spilling,保存到栈)和填充(filling,从栈恢复)。
SBF 指令集(Opcodes)
如我们所知,SBF 基于 eBPF,因此它们使用相同的指令集。Solana VM 使用的所有指令(opcodes)都定义在 here,你也可以在 eBPF specification 中找到带有描述的完整指令集。
这些 opcodes 包括:
算术和逻辑操作:
- 算术 opcodes:
add、sub、mul、div、mod、neg(取负)、sdiv(有符号除法)和smod(有符号取模) - 逻辑 opcodes:
and、or、xor、lsh(左移)、rsh(右移)、arsh(arithmetic right shift) - 每个 opcode 都有一个 64 位变体(默认)和一个 32 位变体
- 每个 opcode 有两种形式:一种接受两个寄存器(目标和源),另一种接受一个寄存器和一个立即数(immediate value,硬编码到程序字节码中的常量)。例如,
add64 r0, r1将寄存器 r1 加上 r0,而add64 r0, 42将常量 42 加到 r0。
数据移动:
- 还有一个
movopcode,用于在寄存器之间或从立即数到寄存器复制值
控制流:
Solana VM 具有用于无条件和条件跳转的 opcodes。
ja执行无条件跳转。它将执行移动到另一个指令偏移量(offset),而不进行任何检查。- 其次是条件跳转。
jeq在两个值相等时跳转。jne在不相等时跳转。jlt和jgt检查小于或大于。jle和jge检查小于等于或大于等于。 - 也有带符号的版本。
jslt、jsgt、jsle和jsge处理相同的比较,但将操作数视为有符号整数。 call将执行移动到字节码的标记部分(如编译后的函数)。对于 syscalls,程序使用syscall指令(而不是call)。syscall 指令后跟一个标识符,指定要调用的 syscall,例如sol_log_或sol_log_64_。exit返回给调用者,如果调用栈为空,则结束程序执行。
内存操作:
也有用于执行内存读写操作的 opcodes。
ldx从内存读入寄存器,而stx将寄存器写入内存。- load 和 store 指令具有带后缀的尺寸变体,这些后缀显示每个版本读取或写入多少字节。所以对于 load,我们有:
ldxdw、ldxw、ldxh、ldxb。因此ldxdw加载 8 个字节(双字),ldxw加载 4 个字节(一个字),ldxh加载 2 个字节(半字),而ldxb加载 1 个字节。store 版本遵循相同的模式。由于所有寄存器都是 64 位宽,较小的操作(32 位、16 位、8 位)会写入寄存器的低位,并将高位置零。 - Load 接受两个操作数:一个目标寄存器和一个内存地址。例如,
ldxdw r0, [r1+0x08]将位于地址r1+0x08的内存中的 8 个字节加载到寄存器r0中。语法[r1+0x08]的意思是:获取存储在r1中的地址,加上一个 0x08 字节的偏移量(offset),然后从该最终地址读取。 - Store 也接受两个操作数:一个内存地址和一个源寄存器。例如,
stxdw [r1+0x08], r0将寄存器r0中的 8 个字节存储到地址为r1+0x08的内存中。 - 内存地址如上所示,被计算为基址寄存器加上偏移量(例如,
[r1+0x08])。
后续步骤
既然你已经了解了 sBPF VM 的架构、寄存器约定和指令集,下一篇文章将演示如何使用追踪(traces)来分析程序执行,并从实际的字节码执行中计算计算单元。
本文是 Solana development 教程系列的一部分