整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

「在 Nervos CKB 上做开发」CKB脚本编程

「在 Nervos CKB 上做开发」CKB脚本编程简介「2」:脚本基础

KB脚本编程简介[2]:脚本基础

原文作者:Xuejie原文链接:

https://xuejie.space/2019_07_13_introduction_to_ckb_script_programming_script_basics/本文译者:Shooter,Jason,Orange (排名不分先后)

上一篇我们介绍了当前 CKB 的验证模型。这一篇会更加有趣一点,我们要向大家展示如何将脚本代码真正部署到 CKB 网络上去。我希望在你看完本文后,你可以有能力自行去探索 CKB 的世界并按照你自己的意愿去编写新的脚本代码。

需要注意的是,尽管我相信目前的 CKB 的编程模型已经相对稳定了,但是开发仍在进行中,因此未来还可能会有一些变化。我将尽力确保本文始终处于最新的状态,但是如果在过程到任何疑惑,本文以 https://github.com/nervosnetwork/ckb/commit/80b51a9851b5d535625c5d144e1accd38c32876b 作为依据。

警告:这是一篇很长的文章,因为我想为下周更有趣的话题提供充足的内容。所以如果你没有充足的时间,你不必马上完成它。我在试着把它分成几个独立的不凡,这样你就可以一次尝试一个。

语法

在继续之前,我们先来区分两个术语:脚本(script)和脚本代码(script code)

在本文以及整个系列文章内,我们将区分脚本和脚本代码。脚本代码实际上是指你编写和编译并在 CKB 上运行的程序。而脚本,实际上是指 CKB 中使用的脚本数据结构,它会比脚本代码稍微多一点点:

pub struct Script {
 pub args: Vec<Bytes>,
 pub code_hash: H256,
 pub hash_type: ScriptHashType,
 }

我们目前可以先忽略hash_type,之后的文章再来解释什么是hash_type以及它有什么有趣的用法。在这篇文章的后面,我们会说明code_hash实际上是用来标识脚本代码的,所以目前我们可以只把它当成脚本代码。那脚本还包括什么呢?脚本还包括args这个部分,它是用来区分脚本和脚本代码的。args在这里可以用来给一个 CKB 脚本提供额外的参数,比如:虽然大家可能都会使用相同的默认的 lock script code,但是每个人可能都有自己的 pubkey hash,args 就是用来保存 pubkey hash 的位置。这样,每一个CKB 的用户都可以拥有不同的 lock script ,但是却可以共用同样的 lock script code。

请注意,在大多数情况下,脚本和脚本代码是可以互换使用的,但是如果你在某些地方感到了困惑,那么你可能有必要考虑一下两者间的区别。

一个最小的 CKB 脚本代码

你可能之前就已经听所过了,CKB (编者注:此处指的应该是 CKB VM)是基于开源的 RISC-V 指令集编写的。但这到底意味着什么呢?用我自己的话来说,这意味着我们(在某种程度上)在 CKB 中嵌入了一台真正的微型计算机,而不是一台虚拟机。一台真正的计算机的好处是,你可以用任何语言编写任何你想写的逻辑。在这里,我们展示的前面几个例子将会用 C语言编写,以保持简单性(我是说工具链中的简单性,而不是http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html),之后我们还会切换到基于 JavaScript 的脚本代码,并希望在本系列中展示更多的语言。记住,在 CKB 上有无限的可能!

正如我们提到的,CKB VM 更像是一台真正的微型计算机。CKB 的代码脚本看起来也更像是我们在电脑上跑的一个常见的 Unix 风格的可执行程序。

int main(int argc, char* argv[])
{
 return 0;
}

当你的代码通过 C 编译器编译时,它将成为可以在 CKB 上运行的脚本代码。换句话说,CKB 只是采用了普通的旧式 Unix 风格的可执行程序(但使用的是 RISC-V 体系结构,而不是流行的 x86 体系结构),并在虚拟机环境中运行它。如果程序的返回代码是 0 ,我们认为脚本成功了,所有非零的返回代码都将被视为失败脚本。

在上面的例子中,我们展示了一个总是成功的脚本代码。因为返回代码总是 0。但是请不要使用这个作为您的 lock script code ,否则您的 token 可能会被任何人拿走。

但是显然上面的例子并不有趣,这里我们从一个有趣的想法开始:我个人不是很喜欢胡萝卜。我知道胡萝卜从营养的角度来看是很好的,但我还是想要避免它的味道。如果现在我想设定一个规则,比如我想让我在 CKB 上的 Cell 里面都没有以carrot开头的数据?让我们编写一个脚本代码来实现这一点。

为了确保没有一个 cell 在 cell data中包含carrot,我们首先需要一种方法来读取脚本中的 cell data。CKB 提供了syscalls来帮助解决这个问题。

为了确保 CKB 脚本的安全性,每个脚本都必须在与运行 CKB 的主计算机完全分离的隔离环境中运行。这样它就不能访问它不需要的数据,比如你的私钥或密码。然而,要使得脚本有用,必须有特定的数据要访问,比如脚本保护的 cell 或脚本验证的事务。CKB 提供了syscalls来确保这一点,syscalls是在 RISC-V 的标准中定义的,它们提供了访问环境中某些资源的方法。在正常情况下,这里的环境指的是操作系统,但是在 CKB VM 中,环境指的是实际的 CKB 进程。使用syscalls, CKB脚本可以访问包含自身的整个事务,包括输入(inputs)、输出(outpus)、见证(witnesses)和 deps。

好消息是,我们已经将syscalls封装在了一个易于使用的头文件中,非常欢迎您在这里https://github.com/nervosnetwork/ckb-system-scripts/blob/66d7da8ec72dffaa7e9c55904833951eca2422a9/c/ckb_syscalls.h,了解如何实现syscalls。最重要的是,您可以只获取这个头文件并使用包装函数来创建您想要的系统调用。

现在有了syscalls,我们可以从禁止使用carrot的脚本开始:

#include <memory.h>
#include "ckb_syscalls.h"
int main(int argc, char* argv[]) {
 int ret;
 size_t index=0;
 volatile uint64_t len=0; /* (1) */
 unsigned char buffer[6];
 while (1) {
 len=6;
 memset(buffer, 0, 6);
 ret=ckb_load_cell_by_field(buffer, &len, 0, index, CKB_SOURCE_OUTPUT,
 CKB_CELL_FIELD_DATA); /* (2) */
 if (ret==CKB_INDEX_OUT_OF_BOUND) { /* (3) */
 break;
 }
 if (memcmp(buffer, "carrot", 6)==0) {
 return -1;
 }
 index++;
 }
 return 0;
}

以下几点需要解释一下:

  1. 由于 C 语言的怪癖,len字段需要标记为volatile。我们会同时使用它作为输入和输出参数,CKB VM 只能在它还保存在内存中时,才可以把它设置输出参数。而volatile可以确保 C 编译器将它保存为基于 RISC-V 内存的变量。
  2. 在使用syscall时,我们需要提供以下功能:一个缓冲区来保存syscall提供的数据;一个len字段,来表示系统调用返回的缓冲区长度和可用数据长度;一个输入数据缓冲区中的偏移量,以及几个我们在交易中需要获取的确切字段的参数。详情请参阅我们的https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0009-vm-syscalls/0009-vm-syscalls.md。
  3. 为了保证最大的灵活性,CKB 使用系统调用的返回值来表示数据抓取状态:0 (or CKB_SUCCESS) 意味着成功,1 (or CKB_INDEX_OUT_OF_BOUND) 意味着您已经通过一种方式获取了所有的索引,2 (orCKB_ITEM_MISSING) 意味着不存在一个实体,比如从一个不包含该 type 脚本的 cell 中获取该 type 的脚本。

概况一下,这个脚本将循环遍历交易中的所有输出 cells,加载每个 cell data 的前6个字节,并测试这些字节是否和carrot匹配。如果找到匹配,脚本将返回-1,表示错误状态;如果没有找到匹配,脚本将返回0退出,表示执行成功。

为了执行该循环,该脚本将保存一个index变量,在每次循环迭代中,它将试图让 syscall 获取 cell 中目前采用的index值,如果 syscall 返回 CKB_INDEX_OUT_OF_BOUND,这意味着脚本已经遍历所有的 cell,之后会退出循环;否则,循环将继续,每测试 cell data 一次,index变量就会递增一次。

这是第一个有用的 CKB 脚本代码!在下一节中,我们将看到我们是如何将其部署到 CKB 中并运行它的。

将脚本部署到 CKB 上

首先,我们需要编译上面写的关于胡萝卜的源代码。由于 GCC 已经提供了 RISC-V 的支持,您当然可以使用官方的 GCC 来创建脚本代码。或者你也可以使用我们准备的 https://hub.docker.com/r/nervos/ckb-riscv-gnu-toolchain来避免编译 GCC 的麻烦:

$ ls
carrot.c ckb_consts.h ckb_syscalls.h
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:xenial bash
root@dc2c0c209dcd:/# cd /code
root@dc2c0c209dcd:/code# riscv64-unknown-elf-gcc -Os carrot.c -o carrot
root@dc2c0c209dcd:/code# exit
exit
$ ls
carrot* carrot.c ckb_consts.h ckb_syscalls.h

就是这样,CKB 可以直接使用 GCC 编译的可执行文件作为链上的脚本,无需进一步处理。我们现在可以在链上部署它了。注意,我将使用 CKB 的 Ruby SDK,因为我曾经是一名 Ruby 程序员,当然 Ruby 对我来说是最自然的(但不一定是最好的)。如何设置请参考https://github.com/nervosnetwork/ckb-sdk-ruby/blob/develop/README.md。

要将脚本部署到 CKB,我们只需创建一个新的 cell,把脚本代码设为 cell data 部分:

pry(main)> data=File.read("carrot")
pry(main)> data.bytesize=> 6864
pry(main)> carrot_tx_hash=wallet.send_capacity(wallet.address, CKB::Utils.byte_to_shannon(8000), CKB::Utils.bin_to_hex(data))

在这里,我首先要通过向自己发送 token 来创建一个容量足够的新的 cell。现在我们可以创建包含胡萝卜脚本代码的脚本:

pry(main)> carrot_data_hash=CKB::Blake2b.hexdigest(data)
pry(main)> carrot_type_script=CKB::Types::Script.new(code_hash: carrot_data_hash, args: [])

回忆一下脚本数据结构:

pub struct Script {
 pub args: Vec<Bytes>,
 pub code_hash: H256,
 pub hash_type: ScriptHashType,
 }

我们可以看到,我们没有直接将脚本代码嵌入到脚本数据结构中,而是只包含了代码的哈希,这是实际脚本二进制代码的 Blake2b 哈希。由于胡萝卜脚本不使用参数,我们可以对args部分使用空数组。

注意,这里仍然忽略了 hash_type,我们将在后面的文章中通过另一种方式讨论指定代码哈希。现在,让我们尽量保持简单。

要运行胡萝卜脚本,我们需要创建一个新的交易,并将胡萝卜 type 脚本设置为其中一个输出 cell 的 type 脚本:

pry(main)> tx=wallet.generate_tx(wallet2.address, CKB::Utils.byte_to_shannon(200))
pry(main)> tx.outputs[0].instance_variable_set(:@type, carrot_type_script.dup)

我们还需要进行一个步骤:为了让 CKB 可以找到胡萝卜脚本,我们需要在一笔交易的 deps 中引用包含胡萝卜脚本的 cell:

pry(main)> carrot_out_point=CKB::Types::OutPoint.new(cell: CKB::Types::CellOutPoint.new(tx_hash: carrot_tx_hash, index: 0))
pry(main)> tx.deps.push(carrot_out_point.dup)

现在我们准备签名并发送交易:

[44] pry(main)> tx.witnesses[0].data.clear
[46] pry(main)> tx=tx.sign(wallet.key, api.compute_transaction_hash(tx))
[19] pry(main)> api.send_transaction(tx)=> "0xd7b0fea7c1527cde27cc4e7a2e055e494690a384db14cc35cd2e51ec6f078163"

由于该交易的 cell 中没有任何一个的 cell data 包含carrot,因此 type 脚本将验证成功。现在让我们尝试一个不同的交易,它确实含有一个以carrot开头的 cell:

pry(main)> tx2=wallet.generate_tx(wallet2.address, CKB::Utils.byte_to_shannon(200))
pry(main)> tx2.deps.push(carrot_out_point.dup)
pry(main)> tx2.outputs[0].instance_variable_set(:@type, carrot_type_script.dup)
pry(main)> tx2.outputs[0].instance_variable_set(:@data, CKB::Utils.bin_to_hex("carrot123"))
pry(main)> tx2.witnesses[0].data.clear
pry(main)> tx2=tx2.sign(wallet.key, api.compute_transaction_hash(tx2))
pry(main)> api.send_transaction(tx2)
CKB::RPCError: jsonrpc error: {:code=>-3, :message=>"InvalidTx(ScriptFailure(ValidationFailure(-1)))"}
from /home/ubuntu/code/ckb-sdk-ruby/lib/ckb/rpc.rb:164:in `rpc_request'

我们可以看到,我们的胡萝卜脚本拒绝了一笔生成的 cell 中包含胡萝卜的交易。现在我可以使用这个脚本来确保所有的 cell 中都不含胡萝卜!

所以,总结一下,部署和运行一个 type 脚本的脚本,我们需要做的是:

  1. 将脚本编译为 RISC-V 可执行的二进制文件
  2. 在 cell 的 data 部分部署二进制文件
  3. 创建一个 type 脚本数据结构,使用二进制文件的 blake2b 散列作为code hash,补齐args部分中脚本代码的需要的参数
  4. 用生成的 cell 中设置的 type 脚本创建一个新的交易
  5. 将包含脚本代码的 cell 的 outpoint 写入到一笔交易的 deps 中去

这就是你所有需要的!如果您的脚本遇到任何问题,您需要检查这些要点。

虽然在这里我们只讨论了 type 脚本,但是 lock 脚本的工作方式完全相同。您惟一需要记住的是,当您使用特定的 lock 脚本创建 cell 时,lock 脚本不会在这里运行,它只在您使用 cell 时运行。因此, type 脚本可以用于构造创建 cell 时运行的逻辑,而 lock 脚本用于构造销毁 cell 时运行的逻辑。考虑到这一点,请确保您的 lock 脚本是正确的,否则您可能会在以下场景中丢失 token:

您的 lock 脚本有一个其他人也可以解锁您的 cell 的 bug。

您的 lock 脚本有一个 bug,任何人(包括您)都无法解锁您的 cell。

在这里我们可以提供的一个技巧是,始终将您的脚本作为一个 type 脚本附加到你交易的一个 output cell 中去进行测试,这样,发生错误时,您可以立即知道,并且您的 token 可以始终保持安全。

分析默认 lock 脚本代码

根据已经掌握的知识,让我们看看 CKB 中包含的默认的 lock 脚本代码。 为了避免混淆,我们正在查看 lock 脚本代码在 https://github.com/nervosnetwork/ckb-system-scripts/blob/66e2b3fc4fa3e80235e4b4f94a16e81352a812f7/c/secp256k1_blake160_sighash_all.c。

默认的 lock 脚本代码将循环遍历与自身具有相同 lock 脚本的所有的 input cell,并执行以下步骤:

  • 它通过提供的 syscall 获取当前的交易 hash
  • 它获取相应的 witness 数据作为当前输入
  • 对于默认 lock 脚本,假设 witness 中的第一个参数包含由 cell 所有者签名的可恢复签名,其余参数是用户提供的可选参数
  • 默认的 lock 脚本运行 由交易 hash 链接的二进制程序的 blake2b hash, 还有所有用户提供的参数(如果存在的话)
  • 将 blake2b hash 结果用作 secp256k1 签名验证的消息部分。注意,witness 数据结构中的第一个参数提供了实际的签名。
  • 如果签名验证失败,脚本退出并返回错误码。否则它将继续下一个迭代。

注意,我们在前面讨论了脚本和脚本代码之间的区别。每一个不同的公钥 hash 都会产生不同的 lock 脚本,因此,如果一个交易的输入 cell 具有相同的默认 lock 脚本代码,但具有不同的公钥 hash(因此具有不同的 lock 脚本),将执行默认 lock 脚本代码的多个实例,每个实例都有一组共享相同 lock 脚本的 cell。

现在我们可以遍历默认 lock 脚本代码的不同部分:

if (argc !=2) {
 return ERROR_WRONG_NUMBER_OF_ARGUMENTS;
}
secp256k1_context context;
if (secp256k1_context_initialize(&context, SECP256K1_CONTEXT_VERIFY)==0) {
 return ERROR_SECP_INITIALIZE;
}
len=BLAKE2B_BLOCK_SIZE;
ret=ckb_load_tx_hash(tx_hash, &len, 0);
if (ret !=CKB_SUCCESS) {
 return ERROR_SYSCALL;
}

当参数包含在 Script数据结构的 args部分, 它们通过 Unix 传统的arc/argv方式发送给实际运行的脚本程序。为了进一步保持约定,我们在argv[0] 处插入一个伪参数,所以 第一个包含的参数从argv[1]开始。在默认 lock 脚本代码的情况下,它接受一个参数,即从所有者的私钥生成的公钥 hash。

ret=ckb_load_input_by_field(NULL, &len, 0, index, CKB_SOURCE_GROUP_INPUT,
 CKB_INPUT_FIELD_SINCE);
if (ret==CKB_INDEX_OUT_OF_BOUND) {
 return 0;
}
if (ret !=CKB_SUCCESS) {
 return ERROR_SYSCALL;
}

使用与胡萝卜这个例子相同的技术,我们检查是否有更多的输入 cell 要测试。与之前的例子有两个不同:

  • 如果我们只想知道一个 cell 是否存在并且不需要任何数据,我们只需要传入NULL 作为数据缓冲区,一个 len 变量的值是 0。
  • 通过这种方式,syscall 将跳过数据填充,只提供可用的数据长度和正确的返回码用于处理。
  • 在这个 carrot 的例子中,我们循环遍历交易中的所有输入, 但这里我们只关心具有相同 lock 脚本的输入cell。 CKB将具有相同锁定(或类型)脚本的cell命名为group。 我们可以使用 CKB_SOURCE_GROUP_INPUT 代替 CKB_SOURCE_INPUT, 来表示只计算同一组中的 cell,举个例子,即具有与当前 cell 相同的 lock 脚本的 cells。
len=WITNESS_SIZE;
ret=ckb_load_witness(witness, &len, 0, index, CKB_SOURCE_GROUP_INPUT);
if (ret !=CKB_SUCCESS) {
 return ERROR_SYSCALL;
}
if (len > WITNESS_SIZE) {
 return ERROR_WITNESS_TOO_LONG;
}
if (!(witness_table=ns(Witness_as_root(witness)))) {
 return ERROR_ENCODING;
}
args=ns(Witness_data(witness_table));
if (ns(Bytes_vec_len(args)) < 1) {
 return ERROR_WRONG_NUMBER_OF_ARGUMENTS;
}

继续沿着这个路径,我们正在加载当前输入的 witness。 对应的 witness 和输入具有相同的索引。现在 CKB 在 syscalls 中使用flatbuffer作为序列化格式,所以如果你很好奇,https://github.com/dvidelabs/flatcc是你最好的朋友。

/* Load signature */
len=TEMP_SIZE;
ret=extract_bytes(ns(Bytes_vec_at(args, 0)), temp, &len);
if (ret !=CKB_SUCCESS) {
 return ERROR_ENCODING;
}
/* The 65th byte is recid according to contract spec.*/
recid=temp[RECID_INDEX];
/* Recover pubkey */
secp256k1_ecdsa_recoverable_signature signature;
if (secp256k1_ecdsa_recoverable_signature_parse_compact(&context, &signature, temp, recid)==0) {
 return ERROR_SECP_PARSE_SIGNATURE;
}
blake2b_state blake2b_ctx;
blake2b_init(&blake2b_ctx, BLAKE2B_BLOCK_SIZE);
blake2b_update(&blake2b_ctx, tx_hash, BLAKE2B_BLOCK_SIZE);
for (size_t i=1; i < ns(Bytes_vec_len(args)); i++) {
 len=TEMP_SIZE;
 ret=extract_bytes(ns(Bytes_vec_at(args, i)), temp, &len);
 if (ret !=CKB_SUCCESS) {
 return ERROR_ENCODING;
 }
 blake2b_update(&blake2b_ctx, temp, len);
}
blake2b_final(&blake2b_ctx, temp, BLAKE2B_BLOCK_SIZE);

witness 中的第一个参数是要加载的签名,而其余的参数(如果提供的话)被附加到用于 blake2b 操作的交易 hash 中。

secp256k1_pubkey pubkey;
if (secp256k1_ecdsa_recover(&context, &pubkey, &signature, temp) !=1) {
 return ERROR_SECP_RECOVER_PUBKEY;
}

然后使用哈希后的 blake2b 结果作为信息,进行 secp256 签名验证。

size_t pubkey_size=PUBKEY_SIZE;
if (secp256k1_ec_pubkey_serialize(&context, temp, &pubkey_size, &pubkey, SECP256K1_EC_COMPRESSED) !=1 ) {
 return ERROR_SECP_SERIALIZE_PUBKEY;
}
len=PUBKEY_SIZE;
blake2b_init(&blake2b_ctx, BLAKE2B_BLOCK_SIZE);
blake2b_update(&blake2b_ctx, temp, len);
blake2b_final(&blake2b_ctx, temp, BLAKE2B_BLOCK_SIZE);
if (memcmp(argv[1], temp, BLAKE160_SIZE) !=0) {
 return ERROR_PUBKEY_BLAKE160_HASH;
}

最后同样重要的是,我们还需要检查可恢复签名中包含的 pubkey 确实是用于生成 lock 脚本参数中包含的 pubkey hash 的 pubkey。否则,可能会有人使用另一个公钥生成的签名来窃取你的 token。

简而言之,默认 lock 脚本中使用的方案与现在https://bitcoin.org/en/transactions-guide#p2pkh-script-validation非常相似。

介绍 Duktape

我相信你和我现在的感觉一样: 我们可以用 C 语言写合约,这很好,但是 C 语言总是让人觉得有点乏味,而且,让我们面对现实,它很危险。有更好的方法吗?

当然! 我们上面提到的 CKB VM 本质上是一台微型计算机,我们可以探索很多解决方案。 我们在这里做的一件事是,使用 JavaScript 编写 CKB 脚本代码。 是的,你说对了,简单的 ES5 (是的,我知道,但这只是一个例子,你可以使用转换器) JavaScript。

这怎么可能呢? 由于我们有 C 编译器,我们只需为嵌入式系统使用一个 JavaScript 实现,在我们的例子中,https://duktape.org/ 将它从 C 编译成 RISC-V 二进制文件,把它放在链上,我们就可以在 CKB 上运行 JavaScript 了!因为我们使用的是一台真正的微型计算机,所以没有什么可以阻止我们将另一个 VM 作为 CKB 脚本嵌入到 CKB VM 中,并在 VM 路径上探索这个 VM。

从这条路径展开,我们可以通过 duktape 在 CKB 上使用 JavaScript,我们也可以通过 https://github.com/mruby/mruby在 ckb 上使用 Ruby, 我们甚至可以将比特币脚本或EVM放到链上,我们只需要编译他们的虚拟机,并把它放在链上。这确保了 CKB VM 既能帮助我们保存资产,又能构建一个多样化的生态系统。所有的语言都应该在 CKB 上被平等对待,自由应该掌握在区块链合约的开发者手中。

在这个阶段,你可能想问: 是的,这是可能的,但是 VM 之上的 VM 不会很慢吗? 我相信这取决于你的例子是否很慢。我坚信,基准测试没有任何意义,除非我们将它放在具有标准硬件需求的实际用例中。 所以我们需要有时间检验这是否真的会成为一个问题。 在我看来,高级语言更可能用于 type scripts 来保护 cell 转换,在这种情况下,我怀疑它会很慢。此外,我们也在这个领域努力工作,以优化 CKB VM 和 VMs 之上的 CKB VM,使其越来越快,:P

要在 CKB 上使用 duktape,首先需要将 duktape 本身编译成 RISC-V 可执行二进制文件:

$ git clone https://github.com/nervosnetwork/ckb-duktape
$ cd ckb-duktape
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:xenial bash
root@0d31cad7a539:~# cd /code
root@0d31cad7a539:/code# make
riscv64-unknown-elf-gcc -Os -DCKB_NO_MMU -D__riscv_soft_float -D__riscv_float_abi_soft -Iduktape -Ic -Wall -Werror c/entry.c -c -o build/entry.o
riscv64-unknown-elf-gcc -Os -DCKB_NO_MMU -D__riscv_soft_float -D__riscv_float_abi_soft -Iduktape -Ic -Wall -Werror duktape/duktape.c -c -o build/duktape.o
riscv64-unknown-elf-gcc build/entry.o build/duktape.o -o build/duktape -lm -Wl,-static -fdata-sections -ffunction-sections -Wl,--gc-sections -Wl,-s
root@0d31cad7a539:/code# exit
exit
$ ls build/duktape
build/duktape*

与 carrot 示例一样,这里的第一步是在 CKB cell 中部署 duktape 脚本代码:

pry(main)> data=File.read("../ckb-duktape/build/duktape")
pry(main)> duktape_data.bytesize=> 269064
pry(main)> duktape_tx_hash=wallet.send_capacity(wallet.address, CKB::Utils.byte_to_shannon(280000), CKB::Utils.bin_to_hex(duktape_data))
pry(main)> duktape_data_hash=CKB::Blake2b.hexdigest(duktape_data)
pry(main)> duktape_out_point=CKB::Types::OutPoint.new(cell: CKB::Types::CellOutPoint.new(tx_hash: duktape_tx_hash, index: 0))

与 carrot 的例子不同,duktape 脚本代码现在需要一个参数: 要执行的 JavaScript 源代码:

pry(main)> duktape_hello_type_script=CKB::Types::Script.new(code_hash: duktape_data_hash, args: [CKB::Utils.bin_to_hex("CKB.debug(\"I'm running in JS!\")")])

注意,使用不同的参数,你可以为不同的用例创建不同的 duktape 支持的 type script:

pry(main)> duktape_hello_type_script=CKB::Types::Script.new(code_hash: duktape_data_hash, args: [CKB::Utils.bin_to_hex("var a=1;\nvar b=a + 2;")])

这反映了上面提到的脚本代码与脚本之间的差异:这里 duktape 作为提供 JavaScript 引擎的脚本代码,而不同的脚本利用 duktape 脚本代码在链上提供不同的功能。

现在我们可以创建一个 cell 与 duktape 的 type script 附件:

pry(main)> tx=wallet.generate_tx(wallet2.address, CKB::Utils.byte_to_shannon(200))
pry(main)> tx.deps.push(duktape_out_point.dup)
pry(main)> tx.outputs[0].instance_variable_set(:@type, duktape_hello_type_script.dup)
pry(main)> tx.witnesses[0].data.clear
pry(main)> tx=tx.sign(wallet.key, api.compute_transaction_hash(tx))
pry(main)> api.send_transaction(tx)=> "0x2e4d3aab4284bc52fc6f07df66e7c8fc0e236916b8a8b8417abb2a2c60824028"

我们可以看到脚本执行成功,如果在ckb.toml 文件中将 ckb-script日志模块的级别设置为debug,你可以看到以下日志:

2019-07-15 05:59:13.551 +00:00 http.worker8 DEBUG ckb-script script group: c35b9fed5fc0dd6eaef5a918cd7a4e4b77ea93398bece4d4572b67a474874641 DEBUG OUTPUT: I'm running in JS!

现在您已经成功地在 CKB 上部署了一个 JavaScript 引擎,并在 CKB 上运行基于 JavaScript 的脚本!

你可以在这里尝试认识的 JavaScript 代码。

一道思考题

现在你已经熟悉了 CKB 脚本的基础知识,下面是一个思考:在本文中,您已经看到了一个 always-success 的脚本是什么样子的,但是一个 always-failure 的脚本呢?一个 always-failure 脚本(和脚本代码)能有多小?

提示:这不是 gcc 优化比赛,这只是一个思考。

下集预告

我知道这是一个很长的帖子,我希望你已经尝试过,并成功地部署了一个脚本到 CKB。在下一篇文章中,我们将介绍一个重要的主题:如何在 CKB 定义自己的用户定义 token(UDT)。CKB 上 udt 最好的部分是,每个用户都可以将自己的 udt 存储在自己的 cell 中,这与 Ethereum 上的 ERC20 令牌不同,在 Ethereum 上,每个人的 token 都必须位于 token 发起者的单个地址中。所有这些都可以通过单独使用 type script 来实现。

如果你感兴趣,请继续关注 :)

加入 Nervos Community

Nervos Community 致力于成为最好的 Nervos 社区,我们将持续地推广和普 及 Nervos 技术,深入挖掘 Nervos 的内在价值,开拓 Nervos 的无限可能, 为每一位想要深入了解 Nervos Network 的人提供一个优质的平台。

添加微信号:BitcoinDog 即可加入 Nervos Community,如果是程序员请备注,还会将您拉入开发者群。


者:Xuejie原文链接:https://xuejie.space/20191018introductiontockbscriptprogrammingdebugging/

Nervos CKB 脚本编程简介[5]:调试 debug

事实上,CKB 脚本工作的层级要比其他智能合约低很多,因此 CKB 的调试过程就显得相当神秘。在本文中,我们将展示如何调试 CKB 脚本。你会发现,其实调试 CKB 脚本和你日常调试程序并没有太大区别。

本文建立在 ckb v0.23.0 之上。具体的,我在每个项目中使用的是如下版本的 commit:

  • ckb: 7e2ad2d9ed6718360587f3762163229eccd2cf10
  • ckb-sdk-ruby: 18a89d8c69e173ad59ce3e3b3bf79b5d11c5f8f8
  • ckb-duktape:347bf730c08eb0aab7e56e0357945a4d6cee109a
  • ckb-standalone-debugger: 2379e89ae285e4e639b961756c22d8e4fde4d6ab

使用 GDB 调试 C 程序

CKB 脚本调试的第一种方案,通常适用于 C、Rust 等编程语言。也许你已经习惯了写 C 的程序,而 GDB 也是你的好搭档。你想知道是不是可以用 GDB 来调试 C 程序,答案当然是:Yes!你肯定可以通过 GDB 来调试用 C 编写的 CKB 脚本!让我来演示一下:

首先,我们还是用之前文章中用到的关于 carrot 的例子:

#include <memory.h>#include "ckb_syscalls.h"
int main(int argc, char* argv[]) {
 int ret;
 size_t index=0;
 uint64_t len=0;
 unsigned char buffer[6];
 while (1) {
 len=6;
 memset(buffer, 0, 6);
 ret=ckb_load_cell_data(buffer, &len, 0, index, CKB_SOURCE_OUTPUT);
 if (ret==CKB_INDEX_OUT_OF_BOUND) {
 break;
 }
 int cmp=memcmp(buffer, "carrot", 6);
 if (cmp) {
 return -1;
 }
 index++;
 }
 return 0;
}

这里我进行了两处修改:

首先我更新了这个脚本,让它可以兼容 ckb v0.23.0。在这个版本中,我们可以使用 ckbloadcell_data 来获取 cell 的数据。

我还在这段代码中加入了一个小 bug,这样我们等会儿就可以进行调试的工作流程。如果你非常熟悉 C,你可能已经注意到了,当然你没有在意到的话也完全不用担心,稍后我会解释的。

和往常一样,我们使用官方的 toolchain 来将其编译成 RISC-V 的代码:

$ ls
carrot.c
$ git clone https://github.com/nervosnetwork/ckb-system-scripts
$ cp ckb-system-scripts/c/ckb_*.h ./
$ ls
carrot.c ckb_consts.h ckb_syscalls.h ckb-system-scripts/
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191012 bash
root@3efa454be9af:/# cd /code
root@3efa454be9af:/code# riscv64-unknown-elf-gcc carrot.c -g -o carrot
root@3efa454be9af:/code# exit

请注意,当我编译脚本的时候,我添加了 -g,以便生成调试信息,这在 GDB 中非常有用。对于实际使用的脚本,你总是希望尽量地完善它们来尽量节省存储在链上的空间。

现在,让我们将脚本部署到 CKB 上。保持 CKB 节点处于运行状态,并启动 Ruby SDK:

pry(main)> api=CKB::API.new
pry(main)> wallet=CKB::Wallet.from_hex(api, "<your private key>")
pry(main)> wallet2=CKB::Wallet.from_hex(api, CKB::Key.random_private_key)
pry(main)> carrot_data=File.read("carrot")
pry(main)> carrot_data.bytesize=> 19296
pry(main)> carrot_tx_hash=wallet.send_capacity(wallet2.address, CKB::Utils.byte_to_shannon(20000), CKB::Utils.bin_to_hex(carrot_data), fee: 21000)
pry(main)> carrot_data_hash=CKB::Blake2b.hexdigest(carrot_data)
pry(main)> carrot_type_script=CKB::Types::Script.new(code_hash: carrot_data_hash, args: "0x")
pry(main)> carrot_cell_dep=CKB::Types::CellDep.new(out_point: CKB::Types::OutPoint.new(tx_hash: carrot_tx_hash, index: 0))

现在链上有了 carrot 的脚本,我们可以创建一笔交易来测试这个 carrot 脚本:

pry(main)> tx=wallet.generate_tx(wallet2.address, CKB::Utils.byte_to_shannon(100), use_dep_group: false, fee: 5000)
pry(main)> tx.outputs[0].type=carrot_type_script
pry(main)> tx.cell_deps << carrot_cell_dep
pry(main)> tx.witnesses[0]="0x"
pry(main)> tx=tx.sign(wallet.key, api.compute_transaction_hash(tx))
pry(main)> api.send_transaction(tx)
CKB::RPCError: jsonrpc error: {:code=>-3, :message=>"Script(ValidationFailure(-1))"}

如果你仔细检查这笔交易,你会发现在输出的 cell 中,并没有以 carrot 开头的数据。然而我们运行之后仍然是验证失败,这意味着我们的脚本一定存在 bug。先前,没什么别的办法,你可能需要返回去检查代码,希望可以找到出错的地方。但现在没有这个必要了,你可以跳过这里的交易,然后将其输入到一个独立的 CKB 调试器开始调试它!

首先,让我们将这笔交易连同使用的环境,都转存到一个本地文件中:

pry(main)> CKB::MockTransactionDumper.new(api, tx).write("carrot.json")

在这里你还需要跟踪 carrot 类型脚本的哈希:

pry(main)> carrot_type_script.compute_hash=> "0x039c2fba64f389575cdecff8173882b97be5f8d3bdb2bb0770d8a7e265b91933"

请注意,你可能会得到和我这里不一样的哈希,这得看你使用的环境。

现在,让我们来试试 ckb-standalone-debugger:

$ git clone https://github.com/nervosnetwork/ckb-standalone-debugger
$ cd ckb-standalone-debugger/bins
$ cargo build --release
$ ./target/release/ckb-debugger -l 0.0.0.0:2000 -g type -h 0x039c2fba64f389575cdecff8173882b97be5f8d3bdb2bb0770d8a7e265b91933 -t carrot.json

注意,你可能需要根据你的环境,调整 carrot 类型脚本的哈希或者 carrot.json 的路径。现在让我们试试在一个不同的终端内通过 GDB 连接调试器:

$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191012 bash
root@66e3b39e0dfd:/# cd /code
root@66e3b39e0dfd:/code# riscv64-unknown-elf-gdb carrot
GNU gdb (GDB) 8.3.0.20190516-git
Copyright (C) 2019 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=x86_64-pc-linux-gnu --target=riscv64-unknown-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
 <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from carrot...
(gdb) target remote 192.168.1.230:2000
Remote debugging using 192.168.1.230:2000
0x00000000000100c6 in _start ()
(gdb)

注意,这里的 192.168.1.230 是我的工作站在本地网络中的 IP 地址,你可能需要调整该地址,因为你的计算机可能是不同的 IP 地址。现在我们可以试一下常见的 GDB 调试过程:

(gdb) b main
Breakpoint 1 at 0x106b0: file carrot.c, line 6.
(gdb) c
Continuing.

Breakpoint 1, main (argc=0, argv=0x400000) at carrot.c:6
6 size_t index=0;
(gdb) n
7 uint64_t len=0;
(gdb) n
11 len=6;
(gdb) n
12 memset(buffer, 0, 6);
(gdb) n
13 ret=ckb_load_cell_data(buffer, &len, 0, index, CKB_SOURCE_OUTPUT);
(gdb) n
14 if (ret==CKB_INDEX_OUT_OF_BOUND) {
(gdb) n
18 int cmp=memcmp(buffer, "carrot", 6);
(gdb) n
19 if (cmp) {
(gdb) p cmp
$1=-99
(gdb) p buffer[0]
$2=0 '\000'
(gdb) n
20 return -1;

这里我们可以看到哪里出问题了:buffer 中第一个字节的值是 0,这和 c 不同,因此我们的 buffer 和 carrot 不同。条件 if (cap) { 没有跳转到下一个循环,而是跳到了 true 的情况,返回了 -1,表明与 carrot 匹配。出现这样问题的原因是,当两个 buffers 相等的时候,memcmp 将会返回 0,当它们不相等的时候,将返回非零值。但是我们没有测试 memcmp 的返回值是否为 0,就直接在 if 条件中使用了它,这样 C 会把所有的非零值都视为 true,这里返回的 -99 就会被判断为 true。对于初学者而言,这是在 C 中会遇到的典型的错误,我希望你不会再犯这样的错误。

现在我们知道了错误的原因,接下来去修复 carrot 脚本中的错误就非常简单了。但是正如你看到的,我们设法从 CKB 上获取一笔错误交易在运行时的状态,然后通过 GDB(一个业界常见的工具)来对其进行调试。而且您在 GDB 上现有的工作流程和工具也可以在这里使用,是不是很棒?

基于 REPL 的开发/调试

然而,GDB 仅仅是现代软件开发中的一部分。动态语言在很大程度上占据了主导地位,很多程序员都使用基于 REPL 的开发/调试工作流。这与编译语言中的 GDB 完全不同,基本上你需要的是一个运行的环境,你可以输入任何你想要与环境进行交互的代码,然后得到不同的结果。正如我们将在这里展示的,CKB 也会支持这种类型的开发/调试工作流。

在这里,我们将使用 ckb-duktape 来展示基于 JavaScript 的 REPL。但是请注意,这只是一个 demo 用来演示一下工作流程,没有任何东西阻止您将自己喜爱的动态语言(不管是 Ruby、Rython、Lisp 等等)移植到 CKB 中去,并为该语言启动 REPL。

首先,让我们尝试编译 duktape:

$ git clone https://github.com/nervosnetwork/ckb-duktape
$ cd ckb-duktape
$ sudo docker run --rm -it -v `pwd`:/code nervos/ckb-riscv-gnu-toolchain:bionic-20191012 bash
root@982d1e906b76:/# cd /code
root@982d1e906b76:/code# make
riscv64-unknown-elf-gcc -Os -DCKB_NO_MMU -D__riscv_soft_float -D__riscv_float_abi_soft -Iduktape -Ic -Wall -Werror c/entry.c -c -o build/entry.o
riscv64-unknown-elf-gcc -Os -DCKB_NO_MMU -D__riscv_soft_float -D__riscv_float_abi_soft -Iduktape -Ic -Wall -Werror duktape/duktape.c -c -o build/duktape.o
riscv64-unknown-elf-gcc build/entry.o build/duktape.o -o build/duktape -lm -Wl,-static -fdata-sections -ffunction-sections -Wl,--gc-sections -Wl,-s
riscv64-unknown-elf-gcc -Os -DCKB_NO_MMU -D__riscv_soft_float -D__riscv_float_abi_soft -Iduktape -Ic -Wall -Werror c/repl.c -c -o build/repl.o
riscv64-unknown-elf-gcc build/repl.o build/duktape.o -o build/repl -lm -Wl,-static -fdata-sections -ffunction-sections -Wl,--gc-sections -Wl,-s
root@982d1e906b76:/code# exit

你需要在这里生成 build/repl 二进制文件。和 carrot 的例子类似,我们先将 duktape REPL 的二进制文件部署在 CKB 上:

pry(main)> api=CKB::API.new
pry(main)> wallet=CKB::Wallet.from_hex(api, "<your private key>")
pry(main)> wallet2=CKB::Wallet.from_hex(api, CKB::Key.random_private_key)
pry(main)> duktape_repl_data=File.read("build/repl")
pry(main)> duktape_repl_data.bytesize=> 283048
pry(main)> duktape_repl_tx_hash=wallet.send_capacity(wallet2.address, CKB::Utils.byte_to_shannon(300000), CKB::Utils.bin_to_hex(duktape_repl_data), fee: 310000)
pry(main)> duktape_repl_data_hash=CKB::Blake2b.hexdigest(duktape_repl_data)
pry(main)> duktape_repl_type_script=CKB::Types::Script.new(code_hash: duktape_repl_data_hash, args: "0x")
pry(main)> duktape_repl_cell_dep=CKB::Types::CellDep.new(out_point: CKB::Types::OutPoint.new(tx_hash: duktape_repl_tx_hash, index: 0))

我们还需要创建一笔包含 duktape 脚本的交易,我这里使用一个非常简单的脚本,当然你可以加入更多的数据,这样你就可以在 CKB 上玩起来了!

pry(main)> tx=wallet.generate_tx(wallet2.address, CKB::Utils.byte_to_shannon(100), use_dep_group: false, fee: 5000)
pry(main)> tx.outputs[0].type=duktape_repl_type_script
pry(main)> tx.cell_deps << duktape_repl_cell_dep
pry(main)> tx.witnesses[0]="0x"

然后让我们把它转存到文件中,并检查 duktape 类型脚本的哈希:

pry(main)> CKB::MockTransactionDumper.new(api, tx).write("duktape.json")=> 2765824
pry(main)> duktape_repl_type_script.compute_hash=> "0xa8b79392c857e29cb283e452f2cd48a8e06c51af64be175e0fe0e2902c482837"

与上面不同的是,我们不需要启动 GDB,而是可以直接启动程序:

$ ./target/release/ckb-debugger -g type -h 0xa8b79392c857e29cb283e452f2cd48a8e06c51af64be175e0fe0e2902c482837 -t duktape.json
duk>

你可以看到一个 duk> 提示你输入 JS 代码!同样,如果遇到错误,请检查是否需要更改类型脚本的哈希,或者使用正确的 duktape.json 路径。我们看到常见的 JS 代码可以在这里工作运行:

duk> print(1 + 2)
3=undefined
duk> function foo(a) { return a + 1; }=undefined
duk> foo(123)=124

您还可以使用与 CKB 相关的功能:

duk> var hash=CKB.load_script_hash()=undefined
duk> function buf2hex(buffer) { return Array.prototype.map.call(new Uint8Array(buffer), function(x) { return ('00' + x.toString(16)).slice(-2); }).join(''); }=undefined
duk> buf2hex(hash)=a8b79392c857e29cb283e452f2cd48a8e06c51af64be175e0fe0e2902c482837

请注意,我们在这里得到的脚本哈希正是我们当前执行的类型脚本的哈希!这将证明 CKB 系统调试在这里是有效的,我们也可以尝试更多有趣的东西:

duk> print(CKB.SOURCE.OUTPUT)
2=undefined
duk> print(CKB.CELL.CAPACITY)
0=undefined
duk> capacity_field=CKB.load_cell_by_field(0, 0, CKB.SOURCE.OUTPUT, CKB.CELL.CAPACITY)=[object ArrayBuffer]
duk> buf2hex(capacity_field)=00e40b5402000000

这个 00e40b5402000000 可能在一开始看起来有点神秘,但是请注意 RISC-V 使用的是 little endian(低字节序),所以如果在这里我们将字节序列颠倒,我们将得到 00000002540be400,在十进制中正好是 10000000000。还要记住,在 CKB 中容量使用的单位是 shannons,所以 10000000000 正好是 100 个字节,这正是我们生成上面的交易时,想要发送的代币的数量!现在你看到了如何在 duktape 环境中与 CKB 愉快地玩耍了 。

结论

我们已经介绍了两种不同的在 CKB 中调试的过程,你可以随意使用其中一种(或者两种)。我已经迫不及待地想看你们在 CKB 上玩出花来啦!

加入 Nervos Community

Nervos Community 致力于成为最好的 Nervos 社区,我们将持续地推广和普 及 Nervos 技术,深入挖掘 Nervos 的内在价值,开拓 Nervos 的无限可能, 为每一位想要深入了解 Nervos Network 的人提供一个优质的平台。

背景简介

手机淘宝客户端在历史上接过多种多样的脚本引擎,用于支持的语言包括:js/python/wasm/lua,其中js引擎接过的就有:javascriptcore/duktape/v8/quickjs 等多个。众多的引擎会面临共同面临包大小及性能相关的问题,我们是否可以提供一套方案,在能支持业务需求的前提下,用一个引擎来支持尽可能多的语言,能较好的兼顾包大小较小和性能优异。为了解决这个问题,我们开始了 hyengine 的探索。

二 设计简介

"有hyengine就够全家用了" - hyengine是为统一移动技术所需的各种脚本语言(wasm/js/python 等)执行引擎而生,以轻量级、高性能、多语言支持为设计和研发目标。目前已通过对 wasm3/quickjs 的 jit 编译及 runtime 优化,以极小包体积的代价实现了 wasm/js 执行速度 2~3 倍的提升,未来将通过实现自有字节码和 runtime 增加对 python 及其他语言的支持。

注:由于当前手机绝大多数都已支持 arm64,hyengine 仅支持 arm64 的 jit 实现。

注:由于 ios 不支持 jit,目前 hyengine 只有 android 版本。

hyengine 整体分为两大块,编译(compiler)部分及引擎(vm)部分。

compiler 部分分为前端、中端、后端,其中前端部分复用现有脚本引擎的实现,比如 js 使用 quickjs,wasm 使用 emscripten,中端计划实现一套自己的字节码、优化器及字节码转换器,后端实现了 quickjs 和 wasm 的 jit 及汇编器和优化器。

vm 分为解释器、runtime、api、调试、基础库,由于人力有限,目前VM暂无完整的自有实现,复用quickjs/wasm3 的代码,通过实现一套自己的内分配器及gc,和优化现有runtime实现来提升性能。

业务代码(以wasm为例)通过下图所示的流程,被编译为可执行代码:

c/c++ 代码经过 emscripten 编译变为 wasm 文件,wasm 经过 hyengine(wasm3) 加载并编译为 arm64 指令,arm64 指令经过 optimizer 优化产出优化后的 arm64 指令,业务方通过调用入口 api 来执行对应代码。

注:hyengine 本身期望沉淀一套自己的底层(汇编级别)的基础能力库,除了用于 jit 相关用途外,还计划用于手机客户端的包大小、性能优化、调试辅助等场景。

注:本方案业界的方舟编译器和 graalvm 可能有一定相似度。

三 实现介绍

1 编译(compiler)部分

为了让实现方案较为简单,hyengine 的编译采用直接翻译的方式,直接翻译出来的代码性能一般较慢,需要经过优化器的优化来提升性能。下面是相关模块的具体实现:

汇编器

为了生成 cpu 能执行的代码,我们需要实现一个汇编器,将相关脚本的 opcode 翻译成机器码。

汇编器的核心代码基于 golang 的 arch 项目已有的指令数据根据脚本生成,并辅佐人工修正及对应的工具代码。

单个汇编代码示例如下:

// Name: ADC
// Arch: 32-bit variant
// Syntax: ADC <Wd>, <Wn>, <Wm>
// Alias: 
// Bits: 0|0|0|1|1|0|1|0|0|0|0|Rm:5|0|0|0|0|0|0|Rn:5|Rd:5
static inline void ADC_W_W_W(uint32_t *buffer, int8_t rd, int8_t rn, int8_t rm) {
    uint32_t code=0b00011010000000000000000000000000;
    code |=IMM5(rm) << 16;
    code |=IMM5(rn) << 5;
    code |=IMM5(rd);
    *buffer=code;
}

代码的作用是汇编ADC , , 指令,第一个参数是存放机器码的 buffer ,后三个参数分别为汇编指令的操作数Wd/Wn/Wm。代码中第7行的 code 为机器码的固定部分,第 8~10 行为将操作数对应的寄存器编号放入机器码对应的位置(详见注释种的 Bits 部分),第 9 行为将机器码放入 buffer 。其中IMM5表示取数值的低 5 位,因为寄存器是一个 5bits 长的数字。这样命名的好处是,可以直观的将汇编器的方法名和其产生的机器码的助记词形式相关联。

其中IMM5实现如下:

#define IMM5(v) (v & 0b11111)

为了保证汇编方法的正确性,我们基于 golang 的 arch 项目中的gnucases.txt,采取机器生成 + 人工修正的方式,产出了如下格式的单测用例:

    // 0a011f1a|  adc w10, w8, wzr
    ADC_W_W_W(&buffer, R10, R8, RZR);
    assert(buffer==bswap32(0x0a011f1a));

第一行注释中前半部分为机器码的大端表示,后半部分为机器码对应的汇编代码。第二行为汇编器的汇编方法调用。第三行为汇编结果检查,确认结果和注释中的机器码一致,由于注释中的机器码为大端表示,需要做 byte swap 才和汇编结果匹配。

反汇编器

这里的反汇编器不包含完整的反汇编功能,目的是为了用于在优化器中识别机器码,取机器码中的参数使用。简单举例:

#define IS_MOV_X_X(ins) \
    (IMM11(ins >> 21)==IMM11(HY_INS_TEMPLATE_MOV_X_X >> 21) && \
    IMM11(ins >> 5)==IMM11(HY_INS_TEMPLATE_MOV_X_X >> 5))

这条指令就可以在优化器中判断某条指令是不是mov xd, xm,进而可以通过如下代码取出 xd 中 d 的具体数值:

#define RM(ins) IMM5(ins >> 16)

#define RN(ins) IMM5(ins >> 5)

#define RD(ins) IMM5(ins)

同样的,我们为反汇编器也做了对应的单测:

// e7031eaa|    mov x7, x30
assert(IS_MOV_X_X(bswap32(0xe7031eaa)));

wasm编译

编译时我们会遍历 wasm 模块的每个方法,估算存放产物代码所需的内存空间,然后将方法中的字节码翻译为机器码。

其中核心的翻译的整体实现是一个大的循环 + switch,每遍历一个 opcode 即生成一段对应的机器码,代码示例如下:

M3Result h3_JITFunction(IH3JITState state, IH3JITModule hmodule,
                        IH3JITFunction hfunction) {
    uint32_t *alloc=state->code + state->codeOffset;
    
    ......
    
    // prologue
    // stp        x28, x27, [sp, #-0x60]!
    // stp        x26, x25, [sp, #0x10]!
    // stp        x24, x23, [sp, #0x20]
    // stp        x22, x21, [sp, #0x30]
    // stp        x20, x19, [sp, #0x40]
    // stp        x29, x30, [sp, #0x50]
    // add        x20, sp, #0x50
    STP_X_X_X_I_PR(alloc + codeOffset++, R28, R27, RSP, -0x60);
    STP_X_X_X_I(alloc + codeOffset++, R26, R25, RSP, 0x10);
    STP_X_X_X_I(alloc + codeOffset++, R24, R23, RSP, 0x20);
    STP_X_X_X_I(alloc + codeOffset++, R22, R21, RSP, 0x30);
    STP_X_X_X_I(alloc + codeOffset++, R20, R19, RSP, 0x40);
    STP_X_X_X_I(alloc + codeOffset++, R29, R30, RSP, 0x50);
    ADD_X_X_I(alloc + codeOffset++, R29, RSP, 0x50);
    
    ......
    
    for (bytes_t i=wasm; i < wasmEnd; i +=opcodeSize) {
        uint32_t index=(uint32_t)(i - wasm) / sizeof(u8);
        uint8_t opcode=*i;
        
        ......
        
        switch (opcode) {
        case OP_UNREACHABLE: {
            BRK_I(alloc + codeOffset++, 0);
            break;
        }

        case OP_NOP: {
            NOP(alloc + codeOffset++);
            break;
        }
        
        ......
        
        case OP_REF_NULL:
        case OP_REF_IS_NULL:
        case OP_REF_FUNC:
        default:
            break;
        }

        if (spOffset > maxSpOffset) {
            maxSpOffset=spOffset;
        }

    }
    
    ......
    
    // return 0(m3Err_none)
    MOV_X_I(alloc + codeOffset++, R0, 0);
    // epilogue
    // ldp        x29, x30, [sp, #0x50]
    // ldp        x20, x19, [sp, #0x40]
    // ldp        x22, x21, [sp, #0x30]
    // ldp        x24, x23, [sp, #0x20]
    // ldp        x26, x25, [sp, #0x10]
    // ldp        x28, x27, [sp], #0x60
    // ret
    LDP_X_X_X_I(alloc + codeOffset++, R29, R30, RSP, 0x50);
    LDP_X_X_X_I(alloc + codeOffset++, R20, R19, RSP, 0x40);
    LDP_X_X_X_I(alloc + codeOffset++, R22, R21, RSP, 0x30);
    LDP_X_X_X_I(alloc + codeOffset++, R24, R23, RSP, 0x20);
    LDP_X_X_X_I(alloc + codeOffset++, R26, R25, RSP, 0x10);
    LDP_X_X_X_I_PO(alloc + codeOffset++, R28, R27, RSP, 0x60);
    RET(alloc + codeOffset++);
    
    ......
    
    return m3Err_none;
}

上述代码会先生成方法的 prologue,然后 for 循环遍历 wasm 字节码,生产对应的 arm64 机器码,最后加上方法的 epilogue。

字节码生成机器码以 wasm 的 opcode i32.add 为例:

case OP_I32_ADD: {
    LDR_X_X_I(alloc + codeOffset++, R8, R19, (spOffset - 2) * sizeof(void *));
    LDR_X_X_I(alloc + codeOffset++, R9, R19, (spOffset - 1) * sizeof(void *));
    ADD_W_W_W(alloc + codeOffset++, R9, R8, R9);
    STR_X_X_I(alloc + codeOffset++, R9, R19, (spOffset - 2) * sizeof(void *));
    spOffset--;
    break;
}

代码中的alloc是当前正在编译的方法的机器码存放首地址,codeOffset是当前机器码相对于首地址的偏移,R8/R9代表我们约定的两个临时寄存器,R19存放的栈底地址,spOffset是运行到当前 opcode 时栈相对于栈底的偏移。

这段代码会生成 4 条机器码,分别用于加载位于栈上spOffset - 2和spOffset - 1位置的两条数据,然后相加,再把结果存放到栈上spOffset - 2位置。由于 i32.add 指令会消耗 2 条栈上数据,并生成 1 条栈上数据,最终栈的偏移就要 -1。

上述代码生成的机器码及其对应助记形式如下:

f9400a68: ldr    x8, [x19, #0x10]
f9400e69: ldr    x9, [x19, #0x18]
0b090109: add    w9, w8, w9
f9000a69: str    x9, [x19, #0x10]

x表示64位寄存器,w表示 64 位寄存器的低 32 位,由于 i32.add 指令是做 32 位加法,这里只需要加低 32 位即可。

以如下 fibonacci 的 c 代码:

uint32_t fib_native(uint32_t n) {
    if (n < 2) return n;
    return fib_native(n - 1) + fib_native(n - 2);
}

编译产生的 wasm 代码:

    parse  |  load module: 61 bytes
    parse  |  found magic + version
    parse  |  ** Type [1]
    parse  |      type  0: (i32) -> i32
    parse  |  ** Function [1]
    parse  |  ** Export [1]
    parse  |      index:   0; kind: 0; export: 'fib'; 
    parse  |  ** Code [1]
    parse  |      code size: 29  
  compile  |  compiling: 'fib'; wasm-size: 29; numArgs: 1; return: i32
  compile  |  estimated constant slots: 3
  compile  |  start stack index: 1
  compile  |     0 | 0x20  .. local.get
  compile  |     1 | 0x41  .. i32.const
  compile  |       | .......... (const i32=2)
  compile  |     2 | 0x49  .. i32.lt_u
  compile  |     3 | 0x04  .. if
  compile  |     4 | 0x20  .... local.get
  compile  |     5 | 0x0f  .... return
  compile  |     6 | 0x0b  .. end
  compile  |     7 | 0x20  .. local.get
  compile  |     8 | 0x41  .. i32.const
  compile  |       | .......... (const i32=2)
  compile  |     9 | 0x6b  .. i32.sub
  compile  |    10 | 0x10  .. call
  compile  |       | .......... (func='fib'; args=1)
  compile  |    11 | 0x20  .. local.get
  compile  |    12 | 0x41  .. i32.const
  compile  |       | .......... (const i32=1)
  compile  |    13 | 0x6b  .. i32.sub
  compile  |    14 | 0x10  .. call
  compile  |       | .......... (func='fib'; args=1)
  compile  |    15 | 0x6a  .. i32.add
  compile  |    16 | 0x0f  .. return
  compile  |    17 | 0x0b   end
  compile  |  unique constant slots: 2; unused slots: 1
  compile  |  max stack slots: 7

经过 hyengine jit 编译的产出代码如下:

    0x107384000: stp    x28, x27, [sp, #-0x60]!
    0x107384004: stp    x26, x25, [sp, #0x10]
    0x107384008: stp    x24, x23, [sp, #0x20]
    0x10738400c: stp    x22, x21, [sp, #0x30]
    0x107384010: stp    x20, x19, [sp, #0x40]
    0x107384014: stp    x29, x30, [sp, #0x50]
    0x107384018: add    x29, sp, #0x50            ;=0x50 
    0x10738401c: mov    x19, x0
    0x107384020: ldr    x9, [x19]
    0x107384024: str    x9, [x19, #0x8]
    0x107384028: mov    w9, #0x2
    0x10738402c: str    x9, [x19, #0x10]
    0x107384030: mov    x9, #0x1
    0x107384034: ldr    x10, [x19, #0x8]
    0x107384038: ldr    x11, [x19, #0x10]
    0x10738403c: cmp    w10, w11
    0x107384040: csel   x9, x9, xzr, lo
    0x107384044: str    x9, [x19, #0x8]
    0x107384048: ldr    x9, [x19, #0x8]
    0x10738404c: cmp    x9, #0x0                  ;=0x0 
    0x107384050: b.eq   0x107384068
    0x107384054: ldr    x9, [x19]
    0x107384058: str    x9, [x19, #0x8]
    0x10738405c: ldr    x9, [x19, #0x8]
    0x107384060: str    x9, [x19]
    0x107384064: b      0x1073840dc
    0x107384068: ldr    x9, [x19]
    0x10738406c: str    x9, [x19, #0x10]
    0x107384070: mov    w9, #0x2
    0x107384074: str    x9, [x19, #0x18]
    0x107384078: ldr    x8, [x19, #0x10]
    0x10738407c: ldr    x9, [x19, #0x18]
    0x107384080: sub    w9, w8, w9
    0x107384084: str    x9, [x19, #0x10]
    0x107384088: add    x0, x19, #0x10            ;=0x10 
    0x10738408c: bl     0x10738408c
    0x107384090: ldr    x9, [x19]
    0x107384094: str    x9, [x19, #0x18]
    0x107384098: mov    w9, #0x1
    0x10738409c: str    x9, [x19, #0x20]
    0x1073840a0: ldr    x8, [x19, #0x18]
    0x1073840a4: ldr    x9, [x19, #0x20]
    0x1073840a8: sub    w9, w8, w9
    0x1073840ac: str    x9, [x19, #0x18]
    0x1073840b0: add    x0, x19, #0x18            ;=0x18 
    0x1073840b4: bl     0x1073840b4
    0x1073840b8: ldr    x8, [x19, #0x10]
    0x1073840bc: ldr    x9, [x19, #0x18]
    0x1073840c0: add    w9, w8, w9
    0x1073840c4: str    x9, [x19, #0x10]
    0x1073840c8: ldr    x9, [x19, #0x10]
    0x1073840cc: str    x9, [x19]
    0x1073840d0: b      0x1073840dc
    0x1073840d4: ldr    x9, [x19, #0x10]
    0x1073840d8: str    x9, [x19]
    0x1073840dc: mov    x0, #0x0
    0x1073840e0: ldp    x29, x30, [sp, #0x50]
    0x1073840e4: ldp    x20, x19, [sp, #0x40]
    0x1073840e8: ldp    x22, x21, [sp, #0x30]
    0x1073840ec: ldp    x24, x23, [sp, #0x20]
    0x1073840f0: ldp    x26, x25, [sp, #0x10]
    0x1073840f4: ldp    x28, x27, [sp], #0x60
    0x1073840f8: ret

这段代码运行fib_native(40)耗时 1716ms,而 wasm3 解释执行 wasm 运行同样代码耗时 3637ms,耗时只有解释执行的约 47%,但这够快吗?

优化器

上面的代码看起来似乎感觉没什么大毛病,但是和 llvm 编译出来的 native 代码一比较,差距就出来的了。fib_native的 c 代码经过 llvm 编译的反汇编代码如下:

hyengine 产出的指令有 63 条,而 llvm 产出的指令只有 17 条,指令数量是 llvm 的约 3.7 倍!而实际运行性能差距更大,hyengine 产出的代码运行fib_native(40)耗时 1716ms,llvm 产出的代码耗时 308ms,耗时是 llvm 的约 5.57 倍。

为了缩小差距,是时候做一些优化了。

1)优化器的主要流程

优化器的主要流程如下:

先将整个方法体的代码按照跳转指令(如:b/cbz 等)及其跳转目标地址做拆分,将方法体拆为多个代码块。然后对每个块跑一下优化的 pass ,对块内代码进行优化。最后将优化后的指令块重新合并为一个方法体,优化完成。

需要把方法体拆分为块的原因之一在于,优化器可能会删除或者增加代码,这样跳转指令的跳转目标地址就会发生改变,需要重新计算跳转目标,拆成块后跳转目标比较容易计算。

在块拆分及优化 pass 的实现中,会用到前面提到反汇编器和汇编器,这也是整个优化器的核心依赖。

我们以前文中的代码的一部分为例子,做优化流程的介绍,首先是块拆分:

    ; --- code block 0 ---
    0x107384048: ldr    x9, [x19, #0x8]
    0x10738404c: cmp    x9, #0x0                  ;=0x0 
 -- 0x107384050: b.eq   0x107384068               ; b.eq 6
|    
|    ; --- code block 1 ---
|   0x107384054: ldr    x9, [x19]
|   0x107384058: str    x9, [x19, #0x8]
|   0x10738405c: ldr    x9, [x19, #0x8]
|   0x107384060: str    x9, [x19]
|   0x107384064: b      0x1073840dc
|    
|    ; --- code block 2 ---
 -> 0x107384068: ldr    x9, [x19]
    0x10738406c: str    x9, [x19, #0x10]

这里会根据代码中的第四行的b.eq指令及其跳转的目标地址第 14 行作拆分,代码为拆为了 3 个块。原本第 11 行的 b 指令也要做一次拆分,但前面的b.eq已经拆过了,就不再拆了。

接下对会对拆分成块后的代码跑一堆优化的 pass ,跑完后的结果如下:

    ; --- code block 0 ---
    0x104934020: cmp    w9, #0x2                  ;=0x2 
 -- 0x104934024: b.hs   0x104934038               ; b.hs 5
|   
|   ; --- code block 1 ---
|   0x104934028: mov    x9, x20
|   0x10493402c: mov    x21, x9
|   0x104934030: mov    x20, x9
|   0x104934034: b      0x104934068
|
|    ; --- code block 2 ---
 -> 0x104934038: sub    w22, w20, #0x2            ;=0x2

在跑完一堆 pass 后代码完全变了样(关键优化的实现请看下一节内容),但可以看出 code block 1 的代码从 5 条指令变成了 4 条,之前的b.eq被优化为了b.hs跳转的目标地址的偏移也少 1,从 6 变为 5。

最后把块重新合并成为新的方法体指令:

    0x104934020: cmp    w9, #0x2                  ;=0x2 
    0x104934024: b.hs   0x104934038               ; b.hs 5
    0x104934028: mov    x9, x20
    0x10493402c: mov    x21, x9
    0x104934030: mov    x20, x9
    0x104934034: b      0x104934068
    0x104934038: sub    w22, w20, #0x2            ;=0x2

2)关键优化之寄存器分配

3.7 倍代码量的速度慢 5.57 倍的一个主要原因在于,我们生产的代码中数据完全存放在栈中,栈在内存上,各种ldr/str指令对内存的访问,就算数据在 cpu 的 l1 cache 上,也比对寄存器的访问慢 4 倍。为此,如果我们将数据尽量放在寄存器,减少对内存的访问,就可以进一步提升性能。

寄存器分配有一些较为成熟的方案,常用的包括:基于 live range 的线性扫描内存分配,基于 live internal 的线性扫描内存分配,基于图染色的内存分配等。在常见 jit 实现,会采用基于 live internal 的线性扫描内存分配方案,来做到产物性能和寄存器分配代码的时间复杂度的平衡。

为了实现的简单性,hyengine 使用了一种非主流的极简方案,基于代码访问次数的线性扫描内存分配,用人话说就是:给代码中出现次数最多的栈偏移分配寄存器。

假设代码如下(节选自 hyengine jit 产出代码):

    0x107384020: ldr    x9, [x19]
    0x107384024: str    x9, [x19, #0x8]
    0x107384028: mov    w9, #0x2
    0x10738402c: str    x9, [x19, #0x10]
    0x107384030: mov    x9, #0x1
    0x107384034: ldr    x10, [x19, #0x8]
    0x107384038: ldr    x11, [x19, #0x10]

对假设代码的分配寄存器后代码如下:

    0x107384020: ldr    x9, [x19]        ; 偏移0没变
    0x107384024: mov    x20, x9          ; 偏移8变成x20
    0x107384028: mov    w9, #0x2
    0x10738402c: mov    x21, x9          ; 偏移16变成x21
    0x107384030: mov    x9, #0x1
    0x107384034: mov    x10, x20         ; 偏移8变成x20
    0x107384038: mov    x11, x21         ; 偏移16变成x21

之前的 jit 产物代码优化后如下(注:做了少量指令融合):

    0x102db4000: stp    x28, x27, [sp, #-0x60]!
    0x102db4004: stp    x26, x25, [sp, #0x10]
    0x102db4008: stp    x24, x23, [sp, #0x20]
    0x102db400c: stp    x22, x21, [sp, #0x30]
    0x102db4010: stp    x20, x19, [sp, #0x40]
    0x102db4014: stp    x29, x30, [sp, #0x50]
    0x102db4018: add    x29, sp, #0x50            ;=0x50 
    0x102db401c: mov    x19, x0
    0x102db4020: ldr    x9, [x19]
    0x102db4024: mov    x20, x9
    0x102db4028: mov    x21, #0x2
    0x102db402c: mov    x9, #0x1
    0x102db4030: cmp    w20, w21
    0x102db4034: csel   x9, x9, xzr, lo
    0x102db4038: mov    x20, x9
    0x102db403c: cmp    x9, #0x0                  ;=0x0 
    0x102db4040: b.eq   0x102db4054
    0x102db4044: ldr    x9, [x19]
    0x102db4048: mov    x20, x9
    0x102db404c: str    x20, [x19]
    0x102db4050: b      0x102db40ac
    0x102db4054: ldr    x9, [x19]
    0x102db4058: mov    x21, x9
    0x102db405c: mov    x22, #0x2
    0x102db4060: sub    w9, w21, w22
    0x102db4064: mov    x21, x9
    0x102db4068: add    x0, x19, #0x10            ;=0x10 
    0x102db406c: str    x21, [x19, #0x10]
    0x102db4070: bl     0x102db4070
    0x102db4074: ldr    x21, [x19, #0x10]
    0x102db4078: ldr    x9, [x19]
    0x102db407c: mov    x22, x9
    0x102db4080: mov    x23, #0x1
    0x102db4084: sub    w9, w22, w23
    0x102db4088: mov    x22, x9
    0x102db408c: add    x0, x19, #0x18            ;=0x18 
    0x102db4090: str    x22, [x19, #0x18]
    0x102db4094: bl     0x102db4094
    0x102db4098: ldr    x22, [x19, #0x18]
    0x102db409c: add    w9, w21, w22
    0x102db40a0: mov    x21, x9
    0x102db40a4: str    x21, [x19]
    0x102db40a8: nop    
    0x102db40ac: mov    x0, #0x0
    0x102db40b0: ldp    x29, x30, [sp, #0x50]
    0x102db40b4: ldp    x20, x19, [sp, #0x40]
    0x102db40b8: ldp    x22, x21, [sp, #0x30]
    0x102db40bc: ldp    x24, x23, [sp, #0x20]
    0x102db40c0: ldp    x26, x25, [sp, #0x10]
    0x102db40c4: ldp    x28, x27, [sp], #0x60
    0x102db40c8: ret

优化后的代码量从 63 条减少到 51 条,且内存访问数量明显减少,耗时也减少到 1361ms,耗时减少到 llvm 的约 4.42 倍。

3)关键优化之寄存器参数传递

在寄存器分配优化的最后一条中提到,在方法调用时需要把寄存器的值拷回栈,额外增加了内存访问的开销。其相关汇编代码为:

    0x102db4068: add    x0, x19, #0x10            ;=0x10 
    0x102db406c: str    x21, [x19, #0x10]
    0x102db4070: bl     0x102db4070
    0x102db4074: ldr    x21, [x19, #0x10]

而 arm64 的调用约定中,参数传递是通过寄存器来做的,这样每次方法调用可以减少两次内存访问。
这里把 wasm 的栈作为放入x0, 第一个参数x22直接放入x1,方法调用后的返回值x0直接放入x22,优化后代码如下:

    0x1057e405c: add    x0, x19, #0x10            ;=0x10 
    0x1057e4060: mov    x1, x22
    0x1057e4064: bl     0x1057e4064
    0x1057e4068: mov    x22, x0

注:这里因为给栈偏移 0 也分配了寄存器,所以寄存器的编号比优化前的代码多 1 。
同时将方法头部取参数的代码从:

    0x102db4020: ldr    x9, [x19]
    0x102db4024: mov    x20, x9

优化为:

    0x1057e4020: mov    x20, x1

这里又减少了一次内存访问和一条指令。
优化后最终完整的代码如下:

    0x1057e4000: stp    x28, x27, [sp, #-0x60]!
    0x1057e4004: stp    x26, x25, [sp, #0x10]
    0x1057e4008: stp    x24, x23, [sp, #0x20]
    0x1057e400c: stp    x22, x21, [sp, #0x30]
    0x1057e4010: stp    x20, x19, [sp, #0x40]
    0x1057e4014: stp    x29, x30, [sp, #0x50]
    0x1057e4018: add    x29, sp, #0x50            ;=0x50 
    0x1057e401c: mov    x19, x0
    0x1057e4020: mov    x20, x1
    0x1057e4024: mov    x21, x20
    0x1057e4028: mov    x22, #0x2
    0x1057e402c: mov    x9, #0x1
    0x1057e4030: cmp    w21, w22
    0x1057e4034: csel   x9, x9, xzr, lo
    0x1057e4038: mov    x21, x9
    0x1057e403c: cmp    x9, #0x0                  ;=0x0 
    0x1057e4040: b.eq   0x1057e404c
    0x1057e4044: mov    x21, x20
    0x1057e4048: b      0x1057e409c
    0x1057e404c: mov    x22, x20
    0x1057e4050: mov    x23, #0x2
    0x1057e4054: sub    w9, w22, w23
    0x1057e4058: mov    x22, x9
    0x1057e405c: add    x0, x19, #0x10            ;=0x10 
    0x1057e4060: mov    x1, x22
    0x1057e4064: bl     0x1057e4064
    0x1057e4068: mov    x22, x0
    0x1057e406c: mov    x23, x20
    0x1057e4070: mov    x24, #0x1
    0x1057e4074: sub    w9, w23, w24
    0x1057e4078: mov    x23, x9
    0x1057e407c: add    x0, x19, #0x18            ;=0x18 
    0x1057e4080: mov    x1, x23
    0x1057e4084: bl     0x1057e4084
    0x1057e4088: mov    x23, x0
    0x1057e408c: add    w9, w22, w23
    0x1057e4090: mov    x22, x9
    0x1057e4094: mov    x20, x22
    0x1057e4098: nop    
    0x1057e409c: mov    x0, x20
    0x1057e40a0: ldp    x29, x30, [sp, #0x50]
    0x1057e40a4: ldp    x20, x19, [sp, #0x40]
    0x1057e40a8: ldp    x22, x21, [sp, #0x30]
    0x1057e40ac: ldp    x24, x23, [sp, #0x20]
    0x1057e40b0: ldp    x26, x25, [sp, #0x10]
    0x1057e40b4: ldp    x28, x27, [sp], #0x60
    0x1057e40b8: ret

优化后的代码量从 51 条减少到 47 条,耗时也减少到 687ms,耗时减少到 llvm 的约 2.23 倍。虽然代码量只减少了 4 条,但耗时显著减少了约 50%。

注:这个优化仅对方法体比较短且调用频繁的方法有显著跳过,方法体比较长的代码效果不明显。

4)关键优化之特征匹配

特征匹配就是在代码中遍历预设的代码特征,对符合特征的代码做相应的优化。
比如上面代码中的:

    0x1057e404c: mov    x22, x20
    0x1057e4050: mov    x23, #0x2
    0x1057e4054: sub    w9, w22, w23
    0x1057e4058: mov    x22, x9

可以被优化为:

    0x104934038: sub    w22, w20, #0x2            ;=0x2

4 条指令变 1 条。

5)优化结果

经过上述多种优化后,代码变为:

    0x104934000: stp    x24, x23, [sp, #-0x40]!
    0x104934004: stp    x22, x21, [sp, #0x10]
    0x104934008: stp    x20, x19, [sp, #0x20]
    0x10493400c: stp    x29, x30, [sp, #0x30]
    0x104934010: add    x29, sp, #0x30            ;=0x30 
    0x104934014: mov    x19, x0
    0x104934018: mov    x20, x1
    0x10493401c: mov    x9, x20
    0x104934020: cmp    w9, #0x2                  ;=0x2 
    0x104934024: b.hs   0x104934038
    0x104934028: mov    x9, x20
    0x10493402c: mov    x21, x9
    0x104934030: mov    x20, x9
    0x104934034: b      0x104934068
    0x104934038: sub    w22, w20, #0x2            ;=0x2 
    0x10493403c: add    x0, x19, #0x10            ;=0x10 
    0x104934040: mov    x1, x22
    0x104934044: bl     0x104934000
    0x104934048: mov    x22, x0
    0x10493404c: sub    w23, w20, #0x1            ;=0x1 
    0x104934050: add    x0, x19, #0x18            ;=0x18 
    0x104934054: mov    x1, x23
    0x104934058: bl     0x104934000
    0x10493405c: add    w9, w22, w0
    0x104934060: mov    x22, x9
    0x104934064: mov    x20, x9
    0x104934068: mov    x0, x20
    0x10493406c: ldp    x29, x30, [sp, #0x30]
    0x104934070: ldp    x20, x19, [sp, #0x20]
    0x104934074: ldp    x22, x21, [sp, #0x10]
    0x104934078: ldp    x24, x23, [sp], #0x40
    0x10493407c: ret

优化后的代码量从 63 条减少到 32 条,耗时从 1716ms 减少到 493ms ,耗时减少到 llvm 的约 1.6 倍。

quickjs 编译

注:js 的主要耗时在 runtime,所以目前 jit 只占 js 整体性能优化的约 20%,后续将引入更多 jit 优化细节。

quickjs 的编译流程和 wasm 类似,只是对 opcode 的实现上会稍微复杂一些,以OP_object为例:

    // *sp++=JS_NewObject(ctx);
    // if (unlikely(JS_IsException(sp[-1])))
    //     goto exception;
case OP_object: {
    MOV_FUNCTION_ADDRESS_TO_REG(R8, JS_NewObject);
    MOV_X_X(NEXT_INSTRUCTION, R0, CTX_REG);
    BLR_X(NEXT_INSTRUCTION, R8);
    STR_X_X_I(NEXT_INSTRUCTION, R0, R26, SP_OFFSET(0));
    CHECK_EXCEPTION(R0, R9);
    
    break;
}

这里首先通过MOV_FUNCTION_ADDRESS_TO_REG宏把要调用的JS_NewObject方法地址放入R8寄存器:

#define MOV_FUNCTION_ADDRESS_TO_REG(reg, func)                                 \
{                                                                          \
        uintptr_t func##Address=(uintptr_t)func;                             \
        MOVZ_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address), LSL, 0);     \
        if (IMM16(func##Address >> 16) !=0) {                                 \
            MOVK_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address >> 16),    \
                         LSL, 16);                                             \
        } else {                                                               \
            NOP(NEXT_INSTRUCTION);                                             \
        }                                                                      \
        if (IMM16(func##Address >> 32) !=0) {                                 \
            MOVK_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address >> 32),    \
                         LSL, 32);                                             \
        } else {                                                               \
            NOP(NEXT_INSTRUCTION);                                             \
        }                                                                      \
        if (IMM16(func##Address >> 48) !=0) {                                 \
            MOVK_X_I_S_I(NEXT_INSTRUCTION, reg, IMM16(func##Address >> 48),    \
                         LSL, 48);                                             \
        } else {                                                               \
            NOP(NEXT_INSTRUCTION);                                             \
        }                                                                      \
    }

然后将CTX_REG(里面存的 ctx 地址)放入R0作为第一个参数,并调用JS_NewObject,然后结果存入 js 栈的SP_OFFSET(0)位置。然后通过CHECK_EXCEPTION判断结果是否存在异常:

#define EXCEPTION(tmp)                                                     \
    LDR_X_X_I(NEXT_INSTRUCTION, tmp, CTX_REG, HYJS_BUILTIN_OFFSET(0));     \
    MOV_X_I(NEXT_INSTRUCTION, R0, SP_OFFSET(0));                           \
    BLR_X(NEXT_INSTRUCTION, tmp);

#define CHECK_EXCEPTION(reg, tmp)                                          \
    MOV_X_I(NEXT_INSTRUCTION, tmp, ((uint64_t)JS_TAG_EXCEPTION<<56));      \
    CMP_X_X_S_I(NEXT_INSTRUCTION, reg, tmp, LSL, 0);                       \
    B_C_L(NEXT_INSTRUCTION, NE, 4 * sizeof(uint32_t));                     \
    EXCEPTION(tmp)

就这一个 opcode 生成的 arm64 机器码就多达 13 条!而且这还不算多的。

同样是 fibonacci 的实现,wasm 的 jit 产物代码只有 32 条,而 quickjs 的有 467 条!!!又想起了被汇编所支配的恐惧。

注:这么指令源于对 builtin 的调用、引用计数、类型判断。后面 vm 优化将引用计数干掉后代码量减少到 420 条。

2 引擎(vm)部分

因为 wasm 本身是强类型的字节码,runtime 本身提供的能力较少,性能瓶颈也主要在代码的解释执行,所以 vm 部分的基本没有做优化。而 quickjs 的字节码作为弱类型的字节码,其主要功能需要依赖 runtime 来实现,同时由于语言本身接管了内存管理,由此带来的 gc 也开销也比较明显。

在之前对某业务js代码的性能分析后发现,超过 50% 的性能开销在内存分配及 gc 上,为此引擎部分将主要介绍对 quickjs 的内存分配和 gc 优化,部分 runtime 的 builtin 的快路径、inline cache 目前优化占比不高,仅做少量介绍。

内存分配器 hymalloc

为了实现 hyengine 对 quickjs 性能优化,同时兼顾 gc 优化所需要的对内存的管理权,需要设计一套更快速(无锁,非线程安全)的内存分配器。同时需要考虑面向其他引擎可能需要的定制,来做到 hymalloc 的尽量通用。

1)实现简介

hymalloc 将内存分为 19 个区(region),18 个 small region/1 个 large region。small region主要用来存放规则内存,每个区的大小分从为 116 至 1916 bytes;large region 用于存放大于 9*16 bytes 的内存。

每个区可包含多个池(pool),每个池里面可包含多个目标大小的条目(item)。large region 比较特殊,每个 pool 里只有 1 个条目。在向系统申请内存时,按 pool 来做申请,之后再将 pool 拆分成对应的 item。

每个 small region 初始化有一个池,池的大小可配置,默认为 1024 个 item;large region 默认是空的。

区/块/池的示意图如下:

这里对最关键的两个数据结构做下简单介绍:

// hymalloc item
struct HYMItem {
    union {
        HYMRegion* region;     // set to region when allocated
        HYMItem*   next;       // set to next free item when freed
    };
    size_t  flags;
    uint8_t ptr[0];
};

// hymalloc pool
struct HYMPool {
    HYMRegion *region;
    HYMPool   *next;
    size_t    item_size;
};

其中 HYMItem 是前面提到的 item 的数据结构,这里的 item 的大小不固定,数据结构本身更像是 item header描述,其中 flags 目前作为 gc 的特别标记存在,ptr 用于取 item 的实际可用部分内存的地址(通过&item->ptr获取)。union 中的 region/next 是一个用来省内存的设计,在 item 被分配出去之前,next 的值指向 region 的下一个空闲 item;在 item 被分配出去之后,region 被设定为 item 所属的 region 地址。
region 的空闲 item 链表示意图如下:

在内存分配时,取链表的首个 item 作为分配结果,链表如果为空,则向系统申请一个新的 pool 并把 pool 的item 放入链表,分配示意图如下:

分配代码如下:

static void* _HYMallocFixedSize(HYMRegion *region, size_t size) {
    // allocate new pool, if no free item exists
    if (region->free_item_list==NULL) {
        // notice: large region's item size is 0, use 'size' instead
        size_t item_size=region->item_size ? region->item_size : size;
        int ret=_HYMAllocPool(region, region->pool_initial_item_count, item_size);
        if (!ret) {
            return NULL;
        }
    }
    
    // get free list item head, and set region to item's region
    HYMItem *item=region->free_item_list;
    region->free_item_list=item->next;
    item->region=region;
    item->flags=0;
    
    return &item->ptr;
}

在内存释放时,将 item 插入所属 region 的空闲链表的头部即可:

void HYMFree(void *ptr) {
    HYMItem *item=(HYMItem *)((uint8_t *)ptr - HYM_ITEM_SIZE_OVERHEAD);
    
    // set item as head of region's free item list
    HYMRegion *region=item->region;
    HYMItem *first_item_in_region=region->free_item_list;
    region->free_item_list=item;
    item->next=first_item_in_region;

}

上述实现在简单的内存分配/释放测试 case 中,在 macbook m1 设备上比系统提供的 malloc/free 快约4倍。

2)内存 compact + update

为了减少内存占用,hymalloc 实现了部分内存 compact ,可以清理完全未使用的 small region中的 pool 和 large region 的所有 pool。但目前没有实现 update 功能,无法做到真正的将不同 pool 之间的 item 相互拷贝,来做到更多内存的节省。

但从客户端的使用场景来看,运行代码的内存用量本身不高,compact + update 完整组合的实现复杂度较高,性价比不足。后续根据实际业务的使用情况,再评估实现完整 compact + update 的必要性。

3)hymalloc 的局限性

为了提升分配和释放性能,hymalloc 的每个 item 都有 header,需要额外占用内存空间,这会导致一定的内存浪费。

而且虽然 hymalloc 提供了 compact 方法来释放空闲的内存,但由于按照 pool 来批量申请内存,只要 pool 中有一个 item 被使用,那么这个 pool 就不会被释放,导致内存不能被完全高效的释放。

另外,考虑到内存被复用的概率,large region 的内存会默认按 256bytes 对齐来申请,同样可能存在浪费。

上述问题可以通过设定更小的 pool 的默认 item 数量,及更小的对齐尺寸,牺牲少量性能,来减少内存浪费。

后续可以引入更合理的数据结构,以及更完善的 compact + update 机制,来减少内存浪费。

垃圾回收器 hygc

quickjs 的原本的gc基于引用计数 + mark sweep,设计和实现本身比较简洁高效,但未实现分代、多线程、compact、闲时 gc、拷贝 gc,使得 gc 在整体执行耗时中的占比较高,同时也存在内存碎片化带来的潜在性能降低。另外由于引用计数的存在,jit 生成的代码中会存在大量的引用计数操作的指令,使得代码体积较大。

为了实现 hyengine 对 quickjs 性能优化,减少 gc 在整体耗时种的占比,减少 gc 可能导致的长时间运行停止。参考 v8 等其他先进引擎的 gc 设计思路,实现一套适用于移动端业务的,轻量级、高性能、实现简单的 gc。

注:本实现仅仅针对于 quickjs,后续可能会衍生出通用的 gc 实现。

注:为了保障业务体验不出现卡顿,需要将 gc 的暂停时间控制在 30ms 内。

1)常用垃圾回收实现

常用的垃圾回收主要有 3 大类:

  • 引用计数给每个对象加一个引用数量,多一个引用数量 +1,少一个引用数量 -1,如果引用数量为 0 则释放。弊端:无法解决循环引用问题。
  • mark sweep遍历对象,标记对象是否有引用,如果没有请用则清理掉。
  • 拷贝 gc遍历对象,标记对象是否有引用,把有引用的对象拷贝一份新的,丢弃所有老的内存。

基于这三大类会有一些衍生,来实现多线程等支持,比如:

  • 三色标记 gc遍历对象,标记对象是否有引用,状态比单纯的有引用(黑色)和无引用(白色)多一个中间状态标记中/不确定(灰色),可支持多线程。

为了尽可能减少 gc 暂停时间并减少 js 执行耗时,hygc 采用多线程三色 gc 方案。在业务 case 测试中,发现本身内存使用量并不大,故没有引入分代支持。

2)hygc 的业务策略

hygc 计划将策略可以暴露给用户,用于满足不同使用场景的性能需求,提供:无 gc、闲时 gc、多线程 gc 三种选项,应对不同场景对内存和性能的不同诉求。业务根据实际需求选择 gc 策略,建议对 gc 策略设置开关,避免所选的 gc 策略可能导致非预期的结果。

  • 无 gc运行期不触发 gc 操作。待代码完全运行完毕销毁 runtime 时做一次 full gc 整体释放内存。
  • 闲时 gc运行期不触发 gc 操作,运行结束后在异步线程做 gc。代码完全运行完毕销毁 runtime 时做一次 full gc 整体释放内存。
  • 默认 gc运行期会触发 gc。代码完全运行完毕销毁 runtime 时做一次 full gc 整体释放内存。

我们的某个业务case就可以设定无 gc 或闲时 gc,因为代码运行期间没有内存能被回收,gc 是在浪费时间。

3)hygc 的实现方案

quickjs 原本采用引用计数 + mark sweep 结合的 gc 方案,在 gc 优化时被移除,并替换为新的多线程三色标记gc 方案。hygc 的实现复用了部分原本 quickjs 的代码,做到尽可能简单的实现所需功能。

hygc 的三色标记流程(单线程版本):

首先,收集根对象的主要操作是扫描 js 线程的栈,并将线程栈上的 js 对象和 js 调用栈关联的对象收集起来,作为三色标记的根对象。然后,从根对象作为标记入口,依次递归标记子对象。遍历 gc_obj_list(quickjs 的所有需要 gc 的对象都在这个双向链表上),将没有被标记到的对象放入 tmp_obj_list。最后,释放 tmp_obj_list 中的对象。

单线程的 gc 会在 gc 过程中完全暂停 js 的执行,存在潜在的业务卡顿风险(仅仅是潜在,由于实际业务的内存使用量较小,暂并未出现由 gc 导致的卡顿),并且会让js的执行时间相对较长。为此 hygc 引入了多线程的三色标记,其流程如下:

在多线程版本中,存在 js 和 gc 两个线程,js 线程完成根对象收集及老对象转移到异步 gc 链表,然后 js 继续执行。gc 线程会先将老对象的三色标记全设为 0,然后开始标记存活对象,然后对垃圾对象进行收集。这里将垃圾对象的释放拆分成了 2 个阶段,一个是可以在 gc 线程执行的垃圾对象相关属性修改及置空,另一个是需要在 js 线程做的内存释放,这么做的原因是 hymalloc 不是线程安全的。这样 js 线程中的 gc 操作就只剩下相对不耗时的根对象收集、老对象转移、内存释放三个操作。

注:令人悲伤的是,由于 mark 和垃圾回收仍然只在单独一个线程完成,这里只用到了两种颜色做标记,灰色实际上没用到。后续优化让 hygc 实现和 quickjs 原本的 gc 能够共存,让 gc 的迁移风险更低。

4)hygc 的局限性

hygc 的异步线程在做垃圾回收时,仅仅会对老对象做 gc,在完成老对象转移后的新对象将不会参与 gc,可能会造成内存使用峰值的提升,提升程度与 gc 线程的执行耗时相关。

此问题后续也将根据实际情况,判断是否进行方案优化来解决。

其他优化举例

1)global 对象的 inline cache

quickjs 的 global 对象的操作被单独编译为了OP_get_var/OP_put_var等 op ,而这两个 op 的实现格外的慢,为此我们对 global object 访问加上了 inline cache。对 js 的对象属性访问可以简化理解为在遍历数组来找到想要的属性,inline cache 的目的就是缓存住某段代码访问的属性所在的数组中的偏移,这样下次取就直接用偏移来取了,不用再做重复的属性数组遍历。

global inline cache 的数据结构如下:

typedef struct {
    JSAtom prop;  // property atom
    int offset;   // cached property offset
    void *obj;    // global_obj or global_var_obj
} HYJSGlobalIC;

这里的第 4 行的void *obj比较特殊,原因在于 quickjs 的 global 可能存在 context 对象的 global_obj 或 global_var_obj 中,具体存在哪个里面需要一并放入 cache 中。
具体代码实现如下:

case OP_get_var: { // 73
    
    JSAtom atom=get_u32(buf + i + 1);
    
    uint32_t cache_index=hyjs_GetGlobalICOffset(ctx, atom);
    JSObject obj;
    JSShape shape;

    LDR_X_X_I(NEXT_INSTRUCTION, R8, CTX_REG, (int32_t)((uintptr_t)&ctx->global_ic - (uintptr_t)ctx));
    ADD_X_X_I(NEXT_INSTRUCTION, R8, R8, cache_index * sizeof(HYJSGlobalIC));
    LDP_X_X_X_I(NEXT_INSTRUCTION, R0, R9, R8, 0);
    CBZ_X_L(NEXT_INSTRUCTION, R9, 12 * sizeof(uint32_t)); // check cache exsits
    LSR_X_X_I(NEXT_INSTRUCTION, R1, R0, 32); // get offset
    LDR_X_X_I(NEXT_INSTRUCTION, R2, R9, (int32_t)((uintptr_t)&obj.shape - (uintptr_t)&obj)); // get shape
    ADD_X_X_I(NEXT_INSTRUCTION, R2, R2, (int32_t)((uintptr_t)&shape.prop - (uintptr_t)&shape)); // get prop
    LDR_X_X_W_E_I(NEXT_INSTRUCTION, R3, R2, R1, UXTW, 3); // get prop
    LSR_X_X_I(NEXT_INSTRUCTION, R3, R3, 32);
    CMP_W_W_S_I(NEXT_INSTRUCTION, R0, R3, LSL, 0);
    B_C_L(NEXT_INSTRUCTION, NE, 5 * sizeof(uint32_t));
    LDR_X_X_I(NEXT_INSTRUCTION, R2, R9, (int32_t)((uintptr_t)&obj.prop - (uintptr_t)&obj)); // get prop
    LSL_W_W_I(NEXT_INSTRUCTION, R1, R1, 4); // R1 * sizeof(JSProperty)
    LDR_X_X_W_E_I(NEXT_INSTRUCTION, R0, R2, R1, UXTW, 0); // get value
    
    B_L(NEXT_INSTRUCTION, 17 * sizeof(uint32_t));

    MOV_FUNCTION_ADDRESS_TO_REG(R8, HYJS_GetGlobalVar);
    
    MOV_X_X(NEXT_INSTRUCTION, R0, CTX_REG);
    MOV_IMM32_TO_REG(R1, atom);
    MOV_X_I(NEXT_INSTRUCTION, R2, opcode - OP_get_var_undef);
    MOV_X_I(NEXT_INSTRUCTION, R3, cache_index);
    BLR_X(NEXT_INSTRUCTION, R8);

    CHECK_EXCEPTION(R0, R9);

    STR_X_X_I(NEXT_INSTRUCTION, R0, R26, SP_OFFSET(0));
    
    i +=4;
    break;
}

首先是第5行的hyjs_GetGlobalICOffset,这个方法会为当前 opcode 分配一个 inline cache 的 cache_index,这个 cache_index 会在第 31 行设定为HYJS_GetGlobalVar方法调用的第 4 个参数。代码的第 9 行到第 19 行,会根据 cache_index 取 cache,并根据 cache 中的 offset,取 global 对象对应偏移里存的 prop(也就是属性 id,数据类型是 atom),和当前需要取的对象的属性的 atom 比较,确认 cache 是否仍然有效。如果 cache 有效则通过第 20-22 行代码直接取对象属性数组,如果无效则走到第 26 行的慢路径,遍历属性数组,并更新 inline cache。

2)builtin 的快路径优化

快路径优化是将代码中的某些执行概率更高的部分,单独提出来,来避免冗余代码的执行拖慢性能。

以 Array.indexOf 的实现为例:

static JSValue hyjs_array_indexOf(JSContext *ctx, JSValueConst func_obj,
                                  JSValueConst obj,
                                  int argc, JSValueConst *argv, int flags)
{
    ......

    res=-1;
    if (len > 0) {

        ......
        
        // fast path
        if (JS_VALUE_GET_TAG(element)==JS_TAG_INT) {
            for (; n < count; n++) {
                if (JS_VALUE_GET_PTR(arrp[n])==JS_VALUE_GET_PTR(element)) {
                    res=n;
                    goto done;
                }
            }
            goto property_path;
        }
        
        // slow path
        for (; n < count; n++) {
            
            if (js_strict_eq2(ctx, JS_DupValue(ctx, argv[0]),
                              JS_DupValue(ctx, arrp[n]), JS_EQ_STRICT)) {
                res=n;
                goto done;e
            }
        }
        
        ......
    }
 done:
    return JS_NewInt64(ctx, res);

 exception:
    return JS_EXCEPTION;
}

原本的实现是从第 23 行开始的慢路径,这里需要调用js_strict_eq2方法来判断数组 index 是否相等,这个比较方法会相对比较重。而实际上 index 绝大多数情况都是 int 类型,所以提出来第 12 行的快路径,如果 index 本身是 int 类型,那么直接做 int 类型数据的比较,就会比调用 js_strict_eq2 来比较要快。

四 优化结果

性能测试设备基于 m1(arm64) 芯片的 macbook ,wasm 业务性能测试基于 huawei mate 8 手机;测试结果选择方法为每个 case 跑 5 次,取排第 3 位的结果;测试 case 选择为斐波那契数列、benchmark、业务 case 三种,以评估不同场景下优化带来的性能变化。

1 wasm 性能

注:在业务 case 中得出的时间是单帧渲染的整体耗时,包括 wasm 执行和渲染耗时两部分。

注:coremark hyengine jit 耗时是 llvm 编译版本的约 3 倍,原因在于对计算指令优化不足,后续可在优化器中对更多计算指令进行优化。

注:上述测试编译优化选项为 O3。

2 js性能

注:microbench 的部分单项在 gc 优化上有负向的优化,使得整体优化的提升并不明显,但改单项对业务影响不大。

注:从业务 case 上可以看出,vm 优化所带来的提升远大于目前 jit 带来的提升,原因在于 jit 目前引入的优化方式较少,仍有大量的优化空间。另外 case 1 在 v8 上,jit 比 jitless 带来的提升也只有 30% 左右。在 jit 的实现中,单项的优化单来可能带来的提升只有 1% 不到,需要堆几十上百个不同的优化,来让性能做到比如 30% 的提升,后续会更具性能需求及开发成本来做均衡选择。
注:上述测试编译优化选项为 Os。

五 后续计划

后续计划主要分为 2 个方向:性能优化、多语言支持,其中性能优化将会持续进行。
性能优化点包括:

  • 编译器优化,引入自有字节码支持。
  • 优化器优化,引入更多优化pass。
  • 自有 runtime,热点方法汇编实现。

六 参考内容

  • wasm3: https://github.com/wasm3/wasm3
  • quickjs: https://bellard.org/quickjs/
  • v8: https://chromium.googlesource.com/v8/v8.git
  • javascriptcore: https://opensource.apple.com/tarballs/JavaScriptCore/
  • golang/arch: https://github.com/golang/arch
  • libmalloc: https://opensource.apple.com/tarballs/libmalloc/
  • Trash talk: the Orinoco garbage collector: https://v8.dev/blog/trash-talk
  • JavaScript engine fundamentals: Shapes and Inline Caches:https://mathiasbynens.be/notes/shapes-ics
  • cs143: https://web.stanford.edu/class/cs143/
  • C in ASM(ARM64):https://www.zhihu.com/column/c_142064221

作者 | 知兵

原文链接:https://developer.aliyun.com/article/848417?utm_content=g_1000316862

本文为阿里云原创内容,未经允许不得转载。