点击查看目录
下面是我所知道的关于将 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 提供的主要功能:
- 依赖管理:Cargo 可以通过 Cargo.toml 文件管理 Rust 项目的依赖。当添加、更新或删除依赖时,Cargo 会自动处理依赖的版本控制、依赖解决和依赖编译等问题。
- 构建和测试:Cargo 可以使用 rustc 编译器构建 Rust 项目,并自动解决依赖关系。同时,Cargo 还支持项目测试和文档生成等功能。
- 发布和分发: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 的类型(如i32
或f64
)。如果你使用更高级别的类型,如数组、切片,甚至 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-strip
或 wasm-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
另一个几乎应该成为构建管道一部分的工具是来自 binaryen 的 wasm-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 Book 和 Embedomicon 解释了如何为这些环境正确编写 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。看到它在幕后是如何工作的,我从中获得了很多乐趣,它帮助我理解和欣赏所有工具为我所做的事情。我希望你也有同感。非常感谢 Ingrid、Ingvar 和 Saul 审阅这篇文章。