Wasm 的历史发展#

早期(Wasm MVP - 2017)#

  • 诞生背景: Wasm 的设计目标是为了替代 asm.js,提供更小、更快、更安全的 Web 二进制格式。
  • 核心模块的初步定义: MVP(Minimum Viable Product)阶段定义了 Wasm Core Module 的基本结构:函数、内存、表、导入、导出、全局变量等。
  • 主要用例: 游戏引擎、音视频编解码、计算密集型任务等。
  • 限制:
    • 没有模块化系统: 模块之间没有标准的链接机制,只能通过宿主环境(如 JavaScript)进行协调。
    • 缺乏垃圾回收(GC): 需要手动内存管理或使用语言自带的 GC 机制(如 Emscripten 的 mimalloc)。
    • 没有线程: 无法直接利用多核 CPU。
    • 没有宿主 API 标准化: 模块与宿主环境的交互方式高度依赖宿主(如浏览器),没有统一的接口定义。
    • 没有组件模型: 模块重用和组合非常困难。

中期(MVP 之后 - 持续演进)#

Wasm 社区和工作组认识到 MVP 的局限性,并开始着手扩展 Wasm 的能力,这直接影响了 Core Module 的能力:

  • 多值(Multiple Returns & Parameters): 允许函数返回多个值,接收多个参数,提高表达能力。
  • 引用类型(Reference Types): 引入了 externref 和 funcref,允许 Wasm 直接引用宿主对象和函数,而无需通过数字 ID 传递,为未来的 GC 和组件模型打下基础。
  • 固定大小的 SIMD(Fixed-width SIMD): 引入了新的指令集,允许在 Wasm 中进行向量化操作,进一步提升某些计算密集型任务的性能。
  • 线程(Threads): 引入了共享内存和原子操作,允许 Wasm 模块在多线程环境下运行,极大地提升了并行计算能力。
  • 内存增长和限制(Memory Growth and Limits): 提供了更灵活的内存管理机制。
  • Tail Calls(尾调用): 优化了函数调用的性能。

近期和未来(Wasm Component Model)#

这是 Wasm 发展中最重要的方向之一,旨在解决 Core Module 在模块化和互操作性方面的根本性问题:

  • Wasm Component Model 的提出:
    • 核心痛点: Wasm Core Modules 是“低级指令集”,它们之间直接通信非常困难。不同的语言编译出的 Wasm 模块可能使用不同的内存布局、字符串编码、错误处理机制等,导致模块复用性差,“乐高积木”式的组合难以实现。
    • 解决方案: Component Model 引入了高层语义。它不是替换 Core Module,而是在 Core Module 之上构建了一个标准化层。一个 Component 是一个或多个 Core Modules 的封装,它定义了明确的、语言无关的接口(WIT - WebAssembly Interface Type)。
    • WIT(WebAssembly Interface Type): 类似于 IDL(Interface Definition Language),用于定义组件的输入和输出类型,包括结构体、枚举、变体、结果类型等,这些类型可以跨语言边界安全地传递。
    • 适配器(Adapters): Component Model 运行时会自动生成适配器代码,处理不同 Core Module 之间的类型转换、内存布局差异等,使得模块开发者无需关心这些底层细节。
    • 虚拟化和实例隔离: 允许组件在不同的虚拟实例中运行,增强了安全性。
  • 发展阶段:
    • Component Model 仍在积极开发中,但已经取得了显著进展,并且有多个实现(如 wasmtime 的 Wasm Component 支持)。
    • 它旨在实现真正的“跨语言模块化”,让用 Rust 编写的 Wasm 组件可以无缝地调用用 Go 编写的 Wasm 组件,反之亦然,而无需通过 JavaScript 或系统调用作为中介。
  • 对 Core Module 的影响: Core Module 仍然是 Wasm 的基本执行单元,但 Component Model 将会是 Wasm 生态的未来,它使得 Core Module 能够被更高效、更安全、更灵活地组合和复用。

Core Module#

Wasm Core Module 是一个独立的、可部署的 WebAssembly 单元,它包含了编译后的 WebAssembly 二进制代码(字节码)以及与这些代码相关的元数据。

009feded3444b9ac0491d74b84572c5e_MD5

Binary Section 组成:

  1. 类型(Type Section): 模块中使用的函数签名的定义。Wasm 只有 4 种具体类型,i32, i64, f32, f64。其它的复杂类型都是构建在这4种基础类型之上的。==Type Section 对于所有模块是必须的。==
(i32 i32 -> i32)  // func_type #0
(i64 -> i64)      // func_type #1
( -> )            // func_type #2
  1. 导入(Imports Section): 列出了模块从外部(宿主环境或其它 Wasm 模块)导入的函数、内存、表或全局变量。宿主机环境解析 Import Section,决定如何将外部函数动态链接到 Wasm。这也是 Wasm 与非 Wasm functions 交互的 FFI。
("dumb-math", "quadruple", (func_type 1))        // func #0
("dumb-math", "pi", (global_type i64 immutable))
  1. 函数(Function Section): function section 为 code section 中定义的每个函数声明索引,其中列表中的位置是函数的索引,值是其类型。有效的函数索引 import section 中的函数类型开始,这意味着模块中可用的有效函数列表是 import section 中 func_type 的个数加上 function section 的条目数。==Function Section 对于所有模块也是必须的。==
(func_type 1)  // func #1
(func_type 1)  // func #2
(func_type 0)  // func #3
  1. 代码(Code Section): 这是模块最重要的部分,包含了一系列 Wasm 函数的二进制指令。这些指令是低级的、栈式的虚拟机指令,执行效率高。
get_local 0  // push parameter #0 on stack (our dividend)
i64.const 2  // push constant int64 "2" on stack (our divisor)
i64.div_u    // unsigned division; pushes result onto stack
end          // ends function, resulting in one i64 (top of stack)

538d8c005c0a8509eb5438f7fd96cc54_MD5

  1. 数据(Data Section): 定义了模块初始化时需要加载到内存中的静态数据。这些数据可以是字符串、常量数组或其他预定义的值。如下的 data section 将使用提供的字节值初始化第 0 个内存中的字节 [4–8],如果将其作为无符号 i32 读取,则会得到数字 “42”。
(data_segment
  0                          // linear memory index
  (init_expr (i32.const 4))  // byte offset at which to place the data
  (data 0x2a 0x0 0x0 0x0))
  1. 内存(Memory Section): 声明模块所需的线性内存(通常是字节数组)。模块可以通过指令读写这块内存。Wasm 内存是沙盒化的,每个模块通常有自己独立的内存空间,增强了安全性。
  2. 表(Table Section): 声明了模块所需的表。目前 Wasm 表主要用于存储函数引用,管理函数指针,这对于实现高级语言特性(如高阶函数、间接调用)非常重要。
  3. 导出(Exports Section): 列出了模块对外暴露的函数、内存、表或全局变量。外部环境(如 JavaScript)可以通过这些导出接口与 Wasm 模块进行交互。如下面的例子,通过查找 func 1 的类型是 func_type 1,可知导出的 half 函数签名。
("half" (func 1))
// func half(arg int64) int64
  1. 全局变量(Global Section): 定义了模块内部的全局变量,可以是可变或不可变的。
  2. 开始函数(Start Section - Optional): 可以指定一个函数作为模块加载后的自动执行入口点。可以用来使模块成为可执行程序,或用于动态初始化模块的全局变量或内存。
  3. 元素(Element Section): 主要用于初始化模块的表(Tables)

核心理念:

  • 沙盒化(Sandboxing): 每个 Wasm 模块在一个受限的环境中运行,不能直接访问宿主系统的资源,只能通过明确的导入/导出机制进行交互。这极大地提高了安全性。
  • 平台无关性(Platform Independence): Wasm 字节码是平台无关的,可以在任何支持 Wasm 运行时的环境中执行(浏览器、Node.js、服务器、IoT 设备等)。
  • 高性能(High Performance): Wasm 被设计为接近原生代码的执行速度。运行时通常会进行即时编译(JIT)或提前编译(AOT)来优化性能。
  • 确定性(Determinism): Wasm 模块的执行是确定性的,只要输入相同,输出就相同。这对于调试和安全性很重要。
  • 语言无关性(Language Agnostic): 任何能编译成 Wasm 的语言(C/C++, Rust, Go, C#, AssemblyScript 等)都可以生成 Wasm 模块。

内存使用#

  • WebAssembly 的内存是线性内存,通常以页为单位,每页大小为 64KB。Wasm 始终使用小端序做字节存储。
  • 内存可以通过 export 导出到宿主机环境中,供外部代码读取或写入。
export "string_mem" (mem 1)
  1. export:
    • 这是 WebAssembly 的一个指令,用于将模块中的某些资源(例如函数、内存、全局变量等)导出。
    • 导出的资源可以被外部环境(例如 JavaScript)访问和使用。
  2. "string_mem":
    • 这是导出的内存资源的名称。
    • 外部环境(比如 JavaScript)会通过这个名字来引用和使用该内存。
  3. (mem 1):
    • mem 表示内存对象。
    • 1 是内存索引,表示这是模块中的第 2 个内存(索引从 0 开始)。
    • WebAssembly 模块可以包含多个内存对象,每个内存对象都有一个唯一的索引。

访存操作#

内存的 load 和 store 有两个参数,align 和 offset,分别表示对齐边界和基址偏移量。

i32.const 3      // address-operand = 3
i64.const 1234   // value
i64.store16 1 3  // alignment = 1 = 16-bit, offset-immediate = 3
                 // effective-address = 3 + 3 = 6

如上例子,基址是 3,偏移量是 3,所以有效地址是 6, 1234(0x04D2)会放在 [6-7] 上,小端序 D2 放地址低位。2 字节对齐,符合对齐规则。

i64.store32 2 3  // align = 2 = 32-bit, offset = 3  =>  addr = 6

如果把 store 命令对齐边界设置为 32 bit,同时存储一个 32 bit 的数值,那么存储的地址为 [6-9],很明显不符合 32 bit 对齐。只有当有效地址位于承诺的对齐边界内时,load 或 store 指令才被认为是对齐的。 对齐在 Wasm 中不是强制要求,但是有助于 Wasm runtime 进行潜在的性能优化。如果声明了对齐,但实际访存操作没有对齐,可能会导致性能下降。

Component Model#

Wasm Component Model 的核心概念围绕着如何让不同的 Wasm 模块(或“组件”)能够以类型安全、跨语言、隔离的方式相互通信和协作。其核心目标是:

  1. 添加一致的高级类型表示(Canonical ABI)
  2. 接口驱动的开发模式(WIT)
  3. 使 WebAssembly Core Modules 可组合(Component):提供功能的组件和使用它们的组件可以组合成另外一个组件

以下是一些关键概念:

  1. 组件 (Component):
    • 定义: 组件是 Wasm 模块的升级版。它不仅仅是裸的 WebAssembly 代码,还包含清晰定义的接口,描述了它提供的功能(导出 export)和它所需的外部功能(导入 import)。
    • 抽象: 组件隐藏了其内部实现细节,只暴露其接口。这意味着你可以用任何支持 Wasm 的语言(Rust, C, Go, JS, Python 等)实现一个组件,只要它符合接口定义,其他组件就能使用它。
    • 可组合性: 组件是可组合的基本单元。你可以将多个组件连接起来形成更复杂的应用程序。
  2. 接口 (Interface / WIT):
    • 定义: 接口是使用一种名为 WIT (WebAssembly Interface Type) 的语言定义的。WIT 是一个与语言无关的接口描述语言,类似于 Protocol Buffers 或 OpenAPI Specification,但专门为 Wasm 设计。
    • 类型安全: WIT 定义了数据结构、函数签名、枚举等,确保组件之间的通信是类型安全的。这极大地减少了运行时错误。
    • 跨语言互操作: 任何语言,只要实现了 WIT 到其原生类型的映射,就可以实现或使用一个 WIT 接口。这是实现跨语言互操作的关键。
    • 能力驱动: 接口不仅定义了数据和函数,还可以定义组件所需或提供的“能力”(如文件系统访问、网络请求等),这有助于实现基于能力的安全模型。
  3. 适配器 (Adapter):
    • 定义: 适配器是连接不同 Wasm ABIs(应用二进制接口)或类型表示的关键。当不同的语言或工具链生成 Wasm 时,它们可能使用不同的底层内存布局或调用约定。
    • 作用: 适配器在组件的入口和出口处进行数据转换和函数签名适配,确保即使底层实现不同,组件之间也能无缝通信。
    • 自动化生成: 适配器通常由 Wasm Component Model 工具链自动生成,开发者无需手动编写。
  4. 世界 (World):
    • 定义: “世界”是 WIT 中的一个高级概念,它代表了一个特定应用程序或上下文所需的完整接口集合。
    • 作用: 它定义了一个组件在特定环境中需要提供的功能和所需的功能。例如,一个“命令行工具世界”可能定义了访问标准输入/输出、环境变量和文件系统的接口。一个“浏览器插件世界”可能定义了与 DOM 交互的接口。
    • 契约: 世界可以看作是组件与其运行环境之间的契约。一个组件声明它实现了某个世界,就意味着它遵循了该世界定义的所有导入和导出要求。
  5. Lift & Lower (提升与降低):
    • 定义: 这是 Wasm Component Model 在幕后进行类型转换的过程。
    • Lower: 将高级语言(如 Rust struct)的数据结构“降低”为 WebAssembly 内存中的原始字节序列,以便在组件之间传递。
    • Lift: 将 WebAssembly 内存中的原始字节序列“提升”回高级语言(如 JavaScript 对象)的数据结构。
    • 自动化: 这些操作通常是工具链自动完成的,开发者无需关心底层细节。
  6. 包 (Package):
    • 定义: 包是组件、接口、世界和其他相关资源的集合,可以进行分发和版本管理。
    • 可发现性: 包注册表(如 wasm.io)将允许开发者发布和发现 Wasm 组件包。
  7. 平台(Platform):
    • 定义:

一个 WIT 定义的例子:

package docs:adder@0.1.0;

interface add {
    add: func(x: u32, y: u32) -> u32;
}

world adder {
    include wasi:cli/imports@0.2.0;
    export add;
}
  • package
    • package: 这关键字表示定义一个 WIT 包。
    • docs:adder: 这是包的名称。遵循一种常见的命名约定,即 registry_prefix:package_name。在这里,docs 可以理解为一个虚构的组织或领域,adder 是这个包的具体名称。
    • @0.1.0: 这是包的版本号,遵循语义化版本(Semantic Versioning)规范。
  • interface
    • interface add: 这关键字开始定义一个名为 add 的接口。接口是 WIT 中定义一组相关函数和数据类型的方式。它可以被其他接口或世界导入或导出。
    • add: func(x: u32, y: u32) -> u32;:
      • add: 这是接口内部定义的一个函数的名称。这个函数与接口同名,但实际上这只是一个巧合,它们可以不同名。
      • func: 表示这是一个函数定义。
      • (x: u32, y: u32): 这是函数的参数列表。
        • x 和 y 是参数名,用于在描述中清晰指代。
        • u32 是参数的类型,表示一个无符号32位整数。WIT 支持多种基本类型,如 u8u16u32u64s8s16s32s64float32float64charboolstring, 以及复合类型(如 recordvariantlistoptionresult 等)。
      • -> u32: 这是函数的返回类型。表示函数将返回一个无符号32位整数。
      • ;: 语句结束符。
  • world
    • world adder: 这关键字开始定义一个名为 adder 的“世界”。“世界”是 WIT 中最高级别的概念,它代表了一个特定组件或应用程序的完整接口需求和提供能力。它描述了组件与其运行环境之间的契约。
    • include wasi:cli/imports@0.2.0;:
      • include: 这个关键字用于将另一个包或接口的定义导入到当前世界中。
      • wasi:cli/imports@0.2.0: 这是指定要导入的外部包。
        • wasi:cli/imports: 是 WASI (WebAssembly System Interface) 标准中的一个特定部分,它定义了命令行接口(CLI)应用程序所需的功能。
        • @0.2.0: 指定导入该接口的 0.2.0 版本。
      • 作用: 这行表示,实现 adder 世界的组件需要能够访问 WASI CLI 定义的导入功能。这通常包括能够解析命令行参数、访问标准输入/输出流、环境变量等,使得这个 Wasm 组件可以像一个标准的命令行程序一样运行。它导入了宿主环境提供给它的能力。
    • export add;:
      • export: 这个关键字用于指定当前世界(即实现它的组件)将向其宿主环境或调用者提供哪些功能。
      • add: 这里引用的是上面定义的 add 接口。
      • 作用: 这行表示,实现 adder 世界的 Wasm 组件将导出 add 接口中定义的 add 函数。这意味着外部代码(比如 Wasm runtime 或其他 Wasm 组件)可以通过这个接口调用 adder 组件的加法功能。

Canonical ABI#

Wasm 的 Canonical ABI(Application Binary Interface)是一个关于 WebAssembly 模块如何与外部世界(通常是宿主环境或其它 Wasm 模块)进行高效、类型安全且语言无关的数据交换和函数调用的规范。 它的核心目标是解决 Wasm 生态系统中的互操作性痛点,特别是在 Wasm Component Model 出现之后,显得尤为重要。

为什么需要 Canonical ABI?#

在 Wasm Component Model 出现之前,Core Wasm Module 的 ABI 是非常底层的:

  1. 仅支持基本数值类型: Wasm Core 只支持 i32, i64, f32, f64
  2. 手动内存管理: 传递复杂数据结构(如字符串、数组、结构体)时,需要手动在模块的线性内存中分配空间、拷贝数据,并通过指针(i32)传递给对方。
  3. 语言特定的 ABI: 不同的源语言(C, Rust, Go 等)有自己的 ABI 约定,导致它们编译生成的 Wasm 模块之间直接通信非常困难。例如,C 的字符串是零终止的字节数组,而 Rust 的字符串是utf8编码的Vec<u8>与长度的组合。
  4. 错误处理混乱: 没有统一的错误传播机制。
  5. 资源管理: 缺乏对宿主资源(如文件句柄、网络套接字)的统一抽象和传递机制。 这些问题使得 Wasm 模块在进行复杂交互时,需要大量的手动“胶水代码”,效率低下且容易出错。Canonical ABI 就是为了解决这些问题,提供一个标准的、宿主无关的、多语言通用的数据交换和函数调用约定。

Canonical ABI 的核心概念#

Canonical ABI 主要定义了两件事:

  1. 数据布局(Data Layout): 复杂数据结构(字符串、列表、记录、变体、资源等)如何在 Wasm 线性内存中表示。
  2. 调用约定(Calling Convention): 函数如何接收和返回这些复杂数据结构,以及错误如何传播。

以下是其关键特性:

  1. 丰富的类型系统(Interface Types):
    • 基础类型: 除了 i32, i64, f32, f64,还引入了 bool, char, u8, u16, u32, u64, s8, s16, s32, s64 等整数类型。
    • 复合类型:
      • string UTF-8 编码的不可变字符串。
      • list<T> 任意 Wasm 类型 T 的列表(类似于数组或向量)。
      • record { ... } 结构体,包含命名字段。
      • variant { ... } 枚举类型,包含不同的变体和关联值(类似于 Rust 的 enum 或 Scala 的 sealed trait)。
      • option<T> 可选值,类似于 NullableOption
      • result<Ok, Err> 结果类型,用于表示成功或失败以及相关值。
      • resource<T> 表示宿主创建并管理的抽象资源句柄(如文件描述符、网络连接、数据库连接)。这使得 Wasm 模块可以透明地传递和操作这些宿主资源,而不需要关心底层的整数 ID。
  2. 标准化的内存布局:
    • Flat Memory Model: 所有数据(包括复杂类型)都被序列化并存储在 Wasm 模块的线性内存中。
    • “所有权”传递: Canonical ABI 会明确区分数据的所有权(ownership)。当一个 Wasm 模块将数据传递给另一个模块时,如果数据是“owned”(例如,创建一个新字符串并传递),那么接收方会获得该内存区域的所有权,并负责释放。
    • 自动处理: 工具链(如 wasm-toolswit-bindgen)负责将高级接口类型映射到 Core Wasm 的低级内存操作。这意味着开发者无需手动编写内存分配、拷贝、释放的代码。
  3. 标准化的调用约定:
    • Multiple Results: 支持函数返回多个值(Wasm Core 本身就支持多返回值)。
    • 参数和返回值传递: 对于简单类型,直接通过 Wasm 栈传递。对于复杂类型,通过在 Wasm 线性内存中分配空间、拷贝数据,然后传递指针和长度。
    • 特定目的的函数: 可能会引入一些辅助函数,例如内存分配 (canonical_abi_malloc) 和释放 (canonical_abi_free),但这通常会被工具链隐藏。
    • 错误传播: 通过 result<Ok, Err> 类型来统一表示和传递操作的成功或失败状态。
  4. 资源管理机制:
    • 通过 resource<T> 类型,Wasm 组件可以安全地传递和消费宿主资源。
    • 这些资源具有引用计数语义,当最后一个引用被释放时,宿主会清理底层资源。这解决了 Wasm 模块内外的资源生命周期管理问题。

如何实现?#

Canonical ABI 并非 Wasm 运行时直接实现的,而是通过工具链(如 wit-bindgen)和 Wasm Component Model 的适配层来实现的:

  1. WIT (WebAssembly Interface Type) 文件: 开发者使用 WIT 语言定义组件的接口(数据类型和函数签名)。WIT 文件是语言中立的,它描述了组件的“世界”及其能力。
  2. wit-bindgen 这个工具根据 WIT 文件生成特定语言的抽象(如 Rust 的 Traits、Python 的类)和 Wasm Core Module 级别的“适配器代码”。
  3. 适配器代码: 这些适配器代码负责:
    • 数据序列化/反序列化: 将宿主语言的高级数据结构转换为 Wasm 线性内存中的 Canonical ABI 格式,反之亦然。
    • 内存管理: 调用 Wasm 模块内部的内存分配和释放函数,或宿主提供的内存分配器。
    • 错误转换: 将宿主的错误类型映射到 Wasm result 类型。
    • 资源映射: 将宿主资源的句柄映射到 Wasm resource 类型。

优势#

  • 真正的语言无关性: 任何支持 Canonical ABI 的语言(通过 wit-bindgen)都可以与任何其他支持相同 ABI 的语言无缝交互,无需编写手动绑定。
  • 类型安全: 提供了更丰富的类型系统和更严格的类型检查,减少了运行时错误。
  • 开发效率: 自动化了复杂的数据 marshalling 和内存管理,大大降低了开发难度。
  • 模块化和可组合性: 促进了更细粒度、更易于组合的 Wasm 组件的创建。
  • 性能: 尽管引入了适配层,但相较于手动实现复杂的 C-style ABI,自动生成的 Canonical ABI 通常能提供更优化的性能,且避免了因手动操作导致的错误。
  • 未来扩展性: 为 Wasm 生态系统的未来发展(如垃圾回收、多态、异步操作等)奠定了坚实的基础。

简而言之,Wasm Canonical ABI 是 Wasm Component Model 的核心组成部分,它为 Wasm 模块提供了一个通用的、高效的、类型安全的“对话语言”和“数据交换协议”,从而实现了 Wasm 生态系统内不同模块和宿主环境之间的无缝互操作。