我们进入模块 7:原生 Solana 程序。
在本系列中,我们一直使用 Anchor 框架来构建 Solana 程序。本教程将教你如何使用原生 Rust 而不依赖 Anchor 来编写它们。
你可能希望这样做有几个原因,例如:
- 低级控制:你可以控制数据如何序列化和反序列化、账户验证以及程序逻辑的结构。
- 性能:在原生 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] 属性宏在幕后自动生成一个程序入口点。这个入口点接收所有传入指令,并根据客户端传递的指令数据将它们分派到你的各个函数(initialize、update 等)。在原生 Rust 中,我们将使用 Solana SDK 中的 entrypoint! 宏来创建入口点并手动处理分派。
什么是 Solana 中的入口点?
将入口点视为 Solana 程序的“前门”。在 Ethereum 中,每个公共函数都像有多个前门:EVM 可以直接调用 ERC20 中的 transfer()、approve() 等公共函数,或任何其他公共函数。Solana 的工作方式不同。Solana 程序只有一个前门(入口点),处理来自客户端的所有传入调用。
入口点函数不是由我们直接编写的。它在编译时由 Solana SDK 提供的 entrypoint! 宏生成(我们稍后编写原生程序时会看到)。当客户端调用 Solana 程序时,运行时会调用入口点,入口点会反序列化传入指令并将其传递给我们定义的指令处理器函数(我们接下来会讨论)。指令处理器可以将指令路由到正确的程序函数,执行账户验证,或直接处理业务逻辑。
因此,entrypoint! 宏处理运行时调用你的程序、反序列化指令参数并将其转发给指令处理器所需的所有低级代码。这让你可以使用普通的 Rust 函数和类型编写程序逻辑,而宏则管理与 Solana 的接口。
指令处理器
在原生 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 程序可能返回的错误。
在 Anchor 中,原始的 process_instruction 参数和返回类型是隐藏的。相反,你的处理器会收到一个 Context<T>,其中包含完全反序列化的账户和指令数据,并应用了自动验证,因此你可以直接使用类型化的结构体而不是原始字节切片。
你可以随意命名这个指令处理器函数,但 Solana 生态系统约定俗成地命名为 process_instruction。这就是你传递给 entrypoint! 宏的函数(正如我们前面讨论的)。
现在,让我们编写一个 Solana 程序,其中包含一个通过 entrypoint! 宏连接的指令处理器,并执行它。我们将使用 TypeScript 客户端测试我们的程序,以了解它在实践中是如何工作的。
构建我们的 Solana 程序
项目设置
我们将构建一个简单的 Solana 程序,其中包含一个执行基本算术并记录结果的指令处理器。这将演示入口点和指令处理器在实践中是如何协同工作的。
如果你一直在遵循之前的教程,你本地应该已经安装了 Rust 和 Solana。如果没有,请参阅 Solana Hello World(安装和故障排除)。
现在让我们为我们的程序创建一个新目录并初始化它,运行以下命令:
mkdir solana-entrypoint-tutorial # 为我们的程序创建一个新目录
cd solana-entrypoint-tutorial # 进入目录
cargo init --lib # 初始化一个新的 Rust 库
接下来,更新项目的 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 program 依赖项:
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); // 注册 process_instruction 作为我们的指令处理器
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 运行时调用的实际入口点函数
- 反序列化运行时输入(包含程序 ID、账户和指令数据)
- 使用反序列化的参数调用你的指令处理器函数 (
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 # 在另一个终端中
solana program deploy target/deploy/solana_entrypoint_tutorial.so
以下是每个命令的作用:
cargo build-sbf:为 Solana 运行时构建我们的 Rust 程序,并创建一个 target/deploy/ 文件夹,其中包含一个 .so 文件(共享对象),这是我们编译的程序。与 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';
// === 替换为你的实际程序 ID === //
const PROGRAM_ID = new PublicKey('7x8574zHWf6cRQJrE5T3cfUdcgDi6Vt6q7HhLfHkHZQ4'); // 替换为你的实际程序 ID
const connection = new Connection('http://localhost:8899', 'confirmed');
async function testBasicEntrypoint() {
const payer = Keypair.generate();
// 空投一些 SOL 以支付交易
await connection.requestAirdrop(payer.publicKey, LAMPORTS_PER_SOL);
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待空投
// 创建一个调用我们程序的指令
const instruction = new TransactionInstruction({
keys: [], // keys 是账户元数据数组;这个简单示例不需要账户
programId: PROGRAM_ID,
data: Buffer.alloc(0), // 不需要指令数据
});
const transaction = new Transaction().add(instruction);
console.log('正在调用我们的程序...');
const signature = await sendAndConfirmTransaction(connection, transaction, [payer]);
console.log(`交易已确认: ${signature}`);
}
testBasicEntrypoint().catch(console.error);
用你的实际程序 ID 替换 PROGRAM_ID 中的程序 ID。
在我们运行测试之前,请确保本地验证器仍在运行并且程序已部署。然后在一个新终端中运行 solana logs 来监视我们的程序日志。
现在运行测试:
cd client
npm run test
你应该会看到类似这样的程序日志:

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