WebAssembly Spec 浅析和跨语言实践

WebAssembly Spec 浅析和跨语言实践

本文主要聚焦于 WebAssembly 的核心规范(Core Spec)部分。WASI 和 Embedder Spec 等其他部分并非本文的重点,感兴趣的读者可自行查阅相关资料。

目的 #

随着 Web 技术的普及,越来越多的应用场景(如游戏、音视频处理、AI 等)需要在浏览器中运行。这些场景通常涉及 CPU 密集型任务,而现有 Web 引擎在处理此类任务时,性能仍不及原生语言。此外,C/C++ 等语言已积累了大量成熟的库,为了高效复用这些库以扩展 Web 的能力,急需一种新方式,使 C++/Rust 等语言也能在浏览器环境中运行。

为解决上述问题,W3C 提出了 WebAssembly 规范。该规范设计了一种全新的、与机器无关的汇编指令集、运行时(可理解为虚拟机)以及内存模型等。

WebAssembly 规范发布后,各大浏览器厂商迅速跟进支持,使其成为一种跨平台的二进制格式,能够在不同操作系统和硬件平台上运行。WASM 的应用范围也因此不再局限于 Web 场景,而是扩展到移动端、服务器端等领域。在云原生领域,Envoy、Kong 和 Apisix 等项目已支持 WASM 作为其扩展插件。WasmEdge 更进一步,直接提出将 WASM 应用于边缘计算场景,例如无服务器应用和函数即服务等。

核心概念 #

在最新规范中,WebAssembly 定义了以下核心概念:

概念 解释说明
Values 提供四种基础数值类型:32位和64位的整型及浮点型。32位整型可用于表示布尔值或内存地址。另有128位扩展整型用于高精度计算。
Instructions 基于栈式虚拟机执行的指令,分为简单指令和控制指令两类。
Traps 类似异常机制,当发生非法操作(如越界访问)时立即中止执行并报告宿主环境。功能类似于Go/Rust中的panic。
Functions 与其他编程语言一致,用于组织特定功能的代码,接收参数并返回结果。
Tables 数据结构上是一个数组,用于存储特定类型(funcrefexternref),可通过索引模拟函数指针。
Linear Memory 一段可动态增长的连续字节数组,程序可存储和加载其中任意位置的数据,越界访问会触发Trap。
Modules 包含类型、函数、表、内存和全局变量的定义,作为部署、加载和编译的基本单位。可声明导入导出项,并支持定义自动执行的启动函数。
Embedder 指将WebAssembly程序嵌入宿主环境的实现方式,如wasmtime或Web环境中的WebAssembly运行时。

举例分析 #

接下来,我们将结合一个简单的 WASM 程序来辅助理解 WebAssembly 规范的内容。这里使用 Rust 语言编写一个简单的加法程序,并将其编译为 WASM 模块:

#![no_std]

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

#[unsafe(no_mangle)]
pub extern "C" fn subtract(left: u32, right: u32) -> u32 {
    left - right
}

通过 wasmtime 这样的工具可以方便的调用 wasm 模块,如下:

>>> wasmtime --invoke add ./wasm_demo.wasm 1 2
3

编译生成的 wasm 可以使用 wasm2wat 将二进制格式转为 WAT 文本格式,方便查看和分析。 wasm2wat wasm_demo.wasm -o wasm_demo.wat

(module $wasm_demo.wasm
  (type (;0;) (func (param i64 i64) (result i64)))
  (type (;1;) (func (param i32 i32) (result i32)))
  (func $add (type 0) (param i64 i64) (result i64)
    local.get 1
    local.get 0
    i64.add)
  (func $subtract (type 1) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.sub)
  (memory (;0;) 16)
  (global $__stack_pointer (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "add" (func $add))
  (export "subtract" (func $subtract))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

整体结构 #

WAT 文件以 (module …) 开头,它定义了一个 WebAssembly 模块。模块是 WebAssembly 的基本封装单元,包含类型、函数、内存、全局变量等元素。

(module $wasm_demo.wasm
  ...
)

$wasm_demo.wasm 是模块的可选名称,用于在调试或引用时标识该模块。

类型定义 #

(type (;0;) (func (param i64 i64) (result i64)))

(type ...) 用于定义函数类型。
(;0;) 是类型的索引,这里表示该类型的索引为 0。
(func (param i64 i64) (result i64)) 定义了一个函数类型,该函数接受两个 64 位整数(i64)作为参数,并返回一个 64 位整数。

可以看到这里有两个函数类型定义,但是如果 addsubtract 的函数签名改成一致的,这里就可以只定义一个函数类型。

函数定义 #

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

(func ...) 用于定义函数。
$add 是函数的名称,用于在其他地方引用该函数。
(type 0) 表示该函数的类型为索引为 0 的类型。
(param i64 i64) 定义了两个 64 位整数参数。
(result i64) 表示函数返回一个 64 位整数。
函数体由指令序列组成,这里使用了 local.geti64.add 指令。

内存定义 #

(memory (;0;) 16)

(memory ...) 用于定义内存。
(;0;) 是内存的索引,这里表示该内存的索引为 0。
16 表示内存的初始页数为 16 页,每页大小为 64KB, 所以初始内存大小为 16 * 64KB = 1024KB。

全局变量定义 #

(global $__stack_pointer (mut i32) (i32.const 1048576))

(global ...) 用于定义全局变量。
$__stack_pointer 是全局变量的名称。
(mut i32) 表示该全局变量为可变的 32 位整数类型。
(i32.const 1048576) 表示初始值为 1048576 的 32 位整数。

导出定义 #

(export "memory" (memory 0))
(export "add" (func $add))
(export "subtract" (func $subtract))
(export "__data_end" (global 1))
(export "__heap_base" (global 2))

(export ...) 用于定义导出项。
"memory" 是导出项的名称,用于在其他地方引用该内存。
(memory 0) 表示导出索引为 0 的内存区域。

至于函数和全局变量的导出,也是类似的定义方式,这里就不一一列出了。

小结 #

WebAssembly,顾名思义,是一种汇编语言,它定义了非常底层的指令。但与传统汇编不同的是,其指令不依赖于具体的 CPU(宿主环境)。同时,它不仅是语言规范,还提供了一些与宿主环境(尤其是 Web 和 JavaScript 宿主环境)交互的约定。

目前,WebAssembly 还在不断发展,例如 WASI (WebAssembly System Interface) 和 WASM Component Model 等更丰富的特性也已相继推出。

通过从打包的 Module 出发,转换为 WAT 格式并逐个理解,即使没有列举全部内容,也足以对 WebAssembly 形成充分的认识。

线性内存 #

通过前面的示例,我们得以窥见 WebAssembly 的大致结构和概念。至于其详细指令,作为使用者我们通常无需深究,我们更关注如何编写 WASM 模块、如何在不同语言的宿主环境中运行,以及如何在宿主环境与 WASM 模块之间进行数据传输。

由于 WebAssembly 没有提供复杂的数据类型,当宿主环境和 WASM 模块需要传输复杂数据时,我们就需要利用 WASM 提供的线性内存进行数据交互。

复杂数据结构包括:数组、字符串、结构体、联合体、枚举等。

这里我们将以最简单的字符串为例,演示宿主环境和 WASM 模块如何通过线性内存进行字符串交互。

需求 #

假设我们使用 Rust 实现了一个为 JSON 字符串动态添加字段的 WASM 模块,现在想要在 python 和 javascript 中调用该功能。

Rust 到 WASM #

在 Rust 中,我们需要实现一个 allocate 函数,用于在 WASM 内存中分配一段空间。同时,我们还需要实现一个 deallocate 函数,用于释放之前分配的内存,并将这两个函数导出,以便在其他语言中调用。

至于怎么为 JSON 字符串动态添加字段,我们可以使用 serde_json 库来进行 JSON 字符串的解析和序列化。

#[no_mangle]
pub extern "C" fn allocate(size: usize) -> *mut u8 {
    let mut buffer = Vec::with_capacity(size);
    let ptr = buffer.as_mut_ptr();
    core::mem::forget(buffer); // 阻止 Vec 在这里被 Drop
    ptr
}

#[no_mangle]
pub extern "C" fn deallocate(ptr: *mut u8, capacity: usize) {
    unsafe {
        let _ = Vec::from_raw_parts(ptr, 0, capacity);
    }
}

#[no_mangle]
pub extern "C" fn json_add_field(
    json_ptr: *const u8,
    json_len: usize,
    field_ptr: *const u8,
    field_len: usize,
) -> u64 {
  // 加载 host 传入的字符串,并解析
    let json_bytes = unsafe { slice::from_raw_parts(json_ptr, json_len) };
    let field_bytes = unsafe { slice::from_raw_parts(field_ptr, field_len) };
    let mut json_value: serde_json::Value = match serde_json::from_slice(json_bytes) {
        Ok(v) => v,
        Err(_) => return 0, // 错误处理
    };
    let field_value: serde_json::Value = match serde_json::from_slice(field_bytes) {
        Ok(v) => v,
        Err(_) => return 0, // 错误处理
    };

    // 执行添加字段的逻辑
    if let (Some(obj), Some(field_obj)) = (json_value.as_object_mut(), field_value.as_object()) {
        for (k, v) in field_obj {
            obj.insert(k.clone(), v.clone());
        }
    } else {
        return 0;
    }

    // 将修改后的 JSON 序列化回字符串
    let result_json = match serde_json::to_string(&json_value) {
        Ok(s) => s,
        Err(_) => return 0, // 错误处理
    };

    // 将结果字符串转换为字节向量并获取指针和长度
    let mut result_bytes = result_json.into_bytes();
    let len = result_bytes.len();
    let ptr = result_bytes.as_mut_ptr() as u64;

    // 泄漏内存,以便 Host 可以访问它
    core::mem::forget(result_bytes);

    // 将长度和指针打包成一个 u64 返回
    // 长度放在高 32 位,指针放在低 32 位
    // Q: WASM 支持 multi-value 返回,RUST 如何使用这个特性呢?
    (len as u64) << 32 | ptr
}

在 python 中使用 #

在 python 中,我们可以使用 wasmtime 库来加载和运行 WASM 模块。运行效果如下:

$ python run.py 
Initial JSON: {"name": "Alice", "age": 30}
Adding Fields: {"city": "New York", "occupation": "Engineer"}
Modified JSON: {"age":30,"city":"New York","name":"Alice","occupation":"Engineer"}
store = wasmtime.Store()
module = wasmtime.Module.from_file(store.engine, "wasms/rust.wasm")
instance = wasmtime.Instance(store, module, [])

# 获取 WASM 中的导出
exports = instance.exports(store)
wasm_memory = exports["memory"]
wasm_allocate = exports["allocate"]
wasm_deallocate = exports["deallocate"]
wasm_json_add_field = exports["json_add_field"]

def rust_string_to_python(ptr, length):
    data_bytes = wasm_memory.read(store, ptr, ptr + length)
    return data_bytes.decode('utf-8')

def python_string_to_wasm(s: str) -> tuple[int, int]:
    s_bytes = s.encode('utf-8')
    s_len = len(s_bytes)
    s_ptr = wasm_allocate(store, s_len)
    wasm_memory.write(store, s_bytes, s_ptr)
    return s_ptr, s_len

def process_json_with_wasm(original_json: str, field_to_add: str) -> str:
    json_ptr, json_len = python_string_to_wasm(original_json)
    field_ptr, field_len = python_string_to_wasm(field_to_add)
    # 调用函数
    result_u64 = wasm_json_add_field(store, json_ptr, json_len, field_ptr, field_len)
    # 释放 WASM 中的内存
    wasm_deallocate(store, json_ptr, json_len)
    wasm_deallocate(store, field_ptr, field_len)

    # 解析 WASM 函数返回的指针和长度
    if result_u64 == 0:
        raise ValueError("Wasm function returned an error (0)")

    result_len = result_u64 >> 32
    result_ptr = result_u64 & 0xFFFFFFFF
    result_json_str = rust_string_to_python(result_ptr, result_len)

    # 在 Rust 这部分内存是没有被释放的,需要手动释放
    wasm_deallocate(store, result_ptr, result_len)

    return result_json_str

if __name__ == "__main__":
    initial_json = '{"name": "Alice", "age": 30}'
    new_field = '{"city": "New York", "occupation": "Engineer"}'
    modified_json = process_json_with_wasm(initial_json, new_field)
    print(f"Modified JSON: {modified_json}")

在 NodeJS 中使用 #

在 NodeJS 中已经自带了 WebAssembly 模块,我们可以直接使用。

async function runWasm() {
    // 1. 读取 Wasm 模块的二进制数据
    const wasmPath = path.join(__dirname, 'wasms', 'rust.wasm');
    const wasmBytes = fs.readFileSync(wasmPath);

    // 2. 创建 WebAssembly 内存实例
    // initial: 初始内存页数 (1页 = 64KB)
    // maximum: 最大内存页数 (可选,但推荐设置,防止内存无限增长)
    const memory = new WebAssembly.Memory({ initial: 1, maximum: 16 }); // 1页 = 64KB

    // 3. 自定义定义导入对象 (imports object)
    const importObject = {
        env: {
            memory: memory,
        }
    };

    // 4. 实例化 Wasm 模块
    const wasm = await WebAssembly.instantiate(wasmBytes, importObject);
    const exports = wasm.instance.exports;

    // 5. 获取导出的函数和内存
    const wasmAllocate = exports.allocate;
    const wasmDeallocate = exports.deallocate;
    const wasmJsonAddField = exports.json_add_field;
    const wasmMemory = exports.memory; // 直接获取导出的内存对象

    function writeStringToWasm(jsString) {
        const encoder = new TextEncoder('utf-8');
        const encodedBytes = encoder.encode(jsString);
        const byteLength = encodedBytes.length;
        const ptr = wasmAllocate(byteLength);
        const wasmByteView = new Uint8Array(wasmMemory.buffer);
        wasmByteView.set(encodedBytes, ptr);
        return { ptr, len: byteLength };
    }
    

    function readStringFromWasm(ptr, len) {
        const wasmByteView = new Uint8Array(wasmMemory.buffer, ptr, len);
        const decoder = new TextDecoder('utf-8');
        return decoder.decode(wasmByteView);
    }

    const initialJson = '{"name": "Alice", "age": 30}';
    const newField = '{"city": "New York", "occupation": "Engineer"}';

    console.log(`Initial JSON: ${initialJson}`);
    console.log(`Field to add: ${newField}`);

    try {
        // 写入原始 JSON 字符串到 Wasm 内存
        const { ptr: jsonPtr, len: jsonLen } = writeStringToWasm(initialJson);
        // 写入要添加的字段 JSON 字符串到 Wasm 内存
        const { ptr: fieldPtr, len: fieldLen } = writeStringToWasm(newField);

        // 调用 Wasm 函数
        // Rust 函数返回一个 u64,高 32 位是长度,低 32 位是指针
        const resultU64 = wasmJsonAddField(jsonPtr, jsonLen, fieldPtr, fieldLen);

        // 释放输入字符串的 Wasm 内存
        wasmDeallocate(jsonPtr, jsonLen);
        wasmDeallocate(fieldPtr, fieldLen);

        if (resultU64 === 0) {
            throw new Error("Wasm function returned an error (0)");
        }

        // 从 u64 中解析指针和长度
        const resultLen = Number(resultU64 >> 32n); // 使用 BigInt 操作 u64
        const resultPtr = Number(resultU64 & 0xFFFFFFFFn); // 使用 BigInt 操作 u64

        // 从 Wasm 内存中读取结果字符串
        const modifiedJson = readStringFromWasm(resultPtr, resultLen);
        console.log(`Modified JSON: ${modifiedJson}`);
        // 释放 Wasm 模块分配的内存
        wasmDeallocate(resultPtr, resultLen);

        console.log("Got JSON:", expectedJsonDict);
    } catch (e) {
        console.error(`An error occurred: ${e.message}`);
    }
}

runWasm();

整体交互流程上与 python 大同小异。

小结 #

全部代码可以在 https://github.com/yeqown/wasm-demo 找到。

简而言之,WASM 内存允许开发者自行管理内存,而不依赖于宿主环境的内存管理机制。虽然诸如 wasm-bindgen 这样的工具可以实现 Rust 到 JavaScript 的绑定,但若需支持其他语言,其能力则有所局限。

然而,一旦理解了其底层原理,即使是更高级的数据结构,也能够自行封装实现。

🤣 这仿佛又回到了 C 语言手动内存管理的时代。

参考 #

访问量 访客数