WASM 汇编入门教程

本系列教程的GitHub仓库为Evian-Zhang/wasm-tutorial,所有代码的源代码位于code目录下,推荐在evian-zhang.github.io/wasm-tutorial中阅读,获得最佳体验。如需PDF版本,可在对应网页的右上角点击「打印」即可。

在互联网大潮下,前端越来越「卷」。为了追求前端的性能,用于替代JavaScript的WebAssembly(简称WASM)的概念被提出。经过几年的发展与沉淀,开发者们发现WASM不仅仅在前端Web领域是一枚银弹:我们既可以在浏览器里运行WASM,也可以用wasmerwasmtime等方式在任意环境下原生地执行WASM代码;在通用编程语言领域,Rust有提案建议将过程宏以WASM的形式执行,并且有了相应的工具watt;在运维领域,Docker正在尝试使用WASM代替传统容器

因此,WASM不再仅仅是前端圈子里的掌上明珠,也逐渐走入全栈所有领域开发者的视野。所以,我决定写一系列文章《WASM汇编入门》,面向所有领域的开发者,介绍WASM入门。

本系列文章讲了什么

本系列文章,将从头开始,以较底层的WASM汇编角度,全方位介绍WASM程序的生成、使用,以及WASM中的概念、汇编语法。

本系列文章是我自己学习的一个记录,必然会有所错误、缺漏。还望大家不吝提出issue发起PR,相互指导,谢谢大家!

预备知识

本系列文章的预备知识包括:

这些预备知识并不是要求读者掌握的炉火纯青,而是大部分常见的概念要了解,以及一些简单的代码要能够看懂。在绝大部分以概念为主的章节中,我基本上是不会涉及具体的高级语言或者汇编语言的代码的,只有在与代码强相关的章节中,我才会以例子的形式引入相应的代码。

环境

本文的所有代码的测试环境为

  • 操作系统

    Ubuntu 22.04.1

  • CPU

    Intel i9-12900K

  • Clang

    Homebrew clang version 15.0.7

  • emcc

    emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.5

  • Rust

    rustc 1.67.0 (fc594f156 2023-01-24)

  • wasmer

    wasmer 3.1.1

  • wabt

    1.0.27

什么是WASM

那究竟什么是WASM呢?粗略来说,WASM是一种接近底层的「中间语言」。虽然它的名字里带有「Web」,但实际上它并不仅仅适用于Web。对于对编译器理论更熟悉的开发者来说,WASM与JVM字节码的用法非常相似,也类似于LLVM IR。通俗来讲,就是一种类似汇编语言的编程语言,可以由各种语言编译而来,可以在各种平台上执行。

从开发者的角度来说,WASM格式可以由后端常见的编程语言编译得到,如C、C++、Rust、C#、Go、Swift等(具体可见Compile a WebAssembly module from...)。因此,后端的开发者可以用自己趁手的语言来开发WASM程序。

从使用者的角度来说,WASM程序可以像JavaScript程序一样,运行在Web浏览器中(目前火狐、Chrome、Safari和Edge均支持执行WASM程序,具体可参考Roadmap),也可以通过wasmer、wasmtime等方式,在如Windows、macOS、Linux等原生环境下执行。因此,WASM程序具有很高的通用性。

为什么要使用WASM

兼容性

成熟的技术选型,往往考虑最多的是技术的「兼容性」。如果一个技术在开发上不兼容(需要单独招程序员)、在使用上不兼容(「本网站请使用IE打开」),那就是依托答辩。

从开发上来说,理论上,凡是以LLVM为后端的编程语言,都支持生成WASM程序。不说别的,主流的C/C++代码,均可生成WASM程序。

从使用上来说,要求能在各个操作系统原生执行已经是基本要求了。WASM不仅如此,能在所有主流浏览器上执行!这可了不得了,Java Applet、Flash都哭了。目前Chrome的V8引擎(WebAssembly compilation pipeline)、火狐的SpiderMonkey引擎(BaldrMonkey)、Safari的Webkit(Assembling WebAssembly)都支持WASM。

前端积怨已久

在前端,从编程语言的角度,对JavaScript的讨伐不绝于耳。尽管ES6等新标准力挽狂澜,给JavaScript增加了许多更利于开发者的特性,TypeScript的出现也让开发者维护项目更加方便,但是由于浏览器端天生需要非常严格的向前兼容性,许多因为历史原因而造成的失误无法弥补。因此,WASM的出现可以让前端开发者在开发WASM模块时,不再受JavaScript的折磨,可以选择更新更好更顺手的编程语言,维护更好的心情。

性能优势

从性能的角度来看,尽管Google的V8引擎如前端的一针强心剂,将JavaScript代码的性能推向了原生级别,但是因为JavaScript语言本身的动态特性,性能还是会差一些。尽管JavaScript本身的特性更加适用于Web上针对DOM的灵活操作、请求响应JSON的解析等功能,但是随着前端负责的功能模块越来越广泛,对于计算密集型的操作,如密码学加解密、图像处理等操作,JavaScript的动态特性会减弱优化程度。而WASM则是底层二进制程序格式,由不同的编程语言编译而来。因此,对代码的优化就不再依赖传统的JavaScript优化,而是可以经过不同编程语言的优化,从而达到非常高的执行效率。

安全性

WASM的安全性在其官网Security部分有详细的描述。一个最显著的特点,WASM程序是运行在沙盒中的。一般来说,WASM程序是无法读写除了自身被分配的内存以外的地址的,因此不会干扰外部程序执行。

License

本仓库遵循CC-BY-4.0版权协议
作为copyleft的支持者之一,我由衷地欢迎大家积极热情地参与到开源社区中。Happy coding!

Hello world

正式学习WASM汇编的第一步,是先对WASM有一个初步的认识。因此,正如在学习别的编程语言的过程中,一个Hello world是必不可少的,本章也将展示WASM的一些基本程序。

正如我们之前提到的,WASM主要有两个用途:一个是作为一个库,提供一些函数,给Web上的JavaScript调用或者给通用的程序调用;另一个是作为一个独立的程序在后端使用wasmer或者wasmtime等工具来直接执行。相应地,我们的程序,也有两个版本:库版本和独立的程序版本。

作为库

我们创建一个文本文件library.wat,其内容为

(module ;; Define a module
    ;; Define a function with name `add`, two parameters of type i32, and returns i32
    (func $add (param $left i32) (param $right i32) (result i32)
        local.get $left ;; Push parameter `left` to stack
        local.get $right ;; Push parameter `right` to stack
        i32.add ;; Consume two values at stack top, and push the sum to stack
    )
    (export "add" (func $add)) ;; Export this function with symbol "add"
)

这就是我们这一系列学习的核心,WASM汇编。看上去确实花里胡哨,搞JavaScript这种高级语言的看不懂,搞AMD64、AArch64这种汇编语言的看这也感觉奇形怪状。

第一眼看不懂不要紧,之后我们会详细解释每一行每一个语句的意思。这里我们可以通过注释,简单了解到,这实际上是定义了一个名字是add的函数,将两个32位有符号整数相加并返回他们的和。

接下来我们要做的,是将这个文本文件转变成二进制文件。这里我们需要使用wabt工具链,其README里有编译或下载安装的方法(对于Linux和macOS用户,也可以使用Homebrew下载)。当我们安装了wabt工具链之后,使用如下命令:

wat2wasm -o adder.wasm library.wat

这个命令将文本形式的library.wat翻译成了二进制形式的adder.wasm

接下来,我们怎么使用这个WASM库呢?

在Web上使用

WASM模块目前最常用的场景,就是在Web上使用。因此,我们可以写一个基础的HTML:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>WASM Test</title>
  </head>

  <body>
    <script>
      WebAssembly.instantiateStreaming(fetch("./adder.wasm"))
        .then(obj => {
            console.log(obj.instance.exports.add(1, 2));
        });
    </script>
  </body>
</html>

我们注意到,需要使用fetch来获取这个wasm文件,因此由于浏览器的同源策略,我们不能直接双击打开这个html来看效果。我们可以用一些简易的服务程序,例如serve等,启动后访问相应的url,然后在控制台上,我们可以看到输出了3,说明正常运行了。

这里我们看到有很多不同的API。关于具体的如何在Web上与WASM交互,之后的章节会有更详细的说明。

在通用程序中使用

这里以Rust为例,我们看看如何在Rust程序中使用WASM模块提供的函数。

首先,我们需要引入wasmer库(wasmtime等其他库也可以)。我们的Rust程序为:

use anyhow::Result;
use std::fs;
use wasmer::{Imports, Instance, Module, Store, TypedFunction};

fn main() -> Result<()> {
    let wasm_bytes = fs::read("./adder.wasm")?;
    let mut store = Store::default();
    let module = Module::new(&store, wasm_bytes)?;
    let imports = Imports::default();
    let instance = Instance::new(&mut store, &module, &imports)?;
    let run_func: TypedFunction<(u32, u32), u32> =
        instance.exports.get_typed_function(&mut store, "add")?;
    let sum = run_func.call(&mut store, 1, 2)?;
    println!("Sum is {sum}");
    Ok(())
}

编译运行后,会输出"Sum is 3"。由此可见,Rust程序也可以使用WASM提供的函数。

关于Rust等通用程序如何与WASM模块交互,之后也会有章节进行具体说明。

作为独立程序

WASM作为库的使用看上去非常简单,我们需要写的WASM汇编代码也很少,不过实现的功能也相对简单,只是一个相加求和的功能。接下来,我们真真正正地写一个Hello world来看看!(以下代码出自bytecodealliance/wastime

(module
    ;; Import the required fd_write WASI function which will write the given io vectors to stdout
    ;; The function signature for fd_write is:
    ;; (File Descriptor, *iovs, iovs_len, nwritten) -> Returns number of bytes written
    (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))

    (memory 1)
    (export "memory" (memory 0))

    ;; Write 'hello world\n' to memory at an offset of 8 bytes
    ;; Note the trailing newline which is required for the text to appear
    (data (i32.const 8) "hello world\n")

    (func $main (export "_start")
        ;; Creating a new io vector within linear memory
        (i32.store (i32.const 0) (i32.const 8))  ;; iov.iov_base - This is a pointer to the start of the 'hello world\n' string
        (i32.store (i32.const 4) (i32.const 12))  ;; iov.iov_len - The length of the 'hello world\n' string

        (call $fd_write
            (i32.const 1) ;; file_descriptor - 1 for stdout
            (i32.const 0) ;; *iovs - The pointer to the iov array, which is stored at memory location 0
            (i32.const 1) ;; iovs_len - We're printing 1 string stored in an iov - so one.
            (i32.const 20) ;; nwritten - A place in memory to store the number of bytes written
        )
        drop ;; Discard the number of bytes written from the top of the stack
    )
)

同样地,我们把这个程序翻译成二进制格式:

wat2wasm -o standalone.wasm standalone.wat

如果要执行这个WASM程序,我们需要安装一个WASM运行时,比较常见的包括wasmerwasmtime

我们以wasmer为例,使用

wasmer run standalone.wasm

我们可以看到,屏幕上输出了"hello world",成功了!

基础概念与工具链

要学习WASM,首先我们需要了解WASM的基础概念与工具链。

基础概念

在了解WASM的语法之前,我们应该首先了解WASM的相关概念及工具链。因此,这里我们不会详细介绍WASM的语法,而是留在之后详细介绍。

WASM格式

WASM本身的层级与JVM、LLVM IR类似,定义了一套指令集,与常用的编程语言不同,更加偏于底层一些。与LLVM IR类似,WASM也有二进制格式与文本格式。

在Hello world一章中:

  • 我们手写的,以.wat结尾的,就是文本格式的WASM
  • 我们使用wat2wasm生成的,以.wasm结尾的,就是二进制格式的WASM。

在我们生成一个WASM程序用于执行时,往往使用的二进制格式,以便执行器解析和减小传输体积。而在我们阅读以及手动书写时,往往会使用文本格式。

嵌入环境

之前提到,WASM一大显著的特点,就是可以在各种环境下运行。例如,WASM可以在各大主流浏览器中运行,也可以当作库被Rust、C调用,也可以作为独立的程序在系统中运行。

这决定了一个特性:WASM和C++、JavaScript语言一样,是一个标准与实现分离的语言。也就是说,WASM有一个类似于标准委员会的组织,制定了WASM的标准,而遵循这个标准的WASM程序可以在哪里运行,则是根据各个环境的实现决定的。比如说,Chrome的V8引擎实现了WASM的标准,而我们之前用到的wasmer、wasmtime也各实现了WASM的标准。

对于WASM程序而言,不同的实现也就对应了不同的运行环境。在WASM概念中,会将这种环境称为嵌入环境(Embedder)。例如,WASM的V8引擎实现,将其嵌入到Chrome浏览器的Web环境中,而Linux上的wasmer实现,则是将WASM嵌入到Linux环境中。

提案

标准与实现分离的一大好处,或者说一大根本原因,就是能够获得极广的支持度,WASM因此可以在各大主流浏览器以及操作系统中运行。但是,一个“臭名昭著”的“缺点”也随之而来:并不是所有标准都被实现了。熟悉C++的同学一定经历过,想要使用最新标准的语义,例如C++20的API,首先得看看主流的编译器(GCC、Clang、MSVC)是否支持。而使用JavaScript的前端开发者更是经常抱怨「Safari是新时代的IE」。

WASM也不可避免地拥有这个问题,也就是不同WASM的实现对特性的支持度不同。与JavaScript类似,WASM也用了「提案」(Proposal)体系来缓解这个问题。粗略地来讲,WASM有一个核心规范,这个规范是所有的实现都遵循的。在这个规范的基础上,会有一些提案来增加新的功能。这些提案经过各方权衡以及讨论,最终又会进入新的标准之中。具体提案的实现情况可以看Roadmap

举个例子,目前WASM的内存最多只有4GB,可以看做一个32位的系统。Memory64提案将内存扩展到了64位。顺带提一句,这也是为什么当我们在使用高级语言编译WASM的时候,声明的target triple是wasm32而不是wasm。目前,我们并不能直接在所有嵌入环境下都直接使用64位的内存,这也是实现对这一提案的支持度不同导致的。

在这个系列中,我所讲的大部分主要功能,都是WASM的核心规范。如果有涉及到提案部分(也就是说不一定所有实现都支持)时,我会加以特殊说明。

WASI

和WASM一个紧密相关的概念是WASI(WebAssembly System Interface)。这个概念十分重要,之后会有专门的章节介绍。但是,我们在学习WASM之前,还是有必要先对这个概念有一个认识。

用汇编语言来类比,当我们使用汇编语言的时候,「指令集」给我们提供了许多基础指令,例如加减乘除、控制流转移等等。但是,我们如果仅仅用这些基础指令写程序,基本上是写不出一个「有状态」(Stateful)的程序的。例如,我们只能写出一个求和函数,给它输入是2和3,输出就一定是5,无论运行多少次都是这样的。但是,我们在真正写程序的过程中,往往是有状态的。例如创建一个文件,那么运行第二次,就会提示文件已存在,创建失败。在汇编语言中,这是使用「系统调用」(Syscall)来实现的。AMD64指令集的汇编程序通过执行syscall指令,通知处理器,处理器将执行流交给操作系统内核,操作系统内核执行相应的函数。

对于WASM也一样,WASM提供的基础指令同样不能实现例如文件创建等高级功能。因此,WASI被提了出来。WASI实际上也是一个标准,其规范了一系列与系统相关的API,如文件读写等。而不同的嵌入环境下的不同实现可以选择性地实现这些接口。例如,在网页中,我们不能直接读写任意路径文件,因此当嵌入环境是网页时,WASM引擎将不会实现相应的接口。

值得指出的是,WASM和WASI实际上是一种独立的关系。如果我们的程序不使用WASI提供的接口,那么对应的target triple实际上是wasm32-unknown-unknown。而如果我们的程序使用WASI提供的接口,则对应的是wasm32-wasi。

另一个十分常见的与系统交互的情况,实际上是进程创建。当我们运行一个独立的可执行程序的时候,系统实际上会将命令行参数、环境变量等传递给程序,而在程序退出的时候,会将返回值传递回操作系统。这其实暗示了一个非常重要的事:事实上,我们所有独立运行的WASM程序(例如我们前一章中打印hello world的程序)都一定是使用了WASI的程序。

由于WASI较为复杂,之后会有专门的章节介绍WASI,因此接下来的介绍将主要集中在不使用WASI的程序,也就是将WASM作为库使用的情况。

WASM基础工具链

讲了这么多WASM的概念,我们可以来梳理一下,在我们一般的开发者进行软件开发的过程中,是如何使用WASM的呢?

  1. 使用高级语言(Rust/C++等)编写代码
  2. 使用特定的编译器,将代码编译成WASM程序
  3. 在不同的嵌入环境中(如Web、特定操作系统等)使用WASM程序

那么,在这一过程中,与WASM相关的基础工具有哪些呢?接下来,我们可以粗略地了解一下WASM涉及的基础工具链。而具体的如何生成WASM和如何使用WASM,将在之后的章节详细解释。

生成WASM程序

我们常用的C/Rust,都是通过LLVM后端来生成WASM程序的。LLVM是现在最主流的编译器后端之一,其通过LLVM IR中间语言,让编程语言的开发者只需要考虑编译器前端。关于LLVM IR,可以参考我写的LLVM IR入门指南。简单来说,对于C语言、Rust等编程语言,其编译器通过将代码变为AST,然后进行分析,最后生成LLVM IR。而LLVM后端则将LLVM IR生成到不同平台的可执行程序。

处理WASM程序

我们在Hello world一章中,已经初次经历了wabt,一个用于处理WASM的工具链。wabt主要提供了以下几个工具:

  • wat2wasm

    .wat的文本格式WASM转换为.wasm的二进制格式WASM

  • wasm2wat

    .wasm的二进制格式WASM转换为.wat的文本格式WASM

  • wasm-objdump

    查看.wasm的二进制格式WASM中的信息。

wasm-objdumpwasm2wat的区别就在于,前者可以更好地查看二进制格式中每个字节对应的哪条文本指令,并且也可以查看相应的元信息。

此外,另一套工具链为binaryen。wabt工具链在进行文本格式与二进制格式的转化的过程中,没有任何多余的步骤,采用一对一的直译;而binaryen工具链,定义了一整套binaryen IR,比WASM更为底层,来优化WASM程序。

例如,binaryen工具链提供了wasm-opt工具,我们可以使用

wasm-opt a.wasm -o optimized.wasm -O3

a.wasm优化,并输出optimized.wasm

使用WASM程序

当WASM程序在Web端使用时,不同的主流浏览器的引擎分别有实现。当我们将其作为一个库在C++/Rust程序中使用的时候,可以使用wasmer、wasmtime等工具作为库来使用。

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宏即可。

WASM的使用

在我们使用高级语言生成了WASM之后,毫无疑问接下来就是对WASM的使用。本章将主要讲述WASM的基本使用方法,而一些高级的使用方法在后续章节里会陆续介绍。

总的来说,在我们通过WASM引擎使用WASM程序的过程中,WASM标准规范了三个语义阶段:

  • 解码(Decoding)

    通常来说,WASM是以二进制格式分发的。因此,在使用WASM时,第一步就是将二进制格式的WASM解码成内存中的内部表示。

  • 验证(Validation)

    WASM本身是一个有类型的编程语言,除此之外也有许多保证正确性的约束。在解码之后,执行之前,WASM引擎会对WASM程序的正确性作验证。

  • 执行(Execution)

    在验证完正确性之后,WASM引擎会真正执行这个WASM程序。

在Web中使用

WASM程序目前最多的用途,就是在Web中使用了。本节就主要介绍几种在Web中使用WASM的常见方式。

基础方式

在Hello world一章中,我们展示了在Web中使用WASM程序的基础方式:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>WASM Test</title>
  </head>

  <body>
    <script>
      WebAssembly.instantiateStreaming(fetch("./adder.wasm"))
        .then(obj => {
            console.log(obj.instance.exports.add(1, 2));
        });
    </script>
  </body>
</html>

在这里,我们调用了内置的WebAssembly模块的instantiateStreaming函数。这个函数是目前最主要使用的引入WASM程序的接口。同时也从这个的使用方式看出来,当我们需要引入WASM模块时,必须要手动请求这个资源,例如使用fetch等函数;此外,这个函数是异步的,说明目前引入WASM模块时,也都是异步的请求。

当我们查看这个函数的API文档时,会发现其接收第二个参数importObject,这个暂时不细讲,之后讲到WASM函数的导入与导出时再详细解释。

此外,这个函数实际上做了之前提到的WASM语义阶段中的「解码」与「验证」两个步骤(严格来说,也做了「执行」步骤中的「实例化」一步,不过不重要)。

在使用instantiateStreaming函数引入WASM之后,我们可以通过obj.instance.exports.add来访问我们之前导出的add函数。

ES Module形式导入

在JavaScript来到新时代之后,ES Module让JavaScript多文件组织变得异常清晰与简便。那我们多么希望能够这样使用WASM模块:

import { add } from "./adder.wasm";

console.log(add(1, 2));

如果WASM模块也能以ES Module的形式来导入,这该多好啊!

事实上,这个功能目前还是一个提案ES Module Integration Proposal for WebAssembly,没有被广泛地实现。目前,实现这个功能还有一些阻碍,例如,之前我们提到,对WASM的引入都是异步的,因此如果要全局直接import,势必要Top-level Await,而这个也没有很好地实现。

但是,尽管目前没有普遍实现,一个好消息是:Webpack支持以ES Module的形式导入WASM程序!具体的代码可以参考本仓库的web-webpack目录,其中的代码参考了ballercat/minimal-webpack5-wasm-demo

简单而言,我们只需要在webpack.config.js中加入下面一段代码:

module.exports = {
  // ...
  experiments: {
    asyncWebAssembly: true,
  },
  // ...
};

就可以以ES Module的方式引入WASM程序啦!

在通用程序中使用

除了在Web中使用,由于其安全性与通用型,WASM目前也越来越多地在通用程序中作为库被使用。目前最常用的两个辅助库是wasmerwasmtime

wasmer目前支持的编程语言包括Rust, C/C++, JavaScript, Go, Python, PHP, Ruby, OCaml和Swift等。wasmtime目前支持的编程语言包括Rust, C/C++, Python, C#, Go, Ruby等。这里以Rust语言为例,介绍如何分别通过这两个库,在Rust程序中使用WASM库提供的函数。对于其他通用编程语言,这两个工具的官网上都有详细的指导,并且过程和原理与Rust语言并没有太大的差别。并且对于C/C++来说,WASM官方也正在推进统一的WebAssembly C and C++ API,不过目前还不成熟。

使用wasmer

在Rust中使用wasmer的方式,在Hello world一章中已经介绍了。也就是在依赖中声明wasmer这个库之后,编写如下代码:

use anyhow::Result;
use std::fs;
use wasmer::{Imports, Instance, Module, Store, TypedFunction};

fn main() -> Result<()> {
    let wasm_bytes = fs::read("./adder.wasm")?;
    let mut store = Store::default();
    let module = Module::new(&store, wasm_bytes)?;
    let imports = Imports::default();
    let instance = Instance::new(&mut store, &module, &imports)?;
    let add: TypedFunction<(u32, u32), u32> =
        instance.exports.get_typed_function(&mut store, "add")?;
    let sum = add.call(&mut store, 1, 2)?;
    println!("Sum is {sum}");
    Ok(())
}

下面我们就解释一下这代码中一些主要的语句的含义。

读入程序

首先,我们通过Rust标准库,将WASM程序的字节码读入了内存wasm_bytes中,此时wasm_bytes的类型是Vec<u8>。这是因为我们后续在使用Module::new解析WASM时,会要求参数满足AsRef<[u8]>,因此我们一般都是直接读入内存即可。

Store

随后,我们创建了一个默认的Store类型变量。Store实际上是一个通用的WASM中的概念,但是放在Rust的代码里讲,就直观很多。

我们知道,Rust是一个非常强调所有权和生命周期的编程语言。在一个WASM程序运行的过程中,会有很多全局的状态。例如,WASM程序中的函数本身,其生命周期理应是全局的。用来托管这些全局状态的,就称为Store。

除此之外,在wasmer中,Store还负责管理引擎。所谓引擎,就是指我们如何将WASM的内部表示,转译成原生的机器指令。例如:

use wasmer::{Store, EngineBuilder};
use wasmer_compiler_llvm::LLVM;

let compiler = LLVM::new();
let mut store = Store::new(compiler);

上述代码就是创建了一个,通过LLVM引擎来将WASM的内部表示转译成原生指令的Store。

LLVM引擎生成的原生字节码,目前是优化程度最高的,适合用于生产环境。而Store::default默认使用的是用Rust原生开发的,LLVM的平替cranelift。这个引擎在转译时间和优化程度之间达到平衡,适用于开发环境。

Module

在之后详细解释WASM代码的章节中我们会了解到,一个WASM程序就是一个module。因此,我们之前提到的「WASM的内部表示」,实际上就是这样一个Module类型的变量。可以看到,这个变量创建的过程中,需要store作为参数。这是因为需要其需要引擎提供加持,所以要从store中提取目前的引擎。

创建Module类型的变量,就完成了之前提到的WASM语义阶段中的「解码」和「验证」两个阶段。

Imports

这个变量在与WASM的交互过程中必不可少,但目前我们暂时不需要了解,之后的章节会介绍。

Instance

当我们创建Instance类型的变量时,就是真正执行WASM程序的过程。创建这个变量,就会初始化WASM程序,形成一个WASM实例。也就是说,我们WASM程序从现在开始正式进入执行阶段。

调用WASM导出函数

接下来,我们使用了get_typed_function来获得我们在WASM程序中导出的"add"函数,然后使用call来调用这个函数。我们知道,Rust是强类型的语言,所以需要提供这个函数的类型信息。TypedFunction<(u32, u32), u32>就提示编译器,这个add函数接收两个u32类型的参数,返回一个u32类型的值。

这里值得指出,为什么我们在生成WASM的时候,使用的是usize,而使用WASM时,使用的是u32呢?这是因为,在生成WASM的时候,Rust程序的目标平台为wasm32-unknown-unknown,是32位平台,所以usize自动成为了u32;而在使用WASM的时候,我们是要编译生成原生的Rust程序,例如目标平台为x86_64-unknown-linux-gnu。因此,usize是64位,我们需要手动使用u32才能保证类型信息是正确的。

使用wasmtime

wasmtime与wasmer类似,也是一个可以用作WASM引擎的库。我们声明wasmtime之后,编写如下代码:

use anyhow::Result;
use std::fs;
use wasmtime::*;

fn main() -> Result<()> {
    let wasm_bytes = fs::read("./adder.wasm")?;
    let engine = Engine::default();
    let mut store = Store::new(&engine, ());
    let module = Module::new(&engine, wasm_bytes)?;
    let instance = Instance::new(&mut store, &module, &[])?;
    let add = instance.get_typed_func::<(u32, u32), u32>(&mut store, "add")?;
    let sum = add.call(&mut store, (1, 2))?;
    println!("Sum is {sum}");
    Ok(())
}

上述代码与之前wasmer的代码做了一样的事。

关于上述代码,大部分是与wasmer中的类似,其中,与wasmer的代码最大的不同,是Store::new创建时的第二个参数与Instance::new创建时的第三个参数。这两个参数实际上对应的是wasmer中的Imports,因此在这里暂时不作细讲,之后介绍WASM函数的导入与导出时再详细介绍。

此外,目前wasmtime主要使用的是cranelift来将WASM内部表示生成为原生机器码,而不像wasmer一样提供LLVM等方式。

总结

总结而言,在上述通用程序的使用中,我们可以发现,对WASM的解码、验证与执行,都是运行时执行的,而非编译时或链接时执行的。这与我们传统的使用静态链接库的方式不一样,更类似于我们使用动态链接库。这是因为,我们将WASM作为库在通用程序中使用的时候,往往是考虑其较高的安全性与平台的支持性,因此大部分情况下,这些WASM程序都是作为插件系统来使用。而插件系统的特点,就是在运行时加载、执行,因此目前相关的API设计才是如此。

基本语法

在深入学习了如何生成与使用WASM程序之后,我们已经有了许多模板,比如Rust生成WASM的,比如Web使用WASM的。因此,接下来我们就可以完全关注在WASM代码本身,一切新学习到的知识都可以通过之前拥有的模板来验证、测试。

从本章开始,我们将学习WASM的基本语法。在本章中,我将介绍一些最基础的WASM的语法。这些基础知识往往比较零散,之间的联系并不是十分紧密,但如果单独一章介绍一个的话,又不足以支撑篇幅。因此,本章就将那些基础的、小块儿的知识放在一起来介绍。

我们仍然以Hello world一章中最基本的程序library.wat为例:

(module ;; Define a module
    ;; Define a function with name `add`, two parameters of type i32, and returns i32
    (func $add (param $left i32) (param $right i32) (result i32)
        local.get $left ;; Push parameter `left` to stack
        local.get $right ;; Push parameter `right` to stack
        i32.add ;; Consume two values at stack top, and push the sum to stack
    )
    (export "add" (func $add)) ;; Export this function with symbol "add"
)

注释

首先,我们第一眼看这个代码,就可以立刻明白,在WASM中,以两个分号;;打头的是注释。当然,C语言中的块注释/* ... */,WASM中也有类似的(; ... ;)

(module (; This is comment ;))

因此,在仔细研究WASM的语法结构的时候,可以在脑中直接把注释部分忽略,也就是下面这个样子:

(module
    (func $add (param $left i32) (param $right i32) (result i32)
        local.get $left
        local.get $right
        i32.add
    )
    (export "add" (func $add))
)

S表达式

熟悉Lisp语言的开发者在看到WASM的文本格式代码时,肯定第一眼就会说,这不就是S表达式(S-Expression)嘛!没错,WASM的文本格式,其整体而言是以S表达式的形式组织的。

严格来说,一个S表达式的定义为:

  • 一个原子元素,或
  • (x y),其中xy都是S表达式

这种格式非常适合表示树结构,在编程语言中,非常常见的树结构就是抽象语法树。例如,在Lisp中:

(* 5 (+ 7 3))

意思就是5 * (7 + 3)。通过S表达式,Lisp的代码可以轻松地解析为抽象语法树。

那我们就以S表达式的眼光,来看看我们的WASM代码的结构:

  • 从最外层来看,是一个module,其有两个组件:一个func,一个export
    • 对于func来说,其有三个子组件:两个param和一个result。其余部分均可以看做原子元素
    • 对于export来说,其有一个子组件func

因此,从树结构的角度来理解的话,这段WASM代码,其最大深度为3,也就是可以看做这种形状:

module
|------func
|      |-------param
|      |-------param
|      |-------result
|
|------export
       |-------func

模块

在WASM代码的顶层,是module。WASM规定,一个WASM程序对应一个WASM模块。因此,在我们的WASM代码中,顶层永远是module,并且不允许出现多个module

事实上,在我们之前通过Rust使用WASM的过程中,经常出现Module类型,对应一个WASM模块,这就是对应的其代码中的根结点module

函数

接下来,我们就好好研究一下WASM的函数是怎么写的。在WASM中,一个模块中可以有任意多个函数,而下面我们研究研究之前代码里的函数

(func $add (param $left i32) (param $right i32) (result i32)
    local.get $left
    local.get $right
    i32.add
)

标识符

首先,我们看到,紧跟在func后面的,是$add。在WASM中,以$打头的称为标识符,它和我们在高级编程语言里遇到的标识符有着同样的作用。也就是说,func $add就代表这个函数名字叫add。

有两点需要注意的。首先,标识符不是必须的。标识符的存在,只是为了方便后续对这个元素的引用。例如,我们可以看到,在我们的WASM代码中,最后的export一段,引用了我们之前定义的函数func $add。因此,我们才必须给这个函数一个名字$add。如果我们在整个代码中,不需要引用这个函数,那我们无需给这个函数名字,可以直接写成

(func (param $left i32) (param $right i32) (result i32)
    local.get $left
    local.get $right
    i32.add
)

第二点,标识符不是必须出现在二进制格式中的。和高级编程语言一样,标识符只是方便开发者进行编程。而一个标识符究竟会不会出现在生成的二进制镜像中,这取决于开发者的意愿。如果我们想将这个标识符导出,那么这个标识符就可以以字符串的形式出现在二进制镜像中。我们WASM代码的最后一段的export就做了这个事。

除了使用标识符以外,我们还可以用索引来引用WASM中的元素。我们提到,二进制镜像中不会包含标识符,只会包含开发者手动指定的导出符号。那么,如果我们将一个文本格式的WASM程序转译成二进制格式,然后再转译回文本格式,那么标识符会变成什么呢?

我们可以手写一个index.wat

(module
    (func $add (param $left i32) (param $right i32) (result i32)
        local.get $left
        local.get $right
        i32.add
    )
    (func $sub (param $left i32) (param $right i32) (result i32)
        local.get $left
        local.get $right
        i32.sub
    )
    (export "sub" (func $sub))
)

这里声明了两个函数,导出的是第二个函数$sub,因此更方便我们理解索引结构。我们使用如下指令:

wat2wasm index.wat -o index.wasm
wasm2wat index.wasm -o index2.wat

我们查看index2.wat的内容,会发现是:

(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func (;0;) (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
  (func (;1;) (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.sub)
  (export "sub" (func 1)))

首先先不用管type相关的语句,这之后马上就会提到。我们发现,原先在函数定义时的标识符$add$sub,由于没有记录到二进制镜像中,因此在还原的时候,被替换为了块注释(;0;)(;1;)。这些注释相当于空字符,不会对语义产生影响,所以我们可以忽略。

但是,在最后的导出语句中,使用了(func 1)来替代(func $sub)。这是因为,在WASM中,同级同属性的节点会自动拥有从0开始的索引。说得直白一点,这个WASM程序有两个函数,因此自动地,第一个函数有索引值0,第二个函数有索引值1。我们可以直接通过索引值来引用这个函数,所以export语句就可以通过(func 1)引用第二个函数,也就是我们先前定义的sub

除了函数之外,几乎所有的元素都会有其索引值,我们也可以在这个代码中看到(type 0)(local.get 0)等语句,这就是索引值的使用。

签名

func $add之后,是声明这个函数的参数、返回值类型。这里我们可以很直观地看到,这个函数接收两个i32类型的参数,返回一个i32类型的值。一个函数的参数+返回值类型,称为这个函数的签名(Signature)。

对于参数来说,正如我们之前提到的,如果一个参数不需要用名字去引用它,就可以省略相应的标识符,例如(param i32)。如果一个函数的参数都没有标识符,我们有一个语法糖:

(func $add (param i32 i32) (result i32)
    ;; ...
)

此外,我们也可以额外加一个type语句,用来double check一下这个函数的签名的正确性(只会在生成二进制程序时被检查):

(module
    (type $add_type (func (param i32 i32) (result i32)))
    (func $add (type $add_type) (param $left i32) (param $right i32) (result i32)
        local.get $left
        local.get $right
        i32.add
    )
    (export "add" (func $add))
)

这也解释了我们在之前,将生成的二进制程序还原成文本形式时出现的type语句。

栈机

我们接下来就重点关注其函数体,也就是其中的指令部分:两个local.get和一个i32.add

在了解具体指令之前,我们首先需要知道「栈机」(Stack machine)和「寄存器机」(Register machine)。

计算模型

对于一个基于虚拟机的编程语言(例如Java基于JVM,Rust、C/C++可以基于WASM),所谓的「虚拟机」就是指,输入其自定义的指令字节码(如JVM字节码、WASM二进制表示等),虚拟机将根据指令字节码,执行相应的指令。在我们实现这样一个虚拟机的时候,往往需要设计一种「计算模型」(Computational model)。在虚拟机的常见实现中,主要分为两种计算模型:「栈机」和「寄存器机」。

例如,我们希望我们的虚拟机能够实现加法add指令:

  • 对于栈机而言,我们需要在整个执行过程中,维护一个操作数栈。add指令不需要显式给出参数。我们若想执行加法功能,例如实现2 + 3,需要:

    1. 将数字2和3压入栈中
    2. 调用add指令
    3. 虚拟机从栈上弹出两个数字,也就是2和3
    4. 将其相加
    5. 把结果5压入栈中
  • 对于寄存器机而言,我们需要维护一系列寄存器。add指令需要三个寄存器编号作为参数。我们若想执行2 + 3,需要:

    1. 先将2放入0号寄存器,然后将3放入1号寄存器,再将2号寄存器作为返回值存放的寄存器
    2. 将这3个寄存器编号,也就是0、1和2作为参数,调用add指令
    3. 虚拟机去相应的寄存器中寻找值
    4. 将其相加
    5. 把结果5存到2号寄存器中去

熟悉Intel的汇编以及调用约定的开发者想必会有一些共鸣,从某种意义上,32位x86的调用约定类似一种栈机(但其返回值不通过栈传递),而64位AMD64的调用约定类似一种寄存器机。

此外,还需要注意的是,这里讲的栈机中的栈,和我们真正编程过程中使用的,进程的栈并不是同一个东西。进程的栈在执行过程中,还要把什么返回地址、帧指针之类的全压到栈上去,是一种混合了「调用栈」与「操作数栈」的模型。而这里讲的栈机的栈,仅仅是用来传递参数和返回值的。

当然,还有一点值得指出。无论是栈机还是寄存器机,都只是一种「模型」。也就是说,通过这种模型,可以很好地定义其执行的方式、顺序,但真正的虚拟机实现里,是不一定需要维护一个真实的栈或者一系列寄存器的,可以通过优化去做更多的事。

WASM栈机

WASM大致是一个栈机(但实际上,WASM的局部变量机制导致它不是一个真正的栈机,详情可见WebAssembly Is Not a Stack Machine),目前我们可以粗略地用栈机的眼光来理解WASM的函数。

回到我们之前的add函数。从栈机的角度来简单地解释一下这个函数的意思:

  • local.get $left

    将第一个参数压栈

  • local.get $right

    将第二个参数压栈

  • i32.add

    从栈上弹出两个参数,将其求和,然后将结果压栈

我们之前声明了add函数,可以注意到,我们说了它有一个i32类型的返回值。然后在这个函数结束的时候,它的栈上正好还剩这一个结果。这个最终剩在栈上的值,就会成为这个函数的返回值。这种栈的「平衡性」,也是WASM引擎的「验证」阶段可以静态完成的一个重要的事。

但我们在写WASM的过程中,有时候很难保证,函数结束的时候,恰好栈上剩下的值的个数等于返回值的个数。有可能有一些中间变量也在栈上,只不过不在栈顶。为了保证栈的平衡性,我们可以使用return或者drop指令。return指令首先会查看当前函数返回值的个数,然后从栈上弹出相应个数个值作为返回值,剩下的全部丢掉。而drop指令则一般用在没有返回值的函数中,直接将当前栈上所有值丢掉。

此外,有一个非常重要的,值得注意的事:参数传递的顺序。我们可以发现,与x86不同,我们是从左往右压栈。也就是说,接下来的指令的第一个参数先压栈,最后一个参数最后再压栈。这是值得注意的。

另外,我们需要强调的是,尽管在上述指令的解释中,我们用了「压栈」,但实际上这些指令本身是不会压栈的,也没有一个指令专门是「压栈」。只不过是,WASM在执行每条指令时,将根据这条指令的特性,调整栈。例如,WASM在执行时,发现这条指令是local.get,它不接受参数,返回一个值,因此将这条执行执行后的值放到栈上;在执行到i32.add时,发现这条指令接受两个参数,返回一个值,因此把栈上弹两个值出来,作为参数,执行后把结果存到栈上。

在真正书写WASM的过程中,我们有一个语法糖(一般被称为折叠格式(folded form)):

(func $add (param $left i32) (param $right i32) (result i32)
    (i32.add (local.get $left) (local.get $right))
)

这个语法一看就和我们正常的高级语言的语法类似了,但是这是个语法糖,其底层还是会变成之前的栈机形式,不过这种写法更利于人类阅读和书写。

指令

在WASM中,大部分的指令都分为两个部分,前半部分表示指令所属的类别,后半部分表示指令的内容。例如,之前我们遇到的local.get,其类别属于local,内容是获取local的值;i32.add,类别属于i32,内容是将两个i32的值相加。

WASM的指令数目不多,和AArch64类似,基本属于精简指令集了,不会有非常复杂的指令。具体的指令列表可以参考官方文档Index of Instructions。在本系列中,不会集中地讲解指令集,而是会在需要的地方,详细解释与某些概念息息相关的指令。

基本的数字类型

WASM最常做的事,就是进行大量的数字相关的计算。在WASM中,有以下四个数字类型:

  • i32

    32位整数类型

  • i64

    64位整数类型

  • f32

    32位单精度浮点型

  • f64

    64位双精度浮点型

熟悉底层,特别是LLVM IR的开发者应该更方便理解这里的概念,因为和LLVM IR类似,WASM中的整型有无符号,并不是记录在类型信息中,而是根据不同的指令加以区别。对于了解高级语言的开发者来说,如Rust中,整型往往需要有符号信息(如usizeisize),但是在底层中,我们可以举两个例子。

有无符号结果一致

在二进制层面,有符号整型往往是通过「补码」这种编码格式来存储,而无符号整型则直接原封不动存储就行。关于这种编码格式,各种本科的基础课里已经有了很多讲解。这里我们只需要知道一件事:

我们设计的编码格式非常强大,强大到:我们通过CPU的加法器将两个寄存器的值a和b相加,得到c:

  • 如果将a和b看作有符号整数的编码,那么将c按照有符号整数解码,就是之前两个有符号整数相加的和
  • 如果将a和b看作无符号整数的编码,那么将c按照无符号整数解码,就是之前两个无符号整数相加的和

也就是说,我们不需要有符号整数一个加法器、无符号整数一个加法器,而是一个加法器,就可以解决所有整型相加的问题,并且其有无符号,与底层CPU系统不再有关。

因此,在WASM层面也是一致的。这里的i32i64i,只是整数integer的代表,不代表有无符号。当我们定义了add函数:

(func $add (param $left i32) (param $right i32) (result i32)
    local.get $left
    local.get $right
    i32.add
)

i32.add指令也不再指定符号信息,因为与底层无关。而其究竟有无符号,是根据使用这个的高级语言来决定的。在Rust层面,我们既可以将其看作一个(i32, i32) -> i32的函数,也可以看做一个(u32, u32) -> u32的函数。

有无符号结果不一致

在加法层面,有无符号结果是一致的。然而有些指令则不一样。例如在整数除法时,根据符号不同,我们需要将结果按不同的方式向上或者向下取整。

因此,在WASM指令集层面,提供了两个指令i32.idiv_ui32.idiv_s,分别提供无符号整数除法和有符号整数除法。无符号整数除法将两个操作数看做无符号整数,有符号整数除法则是看做有符号整数。

从这里,我们就可以看出,在WASM层面,与LLVM IR类似,整型变量有无符号,不存储在类型信息中,而是根据指令的不同来决定的。

导出

在最后,我们使用了

(export "add" (func $add))

来表示,我们要导出之前声明的一个叫$add的函数,并且它的导出的名字是"add"。事实上,还有一种更简单的写法:

(module
    (func (export "add") (param $left i32) (param $right i32) (result i32)
        local.get $left
        local.get $right
        i32.add
    )
)

将最后一个export语句直接移到函数的定义中。这样的话,就不需要在后面引用这个函数,从而不需要再给这个函数命名了,所以我们自然也省去了$add这个标识符。

变量与常量

在讲基本语法时,我们故意地忽略了函数的参数、返回值相关的深入知识。在这一章里,我们将连同这些知识一起,统一介绍在WASM中的变量与常量。

局部变量

我们之前提到过,在WebAssembly Is Not a Stack Machine一文中指出,WASM的局部变量设计让它不是一个真正的栈机,甚至可以说是一个寄存器机。同时,这也让WASM代码很难变成SSA形式,从而难以利用SSA的各种优化。那么,我们来看看,WASM的局部变量究竟是如何设计的。

在WASM中,我们可以这样声明一个函数:

(func (param $a i32) (param $b i32) (result i32)
    (local $c i32)
    ;; ...
)

在这里,我们定义了一个函数,它接受两个i32类型的参数,返回一个i32类型的值,并且主动声明了一个i32类型的局部变量。事实上,与C语言类似,除了我们主动声明的局部变量外,函数的参数也可以看做局部变量。也就是说,这个函数的局部变量,是$a$b$c。因此,我们之前提到,除了可以通过标识符来引用WASM的元素之外,也可以通过每个元素自带的编号来引用。而对于局部变量来说,其编号是从参数开始编的。也就是说,对于我们这个函数来说,参数$a的编号是0,而局部变量$c的编号是2。

对于局部变量,我们最常做的两个操作是local.getlocal.set。例如:

local.get $a

将返回局部变量$a的值,也就是说往栈上压入一个$a的值。

local.set $a

则是将栈顶元素弹出,并赋值给局部变量$a

在我们使用高级语言编程的过程中,一个常见的编程错误是使用未初始化的变量。例如,当我们在Rust中写下如下语句:

fn foo() {
    let a: i32;
    let b = a - 1;
}

会报错:

error[E0381]: used binding `a` isn't initialized
 --> src/lib.rs:3:13
  |
2 |     let a: i32;
  |         - binding declared here but left uninitialized
3 |     let b = a - 1;
  |             ^ `a` used here but it isn't initialized
  |
help: consider assigning a value
  |
2 |     let a: i32 = 0;
  |                +++

很显然,使用未初始化的变量会造成程序错误。

在WASM中,我们会遇到同样的问题吗?我们可以编写如下程序:

(module
    (func (export "local_initialize") (result i32)
        (local $dummy i32)
        local.get $dummy
    )
)

我们并没有手动给$dummy变量赋值,那么我们直接获取它的值会发生什么呢?我们可以把这个程序放在各种WASM引擎中运行,会发现这个函数顺利通过了「验证」阶段,并且执行的结果是0。

事实上,在WASM的核心标准中(这一段)规定了,在调用一个函数的时候,它的局部变量是已经被初始化了的。而对于我们常用的数字类型来说,其默认值就是0。因此,在WASM中,不会出现使用未初始化的局部变量的问题(顺便一提,在Function Reference Types Proposal这个提案中引入了非Null的引用类型,这种类型就没有默认值了)。

寄存器机

我们可以声明任意多个局部变量,而这些局部变量,都可以用来存放--获取值。所以,一个局部变量实际上就是一个寄存器,而我们的WASM,实际上变成了一个拥有无数个寄存器的寄存器机!

这该如何理解呢?我们知道,寄存器机就是可以把变量临时存放在寄存器中,在参与运算的时候,将寄存器的值给相应的指令即可。那么,例如对于i32.add指令,我们可以将其操作数利用local.set先存放在两个局部变量中。在执行指令之前,再使用local.get将两个局部变量的值读出放在栈上,就模拟出了寄存器机。

寄存器机好不好呢?我只能说,SSA的寄存器机是好文明。所谓SSA,简单来说就是指任何一个寄存器的值都是不可变的。如果我们想改变一个寄存器的值,那最好的方法就是再用一个新的寄存器。SSA的寄存器机编写的代码,能够非常高效地进行各种程序自动分析算法,例如活性检测算法等,从而能得到更好的优化结果。LLVM IR就是一个著名的SSA的寄存器机。但很显然,WASM不是一个SSA的寄存器机。直观上来看,在对栈机编写的代码进行程序分析时,可以轻松地将其转变为SSA的寄存器机。

局部变量的必要性

为什么WASM会引入局部变量,而让它不完全是一个栈机,甚至不是SSA的寄存器机呢?从历史上看,WASM设计之初的目标并不是成为一个底层指令集,而且也没有很好的编译器实现,所以就自然引入了局部变量机制(详情可看这一节)。

但是对于这样的底层语言来说,历史原因往往并不重要,因为大部分情况下,WASM都是直接由编译器自高级语言生成。所以如果我们不想被局部变量破坏了我们的栈机,为什么不直接让编译器不生成局部变量呢?因此,这就涉及到了局部变量的必要性。也就是说,在WASM代码中,有一些事,不能通过栈机来实现,而只能通过局部变量来实现。

这里需要指出,WASM层面的局部变量的必要性,并不与高级语言层面的局部变量的必要性挂钩。例如我们的C语言程序

int a = 1 + 2;
int b = 3 * 4;
int c = a & b;
int d = 5 - 2;
int e = c / d;

尽管这里C语言中用了好多局部变量,但是这些仍然可以只用栈机来描述,也就是通过压栈、弹栈,而不通过存放寄存器,仍然能够实现这样的操作。那究竟怎样的操作不能通过栈机来描述呢?

第一个原因是语言设计上的限制。首先,在很早之前,WASM的局部作用域(比如说block)是不能读取这个作用域开始之前的栈上的内容的,因此只能通过局部变量来传递一些值。这个问题在「WASM is not a stack machine」这篇文章里被着重强调了。

但到了2023年的今天,multi-value提案已经成了标准化的WASM特性,被各大引擎都实现了,上述这个问题也不再是问题了。那我们还有用局部变量的必要性吗?

针对这个问题,我特意请教了之前这篇文章的作者JEF。我节选一下他关于这的回信:

I believe that locals are still necessary as (as far as I know) Wasm still doesn't have any stack manipulation instructions such as peek and poke. This means that if you want to store values for later you still need locals.

简单来说,第二个问题是复用的问题。我们在编程的过程中,往往需要变量的复用,如在C语言中:

int a = 1 + 2;
int b = 3 * a;
int c = a & b;
int d = 5 - a;
int e = a + c / d;

可以看到,这里变量a在很多语句中都被使用。但是对于栈机来说,不能保证每次在构建这些值的时候,a的值都在栈顶。对于真正的栈机,往往会提供peekpoke指令。peek可以将此时栈上指定位置的值复制到栈顶,而poke则可将栈顶的值插入到栈的指定位置中。目前WASM并没有这样的指令,不过我们可以模拟出来,之后在控制语句与基本块一章中,我们会给出一个实际的例子。

所以在WASM中,我们仍然需要局部变量机制来存储这些值,也就是说,在计算出a的值后,我们需要一个local.set将其临时存储。在之后计算别的值的时候如果需要用到a,再使用local.get

常量

我们之前的WASM代码中,指令的操作数最终都是通过local.get来获得的,但是往往我们还是会需要常量的,比如说b = a + 1,其一个操作数可以通过local.get来获取,但是另一个操作数就需要常量1

在WASM中,我们可以使用

i32.const 1

这种语句来获得一个常量1。

全局变量

WASM也支持全局变量。我们可以用global来声明,通过global.get来获取。例如:

(module
    (global $kitzuki i32 (i32.const 323))
    (func $get_kitzuki (result i32)
        global.get $kitzuki
    )
)

我们声明了一个叫$kitzuki的全局变量,其初始值为323(WASM的全局变量必须初始化)。随后,我们在函数$get_kitzuiki中,使用了global.get获取了这个全局变量的值。

对于全局变量的设置,也就是global.set,则有一点点不太一样。正如Rust需要特别使用static mut来声明可变的静态变量,在WASM中,我们也需要mut来声明可变的全局变量:

(module
    (global $kitzuki (mut i32) (i32.const 323))
    (func $set_kitzuki
        (global.set $kitzuki (i32.const 1219))
    )
)

在这里,我们声明了一个可变的全局变量$kitzuki,初始值为323。随后,我们使用global.set给其赋值为1219。

在WASM中,全局变量和局部变量类似,也会破坏栈机的性质。不过对于目前现代化的高级语言以及高级的编程模型,我们往往并不需要全局变量。全局变量在WASM中还有一个作用是通过导入与导出,与外界传递信息。不过这个留在之后的章节再说。

控制语句与基本块

之前的几章中,我们反复提到WASM的局部变量机制与栈机、寄存器机的特性,其中我们指出,早期WASM的基本块语法设计导致局部变量的使用,但后来已经解决。在这一章里,我们将介绍在编程中不可或缺的元素——控制语句,并且用实际的例子,给大家分析基本块与寄存器机、栈机。

函数调用

首先,我们来讲讲函数调用,从某种意义上,它也是一种控制语句。在WASM中,我们可以使用call语句进行函数调用。它与我们在高级语言中常见的函数调用几乎没有区别,唯一要注意的就是对于栈的控制。举一个实际的例子,我们会更好理解:

(module
    (func $foo (param i32 i32) (result i32 i32 i32)
        ;; Do something...
    )
    (func $bar
        i32.const 1
        i32.const 1
        call $foo
        ;; Here we got three elements on the stack
    )
)

我们定义了一个接受两个参数,返回三个值的函数$foo(函数返回多个值的提案Multi-value proposal已经成为标准化特性而被广泛实现了),当我们在函数$bar中调用$foo函数时,首先得确保栈上已经有两个元素了。在调用$foo之后,栈上的两个元素被这个函数消耗掉了(也就是弹栈走了),然后$foo返回了三个值,都存储在栈上,所以此时栈上有三个元素。

此外,值得一提,函数调用指令也适用我们之前提到的语法糖,也就是:

(func $bar
    (call $foo (i32.const 1) (i32.const 1))
    ;; Here we got three elements on the stack
)

在了解了函数调用的栈性质之后,我们其实可以模拟出之前提到的peek和poke了:

(module
    (func $peek0 (param i32) (result i32 i32)
        local.get 0
        local.get 0
    )

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

$peek0函数做的事是将距离栈顶0个元素(也就是栈顶元素)复制并压栈,而peek1则是将距离栈顶为1的元素复制并压栈。

选择语句

熟悉底层汇编的开发者一定了解,对于我们在高级语言中常见的选择语句if...else,在大部分的汇编架构中,其往往是实现成针对特定flag的标签跳转。但在WASM中,实际上提供了更高层面的抽象,也就是接近高级语言的if...else...end语句,即:

if
    ;; True branch
else
    ;; False branch
end

在执行这个结构时,会从栈上将栈顶元素弹栈,若其为0,则执行False分支,否则执行True分支,非常符合直觉。

我们在高级语言中使用选择语句的时候,往往是通过比大小来判断执行哪个分支,其本质上就是将大小转变为布尔值,然后根据布尔值来做判断。在WASM中,也提供了一系列将大小转变为布尔值的指令,例如

i32.const 1219
i32.const 323
i32.ge_u

上述程序将得到1。其意思是,i32.ge_uge代表大于等于(greater than or equal to),而u代表将两个操作数看做无符号整数(之前我们提过,WASM的整数的有无符号是根据指令来决定的)。类似地,我们一共有gegtltleeqne等大小比较,与su的组合。

在了解了函数调用与选择语句之后,我们就可以实现一个简单的递归形式的阶乘函数了:

(func $factorial_recur (param $input i32) (result i32)
    (i32.le_u (local.get $input) (i32.const 1))
    if
        i32.const 1
        return
    end
    (i32.mul
        (local.get $input)
        (call $factorial_recur (i32.sub (local.get $input) (i32.const 1)))
    )
)

上述代码非常简洁清楚,如果不明白,我们可以用类似的C语言来帮助理解:

unsigned int factorial_recur(unsigned int input) {
    if (input <= 1) {
        return 1;
    }
    return input * factorial_recur(input - 1);
}

基本块与跳转

除了选择语句以外,我们常见的循环语句,在底层往往需要通过跳转来实现。在WASN中,循环语句的跳转往往与基本块相绑定。

具体而言,WASM中的跳转包括brbr_if,前者相当于底层汇编指令中的无条件跳转,例如AMD64中的jmp,而br_if则是有条件跳转,其工作方式与WASM中的if类似,消耗掉栈顶的一个值,判断其是否为true,如果为true则跳转。这两者使用起来很类似,所以接下来就以br_if为例。

在WASM中,如果需要使用这类跳转,往往必须在一个基本块block或者loop中,具体而言,是这样的一个结构:

(func
    ;; Do something
    block $my_block
        ;; Do something inside this block
        br_if $my_block
        ;; Do something else
    end
    ;; ...
    loop $my_loop
        ;; Do something inside this loop
        br_if $my_loop
        ;; Do something else
    end
)

我们通过blockloop可以附带一个标识符,然后在br或者br_if的指令中,操作数就是相应的标识符,也就是br_if $my_blockbr_if $my_loop

这两者有什么区别呢?很简单,br以及br_if,在block中将跳转到block之后的指令,而在loop中则会跳转到loop开头的第一条指令。我们可以近似地理解成,在block中充当了C语言中break的作用,而在loop中充当了C语言continue的作用。并且一点很重要的是,blockloop本身并不是循环,也就是说,如果内部没有br或者br_if,则执行完这个基本块就不会重复执行,而是接下来执行下一条指令

基本块的参数和返回值

WASM中的blockloop可以拥有返回值,而Multi-value proposal这个提案则让基本块可以拥有参数。由于这个提案已经被广泛地实现,所以接下来,我们就介绍一下,blockloop的参数及返回值相关特性。

简单来说,我们可以像定义函数一样,给基本块定义参数和返回值,如:

(func $my_func
    i32.const 1
    i32.const 2
    block $my_block (param i32 i32) (result i32)
        ;; ...
    end
)

在这里,我们定义了一个接受两个i32参数,返回一个i32的基本块$my_block。但是,这里的语义实际上是与函数不太一样的,参数并不可以看做一个局部变量,而是需要用栈机的视角去看待。

我们可以粗略地理解成,$my_block这个基本块也有一个栈,这个栈位于$my_func的栈内。当我们进入这个基本块之前,函数栈上已经有了1和2这两个元素。当我们进入这个基本块时,1和2这两个元素就归属于基本块的栈了,也就是说,在基本块的开头,栈上就有了两个元素1和2。

由于我们声明这个基本块需要返回1个元素,因此当这个基本块结束时,这个基本块的栈上应该只剩1个元素,并且返回后,这1个元素就归属回函数栈,并且之前的元素1和2,由于归属在基本块,所以可以供其操作,把栈上的元素从开头的两个,变成最终只剩一个。

换句话说,如果这个函数在进入基本块前,栈上已经有了3个元素,而我们的基本块接受2个参数,那么基本块是没有办法访问函数栈的第一个元素,只能访问后两个元素;类似地,基本块结束时,也必须保证栈上元素的数量等于其返回的值的数量。

这看上去似乎很复杂,那我们就通过两个例子来学习一下。

寄存器机形式的阶乘函数

首先,我们来看一下在WASM中,以迭代方式实现的阶乘函数。

(func $factorial_iter_register (param $input i32) (result i32)
    (local $prod i32)
    (local.set $prod (i32.const 1))
    loop $main_loop (result i32)
        (i32.le_u (local.get $input) (i32.const 1))
        if
            local.get $prod
            return
        end
        (local.set $prod
            (i32.mul (local.get $input) (local.get $prod))
        )
        (local.set $input (i32.sub (local.get $input) (i32.const 1)))
        br $main_loop
    end
)

这个例子看上去不算难以理解,其可粗略地看做下面的C程序:

unsigned int factorial_iter_register(unsigned int input) {
    unsigned int prod = 1;
    while (1) {
        if (input <= 1) {
            return prod;
        }
        prod = input * prod;
        input = input - 1;
    }
}

在这个例子中,有两点值得注意。

首先,这个例子中的基本块$main_loop较为简单,没有参数,所以容易理解一些。由于其返回一个i32类型的值,因此在这个基本块结束的时候,必须要求其栈上只剩1个值。在这里,我们通过return指令巧妙地保证了这一点。

其次,这个例子大量使用了局部变量用于存储临时值,因此是以寄存器机的形式来实现的这一功能。

栈机形式的阶乘函数

事实上,在基本块可以接受参数之后,我们就可以用纯栈机的形式实现阶乘函数了,不过需要借助我们之前模拟实现的$peek0$peek1(这段代码改编自Multi-value proposal中的官方示例):

(func $factorial_iter_stack (param $input i32) (result i32)
    i32.const 1
    local.get $input
    loop $main_loop (param i32 i32) (result i32)
        call $peek1
        call $peek1
        i32.mul
        call $peek1
        i32.const 1
        i32.sub
        call $peek0
        i32.const 1
        i32.gt_u
        br_if $main_loop
        call $peek1
        return
    end
)

这一段代码乍看很难理解,那么,为了方便理解,我们不妨举一个例子。当$input为3时,我们来看看,这个函数的栈究竟是如何变化的:

[i32.const 1]          1
[local.get $input]     1  3
[INSIDE $main_loop]    1  3
[call $peek1]          1  3  1
[call $peek1]          1  3  1  3
[i32.mul]              1  3  3
[call $peek1]          1  3  3  3
[i32.const 1]          1  3  3  3  1
[i32.sub]              1  3  3  2
[call $peek0]          1  3  3  2  2
[i32.const 1]          1  3  3  2  2  1
[i32.gt_u]             1  3  3  2  1
[br_if $main_loop YES] 3  2
[INSIDE $main_loop]    3  2
[call $peek1]          3  2  3
[call $peek1]          3  2  3  2
[i32.mul]              3  2  6
[call $peek1]          3  2  6  2
[i32.const 1]          3  2  6  2  1
[i32.sub]              3  2  6  1
[call $peek0]          3  2  6  1  1
[i32.const 1]          3  2  6  1  1  1
[i32.gt_u]             3  2  6  1  0
[br_if $main_loop NO]  3  2  6  1
[call $peek1]          3  2  6  1  6
[return]               6

可以看到,在最终return的时候,返回的值是6,确实计算成功了3的阶乘。

这里有几点需要注意的。在第一次进入$main_loop的时候(第三行),由于这个基本块接受两个参数,因此此时栈上的1和3就归属于了$main_loop这个基本块,从而之后的$peek1操作,才能访问这两个元素。

在第一次条件跳转br_if $main_loop时,首先消耗掉栈顶的值,也就是1,这个值是刚刚i32.gt_u得到的比较结果。由于其为1,所以需要跳转到这个loop的开头。同时,brbr_if的语义要求,将此时基本块栈上的值只保留基本块参数个数。简单来说,由于$main_loop接受两个参数,而在消耗掉栈顶的1后,栈上还有四个值(1, 3, 3, 2),因此3和2会被作为下一次循环时,这个基本块的参数,而剩下的1和3就被抛弃了,所以此时栈上就只剩下3和2了。

导入与导出

在熟悉了WASM的基本语法之后,我们会发现,正如最开始所说,WASM的核心还是对各种数的计算,而缺少与系统的交互,主要功能是做一些计算密集型的操作,也就是提供一些例如加密、哈希等操作。那么,WASM究竟怎样才能与外界系统交互呢?从这一章开始,我们就要讨论这个问题。在这一章中,我们讨论的第一个方法是导入(Import)与导出(Export)。

在WASM中,导入与导出非常的简单,我们直接以一个例子来说明。我们创建common.wat并写入以下代码:

(module
    (import "outer" "log_number" (func $log_number (param i32)))
    (import "outer" "instability" (global $instability i32))

    (func $peek0 (param i32) (result i32 i32)
        local.get 0
        local.get 0
    )

    (func (export "wasm_mul") (param $left i32) (param $right i32) (result i32)
        (i32.add
            (i32.mul (local.get $left) (local.get $right))
            (global.get $instability)
        )
        call $peek0
        call $log_number
    )
)

这段程序想要实现的是一个脑子多少有点问题的计算器,它可以用来计算乘法,但算出来的结果总会有些偏移。

我们首先看到的是两个import语句,这个语句就是WASM中的「导入」。import后跟着两个字符串,例如"outer"和"log_number",这个的意思是导入outer模块的log_number函数。关于这里的模块怎么用,我们后面会提到。紧接着这两个字符串,就声明了这个导入的类型,这里是一个接受一个i32类型参数的函数$log_number,而下一个import则导入的是一个i32类型的全局变量$instability

而导出,则是我们之前已经用了很多次的export语句。在这段程序里,我们导出了一个名叫wasm_mul的函数。其实现为,将输入的两个数相乘,然后加上之前导入的偏移值全局变量$instability,调用导入的函数$log_number作个日志输出,然后返回。

从上面的描述可以知道,我们的WASM程序终于有了主动输出的能力了,虽然这个能力是导入的,但终究还是能自己控制的。

导入与导出的使用

说了这么多,我们来看看使用效果。

在Rust中使用wasmer库

与之前使用wasmer来调用adder.wasm十分类似,我们来看看这次的代码是怎么写的:

let wasm_bytes = fs::read("./common.wasm")?;
let mut store = Store::default();
let module = Module::new(&store, wasm_bytes)?;
let imports = imports! {
    "outer" => {
        "log_number" =>
            Function::new_typed(
                &mut store,
                |number: i32| println!("In WASM, we got {number}")
            ),
        "instability" => Global::new(&mut store, Value::I32(-5)),
    }
};
let instance = Instance::new(&mut store, &module, &imports)?;
let wasm_mul: TypedFunction<(u32, u32), u32> = instance
    .exports
    .get_typed_function(&mut store, "wasm_mul")?;
println!("Calculating 5 x 8 with instability -5 ...");
let prod = wasm_mul.call(&mut store, 5, 8)?;
println!("From outside, we got {prod}");

运行这段程序,我们可以看到输出:

Calculating 5 x 8 with instability -5 ...
In WASM, we got 35
From outside, we got 35

可以看到,我们之前写的WASM程序确实像我们说的一样,想计算5乘8的结果,但是脑子出了点问题,算出来的结果是五八三十五。特别值得注意的是"In WASM, we got 35",这段话是在WASM执行的过程中输出的!说明我们的WASM程序,确实通过导入函数的方法,实现了与系统的交互。

具体到wasmer的API中来看,我们与之前和调用adder.wasm的程序作对比,会发现,这里主要是新增了imports的相关语句。wasmer提供了imports!宏,我们在这个宏中,首先声明了第一层的"outer",这对应了我们在WASM的导入中第一个字符串,然后分别定义了log_number函数和instability全局变量,其定义方法非常直观。随后,在Instance实例创建的过程中传入这个对象,就可以将Rust中的函数、数据传入WASM中。

在Rust中使用wasmtime库

与之前类似,我们来看看如何使用wasmtime库来建立导入导出:

let wasm_bytes = fs::read("./common.wasm")?;
let engine = Engine::default();
let mut store = Store::new(&engine, ());
let module = Module::new(&engine, wasm_bytes)?;
let log_number = Func::wrap(&mut store, |number: i32| {
    println!("In WASM, we got {number}");
});
let instability = Global::new(
    &mut store,
    GlobalType::new(ValType::I32, Mutability::Const),
    (-5i32).into(),
)?;
let instance = Instance::new(
    &mut store,
    &module,
    &[log_number.into(), instability.into()],
)?;
let wasm_mul = instance.get_typed_func::<(u32, u32), u32>(&mut store, "wasm_mul")?;
println!("Calculating 5 x 8 with instability -5 ...");
let prod = wasm_mul.call(&mut store, (5, 8))?;
println!("From outside, we got {prod}");

可以看到,我们也是创建了log_numberinstability这两个用于传递的对象,随后在创建Instance实例的过程中传入,即可将Rust中的函数与数据传入WASM。

在Web中

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>WASM Test</title>
  </head>

  <body>
    <script>
      const importObject = {
        outer: {
            log_number: (number) => console.log(`In WASM, we got ${number}`),
            instability: new WebAssembly.Global({ value: "i32", mutable: false }, -5)
        }
      };
      WebAssembly.instantiateStreaming(fetch("./common.wasm"), importObject)
        .then(obj => {
            console.log('Calculating 5 x 8 with instability -5 ...');
            const prod = obj.instance.exports.wasm_mul(5, 8);
            console.log(`From outside, we got ${prod}`);
        });
    </script>
  </body>
</html>

在Web中使用WASM的导入和导出,只需要在instantiateSteaming函数中提供第二个参数importObject即可。这个参数的创建也很清楚,这里不再赘述。

值得一提的是,如果我们想使用ES Module的方案,也就是目前利用webpack来做import { wasm_mul } from './common.wasm',则并不是那么容易,需要修改WASM中的代码,具体可以看ES Module Integration Proposal的示例

类型转换

上述讲了若干种高级编程语言与WASM程序通过导入与导出来交互的例子,有一个问题我们需要注意:类型转换。无论在Rust还是JavaScript中,我们都有若干种类型,例如Rust的boolusize,抑或是JavaScript的number等等,以及各种用户自定义类型。而WASM中,我们只有i32i64f32f64这几个基本的数字类型,那我们在调用接口的过程中,究竟是怎样做类型转换的呢?

在本章中,我们将集中关注高级编程语言的基本数字类型与WASM的数字类型的转换,而对于自定义类型、结构体、数组等,我们将在之后关注。

对于C/C++和Rust而言,目前遵循的是BasicCABI,其中值得注意的是,将所有小于8字节的整型(例如boolu8u16u32)均转化为WASM中的i32,而8字节整型(Rust中的u64,C中的unsigned long long)转化为WASM中的i64

而WASM类型与JavaScript中类型的互相转化则遵循ToJSValueToWebAssemblyValue

内存与引用

在上一章的最后,我们提到,我们暂时将注意力集中在高级语言的基本数字类型与WASM的数字类型之间的转化,而在本章中,我们就来谈谈对于高级的复杂聚合类型,WASM的处理方法是什么。不仅如此,我们还要谈一谈,无论在高级语言还是汇编语言中,都非常常见的概念——「内存」与「引用」。

为什么要引入内存

首先我们需要解决的问题是,为什么WASM中需要内存这个概念?在高级语言或者汇编语言中的内存提供的功能中,有哪些是WASM目前没有内存这一概念就做不了的事。

第一,在高级语言中,当我们需要临时存储一个值的时候,往往会使用局部变量,而局部变量往往会以内存的形式存在,更具体地说,是使用内存的「栈区」这一概念。但是在WASM中,我们不仅有局部变量、全局变量这种机制,而且通过模拟栈机的peek和poke,也可以实现临时存储值这一功能。因此这一功能并不需要内存就可以实现。

内存除了栈区,还有堆区。在C语言中,我们往往使用mallocfree等来操作堆区的内存,而在Rust中,Box等指针往往也会使用堆区的内存。在高级语言中,堆区往往有两个作用:

  • 用来分配动态大小的内存

    对于在运行时才能知道大小的数组等大小是动态的对象,我们往往将其分配在堆区,以提高程序的优化程度。在这种情况下,WASM不用内存确实很难做到。

  • 用来分配所有权不定的对象

    对于所有权难以确定的对象,高级语言往往有很多种处理方法。而将其放在堆区,也是一个很常用的手段。尽管有不放在堆区的方法,但是WASM大部分情况下还是由高级语言编译而来,所以难以避免这种情形。

内存除了这些用途之外,还有一个特性离不开内存,那就是「引用」。高级语言中的引用,在汇编层面,往往会编译为一个存有内存地址的指针。在常见的C ABI中,如果函数的返回值类型是结构体,往往也会要求底层实现是以指针的形式实现。而在WASM中,尽管有局部变量、栈机机制,但是如果没有内存,就没有办法实现指针、引用这一机制。

WASM内存的定义与使用

在WASM中,定义一个内存十分简单:

(module
    (memory 1)
)

这里的1表示WASM的内存至少有1页大小(WASM中定义一页为64KB)。

那我们该如何使用WASM的内存呢?我们可以用一句话粗略地理解:WASM中的内存是用数组模拟的内存。我们通过(memory 1)申请的,至少有64KB大小的内存,实际上可以看做一个数组[u8; 1 << 16],也就是一个长度为64KB的字节数组。

这意味着什么事呢?我们一般意义上的「内存地址」,实际上变成了这个数组的索引值。因此,也就是取值范围为0到64K的i32类型的数(目前是i32类型,Memory64提案将引入i64类型的数也作为索引类型)。

理解了这一点,我们就可以轻松理解WASM中的内存使用方法了:

i32.const 0
i32.load

i32.load指令接收一个操作数,也就是第一句i32.const 0。这条指令执行的结果,就是在当前模块的内存中,取地址为0的i32类型的数。

类似地:

i32.const 0
i32.const 323
i32.store

这些指令的结果是,将i32类型的数323存储到当前模块内存中地址为0的位置。

关于内存读写指令,还有两点需要注意的:

第一,WASM提供了offset机制,能够更方便地做内存读写:

i32.const 0
i32.load offset=4

这些指令的结果是,从内存地址0开始,偏移值为4(也就是内存地址为4)处读取i32类型的值。

WASM提供这种机制的原因是,在很多情况下,内存值是动态确定的(例如某个结构体的地址),但是偏移值是确定的(例如读取这个结构体的某个字段)。这样可以减少内存地址的计算。

第二,内存中的值,最小的单位是1字节,而WASM中指令操作的单位是i32或者i64。因此,WASM在读取内存时,提供了诸如i32.load8_si32.load16_ui32.store8等操作,分别对应读取、写入的比特数(读取时还应考虑有无符号扩展)。熟悉汇编指令的开发者对这个一定了如指掌,这里不再赘述。

在有了这些概念以及指令之后,我们之前提到的,在WASM中使用内存的必要性的两个问题就得到了解决。

通过内存与外界交互

在拥有了内存之后,我们终于能够解决之前提出的,怎样在高级语言与WASM之间,传递复杂聚合对象的问题了。而解决这个问题的方法,就是通过内存的导入与导出。

与函数、全局变量类似,内存也可以导入与导出。我们可以写

(module
    (memory (export "memory") 1)
)

来声明我们当前模块导出至外界一个大小至少为64KB的内存。而外界也可以通过与上一章中类似的方法,获取内存。以wasmer为例,我们可以通过

let memory = instance.exports.get_memory("memory")?;
let memory_view = memory.view(&store);

来获取导出的内存,然后对memory_view进行读写。这里需要注意的一点是,memory_view需要获取对store的共享引用,因此在每一次我们调用WASM导出的函数时,会对store进行独占引用,因此在调用后,我们需要重新使用.view函数来获取memory_view。这么做的原因是,在WASM执行的过程中,可能会对内存进行扩容等操作,而这些操作可能会让内存地址产生改变,从而需要重新获取内存。

我们在高级语言中可以对WASM的内存进行读写,这有什么好处呢?我们知道,之前之所以我们没有办法将高级语言中的复杂聚合类型传入WASM的函数,是因为我们缺少相对的表达能力。WASM的函数大部分只接受基本数字类型,我们没法传结构体进去;WASM也不能读写外界的内存,所以我们传数组的首地址进去更是无可奈何。

而通过读写WASM内存,我们就有了一种传递复杂聚合类型的能力。以下面这个例子为例:

我们创建transformer.wat

(module
    (memory (export "memory") 1)
    (func (export "transform") (param $index i32) (param $length i32)
        (local $ch i32)
        loop $main_loop
            (i32.le_u (local.get $length) (i32.const 0))
            if
                return
            end
            (i32.store8
                (local.get $index)
                (i32.add
                    (i32.load8_u (local.get $index))
                    (i32.const 1)
                )
            )
            (local.set $index (i32.add (local.get $index) (i32.const 1)))
            (local.set $length (i32.sub (local.get $length) (i32.const 1)))
            br $main_loop
        end
    )
)

然后我们在Rust中,仍然使用wasmer,在实例化WASM之后,使用

let transform: TypedFunction<(u32, u32), ()> = instance
    .exports
    .get_typed_function(&mut store, "transform")?;
let memory = instance.exports.get_memory("memory")?;

// Prepare source
let source: [u8; 6] = [1, 1, 4, 5, 1, 4];
let memory_view = memory.view(&store);
memory_view.write(0, &source)?;

transform.call(&mut store, 0, 6)?;

// Retrieve transformed source
let memory_view = memory.view(&store);
let mut transformed_source = [0; 6];
memory_view.read(0, &mut transformed_source)?;

println!("Transformed source is {transformed_source:?}");

下面我们来解释这个例子的功能。

  1. 我们在WASM中,导出了WASM的内存,同时提供了一个函数transform。这个函数接受两个参数:WASM内存中的地址,以及相应对象的长度。
  2. 我们在Rust中,创建了一个长度为6字节数组,其内容为1, 1, 4, 5, 1, 4。将其写入内存地址0后,调用transform函数时,第一个参数就传入的是0,也就是这个数组在WASM内存中的首地址,而第二个参数传入的是6,也就是这个数组的长度。
  3. transform函数中,我们遍历WASM中,从首地址0开始,长度为6的每个字节,将其加1后写回
  4. 在Rust中,调用WASM导出的transform之后,我们再次读取内存中相应的片段,并输出,可以发现值变成了2, 2, 5, 6, 2, 5。

这就是利用读写WASM内存,在高级语言与WASM之间传递复杂聚合类型的一种途径。

沙盒

为什么传递复杂聚合类型要这么麻烦?我们可以注意到,如果需要传递复杂聚合类型,我们需要两次整段内存的复制!一次从嵌入环境复制到WASM中,一次是从WASM中复制回嵌入环境中。

如果经常在各种讨论版中关注WASM的开发者一定会注意到,往往会有一些WASM与原生JavaScript性能的比较,有些情况WASM会偏慢,然后就会有人评论说,你这测试方法不标准,偏慢的时间应该是在传递数据,而不是在计算。并且也会有很多专业的架构师,正是考虑到传递数据时偏慢,才会三思要不要使用WASM。

事实上,这么做的原因,是WASM的一个招牌特性——沙盒(Sandbox)。我们其实可以注意到,如果外界不向WASM导入内存读写函数,那么,WASM永远无法读写除了自身那一段内存以外的内存。也就是说,WASM的程序是跑在一个沙盒内的,绝对不会影响宿主环境的内存。

字符串

我们之前一直没有在WASM中真正处理过字符串,但是字符串确实是一个非常常见的编程元素。在大部分高级编程语言中,字符串在底层的实现都是数组。而我们了解了WASM的内存概念,是不是终于可以处理字符串了呢!

事实上,WASM特地为字符串常量设计了一个非常方便的语法:数据段(Data segment)。

(module (memory 1) (data (i32.const 0) "Hello") )

这一段代码的意思是,在WASM的内存中,地址为0开始,定义一串字符串"Hello"。当我们实例化这个WASM模块时,它的内存从0开始就会有这一串字符串。

有了这个工具,我们终于可以随心所欲在WASM中输出文本了。

我们编写string_in_wasm.wat

(module
    (import "outer" "memory" (memory 1))
    (import "outer" "log" (func $log (param i32 i32)))

    (data (i32.const 0) "关注希月萌奈喵")

    (func (export "output_inside_string")
        (call $log (i32.const 0) (i32.const 21))
    )
)

以及index.html(代码改编自WebAssembly Memory):

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>WASM Test</title>
  </head>

  <body>
    <script>
      const memory = new WebAssembly.Memory({ initial: 1 });
      const importObject = {
        outer: {
            log: (index, length) => {
                const bytes = new Uint8Array(memory.buffer, index, length);
                const string = new TextDecoder("utf8").decode(bytes);
                console.log(string);
            },
            memory: memory
        }
      };
      WebAssembly.instantiateStreaming(fetch("./string_in_wasm.wasm"), importObject)
        .then(obj => {
            obj.instance.exports.output_inside_string();
        });
    </script>
  </body>
</html>

这段代码是什么意思呢?

在WASM中,我们定义了内存中的一个字符串“关注希月萌奈喵”(这串中文字符串的长度为21字节)。随后,我们需要导入一个log函数。这个函数接受两个i32类型的参数,其实现位于我们的JavaScript代码中。从代码中我们可以了解到,这段代码可以从WASM的内存中,index开始,读取length长度个字节,然后将其解码为UTF8字符串,然后输出。因此,我们在WASM中调用这段代码,并传入参数0和21。当我们测试这段程序时,我们可以发现,在控制台上,真的输出了:

「关注希月萌奈喵」

这串字符串。

值得注意的是,这里我们采用JavaScript作为示例代码,是因为Rust中会有一些麻烦。敏感的开发者一定已经注意到了,在JavaScript代码中,memory这个变量,如果转成Rust会有些麻烦。因为这个变量在作为导入函数传给store时,所有权已经给store了,但是log还引用了这个变量,十分难搞。在wasmer中,我们需要使用FunctionEnv等方法来处理这件事,具体可以看官方文档WasmerEnv is removed in favor of FunctionEnv

函数指针

几乎所有的和内存相关的问题都解决了,但是,「函数指针」怎么解决???

我们知道,在C、Rust等高级语言中,一定会存在「函数指针」。也就是说,我们要调用的函数具体是哪个,需要在运行时决定。在底层实现中,往往是由「间接调用」来解决,也就是将目标函数的地址存储在寄存器中,跳转时读取寄存器的值作为调用目标。

但是在WASM中,对函数的调用指令call的操作数不是地址,而是一个编号,也就是例如我们之前的call $log,这里$log并不是一个真正的数字类型,它只是一个函数编号而已。第二个问题,我们存储在WASM内存中的值,只能是数字类型,因此内存中也没法存储函数的地址。这咋办捏?

WASM引入了table机制(代码改编自WebAssembly Tables):

(module
    (table 2 funcref)
    (elem (i32.const 0) $home $birth)
    (func $home (result i32)
        i32.const 323
    )
    (func $birth (result i32)
        i32.const 1219
    )
    (type $func_t (func (result i32))
    (func $call_by_index (param $func_index $i32) (result i32)
        (call_indirect (local.get $func_index) (type $func_t))
    )
)

这段代码实现了啥功能呢?用C语言来看,可以粗略看成:

int home(void) { return 323; }
int birth(void) { return 1219; }

typedef int(*func_t)(void);
func_t my_func_table[2] = { home, birth };

int call_by_index(int func_index) {
    return (my_func_table[func_index])();
}

WASM的table语句,类似之前的memory,定义了一个元素个数为2的表。随后,我们通过elem语句,类似之前的data语句,声明了这个表的内容,其两个元素分别是我们定义的$home$birth

在调用时,我们需要专门使用call_indirect指令。这个指令的第一个参数就是目标函数在这个表中的位置,第二个参数,则是目标函数的类型。

通过这种方式,WASM可以有效避免一般的控制流劫持技术,也就是攻击者想将函数跳转的地址指向攻击者自己编写的函数。而这里,call_indirect指令,必须跳转到事先已经写在table中的函数,并且类型还必须一致。

WASI

之前我们提到,要想解决WASM程序与外界交互的问题,方法之一就是使用导入与导出。有没有方法之二呢?答案是「YES AND NO」。在这章中我们要讨论的WASI(The WebAssembly System Interface),可以看做方法之二,而它实际上也是在导入与导出基础之上的。

WASI的想法非常简单,我们在WASM中使用import对外界请求导入函数时,有些功能往往被非常多WASM程序请求,例如读文件、写文件等等。那WASI就是把这些功能抽象出来,既可以理解成操作系统提供的系统调用接口,也可以理解成libc的统一接口,总之是一个抽象的接口。

WASI定义的是一个接口标准,而各个嵌入环境的实现可以选择性地实现接口。例如,Web环境肯定是不允许直接读写文件的,因此Web环境可以选择不实现对文件读写的接口。但总之,通过WASI接口,我们的WASM程序就可以实现更多的可移植性了。

值得一提的是,WASI目前还是一个很新的技术,因此目前的标准、规定也不是稳定的。所以在这一章中,我们主要讨论的是WASI的基本原理,而不会详细阐述WASI的API。

使用WASI的WASM程序

首先我们看看使用WASI的WASM程序长什么样:

(module
    ;; Import the required fd_write WASI function which will write the given io vectors to stdout
    ;; The function signature for fd_write is:
    ;; (File Descriptor, *iovs, iovs_len, nwritten) -> Returns number of bytes written
    (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))

    (memory 1)
    (export "memory" (memory 0))

    ;; Write 'hello world\n' to memory at an offset of 8 bytes
    ;; Note the trailing newline which is required for the text to appear
    (data (i32.const 8) "hello world\n")

    (func $main (export "_start")
        ;; Creating a new io vector within linear memory
        (i32.store (i32.const 0) (i32.const 8))  ;; iov.iov_base - This is a pointer to the start of the 'hello world\n' string
        (i32.store (i32.const 4) (i32.const 12))  ;; iov.iov_len - The length of the 'hello world\n' string

        (call $fd_write
            (i32.const 1) ;; file_descriptor - 1 for stdout
            (i32.const 0) ;; *iovs - The pointer to the iov array, which is stored at memory location 0
            (i32.const 1) ;; iovs_len - We're printing 1 string stored in an iov - so one.
            (i32.const 20) ;; nwritten - A place in memory to store the number of bytes written
        )
        drop ;; Discard the number of bytes written from the top of the stack
    )
)

这熟悉的感觉,这不就是我们最开始Hello world那一章的例子嘛!在经历了这么多之后,我们再来看这段代码,是不是清晰了很多呢。

没错,这就是一个简单的输出"Hello world\n"的程序嘛。唯一值得注意的,就是import中的"wasi_unstable"模块,以及将$main函数导出为_start这两点。

我们之前在讲导入导出时提过,import后的第一个字符串是模块名,而我们使用Rust/JavaScript向其导入函数时,并没有什么特殊的操作,只是把这个模块名当作一个key来用。而在这里,"wasi_unstable"就是指WASI这个模块名。当我们的模块名是这个(或者"wasi_snapshot_preview1",目前并没有什么区别)时,就代表我们想引入的是WASI规定的接口,这里就是"fd_write`这个函数。

那么,将$main函数导出为_start这个符号又是为什么呢?这目前遵循的是C ABI的入口点标准以及WASI Application ABI,也就是说,当我们的程序作为独立的程序在操作系统中执行时,程序的入口点目前的符号就是_start

WASI程序的使用

那么,我们该如何使用WASI程序呢?首先值得指出,遵循WASI接口的WASM程序,它在二进制层面,和普通的WASM程序没有任何区别,它就是个WASM程序。因此,我们还是可以通过wat2wasm将文本格式转变为二进制格式。

作为独立程序使用

当我们编写的WASI程序是一个独立程序时(例如上面的hello world程序),在Hello world一章中我们提到,可以直接

wasmer run standalone.wasm

使用wasmer或者wasmtime直接运行。

作为库使用

从某种意义上来说,将WASI程序作为独立程序使用,就是一种将其作为库使用的特殊情形。因此,我们来讨论一下,将WASI程序作为库使用是怎样的。

我们之前提到,WASI本身只是一个接口,还需要嵌入环境的实现。因此,当我们执行WASI程序时,是需要给出其实现的:

  • 当我们在Web上使用WASI程序时,需要使用WASI polyfill
  • 当我们使用wasmer引擎运行WASI程序时,需要使用wasmer-wasi
  • 当我们使用wasmtime引擎运行WASI程序时,需要使用wasmtime-wasi

目前来看,我们使用的逻辑就是:

各个环境实现WASI --> 在执行的时候,由引擎将实现导入 --> 执行WASI程序

WASI程序的生成

当我们使用C/C++或Rust生成WASI程序时,如果还需要手动引入WASI头文件,然后手动调用WASI提供的接口,未免有些麻烦了。事实上,对于C/C++而言,我们有wasi-libc,而Rust的libc也有wasi版本

简单来说,就是我们使用WASI定义的接口,实现了大部分libc的函数。那么我们基于libc写的C、Rust函数,就可以无缝生成wasi版本了。

Component Model

不考虑我们手写WASM程序的情形,那么我们遵循WASI接口的WASM程序,从生成到使用,其步骤是

  1. 各平台实现基于WASI接口的libc
  2. 正常使用高级语言编写基于libc接口的程序
  3. 编译器基于WASI接口的libc,生成遵循WASI接口的WASM程序
  4. 针对不同的执行引擎,引入相应的WASI实现
  5. 执行引擎将WASI实现导入WASM模块
  6. 执行引擎执行WASM程序

我们会发现,在生成WASI程序的时候,看上去还不错,耦合性并不高,基于WASI接口的libc根据平台实现,不同编程语言的libc接口由编程语言实现,WASM程序的生成由编译器实现。但是到了执行WASI程序的时候,耦合性就上来了。每个执行引擎都有WASI的实现,但这似乎毫无必要,因为WASI的实现应该与执行引擎无关才对。

因此,目前WASM社区正在摸索一条基于「Component Model」的道路。这个模型不仅适用于WASI,实际上也适用于大部分的导入导出情形。简单而言,就是说我们在执行WASM程序的时候,可以粗略地看做三部分:执行引擎、WASM程序、其他语言编写的导入函数。执行引擎通过将其他语言编写的导入函数导入到WASM程序之中,就可以执行WASM程序。这样的话,就能巧妙地解决我们上述提到的,WASI的实现耦合性较高的问题了。