在上一篇教程中,我们学习了程序如何从内存读取数据到 sBPF 虚拟机寄存器中。现在,我们将在此模型的基础上,展示程序如何通过系统调用(syscalls)调用 Solana 运行时(runtime)功能,并通过寄存器提供系统调用参数。
系统调用是由 Solana 运行时暴露并执行的 API,程序通过调用它来执行自身无法完成的操作,例如日志记录和跨程序调用(cross-program invocation)。
它的工作原理如下:
- 如果系统调用需要参数,程序会将值加载到参数寄存器(
r1到r5)中。 - 你的程序执行
syscall指令,并将控制权转移给 Solana 运行时。syscall处理程序会从你加载了值的寄存器中读取数据。 - 运行时执行请求的操作,然后将控制权交还给程序。

Solana 为不同目的提供了系统调用,如日志记录、密码学操作、跨程序调用、sysvar 访问和内存操作。在本教程中,我们将重点关注日志记录系统调用。
用于日志记录的系统调用
程序在执行期间调用日志记录系统调用来打印值。日志记录系统调用共有五个,均列在下方。我们将在后面的部分详细讨论每一个。
fn sol_log_(message: *const u8, len: u64):该syscall将 UTF-8 文本打印到程序日志中。fn sol_log_data(data: *const u8, data_len: u64):将任意字节数据记录到程序日志中。fn sol_log_64_(arg1: u64, arg2: u64, arg3: u64, arg4: u64, arg5: u64):该syscall记录五个 64 位整数值。fn sol_log_pubkey(pubkey_addr: *const u8):将公钥记录到程序日志中。fn sol_log_compute_units_():该syscall记录在其执行点处剩余的计算单元数量。它不接受任何参数。
这些系统调用中有四个需要参数。在调用系统调用并将控制权移交给运行时之前,程序会将这些参数加载到寄存器中。sol_log_compute_units_ 不接受任何参数,因为它仅查询运行时的内部状态。
就像 EVM 中的操作码一样,每个 syscall 都有在客户端源代码中记录的计算成本。虽然像 sol_log_64_ 这样的系统调用具有固定的计算单元成本,但像 sol_log_ 这样处理变长数据的系统调用,其成本取决于其输入的大小。开发者可以使用 sol_log_compute_units_ 系统调用来测量消耗的计算单元数量。
接下来,我们将设置环境,以实验如何将数据从内存加载到寄存器中,并使用系统调用进行记录。
设置
确保 solana-test-validator 正在运行。完成以下设置步骤:
- 创建一个名为
syscalls的文件夹 - 为汇编代码创建一个文件
syscalls/syscalls.asm - 创建一个文件
syscalls/instructions.json用于存放交易数据,并添加下方的 JSON 内容。在我们的演示中,将不需要账户(accounts)。我们的重点将放在instruction_data上。在后续过程中,我们将不断更新instruction_data的内容:
{
"accounts": [],
"program_id": "HTpqQdG7f44su3QsV3HHurraR1ZNjHAdArCy3qHKyKBC",
"instruction_data": []
}
我们将使用前面教程中相同的命令,仅针对我们的 syscalls 目录修改文件路径。
agave-ledger-tool program run syscalls/syscalls.asm --limit 200000 --trace syscalls/trace.txt --ledger test-ledger --input syscalls/instructions.json
既然我们的设置已经完成,让我们展示如何在 sBPF 汇编中执行系统调用。
如何在 sBPF 汇编中执行系统调用
sBPF 汇编中的系统调用遵循以下模板。在调用系统调用之前,必须将参数值加载到寄存器中。
... ; instructions that copy arguments into registers
syscall <syscall_name>
在本文的汇编代码中,我们将贯穿使用该模板。
在下一节中,我们将使用 sBPF 汇编调用 sol_log_ 系统调用。
使用 sol_log_ 记录字符串 1/5
sol_log_ 系统调用将指向内存中消息字符串的指针作为第一个参数,将消息长度作为第二个参数,然后将该消息作为 UTF-8 文本记录到程序日志中。sol_log_ 系统调用的 Rust 定义如下代码所示:
fn sol_log_(message: *const u8, len: u64)
让我们分 3 步演示如何使用 sol_log_ 在 sBPF 汇编中记录消息“Hello world”:
- 我们将把“Hello world”的 ASCII 十进制字节表示作为指令数据传递给程序。
- 运行时会将指令数据复制到输入内存区域中,我们的汇编代码将使用
r1作为基指针来读取它,因为在程序启动时输入会被加载到r1中。 - 然后,我们将记录“Hello world”消息。
首先,将 instructions.json 中的 instruction_data 更新为“Hello world”的 ASCII 十进制字节表示:
{
"accounts": [],
"program_id": "HTpqQdG7f44su3QsV3HHurraR1ZNjHAdArCy3qHKyKBC",
"instruction_data": [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]
}
请注意,sol_log_ 系统调用期望 r1 保存指向内存中消息字符串的指针,并期望 r2 保存其长度(字符串 “Hello world” 长 11 个字节)。
以下是使用 sol_log_ 系统调用打印“Hello world”消息的程序。将其复制到 syscalls/syscalls.asm 文件中。
mov64 r3, r1 ; Copy r1 (0x400000000) to r3 to preserve base pointer
add64 r1, 16 ; r1 now points to first byte of "Hello world" (0x400000010)
ldxdw r2, [r3+8] ; Load instruction data length (11) from memory into r2
syscall sol_log_ ; Invoke syscall: r1=message pointer, r2=length
exit
让我们详细解释每条指令:
第 1/4 行,第一条指令:mov64 r3, r1
该指令将输入内存区域的起始地址(0x400000000)从 r1 复制到 r3 以进行保存。我们将此值保存在 r3 中,因为稍后读取指令数据长度时需要用到它。
第 2/4 行,第二条指令:add64 r1, 16
此指令的目的是将指向指令数据的指针存储在 r1 中,这正是 sol_log_ 系统调用所期望的。
该指令将 r1 在内存中向前移动,越过账户数量和指令数据长度,使其指向指令数据的第一个字节,在本例中也就是“Hello world”字符串的起点。
账户数量字段始终存在于内存中,即使指令中没有账户也是如此:
- 当存在账户时,账户数量后面跟随着账户元数据、公钥和账户数据。
- 当不存在账户时,账户数量紧随其后的是指令数据长度。
在我们的例子中(没有账户),前 16 个字节仅包含账户数量(8 字节)和指令数据长度(8 字节)。

因此,r1 指向内存中指令数据的第一个字节,并变为 0x400000000 + 16(16 的十六进制为 0x10) = 0x400000010。既然我们已经知道目标内存位置,其实也可以使用 lddw r1, 0x400000010 指令将“Hello world”消息指针加载到 r1 中。我们使用 add64 指令是因为相对地导航动态内存结构更符合惯用法。
第 3/4 行,第三条指令:ldxdw r2, [r3+8]
该指令的目的是将指令数据长度加载到 r2 中,这正是 sol_log_ 系统调用所期望的第二个参数。
请记住,我们在第一条指令中特意为此目的将基指针保存在了 r3 中。
ldxdw 指令从内存中加载一个 8 字节的值。指令数据长度字段位于基指针后 8 个字节处,因此其地址为 0x400000000 + 8 = 0x400000008。因此,ldxdw r2, [r3+8] 将存储在 0x400000008 处的 8 字节值加载到 r2 中。
在我们的例子中,这将值 11(“Hello world”的长度)加载到 r2 中。
第 4/4 行,第四条指令:syscall sol_log_
这会调用 sol_log_ 系统调用,并将 r1 和 r2 分别作为其第一个和第二个参数传递。
既然我们了解了程序的工作原理,现在可以使用 agave-ledger-tool 运行该程序。结果应该记录“Hello world”消息。

如果你正在使用原生 Rust 或 Anchor 编写 Solana 程序,通常会使用 msg! 宏,而不是直接调用 sol_log_。Solana 的 msg! 宏是对 sol_log_ 系统调用的包装。
macro_rules! msg {
($msg:expr) => {
$crate::sol_log($msg)
};
($($arg:tt)*) => ($crate::sol_log(&format!($($arg)*)));
}
此外,你也可以在 Anchor Rust 程序中直接从 solana_program crate 导入并调用 sol_log 系统调用,如下所示:
use anchor_lang::solana_program::log::sol_log;
sol_log("Hello world");
使用 sol_log_data 记录二进制数据 2/5
该系统调用与 sol_log_ 系统调用类似。区别在于,它将 base64 编码的二进制数据记录到程序日志中,而不是 UTF-8 文本。
当调用 sol_log_data 系统调用时会发生以下情况:
- 运行时从内存中读取一个切片描述符(slice descriptors)数组。切片描述符是内存中的一条记录,告诉运行时字节缓冲区的起始位置及其包含的字节数。
- 运行时使用每个描述符中存储的指针在内存中定位该字节缓冲区,
- 然后读取由描述符长度字段指定数量的字节,并将它们作为 base64 编码的输出进行记录。
每个描述符在内存中以 16 字节的形式存储:一个指向数据的 8 字节指针和一个 8 字节长度字段。

sol_log_data 函数定义与寄存器要求
以下是 sol_log_data 的签名:
fn sol_log_data(data: *const u8, data_len: u64)
data 参数是一个 *const u8,运行时将其视为指向 16 字节切片描述符(指针、长度)数组的指针。data_len 参数指定要读取多少个描述符。
sol_log_data 系统调用期望:
r1存放指向切片描述符数组的内存地址r2存放数组中的切片数量
将“Hello”作为二进制数据记录
让我们演示如何使用 sol_log_data 将字符串“Hello”作为二进制数据进行记录。
首先,将 instructions.json 中的 instruction_data 更新为“Hello”的 ASCII 十进制字节表示:
{
"accounts": [],
"program_id": "HTpqQdG7f44su3QsV3HHurraR1ZNjHAdArCy3qHKyKBC",
"instruction_data": [72, 101, 108, 108, 111]
}
要使用 sol_log_data 记录“Hello”,我们需要在栈上建立一个切片描述符。首先,我们将从输入内存中提取指向‘Hello’的指针及其长度,并将这两个值存储在栈上以构成描述符,然后将描述符的地址加载到 r1 中,将切片数量加载到 r2 中,如下方代码所示。将代码复制到 syscalls/syscalls.asm 文件中,并注意注释:
ldxdw r2, [r1+8] ; Load instruction data length from memory
add r1, 16 ; Advance r1 to instruction data start
stxdw [r10-16], r1 ; Store pointer on stack (descriptor field 1)
stxdw [r10-8], r2 ; Store length on stack (descriptor field 2)
mov r1, r10 ; Copy stack pointer to r1
add r1, -16 ; Adjust r1 to descriptor address
mov r2, 1 ; Load immediate value 1 (slice count)
syscall sol_log_data ; invoking the sol_log_data syscall
exit
使用 agave-ledger-tool 运行程序:

输出显示了 SGVsbG8=,这是“Hello”的 base64 编码。这表明 sol_log_data 成功记录了我们的二进制数据。
以下是在 Anchor 中使用 sol_log_data 系统调用的方法。
use anchor_lang::solana_program::log::sol_log_data;
let a = b"hello";
let b = b"world";
sol_log_data(&[a, b]);
使用 sol_log_64_ 记录整数 3/5
sol_log_64_ 系统调用在程序执行期间记录五个 64 位值。你并不一定要向 sol_log_64_ 系统调用传递全部五个参数。如果未向某个参数寄存器加载值,sol_log_64_ 系统调用将会记录该寄存器中已有的任何值(可能是 0 或残留数据)。为了明确起见,请将未使用的寄存器设置为 0。sol_log_64 在 Rust 中的函数签名如下所示:
fn sol_log_64_(arg1: u64, arg2: u64, arg3: u64, arg4: u64, arg5: u64)
在 sBPF 汇编中,我们将把每个参数加载到 r1 到 r5 的参数寄存器中。系统调用仅从参数寄存器获取其参数。
请注意,sol_log_64_ 以十六进制(hex)打印值。
以下是如何将 5 个 u64 值传递给 sol_log_64_ 系统调用的示例:
mov64 r1, 1
mov64 r2, 2
mov64 r3, 3
mov64 r4, 4
mov64 r5, 5
syscall sol_log_64_
exit
如需记录更少的值,请明确地将未使用的寄存器设置为 0:
mov64 r1, 1
mov64 r2, 0
mov64 r3, 0
mov64 r4, 0
mov64 r5, 0
syscall sol_log_64_
exit
为了演示如何从内存中加载单个 64 位值到寄存器并将其记录下来,我们将使用 5 的 8 字节表示作为示例,更新我们当前 instructions.json 文件中的 instruction_data 字段:
{
"accounts": [],
"program_id": "HTpqQdG7f44su3QsV3HHurraR1ZNjHAdArCy3qHKyKBC",
"instruction_data": [5, 0, 0, 0, 0, 0, 0, 0]
}
然后,我们可以像之前在 sol_log_ 系统调用中所做的那样,通过将 r1 向前推移越过账户数量(8 字节)和指令数据长度(8 字节),从而从内存中加载指令数据。
ldxdw r1, [r1 + 16]
syscall sol_log_64_
exit
当我们运行这段代码时,将得到如下结果:

与 sol_log_ 不同,Anchor 没有从 solana_program crate 中重新导出 sol_log_64_。
使用 sol_log_pubkey 记录公钥 4/5
sol_log_pubkey 系统调用在程序执行期间记录 Solana 公钥。它接受单个参数:一个指向内存中 32 字节公钥的指针。
与其他日志记录系统调用不同,sol_log_pubkey 不接受长度参数。运行时从 r1 提供的地址开始准确读取 32 个字节,并将这些字节解释为公钥。
为了演示它在 sBPF 汇编中的工作原理,我们将从运行的 JSON 输入文件中记录程序 ID,因为它是一个公钥。更新 instructions.json,使其仅保留 program_id。运行程序时,此指令输入将被加载到内存中:
{
"accounts": [],
"program_id": "HTpqQdG7f44su3QsV3HHurraR1ZNjHAdArCy3qHKyKBC",
"instruction_data": []
}
然后将下方代码复制到 syscalls/syscalls.asm 中:
add64 r1, 16
syscall sol_log_pubkey
exit
正如我们所知,在程序入口处,r1 指向输入内存区域的起点。在上述代码中,我们为 r1 加上 16 以跳过:
- 8 字节的账户数量
- 8 字节的指令数据长度(指令数据长度字段始终存在于内存布局中,无论指令是否包含任何实际的指令数据字节,这与账户数量字段类似)
因为没有指令数据,所以内存布局中指令数据长度之后的下一项就是程序 ID。此时 r1 包含了指向内存中程序 ID 的指针。
运行该程序。你的输出应该如下方截图所示。程序 ID 公钥应该与我们输入中的程序公钥相匹配。

使用 sol_log_compute_units_ 追踪计算单元使用情况 5/5
sol_log_compute_units_ 系统调用记录了在其执行的准确点剩余的计算单元数量。与我们讨论过的其他系统调用不同,它不接受任何参数。当 sol_log_compute_units_ 系统调用运行时,运行时会查询当前的计算预算,并将剩余余额打印到程序日志中。
所有计算成本均由运行时强制执行。某些系统调用需要固定数量的计算单元(例如,sol_log_64 系统调用消耗 100 CU),而其他系统调用的成本取决于其输入,sol_log_ 系统调用就是一个例子,因为它接受变长字符串。
sol_log_compute_units_ 系统调用是一个衡量性的日志记录工具,帮助你在开发过程中观察计算成本。
手动记录程序执行期间使用的计算单元
为了记录特定交易的计算成本,我们需要计算交易中涉及的每个操作码的成本。我们以 sol_log_64_ 系统调用为例:
ldxdw r1, [r1 + 16]
syscall sol_log_64_
exit
首先我们需要确定每个操作码的成本,尽管汇编中所有操作码的计算单元均未记录在档,但我们很快就能确定它们。syscall、ldxdw 和 exit 操作码各消耗 1 个计算单元,而 sol_log_64 消耗 100 CU(在这个结构体中定义)。因此,要计算此交易的计算单元成本,我们将这些成本相加,得出 1 + 1 + 1 + 100 = 103 个计算单元。
在包含数百或数千个操作码的实际程序中,手动计算总计算成本是不切实际的。这就是 sol_log_compute_units_ 派上用场的地方。我们可以使用 sol_log_compute_units_ 来记录执行后剩余的计算单元数量。
使用 sol_log_compute_units_ 记录程序执行期间使用的计算单元
既然我们知道最大计算单元限制为 1,400,000,并且 sol_log_compute_units_ 系统调用返回剩余计算单元。当我们使用 sol_log_compute_units_ 系统调用运行程序时,我们将最大计算单元(1,400,000)减去剩余计算单元。其差值即等于我们程序所消耗的计算单元。
请注意,sol_log_compute_units_ 系统调用本身也消耗 100 个计算单元,因此在计算中必须将其考虑在内。
让我们演示一下如何使用汇编来完成此操作。将下方代码复制到您的 syscalls/syscalls.asm 文件中。
ldxdw r1, [r1 + 16]
syscall sol_log_64_
syscall sol_log_compute_units_
exit
当我们使用 agave-ledger-tool 运行上述程序时,将得到以下结果:

从截图中,我们看到剩余 1,399,797 个计算单元。
消耗总计:1,400,000 - 1,399,797 = 203 CU
但这个 203 包含了 sol_log_compute_units_ 本身的成本(100 CU)及其 syscall 指令成本(1 CU),我们需要将其排除。此外,它还未包含在日志记录之后运行的 exit 指令成本(1 CU)。
实际程序成本 = 203 - 100(日志记录系统调用)- 1(系统调用指令)+ 1(退出)= 103 CU
这与我们早期的手动计算相吻合。
你也可以在 Solana 程序中通过从 solana_program crate 导入来直接使用该系统调用:
solana_program::log::sol_log_compute_units()
要了解有关计算单元优化的更多信息,请参考我们之前关于计算单元优化的教程。
下表总结了我们所讨论的五个日志记录系统调用:
| Syscall | 参数 | 记录内容 | 计算成本 |
|---|---|---|---|
sol_log_ |
r1:指向内存中 UTF-8 字符串的指针,r2:字节长度 |
作为可读字符串的 UTF-8 文本 | 可变(取决于字符串长度) |
sol_log_data |
r1:指向切片描述符数组的指针,r2:切片数量 |
作为 base64 编码的二进制数据 | 可变(取决于数据) |
sol_log_64_ |
r1 到 r5:五个 64 位整数 |
最多五个采用十六进制格式的整数 | 100 CU(固定) |
sol_log_pubkey |
r1:指向内存中 32 字节公钥的指针 |
采用标准 Solana base58 格式的公钥 | 100 CU(固定) |
sol_log_compute_units_ |
无 | 在执行点的剩余计算单元 | 100 CU(固定) |
本文是 Solana 开发教程系列的一部分