WASM的生成

在我们学习底层语言,如LLVM IR、AMD64和AArch64汇编等的过程中,一个非常有效的方法是,将自己熟悉的高级语言编译为相应的底层语言,从而了解不同的语义是怎样在底层实现的。因此,我们在学习WASM的过程中,首先也可以先学习一下如何由高级语言生成WASM。

目前最主流的生成WASM的高级语言是Rust和C/C++,因此,本章也主要以这两个语言为例说明。

Rust程序生成WASM

我们可以用rustup程序来管理Rust语言能够生成的目标平台,例如,使用

rustup target list

可以查看目前Rust支持生成哪些平台的程序。我们可以在这其中看到wasm32-unknown-unknownwasm32-wasi。目前,我们需要wasm32-unknown-unknown。因此,我们使用

rustup target add wasm32-unknown-unknown

下载安装相应的组件,随后,我们就可以使用Rust生成WASM程序了。

基础方法

我们使用

cargo new --lib rust-wasm-adder

生成一个默认的Rust库rust-wasm-adder

首先,我们需要修改Cargo.toml,在其中加上

[lib]
crate-type = ["cdylib"]

随后,在lib.rs中写上

#[no_mangle]
pub extern "C" fn add(left: usize, right: usize) -> usize {
    left + right
}

然后,使用

cargo build --release --target wasm32-unknown-unknown

编译后,就可以在target/wasm32-unknown-unknown/release/目录下找到rust_wasm_adder.wasm文件,这就是我们生成的WASM程序。这个WASM程序提供了add函数,我们可以使用Hello world一章中提供的方法,在Web或者后端将这个WASM作为库使用,来验证我们确实生成成功了。

关于这段Rust代码,有几点值得指出说明。

#[no_mangle]

首先是#[no_mangle]。这个属性和#[export_name = "xxxx"]一样,在Rust官方文档的Application Binary Interface中有说明。这些属性一般而言,是在我们用Rust写一些提供给别的语言调用的库函数时使用,用来关闭命名修饰(Name mangling)。但是在目标平台为WASM时,语义会有少许变化。在这里,使用#[no_mangle]属性,除了告诉编译器,生成的函数名字在二进制层面就叫add以外,还有一个作用,是让编译器导出这个符号。我们知道,当我们使用一个Rust crate的时候,只能使用其中用pub修饰的函数。但是,pub只是Rust语义层面的。在WASM层面,我们必须使用#[no_mangle]或者#[export_name = "xxxx"]这个属性,才能确保编译器确实导出了这个函数。

extern "C"

其次是extern "C"。同样地,这个修饰符在一般情况下,是用来告诉编译器,这个函数的ABI采用C语言的ABI,以便在生成二进制库的时候可以被别的编程语言调用。而在生成目标为WASM的情况下,目前Rust的extern "wasm"还没有稳定,所以暂时也是使用extern "C"来声明ABI。

usize

还有一点值得注意,我们之前提到,目前通用的WASM程序可以看做32位平台,因为其内存最多只有32位。所以,usize在这个平台下实际上是u32。我们通过

wasm2wat target/wasm32-unknown-unknown/release/rust_wasm_adder.wasm -o rust_wasm_adder.wat

也可以看到这一段代码:

(func $add (type 0) (param i32 i32) (result i32)
  local.get 1
  local.get 0
  i32.add)

从这也可以看出来,确实参数确实是32位(关于WASM中的i32和Rust中的u32的关系,之后我们会解释)。

导出函数

还有一点值得指出的是,上面这些额外的修饰,只有在该函数需要被导出时才需要使用。也就是说,如果我们写的Rust程序内部有一些内部的函数,不需要在WASM中导出,用来被别的语言使用,那么就不需要加这些额外的修饰。

系统交互

此外,我们之前提到过,WASM本身提供的基础指令,是没有办法进行系统调用的。WASI提供了一套与系统交互的接口,供WASM来使用。因此,当我们Rust程序的目标平台是wasm32-unknown-unknown时(也就是说,不使用WASI),是没有办法,在不导入别的函数的情况下,直接进行系统操作的。因此,如果我们的add函数写成

#[no_mangle]
pub extern "C" fn add(left: usize, right: usize) -> usize {
    println!("This is add in WASM");
    left + right
}

在执行这个WASM模块的时候,是不会产生任何输出的。这是因为println宏需要对stdout进行写操作,而这是WASI提供的能力,因此是做不到输出的。

wasm-bindgen

从上面的讨论我们可以知道,Rust本身虽然支持直接生成WASM程序,但是目前还有一些功能不稳定,例如extern "wasm"不稳定所以只能使用extern "C"。因此,在使用Rust编写WASM程序的过程中,往往更广泛使用的是一个叫wasm-bindgen的库。这个库有其官方文档

最基础地,wasm-bindgen提供了一个宏,让我们直接定义需要导出的WASM函数:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(left: usize, right: usize) -> usize {
    left + right
}

通过#[wasm_bindgen]这个宏,我们不需要再手动写之前很长的一大堆#[no_mangle]之类的东西。同时,这样的封装也有助于向后兼容。之后extern "wasm"稳定之后,只需要wasm-bindgen这个库内部改动一下,就可以适配新版本,而不需要开发者再手动修改了。

此外,这个库还有一个很重要的目的,是解决了WASM与JavaScript交互的问题。以其README里的代码简单举个例子:

use wasm_bindgen::prelude::*;

// Import the `window.alert` function from the Web.
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

// Export a `greet` function from Rust to JavaScript, that alerts a
// hello message.
#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

之前提到,不借助WASI,WASM的程序是没办法再不导入函数的情况下与系统交互的。而这里,我们使用#[wasm_bindgen]这个宏,就可以在WASM之中使用window.alert函数了!

关于这个功能,之后在介绍WASM的函数导入与导出时,会再详细介绍。

C程序生成WASM

毫无疑问,C/C++是目前使用平台最广的编程语言。因此,我们也可以将C/C++程序生成WASM。这里以C程序为例。

Clang

第一种方案,与Rust类似,使用LLVM后端来做代码生成。因此,对应的编译器为Clang。

我们编写一个简单的程序add.c

int add(int left, int right) __attribute__((export_name("add"))) {
    return left + right;
}

使用如下命令生成WASM程序:

clang \
  --target=wasm32-unknown-unknown -nostdlib \
  -O3 \
  -Wl,--no-entry -Wl,--export-dynamic \
  -o adder.wasm \
  adder.c

生成的adder.wasm也可以像Hello world一章中放在Web中或者后端程序中来检验。

首先,我们使用了--target=wasm32-unknown-unknown来声明生成的目标平台,同时由于WASM程序不会使用系统的libc,也不会使用系统的crt等等,所以我们也需要使用-nostdlib来关闭对这些系统库的链接。

此外,我们使用了-Wl选项。这个选项的意思是将后续的参数传递给链接器。这里我们传递给链接器的参数有--no-entry--export-all

我们之前指出过,不使用WASI的WASM程序往往都是库程序,而不会直接作为独立的可执行程序。因此,也就不会有_startmain等entrypoint。所以我们需要传递给链接器--no-entry参数,让它别找entrypoint了。

与Rust类似,我们也可以控制在WASM中导出的函数。当我们给链接器传递export-dynamic参数时,在程序中以export_name属性修饰的函数会被导出,而其余的函数是不会被导出的。在我们的例子中,使用了__attribute__((export_name("add"))),因此这个函数会以"add"的名字导出。

Emscripten

之前了解WASM的开发者,想必一定听说过Emscripten的大名。Emscripten有悠久的历史,WASM的发展与其有很多很多的关系。简单来说,Emscripten就是提供了一整套工具链,让我们可以将C/C++程序编译为WASM程序。

在早期,Emscripten的工作流程是,使用emcc将C/C++代码编译为LLVM IR,使用fastcomp将LLVM IR编译为asm.js语言(一种类似JavaScript的底层语言),然后使用我们之前提到的Binaryen工具链中的asm2wasm工具,将asm.js代码编译为WASM代码。这一过程详见I don't know how Binaryen is used by Emscripten

而现在,Emscripten的工作流程是,使用emcc编译为LLVM IR之后,直接使用LLVM的wasm后端来生成wasm文件。Emscripten也和wasm-bindgen类似,提供了一些C/C++与系统交互的接口。

Emscripten使用起来更为简单,我们编写adder.c:

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE int add(int left, int right) {
    return left + right;
}

随后使用

emcc adder.c -O3 -o adder.wasm

即可生成一个WASM程序。

这里,我们就不再需要Clang的export_name属性,而是直接使用EMSCRIPTEN_KEEPALIVE宏即可。