在第一部分中,我们介绍了 Solana 程序输入序列化格式以及程序输入在内存中的布局方式。在这一部分中,我们将探讨程序如何将传入的程序输入路由到适当的处理器,以及 entrypoint 为实现这一功能所配置的支持代码。
指令处理器
entrypoint 函数(由 entrypoint! 宏生成)将原始字节数组反序列化为 program_id、accounts 和 instruction_data,然后将它们传递给指令处理器。指令处理器读取 instruction_data 以确定应该执行哪个处理器——这类似于 Solidity 合约如何基于函数选择器来路由函数调用。
指令处理器必须由你的程序本身来定义。entrypoint 不提供此函数,它只定义你的指令处理器必须匹配的类型签名。该类型定义如下:
pub type ProcessInstruction = fn(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult;
你的程序必须创建一个与此签名匹配的函数(接收这三个程序输入并返回一个 ProgramResult),然后将其传递给 entrypoint! 宏(我们将在下文看到它是如何工作的)。如何创建此函数取决于你是使用 Anchor 还是编写原生的 Rust 程序。
在原生 Rust 程序中,你需要定义一个具有此确切签名的函数,并将其传递给我们前面提到的 entrypoint! 宏。该函数通过检查指令数据以确定要调用的函数,从而处理指令路由逻辑。我们的原生 Rust Solana 程序系列详细介绍了如何实现这一点。
在 Anchor 程序中,#[program] 宏会自动为你生成指令处理器。#[program] 模块中的每个公共函数都会成为一个指令处理器,并且 Anchor 会基于从函数名派生出的鉴别器生成路由逻辑。这是 Anchor 抽象掉样板代码并使程序开发更加便捷的方法之一。
当指令处理器返回 Ok(()) 时,entrypoint 会返回 SUCCESS (0)。如果返回错误,entrypoint 会将该错误转换为错误代码并将其返回。
下面是这些内容在 entrypoint! 宏中如何组合在一起的:
#[macro_export]
macro_rules! entrypoint {
($process_instruction:ident) => {
/// # Safety
#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
let (program_id, accounts, instruction_data) = unsafe { $crate::deserialize(input) };
match $process_instruction(program_id, &accounts, instruction_data) {
Ok(()) => $crate::SUCCESS,
Err(error) => error.into(),
}
}
$crate::custom_heap_default!();
$crate::custom_panic_default!();
};
}
该宏生成反序列化输入的代码(使用我们前面讨论过的 deserialize 函数)以提取三个程序输入,将它们传递给你的 process_instruction 函数,并处理结果。
注意末尾的另外两个宏调用:custom_heap_default!() 和 custom_panic_default!()。它们设置了你的程序运行所需的关键基础设施,让我们看看它们的作用。
自定义堆分配器
custom_heap_default!() 宏为 Solana 程序设置了默认的堆分配器。Solana 程序在 no_std 环境中运行,这意味着它们不使用 Rust 的标准库。这是因为它们在没有操作系统的 Solana VM 内部执行。Rust 标准库分配器依赖于 malloc 和 free 等操作系统系统调用,而 Solana VM 不提供这些调用。因此,Solana 程序需要一个直接与 VM 内存交互的自定义分配器。如果没有全局分配器,任何分配堆内存的代码(例如 Vec::new() 或 String::from())都将无法编译,因为 Rust 要求堆操作必须有全局分配器。
这个自定义堆分配器具有 32KB 的固定大小。它的工作原理是维护一个随着每次分配向前移动的指针。它在单次程序执行期间从不释放内存,这使得分配速度非常快。一旦你分配了某些内容,该内存将一直保持分配状态,直到程序执行完成。在程序执行完毕后,整个堆将被释放并为下一次执行进行重置。如果你在单次执行期间尝试分配超过 32KB 的总内存,分配将会失败并返回一个空指针。
以下是 custom_panic_default!() 宏的定义:
#[macro_export]
macro_rules! custom_heap_default {
() => {
#[cfg(all(not(feature = "custom-heap"), target_os = "solana"))]
#[global_allocator]
static A: $crate::BumpAllocator = unsafe {
$crate::BumpAllocator::with_fixed_address_range(
$crate::HEAP_START_ADDRESS as usize,
$crate::HEAP_LENGTH,
)
};
};
}
只有当未在 Cargo.toml 中定义 custom-heap feature 时,才会启用此自定义堆分配器。这允许你在该分配器不满足需求时提供自己的分配器。在实践中,很少需要这样做。提供的分配器对大多数程序来说都运行良好。只有在以下情况你才会考虑替换它:需要在单次程序执行期间释放和重用内存(因为这个默认的自定义 Solana 分配器在执行期间从不释放内存)、构建需要超过 32KB 堆空间的程序,或者想要为对性能要求极高的应用程序提供更复杂的分配器。
自定义 Panic 处理器
custom_panic_default!() 宏设置了一个自定义的 panic 处理器。与堆分配器类似,Rust 的标准 panic 处理器依赖于 Solana VM 中不存在的操作系统特性(如 stderr 和进程终止)。因此,Solana 程序需要一个使用 Solana 特定系统调用(sol_log_ 和 sol_panic_)来报告错误的自定义 panic 处理器。如果没有定义 panic 处理器,Solana 程序将无法编译,因为 Rust 要求在 no_std 环境中必须指定一个 panic 处理器。
此自定义 panic 处理器使用 Solana 系统调用记录 panic 消息和位置(文件、行号、列号),然后程序终止。它的代码如下:
#[macro_export]
macro_rules! custom_panic_default {
() => {
#[cfg(all(not(feature = "custom-panic"), target_os = "solana"))]
#[no_mangle]
fn custom_panic(info: &core::panic::PanicInfo<'_>) {
if let Some(mm) = info.message().as_str() {
unsafe {
$crate::__log(mm.as_ptr(), mm.len() as u64);
}
}
if let Some(loc) = info.location() {
unsafe {
$crate::__panic(
loc.file().as_ptr(),
loc.file().len() as u64,
loc.line() as u64,
loc.column() as u64,
)
}
}
}
};
}
就像堆分配器一样,你可以通过在 Cargo.toml 中添加 custom-panic feature 并定义自己的 panic 处理器来覆盖它。这样做最常见的原因是为了缩减程序大小。这个自定义 panic 处理器包含了字符串格式化和日志记录代码,这会给编译后的程序增加大约 25KB 的大小。如果达到了程序大小限制(BPF 程序为 10MB),你可以定义一个什么都不做的 no-op panic 处理器,从而节省该空间。代价是 panic 会静默发生。你将看不到错误消息,这会使调试变得更加困难。大多数开发者只有在需要压缩程序大小时才会将此作为最后的手段。
本文是 Solana 开发教程系列的一部分