Rust 编译 WebAssembly 指南

点击查看目录

下面是我所知道的关于将 Rust 编译为 WebAssembly 的所有知识。

前一段时间,我写了一篇如何在没有 Emscripten 的情况下将 C 编译为 WebAssembly 的博客文章,即不默认工具来简化这个过程。在 Rust 中,使 WebAssembly 变得简单的工具称为 wasm-bindgen,我们正在放弃它!同时,Rust 有点不同,因为 WebAssembly 长期以来一直是一流的目标,并且开箱即用地提供了标准库布局。

Rust 编译 WebAssembly 入门

让我们看看如何让 Rust 以尽可能少的偏离标准 Rust 工作流程的方式编译成 WebAssembly。如果你浏览互联网,许多文章和指南都会告诉你使用 cargo init --lib 创建一个 Rust 库项目,然后将 crate-type = ["cdylib"] 添加到你的 cargo.toml,如下所示:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

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

如果你不将 crate 类型设置为 cdylib,Rust 编译器将生成一个 .rlib 文件,这是 Rust 自己的库格式。虽然 cdylib 这个名字暗示了一个与 C 兼容的动态库,但我怀疑它真的只是代表“使用可互操作的格式”或类似的东西。

什么是 crate?

在 Rust 编程中,Crate(中文意思是 “板条箱”)指的是 Rust 语言中的包(Package),是 Rust 代码的一个单元,用于组织、构建和共享 Rust 代码。一个 Crate 可以包含一个或多个模块(Module),并且可以被其他 Crate 引用和使用。

每个 Crate 都需要有一个 Cargo.toml 文件作为其配置文件。Cargo.toml 中包含了 Crate 的元信息,如名称、版本、作者、依赖等信息。同时,Cargo.toml 中还可以定义编译器选项、环境变量等配置信息,用于构建和发布 Crate。

在 Rust 社区中,有很多优秀的 Crate 可以供使用。通过引用这些 Crate,可以快速、简便地开发高质量的 Rust 应用程序。同时,Rust 社区也鼓励开发者贡献自己的 Crate,以便其他开发者使用和贡献。

cdylib 也可以被称为 “C-compatible Dynamic Library”。cdylib Crate 可以通过 Rust 语言编写动态链接库,并将其导出为 C ABI(Application Binary Interface)。这使得其他语言(如 C、C++、Python、Java 等)可以通过 C ABI 接口调用 Rust 动态链接库中的函数和变量。这对于 Rust 与其他语言的互操作性非常重要,特别是在需要与现有代码进行集成的情况下。

使用 cdylib Crate 可以方便地创建和发布 Rust 动态链接库,并将其与其他语言进行集成。同时,cdylib Crate 也提供了一些与动态链接库相关的工具和 API,如动态链接库版本管理、符号导出等。这些工具和 API 可以方便地将 Rust 动态链接库的开发和集成过程变得更加简单、可靠和高效。

现在,我们将使用 Cargo 在创建新库时生成的默认/示例函数:

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

一切就绪后,我们现在可以将这个库编译为 WebAssembly:

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

你会在 target/wasm32-unknown-unknown/release/my_project.wasm 找到它。在整篇文章中,我将继续使用 --release 进行构建,因为它使 WebAssembly 模块在我们反汇编时更具可读性。

什么是 Cargo?

Cargo 是一个 Rust 项目管理工具,用于构建、测试、发布 Rust 应用程序和库。Cargo 提供了一个命令行界面和一组 Rust API,用于管理项目依赖、编译、测试和发布过程。

以下是 Cargo 提供的主要功能:

  1. 依赖管理:Cargo 可以通过 Cargo.toml 文件管理 Rust 项目的依赖。当添加、更新或删除依赖时,Cargo 会自动处理依赖的版本控制、依赖解决和依赖编译等问题。
  2. 构建和测试:Cargo 可以使用 rustc 编译器构建 Rust 项目,并自动解决依赖关系。同时,Cargo 还支持项目测试和文档生成等功能。
  3. 发布和分发:Cargo 可以将 Rust 项目打包为 Crate 并发布到 crates.io 上,也可以将二进制文件打包为可执行文件并发布到其他平台上。

通过使用 Cargo,开发者可以方便地创建、构建、测试和发布 Rust 应用程序和库。同时,Cargo 还提供了一些有用的工具和命令行选项,如清理项目、查询依赖、查看构建日志等,用于提高 Rust 项目的开发效率和质量。

可执行文件与库

你可以创建一个 Rust 可执行文件(通过 cargo init --bin),而不是创建一个库。但是请注意,你要么必须让 main() 函数具有完善的签名,要么使用 #![no_main] 关闭编译器以让它知道缺少 main() 是故意的。

那个更好吗?这对我来说似乎是一个品味问题,因为这两种方法在功能上似乎是等同的并且生成相同的 WebAssembly 代码。大多数时候,WebAssembly 模块似乎扮演了一个库的角色,而不是一个可执行文件(除了在 WASI 的上下文中,稍后会详细介绍!),所以在我看来,库方法在语义上似乎更可取。除非另有说明,否则我将在本文的其余部分使用库设置。

导出

继续库样式的设置,让我们看看编译器生成的 WebAssembly 代码。为此,我推荐 WebAssembly Binary Toolkit(简称“wabt”),它提供了有用的工具,如 wasm2wat。另外,请确保安装了 Binarygen,因为本文后面我们将需要 wasm-opt。Binaryen 还提供了 wasm-dis,其工作方式与 wasm2wat 类似,但不产生 WebAssembly 文本格式 (WAT)。它生成标准化程度较低的 WebAssembly S-Expression 文本格式 (WAST)。最后,ByteCodeAlliance 的 wasm-tools 提供了 wasm-tools print

wasm2wat ./target/wasm32-unknown-unknown/release/my_project.wasm

此命令会将 WebAssembly 二进制文件转换为 WAT:

(module
  (table (;0;) 1 1 funcref)
  (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 "__data_end" (global 1))
  (export "__heap_base" (global 2)))

令人发指的是,我们发现我们的 add 函数已从二进制文件中完全删除。我们只剩下一个堆栈指针和两个全局变量,它们指定数据部分的结束位置和堆的开始位置。事实证明,将函数声明为 pub 不足以让它出现在我们最终的 WebAssembly 模块中。我其实希望这就足够了,但我怀疑 Rust 模块可见性是唯一的,而不是链接器级别的符号可见性。

确保编译器不会删除我们关心的函数的最快方法是添加属性 #[no_mangle],尽管我不喜欢这个命名。

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

很少需要,但是你可以通过使用 #[export_name = "..."] 导出一个名称与其 Rust 内部名称不同的函数。

将我们的 add 函数标记为导出后,我们可以再次编译项目并检查生成的 WebAssembly 文件:

(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func $add (type 0) (param i32 i32) (result i32)
    local.get 1
    local.get 0
    i32.add)
  (table (;0;) 1 1 funcref)
  (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 "__data_end" (global 1))
  (export "__heap_base" (global 2)))

这个模块可以用普通的 WebAssembly API 实例化:

const importObj = {};

// Node
const data = require("fs").readFileSync("./my_project.wasm");
const {instance} = await WebAssembly.instantiate(data, importObj);

// Deno
const data = await Deno.readFile("./my_project.wasm");
const {instance} = await WebAssembly.instantiate(data, importObj);

// For Web, it’s advisable to use `instantiateStreaming` whenever possible:
const response = await fetch("./my_project.wasm");
const {instance} = 
  await WebAssembly.instantiateStreaming(response, importObj);

instance.exports.add(40, 2) // returns 42

突然之间,我们几乎可以使用 Rust 的所有功能来编写 WebAssembly。

需要特别注意模块边界处的函数(即你从 JavaScript 调用的函数)。至少就目前而言,最好坚持使用能够清晰映射到 WebAssembly 的类型(如i32f64)。如果你使用更高级别的类型,如数组、切片,甚至 String,该函数最终可能会使用比它们在 Rust 中更多的参数,并且通常需要对内存布局和类似原则有更深入的了解。

ABI

请注意:是的,我们正在成功地将 Rust 编译为 WebAssembly。然而,在 Rust 版本中,可能会生成一个具有完全不同函数签名的 WebAssembly 模块。函数参数从调用者传递到被调用者的方式(例如作为指向内存的指针或作为立即值)是应用程序二进制接口定义或简称“ABI”的一部分。rustc 默认使用 Rust 的 ABI,它不稳定,主要考虑 Rust 内部。

rustc 为了稳定这种情况,我们可以显式定义要为函数使用哪个 ABI。这是通过使用 extern 关键字来完成的。跨语言函数调用的一个长期选择是 C ABI,我们将在此处使用它。C ABI 不会改变,所以我们可以确定我们的 WebAssembly 模块接口也不会改变。

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

我们甚至可以省略 "C" 而只使用 extern,因为 C ABI 是默认的替代 ABI。

导入

WebAssembly 的一个重要部分是它的沙箱。它确保在 WebAssembly VM 中运行的代码无法访问主机环境中的任何内容,除了通过 imports 对象显式传递到沙箱中的函数。

假设我们想在我们的 Rust 代码中生成随机数。我们可以引入 rand Rust 沙箱,但如果主机环境中已经有东西,为什么还要发布代码。作为第一步,我们需要声明我们的 WebAssembly 模块需要导入:

#[link(wasm_import_module = "Math")]
extern "C" {
    fn random() -> f64;
}

#[export_name = "add"]
pub fn add(left: f64, right: f64) -> f64 {
    left + right  
    left + right + unsafe { random() }
}

extern "C" 块(不要与上面的 extern "C" 函数混淆)声明编译器希望在链接时由“其他人”提供的函数。这通常是你在 Rust 中链接 C 库的方式,但该机制也适用于 WebAssembly。但是,外部函数总是隐式不安全的,因为编译器无法为非 Rust 函数提供任何安全保证。因此,除非我们将调用包装在 unsafe { ... } 块中,否则我们无法调用它们。

上面的代码可以编译,但不会运行。我们的 JavaScript 代码抛出错误,需要更新以满足我们指定的导入。导入对象是导入模块的字典,每个模块都是导入项的字典。在我们的 Rust 代码中,我们声明了一个导入模块"Math",并期望一个被调用的函数"random"出现在该模块中。这些值当然是经过仔细选择的,这样我们就可以传入整个 Math 对象。

  const importObj = {
    Math: {
      random: () => Math.random(),
    }
  };

  // or
  
  const importObj = { Math };

为了避免到处注入 unsafe { ... },通常需要编写包装函数来恢复 Rust 的安全不变量。这是 Rust 内联模块的一个很好的用例:

mod math {
    mod math_js {
        #[link(wasm_import_module = "Math")]
        extern "C" {
            pub fn random() -> f64;
        }
    }

    pub fn random() -> f64 {
        unsafe { math_js::random() }
    }
}

#[export_name = "add"]
pub extern "C" fn add(left: f64, right: f64) -> f64 {
    left + right + math::random()
}

顺便说一句,如果我们没有指定 #[link(wasm_import_module = ...)]属性,则函数将在默认 env 模块上运行。此外,就像你可以使用 #[export_name = "..."] 更改导出的函数的名称一样,你可以使用 #[link_name = "..."] 更改导入的函数的名称。

高级类型

我之前说过,在模块边界处理函数的最有效方法是使用透明映射到 WebAssembly 支持的数据类型的值类型。当然,编译器允许你使用更复杂的类型作为函数的参数和值。在这些情况下,编译器生成 C ABI 中指定的代码(除了 rustc 目前不完全符合 C ABI 的不足)。

无需赘述,类型大小(例如,struct、enum 等)就变成了一个简单的指针。数组和元组是有大小的类型,如果它们使用少于 32 位,它们将被转换为立即值。更复杂的情况是函数返回大于 32 位的数组类型的值:如果是这种情况,函数将不会收到返回值,而是会收到一个附加类型的参数 i32,该函数将利用指向此参数的指针来存储结果。如果一个函数返回一个元组,无论元组的大小如何,它总是被认为是函数的参数。

(?Sized) 具有未指定类型的函数参数,例如 str[u8]dyn MyTrait,由两部分组成:第一部分是指向数据的指针,第二部分是指向元数据的指针。如果是 str 的一个或一部分,则元数据是数据的长度。在特征对象的实例中,它是一个虚拟表(或 vtable),它是指向各个特征函数实现的函数指针列表。如果你想了解更多有关 Rust 中的 VTable 的信息,我可以推荐 Thomas Bächler 的这篇文章

我在这里省略了重要的细节,因为建议你不要编写下一个 wasm-bindgen,除非你非要这样做。我建议依靠现有工具而不是创建新工具。

模块大小

当 WebAssembly 部署在 web 上时,它的二进制文件的大小非常重要。每一点都必须通过网络传输并通过浏览器的 WebAssembly 编译器,因此,较小的二进制大小意味着在 WebAssembly 开始运行之前用户等待的时间更少。如果我们将默认项目构造为发布版本,我们将生成 1.7MB 的 WebAssembly。这对于两个数字相加的功能似乎太大了。

数据部分:WebAssembly 模块的大部分由数据组成。即数据在特定点保存在内存中,然后复制到线性内存。这些部分的编译成本很低,因为编译器会跳过它们,在分析和减少模块的启动时间时请记住这一点。

检查 WebAssembly 模块内部结构的一种简单方法是 llvm-objdump,这应该可以在你的系统上访问。或者,你可以使用 wasm-objdump,它是 wabt 的一部分,通常提供相同的接口。

$ llvm-objdump -h target/wasm32-unknown-unknown/release/my_project.wasm

target/wasm32-unknown-unknown/release/my_project.wasm: file format wasm

Sections:
Idx Name            Size     VMA      Type
  0 TYPE            00000007 00000000
  1 FUNCTION        00000002 00000000
  2 TABLE           00000005 00000000
  3 MEMORY          00000003 00000000
  4 GLOBAL          00000019 00000000
  5 EXPORT          0000002b 00000000
  6 CODE            00000009 00000000 TEXT
  7 .debug_info     00062c72 00000000
  8 .debug_pubtypes 00000144 00000000
  9 .debug_ranges   0002af80 00000000
 10 .debug_abbrev   00001055 00000000
 11 .debug_line     00045d24 00000000
 12 .debug_str      0009f40c 00000000
 13 .debug_pubnames 0003e3f2 00000000
 14 name            0000001c 00000000
 15 producers       00000043 00000000

llvm-objdump 过于笼统,为那些有使用其他语言汇编经验的人提供熟悉的命令行。然而,专门用于调试二进制字符串的大小,它缺少简单的工具,如按大小排序部分或按功能分解部分。幸运的是,有专门为此设计的 WebAssembly 专用工具 Twiggy

$ twiggy top target/wasm32-unknown-unknown/release/my_project.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼─────────────────────────────────────────
        652300 ┊    36.67% ┊ custom section '.debug_str'
        404594 ┊    22.75% ┊ custom section '.debug_info'
        285988 ┊    16.08% ┊ custom section '.debug_line'
        254962 ┊    14.33% ┊ custom section '.debug_pubnames'
        176000 ┊     9.89% ┊ custom section '.debug_ranges'
          4181 ┊     0.24% ┊ custom section '.debug_abbrev'
           324 ┊     0.02% ┊ custom section '.debug_pubtypes'
            67 ┊     0.00% ┊ custom section 'producers'
            25 ┊     0.00% ┊ custom section 'name' headers
            20 ┊     0.00% ┊ custom section '.debug_pubnames' headers
            19 ┊     0.00% ┊ custom section '.debug_pubtypes' headers
            18 ┊     0.00% ┊ custom section '.debug_ranges' headers
            17 ┊     0.00% ┊ custom section '.debug_abbrev' headers
            16 ┊     0.00% ┊ custom section '.debug_info' headers
            16 ┊     0.00% ┊ custom section '.debug_line' headers
            15 ┊     0.00% ┊ custom section '.debug_str' headers
            14 ┊     0.00% ┊ export "__heap_base"
            13 ┊     0.00% ┊ export "__data_end"
            12 ┊     0.00% ┊ custom section 'producers' headers
             9 ┊     0.00% ┊ export "memory"
             9 ┊     0.00% ┊ add
...

现在很明显,模块大小的所有主要贡献者都是与模块用途无关的自定义组件。它们的标题暗示它们包含用于故障排除的信息,因此这些部分是为构建和发布而发出的这一事实有些不合常规。这似乎与我们代码的一个长期存在的问题有关,该问题导致它在编译时没有调试符号,但在我们的机器上预编译的标准库仍然有调试符号。

为了解决这个问题,我们在 Cargo.toml 中添加了:

[profile.release]
strip = true

这将导致 rustc 删除所有自定义部分,包括为函数分配名称的部分。这可能不是我们想要的,因为 twiggy 的输出将只包含 saycode[0] 或类似的函数。如果你想维护函数名称,我们可以使用特定的模式来删除信息:

[profile.release]
strip = true
strip = "debuginfo"

如果你想完全细粒度控制,你可以恢复并完全禁用 rustc 的 strip 方法,而是使用 llvm-stripwasm-strip。这使你能够决定应保留哪些自定义部件。

llvm-strip --keep-section=name target/wasm32-unknown-unknown/release/my_project.wasm

移除外层后,我们剩下一个与 116B 一样大或大于 116B 的块。拆解它会发现该模块的唯一目的是调用 add 并执行 (f64.add (local.get 0) (local.get 1)),这意味着 Rust 编译器能够生成最佳代码。当然,代码库的大小增加了,这使得掌握二进制大小变得更加困难。

自定义部分

有趣的事实:我们可以使用 Rust 将我们的自定义部分添加到 WebAssembly 模块中。如果我们声明一个字节数组(不是切片!),我们可以添加一个 #[link_section=...] 属性来将这些字节打包到它自己的部分中。

const _: () = {
    #[link_section = "surmsection"]
    static SECTION_CONTENT: [u8; 11] = *b"hello world";
};

我们可以使用 WebAssembly.Module.customSection() API 或使用 llvm-objdump 提取这些数据:

$ llvm-objdump -s -j surmsection target/wasm32-unknown-unknown/release/my_project.wasm

target/wasm32-unknown-unknown/release/my_project.wasm: file format wasm
Contents of section surmsection:
 0000 68656c6c 6f20776f 726c64             hello world

偷偷摸摸的膨胀

我在网上看到一些关于 Rust 为看似很小的工作创建 WebAssembly 模块的抱怨。根据我的经验,Rust 创建的 WebAssembly 二进制文件可能很大的原因有以下三个:

  • 调试构建(即忘记将 --release 传递给 Cargo)
  • 调试符号(即忘记运行 llvm-strip
  • 意外的字符串格式和恐慌

我们已经看到了前两个。让我们仔细看看最后一个。这个无害的程序编译成 18KB 的 WebAssembly:

static PRIMES: &[i32] = &[2, 3, 5, 7, 11, 13, 17, 19, 23];

#[no_mangle]
extern "C" fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
}

好吧,也许它毕竟不是那么无害。你可能已经知道我要干嘛了。

恐慌

快速浏览一下 twiggy 就会发现,影响 Wasm 模块大小的主要因素是与字符串格式化、恐慌和内存分配相关的函数。这说得通!参数 n 未清理并用于索引数组。Rust 别无选择,只能注入边界检查。如果边界检查失败,Rust 会崩溃,这是创建格式正确的错误消息和堆栈跟踪所必需的。

解决这个问题的一种方法是自己进行边界检查。Rust 的编译器非常擅长仅在需要时注入检查。

fn nth_prime(n: usize) -> i32 {
    if n < 0 || n >= PRIMES.len() { return -1; }
    PRIMES[n]
}

可以说更惯用的方法是依靠Option<T>API 来控制错误情况的处理方式:

fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
    PRIMES.get(n).copied().unwrap_or(-1)
}

第三种方法是使用 unchecked Rust 明确提供的一些方法。这些为未定义的行为打开了大门,因此是 unsafe,但如果你能够承担起安全的重担,性能(或文件大小)的提高将是显着的!

fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
    unsafe { *PRIMES.get_unchecked(n) }
}

我们可以尝试处理恐慌可能发生的位置,并尝试手动处理这些路径。然而,一旦我们开始依赖第三方 crate,成功的机会就会减少,因为我们无法轻易改变库内部处理错误的方式。

LTO

我们可能不得不接受这样一个事实,即我们无法避免代码库中出现 panic 的代码路径。虽然我们可以尝试减轻恐慌的影响(我们会的!),但有一个相当强大的优化通常可以节省一些重要的代码。这个优化过程由 LLVM 提供,称为 LTO(Link Time Optimization,链接时优化)rustc 在将所有内容链接到最终二进制文件之前编译和优化每个 crate。然而,一些优化只有在链接后才会变得明显。例如,许多函数根据输入有不同的分支。在编译期间,你只会看到来自同一个 crate 的函数调用。在链接时,你知道对任何给定函数的所有可能调用,这意味着现在可以消除其中一些代码分支。

LTO 默认处于关闭状态,因为它是一项代价高昂的优化,会显着减慢编译时间,尤其是在较大的 crate 中。你可以通过在 Cargo.toml 中配置 rustc 的许多代码生成选项启用。具体来说,我们需要将这一行添加到我们的 Cargo.toml 中以在发布版本中启用 LTO:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

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

[profile.release]
lto = true

启用 LTO 后,剥离的二进制文件减少到 2.3K,这令人印象深刻。LTO 的唯一成本是更长的链接时间,但如果二进制大小是一个问题,LTO 将成为一项利器,因为它“仅”花费构建时间并且不需要更改代码。

wasm-opt

另一个几乎应该成为构建管道一部分的工具是来自 binaryenwasm-opt。它是另一个优化过程的集合,完全在 WebAssembly VM 指令上工作,独立于生成它们的源语言。像 Rust 这样的高级语言有更多的信息可以用来应用更复杂的优化,所以 wasm-opt 不能替代你的语言编译器的优化。但是,它通常设法将模块大小减少几个额外的字节。

wasm-opt -O3 -o output.wasm target/wasm32-unknown-unknown/my_project.wasm

在我们的例子中,wasm-opt 进一步缩小了 Rust 的 2.3K WebAssembly 二进制文件,最后是 2.0K。好的!但别担心,我不会就此打住。这对于数组中的查找来说仍然太大了。

非标准

Rust 有一个标准库,其中包含你每天进行系统编程时所需的许多抽象和实用程序:访问文件、获取当前时间或打开网络套接字。一切都在那里供你使用,无需去 crates.io 或类似网站上搜索。然而,许多数据结构和函数对它们的使用环境做出了假设:它们假设硬件的细节被抽象成一个统一的 API,并且它们假设它们可以以某种方式分配(和释放)任意大小的内存块。通常,这两项工作都是由操作系统完成的,我们大多数人每天都在操作系统上工作。

但是,当你通过原始 API 实例化 WebAssembly 模块时,情况就不同了:沙箱(WebAssembly 的定义安全功能之一)将 WebAssembly 代码与主机隔离开来,从而与操作系统隔离开来。你的代码只能访问一大块线性内存,它甚至无法弄清楚哪些部分正在使用,哪些部分可以使用。

WASI:这不是本文的一部分,但就像 WebAssembly 是对运行代码的处理器的抽象一样,WASI(WebAssembly 系统接口)旨在成为对运行代码的操作系统的抽象,并为你提供可以使用单一、统一的 API。Rust 支持 WASI,尽管 WASI 本身仍在发展中。

这意味着 Rust 给了我们一种虚假的安全感!它为我们提供了一个没有操作系统支持的完整标准库。事实上,许多 stdlib 模块只是别名或者失败了。也就是说,它们在没有操作系统支持的情况下不能正常工作。在没有操作系统支持的情况下,许多返回 Result <T> 类型的函数可能会因为无法正常工作而始终返回 Err,这意味着无法得到正确的操作结果。同样,其他一些函数可能会因为无法正常工作而导致程序崩溃。

向无操作系统设备学习

只是一个线性内存块。没有管理内存或外围设备的中央实体。只是算术。如果你曾经使用过嵌入式系统,这听起来可能很熟悉。虽然现代嵌入式系统运行 Linux,但较小的微处理器没有资源来这样做。 Rust 还针对那些超受限环境Embedded Rust BookEmbedomicon 解释了如何为这些环境正确编写 Rust。

要进入裸机世界🤘,我们必须在代码中添加一行:#![no_std]。这个 crate 宏告诉 Rust 不要链接到标准库。相反,它只链接到 core。Embedonomicon 非常简洁地解释了这意味着什么:

core crate 是 std crate 的子集,它对程序将在其上运行的系统做出零假设。因此,它为语言原语(如浮点数、字符串和切片)提供 API,以及公开处理器功能(如原子操作和 SIMD 指令)的 API。但是,它缺少任何处理堆内存分配和 I/O 的 API。

对于应用程序,std 不仅仅是提供一种访问操作系统抽象的方法。std 还负责设置堆栈溢出保护、处理命令行参数以及在调用程序的主函数之前生成主线程。 #![no_std] 应用程序缺少所有标准运行时,因此如果需要它必须初始化自己的运行时。

这听起来有点可怕,但让我们一步一步来。我们首先将上面的 panic-y 素数程序声明为 no_std

#![no_std]
static PRIMES: &[i32] = &[2, 3, 5, 7, 11, 13, 17, 19, 23];

#[no_mangle]
extern "C" fn nth_prime(n: usize) -> i32 {
    PRIMES[n]
}

很遗憾,Embedonomicon 段落预示了这一点。因为我们没有提供核心依赖项的一些基础知识。在列表的最顶部,我们需要定义在这种环境中发生恐慌时应该发生什么。这是由恰当命名的恐慌处理程序完成的,Embedonomicon 给出了一个例子:

#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
    loop {}
}

这对于嵌入式系统来说是非常典型的,有效地阻止了处理器在崩溃发生后进行任何进一步的处理。然而,这在 web 上不是好的行为,所以对于 WebAssembly,我通常选择手动发出无法访问的指令来阻止任何 Wasm VM 运行:

#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
    loop {}
    core::arch::wasm32::unreachable()
}

有了这个,我们的程序再次编译。剥离和 wasm-opt 后,二进制文件大小为 168B。极简主义再次获胜!

内存管理

当然,我们因非标准而放弃了很多。没有堆分配,就没有 Box,没有 Vec,没有 String 和许多其他有用的东西。幸运的是,我们可以在不放弃整个操作系统的情况下取回这些东西。

std 提供的很多东西实际上只是来自 core 的另一个称为 alloc 的东西。 alloc 包含有关内存分配和依赖于它的数据结构的所有内容。通过导入它,我们可以重新获得我们信任的 Vec

#![no_std]
// One of the few occastions where we have to use `extern crate`
// even in Rust Edition 2021.
extern crate alloc;
use alloc::vec::Vec;

#[no_mangle]
extern "C" fn nth_prime(n: usize) -> usize {
    // Please enjoy this horrible implementation of
    // The Sieve of Eratosthenes.
    let mut primes: Vec<usize> = Vec::new();
    let mut current = 2;
    while primes.len() < n {
        if !primes.iter().any(|prime| current % prime == 0) {
            primes.push(current);
        }
        current += 1;
    }
    primes.into_iter().last().unwrap_or(0)
}

#[panic_handler]
fn panic(_panic: &core::panic::PanicInfo<'_>) -> ! {
    core::arch::wasm32::unreachable()
}

当然,尝试编译它会失败——我们实际上并没有告诉 Rust 我们的内存管理是什么样的,Vec 需要知道它才能运行。

$ cargo build --target=wasm32-unknown-unknown --release
error: no global memory allocator found but one is required; 
  link to std or add `#[global_allocator]` to a static item that implements 
  the GlobalAlloc trait

error: `#[alloc_error_handler]` function required, but not found

note: use `#![feature(default_alloc_error_handler)]` for a default error handler

在撰写本文时,在 Rust 1.67 中,你需要提供一个在分配失败时调用的错误处理程序。在下一个版本中,Rust 1.68 default_alloc_error_handler 已经稳定下来,这意味着每个非标准的 Rust 程序都将带有这个错误处理程序的默认实现。如果你仍想提供自己的错误处理程序,你可以:

#[alloc_error_handler]
fn alloc_error(_: core::alloc::Layout) -> ! {
    core::arch::wasm32::unreachable()
}

有了这个复杂的错误处理程序,我们最终应该提供一种方法来进行实际的内存分配。就像我在 C 到 WebAssembly 的文章中一样,我的自定义分配器将是一个最小的 bump 分配器,它往往又快又小,但不会释放内存。我们静态分配一个 arena 作为我们的堆,并跟踪“空闲区域”的开始位置。由于我们不使用 Wasm 线程,因此我也会忽略线程安全。

use core::cell::UnsafeCell;

const ARENA_SIZE: usize = 128 * 1024;
#[repr(C, align(32))]
struct SimpleAllocator {
    arena: UnsafeCell<[u8; ARENA_SIZE]>,
    head: UnsafeCell<usize>,
}

impl SimpleAllocator {
    const fn new() -> Self {
        SimpleAllocator {
            arena: UnsafeCell::new([0; ARENA_SIZE]),
            head: UnsafeCell::new(0),
        }
    }
}

unsafe impl Sync for SimpleAllocator {}

#[global_allocator]
static ALLOCATOR: SimpleAllocator = SimpleAllocator::new();

#[global_allocator] 全局变量标记为管理堆的实体。此变量的类型必须实现 GlobalAlloc 特性。特性上的 GlobalAlloc 方法都使用 &self,所以如果你想修改数据类型中的任何值,你必须使用内部可变性。我这里选择了 UnsafeCell。使用 UnsafeCell 使我们的结构隐式 !Sync,Rust 不允许全局静态变量。这就是为什么我们还必须手动实现 Synctrait 来告诉 Rust 我们知道我们有责任使这种数据类型成为线程安全的(而我们完全忽略了这一点)。

该结构被标记为 #[repr(C)] 的原因很简单,以便我们可以手动指定对齐方式。这样我们就可以确保即使是 arena 中的第一个字节(以及我们返回的第一个指针的扩展)也具有 32 位对齐,这应该可以满足大多数数据结构。

现在为特征的 GlobalAlloc 的实际实现:

unsafe impl GlobalAlloc for SimpleAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let size = layout.size();
        let align = layout.align();

        // Find the next address that has the right alignment.
        let idx = (*self.head.get()).next_multiple_of(align);
        // Bump the head to the next free byte
        *self.head.get() = idx + size;
        let arena: &mut [u8; ARENA_SIZE] = &mut (*self.arena.get());
        // If we ran out of arena space, we return a null pointer, which
        // signals a failed allocation.
        match arena.get_mut(idx) {
            Some(item) => item as *mut u8,
            _ => core::ptr::null_mut(),
        }
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        /* lol */
    }
}

#[global_allocator] 不仅仅是 #[no_std]!你还可以使用它来覆盖 Rust 的默认分配器并将其替换为你自己的分配器,因为 Rust 的默认分配器消耗大约 10K Wasm 空间。

wee_alloc

当然,你不必自己实现分配器。事实上,依靠经过良好测试的实施可能是明智的。处理分配器中的错误和微妙的内存损坏并不好玩。

许多指南推荐 wee_alloc,这是一个非常小的 (<1KB) 分配器,由 Rust WebAssembly 团队编写,也可以释放内存。可悲的是,它似乎没有得到维护,并且有一个关于内存损坏和内存泄漏的未解决问题

在任何相当复杂的 WebAssembly 模块中,Rust 的默认分配器消耗的 10KB 只是整个模块大小的一小部分,所以我建议坚持使用它并知道分配器经过良好测试和性能。

wasm-bindgen

现在我们已经完成了几乎所有困难的事情,我们已经看到了使用 wasm-bindgen 为 WebAssembly 编写 Rust 的便捷方法。

wasm-bindgen 的关键特性是 #[wasm_bindgen] 宏,我们可以将它放在我们想要导出的每个函数上。这个宏添加了我们在本文前面手动添加的相同编译器指令,但它还做了一些更有用的事情。

例如,如果我们将上面的宏添加到我们的 add 函数中,它会发出另一个以数字格式返回我们的函数 __wbindgen_describe_add 的描述。具体来说,我们函数的描述符如下所示:

Function(
    Function {
        arguments: [
            U32,
            U32,
        ],
        shim_idx: 0,
        ret: U32,
        inner_ret: Some(
            U32,
        ),
    },
)

这是一个非常简单的函数,但是 wasm-bindgen 中的描述符能够表示非常复杂的函数签名。

展开: 如果你想查看宏发出的代码 #[wasm_bindgen],请使用 rust-analyzer 的“递归扩展宏”功能。你可以通过命令面板在 VS Code 运行它。

这些描述符有什么用?wasm-bindgen 不仅提供了一个宏,它还附带了一个 CLI,我们可以使用它来对我们的 Wasm 二进制文件进行后处理。CLI 提取这些描述符并使用此信息生成自定义 JavaScript 绑定(然后删除所有不再需要的描述符函数)。生成的 JavaScript 具有处理更高级别类型的所有例程,允许你无缝传递类型,例如字符串、ArrayBuffer 甚至闭包。

如果你想为 WebAssembly 编写 Rust,我推荐 wasm-bindgen。wasm-bindgen 不适用于 #![no_std],但实际上这很少成为问题。

wasm-pack

我还想提一下 wasm-pack,这是另一个用于 WebAssembly 的 Rust 工具。我们使用全套工具来编译和处理我们的 WebAssembly 以优化最终结果。wasm-pack 是一种对大多数这些过程进行编码的工具。它可以使用针对 WebAssembly 优化的所有设置引导一个新的 Rust 项目。它构建项目并使用所有正确的标志调用 cargo,然后它调用 wasm-bindgen CLI 来生成绑定,最后它运行 wasm-opt 以确保我们不会留下任何性能问题。wasm-pack 还能够准备你的 WebAssembly 模块以发布到 npm,但我个人从未使用过该功能。

总结

Rust 是一种用于 WebAssembly 的优秀语言。启用 LTO 后,你将获得非常小的模块。Rust 的 WebAssembly 工具非常出色,自从我第一次在 Squoosh 中使用它以来,它变得更好了。发出的胶水代码 wasm-bindgen 既现代又 tree-shaken。看到它在幕后是如何工作的,我从中获得了很多乐趣,它帮助我理解和欣赏所有工具为我所做的事情。我希望你也有同感。非常感谢 IngridIngvarSaul 审阅这篇文章。

编辑本页