Rust + WebAssembly 全链路解析
第5章 Rust 的 wasm32 目标与代码生成
5.1 Rust 编译到 WASM 的路径
Rust 编译到 WASM 和编译到 x86-64 走同一条管线,只在最后一步分叉:
flowchart TD
A["Rust 源码"] --> B["HIR<br/>(高层 IR)"]
B --> C["MIR<br/>(中间 IR)"]
C --> D["单态化 + 优化<br/>(MIR 优化 pass)"]
D --> E{目标三元组?}
E -->|"x86_64-unknown-linux-gnu"| F["LLVM → x86 机器码"]
E -->|"wasm32-unknown-unknown"| G["LLVM → wasm32 代码"]
E -->|"wasm32-wasip2"| H["LLVM → wasm32 + WASI 绑定"]
F --> I[".o → lld → ELF"]
G --> J[".o → wasm-ld → .wasm"]
H --> K[".o → wasm-component-ld → .wasm 组件"]
style G fill:#6366f1,color:#fff
style H fill:#22d3ee,color:#333
关键点:MIR 之前的所有阶段(解析、类型检查、借用检查、MIR 优化)与编译目标无关。所有权检查、生命周期推导、trait 求解——这些都在编译期完成,不关心最终是 x86 机器码还是 WASM 字节码。只有从 LLVM IR 开始,代码生成才分叉到不同的后端。
这意味着:Rust 的内存安全保证在 WASM 目标上和原生目标上完全一致。编译器不会因为目标是 WASM 就放松检查——借用检查、unsafe 规则、Send/Sync 这些线程安全约束在进入后端之前就已经检查完毕;切片下标这类边界检查则和原生平台一样,以运行时检查的形式编进产物。
但也存在编译目标相关的差异:
- 标准库可用性:
std::fs、std::net、std::thread在wasm32-unknown-unknown上编译得过,但标准库把它们接到了library/std/src/sys/*/unsupported.rs这套空实现上——File::open直接返回ErrorKind::Unsupported的Err,Thread::new同样返回Err。这是标准库平台适配层的选择,和 MIR 阶段无关。 - 代码生成选择:LLVM 的 wasm32 后端生成的指令集不同(第 5.3 节),某些 Rust 构造(如
dyn Trait)的底层实现方式在 WASM 和原生平台上有差异。 - 链接器差异:
wasm-ld和ld.lld的行为不完全一致——段合并规则、符号可见性、重定位类型都不同。
5.2 两个 WASM 目标:unknown vs wasip2
Rust 的 wasm 目标不止两个——1.96.0 的 compiler/rustc_target/src/spec/targets/ 下还有 wasm32_unknown_emscripten.rs、wasm32v1_none.rs、wasm32_wasip1.rs、wasm32_wasip1_threads.rs、wasm32_wasip3.rs、wasm32_wali_linux_musl.rs、wasm64_unknown_unknown.rs。按各文件 metadata.tier 的取值:wasm32-unknown-unknown、wasm32-wasip1、wasm32-wasip1-threads、wasm32-wasip2、wasm32-unknown-emscripten、wasm32v1-none 是 Tier 2(其中 wasm32v1-none 只有 core/alloc,std: Some(false)),wasm32-wasip3、wasm32-wali-linux-musl、wasm64-unknown-unknown 是 Tier 3。但本章(和实际工程)主要关心的是下面这两个:
| 特性 | wasm32-unknown-unknown |
wasm32-wasip2 |
|---|---|---|
| 目标环境 | 浏览器(或任意 JS 宿主) | WASI Preview 2 运行时 |
| std 支持 | 受限(无 I/O、无线程、无时间) | 完整(通过 WASI 系统调用) |
| 内存 | 线性内存,由 JS 管理 | 线性内存,由 WASI 运行时管理 |
| 导入 | JS 函数(wasm-bindgen 定义) |
WASI 接口(wasi:io/...) |
| 输出 | 裸 .wasm 模块 |
.wasm 组件(需 wasm-component-ld) |
| 链接器 | wasm-ld(LLVM 的 wasm lld,见 compiler/rustc_target/src/spec/base/wasm.rs:75 的 linker: Some("rust-lld".into())) |
wasm-component-ld(组件感知链接器,见 compiler/rustc_target/src/spec/targets/wasm32_wasip2.rs:28) |
wasm32-unknown-unknown
"unknown-unknown" 的含义:操作系统 unknown、环境 unknown——Rust 标准库不知道有什么系统 API 可用。因此 std::fs、std::net、std::thread 等模块不可用或功能受限。
可用的标准库功能:
std::alloc:堆分配(通过dlmalloc或自定义全局分配器)std::vec、std::string、std::collections:堆数据结构(依赖alloc)std::sync::atomic:一直可用,但默认不生成 WASM 原子指令——compiler/rustc_target/src/spec/base/wasm.rs:72写着singlethread: true,随附注释说这是告诉 LLVM 按单线程模型处理,把原子操作降级(legalize)成普通读写。只有开了+atomics才真的发原子指令,那时才需要SharedArrayBufferstd::panic:panic 处理(默认调用abort()-> trap)std::marker、std::ops、std::cmp:纯编译期抽象,零运行时依赖std::fmt:格式化输出(但println!不输出到任何地方——没有 stdout)
不可用的功能:
std::fs:文件系统(浏览器没有文件系统)std::net:网络(浏览器用fetchAPI,不是 socket)std::thread::spawn:线程创建(WASM 没有线程创建 API——threads 提案只提供共享内存与原子指令,JS 侧对应SharedArrayBuffer,见第 3 章)std::time::Instant:高精度计时器(需要宿主提供——wasm-bindgen通过js_sys::Date::now()弥补)std::env:环境变量(不存在)std::process:进程控制(不存在)
wasm-bindgen 通过导入 JS 函数来弥补这些缺失——比如 web_sys::window() 调用 JS 的 window 对象,js_sys::Date::now() 调用 JS 的 Date.now()。
一个在 wasm32-unknown-unknown 上能编译的 Rust 程序,通常需要避免直接使用上述不可用的模块,或者通过条件编译 (#[cfg(target_arch = "wasm32")]) 提供替代实现。
wasm32-wasip2
wasm32-wasip2 是 2024 年新增的目标三元组(Rust 1.78 以 Tier 3 加入,1.82 升为 Tier 2),针对 WASI Preview 2 运行时(Wasmtime、Wasmer 等)。与 unknown-unknown 不同,它有一个明确定义的操作系统接口——WASI。
// 在 wasm32-wasip2 下,这些代码可以直接运行
use std::fs::File;
use std::io::Read;
fn read_config() -> std::io::Result<String> {
let mut f = File::open("/config.json")?;
let mut buf = String::new();
f.read_to_string(&mut buf)?;
Ok(buf)
}
同一个函数在 wasm32-unknown-unknown 下照样能编译通过,但运行时必然失败——unknown 目标的 std::fs 落在 library/std/src/sys/fs/unsupported.rs,File::open 的函数体就是一句 unsupported(),返回 ErrorKind::Unsupported。
wasm32-wasip2 的关键特性:
- 完整的 std 支持:文件 I/O、网络、环境变量、命令行参数、随机数生成——全部通过 WASI 接口实现(网络要宿主显式授权,如
wasmtime run -S inherit-network=y)。 - 组件模型输出:编译产物是 WASM 组件(Component),不是裸模块。组件在裸
.wasm模块外面包了一层类型信息和接口声明——第 14 章会详细拆解组件格式。 - WIT 接口定义:组件通过 WIT(WebAssembly Interface Types)声明导出/导入的接口——比裸模块的
(import "env" "log" (func))更类型安全、更结构化。 - 异步 I/O:WASI Preview 2 的
wasi:io/poll接口支持异步操作——Rust 的async/await可以映射到 WASI 的 poll 机制。
两个目标的切换
条件编译让同一份 Rust 代码适配两个目标:
#[cfg(target_arch = "wasm32")]
#[cfg(not(target_os = "wasi"))]
fn get_time() -> f64 {
// wasm32-unknown-unknown: 通过 JS 获取
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = Date)]
fn now() -> f64;
}
now()
}
#[cfg(target_os = "wasi")]
fn get_time() -> f64 {
// wasm32-wasip2: 直接使用 std
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
duration.as_secs_f64() * 1000.0
}
5.3 LLVM 的 wasm32 后端
LLVM 的 WebAssembly 后端早期以实验性后端存在,从 LLVM 8(2019 年)起成为默认构建的目标,此后持续完善。近几年的 LLVM 对 WASM 的支持已经覆盖了大部分 Rust 语言特性——但仍有少数限制(5.6 节)。
后端代码生成流水线
LLVM 的 wasm32 后端把 LLVM IR 翻译为 WASM 指令的过程:
flowchart TD A["LLVM IR<br/>(SSA 形式)"] --> B["指令选择<br/>(DAG → WASM 操作)"] B --> C["栈化 RegStackify<br/>(能留在值栈上的值不进局部变量)"] C --> D["寄存器着色 RegColoring<br/>(合并虚拟寄存器 → 局部变量)"] D --> E["结构化控制流降低<br/>(任意 CFG → block/loop/if)"] E --> F["WASM 字节码发射<br/>(.o 目标文件)"] style B fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333 style E fill:#dc2626,color:#fff
指令选择
LLVM IR 的每个操作映射到最合适的 WASM 指令。映射关系通常是直接的:
| LLVM IR 操作 | WASM 指令 |
|---|---|
add i32 |
i32.add |
mul i32 |
i32.mul |
load i32 |
i32.load |
store i32 |
i32.store |
icmp eq |
i32.eq |
br |
br |
call |
call |
ret |
return |
有些映射不那么直接:
select(条件选择):LLVM IR 的select i1 %cond, %true_val, %false_val在 WASM 里有直接等价指令——select(opcode0x1b,见binaryen-126/src/wasm-binary.h:507),三个操作数正是ifTrue/ifFalse/condition(binaryen-126/src/wasm.h:1294)。需要注意的是它两个分支都会求值(没有短路),所以带副作用或可能陷阱的操作数仍得展开成if/else/end;引用类型还另有带显式类型标注的select_t(0x1c)。phi(SSA phi 节点):WASM 没有 phi 节点——需要通过local.set/local.get在控制流汇合点传递值。switch:LLVM IR 的 switch 可以有大量 case,WASM 只有br_table(跳转表,所有 case 映射到同一个索引表)。
寄存器分配
WASM 是栈式指令集,局部变量(local)又不限数量,所以 LLVM 的 wasm 后端不跑通用寄存器分配器——LLVM 22.1.2(即 Rust 1.96.0 所带版本)WebAssembly 后端的 WebAssemblyTargetMachine.cpp里 createTargetRegisterAllocator 直接 return nullptr; // No reg alloc。取而代之的是 WebAssemblyRegColoring pass:给虚拟寄存器做着色,把生命期不重叠的合并到同一个局部变量,尽量减少局部变量数量,降低函数体体积。
WASM 的局部变量有两个来源:
- 函数参数:自动成为 local 0, 1, 2, ...
- 声明的局部变量:在函数体开头的局部变量声明区列出
着色结果决定每个 LLVM 虚拟寄存器对应哪个 WASM 局部变量。WASM 局部变量的数量和声明顺序基本不影响运行时性能(引擎编译时会自己重新做寄存器分配),但影响 .wasm 二进制体积——更多的局部变量 = 更大的声明区 + 更多的 local.get/local.set 指令。
栈消除
最朴素的做法是把每个"虚拟寄存器"定义变成 local.set,每次使用变成 local.get:
; 虚拟寄存器形式
v1 = add v2, v3
; WASM (栈消除后)
local.get 2 ;; v2 压栈
local.get 3 ;; v3 压栈
i32.add ;; 弹出 v2, v3; 压入结果
local.set 1 ;; 结果存入 v1
这个转换是机械的——每条 LLVM IR 指令变成一个固定的指令模式。但有一个优化机会:如果一条指令的结果立即被下一条指令使用,中间的 local.set + local.get 可以省略——值留在栈上直接传递给下一条指令。LLVM 的 wasm 后端里做这件事的是 WebAssemblyRegStackify("栈化")pass,它排在 WebAssemblyRegColoring 和结构化控制流(WebAssemblyCFGStackify)之前,只有栈化后仍需保留的值才落到局部变量里(同见 WebAssemblyTargetMachine.cpp 的 pass 顺序)。
结构化控制流的生成
LLVM IR 是任意基本块 + 跳转的图结构,WASM 要求结构化的控制流。转换算法是整个 wasm32 后端最复杂的部分。
flowchart TD A["LLVM IR: 任意 CFG<br/>(基本块 + 无约束跳转)"] --> B["识别循环<br/>(自然循环检测)"] B --> C["识别分支区域<br/>(dominance frontier)"] C --> D["嵌套 block/loop/if"] D --> E["把分支转为 br/br_if<br/>(标签深度编码)"] E --> F["WASM: 结构化控制流<br/>(无任意跳转)"] style A fill:#dc2626,color:#fff style F fill:#10b981,color:#333
if-else 的转换
; LLVM IR
br i1 %cond, label %then, label %else
then:
...
br label %end
else:
...
br label %end
end:
...
转换为:
;; WASM
local.get $cond
if
... ;; then 块
else
... ;; else 块
end
... ;; end 之后
循环的转换
; LLVM IR
loop_header:
...
br i1 %cond, label %loop_body, label %loop_exit
loop_body:
...
br label %loop_header
loop_exit:
...
转换为:
;; WASM
block ;; 从 loop 内部看是标签深度 1:loop_exit
loop ;; 标签深度 0:loop_header
... ;; 头部计算
local.get $cond
i32.eqz
br_if 1 ;; 条件为假 → 跳到外层 block 的 end 之后(loop_exit)
... ;; loop_body
br 0 ;; 跳回 loop 开头(loop_header)
end
end
... ;; loop_exit
br_if 1、br 0 里的数字是标签深度——从当前位置往外数:0 是最内层的 loop,1 是包在外面的那层 block,以此类推。
这个标签深度的编码方式有一个微妙之处:WASM 的 block 和 loop 对 br 的语义不同。br N 在 block 中跳到 block 的 end 之后(向前跳),在 loop 中跳到 loop 的开头(向后跳)。LLVM 的后端需要根据原始 CFG 跳转的方向选择使用 block 还是 loop。
不可规约控制流的处理
LLVM IR 可能包含不可规约控制流(irreducible control flow)——跳转目标不是循环头或分支汇合点,而是跳到循环中间或从循环中间跳出。WASM 的结构化控制流无法直接表达不可规约控制流。
LLVM 的 wasm32 后端有一个专门的 WebAssemblyFixIrreducibleControlFlow pass,把不可规约控制流改写为可规约形式(思路与 Emscripten 的 Relooper 一脉相承)。核心思路:
- 识别不可规约的跳转边
- 把跳转目标拆分为一个新的
block,在block内用br_table实现多路跳转 - 通过"发送者/接收者"模式:跳转前设置一个变量标识目标,在汇合点用
br_table分发
这种转换会增加 .wasm 体积(多一层 block + br_table),但保证所有 CFG 都可以编译为合法的 WASM。实际上 Rust 编译器生成的 LLVM IR 几乎不包含不可规约控制流——Rust 的 if/match/loop/while/for 都是结构化的。唯一可能产生不可规约控制流的是 loop { ... break ... continue ... } 中的非标准跳转模式——但 LLVM 的循环简化 pass 通常在 wasm32 后端看到 IR 之前就把它规约了。
match 表达式的转换
Rust 的 match 表达式编译到 LLVM IR 通常是 switch 指令或一系列 icmp + br。LLVM 的 wasm32 后端把 switch 转换为 WASM 的 br_table:
match value {
0 => "zero",
1 => "one",
2..=10 => "small",
_ => "other",
}
编译后的 WASM 结构:
;; 简化版:br_table 只能跳到「包住它」的标签,所以分支块必须嵌套在外层
block $done
block $default
block $small
block $one
block $zero
local.get $value
br_table $zero $one $small $default
end
;; "zero"
br $done
end
;; "one"
br $done
end
;; "small"(2..=10 还需要一次范围检查,见 5.5 节的实际反汇编)
br $done
end
;; "other"
end
;; match 结束
br_table 的操作码后面跟着一个目标标签数组 + 一个默认标签。每个 case 的值直接映射到数组索引——如果值不在 case 范围内,跳到默认标签。范围匹配(2..=10)需要额外的比较逻辑——LLVM 的 switch lowering 会让 br_table 只覆盖小的连续值,范围检查放到默认分支里用比较指令完成(5.5 节有实际产物)。
5.4 wasm32 调用约定与 ABI
Rust 在 wasm32 目标上遵循的调用约定和原生平台有显著差异。理解这些差异对调试和性能优化至关重要。
函数参数和返回值传递
WASM 的函数调用约定在规范中明确定义——和 x86-64 的 System V ABI 不同,WASM 不依赖寄存器传参:
| 传递方式 | 说明 |
|---|---|
| 参数传递 | 通过抽象值栈压入,函数内部通过 local.get 0, local.get 1, ... 访问 |
| 返回值传递 | 通过抽象值栈弹出,函数结束前把返回值留在栈顶 |
| 多返回值 | WASM 1.0(MVP)只支持单返回值;多返回值提案(Multi-Value)已并入 WASM 2.0 标准,允许函数返回多个值 |
在 wasm32 目标上,Rust 的函数参数和返回值映射规则:
| Rust 类型 | WASM 参数/返回类型 | 说明 |
|---|---|---|
i32, u32 |
i32 |
直接映射 |
i64, u64 |
i64 |
直接映射 |
f32 |
f32 |
直接映射 |
f64 |
f64 |
直接映射 |
bool |
i32 |
0 = false, 1 = true |
char |
i32 |
Unicode 标量值 |
enum (无字段) |
i32 |
判别式 |
&T, &mut T |
i32 |
线性内存偏移量 |
*const T, *mut T |
i32 |
线性内存偏移量 |
Box<T> |
i32 |
堆分配后的指针 |
String, Vec<T>(按值) |
一个 i32 |
12 字节的 cap/ptr/len 结构体按间接方式传递:实参是指向调用方栈上副本的指针 |
&str, &[T] |
Rust ABI:两个 i32;extern "C":一个 i32 |
Rust ABI 拆成指针 + 长度;C ABI 下按间接方式传(指向栈上胖指针的指针) |
&dyn Trait |
两个 i32(Rust ABI) |
数据指针 + vtable 指针 |
(T1, T2) |
Rust ABI 下两个标量的元组拆成两个参数;更大的元组、以及 extern "C" 下的元组按指针间接传递 |
— |
| 聚合类型返回值 | 无返回值,多一个 i32 参数 |
调用方预留返回槽(sret),被调方写进去——即便 multivalue 默认开启,Rust 也不生成多返回值函数 |
上表按 Rust 1.96.0、--release 编译到 wasm32-unknown-unknown 后用 wasm-tools print 查看导出函数签名实测所得(例如 fn(s: &str) 是 (param i32 i32),extern "C" fn(s: &str) 是 (param i32),fn() -> (i32, i32) 是 (param i32) 无 result)。extern "C" 自 Rust 1.89 起在 wasm32-unknown-unknown 上采用符合标准的 C ABI(1.89.0 发布说明),结构体、胖指针一律间接传递。
关键点:Rust 的引用类型 &T 在 WASM 层面就是一个 i32——一个线性内存偏移量。所有权和借用规则在 MIR 阶段已经检查完毕,到代码生成时只剩裸地址。所以跨越 extern "C" 边界的通常只剩 i32/i64/f32/f64 这几种标量——和 C 语言的指针传递一致。
extern "C" vs Rust 默认 ABI
Rust 的 extern "C" 函数使用 C ABI(不是 WASM 核心规范定义的,而是 WebAssembly/tool-conventions 仓库约定的 C 调用约定,clang 与 rustc 共同遵循),参数和返回值按上述规则映射。这是 wasm-bindgen 导出函数使用的调用约定。
Rust 的默认调用约定是 extern "Rust"——一个不稳定、由编译器自行决定布局的 ABI。另有一个专供 Fn/FnMut/FnOnce 使用的 extern "rust-call",它把所有参数打包为一个元组传递。在 WASM 中,这些参数最终仍然扁平化展开——和 extern "C" 的结果类似,但布局不受稳定 ABI 约束。
在 wasm32 目标上,extern "C" 和 Rust 默认调用约定对标量的处理相同——WASM 的值栈传参方式本身就要求参数按顺序压入。差异主要在两处:名称修饰(name mangling),以及两个标量组成的聚合(&str、&[T]、&dyn Trait、二元组)——Rust ABI 拆成两个参数,C ABI 按指针间接传递(见上表)。
栈帧布局
WASM 函数的栈帧在线性内存中的布局(第 3 章已讨论):
高地址 ┌──────────────────────┐
│ 调用者的栈帧 │
├──────────────────────┤
│ 局部变量 │ ← __stack_pointer 递减后
│ 临时值 │
│ ... │
低地址 └──────────────────────┘ ← 当前 __stack_pointer
rustc 为走 wasm-ld 的 wasm 目标默认传入 -z stack-size=1048576 和 --stack-first(compiler/rustc_target/src/spec/base/wasm.rs:13-14 的 -z stack-size=1048576 与 30 行的 --stack-first;wasm32-unknown-emscripten 例外,它改用 emcc 链接、pre_link_args 被置空),于是布局是「影子栈 → 静态数据 → 堆」:__stack_pointer 初值指向 1MB 处(栈顶),栈向低地址增长——栈溢出会下溢到地址 0 附近直接 trap,而不是悄悄踩坏静态数据(这正是 --stack-first 的目的)。需要影子栈的函数在入口递减 __stack_pointer 分配帧空间,出口恢复。只有放不进 WASM local 的数据(被取地址的局部变量、较大的聚合等)才占影子栈,帧大小由 LLVM 后端布局栈帧时算出;像 add 这样只用 local 的函数完全不碰 __stack_pointer。
5.5 用 wasm2wat 检查编译产物
wasm2wat 是 WABT(WebAssembly Binary Toolkit)工具链中的反汇编器——把 .wasm 二进制转换为人类可读的 WAT(WebAssembly Text Format)。它是理解 Rust 编译产物的利器。
安装和使用
# 用系统包管理器安装 WABT(也可以从 GitHub Releases 下载预编译包)
brew install wabt # macOS
apt install wabt # Debian / Ubuntu
# 反汇编 .wasm 文件
wasm2wat target/wasm32-unknown-unknown/release/my_module.wasm -o output.wat
示例:一个简单函数
// Cargo.toml 里需要 [lib] crate-type = ["cdylib"]
#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
(Rust 1.96 下 cargo new 默认 edition 2024,no_mangle 属于 unsafe 属性,裸写 #[no_mangle] 会报错,必须写成 #[unsafe(no_mangle)];本章后面的示例同此。)
编译并反汇编:
cargo build --target wasm32-unknown-unknown --release
wasm2wat target/wasm32-unknown-unknown/release/my_module.wasm
输出(Rust 1.96.0 实测,用 wasm-tools print 打印并省略了末尾的 producers/target_features 自定义段;wasm2wat 的格式略有差别,指令相同):
(module $my_module.wasm
(type (;0;) (func (param i32 i32) (result i32)))
(table (;0;) 1 1 funcref)
(memory (;0;) 16)
(global $__stack_pointer (;0;) (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))
(func $add (;0;) (type 0) (param i32 i32) (result i32)
local.get 1
local.get 0
i32.add
)
)
从这个输出可以看出:Rust 编译器为 add 函数生成了最精简的 WASM——两个 local.get + 一个 i32.add,没有任何冗余指令(加法可交换,LLVM 实际先压 b 再压 a)。export "memory" 是链接器 wasm-ld 默认导出的线性内存(#![no_std] 的模块同样有这一项,与标准库无关);__data_end、__heap_base 两个导出来自 rustc 传给链接器的 --export=__heap_base --export=__data_end(见 5.9 节的链接命令)。memory 16 表示初始 16 页(16 × 64KB = 1MB)——这个初值来自链接期的布局:rustc 默认给影子栈留了整整 1MB(compiler/rustc_target/src/spec/base/wasm.rs:13 的 -z stack-size=1048576),正好 16 页,所以最小的模块就是 16 页起步;带静态数据的模块还要在这之上按页往上取整,页数只多不少。堆再不够时由 dlmalloc 调用 memory.grow 增长。
示例:带堆分配的函数
换一个会走堆的函数,看看分配器是怎么被拉进来的(下面这段是示例代码,不是引自任何源码文件):
#[unsafe(no_mangle)]
pub extern "C" fn make_string() -> *mut String {
Box::into_raw(Box::new(String::from("hello")))
}
反汇编后可以看到 call 指令调用 __rust_alloc(std 的分配器入口,默认一路转到 dlmalloc 的 malloc)。下面是 Rust 1.96.0 --release 实际产物的节选(wasm-tools print;标准库符号是 v0 修饰名,这里把 $_RNvCs…___rust_alloc 之类缩写成 $__rust_alloc,并省略了分配失败的错误处理分支):
(func $make_string (type 4) (result i32)
(local i32 i32)
;; 先分配 "hello" 的缓冲区
i32.const 5
i32.const 1 ;; 对齐 = 1
call $__rust_alloc
... ;; 从数据段把 "hello" 5 个字节拷进去
;; 再分配 String 结构体(3 个 usize = 12 字节在 wasm32 上)
i32.const 12
i32.const 4 ;; 对齐 = 4
call $__rust_alloc
local.tee 1
...
;; 初始化 String 的字段:偏移 0 = capacity,4 = ptr,8 = len
local.get 1
i32.const 5
i32.store offset=8
local.get 1
local.get 0
i32.store offset=4
local.get 1
i32.const 5
i32.store
local.get 1
return
...)
示例:match 表达式
#[unsafe(no_mangle)]
pub extern "C" fn classify(n: i32) -> i32 {
match n {
0 => 0,
1 => 1,
2..=10 => 2,
_ => 3,
}
}
先看一个反直觉的结果:这个每条分支都只产出一个常量的 match,--release 下根本没有 br_table——LLVM 把它折成了两次比较加两条 select(Rust 1.96.0 实测,wasm-tools print):
(func $classify (;0;) (type 3) (param i32) (result i32)
local.get 0
i32.const 2
i32.const 3
local.get 0
i32.const -2
i32.add
i32.const 9
i32.lt_u ;; (n - 2) <u 9,即 2 <= n <= 10
select ;; 是 → 2,否 → 3
local.get 0
i32.const 2
i32.lt_u ;; n <u 2,即 n 为 0 或 1
select ;; 是 → n 本身,否 → 上一步的结果
)
让各分支带上副作用(这里分别调用四个宿主导入函数 a/b/c/d,声明在 #[link(wasm_import_module = "env")] unsafe extern "C" { ... } 里),br_table 才会出现:
#[unsafe(no_mangle)]
pub extern "C" fn dispatch(n: i32) {
unsafe {
match n {
0 => a(),
1 => b(),
2..=10 => c(),
_ => d(),
}
}
}
(func $dispatch (;4;) (type 1) (param i32)
block ;; label = @1
block ;; label = @2
block ;; label = @3
local.get 0
br_table 0 (;@3;) 1 (;@2;) 2 (;@1;) ;; n=0→@3,n=1→@2,其余→@1(默认)
end
call $a
return
end
call $b
return
end
block ;; label = @1
local.get 0
i32.const -2
i32.add
i32.const 9
i32.lt_u ;; 2 <= n <= 10 ?
br_if 0 (;@1;)
call $d
return
end
call $c
)
(导入函数的名字在实际产物里是带哈希的修饰名,这里缩写成 $a 等。)br_table 只覆盖了 0、1 两个索引——其余的值(包括负数,br_table 把索引按无符号数处理)都落到默认目标,然后在那里用 (n - 2) <u 9 做一次范围检查。这是 LLVM 的 switch lowering 策略:对小的连续值用 br_table,对范围用比较指令;而分支只是在几个常量间选择时,它更愿意生成无分支的 select。
5.6 std 在 WASM 中的限制与 no_std
受限的 std 功能
wasm32-unknown-unknown 上 std 的限制不是 Rust 编译器的选择,而是平台能力缺失的必然结果。WASM MVP 没有文件系统、网络、线程、时钟——std 的这些模块没有底层 API 可以调用。
std 在 wasm32-unknown-unknown 上的具体行为:
这些平台适配模块都用 cfg_select! 分派,wasm32-unknown-unknown 匹配不到任何具名分支,落进最后那个 _ => 兜底臂——也就是 unsupported 实现(例如 library/std/src/sys/time/mod.rs:47 的 _ => { mod unsupported; use unsupported as imp; })。
| 模块 | 行为 | 源码 |
|---|---|---|
std::fs |
能编译;每个入口返回 ErrorKind::Unsupported 的 Err |
library/std/src/sys/fs/unsupported.rs:187(File::open 的函数体就是一句 unsupported()) |
std::net |
能编译;同样是 unsupported 空实现,连接/监听一律返回 Err |
library/std/src/sys/net/connection/unsupported.rs:10 |
std::thread::spawn |
能编译;底层 Thread::new 返回 Err,spawn 随即 panic |
library/std/src/sys/thread/unsupported.rs:19 返回 Err(io::Error::UNSUPPORTED_PLATFORM);library/std/src/thread/functions.rs:131 是 .expect("failed to spawn thread") |
std::time::Instant::now() |
panic("time not implemented on this platform") | library/std/src/sys/time/unsupported.rs:13 |
std::time::SystemTime::now() |
panic(同一句) | library/std/src/sys/time/unsupported.rs:35 |
std::env::args() |
返回空迭代器(next() 恒为 None) |
library/std/src/sys/args/unsupported.rs:21 |
std::io::stdout() |
可以创建,但 write 是 no-op——直接 Ok(buf.len()),假装全写成功 |
library/std/src/sys/stdio/unsupported.rs 的 impl io::Write for Stdout |
std::panic::catch_unwind |
能调用,但目标默认 panic_strategy: Abort(rustup 分发的 std 也按 abort 预编译),panic 直接 trap,捕获不到 |
compiler/rustc_target/src/spec/base/wasm.rs:68 |
std::sync::Mutex |
可用(但单线程下无意义,除非用 SharedArrayBuffer) | — |
std::sync::Arc |
可用(引用计数在单线程下正常工作) | — |
表中 fs、net、args、stdout 四行的返回值,以及 Instant::now()、thread::spawn、catch_unwind 三处以 unreachable trap 告终,都用 Rust 1.96.0 编出的模块在 Node.js 24 里实跑确认过;没有安装 panic hook 时,宿主只看到 RuntimeError: unreachable,看不到 panic 消息。
no_std 场景
对于体积极度敏感的场景(< 10KB 的 .wasm),可以完全不用 std,只用 core + alloc:
#![no_std]
extern crate alloc;
use alloc::vec::Vec;
// 还需要一个 #[panic_handler] 和一个 #[global_allocator],见下文
#[unsafe(no_mangle)]
pub extern "C" fn process(ptr: *const u8, len: usize) -> usize {
let data = unsafe { core::slice::from_raw_parts(ptr, len) };
let out: Vec<u8> = data.iter().map(|&b| b.wrapping_add(1)).collect();
out.len()
}
(导出函数只用 i32 级别的参数:&[u8]、Vec<u8> 不是 FFI 安全类型,放在 extern "C" 签名里会触发 improper_ctypes_definitions 警告,而且按 5.4 节的规则会被间接传递。)
no_std + alloc 剥离了 std 中大部分 I/O 和 OS 相关代码,只保留核心数据结构和分配器。省下来的主要是 panic 格式化、backtrace 相关的代码和字符串常量——具体能省多少完全取决于项目实际用到了 std 的哪些部分,需要自己用 twiggy 对比前后产物。
no_std 的代价:
- 没有
println!:格式化机制本身还在——core::fmt在no_std下完整可用,format!也由alloc提供(library/alloc/src/macros.rs:111的macro_rules! format)。真正没有的是println!/print!:它们要 std 的 stdout,而wasm32-unknown-unknown上根本没有。想省掉core::fmt那套体积(它是no_std产物里最大的一块之一),得换ufmt这类微格式化库。 Errortrait 已经不是问题:core::error::Error自 Rust 1.81.0 起稳定(library/core/src/error.rs:2的#![stable(feature = "error_in_core", since = "1.81.0")]),no_std下可以直接用;只有 1.81 之前的工具链才需要自己实现 trait。- 没有
std::panic的 unwind:必须panic=abort。 - 需要自定义 panic handler。
no_std 下没人替你兜底,必须自己写一个。以下是示例代码,不是引自任何源码文件:
#![no_std]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
// 在 WASM 中,最简单的 panic handler 是直接 trap
core::arch::wasm32::unreachable()
}
自定义全局分配器
no_std 场景下用到 alloc 就必须声明全局分配器——否则编译期直接报 no global memory allocator found but one is required。可以借机选择比 dlmalloc 更小的实现:
#![no_std]
extern crate alloc;
// 显式声明一个全局分配器;`talc`、`lol_alloc` 这类为 WASM 设计的
// 小体积分配器可以替换掉 `std` 默认带的 `dlmalloc`
// (以下为 talc 5.1 的写法;talc 4.x 是 `talc::TalckWasm::new_global()`,
// 且在 stable 上要关掉默认 feature:default-features = false, features = ["lock_api"])
#[global_allocator]
static ALLOC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator();
// 如果连 alloc 都不需要,可以完全不声明全局分配器
// 但这时 Vec, String, Box 等都不可用
上面两段与前面的 #[panic_handler] 拼在一起,用 talc 5.1.1、Rust 1.96.0 编到 wasm32-unknown-unknown 并在 Node.js 里调用验证过。
需要提醒的是:过去被广泛推荐的 wee_alloc 已经停止维护(仓库归档,存在未修复的内存泄漏问题),不要在新项目里使用。
5.7 WASM 内置指令与 Rust intrinsics
Rust 的 core::arch::wasm32 模块提供了对 WASM 特有指令的直接访问——和 core::arch::x86_64 提供对 x86 SIMD 指令的访问类似。这些 intrinsics 让 Rust 代码在 WASM 中获得超越标准库的能力。
内存指令 intrinsics
use core::arch::wasm32::*;
// 内存增长:对应 memory.grow 指令
let old_pages = memory_grow::<0>(1); // 内存 0,增长 1 页
if old_pages == usize::MAX {
// 增长失败
}
// 内存大小:对应 memory.size 指令
let current_pages = memory_size::<0>();
注意签名(📄 library/stdarch/crates/core_arch/src/wasm32/memory.rs:53 与同文件 28 行):pub fn memory_grow<const MEM: u32>(delta: usize) -> usize 与 pub fn memory_size<const MEM: u32>() -> usize——内存索引是常量泛型参数而不是普通实参,为将来的多内存预留;但 1.96.0 的实现里有 static_assert!(MEM == 0),目前只能写 0,写别的值编译不过。这两个函数是 Rust 全局分配器底层调用的原语——dlmalloc 在堆空间不足时调用 memory_grow 申请新页。
控制流 intrinsics
控制流这边只有一个 intrinsic,用法是这样(示例代码):
use core::arch::wasm32::*;
// unreachable 指令:触发 trap
fn assert_false() -> ! {
unreachable(); // 编译为 WASM 的 unreachable 指令
}
unreachable()(📄 library/stdarch/crates/core_arch/src/wasm32/mod.rs:31,签名 pub fn unreachable() -> !)是 no_std 场景下最常用的 panic handler 实现方式——比调用宿主的 abort 函数更快,因为它只有一条指令(操作码 0x00)。
SIMD intrinsics
WASM 的 Fixed-width SIMD 提案早已走完流程——它列在 WebAssembly/proposals 的 finished-proposals 里,2021-07-14 由 WG 通过,并入核心规范 2.0(同一张表里 Relaxed SIMD 是 3.0)。它引入了 128 位向量类型 v128 和一系列 SIMD 指令。Rust 通过 core::arch::wasm32 暴露这些指令:
// 需以 -C target-feature=+simd128 编译
use core::arch::wasm32::*;
fn add_arrays_simd(a: &[i32], b: &[i32], out: &mut [i32]) {
assert!(a.len() == b.len() && a.len() == out.len());
let n = a.len() / 4 * 4;
for i in (0..n).step_by(4) {
// SAFETY: i + 4 <= n <= len;v128_load/v128_store 不要求对齐
unsafe {
let va = v128_load(a[i..].as_ptr() as *const v128);
let vb = v128_load(b[i..].as_ptr() as *const v128);
let result = i32x4_add(va, vb);
v128_store(out[i..].as_mut_ptr() as *mut v128, result);
}
}
for i in n..a.len() {
out[i] = a[i].wrapping_add(b[i]); // 不足 4 个的尾部逐个处理
}
}
WASM SIMD 指令集覆盖了常见的向量操作:整数/浮点算术、比较、位运算、shuffle、load/store。和 x86 的 SSE/AVX 指令相比,WASM SIMD 是一个固定 128 位宽度的指令集——没有 256 位或 512 位变体。这简化了实现,但限制了峰值吞吐量。
Rust 的 std::simd(便携式 SIMD API)在 wasm32 目标上会映射到 v128 指令——和 x86_64 上映射到 SSE/AVX 类似。抽象层让同一份 Rust 代码在不同平台上利用 SIMD 加速;不过它在 1.96.0 仍是 unstable(#![feature(portable_simd)],只能在 nightly 用),stable 上只能用 core::arch::wasm32 的 intrinsics。
原子指令 intrinsics
use core::arch::wasm32::*;
// 等待:对应 memory.atomic.wait32 指令
unsafe fn wait_until_changed(ptr: *mut i32, expected: i32) {
// timeout_ns 传 -1 表示无限等待
memory_atomic_wait32(ptr, expected, -1);
}
// 唤醒:对应 memory.atomic.notify 指令
unsafe fn wake_one(ptr: *mut i32) -> u32 {
memory_atomic_notify(ptr, 1)
}
需要注意 core::arch::wasm32 在原子这一块只暴露了三个函数:memory_atomic_wait32、memory_atomic_wait64、memory_atomic_notify(都需要 +atomics,而且在 1.96.0 仍标着 #[unstable(feature = "stdarch_wasm_atomic_wait")],只能在 nightly 上用)——整个 library/stdarch/crates/core_arch/src/wasm32/atomic.rs 就这三个 pub unsafe fn,分别在 40、71、94 行。各种 *.atomic.rmw.* 指令没有专门的 intrinsic——它们由 core::sync::atomic::AtomicI32 等类型的常规方法在 codegen 阶段直接生成,和 x86_64 上生成 lock cmpxchg 是同一套机制。
intrinsics 的局限性
core::arch::wasm32 不包含所有 WASM 指令——比如 call_indirect 没有对应的 intrinsic(Rust 的动态分发由编译器自动生成),br_table 没有对应的 intrinsic(Rust 的 match 由编译器自动选择最优的跳转策略)。intrinsics 只暴露那些标准库无法通过常规 Rust 代码生成的指令。
5.8 Rust 特有构造的 WASM 映射
泛型与单态化
Rust 的泛型在编译时单态化——为每个具体类型生成一份特化代码。这对 WASM 体积有直接影响:
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
// 调用处
add(1i32, 2i32); // 生成 add_i32
add(1i64, 2i64); // 生成 add_i64
add(1.0f64, 2.0f64); // 生成 add_f64
编译到 WASM 后,三个调用点分别生成三个函数。如果 add 是一个大型泛型函数,而 T 被实例化了 10 种类型,WASM 模块中就有 10 份函数体——体积膨胀是真实的风险。
优化策略:
-
减少单态化实例:用
dyn Trait替代部分泛型,将编译期多态变成运行时多态。代价是间接调用(call_indirect),但减少代码体积。 -
把与
T无关的逻辑挪出泛型函数:实例数量由调用处用到多少种具体类型决定,trait 约束写多写少并不改变它;真正能压体积的是让每份实例变薄——泛型外壳只做类型转换(如AsRef<[u8]>转成&[u8]),把主体放进一个非泛型的内部函数,只编译一份。 -
wasm-opt --duplicate-function-elimination:合并二进制级别相同的函数体(不同名字但指令完全相同的函数)。这个优化在 Rust 编译到 WASM 的场景下效果显著——Rust 的泛型单态化经常生成指令完全相同但名字不同的函数(比如Vec<u8>::push和Vec<i8>::push在 WASM 层面完全一样,因为u8和i8都是i32)。
闭包
Rust 的闭包编译为匿名结构体 + Fn trait 实现。在 WASM 中:
let multiplier = |x: i32| x * factor;
编译后等价于:
struct ClosureEnv {
factor: i32,
}
impl Fn<(i32,)> for ClosureEnv {
extern "rust-call" fn call(&self, args: (i32,)) -> i32 {
args.0 * self.factor
}
}
闭包的 env 结构体默认放在调用者的栈帧(影子栈)上,只有被 Box 起来才进堆。调用方式取决于闭包怎么被传递:作为泛型参数 impl Fn 传下去时会跟着单态化,编译出来就是一条普通 call,甚至直接内联;只有被擦成 &dyn Fn/Box<dyn Fn> 或函数指针 fn(...) 时,才落到 call_indirect。
在 WASM 中,一旦真的走到间接调用,开销比原生平台多一层:call_indirect 需要做表查找和运行期签名检查(第 4 章),而原生平台的间接调用也要先把目标地址读进寄存器再跳转。单次差距很小,但在热循环中会累积——要不要在意,取决于实测。
动态分发
dyn Trait 在 WASM 中的实现和原生平台一致——vtable 指针 + 数据指针的胖指针。差异在于:WASM 的 call_indirect 比 call 多了表查找和签名检查,原生平台上间接调用相对直接调用的额外成本,首先来自无法内联(连带丢掉常量传播、死代码消除等跨函数优化),其次才是间接跳转的分支预测失败。两边都是「间接比直接贵」,具体贵多少必须在目标运行时上实测,不存在一个通用数字。
vtable 在 WASM 中的布局:Rust 的 vtable 是一个结构体,包含一组函数指针。在 wasm32 目标上,每个函数指针是一个 i32——对应 WASM 表中的索引。调用 dyn Trait 的方法时:
- 从胖指针的数据部分取得
self指针 - 从胖指针的 vtable 部分取得 vtable 地址(方法槽位的偏移是编译期常量)
- 读取 vtable 中的
i32函数索引 - 用
call_indirect调用表中该索引的函数
以 Rust ABI 接收 &dyn Trait 的函数为例,1.96.0 --release 的实际产物只有四条指令:local.get 0(self)、local.get 1(vtable)、i32.load offset=12(第一个方法的槽位,前面三个字是 drop、size、align)、call_indirect。胖指针的两半直接在 local 里,线性内存访问只有读 vtable 槽位这一次,和原生平台相同;多出来的是 call_indirect 自己的表查找和签名检查。
panic 处理
Rust 的 panic 在 WASM 中有两种策略:
panic=abort(wasm32 目标的默认值):panic 时调用 panic hook 后触发 abort() -> trap。体积更小,但 panic 无法被捕获。这个默认值不来自 Cargo profile,而是写在目标定义里——compiler/rustc_target/src/spec/base/wasm.rs:68 的 panic_strategy: PanicStrategy::Abort,走这个基线的 wasm 目标都继承它(wasm32-unknown-emscripten 例外,它在自己的定义里改成了 PanicStrategy::Unwind,rustc --print cfg 显示 panic="unwind")。
panic=unwind:生成 WASM 异常处理指令,需要运行时支持异常处理提案。按 rustc 自带的平台文档(src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md 的 Unwinding 一节),rustup 分发的 std 是按 panic=abort 预编译的,要用 unwind 必须 nightly -Zbuild-std 重建 std;而且 LLVM 默认仍生成旧版(legacy)的 try 指令,要得到标准的 try_table 还得加 -Cllvm-args=-wasm-use-legacy-eh=false。
# Cargo.toml — 显式写出来,避免 workspace 里其它配置把它改掉
[profile.release]
panic = "abort"
坚持 panic=abort 的原因:大多数 WASM 场景不需要 catch panic(trap 后整个实例不可用),而 unwind 需要额外的异常表和 landing pad,会让产物变大。
panic=abort 下的 panic 流程:
- Rust 的
panic!()宏(2021 及以后的 edition)展开为core::panicking::panic_fmt一类调用,进入 std 的 panic 处理 - 调用 panic hook——默认 hook 把消息写到 stderr,而
wasm32-unknown-unknown的 stderr 是空实现,所以什么也看不到;随后调用abort() abort()在 wasm 上编译成unreachable指令。链接了wasm-bindgen也一样:panic=abort下它不接管 panic,想在浏览器控制台看到消息得自己装console_error_panic_hook这类 hook。它导入的__wbindgen_throw(wasm-bindgen/src/lib.rs:1262)服务于wasm_bindgen::throw_str等显式抛错的 API,不在 panic 路径上unreachable触发 trap:V8(浏览器、Node)里是RuntimeError: unreachable,Wasmtime 44 报wasm trap: wasm `unreachable` instruction executed
5.9 链接:wasm-ld 的角色
LLVM 编译 Rust 代码为 .o 目标文件(wasm 格式),wasm-ld(LLVM 的 wasm lld 链接器)把多个 .o 文件合并为一个 .wasm 模块。
flowchart LR
A["Rust 源码"] --> B["rustc → .o 文件<br/>(wasm 格式)"]
B --> C["标准库 .rlib"]
B --> D["第三方 crate .rlib"]
C --> E["wasm-ld<br/>(链接器)"]
D --> E
B --> E
E --> F[".wasm 模块<br/>(单一文件)"]
F --> G{"目标三元组?"}
G -->|"wasm32-unknown-unknown"| H["直接输出 .wasm"]
G -->|"wasm32-wasip2"| I["wasm-component-ld<br/>→ .wasm 组件"]
style E fill:#6366f1,color:#fff
style I fill:#22d3ee,color:#333
链接过程:
-
符号解析:每个
.o文件声明它导出的符号和需要导入的符号。链接器把导入符号与对应的导出符号绑定。未解析的符号会导致链接错误——std 本身在 wasm 目标上是完整的(缺失的能力都接到了unsupported空实现上),常见的来源是extern块里声明、却没有任何目标文件提供定义的函数。Rust 1.96.0 起 rustc 不再给 wasm 目标传--allow-undefined(1.96.0 发布说明的 Compatibility Notes),这类符号不会再悄悄变成env模块的导入,而是直接报undefined symbol;要从宿主导入,得在extern块上写明#[link(wasm_import_module = "env")]。 -
段合并:相同类型的段(Type、Function、Memory、Data 等)合并为一个。Type 段中相同的函数签名会被去重——Rust 的单态化可能在不同
.o文件中生成相同的签名,链接器合并时只保留一份。 -
地址重定位:修正代码中的符号引用——函数索引、内存偏移、表索引等。WASM 的重定位类型和 ELF 不同——WASM 用函数索引而非地址引用函数,用段内偏移而非虚拟地址引用数据。
-
生成 .wasm:输出最终的
.wasm文件。
rustc 实际传给链接器的参数可以从链接报错的 note 里直接看到。Rust 1.96.0 以 -O 编 wasm32-unknown-unknown 的 cdylib 时,命令行是(省略了 rlib 列表):
"rust-lld" "-flavor" "wasm" "--export" "run" "--export=__heap_base" "--export=__data_end"
"-z" "stack-size=1048576" "--stack-first" "--no-demangle" "--no-entry" <目标文件与 std 各 rlib>
"-o" "und.wasm" "--gc-sections" "--no-entry" "-O3"
其中 -z stack-size、--stack-first、--no-demangle 来自 base/wasm.rs 的公共 pre_link_args,第一个 --no-entry 来自 wasm32_unknown_unknown.rs,--export 列出 #[unsafe(no_mangle)] 导出的符号(这里是 run),末尾的 --no-entry 与 --gc-sections 由 rustc_codegen_ssa 的 WasmLd 按 cdylib 输出类型追加。
对于 wasm32-wasip2 目标,链接器换成 wasm-component-ld——它在 wasm-ld 的基础上额外处理组件模型的元数据(WIT 接口声明、世界定义等)。wasm-component-ld 的输出是一个 WASM 组件——在裸 .wasm 模块外面包了一层适配代码,把 WASI 的 wasi:io/... 接口映射到模块的导入/导出。
死代码消除
链接器的一个重要优化是死代码消除(dead code elimination, DCE)。这不是 wasm-ld 自作主张——是 rustc 主动传的:只要没开 -C link-dead-code,compiler/rustc_codegen_ssa/src/back/link.rs:2739 就会调 cmd.gc_sections(...),而 wasm 那条实现(compiler/rustc_codegen_ssa/src/back/linker.rs:1358 起的 impl Linker for WasmLd,其 gc_sections 在 :1415)无条件加上 --gc-sections——注意它和 Unix 的 GccLinker 不同,后者在产 dylib 时会跳过。只是 wasm 侧回收的粒度是符号而非 ELF 意义上的 section:
- 导出可达性:只有从导出函数可达的代码和数据才会被保留。如果 Rust 代码中有未被任何导出函数调用的
pub函数,链接器不会包含它。 wasm-opt:Binaryen 的wasm-opt工具(不是 WABT)可以在链接之后再清一遍——--dce删函数体内不可达的代码(Binaryen 126src/passes/pass.cpp:132的说明是 "removes unreachable code"),--remove-unused-module-elements删没人引用的函数、全局和导入。
Rust 的链接器在 wasm32 目标上默认做 DCE——cargo build --release 输出的 .wasm 只包含从 #[unsafe(no_mangle)] 或 #[wasm_bindgen] 标记的函数可达的代码。但如果使用 wasm-bindgen,很多内部辅助函数(如 __wbindgen_malloc)也会被导出——这些是 JS 胶水代码需要调用的。
重定位类型
WASM 的重定位类型和 ELF 有本质差异——WASM 用索引而非地址引用符号。wasm-ld 需要处理以下重定位类型:
| 重定位类型 | 说明 | 示例 |
|---|---|---|
R_WASM_FUNCTION_INDEX_LEB |
函数索引(LEB128 编码) | call 指令的目标函数索引 |
R_WASM_TABLE_INDEX_SLEB |
函数在表中的索引(有符号 LEB128) | 取函数指针时 i32.const 里的表索引值 |
R_WASM_MEMORY_ADDR_SLEB |
内存地址(有符号 LEB128) | i32.const 中的地址常量 |
R_WASM_MEMORY_ADDR_LEB |
内存地址(无符号 LEB128) | i32.load 的 offset 立即数 |
R_WASM_GLOBAL_INDEX_LEB |
全局变量索引 | global.get/global.set 的目标索引 |
R_WASM_TYPE_INDEX_LEB |
类型索引 | call_indirect 的 type 立即数 |
这些重定位类型说明了一个关键设计:WASM 的代码不是位置无关的——函数索引、内存偏移在链接时确定。但 WASM 的"位置"不是内存地址,而是模块内的索引——模块在实例化时由运行时分配实际的内存地址。这种两级间接(索引 -> 运行时地址)是 WASM 可移植性的基础。
5.10 target-feature:启用 WASM 提案指令
WASM 的核心规范(MVP)只有最基础的指令集——SIMD、bulk-memory、threads 等都是后续提案。Rust 编译时通过 target-feature 决定生成代码使用哪些提案指令。错误的 target-feature 配置会导致编译产物在某些浏览器上失败、或者错失性能优化。
5.10.1 关键 target-feature 列表
graph TD A["wasm32 target-features"] --> B["默认启用(LLVM generic CPU)"] A --> C["stable 可用、需手动开"] A --> D["仅 nightly"] B --> B1["mutable-globals / sign-ext<br/>multivalue / reference-types<br/>bulk-memory / nontrapping-fptoint<br/>(后两个:Rust 1.87+、LLVM 20+)"] C --> C1["simd128 / relaxed-simd<br/>tail-call / extended-const"] D --> D1["atomics / exception-handling<br/>gc / wide-arithmetic<br/>(unstable:-C 传入只报警告,<br/>#[target_feature] 需 nightly)"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333
这份「默认启用」清单不是 rustc 写死的,而是继承 LLVM generic CPU 的默认值——src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md 明确写了这一点,并且随 LLVM 升级而变化。判断当前工具链到底开了哪些,用 rustc --print cfg --target wasm32-unknown-unknown | grep target_feature 最可靠。Rust 1.96.0(LLVM 22.1.2)实测:wasm32-unknown-unknown、wasm32-wasip1、wasm32-wasip2、wasm64-unknown-unknown 都是 bulk-memory、multivalue、mutable-globals、nontrapping-fptoint、reference-types、sign-ext 六项;wasm32v1-none 只有 mutable-globals。哪些 feature 在 stable 上可用,以 compiler/rustc_target/src/target_features.rs:744 起的 WASM_FEATURES 表为准。
每个 feature 对应一个或多个 WASM 指令:
| feature | 引入的指令 | 浏览器支持 | Wasmtime |
|---|---|---|---|
sign-ext |
i32.extend8_s 等 |
Chrome 74+, FF 62+, Safari 14.1+ | 全版本 |
mutable-globals |
可变全局变量的导入/导出 | Chrome 74+, FF 61+, Safari 13.1+ | 全版本 |
multivalue |
函数/块返回多值(但 Rust 源码不会产出多返回值函数,见 5.4 节) | Chrome 85+, FF 78+, Safari 13.1+ | 0.17+ |
reference-types |
externref、多表、call_indirect 的表索引编码 |
Chrome 96+, FF 79+, Safari 15+ | 0.20+ |
simd128 |
v128.*、i32x4.* 等 |
Chrome 91+, FF 89+, Safari 16.4+ | 0.33+ |
bulk-memory |
memory.copy、memory.fill |
Chrome 75+, FF 79+, Safari 15+ | 0.20+ |
nontrapping-fptoint |
i32.trunc_sat_f32_s |
Chrome 75+, FF 64+, Safari 15+ | 全版本 |
atomics |
i32.atomic.* |
Chrome 74+, FF 79+, Safari 14.1+,且页面需 COOP/COEP | 15+ |
tail-call |
return_call、return_call_indirect |
Chrome 112+, FF 121+, Safari 18.2+ | 22+ |
(浏览器与 Wasmtime 版本取自 webassembly.org 特性表的数据源 WebAssembly/website 仓库 features.json,2026-09-24 核对。)
5.10.2 启用方式:三种粒度
粒度一:项目 .cargo/config.toml(项目级):
(bulk-memory、nontrapping-fptoint 在 1.96 已是默认,写上无害,只是显式记录意图。)
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = [
"-C", "target-feature=+simd128,+bulk-memory,+nontrapping-fptoint",
]
粒度二:构建命令行(一次性):
RUSTFLAGS='-C target-feature=+simd128,+bulk-memory' \
cargo build --target wasm32-unknown-unknown --release
粒度三:函数级(条件编译 + intrinsics):
#[cfg(target_feature = "simd128")]
use core::arch::wasm32::*;
#[cfg(target_feature = "simd128")]
fn fast_sum(data: &[f32]) -> f32 {
let chunks = data.chunks_exact(4);
let tail: f32 = chunks.remainder().iter().sum(); // 不足 4 个的尾部
let mut sum = f32x4_splat(0.0);
for chunk in chunks {
// SAFETY: chunk 恰好 16 字节;v128_load 不要求对齐
sum = f32x4_add(sum, unsafe { v128_load(chunk.as_ptr() as *const v128) });
}
let lanes = [
f32x4_extract_lane::<0>(sum),
f32x4_extract_lane::<1>(sum),
f32x4_extract_lane::<2>(sum),
f32x4_extract_lane::<3>(sum),
];
lanes.iter().sum::<f32>() + tail
}
#[cfg(not(target_feature = "simd128"))]
fn fast_sum(data: &[f32]) -> f32 {
data.iter().sum()
}
5.10.3 兼容性陷阱
启用 simd128 编译的 .wasm 在不支持 simd128 的运行时上实例化失败——不是降级运行,是直接报错。这意味着:
- 默认 feature 集本身已要求 Chrome 96、Firefox 79、Safari 15(由
reference-types、bulk-memory决定,见上表);启用 simd128 后 Firefox 升到 89、Safari 升到 16.4(Chrome 的 91 低于默认集要求的 96,不受影响)——砍掉的主要是旧版 Firefox 与 Safari - WASM 模块没有运行时 feature detection——只能在 JS 侧探测后选择不同的 .wasm
实战模式:双产物 + 运行时探测:
async function loadWasm() {
// JS 侧探测 SIMD
const hasSimd = await WebAssembly.validate(new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version
0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b, // type: () -> v128
0x03, 0x02, 0x01, 0x00, // function: 1 个函数用 type 0
0x0a, 0x16, 0x01, 0x14, 0x00, // code: 段长 22,函数体长 20,0 个 local
0xfd, 0x0c, // v128.const
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 字节立即数
0x0b, // end
]));
const wasmUrl = hasSimd ? '/my-lib-simd.wasm' : '/my-lib-baseline.wasm';
return WebAssembly.instantiateStreaming(fetch(wasmUrl));
}
构建时产出两份 .wasm(启用/不启用 simd128),运行时按浏览器能力选择。
5.10.4 推荐组合
sign-ext、mutable-globals、multivalue、reference-types 在近年的 rustc 上已经默认开启,bulk-memory 和 nontrapping-fptoint 从 Rust 1.87.0(LLVM 20)起也进了默认集——也就是说大多数项目什么都不用配就已经用上了这批指令。真正需要手动做决定的只剩 simd128 和更靠前的提案。这件事在 rustc 源码里有一个很直接的旁证:wasm32-unknown-unknown 的 target spec 不设 options.features(compiler/rustc_target/src/spec/targets/wasm32_unknown_unknown.rs,走 LLVM 的默认集),而 wasm32v1-none 这个目标存在的唯一理由就是把默认集按回 wasm-core-1——它显式写着 options.cpu = "mvp" 与 options.features = "+mutable-globals"(compiler/rustc_target/src/spec/targets/wasm32v1_none.rs:23-24,文件头注释说明了「给想用 stable Rust、又要锁定 WebAssembly 1.0 特性集的人」)。bulk-memory 带来的 memory.copy/memory.fill 会把 memcpy/memset 从循环压成单条指令,对体积和性能都有正面作用,但具体幅度取决于项目里内存拷贝的比重,必须自己实测。
+simd128 单独决定——如果业务有计算热路径(图像/音视频/ML),启用 + 双产物策略;否则不启用避免兼容性问题。
+atomics 目前仍是 unstable 特性:stable 的 rustc 收到 -C target-feature=+atomics 只报一条 unstable feature specified for -Ctarget-feature 警告,但 rustup 分发的 std 没有按 atomics 预编译,真要多线程得用 nightly -Zbuild-std 重建 std(#[target_feature(enable = "atomics")] 这种函数级写法则需要 #![feature(wasm_target_feature)])。仅在确实需要多线程(且部署能配置 COOP/COEP)时启用。开启后 rustc 不再把目标视为单线程(compiler/rustc_codegen_llvm/src/back/write.rs:221 见到 wasm + atomics 就把 singlethread 改回 false),原子操作不能再被降级成普通读写,代价是产物变大。
5.11 构建可重现性与供应链安全
WASM 模块作为可执行二进制,其完整性是供应链安全的关键环节。"昨天的二进制"和"今天的二进制"如果字节相同,CDN/代理/编辑器篡改就一目了然。但换一台机器、换一个目录构建时,Rust 的默认编译产物往往不可重现——同样的源码、同样的命令,可能产生不同字节的 .wasm。
5.11.1 不可重现的来源
flowchart LR
A["相同源码"] --> B{"编译产物相同?"}
B -->|跨机器时常常否| C["差异来源"]
C --> D["编译时间戳"]
C --> E["路径前缀"]
C --> F["工具链与依赖版本"]
C --> G["调试信息"]
style C fill:#dc2626,color:#fff
来源一:编译时间戳。某些 dep 在 build.rs 里嵌入构建时间——每次构建都不同。
来源二:路径前缀。Rust 默认把源码路径嵌入 panic 信息——本项目的文件 Cargo 传的是相对路径,但依赖 crate 的路径是绝对的:A 机器上是 /Users/alice/.cargo/registry/src/…/foo-1.0.0/src/lib.rs,B 机器上是 /home/bob/.cargo/registry/src/…,字节不同。
来源三:工具链与依赖版本。rustc、LLVM、依赖 crate 的版本只要有一个不同,产物就可能不同。常见的误解是「codegen-units > 1 并行编译会让函数顺序随线程调度变化」——rustc 的 CGU 划分是确定性的:用 Rust 1.96.0、codegen-units = 16 清空 target 后连续构建两次(依赖 reqwest 0.12 的 cdylib),两份 .wasm 的 SHA-256 完全相同。
来源四:调试信息。DWARF 里记录编译目录(DW_AT_comp_dir)等绝对路径,同样随机器而变。
5.11.2 实现可重现编译
# Cargo.toml
[profile.release]
codegen-units = 1 # 可选:主要为体积/优化,与可重现性无关
strip = "debuginfo" # 移除调试信息(解决来源四);别用 strip = true,见第 8 章
[profile.release.package."*"]
codegen-units = 1
# 命令行
RUSTFLAGS='--remap-path-prefix=/Users/alice/proj=. \
--remap-path-prefix=/home/bob/proj=.' \
cargo build --target wasm32-unknown-unknown --release
--remap-path-prefix 把绝对路径替换为相对路径——所有机器编译出的 .wasm 中路径相同。依赖 crate 位于 $CARGO_HOME/registry/src 下,这个前缀也要一并映射(如再加一条 --remap-path-prefix=$HOME/.cargo=/cargo);依赖版本则靠提交 Cargo.lock 并用 --locked 构建来固定。
5.11.3 Docker-based 可重现构建
最可靠的方式:在容器中构建,固定所有环境因素:
FROM rust:1.96-slim AS builder
RUN rustup target add wasm32-unknown-unknown
WORKDIR /work
COPY . .
ENV RUSTFLAGS='--remap-path-prefix=/work=.'
RUN mkdir -p /out \
&& cargo build --target wasm32-unknown-unknown --release \
&& cp target/wasm32-unknown-unknown/release/*.wasm /out/
FROM scratch
COPY --from=builder /out/ /
在 CI 中:
- name: Reproducible build
run: docker build --output type=local,dest=./out -f Dockerfile .
- name: Verify hash
run: |
sha256sum out/*.wasm > current.sha256
diff current.sha256 expected.sha256
任何机器(开发者本地、CI、第三方审计)跑同样的 Dockerfile 都得到字节相同的 .wasm——可哈希、可签名、可追溯。
5.11.4 供应链验证:sigstore + cosign
可重现编译的下游应用:用 sigstore/cosign 给 .wasm 签名,运行时验证:
# 发布时签名
cosign sign-blob --output-signature=app.wasm.sig --output-certificate=app.wasm.crt app.wasm
# 部署时验证
cosign verify-blob \
--signature=app.wasm.sig \
--certificate=app.wasm.crt \
--certificate-identity-regexp='^https://github.com/myorg/.*' \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
app.wasm
这个流程让"我下载的 .wasm 来自指定 GitHub Actions 流水线编译"成为可验证的事实——比 SHA-256 哈希更强(哈希只防修改,不防替换)。
供应链安全在 WASM 中尤为重要——一个被篡改的 WASM 模块可能在浏览器中执行任意计算(虽然有沙箱限制,但攻击面包括泄露内存中的敏感数据、操纵业务逻辑)。可重现编译 + 签名验证是基础防线。
5.12 Rust crate 生态在 WASM 上的兼容性
Rust 的 crate 生态规模庞大——但不是所有包都能在 WASM 上工作。理解兼容性边界有助于选型时避免踩坑。
5.12.1 兼容性等级
graph TD
A["Rust crate"] --> B{"WASM 兼容性"}
B --> C["完全兼容<br/>纯计算 + 数据结构"]
B --> D["需要 feature 控制"]
B --> E["仅 wasm32-wasip2 兼容"]
B --> F["不兼容"]
C --> C1["serde, regex, tokio<br/>(部分), chrono, uuid"]
D --> D1["ring → wasm32_unknown_unknown_js feature<br/>getrandom → js / wasm_js feature"]
E --> E1["std::fs, std::net<br/>(原生 socket)"]
F --> F1["mio, raw socket<br/>原生进程操作"]
style C fill:#10b981,color:#333
style D fill:#6366f1,color:#fff
style E fill:#f59e0b,color:#333
style F fill:#dc2626,color:#fff
5.12.2 浏览器端常见 crate 兼容性表
| Crate | wasm32-unknown-unknown | 说明 |
|---|---|---|
| serde / serde_json | ✓ | 完全工作 |
| regex | ✓ | 能编译;但 DFA/Unicode 表会显著抬高产物体积,按需关掉 unicode-* feature |
| chrono | ✓ | 开 wasmbind feature 用 JS Date 拿当前时间 |
| uuid | ✓ | 启用 js feature 用浏览器 RNG |
| reqwest | △ | wasm32 上自动改用浏览器 fetch 后端,无需额外 feature;不支持自定义 connector、代理、连接池等 |
| tokio | △ | 只允许 sync/macros/io-util/rt/time,单线程,无网络 IO |
| futures | ✓ | 全部工作 |
| rand | △ | 需要给 getrandom 开 js(0.2)或 wasm_js(0.3)feature |
| sha2 / blake3 | ✓ | 纯计算,性能优秀 |
| ring | ✓ | 0.17+ 支持 wasm,需 wasm32_unknown_unknown_js feature |
| sqlx | ✗ | 依赖网络栈,不支持浏览器 |
5.12.3 关键 crate 的 WASM 配置
rand / getrandom:
[dependencies]
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
getrandom 在浏览器中通过 crypto.getRandomValues 提供熵源——js feature 启用这个绑定。不加这个 feature 编译时报错"the wasm*-unknown-unknown targets are not supported by default, you may need to enable the "js" feature"(getrandom 0.2.17 的 compile_error!,位于其 lib.rs 第 346 行)。
reqwest:
[dependencies]
reqwest = "0.12"
reqwest 0.12 没有名为 wasm-bindgen 的 feature(写上会被 Cargo 拒绝:unrecognized feature)。它按 target_arch = "wasm32" 条件编译自动切换后端(0.12.28 src/lib.rs:263 的 if_wasm! 宏),在 wasm32 上用 wasm-bindgen + 浏览器 fetch API 而不是 hyper——默认 feature 下就能编过 wasm32-unknown-unknown(已实测 cargo check)。代价:失去自定义 TLS、连接池、proxy 等高级选项。
tokio:
[dependencies]
tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync"] }
在 wasm 目标上(未开 tokio_unstable 时)tokio 只接受 sync、macros、io-util、rt、time 这几个 feature,开别的会撞上 compile_error!("Only features sync,macros,io-util,rt,time are supported on wasm.")(tokio 1.53.1 的 lib.rs 第 479 行);运行时只能是单线程的 rt。time 在没有计时器的环境(如浏览器里的 wasm32-unknown-unknown)调用会 panic。
5.12.4 服务器端(wasm32-wasip2)的更广兼容性
WASI Preview 2 提供了文件 IO、HTTP、socket 等能力——更多 crate 可以工作:
| Crate | wasm32-wasip2 | 说明 |
|---|---|---|
| std::fs | ✓ | 通过 wasi:filesystem |
| std::net::TcpStream | ✓ | 通过 wasi:sockets;1.96.0 的 std 在 wasip2 上走 socket 实现(library/std/src/sys/net/connection/mod.rs:6),宿主须显式授权网络(如 wasmtime run -S inherit-network=y,已实测 TCP 收发),网络授权细节见 §13.8 |
| reqwest | ✗ | 0.12 在所有 wasm32 目标上都走 wasm-bindgen/fetch 后端,不认 wasi:http;WASI 上发 HTTP 用 wasi:http 的绑定(第 13 章) |
| tokio (rt + macros) | △ | 单线程;tokio::net 需 --cfg tokio_unstable 且只在 WASI 上可用(tokio 1.53.1 src/lib.rs 的 Unstable WASM support 一节) |
| sqlx | △ | 官方未声明支持 wasip2,能否使用以其仓库现状为准 |
| async-std | △ | 官方未声明支持 wasip2,以其仓库现状为准 |
经验法则:服务器端 WASI 兼容性比浏览器好——因为 WASI 提供了更接近 Linux 的接口。但仍有限制:多线程、UNIX socket、信号处理在 WASI 都受限。
5.12.5 检查兼容性的工具流程
flowchart TD
A["想引入新 crate"] --> B["在 docs.rs 看是否有 wasm32 target"]
B --> C{"有 wasm32 文档?"}
C -->|是| D["仔细看 feature flags"]
C -->|否| E["搜 GitHub issue"]
D --> F["试编译"]
E --> F
F --> G{"编译通过?"}
G -->|是| H["跑单测验证行为"]
G -->|否| I["看错误信息<br/>缺哪些 feature"]
I --> D
H --> J{"行为正确?"}
J -->|是| K["可以使用"]
J -->|否| L["寻找替代 crate"]
style K fill:#10b981,color:#333
style L fill:#dc2626,color:#fff
docs.rs 默认显示 x86_64-unknown-linux-gnu 文档——上方有 target 切换器,选 wasm32-unknown-unknown 看是否有专门文档。如果没有,通常意味着这个 crate 没考虑 WASM 兼容性——慎用。
5.12.6 替代 crate 的实战清单
某些常用 crate 在 WASM 不工作时的替代:
| 原 crate | 不兼容场景 | 替代 |
|---|---|---|
| openssl | 浏览器(依赖 OS 库) | rustls + ring |
| native-tls | 浏览器 | reqwest(wasm32 上走浏览器 fetch,TLS 由浏览器负责) |
| std::time::SystemTime | 浏览器(默认) | chrono(wasmbind feature) |
| mio / tokio::net | 浏览器 | wasm-bindgen-futures + Web API |
| log4rs | 任何 WASM | tracing + tracing-wasm |
| backtrace | 浏览器 | console_error_panic_hook |
理解这些替代关系有助于在引入新依赖前先做规划——避免"研究两天发现根本不能编"的浪费。
5.12.7 持续追踪生态变化
WASM 生态变化快——半年前不工作的 crate 现在可能支持了。两个跟踪渠道:
- 所用 crate 的 CHANGELOG:wasm 支持的增减通常写在这里
- rust-lang/rust 的发布说明与 issue tracker:跟踪 wasm 目标本身的变化
工程纪律:每个季度做一次"依赖兼容性 review"——确认现有依赖的最新版本仍然工作,看是否有更好的替代。这种小投入避免长期累积的兼容性债务。
5.13 Rust 编译器标志的实战组合
RUSTFLAGS、Cargo.toml profile、target-feature 三层配置共同决定编译产物——理解它们的交互是 WASM 工程的核心技能。配置组合错了,代价是实打实的:增量构建从秒级掉到分钟级,或者 release 产物比该有的大出一大截。
5.13.1 配置层级与作用域
graph TD A["编译配置层级"] --> B["全局:~/.cargo/config.toml"] A --> C["项目:项目根 .cargo/config.toml"] A --> D["Cargo.toml profile"] A --> E["RUSTFLAGS 环境变量"] A --> F["命令行 -C 参数"] G["优先级(高→低),分两条链"] --> H["rustflags:CARGO_ENCODED_RUSTFLAGS > RUSTFLAGS > target.‹triple›.rustflags > build.rustflags<br/>只取第一个存在的来源,不合并"] G --> I["profile:CARGO_PROFILE_* 环境变量 > .cargo/config.toml 的 [profile] > Cargo.toml 的 [profile]"] style E fill:#10b981,color:#333 style D fill:#6366f1,color:#fff
每层有不同生命周期:
- 全局:所有项目共享,最广泛
- 项目:项目级配置,应进 git
- profile:dev/release/profiling 的差异化
- RUSTFLAGS:CI 临时调整或开发实验
- 命令行:单次构建的临时覆盖
多个 .cargo/config.toml 会合并,离当前目录越近的优先,~/.cargo/config.toml 最低。要注意设了 RUSTFLAGS 环境变量后,config 里的 build.rustflags(包括 .cargo/config.toml 里给 wasm32 配的 target-feature)会被整个忽略,而不是追加。
5.13.2 三个推荐 profile 配置
dev profile:编译速度优先
[profile.dev]
opt-level = 1 # 0 太慢,1 是甜点
debug = true # 保留 debug info
incremental = true # 增量编译
codegen-units = 256 # 最大并行
lto = false # dev 不 LTO
[package.metadata.wasm-pack.profile.dev]
wasm-opt = false
效果:首次构建仍然慢,但后续增量构建能压到秒级——具体数字取决于项目规模,自己测。
release profile:体积+性能平衡
[profile.release]
opt-level = 3 # 性能优先
debug = false
codegen-units = 1 # 全局优化
lto = "fat" # 跨 crate 内联
strip = "debuginfo" # 只剥调试信息;strip = true 会删掉 target_features 段,wasm-pack 下 wasm-opt 验证失败(第 8 章)
panic = "abort" # 消除 unwind 表
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O3']
适合:性能敏感的 release(游戏、计算密集)。
release profile:极致体积
[profile.release]
opt-level = "z"
debug = false
codegen-units = 1
lto = "fat"
strip = "debuginfo"
panic = "abort"
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz', '--strip-debug']
适合:浏览器场景(体积敏感,愿意用一点运行时性能换体积)。
profiling profile:性能分析专用
[profile.profiling]
inherits = "release"
debug = true # 保留符号方便 profile
strip = false
[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-O', '-g']
注意 wasm-pack build --profiling 给 cargo 传的其实是 --release(wasm-pack 0.14.0 src/build/mod.rs:93-100),读的只是 [package.metadata.wasm-pack.profile.profiling] 里的 wasm-opt 参数,上面的 [profile.profiling] 不会生效;要用它,得 wasm-pack build --profile profiling(此时 wasm-pack 读的是 [package.metadata.wasm-pack.profile.custom])。
5.13.3 RUSTFLAGS 的常见组合
启用 SIMD(另两项已是默认,写上无害):
RUSTFLAGS="-C target-feature=+simd128,+bulk-memory,+nontrapping-fptoint" \
cargo build --target wasm32-unknown-unknown --release
移除调试信息:
RUSTFLAGS="-C debuginfo=0 -C strip=symbols" \
cargo build --target wasm32-unknown-unknown --release
最大优化(慢编译换最快二进制):
RUSTFLAGS="-C opt-level=3 -C lto=fat -C codegen-units=1 -C embed-bitcode=yes" \
cargo build --target wasm32-unknown-unknown --release
5.13.4 配置陷阱与冲突
graph TD A["配置陷阱"] --> B["profile 冲突"] A --> C["RUSTFLAGS 触发完整重编"] A --> D["target-feature 不兼容"] B --> B1["以为 lto=fat 必须配 codegen-units=1<br/>其实 fat LTO 自己就会合并"] C --> C1["每次改 RUSTFLAGS<br/>所有依赖重新编译<br/>增量构建优势全部作废"] D --> D1["+atomics 但宿主无 SharedArrayBuffer<br/>实例化失败"] style A fill:#dc2626,color:#fff
冲突一:LTO 与 codegen-units 的关系常被说反。lto = "fat" 不需要你手写 codegen-units = 1——fat LTO 本身就是把所有 CGU 的 IR 合并成一个 Module 再优化(compiler/rustc_codegen_llvm/src/back/lto.rs:152 的 run_fat 收一组模块、返回单个模块)。真正的代价是编译时间:合并后无法并行,改任何一处都要重来。
冲突二:RUSTFLAGS 触发重编。RUSTFLAGS 改变会让 Cargo 认为所有依赖需要重编——开发循环中频繁改 RUSTFLAGS 等于每次都全量重编。把稳定的 flags 放 .cargo/config.toml,临时实验才用 RUSTFLAGS。
冲突三:target-feature 与运行时不匹配。开发用 +atomics 编译,但部署到不支持 SharedArrayBuffer 的浏览器——加载失败。生产部署必须做特性检测。
5.13.5 实测:配置组合的影响
各配置的影响方向如下(绝对值高度依赖项目内容与机器,这里只列趋势,具体数字请在自己的项目上测):
| 配置 | 编译时间 | .wasm 体积 |
|---|---|---|
| dev (默认) | 首次较慢 | 最大(未优化、带调试信息) |
| dev opt-level=1 | 首次略慢于默认 dev | 明显小于默认 dev |
| dev 增量构建 | 最快 | - |
| release 默认 | 慢 | 远小于 dev |
| release + LTO | 更慢 | 更小 |
| release + LTO + codegen-units=1 | 最慢之一(无法并行) | 与 fat LTO 相近或略小 |
| release + LTO + opt-level=z | 慢 | 通常最小的 rustc 产物 |
| 上 + wasm-opt -Oz | 再加一步后处理 | 通常还能再小一截 |
趋势是明确的:每加一项优化都要付出编译时间,换来更小的二进制。这笔交易在 CI 里通常划算——构建慢一次,所有用户的加载快一次。
5.13.6 配置审查 checklist
flowchart TD A["项目编译配置审查"] --> B["1. dev profile opt-level=1?"] A --> C["2. release profile lto=fat?"] A --> D["3. release codegen-units=1?"] A --> E["4. panic=abort?"] A --> F["5. strip=debuginfo(不是 true)?"] A --> G["6. RUSTFLAGS 在 .cargo/config(不是临时)?"] A --> H["7. profiling profile 单独存在?"] style A fill:#6366f1,color:#fff
每项不通过都意味着潜在的开发体验或产品质量问题。
5.14 跨平台编译与 Toolchain 升级
WASM 项目的开发者常用 macOS、Linux、Windows——同一份 Rust 代码在不同平台编译应该产出相同的 .wasm。但实践中经常出现"我机器上能跑别人不行"——根因是 toolchain 版本和构建环境差异。
5.14.1 跨平台编译的差异源
graph TD A["跨平台 WASM 编译差异"] --> B["Rust 工具链版本"] A --> C["LLVM 版本(绑定)"] A --> D["路径与文件系统"] A --> E["wasm-bindgen-cli 版本"] A --> F["wasm-opt 版本"] B --> B1["不同 rustc 生成不同字节码"] C --> C1["LLVM 优化策略变化"] D --> D1["路径嵌入到 panic 信息"] E --> E1["生成的 JS 胶水可能不同"] F --> F1["wasm-opt 输出在版本间略有差异"] style A fill:#f59e0b,color:#333
每个差异都可能导致 .wasm 字节级不一致——影响哈希验证、CDN 缓存、签名等下游流程。
5.14.2 跨平台一致性的工程手段
手段一:固定 Rust 版本
# rust-toolchain.toml(提交到 git)
[toolchain]
channel = "1.96.0"
components = ["rustc", "cargo", "clippy", "rustfmt"]
targets = ["wasm32-unknown-unknown", "wasm32-wasip2"]
任何 cargo 命令在该项目目录会自动用 1.96.0——所有平台一致。
手段二:固定 wasm-bindgen-cli 版本
# CI 中精确锁定
- name: Install wasm-bindgen-cli
run: cargo install wasm-bindgen-cli --version 0.2.118 --locked
--locked 让 cargo 用 Cargo.lock 中的精确版本,不解析新版。
手段三:路径标准化
# 任何平台都用相对路径
RUSTFLAGS="--remap-path-prefix=$(pwd)=." cargo build ...
避免不同机器的绝对路径嵌入到 panic 信息。
手段四:Docker 化构建
最可靠——不同开发者机器只跑 docker build,所有差异被容器化掉:
FROM rust:1.96-slim
RUN cargo install wasm-bindgen-cli@0.2.118 --locked
RUN cargo install wasm-pack@0.14.0 --locked
WORKDIR /work
COPY . .
RUN cargo build --target wasm32-unknown-unknown --release
5.14.3 Toolchain 升级的工程流程
升级 Rust 不只是改一个版本号——必须有完整流程避免回归:
flowchart LR
A["决定升级 Rust"] --> B["1. 读 release notes"]
B --> C["2. 创建升级分支"]
C --> D["3. 修改 rust-toolchain.toml"]
D --> E["4. 完整 CI 跑通"]
E --> F["5. 性能对比基准"]
F --> G{"回归 < 5%?"}
G -->|是| H["合并到 main"]
G -->|否| I["排查回归"]
I --> E
style H fill:#10b981,color:#333
style I fill:#dc2626,color:#fff
关键检查点:
- Release notes:必读 Rust 团队的 stable release 公告,关注 wasm 章节
- 性能回归:每次 toolchain 升级都跑性能基准,5% 以上回归必须查
- wasm-bindgen 兼容:
wasm-bindgencrate 与wasm-bindgen-cli的版本必须严格一致(CLI 会校验并直接报错),升级时两者一起动
5.14.4 Rust 版本与生态的对应关系
| Rust 版本 | 同期 wasm-bindgen | 关键 wasm 变化 |
|---|---|---|
| 1.82(2024-10) | 0.2.95 | multivalue、reference-types 进入默认 feature 集 |
| 1.84(2025-01) | 0.2.100 | 移除旧目标名 wasm32-wasi(用 wasm32-wasip1;新名 1.78 引入,1.81 起旧名告警) |
| 1.87(2025-05) | — | 随 LLVM 20,bulk-memory、nontrapping-fptoint 进入默认 feature 集 |
| 1.90(2025-09) | — | x86_64-linux 默认改用 rust-lld(wasm32-unknown-unknown 早在 1.26 就默认用 LLD) |
这四行都能在 Rust 的 RELEASES.md 对应版本段里查到;bulk-memory/nontrapping-fptoint 那条 1.87 的正文没有单独列条目,写明它的是 rustc 自带的平台文档 src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md(默认启用清单里这两项后面标着「Rust 1.87.0+, LLVM 20+」)。
本专栏统一以 wasm-bindgen 0.2.118、wasm-pack 0.14.0 为基准。
工程纪律:升级 Rust 时同步升级 wasm-bindgen、wasm-pack——不要单独升某个。
5.14.5 多平台 CI 的实战配置
# .github/workflows/build.yml
name: Build WASM
on: [push, pull_request]
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Setup Rust
run: rustup toolchain install 1.96.0 --target wasm32-unknown-unknown
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install wasm-pack
run: cargo install wasm-pack@0.14.0 --locked
- name: Build
run: wasm-pack build --release
- name: Verify reproducibility
run: |
sha256sum pkg/*.wasm > "${{ matrix.os }}.sha256"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.os }}-wasm
path: pkg/*.wasm
CI 在三个平台都构建、各自算出 SHA-256,再把几份哈希拿来比对(上面的配置只负责产出,比对这一步需要另加一个汇总 job)——如果不一致,说明有未控制的差异源,必须排查。
5.14.6 升级失败的回滚策略
flowchart TD
A["升级到 Rust 1.X+1"] --> B{"CI 通过?"}
B -->|是| C{"生产部署成功?"}
B -->|否| D["回滚到 1.X"]
C -->|是| E["保留 N-1 版本镜像"]
C -->|否| F["立即回滚"]
E --> G["3 个月稳定后<br/>可清理 N-1"]
style D fill:#f59e0b,color:#333
style F fill:#dc2626,color:#fff
style E fill:#10b981,color:#333
工程纪律:
- 保留 N-1 版本:CI 镜像、Docker 镜像至少保留上一个工作版本
- 快速回滚通道:能在 5 分钟内从新版本切回旧版本
- 金丝雀部署:1% → 10% → 100% 的逐步切流,发现问题及时回滚
升级 Rust 是低频但高风险操作——每次都按完整流程做,避免"小升级大事故"。
5.14.7 长期工具链管理策略
flowchart LR A["年度策略"] --> B["每季度评估升级"] A --> C["Edition 迁移(约三年一次)"] A --> D["补丁版本(patch)出了就跟"] A --> E["Rust 没有官方 LTS"] B --> F["业务空窗期升级"] C --> G["release notes 关注 wasm"] D --> H["小步快跑"] E --> I["保守业务自行锁定版本"] style A fill:#6366f1,color:#fff
| 升级类型 | 频率 | 风险 | 收益 |
|---|---|---|---|
| Patch(1.96.0 → 1.96.1) | 按需(出了补丁版就跟) | 极低 | bug 修复 |
| Minor(1.95 → 1.96) | Rust 每 6 周发一版,团队可按季度集中评估 | 低 | 性能 + feature |
| Edition(2021 → 2024) | 约三年一次 | 中 | 新语法、lint 调整(如 #[unsafe(no_mangle)]) |
5.15 Rust → WASM 编译错误的诊断手册
把 Rust 编译为 WASM 比编译为原生平台更容易出错——某些 crate 不兼容、某些 target-feature 冲突、某些链接错误。这里整理常见错误的快速诊断手册。
5.15.1 错误分类
graph TD A["Rust → WASM 错误"] --> B["编译期"] A --> C["链接期"] A --> D["实例化期"] A --> E["运行期"] B --> B1["target 不支持<br/>feature 错配<br/>std 缺失"] C --> C1["wasm-ld 报错<br/>未定义符号"] D --> D1["验证失败<br/>导入未提供"] E --> E1["trap / panic / 越界"] style B fill:#f59e0b,color:#333 style C fill:#dc2626,color:#fff style D fill:#dc2626,color:#fff style E fill:#6366f1,color:#fff
5.15.2 错误一:getrandom 报错
最常见的 wasm32-unknown-unknown 编译错误(getrandom 0.2.17 实测):
error: the wasm*-unknown-unknown targets are not supported by default, you may need to enable the "js" feature. For more information see: https://docs.rs/getrandom/#webassembly-support
--> …/getrandom-0.2.17/src/lib.rs:346:9
原因:getrandom 在浏览器中需要 js feature 来调 crypto.getRandomValues,默认不启用。
修复:
[dependencies]
getrandom = { version = "0.2", features = ["js"] }
如果不直接用 getrandom 但通过 rand 间接用,需要:
[dependencies]
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
显式声明 getrandom 即使不直接 use——Cargo 才会启用 feature。
注意 getrandom 0.3 换了写法:feature 名从 js 改成 wasm_js。0.3.0–0.3.3 还要额外加一行 rustflags 才生效;0.3.4(2025-10-14)起开 wasm_js feature 就够了,这行 cfg 可写可不写(见其 CHANGELOG 的 0.3.4 条目):
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
rand 0.9 依赖 getrandom 0.3,rand 0.8 依赖 0.2——升级 rand 时这一处必须跟着改,否则报的还是同一个错。
5.15.3 错误二:std::time 在浏览器中
let now = std::time::SystemTime::now();
// 运行时 panic:"time not implemented on this platform"
// (装了 console_error_panic_hook 之类的 hook 才看得到这句,否则宿主只见 unreachable trap)
原因:浏览器没有 SystemTime——必须用 wasm-bindgen 调 Date.now()。
修复:用 chrono 的 wasmbind feature(它经 wasm-bindgen 调 JS Date):
[dependencies]
chrono = { version = "0.4", features = ["wasmbind"] }
let now = chrono::Utc::now();
5.15.4 错误三:动态库不可用
warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`
原因:wasm 目标在 compiler/rustc_target/src/spec/base/wasm.rs:53 里写死了 only_cdylib: true(由 compiler/rustc_session/src/output.rs:134 检查)。直接调 rustc --crate-type dylib 时,dylib 会被丢弃,诊断只是 warning——结果就是"没报错却没产物";经 Cargo 构建则直接报错:cannot produce dylib for ... as the target wasm32-unknown-unknown does not support these crate types(两种情况均在 1.96.0 实测)。
修复:改成 cdylib(C ABI 的动态库;在 wasm 目标上它就是最终那个 .wasm 模块):
[lib]
crate-type = ["cdylib", "rlib"]
cdylib 是 WASM 项目的标准 crate-type。
5.15.5 错误四:unstable feature 报错
error[E0658]: use of unstable library feature `X`
原因:#![feature(...)] 只能在 nightly Rust 用。某些 wasm32 工具链需要 nightly feature。
修复:要么换 stable 替代方案,要么切到 nightly:
# rust-toolchain.toml
[toolchain]
channel = "nightly-2026-04-01" # 锁定具体日期
避免用 nightly(不锁定)——每天的 nightly 都不同,容易破坏。
5.15.6 错误五:linker error / undefined symbol
error: linking with `rust-lld` failed: exit status: 1
= note: rust-lld: error: und.und.cf3a424a33670754-cgu.0.rcgu.o: undefined symbol: host_log
原因:某个函数被引用但没有定义——典型是 extern 块里声明了宿主函数,却没写导入模块名。Rust 1.96.0 起 rustc 不再给 wasm 目标传 --allow-undefined,这类符号不再自动变成 env 的导入,而是直接链接失败(上面是 1.96.0 实测输出)。
修复:在 extern 块上写明从哪个模块导入:
#[link(wasm_import_module = "env")]
unsafe extern "C" {
fn host_log(x: i32);
}
编出来就是 (import "env" "host_log" (func ...))。另外,no_std 下缺 #[panic_handler] 或全局分配器不是链接错误,而是编译期就报:`#[panic_handler]` function required, but not found 和 no global memory allocator found but one is required。补法如下:
// no_std 项目必须提供 panic_handler
#![no_std]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// 如果用了 alloc 也要全局分配器
// (dlmalloc crate 需开 `global` feature:dlmalloc = { version = "0.2", features = ["global"] })
extern crate alloc;
#[global_allocator]
static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
5.15.7 错误六:wasm-bindgen 版本不匹配
报错原文(📄 crates/cli-support/src/wit/mod.rs:1827,逐字照抄,包括上游那句漏了冠词的英文):
it looks like the Rust project used to create this Wasm file was linked against
version of wasm-bindgen that uses a different bindgen format than this binary:
rust Wasm file schema version: <crate 里的版本>
this binary schema version: <CLI 的版本>
原因:wasm-bindgen crate 与 wasm-bindgen-cli 的 schema 版本不一致——CLI 在解析自定义段之前先做这道校验(verify_schema_matches),不匹配就直接 bail!。报错正文里还会给出两条修复命令:cargo update -p wasm-bindgen --precise <CLI 版本>,或者 cargo install -f wasm-bindgen-cli --version <crate 版本>。
修复:让 wasm-pack 自动管理(推荐),或手动对齐:
# 从 Cargo.lock 里取实际锁定的版本(cargo pkgid 输出形如 …#wasm-bindgen@0.2.118)
cargo install -f wasm-bindgen-cli --version "$(cargo pkgid wasm-bindgen | sed 's/.*@//')"
5.15.8 错误七:缺少 WASI 导入
TypeError: WebAssembly.instantiate(): Import #0 "wasi_snapshot_preview1": module is not an object or function
(Node.js 24 实测:把 wasm32-wasip1 编出的模块用空导入对象实例化时的报错;若导入对象里有 wasi_snapshot_preview1 但缺函数,则是 LinkError: ... function import requires a callable。)
原因:编译用了 WASI 目标(wasm32-wasip1,旧名 wasm32-wasi 已在 Rust 1.84 移除),却拿到浏览器里加载(浏览器没有 WASI)。
修复:浏览器项目用 wasm32-unknown-unknown:
# .cargo/config.toml
[build]
target = "wasm32-unknown-unknown"
或者用 wasi-shim 等 polyfill 把 WASI 调用映射到浏览器 API(复杂且不完整)。
5.15.9 错误八:thread::spawn 报错
std::thread::spawn(|| { /* ... */ });
// 运行时 panic:expect("failed to spawn thread"),错误值是
// Unsupported 的 "operation not supported on this platform";
// 没装 panic hook 时宿主只看到 unreachable trap
原因:默认 wasm32-unknown-unknown 单线程——底层 Thread::new 返回 io::Error::UNSUPPORTED_PLATFORM,
thread::spawn 里的 .expect("failed to spawn thread") 随即 panic。
修复:浏览器端要么不用线程(改用 wasm-bindgen-futures 异步),要么用 wasm-bindgen-rayon 这类基于 Web Worker + SharedArrayBuffer 的方案(需要 +atomics、nightly 重建 std,并配置 COOP/COEP);服务端可以用 wasm32-wasip1-threads 目标(注意目标名里是 wasip1 而不是 wasi——wasm32-wasi 这个旧名字已经在 Rust 1.84 移除)。
5.15.10 诊断流程图
flowchart TD
A["Rust → WASM 错误"] --> B{"错误时机?"}
B --> C["编译期"]
B --> D["链接期"]
B --> E["运行期"]
C --> F{"具体信息?"}
F --> G["getrandom → 加 js feature"]
F --> P["no_std 缺 panic_handler / 分配器 → 补上"]
F --> I["dylib → 改 cdylib"]
F --> J["unstable feature → nightly"]
D --> K["undefined symbol"]
K --> L["extern 块补 wasm_import_module"]
E --> M["WASM 调用错误"]
M --> N["wasm-bindgen 版本对齐"]
M --> O["target 选择正确(unknown vs wasi)"]
M --> H["std::time panic → 用 chrono"]
style A fill:#6366f1,color:#fff
5.15.11 错误诊断的工具链
graph LR A["诊断工具"] --> B["cargo check --target wasm32"] A --> C["cargo expand"] A --> D["wasm-tools dump"] A --> E["wasm-tools validate"] A --> F["RUST_LOG=trace"] B --> B1["快速验证编译可行"] C --> C1["看宏展开后真实代码"] D --> D1["看 .wasm 二进制结构"] E --> E1["验证 .wasm 合法"] F --> F1["wasm-bindgen 详细日志"] style A fill:#10b981,color:#333
诊断步骤通常是:先 cargo check 看编译错误;再 wasm-tools validate 看二进制;再 wasm-tools dump 看具体问题段。
5.15.12 常见错误的根因总结
graph TD A["Rust → WASM 高频错误根因"] --> B["1. 默认 feature 不开启<br/>getrandom/wasmbind"] A --> C["2. target 选错<br/>unknown vs wasi"] A --> D["3. 版本不齐<br/>wasm-bindgen vs cli"] A --> E["4. 不支持的 API<br/>thread/file/socket"] A --> F["5. no_std 配置不全"] style A fill:#f59e0b,color:#333
绝大多数 Rust → WASM 报错都落在这 5 类里——遇到错误时按这套思路先分类,比直接去搜报错原文快。
5.16 Rust 异步在 WASM 上的实战
Rust 的 async/await 在原生平台依赖 Tokio/async-std 等运行时——在 WASM 上情况完全不同。理解 WASM 上的 Rust 异步生态,是写出能跑的异步代码的前提。
5.16.1 WASM 上的 Rust 异步全景
graph TD A["Rust 异步在 WASM"] --> B["浏览器"] A --> C["WASI Preview 1"] A --> D["WASI Preview 2"] A --> E["服务端 Wasmtime"] B --> B1["wasm-bindgen-futures<br/>映射到 JS Promise"] C --> C1["不支持原生异步<br/>必须 polling"] D --> D1["wasi:io/poll<br/>原生异步"] E --> E1["tokio(仅 rt feature)"] style B fill:#10b981,color:#333 style C fill:#dc2626,color:#fff style D fill:#6366f1,color:#fff
每个环境有不同方案——同一份 async 代码不能跨环境跑,必须知道目标环境。
5.16.2 浏览器:wasm-bindgen-futures
// Cargo.toml:wasm-bindgen 0.2.118、wasm-bindgen-futures 0.4.68、
// web-sys 0.3.95 且开 features = ["Window", "Response"](已实测 cargo check)
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen]
pub async fn fetch_data(url: &str) -> Result<String, JsValue> {
let window = web_sys::window().unwrap();
let promise = window.fetch_with_str(url);
let response = JsFuture::from(promise).await?;
let resp: web_sys::Response = response.dyn_into()?;
let text_promise = resp.text()?;
let text = JsFuture::from(text_promise).await?;
Ok(text.as_string().unwrap_or_default())
}
wasm-bindgen-futures 在背后做的事:
async fn编译为状态机(Rust 编译器固有能力)- 每个
await点转为返回Pending,让出控制权给 JS 事件循环 - JS Promise 完成时通过
wake()恢复执行
简单但有限制:
- 单线程:JS 事件循环单线程,所有异步任务在主线程跑
- 没有 reactor:
futurescrate 的select!/join!不依赖运行时,照常可用;真正缺的是tokio::net、tokio::time这类要靠 IO/时间驱动的能力 - timer 依赖 setTimeout:精度受浏览器限制
5.16.3 WASI Preview 2:wasi:io/poll
// Preview 2 通过 wasi:io/poll 实现原生异步
use wasi::io::poll;
use wasi::filesystem::types as fs;
async fn read_file_async(path: &str) -> Result<Vec<u8>> {
let descriptor = fs::Descriptor::open_at(path, ...)?;
let stream = descriptor.read_via_stream(0)?;
// 等待数据可读
let pollable = stream.subscribe();
pollable.block(); // 阻塞整个实例直到可读;想同时等多个,用 poll::poll(&[...])
// 数据到达后读取
stream.read(8192)
}
Preview 2 的异步由 host(Wasmtime)调度:多个 pollable 挂起后,host 在自己的 reactor 里等就绪再唤醒对应任务,不必绕道 JS 事件循环。上面这段是示意写法,真实签名以 wasi:filesystem/types 的 WIT 为准(第 13 章)。
5.16.4 选择 Rust 异步运行时
flowchart TD
A["WASM 中的 Rust 异步"] --> B{"目标环境?"}
B --> C["浏览器"]
B --> D["Cloudflare Workers"]
B --> E["WASI Preview 2"]
B --> F["Wasmtime 嵌入"]
C --> G["wasm-bindgen-futures"]
D --> H["worker-rs(封装 Workers API)"]
E --> I["tokio + wasi 适配"]
F --> J["tokio rt feature"]
style G fill:#10b981,color:#333
style I fill:#6366f1,color:#fff
5.16.5 异步代码的可移植性
如果代码要跑在多个环境:
// 推荐:只用 std::future + futures crate
use futures::{Stream, StreamExt};
async fn process_stream<S: Stream<Item = u8> + Unpin>(mut stream: S) {
while let Some(item) = stream.next().await {
// ...
}
}
// 不推荐:直接 use tokio::*(绑定到 tokio)
把"异步逻辑"和"运行时"分离——业务代码只用 std::future + futures crate 的抽象,不直接依赖具体运行时。这样同一份代码既能在 wasm-bindgen 跑、也能在 tokio 跑。
5.16.6 异步性能特征
graph LR A["异步性能"] --> B["浏览器(单线程)"] A --> C["WASI P2(单线程协作)"] A --> D["Tokio 原生(多线程)"] B --> B1["await 唤醒经 JS 微任务队列"] C --> C1["await 唤醒经 host poll 循环"] D --> D1["await 唤醒在进程内,可多核并行"] style D fill:#10b981,color:#333
WASM 上的异步通常比原生 tokio 慢——主要是缺少多线程并行,以及每次唤醒都要穿过 JS/host 边界。但对绝大多数「等 IO 而不是等 CPU」的场景来说完全够用;真要判断够不够,只能在目标运行时上压测。
5.16.7 异步陷阱
graph TD A["WASM 异步陷阱"] --> B["1. blocking 调用"] A --> C["2. 同步回调"] A --> D["3. 长任务"] A --> E["4. 取消语义"] B --> B1["阻塞主线程<br/>整个浏览器卡死"] C --> C1["从 JS 同步调 Rust async fn<br/>得到 Promise 不是结果"] D --> D1["单 task 占主线程 > 16ms<br/>UI 卡顿"] E --> E1["WASM 没有标准 cancellation token"] style A fill:#dc2626,color:#fff
每条都需要专门设计:
- 不要 blocking:浏览器没有
std::thread::sleep,必须用 async timer - 同步回调返回 Promise:JS 调用 async Rust 函数得到 Promise,要 await 才能拿值
- 长任务分片:定期 await 一个基于
setTimeout的 Promise,把控制权交还事件循环 - 取消:用
Drop+ state flag 自己实现
5.16.8 实战:异步 IO 的工程模式
// 示意代码:wait_for_event、yield_to_event_loop、process 需自行实现
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
#[wasm_bindgen]
pub fn start_background_task() {
spawn_local(async {
loop {
// 1. 等待事件
let event = wait_for_event().await;
// 2. 让出控制权给浏览器,避免长任务:要等到下一个宏任务
// (如用 setTimeout(0) 包成的 Promise)——只等一个微任务
// 不会给浏览器渲染和处理输入的机会
yield_to_event_loop().await;
// 3. 处理事件
process(event).await;
// 4. 中间多次让出
yield_to_event_loop().await;
}
});
}
这种"每步骤都让出控制"的模式是 WASM 异步的关键——不让出会导致主线程长时间占用。
5.16.9 异步生态的成熟度
graph LR A["Rust 异步在 WASM"] --> B["wasm-bindgen-futures(成熟)"] A --> C["worker-rs(成熟)"] A --> D["WASI P2 异步(实验中)"] A --> E["WASI P3 原生 async(Rust tier 3)"] style B fill:#10b981,color:#333 style D fill:#f59e0b,color:#333 style E fill:#6366f1,color:#fff
主要工具的成熟度:
- wasm-bindgen-futures:浏览器主流,5+ 年生产级
- worker-rs:Cloudflare Workers 标准
- WASI P2 异步:可用但工具链跟进慢
- P3 原生 async:WASI 0.3.0 已于 2026-06-11 正式发布(本专栏对照的 Wasmtime 44 实现的是 rc-2026-03-15);Rust 已经有 tier-3 的
wasm32-wasip3目标(compiler/rustc_target/src/spec/targets/wasm32_wasip3.rs:20的tier: Some(3),对比 wasip1/wasip2 都是Some(2)),但工具链还没到生产可用
工程建议:当前用 wasm-bindgen-futures,关注 P3 演进。
5.16.10 异步代码的工程清单
flowchart TD A["WASM 异步代码"] --> B["1. 不在 async 中阻塞"] A --> C["2. 长任务分片"] A --> D["3. 异步运行时与逻辑分离"] A --> E["4. 错误处理用 Result"] A --> F["5. 取消语义清晰"] A --> G["6. 性能基准对比"] style A fill:#6366f1,color:#fff
每条都对应过去的 bug——遵循这套清单,WASM 异步代码能像普通 Rust 一样可靠。
5.17 Rust → WASM 项目的安全工程
Rust 因内存安全闻名——但 Rust + WASM 的组合在安全性上仍有盲点。这一节系统整理 Rust → WASM 项目的安全考虑。
5.17.1 Rust + WASM 的安全分层
graph TD A["Rust + WASM 安全"] --> B["语言层"] A --> C["编译层"] A --> D["WASM 层"] A --> E["运行时层"] A --> F["业务层"] B --> B1["Rust 内存安全"] C --> C1["LLVM 优化"] D --> D1["验证器 + 沙箱"] E --> E1["host 实施限制"] F --> F1["输入校验 / 加密"] style A fill:#10b981,color:#333
每层都有独立的安全保证——但盲点也在每层。
5.17.2 Rust 内存安全在 WASM 中的局限
// 这段代码在 Rust 看起来安全
fn process(input: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(input.len() * 2);
// ... 处理 ...
result
}
但 Rust 内存安全不防止:
| 漏洞类型 | Rust 是否防 |
|---|---|
| Use-after-free | ✓ 防 |
| Buffer overflow | ✓ 防(大多数) |
| 整数溢出 | ✗ release 模式不防(wrapping) |
| 逻辑漏洞 | ✗ 不防 |
| 侧信道(时间) | ✗ 不防 |
| Spectre / 微架构 | ✗ 不防 |
WASM 的额外保证(边界检查、沙箱)补充了部分——但仍不完整。
5.17.3 整数溢出的处理
// 反模式:依赖默认行为
fn calculate(a: u32, b: u32) -> u32 {
a + b // release 模式:溢出后按 2^32 取模回绕(debug 模式则 panic)
}
// 推荐:显式处理
fn calculate(a: u32, b: u32) -> Option<u32> {
a.checked_add(b)
}
Rust 的 checked_* / saturating_* / wrapping_* 系列方法明确意图——避免依赖默认行为产生 bug。
5.17.4 unsafe 代码的特殊审查
flowchart TD A["Rust unsafe 代码"] --> B["WASM 中的影响"] B --> C["内存越界写"] B --> D["未初始化读"] B --> E["类型转换错误"] C --> F["WASM 边界检查兜底<br/>但仍能在 WASM 内造成损坏"] D --> G["可能泄漏其他数据"] E --> H["逻辑错误"] style A fill:#dc2626,color:#fff
WASM 的沙箱保护宿主——但不保护 WASM 模块自己。unsafe 代码的 bug 仍能让 WASM 模块内部数据错乱(包括用户敏感数据)。
工程纪律:
unsafe代码必须有详细注释解释 invariant- code review 必须双人审查
- 用
cargo miri检测 UB
5.17.5 依赖审计
# cargo-audit 检查已知漏洞
cargo install cargo-audit
cargo audit
# 输出示例:
# Crate: foo
# Version: 1.2.3
# Title: Buffer overflow in foo::bar
# Date: YYYY-MM-DD
# ID: RUSTSEC-YYYY-NNNN
Rust 的 RustSec 数据库收集 crate 漏洞——cargo audit 在 CI 中自动检查。
- name: Audit dependencies
run: |
cargo install cargo-audit
cargo audit --deny warnings
5.17.6 供应链安全(深入)
flowchart LR A["供应链攻击"] --> B["1. 恶意 crate"] A --> C["2. 依赖污染"] A --> D["3. 编译器后门"] A --> E["4. 注册中心攻击"] style A fill:#dc2626,color:#fff
四类风险的现实程度不同:
- 恶意 crate:模仿热门 crate 名(typosquatting),植入恶意代码——crates.io 上确有先例
- 依赖污染:上游 crate 被攻击者接管,发布恶意版本——在其他语言的包生态里多次发生
- 编译器后门:理论上 rustc 可被植入后门
- 注册中心攻击:crates.io 本身被攻击——目前属于理论风险
防御措施:
Cargo.lock锁定具体版本 + hash- 用
cargo deny限制依赖 - sigstore 签名验证
- 关注社区安全公告
5.17.7 WASM 输入校验
// 即使 Rust 内存安全,输入校验仍必要
#[wasm_bindgen]
pub fn process_user_input(input: &str) -> Result<Output, JsValue> {
// 1. 长度限制(防 DoS)
if input.len() > 1024 * 1024 {
return Err("input too large".into());
}
// 2. 字符集校验
if !input.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err("invalid characters".into());
}
// 3. 业务规则
if !is_valid_format(input) {
return Err("invalid format".into());
}
Ok(do_processing(input))
}
WASM 边界 + Rust 类型安全都不能替代业务级输入校验——这必须显式做。
5.17.8 加密与密钥管理
flowchart TD A["加密数据"] --> B["选择"] B --> C["用 ring(Rust 密码学)"] B --> D["用 Web Crypto(浏览器原生)"] B --> E["用 hardware acceleration"] C --> F["跨平台<br/>需要导入大 crate"] D --> G["浏览器原生<br/>WASM 调用 host API"] E --> H["AES-NI<br/>用 Web Crypto 间接"] style A fill:#6366f1,color:#fff
密码学的工程考虑(§19.3 以 1Password 扩展为原型的案例已涉及):
- 用成熟库(ring/Web Crypto),不要自己实现
- 密钥不出 host(用 keyHandle 引用而非密钥本身)
- 关键操作用 host 硬件加速
5.17.9 拒绝服务(DoS)防御
graph TD A["WASM DoS 防御"] --> B["1. 输入大小限制"] A --> C["2. 复杂度限制(fuel)"] A --> D["3. 内存上限"] A --> E["4. 速率限制"] style A fill:#10b981,color:#333
WASM 应用必须有完整的资源限制——单一防御不够:
// host 设置 fuel:先在 Config 里打开计量
config.consume_fuel(true);
// 限制 wasm 代码在宿主原生栈上可用的栈空间(防深递归打爆栈;
// 注意这不是 Rust 放在线性内存里的影子栈)
config.max_wasm_stack(1_000_000);
let engine = Engine::new(&config)?;
// 内存上限走 ResourceLimiter:把限制器放进 Store 的数据里
let limits = StoreLimitsBuilder::new().memory_size(64 << 20).build();
let mut store = Store::new(&engine, limits);
store.limiter(|limits| limits);
store.set_fuel(10_000_000)?;
// 业务级别速率限制(RateLimiter 为示意类型)
let rate_limiter = RateLimiter::new(100, Duration::from_secs(1));
if !rate_limiter.check() {
return Err("rate limit exceeded");
}
5.17.10 安全工程清单
flowchart TD A["Rust + WASM 安全"] --> B["1. 整数操作显式 checked"] A --> C["2. unsafe 严格审查"] A --> D["3. cargo audit 在 CI"] A --> E["4. 输入校验"] A --> F["5. 资源限制"] A --> G["6. 依赖最小化"] A --> H["7. 持续安全监控"] style A fill:#10b981,color:#333
每条都对应一类常见漏洞。
5.18 跨专栏关联:与 Tokio 运行时的异步对比
本章讨论的 Rust → WASM 编译路径,和《Tokio 源码深度解析》专栏第 4 章「Tokio Runtime 架构总览」有一个关键对照——异步执行模型的差异:
在原生平台上,Tokio 运行时通过 epoll/kqueue 实现 async I/O——Rust 的 async fn 编译为状态机,Tokio 的调度器驱动状态机在 I/O 就绪时恢复执行。
在 wasm32-unknown-unknown 上,没有 epoll/kqueue——WASM MVP 没有异步 I/O 的概念。Rust 的 async/await 在浏览器中需要映射到 JS 的 Promise:
wasm-bindgen-futures提供了spawn_local(async_fn)和JsFuture::from(promise)桥接- 底层是 JS 的事件循环驱动 WASM 中的 async 状态机
在 wasm32-wasip2 上,WASI Preview 2 的 wasi:io/poll 提供了原生的异步 I/O 接口——Wasmtime 可以像 Tokio 一样驱动 async I/O。但语义不同:Tokio 是多线程 work-stealing 调度器,WASI Preview 2 是单线程事件循环。
理解了 Rust 如何编译到 wasm32,下一章看 wasm-bindgen 如何让编译出的代码和 JavaScript 无缝互操作。