这篇文章深入解释了Solana BPF加载器如何序列化程序指令输入,以及程序入口点如何接收并反序列化这些输入,以获取程序ID、账户和指令数据。文章详细描述了内存布局、序列化格式和相应的Rust反序列化代码实现。
本文解释了 BPF 加载器如何序列化程序指令输入,入口点如何接收它们,以及程序如何反序列化该输入以读取程序 ID、账户和指令数据。在本文的第二部分中,我们将介绍程序如何将传入指令路由到适当的处理程序,以及入口点为实现此功能而设置的支持代码。
BPF 加载器是 Solana 的原生程序之一,其实现在此。它负责在链上部署、升级、加载和执行 BPF 程序。
原生程序是验证器实现的一部分的程序,可以作为集群升级的一部分进行升级(例如修复错误或添加功能)。
Solana 有多个 BPF 加载器,但较旧的(V1 和 V2)已被弃用,不再用于新的部署。可升级的 BPF 加载器是当前的标准,除非另有说明,本文中的“BPF 加载器”均指它——包括此处描述的序列化格式。
BPF 加载器设置执行环境并启动 Solana 虚拟机。虚拟机然后调用程序入口点,程序执行开始。
回顾上一篇文章,当我们分析执行轨迹时,字节码执行从 <entrypoint> 标签开始,该标签标记着程序的入口点函数。这就是执行开始的地方。
在 Anchor 程序中,#[program] 宏会自动为你生成此入口点。该宏创建入口点函数,设置指令分派(根据指令数据的前 8 字节将指令路由到你的指令处理程序),并处理输入反序列化和输出序列化。你永远不会自己看到或编写入口点。
在原生 Rust 程序中,你必须使用 solana_program crate 中的 entrypoint! 宏自己定义入口点,然后自己处理指令分派。
entrypoint! 宏生成设置程序入口点的代码。为了理解这段代码的样子,让我们看看生成的函数签名:
#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64;
该函数接受一个参数(input),一个指向存储在虚拟机内存中的序列化指令输入的指针。指令输入由三个参数组成:
program_id:被调用程序的公钥accounts:程序可以访问的账户数组instruction_data:指令特有的数据(类似于 Solidity 中的 calldata)你可能会认出这些指令输入参数与我们上一篇文章中生成执行轨迹时在 instructions.json 文件中提供的数据完全相同:
{
"accounts": [],
"program_id": "HTpqQdG7f44su3QsV3HHurraR1ZNjHAdArCy3qHKyKBC",
"instruction_data": [175, 175, 109, 31, 13, 152, 155, 237]
}
当你调用 Solana 程序时,Solana 运行时(通过 BPF 加载器)将指令输入(program_id、账户列表和 instruction_data)以及每个账户的链上状态(其 lamports 余额、所有者、数据字节和其他元数据)序列化为一个字节数组,并将其存储在虚拟机的内存中。指向此数组开头的指针作为 input 参数传递给程序的入口点。被调用的程序然后反序列化此字节数组,以恢复它需要执行的 program_id、账户和指令数据。
BPF 加载器使用已知的序列化格式,Solana 程序在反序列化指令输入时必须遵循完全相同的格式(我们稍后讨论)。
在 Anchor 程序中,#[program] 宏自动生成遵循此格式的反序列化代码。在原生 Rust 程序中,solana_program crate 中的 entrypoint! 宏提供了一个实现此格式的 deserialize 内部函数。无论哪种方式,反序列化逻辑都已为你提供,因此你无需从头开始实现它。
现在我们来详细了解 BPF 加载器如何在内存中序列化程序输入数据。所有值都以 little-endian 编码,因此在我们检查布局时请记住这一点。以下部分将分解指令输入的结构。
账户数量
最先序列化的是此程序调用需要多少个账户。这占用 8 字节。

然后,账户数组中的每个账户都被序列化
在账户数量之后,加载器逐个序列化每个账户。每个账户都以一个 1 字节的重复标记开始,该标记指示此账户是首次出现在账户数组中还是重复出现(意味着同一个账户在账户数组中多次出现)。

重复账户
如果账户是重复的(即此完全相同的账户在账户数组中已经序列化过),加载器不会再次序列化其所有数据。相反,1 字节标记包含此账户首次出现位置的索引,后跟 7 字节的填充。因此,重复账户的总大小为 8 字节(1 字节索引 + 7 字节填充)。

非重复账户
如果这是此账户在数组中首次出现,加载器会序列化其所有信息。这就是整个序列化数据大小变得可变的原因,因为不同的账户持有不同数量的数据。
加载器按顺序写入每个账户。它首先写入一个 1 字节的非重复标记(0xff),然后是两个 1 字节的标志,指示账户是否为 signer 和 writable,接着是 4 字节的填充——这起始部分总共 7 字节。之后,它写入账户的公钥(32 字节)、lamports 字段(8 字节)和数据长度(8 字节)。这些字段在任何账户数据之前总共是 55 字节。
接下来它写入账户的数据字节(x 字节)。一旦写入,加载器会附加 10,240 字节的保留空间(以便账户在执行期间可以增长而无需重新分配)以及 y 对齐填充字节——其中 y 是使下一个字段对齐到 16 字节边界所需的任意值(在序列化源代码中定义为 BPF_ALIGN_OF_U128)。填充后,它写入账户所有者的公钥(32 字节)、1 字节的 executable 标志和 8 字节的 rent epoch。
因此,非重复账户的总大小为:7(头部)+ 32(公钥)+ 8(lamports)+ 8(data_len)+ x (数据)+ 10,240 + y (保留空间 + 对齐)+ 32(所有者)+ 1(可执行)+ 8(租金 epoch)= 10,336 + x + y 字节。

此模式对账户数组中的每个非重复账户重复。
所有账户序列化(非重复或重复)后,指令数据被序列化
所有账户序列化后,加载器序列化指令数据。首先,它将指令数据的长度写入为一个 8 字节的无符号整数(u64),然后是实际的指令数据本身(z 字节,根据指令而异)。总计:8 + z 字节。

**最后,程序...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!