CKB/CKB-VM/使用标准 Rust 环境编写 CKB 脚本(一)

说实话, 我见过太多教程, 它们总是在一开始就要求我安装一大堆工具链, 还要配置环境变量, 甚至还要安装一个虚拟机. 这总会让我觉得这件事太麻烦, 麻烦到不想开始, 因为我可能只是想先试一下, 不一定会长期使用. 我常常会担心这些事情:

  • 这些安装命令会不会弄坏我的系统?
  • 会不会和我系统上已经安装的工具冲突?
  • 我可以轻松地移除这些工具吗?

很不幸, 这些担心都很有道理. 绝大多数教程开头提供的"快速安装"命令都不带卸载说明, 这就意味着一旦你敲下这些命令, 就很难彻底移除它们. 这也是为什么我决定在本文中使用标准的 Rust 环境来编写 CKB 脚本: 我们不需要安装任何额外的工具链或虚拟机, 也不需要担心复杂的环境配置. 只要你已经安装了 Rust, 就可以直接开始编写 CKB 脚本.

教程并非完全只依赖 Rust: 你还需要安装 LLVM 来检查编译结果, 以及安装 QEMU 来执行编译好的程序. 不过这两者都是很常见的工具, 你可以通过系统包管理器安装它们, 通常既安全, 也容易移除.

环境准备

首先, 我们需要为 Rust 安装 riscv64imac-unknown-none-elf target, 以便编译出标准 RISC-V 二进制文件. CKB-VM 能执行任何符合 RISC-V 规范的二进制文件, 因此我们不需要安装任何特定于 CKB-VM 的工具链.

$ rustup target add riscv64imac-unknown-none-elf

# 使用下面的命令可以移除这个 target, 以节省磁盘空间.
# rustup target remove riscv64imac-unknown-none-elf

以上就是全部环境准备工作. 现在, 你已经可以开始使用 Rust 编写 CKB 脚本了.

创建项目

我们准备实现一个简单的 CKB 脚本, 它总是执行成功, 也就是返回 0. 这意味着任何人都不需要满足额外条件就能通过该脚本, 因此我们将其命名为 always_success. 如果把这个脚本作为某个 Cell 的 Lock Script, 那么任何人都可以拿走这个 Cell 上的资产. 通常来说, 这类脚本非常危险, 几乎不会在生产环境中使用, 也没有实际业务价值; 但它非常适合用来演示如何使用标准 Rust 编写 CKB 脚本.

首先, 创建一个名为 always_success 的 Rust 项目:

$ cargo new --bin always_success

为该项目指定默认编译目标. 创建 .cargo/config.toml 并写入以下内容:

[build]
target = "riscv64imac-unknown-none-elf"

这样, 当你使用 cargo buildcargo run 时, Cargo 会自动使用 riscv64imac-unknown-none-elf 作为编译目标, 从而省去每次手动指定 --target 参数的麻烦.

构建最小可行程序

将以下内容写入 src/main.rs, 这就是一个最小的, 可成功编译的 Rust 程序.

#![no_main]
#![no_std]

#[panic_handler]
fn panic_handler(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
$ cargo build --release
   Compiling always_success v0.1.0 (/tmp/always_success)
    Finished `release` profile [optimized] target(s) in 0.11s

这个程序虽然可以成功编译, 但它没有实际功能, 因此也无法正常结束执行. 我们可以使用 llvm-objdump 检查编译结果, 确认它确实是一个极简二进制文件. 如果你没有安装 llvm-objdump, 可以使用 sudo apt install llvm 安装.

$ llvm-objdump -d target/riscv64imac-unknown-none-elf/release/always_success

target/riscv64imac-unknown-none-elf/release/always_success:     file format elf64-littleriscv

添加退出码

我们日常使用的大多数程序都会返回退出码, 用于提示程序执行成功还是失败: 通常情况下, 0 代表成功, 任意非 0 值代表失败. 在没有操作系统的环境中, 退出码通常通过一个特殊的系统调用返回. 在 RISC-V 架构中, 这个系统调用被称为 ecall, 它允许程序向宿主发出请求, 以执行特定操作. 在这个例子中, 我们将使用 ecall 返回退出码.

将下面的代码写入 src/main.rs, 并重新编译.

fn syscall(mut a0: u64, a1: u64, a2: u64, a3: u64, a4: u64, a5: u64, a6: u64, a7: u64) -> u64 {
    unsafe {
        core::arch::asm!(
          "ecall",
          inout("a0") a0,
          in("a1") a1,
          in("a2") a2,
          in("a3") a3,
          in("a4") a4,
          in("a5") a5,
          in("a6") a6,
          in("a7") a7
        )
    }
    a0
}

// Linux system calls for the RISC-V architecture: Exit.
// See: https://github.com/westerndigitalcorporation/RISC-V-Linux/blob/master/riscv-pk/pk/syscall.h
fn syscall_exit(code: u64) -> ! {
    syscall(code, 0, 0, 0, 0, 0, 0, 93);
    loop {}
}

#[unsafe(no_mangle)]
pub extern "C" fn _start() {
    syscall_exit(0);
}

我们再次使用 llvm-objdump 检查, 可以发现它现在已经包含可执行代码了.

$ llvm-objdump -d target/riscv64imac-unknown-none-elf/release/always_success

target/riscv64imac-unknown-none-elf/release/always_success:     file format elf64-littleriscv

Disassembly of section .text:

0000000000011158 <_start>:
   11158: 05d00893      li      a7, 0x5d
   1115c: 4581          li      a1, 0x0
   1115e: 4601          li      a2, 0x0
   11160: 4681          li      a3, 0x0
   11162: 4701          li      a4, 0x0
   11164: 4781          li      a5, 0x0
   11166: 4801          li      a6, 0x0
   11168: 4501          li      a0, 0x0
   1116a: 00000073      ecall
   1116e: a001          j       0x1116e <_start+0x16>

执行程序

现在, 我们已经成功编译出一个可执行程序. 整个过程没有使用任何 CKB 特定工具或依赖库, 全部基于标准 Rust 环境和通用开源工具链. 这展示了 CKB-VM 的兼容性: 只要你能编译出符合 RISC-V 规范的二进制文件, 就可以在 CKB-VM 上执行它.

你也许会想, 既然已经编译出了可执行程序, 是不是就可以直接在 CKB-VM 上运行? 确实可以, 但我不希望本文依赖任何 CKB 专用工具, 所以这里不介绍如何在 CKB-VM 上执行它, 而是介绍如何在通用 RISC-V 模拟器中运行.

QEMU 是一个开源且通用的模拟器与虚拟化工具. 它能够在你现有的电脑(如 x86 Windows/Linux)上虚拟出另一种完全不同架构的计算机环境(如 RISC-V 或 ARM). 这使得我们在没有真实 RISC-V 硬件的情况下, 也能直接在本机执行编译好的 RISC-V 程序.

$ sudo apt install qemu-user-static
$ qemu-riscv64-static target/riscv64imac-unknown-none-elf/release/always_success
$ echo $?
0

如果配置好相关环境变量, 也可以直接使用 cargo run 运行程序:

$ export CARGO_TARGET_RISCV64IMAC_UNKNOWN_NONE_ELF_RUNNER=qemu-riscv64-static
$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `qemu-riscv64-static target/riscv64imac-unknown-none-elf/debug/always_success`
$ echo $?
0

如果你希望省去环境变量配置, 也可以把配置长期写入 .cargo/config.toml, 添加下面内容即可.

[target.riscv64imac-unknown-none-elf]
runner = "qemu-riscv64-static"

这样, 你只需要执行 cargo run 就可以直接运行程序.

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `qemu-riscv64-static target/riscv64imac-unknown-none-elf/debug/always_success`
$ echo $?
0

重构 panic_handler

在此前的最小可行程序中, panic_handler 是一个死循环, 这通常不是我们希望的行为. 我们可以重构它, 让它以 101 退出:

#[panic_handler]
fn panic_handler(_: &core::panic::PanicInfo) -> ! {
    // If the main thread panics it will terminate all your threads and end your program with code 101.
    // See: https://github.com/rust-lang/rust/blob/master/library/core/src/macros/panic.md
    syscall_exit(101)
}

为什么要在 panic 时让程序以 101 退出? 这里主要是为了与 Rust 的默认约定保持一致: https://github.com/rust-lang/rust/blob/main/library/core/src/macros/panic.md

If the main thread panics it will terminate all your threads and end your program with code 101.

完整代码参考

.cargo/config.toml

[build]
target = "riscv64imac-unknown-none-elf"

[target.riscv64imac-unknown-none-elf]
runner = "qemu-riscv64-static"

src/main.rs

#![no_main]
#![no_std]

#[panic_handler]
fn panic_handler(_: &core::panic::PanicInfo) -> ! {
    syscall_exit(101)
}

fn syscall(mut a0: u64, a1: u64, a2: u64, a3: u64, a4: u64, a5: u64, a6: u64, a7: u64) -> u64 {
    unsafe {
        core::arch::asm!(
          "ecall",
          inout("a0") a0,
          in("a1") a1,
          in("a2") a2,
          in("a3") a3,
          in("a4") a4,
          in("a5") a5,
          in("a6") a6,
          in("a7") a7
        )
    }
    a0
}

// Linux system calls for the RISC-V architecture: Exit.
// See: https://github.com/westerndigitalcorporation/RISC-V-Linux/blob/master/riscv-pk/pk/syscall.h
fn syscall_exit(code: u64) -> ! {
    syscall(code, 0, 0, 0, 0, 0, 0, 93);
    loop {}
}

#[unsafe(no_mangle)]
pub extern "C" fn _start() {
    syscall_exit(0);
}

小结

在本文中, 我们展示了如何使用标准 Rust 环境编写 CKB 脚本, 以及如何用 QEMU 执行编译后的 RISC-V 程序. 通过这个示例可以看到 CKB-VM 的兼容性, 这为后续开发带来了很高的灵活性: 我们可以使用自己熟悉的工具链和开发环境来编写 CKB 脚本, 而不必依赖某种特定的黑箱工具.

在下一节中, 我们将介绍如何通过引入内存分配器来支持动态内存分配, 以便使用 Rust 的一些高级数据结构, 例如 StringVec, 从而让 CKB 脚本更丰富, 功能更完整.