在本系列教程中,我们一直使用 Anchor 框架来构建 Solana 程序。本教程将教你如何不使用 Anchor,而是用原生 Rust 来编写它们。
你可能出于以下几个原因想要这样做:
- 底层控制:你可以控制如何序列化和反序列化数据、验证账户以及组织程序逻辑。
- 性能:在原生 Rust 中,对于不需要这些步骤的简单操作,你可以跳过 Anchor 的序列化、反序列化和账户验证,这将减少计算单元(compute unit)的消耗。
- 更小的二进制体积:没有 Anchor 的开销(额外的 Rust 宏和依赖项)意味着部署的程序体积更小。
- 学习:了解底层机制能让你成为更优秀的 Solana 开发者。
到目前为止,我们一直使用 Anchor 来创建我们的程序,并像这样编写函数:
#[program]
pub mod my_program {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// function logic
Ok(())
}
pub fn update(ctx: Context<Update>, value: u64) -> Result<()> {
// function logic
Ok(())
}
}
#[program] 属性宏在后台会自动生成一个程序入口点(entrypoint)。该入口点接收所有传入的指令,并根据客户端传递的指令数据将它们分发到你各自的函数中(initialize、update 等)。在原生 Rust 中,我们将使用 Solana SDK 提供的 entrypoint! 宏来创建入口点并手动处理分发。
在 Solana 中什么是入口点(Entrypoint)?
可以将入口点看作是你的 Solana 程序的“前门”。在 Ethereum 中,每个公共函数就像是拥有多个前门:EVM 可以直接调用诸如 ERC20 中的 transfer()、approve() 或是其他任何公共函数。Solana 的工作方式则有所不同。Solana 程序只有一扇前门(即入口点),用于处理来自客户端的所有传入调用。
入口点函数并不是由我们直接编写的。它是在编译时由 Solana SDK 提供的 entrypoint! 宏生成的(我们稍后在编写原生程序时会看到)。当客户端调用一个 Solana 程序时,运行时(runtime)会调用入口点,入口点会反序列化传入的指令,并将其传递给我们定义的一个指令处理函数(instruction processor,接下来会讨论)。指令处理函数可以将指令路由到正确的程序函数,执行账户验证,或者直接处理业务逻辑。
因此,entrypoint! 宏处理了运行时调用程序、反序列化指令参数并将其转发给指令处理函数所需的所有底层代码。这让你能够使用普通的 Rust 函数和类型来编写程序逻辑,而该宏则负责管理与 Solana 的接口交互。
指令处理器(Instruction Processor)
在原生 Rust Solana 程序中,我们需要定义一个指令处理器:一个处理传入指令的函数。当客户端向你的程序发送指令时,Solana 运行时会调用程序的入口点,入口点随后反序列化顶层的指令参数,并将它们传递给你的指令处理器函数。这就是你的程序接收和处理每条指令的方式。
这个指令处理器具有标准的函数签名:
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult
process_instruction 函数的三个参数是:
program_id:你程序的地址accounts:你的程序需要读取或写入的所有账户instruction_data:包含程序序列化指令数据的原始字节
ProgramResult 返回类型是 Result<(), ProgramError> 的类型别名,其中 ProgramError 是一个定义了 Solana 程序可能返回的错误 的枚举(enum)。
在 Anchor 中,原始的 process_instruction 参数和返回类型被隐藏了起来。取而代之的是,你的处理程序(handler)会接收到一个 Context<T>,其中包含完全反序列化的账户和指令数据,并已应用了自动验证,因此你可以直接使用类型化的结构体(typed structs)而不是原始的字节切片(byte slices)。
你可以给这个指令处理函数起任何名字,但 Solana 生态系统中的约定是将其命名为 process_instruction。这正是你要传递给 entrypoint! 宏的函数(正如我们前面讨论的)。
现在让我们编写一个通过 entrypoint! 宏连接了指令处理器的 Solana 程序并执行它。我们将用一个 TypeScript 客户端来测试我们的程序,看看它在实践中是如何工作的。
构建我们的 Solana 程序
项目设置
我们将构建一个带有指令处理器的简单 Solana 程序,它会执行基本的算术运算并记录结果。这将演示入口点和指令处理器在实践中是如何协同工作的。
如果你一直跟着前面的教程操作,那么你应该已经在本地安装了 Rust 和 Solana。如果没有,请参考 Solana Hello World (Installation and Troubleshooting)。
现在让我们为程序创建一个新目录,并通过运行以下命令来初始化它:
mkdir solana-entrypoint-tutorial # Create a new directory for our program
cd solana-entrypoint-tutorial # Change into the directory
cargo init --lib # Initialize a new Rust library
接下来,更新项目的 Cargo.toml,使其如下所示:
[package]
name = "solana-entrypoint-tutorial"
version = "0.1.0"
edition = "2021" # added
## NEWLY ADDED ##
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
solana-program = "3.0.0"
我们添加了两个配置:
crate-type = ["cdylib", "lib"]:告诉 Rust 将我们的库编译为 Solana 可以加载的动态库solana-program = "3.0.0":核心的 Solana 程序库,提供了我们在链上程序中需要的所有类型和函数
现在让我们来创建我们的程序。
编写我们的程序代码
我们将从一个执行基本算术运算并记录结果的指令处理器开始。
在 Anchor 中,你可能会在你的 #[program] 模块中像这样定义一个执行基础数学运算的函数:
#[program]
pub mod some_program {
pub fn do_math(ctx: Context<DoMath>) -> Result<()> {
let result = 5 + 3;
msg!("Result: {}", result);
Ok(())
}
}
但是对于原生 Rust Solana 程序,我们需要定义一个指令处理器,并使用 entrypoint! 宏将其连接到程序的入口点。虽然你可以定义其他的公共函数,但它们必须从指令处理器内部被调用,因为所有的执行都从那里开始。
entrypoint! 宏完成了繁重的工作:它生成了 Solana 运行时实际调用的入口点代码,反序列化传入数据,并将其转发给你的指令处理器函数。这样一来,你就可以在指令处理器中编写业务逻辑,而由宏来处理底层的入口点设置。
现在将 src/lib.rs 中的默认代码替换为以下代码。在代码中,我们做了以下工作:
- 导入我们程序所需的 Solana 程序依赖项:
AccountInfo、entrypoint、ProgramResult、msg和Pubkey。 - 使用
entrypoint!宏将process_instruction连接为我们程序的指令处理器。 - 定义一个名为
process_instruction的指令处理器函数,并带有标准的三个参数(因为我们暂未使用它们,所以都加了下划线)。 - 通过相加两个硬编码的数字(5 + 3)来执行简单的算术运算。
- 使用
msg!宏将计算结果记录到交易日志中。 - 返回
Ok(())以指示执行成功。
use solana_program::{
account_info::AccountInfo,
entrypoint,
entrypoint::ProgramResult,
msg,
pubkey::Pubkey,
};
entrypoint!(process_instruction); // Register process_instruction as our instruction processor
pub fn process_instruction(
_program_id: &Pubkey,
_accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let a: u64 = 5;
let b: u64 = 3;
let result = a + b;
msg!("Calculating {} + {} = {}", a, b, result);
Ok(())
}
理解 entrypoint! 宏
前面我们提到过,Solana 提供了一个 entrypoint! 宏来将你的指令处理器与程序的入口点连接起来。
在上述代码中,entrypoint! 宏 做了三件事:
- 生成 Solana 运行时调用的实际入口点函数
- 反序列化运行时输入(包含 program id、accounts 和 instruction data)
- 将反序列化后的参数传递并调用你的指令处理函数(
process_instruction)
当你编写 entrypoint!(process_instruction); 时,它会展开为类似以下形式的代码:
#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64 {
// Deserialize the raw input from the Solana runtime
// `input` is raw runtime memory (just bytes)
let (program_id, accounts, instruction_data) = unsafe { deserialize(input) };
// Call your instruction processor function with the deserialized data
// program_id: &Pubkey
// accounts: Vec<AccountInfo>
// instruction_data: &[u8]
match process_instruction(program_id, &accounts, instruction_data) {
Ok(()) => 0, // Return 0 for success
Err(error) => error.into(), // Return error code on failure
}
}
这个生成的函数是 Solana 运行时与你的 Rust 代码之间的桥梁。运行时通过一个指向内存中指令数据的指针(input: *mut u8)来调用此入口点。该指针指向一个内存位置,其中包含以原始字节序列化后的指令参数(程序 ID、账户和指令数据)。deserialize(input) 函数会读取该内存位置并把这些字节转换为三个值:
program_id(已是一个&Pubkey),accounts(一个Vec<AccountInfo>),以及instruction_data(已是一个&[u8])。
在 Solana SDK 中,deserialize 函数的签名定义如下:
pub unsafe fn deserialize<'a>(input: *mut u8) -> (&Pubkey, Vec<AccountInfo>, &[u8])
在调用 process_instruction(program_id, &accounts, instruction_data) 时,只有 accounts 需要加上 &。
那是因为 deserialize 已经将 program_id 和 instruction_data 作为引用返回了(即 &Pubkey 和 &[u8]——正如我们在上述签名中看到的那样),而把 accounts 作为一个 Vec<AccountInfo> 返回。
在生成的代码中,&accounts 创建了一个 &Vec<AccountInfo>,Rust 会自动将其转换为 process_instruction 所期望的 &[AccountInfo] 切片。
entrypoint! 宏让你能够专注于实现 process_instruction,而该宏则负责处理与 Solana 运行时的交互。你可以在这里查看 entrypoint 宏的完整实现。
另一方面,在 Anchor 中,#[program] 属性会自动生成入口点,反序列化指令数据和账户,并将指令分发给相应的函数。
现在,让我们实际编译并部署我们的程序,以便进行测试。
构建与部署程序
要构建和部署程序,请运行以下命令:
cargo build-sbf --tools-version v1.52
solana-test-validator # in another terminal
solana program deploy target/deploy/solana_entrypoint_tutorial.so
以下是每个命令的作用:
cargo build-sbf:为 Solana 运行时构建我们的 Rust 程序,并创建一个包含.so文件(共享对象)的target/deploy/文件夹,这就是我们编译好的程序。与 Anchor 的构建命令不同,它不会生成 IDL,也不会包含 Anchor 的判别器(discriminators)和自动验证代码。由于没有框架的开销,这使得二进制文件更小。--tools-version v1.52标志在构建时锁定了 Solana 平台的工具链版本。这确保了 Rust 和 Cargo 版本的兼容性,避免了因工具链不匹配或过时而导致的问题。solana-test-validator:就像之前的教程一样,我们用它来启动一个本地 Solana 验证者节点用于测试(在单独的终端中运行此命令)solana program deploy:将构建命令生成的.so文件部署到本地验证者节点
部署到本地验证者节点后,你应该会看到类似如下的输出内容。

从部署输出中复制 Program ID,你在测试时需要用到它。
如果遇到构建错误,请通过运行此命令确保你的 Solana 工具链是最新的: curl --proto '=https' --tlsv1.2 -sSfL https://solana-install.solana.workers.dev | bash
测试阶段 1:基础算术运算与日志记录
现在让我们创建一个 TypeScript 客户端来测试我们的基础入口点程序。首先,我们需要设置客户端环境,然后我们再添加客户端代码。
使用以下命令在我们的项目中设置 TypeScript 客户端:
mkdir client && cd client
npm init -y
npm install @solana/web3.js typescript ts-node @types/node
将 client/package.json 中的默认 test 脚本替换为以下内容:
"scripts": {
"test": "ts-node client.ts"
},
这允许我们使用 npm run test 来运行我们的 TypeScript 客户端,因为由 npm init 生成的默认测试脚本不支持 TypeScript 代码。
创建一个 client/tsconfig.json 并添加如下内容:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": [
"node"
]
},
"include": [
"*.ts"
]
}
此设置将允许我们通过 npm run test 来运行客户端。
现在创建一个 client/client.ts 文件并添加以下代码。在这段代码中,我们做了以下工作:
- 导入必要的 Solana web3.js 依赖项,用于创建连接、交易和密钥对。
- 设置一个连接,连接到运行在 8899 端口(
solana-test-validator的默认端口)上的本地 Solana 验证者节点。 - 创建一个
testBasicEntrypoint函数,生成一个新的密钥对来支付交易费用。 - 请求空投 SOL 以资助交易费用。
- 创建一个没有任何账户和指令数据的
TransactionInstruction(因为我们的程序还没有用到它们)。 - 构建交易并将其发送到我们的程序。
- 记录交易签名以便验证。
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
Transaction,
TransactionInstruction,
sendAndConfirmTransaction,
} from '@solana/web3.js';
import { Buffer } from 'buffer';
// === REPLACE WITH YOUR ACTUAL PROGRAM ID === //
const PROGRAM_ID = new PublicKey('7x8574zHWf6cRQJrE5T3cfUdcgDi6Vt6q7HhLfHkHZQ4'); // Replace with your actual program ID
const connection = new Connection('http://localhost:8899', 'confirmed');
async function testBasicEntrypoint() {
const payer = Keypair.generate();
// Airdrop some SOL to pay for the transaction
await connection.requestAirdrop(payer.publicKey, LAMPORTS_PER_SOL);
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for airdrop
// Create a instruction that calls our program
const instruction = new TransactionInstruction({
keys: [], // keys is the account metadata array; no accounts needed for this simple example
programId: PROGRAM_ID,
data: Buffer.alloc(0), // No instruction data needed
});
const transaction = new Transaction().add(instruction);
console.log('Calling our program...');
const signature = await sendAndConfirmTransaction(connection, transaction, [payer]);
console.log(`Transaction confirmed: ${signature}`);
}
testBasicEntrypoint().catch(console.error);
将 PROGRAM_ID 中的程序 ID 替换为你实际的程序 ID。
在运行测试之前,请确保本地验证者节点仍在运行且程序已被部署。然后在一个新终端中运行 solana logs 来监视我们的程序日志。
现在运行测试:
cd client
npm run test
你应该会看到类似于这样的程序日志:

我们的程序执行成功了。注意与 Anchor 相比这有多么简单,没有任何账户验证或指令解析的代码。
本文是 Solana development 系列教程的一部分。