CKB/WASC: WebAssembly 到 RISC-V 的 AOT 编译器(英文)


In the on-chain virtual machines of the blockchain world, several different instruction-sets are widespread, including WebAssembly, RISC-V, and EVM, which will complete the mission and exit the historical stage. These instruction-sets all run at roughly the same level of abstraction. Before the Ethereum community planned to retire EVM, they were writing a source-to-source compiler from EVM to WebAssembly, so that the two instruction sets could reuse a large part of each other's tools (mainly the latter to the former). At the same time, I found that there is currently a lack of useful tools in the world from WebAssembly to RISC-V compiler, so I decided to fill this job.

In the past few months, I have invested much time in the WASC: https://github.com/mohanson/wasc project and have already implemented some proper completion, and I am happy to share this project with everyone.

WASC can compile WebAssembly bytecode (.wasm) under the WebAssembly specification or S expression files(.wat) into native executable files. It also supports the cross-compilation of some platforms. For example, You can compile executable files that can be run directly on the RISC-V platform under the posix x86_64 platform.

The bottom layer of WASC depends on a project called WAVM. It is a high-performance conversion layer. Benchmark tests show that this is the highest performance conversion layer implementation of WebAssembly to date. It can directly compile WebAssembly code into native code through LLVM. However, like most WebAssembly JIT compilers, WAVM requires a runtime part to bridge the host environment and the virtual machine environment. The runtime part size exceeds 2M, contrary to our original intention to execute WebAssembly on a space-constrained blockchain.

The core design principle of WASC is to try to include all the information needed to build the runtime in one file. It emits the least part of the runtime code in a single C language file (in most cases, it only needs to be used less than 50 lines of C code), and then links it with the object file generated by WAVM through the linker, and generates an executable binary file in ELF format at last.

WASC is currently developed on Linux, but, likely, it should also work on Mac OS and Windows. Although I have not tried this, experience tells me that it should be feasible.

The primary purpose of WASC is to provide a platform that can support more contract programming languages for blockchain virtual machines that use RISC-V as the instruction set. There are many benefits of using WASC + RISC-V, some of which are listed below:

  • Provides contract developers with optionality, you can use many traditional static programming languages (such as C, C++, and Rust), or you can apply some more user-friendly languages (such as AssemblyScript)
  • Access WebAssembly's huge community and surrounding toolchain

Example: Compilation and execution of echo program


A simple example is usually the best explanation for the principle of a complex project. First, we will need to install WASC. This step requires the installation of a complete WebAssembly and RISC-V development environment. You must reserve much time and free disk space. On my old machine, the two numbers are 27 minutes of my life and 11 GB. But don’t worry, all files generated during the installation process are saved in the current directory, so there are no visible side effects on your operating system.

For convenience, the following defaults your working directory to the /src path. If not, you will need to replace the relevant path in the example manually. The example is demonstrated in Ubuntu 18.04.

$ cd /src
$ git clone https://github.com/mohanson/wasc

$ cd wasc
$ ./build.sh

Before that, make sure you have installed LLVM-9 because RISC-V is not officially supported until LLVM-9. If not, use the following command to install it.

$ apt install llvm-9

Hoping your installation goes well! However, you must understand that WebAssembly and RISC-V toolkits are super large projects, and weird problems may appear. If it occurs, it's wise to go to the corresponding community or search engine for helpful ideas.

The WASC project pre-built an example program, which is located in ./example/echo.wasm. The program was initially written in C and then compiled into WebAssembly code using the wasi-sdk toolkit. In general, it is similar to the echo command we commonly use under Linux, the source code is:

#include <stdio.h>

int main(int argc, char** argv)
{
  for(int argIndex = 0; argIndex <= argc; ++argIndex)
  {
    printf("%s", argv[argIndex]);
  }
  return 0;
}

Use wasc to compile the echo.wasm file, and will get a native executable binary file (posix x86_64):

$ ./build/wasc --save ./example/echo.wasm

Note that the --save command will save the temporary files generated during the compilation process, which helps us explain the principles of WASC below. Try the results compiled by WebAssembly, we invoke the echo executable file, and try to make it output "Hello World!".

$ ./example/echo Hello World!
# Hello World!

Working principle and step analysis


Let us first focus on the ./example/echo_build folder, which is a temporary file generated during the compilation process. They will be retained only after receiving the --save parameter, the directory structure is as follows :

.
├── echo                                <--- executable binary
├── echo.c                              <--- entry file
├── echo_glue.h                         <--- glue code, providing minimal runtime
├── echo.o                              <--- object file generated by WAVM
├── echo_precompiled.wasm               <--- pre-compiled file generated by WAVM with complete object code
└── platform
    ├── common
    │   ├── wasi.h                      <--- WASI data structure definition
    │   └── wavm.h                      <--- WAVM data structure definition
    ├── posix_x86_64_wasi.h             <--- partial implementation of WASI on posix (C code)
    └── posix_x86_64_wasi_runtime.S     <--- partial implementation of WASI on posix (assembly code)

The first step of WASC compilation is to use WAVM to generate the target file, and the file name is echo.o. It is the actual output of WAVM during the JIT compilation stage, mainly machine code. It also contains information that allows the linker to see which symbols it contains and the symbols it needs to function correctly. Use the objdump tool to analyze this file, and intercept a part of SYMBOL TABLE as follows:

$ objdump -x echo.o
SYMBOL TABLE:
0000000000000000         *UND*  0000000000000000 biasedInstanceId
0000000000000000         *UND*  0000000000000000 functionDefMutableDatas0
0000000000000000         *UND*  0000000000000000 functionDefMutableDatas1
0000000000000000         *UND*  0000000000000000 functionDefMutableDatas2
0000000000000000         *UND*  0000000000000000 functionImport0
0000000000000000         *UND*  0000000000000000 functionImport1
0000000000000000         *UND*  0000000000000000 functionImport2
0000000000000000         *UND*  0000000000000000 functionImport3
0000000000000000         *UND*  0000000000000000 global5
0000000000000000         *UND*  0000000000000000 memoryOffset0
0000000000000000         *UND*  0000000000000000 typeId3
0000000000000000         *UND*  0000000000000000 typeId4

Yes. We probably already know what data will be provided to the JIT code generated by WAVM when it runs.WASC does one thing here. It uses only a tiny amount of code (far less than WAVM) to provide the object file with what it lacks. This part of the code is called WASC glue code, and most of it is written in C. The glue code of the echo program is shown below:

$ cat echo_glue.h
#include<stddef.h>
#include<stdint.h>
#include<stdlib.h>
#include<string.h>

#include "platform/common/wavm.h"

#ifndef ECHO_GLUE_H
#define ECHO_GLUE_H

const uint64_t functionDefMutableData = 0;
const uint64_t biasedInstanceId = 0;
const uint64_t tableReferenceBias = 0;

const uint64_t typeId0 = 0;
const uint64_t typeId1 = 0;
const uint64_t typeId2 = 0;
const uint64_t typeId3 = 0;
const uint64_t typeId4 = 0;

const int32_t global0 = 0;
const int32_t global1 = 1;
const int32_t global2 = 4;
const int32_t global3 = 8;
const int32_t global4 = 12;
int32_t global5 = 128;

#define wavm_wasi_args_get functionImport0
extern wavm_ret_int32_t (functionImport0) (void*, int32_t, int32_t);

#define wavm_wasi_args_sizes_get functionImport1
extern wavm_ret_int32_t (functionImport1) (void*, int32_t, int32_t);

#define wavm_wasi_fd_write functionImport2
extern wavm_ret_int32_t (functionImport2) (void*, int32_t, int32_t, int32_t, int32_t);

#define wavm_wasi_proc_exit functionImport3
extern void* (functionImport3) (void*, int32_t);

extern wavm_ret_int32_t (functionDef0) (void*, int32_t);
const uint64_t functionDefMutableDatas0 = 0;
extern wavm_ret_int32_t (functionDef1) (void*, int32_t);
const uint64_t functionDefMutableDatas1 = 0;
extern void* (functionDef2) (void*);
const uint64_t functionDefMutableDatas2 = 0;

uint32_t memory0_length = 1;
uint8_t* memory0;
struct memory_instance memoryOffset0;
uint8_t memory0_data0[1] = {
  0x20
};
uint8_t memory0_data1[1] = {
  0x0a
};
#define MEMORY0_DEFINED 1

void init_memory0() {
  memory0 = calloc(65536, 1);
  memcpy(memory0 + 0, memory0_data0, 1);
  memcpy(memory0 + 1, memory0_data1, 1);
  memoryOffset0.base = memory0;
  memoryOffset0.num_pages = 1;
}

#define wavm_exported_function__start functionDef2

void init() {
  init_memory0();
}

int32_t g_argc;
char **g_argv;

int main(int argc, char *argv[]) {
  g_argc = argc;
  g_argv = argv;
  init_wasi();
  init();
  wavm_exported_function__start(NULL);
  return 0;
}

#endif /* ECHO_GLUE_H */

As you can see, many data are simply assigned to 0 in the glue code, such as const uint64_t typeId0 = 0;, this is because, in the runtime environment of WAVM, only the address of certain types of data is useful. For WAVM, typeId0 is a structure that saves a specific function signature, but when judging whether the function signatures of the two functions are consistent at runtime, WAVM only compares whether the signature addresses of the two functions are the same address. Therefore, we don't need to implement a complex function signature structure in the glue code, which gives us a massive room for optimization. WASC has done a lot of trick work to ensure that the runtime provided by the glue code is minimal.

After generating the glue code, WASC will use gcc to compile and link the glue code and WAVM object file and compile it and get the native platform's binary file.

The general process of WASC is like this. However, the actual implementation details will be much more complicated than those introduced above. For example, we have to use some assembly code and Linker Script to complete some of the functions that are difficult to achieve. If I have the opportunity, I would like to introduce it in a dedicated article.

AssemblyScript, Syscall, RISC-V, and CKB-VM


Next, I want to talk about the application of WASC on the RISC-V platform. There is a special instruction in the RISC-V instruction set: ECALL. The ECALL instruction is used to issue a request to the execution environment (usually the operating system), and the system ABI will define how to pass the requested parameters. In order to allow WebAssembly to interact with the RISC-V host environment, it's necessary to implement ECALL in the WebAssembly program. We will start with the AssemblyScript programming language to briefly describe this.

I have written the code in advance, and you can first clone the following two projects to the local.

$ git clone https://github.com/libraries/wasc_dapp_demo_ckb_vm
$ git clone https://github.com/libraries/wasc_dapp_demo_assemblyscript

File ./wasc_dapp_demo_ckb_vm/example/main.c . a simple RISC-V application program, invoking syscall once in the program, and then passing the address and length of a string as one of the parameters to the RISC-V host platform.

#include <string.h>

static inline long __internal_syscall(long n, long _a0, long _a1, long _a2,
                                      long _a3, long _a4, long _a5) {
  register long a0 asm("a0") = _a0;
  register long a1 asm("a1") = _a1;
  register long a2 asm("a2") = _a2;
  register long a3 asm("a3") = _a3;
  register long a4 asm("a4") = _a4;
  register long a5 asm("a5") = _a5;
  register long syscall_id asm("a7") = n;
  asm volatile("scall"
               : "+r"(a0)
               : "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5), "r"(syscall_id));
  return a0;
}

#define syscall(n, a, b, c, d, e, f)                                           \
  __internal_syscall(n, (long)(a), (long)(b), (long)(c), (long)(d), (long)(e), \
                     (long)(f))

#define SYSCODE_DEBUG 2000

int main() {
    char *s = "Hello World!";
    return syscall(SYSCODE_DEBUG, &s[0], strlen(s), 0, 0, 0, 0);
}

The host platform will receive the syscall request, and then the string will be printed to standard output. This part of the logic is implemented in the wasc_dapp_demo_ckb_vm project, which is a RISC-V virtual machine implementation. The bottom layer uses ckb-vm and registers functions that handle specified system calls. We are ready to execute this main program, enter the following command directly:

$ cargo run -- example/main

You will see:

debug: Hello World!
exit=0 cycles=771

This is not the end of the story. On the contrary, the spelendid content just begin! We will use AssemblyScript to rewrite this program and demonstrate how to implement syscall in AssemblyScript code.

Open the ./wasc_dapp_demo_assemblyscript/assembly/index.ts file, which is written by AssemblyScript, the logic is similar to the code written by C before, and its source code is:

import {
  syscall
} from './env'

export function _start(): i32 {
  let str = "Hello World!"
  let strEncoded = String.UTF8.encode(str, true)
  syscall(2000, changetype<usize>(strEncoded), strEncoded.byteLength, 0, 0, 0, 0, 0b100000)
  return 0
}

The syscall function is defined in the env.ts file in the same directory:

export declare function syscall(n: i64, a: i64, b: i64, c: i64, d: i64, e: i64, f: i64, mode: i64): i64

It is an export declare function, indicating that it does not implement this function by itself, and it needs to be provided by the runtime environment.In terms of underlying semantics, it is an Import of WebAssembly. In the glue code part of WASC, it will provide an implementation for it:

wavm_ret_int64_t wavm_env_syscall(void *dummy, int64_t n, int64_t _a0, int64_t _a1, int64_t _a2, int64_t _a3, int64_t _a4, int64_t _a5, int64_t mode)
{
    wavm_ret_int64_t ret;
    ret.dummy = dummy;
    if (mode & 0b100000)
    {
        _a0 = (int64_t)&memoryOffset0.base[0] + _a0;
    }
    if (mode & 0b010000)
    {
        _a1 = (int64_t)&memoryOffset0.base[0] + _a1;
    }
    if (mode & 0b001000)
    {
        _a2 = (int64_t)&memoryOffset0.base[0] + _a2;
    }
    if (mode & 0b000100)
    {
        _a3 = (int64_t)&memoryOffset0.base[0] + _a3;
    }
    if (mode & 0b000010)
    {
        _a4 = (int64_t)&memoryOffset0.base[0] + _a4;
    }
    if (mode & 0b000001)
    {
        _a5 = (int64_t)&memoryOffset0.base[0] + _a5;
    }
    ret.value = syscall(n, _a0, _a1, _a2, _a3, _a4, _a5);
    return ret;
}

Pay extra attention to the last parameter mode of syscall, which is currently passed the value 0b100000. The first parameter of syscall in the code is changetype<usize>(strEncoded), which is a pointer to a string. Still, there is a problem because this string is defined in the memory of WebAssembly instead of the program's running memory. The mode parameter's meaning is to tell our glue code that the incoming address is not the real address, but the offset address of the WebAssembly memory. The glue code will obtain the actual address through the offset address and the first address of the WebAssembly memory.

At last, compile the project, it first generates WebAssembly output through the AssemblyScript toolset, then obtains the RISC-V output through WASC, and finally executes it in wasc_dapp_demo_ckb_vm, your standard output will also get similar results.

$ cd /src/wasc_dapp_demo_assemblyscript
$ npm i
$ npm run asbuild

$ cd build
$ /src/wasc/build/wasc -v --platform ckb_vm_assemblyscript --gcc /src/wasc/third_party/ckb-riscv-gnu-toolchain/build/bin/riscv64-unknown-elf-gcc optimized.wat

$ cd /src/wasc_dapp_demo_ckb_vm
$ cargo run -- /src/wasc_dapp_demo_assemblyscript/build/optimized
debug: Hello World!
exit=0 cycles=14418

Still not perfect


During the process of implementing WASC, I encountered many problems, some have been solved, but some have not been yet. I will list some issues that have troubled me for a long time, and my thoughts on these problems.

  • WASC did not check for out-of-bounds access to WebAssembly memory. I can add this part to check, and the price is the bloated runtime, but is this necessary? We know that the C language does not restrict out-of-bounds access, so out-of-bounds checking is not a function that must exist. In this regard, I introduce a security assumption: the qualified program developed by qualified developers should not have the BUG of cross-border access. If this stupid thing is really done, they should also be able to debug this.
  • AssemblyScript lacks support for various encryption algorithms like C, C++, and Rust. And these algorithms are essential to the blockchain.This is a question someone asked me. After thinking about it, I think it can be solved, and there is more than one way.C can be compiled to WebAssembly, AssemblyScript can be the same, so there is unlimited room for imagination in between.

Last and not least, I want to complain about the test sets of WebAssembly. They are so intertwined, chaotic. Even when I implemented the WASI interface, there are no official test sets for this part of the world (only scattered tests written by third parties can be found). Fortunately, after I reflected on this problem, the official has already planned to optimize this.

Thank you for your reading.