这篇文章详细介绍了Solana BPF (sBPF) 虚拟机的内存布局和寄存器约定。它阐述了五种内存区域及其用途,并深入探讨了sBPF的12个寄存器各自的角色、使用规则,通过具体汇编代码和执行跟踪展示了寄存器的行为。
本教程介绍了 Solana BPF (sBPF) 的内存布局及其虚拟机寄存器的作用。我们将演示程序如何在 sBPF 虚拟机中将数据从内存读入寄存器以及写入寄存器的约定。
sBPF 虚拟机的内存分为 5 个不同的区域。这五个区域各有其特定用途。任何试图访问这些区域之外的内存,或违反区域权限(例如写入只读数据)的行为,都将触发访问违规错误。我们稍后将展示这一点。
在我们描述每个区域之前,我们想强调的是,EVM 直接从合约代码中读取其字节码,而 SVM 在运行之前将字节码加载到内存中。
下面的地址是每个内存区域的起始地址,并在 Solana 源代码 中定义为 u64 常量。诸如 MM_BYTECODE_START、MM_RODATA_START 等名称是 Rust 常量定义中每个区域的起始地址。这里的 MM 代表“memory map”(内存映射),而 RO 代表“read-only”(只读)。Solana 为每个内存区域保留 4GiB,以防止区域间的地址冲突,但会根据每个区域实际所需的大小进行分配。
0x000000000: MM_RODATA_START — 4 GiB 用于只读 ELF 数据(常量、静态数据)0x100000000: MM_BYTECODE_START — 4 GiB 用于程序字节码区域0x200000000: MM_STACK_START — 4 GiB 用于执行栈0x300000000: MM_HEAP_START — 4 GiB 预留给堆内存区域0x400000000: MM_INPUT_START — 4 GiB 用于当前事务中序列化的输入数据(程序 ID、账户和指令数据)。这在程序启动时由运行时填充。Solana BPF 内存布局可以如下图所示:

Solana 客户端定义了如下代码片段所示的常量:
Copypub const MM_RODATA_START: u64 = 0;
pub const MM_BYTECODE_START: u64 = MM_REGION_SIZE; // = MM_REGION_SIZE * 1
pub const MM_STACK_START: u64 = MM_REGION_SIZE * 2;
pub const MM_HEAP_START: u64 = MM_REGION_SIZE * 3;
pub const MM_INPUT_START: u64 = MM_REGION_SIZE * 4;
MM_REGION_SIZE 定义了一个虚拟内存块的大小,计算方式为 $1 \ll \text{VIRTUAL\_ADDRESS\_BITS}$。VIRTUAL_ADDRESS_BITS 在 sBPF 源代码 中定义为 $32$,这意味着在每个区域内,有 $2^{32}$ 个不同的字节地址,这使得每个区域拥有 4GiB 的可寻址空间。
乘数(* 2、* 3、* 4)使每个区域的起始地址以 MM_REGION_SIZE 的倍数递增。

要在内存中使用数据,我们必须首先将其加载到寄存器中。虚拟机为每个寄存器分配了特定的角色,开发人员应约定俗成地遵循这些角色。下一节将通过最简单的示例描述这些角色。
sBPF 虚拟机有 $12$ 个寄存器,命名为 $r0$ 到 $r11$。寄存器 $r0$-$r10$ 对程序开放,而 $r11$ 存储程序计数器,Solana 程序既不能读取也不能写入。
$r0$ 用于保存返回值,$r1$-$r5$ 是参数寄存器,$r6$-$r9$ 是通用暂存寄存器,也称为被调用者保存寄存器(用于在跨调用中存储临时值),而 $r10$ 是当前调用栈的帧指针寄存器。
在我们检查每个寄存器之前,让我们先设置一个环境来观察寄存器值在执行过程中如何变化。
创建一个名为 register-experiment 的新文件夹。在此文件夹内打开终端并运行 solana-test-validator 命令。这将启动一个本地 Solana 集群并在 register-experiment 内部创建一个 test-ledger 目录
本地验证器运行后:
register-experiment 目录中创建一个名为 src 的文件夹。此文件夹将包含我们的汇编程序和一个跟踪文件,显示每条指令执行后寄存器状态如何变化。src/inputs.asm 文件用于我们的汇编代码。你的目录结构应如下所示:
Copyregister-experiment
├── src
└── inputs.asm
我们将使用 agave-ledger-tool(随 Solana 安装提供)来运行我们的汇编代码并创建寄存器跟踪。
使用以下命令运行以下示例。它针对我们的本地测试账本运行,以 $200,000$ 计算单位限制执行汇编程序,并生成一个跟踪文件,显示寄存器值在执行期间如何变化。
Copyagave-ledger-tool program run src/inputs.asm --limit 200000 --trace src/trace.txt --ledger test-ledger
注意:在某些架构上,例如 Apple Silicon,运行此命令可能会触发 JitNotCompiled 错误。要解决此问题,请添加 --mode interpreter 标志以强制解释执行而不是 JIT 编译。
现在我们的设置已完成,我们将演示在执行过程中如何使用从 $r0$ 到 $r11$ 的每个寄存器。我们的演示将值硬编码到寄存器中以隔离其行为。我们将在下一篇文章中展示如何从内存中读取并将值加载到寄存器中。
$r0$程序通过写入 $r0$ 向运行时传达成功或失败。虚拟机在执行完成时读取此值。可能的结果是:
$0$。exit 指令之前终止,因此运行时会忽略 $r0$。让我们根据上面列出的三种可能结果,演示程序如何使用 $r0$ 传达成功或失败。
1/3 一个显示成功执行返回 $0$ 的示例
在 src/inputs.asm 中写入一个简单的 exit 指令:
Copyexit
使用 agave-ledger-tool 命令运行它,你将得到一个成功退出并返回 $0$ 的结果:

以下跟踪将在 trace.txt 中创建,显示 $r0$ 是 0000000000000000(跟踪中的第一列是 $r0$,第二列是 $r1$ ……):
CopyFrame 0
0 [0000000000000000, 0000000400000000, ...] 0: exit
上述演示表明,如果函数成功返回,$r0$ 将保存 $0$。
2/3 一个显示可控失败返回非零值的示例。
在可控失败的情况下,我们可以写入 $r0$。如果出于某种原因,我们想返回一个自定义错误代码,例如 $600$ (0x0000000000000258),程序仍然会干净地退出,但 $r0$ 将保存错误代码而不是零:
Copymov r0, 600 ; 设置自定义错误代码
exit
汇编代码示例中的注释在直接复制时可能会导致解析错误,因为 agave-ledger-tool 在有注释时会抛出错误。如果发生这种情况,请删除注释 ; Set custom error code。
使用 agave-ledger-tool 运行上述代码,你将得到以下输出:

跟踪显示 $r0$ 从 $0$ 开始,其当前状态为十六进制的 0000000000000258,即十进制的 $600$:
CopyFrame 0
0 [0000000000000000, 0000000400000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000000000000, 0000000200001000] 0: lddw r... 如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!