Rust + WebAssembly 全链路解析

第5章 Rust 的 wasm32 目标与代码生成

作者 杨艺韬 · 22,002 字 · 发布于 · 更新于

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 这些线程安全约束在进入后端之前就已经检查完毕;切片下标这类边界检查则和原生平台一样,以运行时检查的形式编进产物。

但也存在编译目标相关的差异:

  1. 标准库可用性:std::fs、std::net、std::thread 在 wasm32-unknown-unknown 上编译得过,但标准库把它们接到了 library/std/src/sys/*/unsupported.rs 这套空实现上——File::open 直接返回 ErrorKind::Unsupported 的 Err,Thread::new 同样返回 Err。这是标准库平台适配层的选择,和 MIR 阶段无关。
  2. 代码生成选择:LLVM 的 wasm32 后端生成的指令集不同(第 5.3 节),某些 Rust 构造(如 dyn Trait)的底层实现方式在 WASM 和原生平台上有差异。
  3. 链接器差异: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 才真的发原子指令,那时才需要 SharedArrayBuffer
  • std::panic:panic 处理(默认调用 abort() -> trap)
  • std::marker、std::ops、std::cmp:纯编译期抽象,零运行时依赖
  • std::fmt:格式化输出(但 println! 不输出到任何地方——没有 stdout)

不可用的功能:

  • std::fs:文件系统(浏览器没有文件系统)
  • std::net:网络(浏览器用 fetch API,不是 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 的关键特性:

  1. 完整的 std 支持:文件 I/O、网络、环境变量、命令行参数、随机数生成——全部通过 WASI 接口实现(网络要宿主显式授权,如 wasmtime run -S inherit-network=y)。
  2. 组件模型输出:编译产物是 WASM 组件(Component),不是裸模块。组件在裸 .wasm 模块外面包了一层类型信息和接口声明——第 14 章会详细拆解组件格式。
  3. WIT 接口定义:组件通过 WIT(WebAssembly Interface Types)声明导出/导入的接口——比裸模块的 (import "env" "log" (func)) 更类型安全、更结构化。
  4. 异步 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(opcode 0x1b,见 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 的局部变量有两个来源:

  1. 函数参数:自动成为 local 0, 1, 2, ...
  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 一脉相承)。核心思路:

  1. 识别不可规约的跳转边
  2. 把跳转目标拆分为一个新的 block,在 block 内用 br_table 实现多路跳转
  3. 通过"发送者/接收者"模式:跳转前设置一个变量标识目标,在汇合点用 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 的代价:

  1. 没有 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 这类微格式化库。
  2. Error trait 已经不是问题: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。
  3. 没有 std::panic 的 unwind:必须 panic=abort。
  4. 需要自定义 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 份函数体——体积膨胀是真实的风险。

优化策略:

  1. 减少单态化实例:用 dyn Trait 替代部分泛型,将编译期多态变成运行时多态。代价是间接调用(call_indirect),但减少代码体积。

  2. 把与 T 无关的逻辑挪出泛型函数:实例数量由调用处用到多少种具体类型决定,trait 约束写多写少并不改变它;真正能压体积的是让每份实例变薄——泛型外壳只做类型转换(如 AsRef<[u8]> 转成 &[u8]),把主体放进一个非泛型的内部函数,只编译一份。

  3. 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 的方法时:

  1. 从胖指针的数据部分取得 self 指针
  2. 从胖指针的 vtable 部分取得 vtable 地址(方法槽位的偏移是编译期常量)
  3. 读取 vtable 中的 i32 函数索引
  4. 用 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 流程:

  1. Rust 的 panic!() 宏(2021 及以后的 edition)展开为 core::panicking::panic_fmt 一类调用,进入 std 的 panic 处理
  2. 调用 panic hook——默认 hook 把消息写到 stderr,而 wasm32-unknown-unknown 的 stderr 是空实现,所以什么也看不到;随后调用 abort()
  3. 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 路径上
  4. 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

链接过程:

  1. 符号解析:每个 .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")]。

  2. 段合并:相同类型的段(Type、Function、Memory、Data 等)合并为一个。Type 段中相同的函数签名会被去重——Rust 的单态化可能在不同 .o 文件中生成相同的签名,链接器合并时只保留一份。

  3. 地址重定位:修正代码中的符号引用——函数索引、内存偏移、表索引等。WASM 的重定位类型和 ELF 不同——WASM 用函数索引而非地址引用函数,用段内偏移而非虚拟地址引用数据。

  4. 生成 .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:

  1. 导出可达性:只有从导出函数可达的代码和数据才会被保留。如果 Rust 代码中有未被任何导出函数调用的 pub 函数,链接器不会包含它。
  2. wasm-opt:Binaryen 的 wasm-opt 工具(不是 WABT)可以在链接之后再清一遍——--dce 删函数体内不可达的代码(Binaryen 126 src/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-bindgen crate 与 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:futures crate 的 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 无缝互操作。