Skip to content

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

5.1 Rust 编译到 WASM 的路径

Rust 编译到 WASM 和编译到 x86-64 走同一条管线,只在最后一步分叉:

关键点:MIR 之前的所有阶段(解析、类型检查、借用检查、MIR 优化)与编译目标无关。所有权检查、生命周期推导、trait 求解——这些都在编译期完成,不关心最终是 x86 机器码还是 WASM 字节码。只有从 LLVM IR 开始,代码生成才分叉到不同的后端。

这意味着:Rust 的内存安全保证在 WASM 目标上和原生目标上完全一致。编译器不会因为目标是 WASM 就放松检查——所有 unsafe 审计、所有边界检查、所有线程安全保证在 MIR 阶段已经完成。

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

  1. 标准库可用性std::fsstd::netstd::threadwasm32-unknown-unknown 上不可用——编译器在链接阶段会报错,不是在 MIR 阶段。
  2. 代码生成选择:LLVM 的 wasm32 后端生成的指令集不同(第 5.3 节),某些 Rust 构造(如 dyn Trait)的底层实现方式在 WASM 和原生平台上有差异。
  3. 链接器差异wasm-ldld.lld 的行为不完全一致——段合并规则、符号可见性、重定位类型都不同。

5.2 两个 WASM 目标:unknown vs wasip2

Rust 支持两个 wasm32 目标三元组:

特性wasm32-unknown-unknownwasm32-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)wasm-component-ld(组件感知链接器)

wasm32-unknown-unknown

"unknown-unknown" 的含义:操作系统 unknown、环境 unknown——Rust 标准库不知道有什么系统 API 可用。因此 std::fsstd::netstd::thread 等模块不可用或功能受限

可用的标准库功能:

  • std::alloc:堆分配(通过 dlmalloc 或自定义全局分配器)
  • std::vecstd::stringstd::collections:堆数据结构(依赖 alloc
  • std::sync::atomic:原子操作(通过 WASM 原子指令,需 SharedArrayBuffer
  • std::panic:panic 处理(默认调用 abort() -> trap)
  • std::markerstd::opsstd::cmp:纯编译期抽象,零运行时依赖
  • std::fmt:格式化输出(但 println! 不输出到任何地方——没有 stdout)

不可用的功能:

  • std::fs:文件系统(浏览器没有文件系统)
  • std::net:网络(浏览器用 fetch API,不是 socket)
  • std::thread::spawn:线程创建(WASM 没有线程创建 API——但 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 年新增的目标三元组,针对 WASI Preview 2 运行时(Wasmtime、Wasmer 等)。与 unknown-unknown 不同,它有一个明确定义的操作系统接口——WASI。

rust
// 在 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 下编译会报错——File::open 在 unknown 目标上不可用。

wasm32-wasip2 的关键特性:

  1. 完整的 std 支持:文件 I/O、网络、环境变量、命令行参数、随机数生成——全部通过 WASI 系统调用实现。
  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 代码适配两个目标:

rust
#[cfg(target_arch = "wasm32")]
#[cfg(not(target_os = "wasi"))]
fn get_time() -> f64 {
    // wasm32-unknown-unknown: 通过 JS 获取
    #[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 对 wasm32 的支持始于 LLVM 8(2019 年),此后持续完善。当前 LLVM 18+ 对 WASM 的支持已经覆盖了大部分 Rust 语言特性——但仍有少数限制(5.5 节)。

后端代码生成流水线

LLVM 的 wasm32 后端把 LLVM IR 翻译为 WASM 指令的过程:

指令选择

LLVM IR 的每个操作映射到最合适的 WASM 指令。映射关系通常是直接的:

LLVM IR 操作WASM 指令
add i32i32.add
mul i32i32.mul
load i32i32.load
store i32i32.store
icmp eqi32.eq
brbr
callcall
retreturn

有些映射不那么直接:

  • select(条件选择):LLVM IR 的 select i1 %cond, %true_val, %false_val 在 WASM 中没有直接等价指令,必须翻译为 if/else/end 结构。
  • phi(SSA phi 节点):WASM 没有 phi 节点——需要通过 local.set/local.get 在控制流汇合点传递值。
  • switch:LLVM IR 的 switch 可以有大量 case,WASM 只有 br_table(跳转表,所有 case 映射到同一个索引表)。

寄存器分配

虽然 WASM 是栈式指令集,LLVM 仍然执行寄存器分配——分配的"寄存器"映射到 WASM 的局部变量(local)。LLVM 的寄存器分配器会尽量减少局部变量数量,降低函数体体积。

WASM 的局部变量有两个来源:

  1. 函数参数:自动成为 local 0, 1, 2, ...
  2. 声明的局部变量:在函数体开头的局部变量声明区列出

LLVM 的寄存器分配结果决定每个 LLVM 虚拟寄存器对应哪个 WASM 局部变量。WASM 局部变量的声明顺序不影响运行时性能(JIT 编译时会做栈消除),但影响 .wasm 二进制体积——更多的局部变量 = 更大的声明区 + 更多的 local.get/local.set 指令。

栈消除

LLVM 的寄存器分配结果转换为栈式指令序列。每个"虚拟寄存器"定义变成 local.set,每次使用变成 local.get

; LLVM IR (寄存器分配后)
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 可以省略——值留在栈上直接传递给下一条指令。这被称为栈优化(peephole optimization),LLVM 的 wasm32 后端在代码发射阶段做这个优化。

结构化控制流的生成

LLVM IR 是任意基本块 + 跳转的图结构,WASM 要求结构化的控制流。转换算法是整个 wasm32 后端最复杂的部分。

if-else 的转换

llvm
; LLVM IR
  br i1 %cond, label %then, label %else
then:
  ...
  br label %end
else:
  ...
  br label %end
end:
  ...

转换为:

wasm
;; WASM
  local.get $cond
  if
    ...  ;; then 块
  else
    ...  ;; else 块
  end
  ...  ;; end 之后

循环的转换

llvm
; LLVM IR
loop_header:
  ...
  br i1 %cond, label %loop_body, label %loop_exit
loop_body:
  ...
  br label %loop_header
loop_exit:
  ...

转换为:

wasm
;; WASM
  loop
    ...
    local.get $cond
    br_if 1       ;; 条件为真 → 跳回 loop 开头
  end             ;; 条件为假 → 退出 loop
  ...

br_if 1 中的 1 是标签深度——指向外层第 1 层 loop 的开头。br 0 跳出当前 blockbr 1 跳出当前 block + 外层一层,以此类推。

这个标签深度的编码方式有一个微妙之处:WASM 的 blockloopbr 的语义不同。br Nblock 中跳到 blockend 之后(向前跳),在 loop 中跳到 loop 的开头(向后跳)。LLVM 的后端需要根据原始 CFG 跳转的方向选择使用 block 还是 loop

不可规约控制流的处理

LLVM IR 可能包含不可规约控制流(irreducible control flow)——跳转目标不是循环头或分支汇合点,而是跳到循环中间或从循环中间跳出。WASM 的结构化控制流无法直接表达不可规约控制流。

LLVM 的 wasm32 后端使用 relabilization 算法把不可规约控制流转换为可规约形式。核心思路:

  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

rust
match value {
    0 => "zero",
    1 => "one",
    2..=10 => "small",
    _ => "other",
}

编译后的 WASM 结构:

wasm
;; 简化版
local.get $value
br_table 0 1 2 3  ;; value=0 → label 0, value=1 → label 1, ...

block  ;; label 0: case 0
  ;; "zero"
  br 4  ;; 跳到 match 结束
end

block  ;; label 1: case 1
  ;; "one"
  br 3
end

block  ;; label 2: case 2..=10
  ;; "small"
  br 2
end

block  ;; label 3: default
  ;; "other"
  ;; 不需要 br,自然落入 match 结束
end
;; match 结束

br_table 的操作码后面跟着一个目标标签数组 + 一个默认标签。每个 case 的值直接映射到数组索引——如果值不在 case 范围内,跳到默认标签。范围匹配(2..=10)需要额外的比较逻辑——LLVM 的 switch lowering pass 会在 br_table 之前插入范围检查。

5.4 wasm32 调用约定与 ABI

Rust 在 wasm32 目标上遵循的调用约定和原生平台有显著差异。理解这些差异对调试和性能优化至关重要。

函数参数和返回值传递

WASM 的函数调用约定在规范中明确定义——和 x86-64 的 System V ABI 不同,WASM 不依赖寄存器传参:

传递方式说明
参数传递通过抽象值栈压入,函数内部通过 local.get 0, local.get 1, ... 访问
返回值传递通过抽象值栈弹出,函数结束前把返回值留在栈顶
多返回值WASM MVP 只支持单返回值;多返回值提案(Multi-Value Proposal,已进入 Phase 4)允许函数返回多个值

wasm32 目标上,Rust 的函数参数和返回值映射规则:

Rust 类型WASM 参数/返回类型说明
i32, u32i32直接映射
i64, u64i64直接映射
f32f32直接映射
f64f64直接映射
booli320 = false, 1 = true
chari32Unicode 标量值
enum (无字段)i32判别式
&T, &mut Ti32线性内存偏移量
*const T, *mut Ti32线性内存偏移量
Box<T>i32堆分配后的指针
String, Vec<T>两个 i32指针 + 长度(或指针 + 容量)
dyn Trait两个 i32数据指针 + vtable 指针
(T1, T2)扁平化:T1, T2 的参数依次排列元组扁平化传递

关键点:Rust 的引用类型 &T 在 WASM 层面就是一个 i32——一个线性内存偏移量。所有权和借用规则在 MIR 阶段已经检查完毕,到代码生成时只剩裸地址。这意味着 extern "C" 函数的 ABI 实际上是 (i32, i32) -> i32 这种形式——和 C 语言的指针传递一致。

extern "C" vs extern "rust-call"

Rust 的 extern "C" 函数使用 C ABI(WASM 规范定义的调用约定),参数和返回值按上述规则映射。这是 wasm-bindgen 导出函数使用的调用约定。

Rust 的默认调用约定 extern "rust-call"(闭包的 Fn::call 使用)有所不同——它把所有参数打包为一个元组传递。在 WASM 中,这意味着闭包调用的参数扁平化展开——和 extern "C" 的结果类似,但内部表示不同。

实际上在 wasm32 目标上,extern "C" 和 Rust 默认调用约定的差异很小——因为 WASM 的值栈传参方式本身就要求参数按顺序压入。差异主要在名称修饰(name mangling)和元组扁平化规则上。

栈帧布局

WASM 函数的栈帧在线性内存中的布局(第 3 章已讨论):

高地址 ┌──────────────────────┐
       │  调用者的栈帧         │
       ├──────────────────────┤
       │  局部变量             │  ← __stack_pointer 递减后
       │  临时值               │
       │  ...                  │
低地址 └──────────────────────┘  ← 当前 __stack_pointer

wasm32 目标上 __stack_pointer 的初始值通常设置为线性内存的末尾(即 initial_pages * 64KB)。每个函数入口递减 __stack_pointer 分配帧空间,出口恢复。帧大小由编译器根据局部变量数量计算——Rust 编译器在 LLVM IR 生成阶段就已经确定了每个函数的栈需求。

5.5 用 wasm2wat 检查编译产物

wasm2wat 是 WABT(WebAssembly Binary Toolkit)工具链中的反汇编器——把 .wasm 二进制转换为人类可读的 WAT(WebAssembly Text Format)。它是理解 Rust 编译产物的利器。

安装和使用

bash
# 安装 WABT
cargo install wabt

# 或用系统包管理器
brew install wabt  # macOS

# 反汇编 .wasm 文件
wasm2wat target/wasm32-unknown-unknown/release/my_module.wasm -o output.wat

示例:一个简单函数

rust
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

编译并反汇编:

bash
cargo build --target wasm32-unknown-unknown --release
wasm2wat target/wasm32-unknown-unknown/release/my_module.wasm

输出:

wasm
(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func $add (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
  (export "add" (func 0))
  (export "memory" (memory 0))
  (memory (;0;) 16))

从这个输出可以看出:Rust 编译器为 add 函数生成了最精简的 WASM——两个 local.get + 一个 i32.add,没有任何冗余指令。export "memory" 是 Rust 标准库默认导出线性内存。memory 16 表示初始 16 页(1MB)——这是 dlmalloc 默认的初始内存大小。

示例:带堆分配的函数

rust
#[no_mangle]
pub extern "C" fn make_string() -> *mut String {
    Box::into_raw(Box::new(String::from("hello")))
}

反汇编后可以看到 call 指令调用 __rust_alloc(即 dlmallocmalloc):

wasm
(func $make_string (type 1) (result i32)
  (local i32 i32)
  ;; 分配 String 结构体(3 个 usize = 12 字节在 wasm32 上)
  i32.const 12
  i32.const 4      ;; 对齐 = 4
  call $__rust_alloc
  ...
  ;; 分配 "hello" 的缓冲区
  i32.const 5
  i32.const 1      ;; 对齐 = 1
  call $__rust_alloc
  ...
  ;; 初始化 String 的 ptr/len/capacity 字段
  ...)

示例:match 表达式

rust
#[no_mangle]
pub extern "C" fn classify(n: i32) -> i32 {
    match n {
        0 => 0,
        1 => 1,
        2..=10 => 2,
        _ => 3,
    }
}

反汇编后可以看到 br_table 的使用:

wasm
(func $classify (type 0) (param i32) (result i32)
  (local i32)
  block  ;; label = @1
    block  ;; label = @2
      block  ;; label = @3
        block  ;; label = @4
          local.get 0
          br_table 0 (;@4;) 1 (;@3;) 2 (;@2;) 3 (;@2;)  ;; n=0→@4, n=1→@3, n=2→@2, n=3→@2, ...
        end ;; @4
        i32.const 0
        local.set 1
        br 1 (;@1;)
      end ;; @3
      i32.const 1
      local.set 1
      br 1 (;@1;)
    end ;; @2
    ;; n >= 2 且 n <= 10 的范围检查
    local.get 0
    i32.const 10
    i32.gt_u
    br_if 0 (;@1;)
    i32.const 2
    local.set 1
    br 1 (;@1;)
  end ;; @1
  local.get 1)

br_table 只覆盖了 0-3 四个索引——更大的值直接跳到 @2,然后在 @2 内做 n > 10 的范围检查。这是 LLVM 的 switch lowering 策略:对小的连续值用 br_table,对范围检查用比较指令。

5.6 std 在 WASM 中的限制与 no_std

受限的 std 功能

wasm32-unknown-unknownstd 的限制不是 Rust 编译器的选择,而是平台能力缺失的必然结果。WASM MVP 没有文件系统、网络、线程、时钟——std 的这些模块没有底层 API 可以调用。

stdwasm32-unknown-unknown 上的具体行为:

模块行为
std::fs编译错误(符号未定义)
std::net编译错误
std::thread::spawn编译错误
std::time::Instant::now()panic("time not implemented on this platform")
std::time::SystemTime::now()panic
std::env::args()返回空迭代器
std::io::stdout()可以创建,但 write 是 no-op
std::panic::catch_unwindpanic=abort 模式下不可用
std::sync::Mutex可用(但单线程下无意义,除非用 SharedArrayBuffer)
std::sync::Arc可用(引用计数在单线程下正常工作)

no_std 场景

对于体积极度敏感的场景(< 10KB 的 .wasm),可以完全不用 std,只用 core + alloc

rust
#![no_std]

extern crate alloc;

use alloc::vec::Vec;
use alloc::string::String;

#[no_mangle]
pub extern "C" fn process(data: &[u8]) -> Vec<u8> {
    data.iter().map(|&b| b.wrapping_add(1)).collect()
}

no_std + alloc 剥离了 std 中大部分 I/O 和 OS 相关代码,只保留核心数据结构和分配器。这可以减少 20-50KB 的 .wasm 体积——主要是 dlmalloc 和 panic 格式化字符串的开销。

no_std 的代价:

  1. 没有 std::fmt 的格式化format!()println!() 不可用——需要 alloc::fmtufmt(微格式化库)。
  2. 没有 std::error::Error:错误处理需要自己实现 trait 或用 core::result::Result
  3. 没有 std::panic 的 unwind:必须 panic=abort
  4. 需要自定义 panic handler
rust
#![no_std]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    // 在 WASM 中,最简单的 panic handler 是直接 trap
    core::arch::wasm32::unreachable()
}

自定义全局分配器

no_std 场景下可以自定义全局分配器——选择比 dlmalloc 更小的实现:

rust
#![no_std]

extern crate alloc;

use alloc::alloc::{GlobalAlloc, Layout};

struct WeeAlloc;

#[global_allocator]
static ALLOC: WeeAlloc = wee_alloc::WeeAlloc::INIT;

// 如果连 alloc 都不需要,可以完全不声明全局分配器
// 但这时 Vec, String, Box 等都不可用

5.7 WASM 内置指令与 Rust intrinsics

Rust 的 core::arch::wasm32 模块提供了对 WASM 特有指令的直接访问——和 core::arch::x86_64 提供对 x86 SIMD 指令的访问类似。这些 intrinsics 让 Rust 代码在 WASM 中获得超越标准库的能力。

内存指令 intrinsics

rust
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);

memory_growmemory_size 的第一个参数是内存索引——多内存提案下指定目标内存,单内存场景传 0。这两个函数是 Rust 全局分配器底层调用的原语——dlmalloc 在堆空间不足时调用 memory_grow 申请新页。

控制流 intrinsics

rust
use core::arch::wasm32::*;

// unreachable 指令:触发 trap
fn assert_false() -> ! {
    unreachable(); // 编译为 WASM 的 unreachable 指令
}

unreachable()no_std 场景下最常用的 panic handler 实现方式——比调用宿主的 abort 函数更快,因为它只有一条指令(操作码 0x00)。

SIMD intrinsics

WASM SIMD 提案(Phase 4,全平台支持)引入了 128 位向量类型 v128 和一系列 SIMD 指令。Rust 通过 core::arch::wasm32 暴露这些指令:

rust
use core::arch::wasm32::*;

fn add_arrays_simd(a: &[i32], b: &[i32], out: &mut [i32]) {
    for i in (0..a.len()).step_by(4) {
        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);
    }
}

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 加速。

原子指令 intrinsics

rust
use core::arch::wasm32::*;
use std::sync::atomic::Ordering;

// 原子比较交换(CAS)
fn atomic_cas(ptr: *mut i32, expected: i32, new: i32) -> i32 {
    // 对应 i32.atomic.rmw.cmpxchg 指令
    unsafe { i32_atomic_rmw_cmpxchg(ptr as u32, expected, new) }
}

i32_atomic_rmw_cmpxchg 直接映射到 WASM 的 i32.atomic.rmw.cmpxchg 指令。Rust 的 AtomicI32::compare_exchangewasm32 目标上底层调用这个 intrinsic——和 x86_64 上调用 cmpxchg 指令一样。

intrinsics 的局限性

core::arch::wasm32 不包含所有 WASM 指令——比如 call_indirect 没有对应的 intrinsic(Rust 的动态分发由编译器自动生成),br_table 没有对应的 intrinsic(Rust 的 match 由编译器自动选择最优的跳转策略)。intrinsics 只暴露那些标准库无法通过常规 Rust 代码生成的指令。

5.8 Rust 特有构造的 WASM 映射

泛型与单态化

Rust 的泛型在编译时单态化——为每个具体类型生成一份特化代码。这对 WASM 体积有直接影响:

rust
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. 泛型约束收紧:只在需要的 trait 上写泛型,不要 where T: Clone + Debug + Hash + ...——过多的约束可能鼓励编译器生成更多特化代码。

  3. wasm-opt --duplicate-function-elimination:合并二进制级别相同的函数体(不同名字但指令完全相同的函数)。这个优化在 Rust 编译到 WASM 的场景下效果显著——Rust 的泛型单态化经常生成指令完全相同但名字不同的函数(比如 Vec<u8>::pushVec<i8>::push 在 WASM 层面完全一样,因为 u8i8 都是 i32)。

闭包

Rust 的闭包编译为匿名结构体 + Fn trait 实现。在 WASM 中:

rust
let multiplier = |x: i32| x * factor;

编译后等价于:

rust
struct ClosureEnv {
    factor: i32,
}

impl Fn<(i32,)> for ClosureEnv {
    extern "rust-call" fn call(&self, args: (i32,)) -> i32 {
        args.0 * self.factor
    }
}

闭包的 env 结构体在线性内存中分配,函数调用通过 call_indirect 实现(因为 Fn trait 是动态分发的)。如果闭包不捕获环境(|| { ... }),Rust 会优化为直接 call——零开销。

在 WASM 中,闭包的间接调用比原生平台有一个额外的性能瓶颈:call_indirect 需要做表查找和签名验证(第 3 章),而原生平台的间接调用只是 call [rax+offset]——一次内存读取。差距约 2-5 纳秒,在热循环中累积可观。

动态分发

dyn Trait 在 WASM 中的实现和原生平台一致——vtable 指针 + 数据指针的胖指针。但有一个微妙差异:WASM 的 call_indirectcall 慢约 2-5 纳秒(因为额外的表查找和签名验证),而原生平台上间接调用和直接调用的差距主要在分支预测——约 1-3 纳秒。差距不大,但在热循环中累积可观。

vtable 在 WASM 中的布局:Rust 的 vtable 是一个结构体,包含一组函数指针。在 wasm32 目标上,每个函数指针是一个 i32——对应 WASM 表中的索引。调用 dyn Trait 的方法时:

  1. 从胖指针的数据部分取得 self 指针
  2. 从胖指针的 vtable 部分取得方法偏移
  3. 读取 vtable 中的 i32 函数索引
  4. call_indirect 调用表中该索引的函数

这个流程需要两次内存访问(vtable 指针 + 表查找),和原生平台上的 call [vtable+offset] 一次内存访问相比略慢。

panic 处理

Rust 的 panic 在 WASM 中有两种策略:

panic=unwind(默认):生成 WASM 异常处理指令(try/catch/throw),需要运行时支持异常处理提案。Chrome 95+、Firefox 100+ 支持。.wasm 体积较大,因为需要异常表。

panic=abort:panic 时直接调用 abort() -> trap。体积更小,但不能 catch_unwind

toml
# Cargo.toml — 推荐 WASM 使用 panic=abort
[profile.release]
panic = "abort"

推荐 panic=abort 的原因:大多数 WASM 场景不需要 catch panic(浏览器 trap 后整个模块不可用),而 panic=unwind 引入的异常表会增加 10-30% 的 .wasm 体积。

panic=abort 下的 panic 流程:

  1. Rust 的 panic!() 宏展开为调用 std::panicking::begin_panic
  2. begin_pawn 调用 abort() 内部函数
  3. abort() 编译为调用一个从宿主导入的函数 wasm-bindgen 提供 __wbindgen_throw,或直接使用 unreachable 指令
  4. 宿主执行 throw new Error(...) 或触发 trap

5.9 链接:wasm-ld 的角色

LLVM 编译 Rust 代码为 .o 目标文件(wasm 格式),wasm-ld(LLVM 的 wasm lld 链接器)把多个 .o 文件合并为一个 .wasm 模块。

链接过程:

  1. 符号解析:每个 .o 文件声明它导出的符号和需要导入的符号。链接器把导入符号与对应的导出符号绑定。未解析的符号会导致链接错误——在 wasm32-unknown-unknown 上很常见,因为 std 中有些函数的实现在 WASM 目标上不存在。

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

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

  4. 生成 .wasm:输出最终的 .wasm 文件。

对于 wasm32-wasip2 目标,链接器换成 wasm-component-ld——它在 wasm-ld 的基础上额外处理组件模型的元数据(WIT 接口声明、世界定义等)。wasm-component-ld 的输出是一个 WASM 组件——在裸 .wasm 模块外面包了一层适配代码,把 WASI 的 wasi:io/... 接口映射到模块的导入/导出。

死代码消除

链接器的一个重要优化是死代码消除(dead code elimination, DCE)。WASM 没有 ELF 那样的 section garbage collection(--gc-sections),但有类似的机制:

  1. 导出可达性:只有从导出函数可达的代码和数据才会被保留。如果 Rust 代码中有未被任何导出函数调用的 pub 函数,链接器不会包含它。
  2. wasm-opt --dce:WABT 的 wasm-opt 工具可以做更精细的 DCE——移除不可达的函数、未使用的局部变量、冗余的导入。

Rust 的链接器在 wasm32 目标上默认做 DCE——cargo build --release 输出的 .wasm 只包含从 #[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)call_indirect 的表索引
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 列表

每个 feature 对应一个或多个 WASM 指令:

feature引入的指令浏览器支持Wasmtime
sign-exti32.extend8_sChrome 74+, FF 62+全版本
mutable-globals可变全局变量Chrome 73+, FF 62+全版本
multivalue函数返回多值Chrome 86+, FF 78+全版本
simd128v128.*i32x4.*Chrome 91+, FF 89+, Safari 16.4+全版本
bulk-memorymemory.copymemory.fillChrome 75+, FF 79+, Safari 15+全版本
nontrapping-fptointi32.trunc_sat_f32_sChrome 75+, FF 84+全版本
atomicsi32.atomic.*需 COOP/COEP全版本
tail-callreturn_callreturn_call_indirectChrome 112+26+

5.10.2 启用方式:三种粒度

粒度一:Cargo profile(项目级)

toml
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = [
    "-C", "target-feature=+simd128,+bulk-memory,+nontrapping-fptoint",
]

粒度二:构建命令行(一次性)

bash
RUSTFLAGS='-C target-feature=+simd128,+bulk-memory' \
    cargo build --target wasm32-unknown-unknown --release

粒度三:函数级(条件编译 + intrinsics)

rust
#[cfg(target_feature = "simd128")]
use core::arch::wasm32::*;

#[cfg(target_feature = "simd128")]
unsafe fn fast_sum(data: &[f32]) -> f32 {
    let mut sum = f32x4_splat(0.0);
    for chunk in data.chunks_exact(4) {
        sum = f32x4_add(sum, v128_load(chunk.as_ptr() as *const _));
    }
    let arr: [f32; 4] = std::mem::transmute(sum);
    arr.iter().sum()
}

#[cfg(not(target_feature = "simd128"))]
fn fast_sum(data: &[f32]) -> f32 {
    data.iter().sum()
}

5.10.3 兼容性陷阱

启用 simd128 编译的 .wasm 在不支持 simd128 的运行时上实例化失败——不是降级运行,是直接报错。这意味着:

  • 启用 simd128 后,最低支持浏览器从 Chrome 60 升到 Chrome 91——直接砍掉数年的旧浏览器
  • WASM 模块没有运行时 feature detection——只能在 JS 侧探测后选择不同的 .wasm

实战模式:双产物 + 运行时探测

javascript
async function loadWasm() {
    // JS 侧探测 SIMD
    const hasSimd = await WebAssembly.validate(new Uint8Array([
        0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,  // magic
        0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b,        // type: () -> v128
        0x03, 0x02, 0x01, 0x00,                          // function
        0x0a, 0x0a, 0x01, 0x08, 0x00, 0xfd, 0x0c,
        // ... v128.const 字节序列
    ]));

    const wasmUrl = hasSimd ? '/my-lib-simd.wasm' : '/my-lib-baseline.wasm';
    return WebAssembly.instantiateStreaming(fetch(wasmUrl));
}

构建时产出两份 .wasm(启用/不启用 simd128),运行时按浏览器能力选择。

5.10.4 推荐组合

90% 的 WASM 项目应该启用:+sign-ext,+mutable-globals,+multivalue,+bulk-memory,+nontrapping-fptoint——这些 feature 在所有现代浏览器(最近 4 年版本)都支持,开启后体积普遍减小 5-15%、性能提升 5-10%。

+simd128 单独决定——如果业务有计算热路径(图像/音视频/ML),启用 + 双产物策略;否则不启用避免兼容性问题。

+atomics 仅在确实需要多线程(且部署能配置 COOP/COEP)时启用,否则这个 feature 反而拖累——它会在所有内存访问插入 fence 指令。

5.11 构建可重现性与供应链安全

WASM 模块作为可执行二进制,其完整性是供应链安全的关键环节。"昨天的二进制"和"今天的二进制"如果字节相同,CDN/代理/编辑器篡改就一目了然。但 Rust 的默认编译产物不可重现——同样的源码、同样的 toolchain、同样的命令,可能产生不同字节的 .wasm。

5.11.1 不可重现的来源

来源一:编译时间戳。某些 dep 在 build.rs 里嵌入构建时间——每次构建都不同。

来源二:路径前缀。Rust 默认把源码文件的绝对路径嵌入 panic 信息——A 机器构建的 /Users/alice/proj/src/lib.rs:42,B 机器构建的 /home/bob/proj/src/lib.rs:42 字节不同。

来源三:并行 codegen 顺序codegen-units > 1 时,多个 codegen unit 并行编译,函数在 .wasm 中的顺序依赖于线程调度——非确定。

来源四:调试信息。DWARF 中可能含有线程 ID 或编译机器名等环境敏感信息。

5.11.2 实现可重现编译

toml
# Cargo.toml
[profile.release]
codegen-units = 1  # 禁用并行(解决来源三)
strip = true        # 移除调试信息(解决来源四)

[profile.release.package."*"]
codegen-units = 1
bash
# 命令行
RUSTFLAGS='--remap-path-prefix=/Users/alice/proj=. \
           --remap-path-prefix=/home/bob/proj=.' \
    cargo build --target wasm32-unknown-unknown --release

--remap-path-prefix 把绝对路径替换为相对路径——所有机器编译出的 .wasm 中路径相同。

5.11.3 Docker-based 可重现构建

最可靠的方式:在容器中构建,固定所有环境因素:

dockerfile
FROM rust:1.86-slim AS builder
RUN rustup target add wasm32-unknown-unknown
WORKDIR /work
COPY . .
ENV RUSTFLAGS='--remap-path-prefix=/work=.'
RUN cargo build --target wasm32-unknown-unknown --release \
    && cp target/wasm32-unknown-unknown/release/*.wasm /out/

FROM scratch
COPY --from=builder /out/ /

在 CI 中:

yaml
- 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 签名,运行时验证:

bash
# 发布时签名
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 生态有 15 万+ 包——但不是所有都能在 WASM 上工作。理解兼容性边界有助于选型时避免踩坑。

5.12.1 兼容性等级

5.12.2 浏览器端常见 crate 兼容性表

Cratewasm32-unknown-unknown说明
serde / serde_json完全工作
regex体积约 30KB
chrono用 wasm-bindgen feature 拿到当前时间
uuid启用 js feature 用浏览器 RNG
reqwest需要 --features wasm-bindgen,不支持自定义 connector
tokio启用 rt feature,但只单线程,无 IO
futures全部工作
rand需要 getrandom 配 wasm-bindgen feature
sha2 / blake3纯计算,性能优秀
ring0.17+ 支持 wasm,需 wasm32_unknown_unknown_js feature
sqlx依赖网络栈,不支持浏览器
reqwest 默认需 wasm-bindgen feature

5.12.3 关键 crate 的 WASM 配置

rand / getrandom

toml
[dependencies]
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }

getrandom 在浏览器中通过 crypto.getRandomValues 提供熵源——js feature 启用这个绑定。不加这个 feature 编译时报错"the wasm32-unknown-unknown target is not supported by default".

reqwest

toml
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["wasm-bindgen"] }

wasm-bindgen feature 让 reqwest 用浏览器的 fetch API 而不是 hyper。代价:失去自定义 TLS、连接池、proxy 等高级选项。

tokio

toml
[dependencies]
tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync"] }

只能启用单线程运行时(rt 而非 rt-multi-thread)和同步原语(sync)。net / io-util / process 等 feature 在浏览器端不工作。

5.12.4 服务器端(wasm32-wasip2)的更广兼容性

WASI Preview 2 提供了文件 IO、HTTP、socket 等能力——更多 crate 可以工作:

Cratewasm32-wasip2说明
std::fs通过 wasi:filesystem
std::net::TcpStream需 wasi-sockets 提案(实验)
reqwest通过 wasi:http 工作
tokio (rt + macros)单线程,IO 通过 wasi:io/poll
sqlx部分 backend 工作(如 sqlite-in-wasm)
async-std需要 patches

经验法则:服务器端 WASI 兼容性比浏览器好——因为 WASI 提供了更接近 Linux 的接口。但仍有限制:多线程、UNIX socket、信号处理在 WASI 都受限。

5.12.5 检查兼容性的工具流程

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 + wasm-bindgen 走浏览器 TLS
std::time::SystemTime浏览器(默认)chrono + wasm-bindgen
mio / tokio::net浏览器wasm-bindgen-futures + Web API
log4rs任何 WASMtracing + tracing-wasm
backtrace浏览器console_error_panic_hook

理解这些替代关系有助于在引入新依赖前先做规划——避免"研究两天发现根本不能编"的浪费。

5.12.7 持续追踪生态变化

WASM 生态变化快——半年前不工作的 crate 现在可能支持了。两个跟踪渠道:

  • awesome-wasm-rust: GitHub 上的 curated list,每月更新
  • rust-lang/rust issue tracker: 跟踪 wasm32 相关 RFC

工程纪律:每个季度做一次"依赖兼容性 review"——确认现有依赖的最新版本仍然工作,看是否有更好的替代。这种小投入避免长期累积的兼容性债务。

5.13 Rust 编译器标志的实战组合

RUSTFLAGSCargo.toml profile、target-feature 三层配置共同决定编译产物——理解它们的交互是 WASM 工程的核心技能。错误的组合可能让"调试构建编译时间从 5 秒变 60 秒"或"release 体积比预期大 5 倍"。

5.13.1 配置层级与作用域

每层有不同生命周期:

  • 全局:所有项目共享,最广泛
  • 项目:项目级配置,应进 git
  • profile:dev/release/profiling 的差异化
  • RUSTFLAGS:CI 临时调整或开发实验
  • 命令行:单次构建的临时覆盖

5.13.2 三个推荐 profile 配置

dev profile:编译速度优先

toml
[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

效果:从 60 秒首次构建降到 5-8 秒增量构建。

release profile:体积+性能平衡

toml
[profile.release]
opt-level = 3            # 性能优先
debug = false
codegen-units = 1        # 全局优化
lto = "fat"              # 跨 crate 内联
strip = true             # 移除符号
panic = "abort"          # 消除 unwind 表

[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O3']

适合:性能敏感的 release(游戏、计算密集)。

release profile:极致体积

toml
[profile.release]
opt-level = "z"
debug = false
codegen-units = 1
lto = "fat"
strip = true
panic = "abort"

[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Oz', '--strip-debug']

适合:浏览器场景(体积敏感,性能差异 5-10% 可接受)。

profiling profile:性能分析专用

toml
[profile.profiling]
inherits = "release"
debug = true             # 保留符号方便 profile
strip = false

[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-O', '-g']

性能分析时用 wasm-pack build --profiling——优化代码 + 保留函数名。

5.13.3 RUSTFLAGS 的常见组合

启用 SIMD + bulk-memory + multivalue

bash
RUSTFLAGS="-C target-feature=+simd128,+bulk-memory,+nontrapping-fptoint" \
    cargo build --target wasm32-unknown-unknown --release

移除调试信息

bash
RUSTFLAGS="-C debuginfo=0 -C strip=symbols" \
    cargo build --target wasm32-unknown-unknown --release

最大优化(慢编译换最快二进制)

bash
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 配置陷阱与冲突

冲突一:LTO + codegen-units 互斥lto = "fat" 要求 codegen-units = 1——否则 LTO 不能跨 codegen unit 工作。Cargo 不会报错,只是 LTO 实际没效果。

冲突二:RUSTFLAGS 触发重编RUSTFLAGS 改变会让 Cargo 认为所有依赖需要重编——开发循环中频繁改 RUSTFLAGS 会导致 60s+ 构建。把稳定的 flags 放 .cargo/config.toml,临时实验才用 RUSTFLAGS。

冲突三:target-feature 与运行时不匹配。开发用 +atomics 编译,但部署到不支持 SharedArrayBuffer 的浏览器——加载失败。生产部署必须做特性检测。

5.13.5 多 profile 协作的工程实践

工程纪律:

  • 永远不在 release 配置上开发:太慢
  • 永远不在 dev 配置上发布:体积大、未优化
  • profile 变更必须提交:进 git,团队共享
  • CI 用 release,本地用 dev:分工清晰

5.13.6 实测:配置组合的影响

实测同一项目(中等复杂度,~3000 行 Rust,30 deps):

配置编译时间.wasm 体积
dev (默认)65 s(首次)2.1 MB
dev opt-level=170 s(首次)1.8 MB
dev 增量构建4 s-
release 默认180 s380 KB
release + LTO280 s240 KB
release + LTO + codegen-units=1350 s195 KB
release + LTO + opt-level=z320 s165 KB
上 + wasm-opt -Oz335 s138 KB

每加一项优化都要付出编译时间——最终配置比基线慢 2-3 倍构建,但二进制小 60%。在 CI 中是值得的——用户加载体验改善显著。

5.13.7 配置审查 checklist

每项不通过都意味着潜在的开发体验或产品质量问题。把这套审查嵌入项目 onboarding,新人接手项目时立刻就有正确的编译配置。

5.14 跨平台编译与 Toolchain 升级

WASM 项目的开发者常用 macOS、Linux、Windows——同一份 Rust 代码在不同平台编译应该产出相同的 .wasm。但实践中经常出现"我机器上能跑别人不行"——根因是 toolchain 版本和构建环境差异。

5.14.1 跨平台编译的差异源

每个差异都可能导致 .wasm 字节级不一致——影响哈希验证、CDN 缓存、签名等下游流程。

5.14.2 跨平台一致性的工程手段

手段一:固定 Rust 版本

toml
# rust-toolchain.toml(提交到 git)
[toolchain]
channel = "1.86.0"
components = ["rustc", "cargo", "clippy", "rustfmt"]
targets = ["wasm32-unknown-unknown", "wasm32-wasip2"]

任何 cargo 命令在该项目目录会自动用 1.86.0——所有平台一致。

手段二:固定 wasm-bindgen-cli 版本

yaml
# CI 中精确锁定
- name: Install wasm-bindgen-cli
  run: cargo install wasm-bindgen-cli --version 0.2.99 --locked

--locked 让 cargo 用 Cargo.lock 中的精确版本,不解析新版。

手段三:路径标准化

bash
# 任何平台都用相对路径
RUSTFLAGS='--remap-path-prefix=$(pwd)=.' cargo build ...

避免不同机器的绝对路径嵌入到 panic 信息。

手段四:Docker 化构建

最可靠——不同开发者机器只跑 docker build,所有差异被容器化掉:

dockerfile
FROM rust:1.86-slim
RUN cargo install wasm-bindgen-cli@0.2.99 --locked
RUN cargo install wasm-pack@0.13.0 --locked
WORKDIR /work
COPY . .
RUN cargo build --target wasm32-unknown-unknown --release

5.14.3 Toolchain 升级的工程流程

升级 Rust 不只是改一个版本号——必须有完整流程避免回归:

关键检查点:

  • Release notes:必读 Rust 团队的 stable release 公告,关注 wasm 章节
  • 性能回归:每次 toolchain 升级都跑性能基准,5% 以上回归必须查
  • wasm-bindgen 兼容:rustc 1.X 升 1.X+1 时,wasm-bindgen 必须同步升级

5.14.4 Rust 版本与生态的对应关系

Rust 版本wasm-bindgenwasm-pack关键 wasm feature
1.780.2.920.12multivalue 默认
1.820.2.950.13部分异步改进
1.860.2.990.13性能优化

工程纪律:升级 Rust 时同步升级 wasm-bindgen、wasm-pack——不要单独升某个。

5.14.5 多平台 CI 的实战配置

yaml
# .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.86.0 --target wasm32-unknown-unknown

      - name: Cache cargo
        uses: actions/cache@v3
        with:
          path: |
            ~/.cargo/registry
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

      - name: Install wasm-pack
        run: cargo install wasm-pack@0.13.0 --locked

      - name: Build
        run: wasm-pack build --release

      - name: Verify reproducibility
        run: |
          sha256sum pkg/*.wasm > "${{ matrix.os }}.sha256"

      - uses: actions/upload-artifact@v3
        with:
          name: ${{ matrix.os }}-wasm
          path: pkg/*.wasm

CI 在三个平台都构建,最后比对 SHA-256——如果不一致,说明有未控制的差异源,必须排查。

5.14.6 升级失败的回滚策略

工程纪律:

  • 保留 N-1 版本:CI 镜像、Docker 镜像至少保留上一个工作版本
  • 快速回滚通道:能在 5 分钟内从新版本切回旧版本
  • 金丝雀部署:1% → 10% → 100% 的逐步切流,发现问题及时回滚

升级 Rust 是低频但高风险操作——每次都按完整流程做,避免"小升级大事故"。

5.14.7 长期工具链管理策略

升级类型频率风险收益
Patch(1.86.0 → 1.86.1)每月极低bug 修复
Minor(1.85 → 1.86)每季度性能 + feature
Major(1.x → 2.x)罕见重大特性

把这套策略写进项目文档——让团队有清晰的工具链管理预期。

5.15 Rust → WASM 编译错误的诊断手册

把 Rust 编译为 WASM 比编译为原生平台更容易出错——某些 crate 不兼容、某些 target-feature 冲突、某些链接错误。这里整理常见错误的快速诊断手册。

5.15.1 错误分类

5.15.2 错误一:getrandom 报错

最常见的 wasm32-unknown-unknown 编译错误:

error: the wasm32-unknown-unknown target is not supported by default,
       you may need to enable the "js" feature
note: ... required by `getrandom::getrandom`

原因getrandom 在浏览器中需要 js feature 来调 crypto.getRandomValues,默认不启用。

修复

toml
[dependencies]
getrandom = { version = "0.2", features = ["js"] }

如果不直接用 getrandom 但通过 rand 间接用,需要:

toml
[dependencies]
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }

显式声明 getrandom 即使不直接 use——Cargo 才会启用 feature。

5.15.3 错误二:std::time 在浏览器中

rust
let now = std::time::SystemTime::now();
// 运行时报错:time not implemented on this platform

原因:浏览器没有 SystemTime——必须用 wasm-bindgen 调 Date.now()

修复:用 chrono + wasm-bindgen feature:

toml
[dependencies]
chrono = { version = "0.4", features = ["wasmbind"] }
rust
let now = chrono::Utc::now();

5.15.4 错误三:动态库不可用

error: cannot use `dylib` crate-type with wasm32-unknown-unknown

原因:WASM 目标不支持动态库——所有代码必须静态链接。

修复:改成 cdylib(静态库 + C ABI):

toml
[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:

toml
# 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: ... undefined symbol: _ZN...

原因:某个函数被引用但没有定义——通常是 panic_handler 或全局分配器在 no_std 下缺失。

修复

rust
// no_std 项目必须提供 panic_handler
#![no_std]

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

// 如果用了 alloc 也要全局分配器
extern crate alloc;
#[global_allocator]
static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;

5.15.7 错误六:wasm-bindgen 版本不匹配

error: it looks like the Rust project used to create this Wasm file was linked
       against a different version of wasm-bindgen than this binary

原因wasm-bindgen crate 与 wasm-bindgen-cli 版本不一致。

修复:让 wasm-pack 自动管理(推荐),或手动对齐:

bash
cargo install wasm-bindgen-cli --version $(grep '^wasm-bindgen' Cargo.toml | sed 's/.*"=\?\([0-9.]*\)".*/\1/')

5.15.8 错误七:too many imports

RuntimeError: WebAssembly.instantiate(): unknown import: `wasi_snapshot_preview1`::`fd_write`

原因:编译用了 wasm32-wasi target,但用 wasm-pack 打包到浏览器(浏览器没有 WASI)。

修复:浏览器项目用 wasm32-unknown-unknown

toml
# .cargo/config.toml
[build]
target = "wasm32-unknown-unknown"

或者用 wasi-shim 等 polyfill 把 WASI 调用映射到浏览器 API(复杂且不完整)。

5.15.9 错误八:thread::spawn 报错

rust
std::thread::spawn(|| { /* ... */ });
// 运行时 trap:thread::spawn not implemented

原因:默认 wasm32-unknown-unknown 单线程。

修复:要么不用线程(用 wasm-bindgen-futures 异步),要么启用 wasm32-wasi-threads target(复杂,需要 SharedArrayBuffer)。

5.15.10 诊断流程图

5.15.11 错误诊断的工具链

诊断步骤通常是:先 cargo check 看编译错误;再 wasm-tools validate 看二进制;再 wasm-tools dump 看具体问题段。

5.15.12 常见错误的根因总结

理解这 5 类根因覆盖 90% 的 Rust → WASM 错误——遇到错误时按这套思路诊断比"上 Stack Overflow 搜"快很多。

把这套诊断手册放进项目 wiki——团队 onboarding 时少走 80% 的弯路。

5.16 Rust 异步在 WASM 上的实战

Rust 的 async/await 在原生平台依赖 Tokio/async-std 等运行时——在 WASM 上情况完全不同。理解 WASM 上的 Rust 异步生态,是写出能跑的异步代码的前提。

5.16.1 WASM 上的 Rust 异步全景

每个环境有不同方案——同一份 async 代码不能跨环境跑,必须知道目标环境。

5.16.2 浏览器:wasm-bindgen-futures

rust
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 事件循环单线程,所有异步任务在主线程跑
  • 不支持 select!:复杂的异步组合需要手动实现
  • timer 依赖 setTimeout:精度受浏览器限制

5.16.3 WASI Preview 2:wasi:io/poll

rust
// 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();  // 阻塞当前任务,让 host 调度

    // 数据到达后读取
    stream.read(8192)
}

Preview 2 的异步是"真异步"——host(Wasmtime)调度多个等待中的任务,比浏览器的"假异步"性能好。

5.16.4 选择 Rust 异步运行时

5.16.5 异步代码的可移植性

如果代码要跑在多个环境:

rust
// 推荐:只用 std::future + futures crate
use futures::Stream;

async fn process_stream<S: Stream<Item = u8>>(mut stream: S) {
    while let Some(item) = stream.next().await {
        // ...
    }
}

// 不推荐:直接 use tokio::*(绑定到 tokio)

把"异步逻辑"和"运行时"分离——业务代码只用 std::future + futures crate 的抽象,不直接依赖具体运行时。这样同一份代码既能在 wasm-bindgen 跑、也能在 tokio 跑。

5.16.6 异步性能特征

WASM 上的异步比原生 tokio 慢——主要是缺少多线程并行 + 跨边界开销。但通常足够(10000 个并发 IO 任务 < 100ms)。

5.16.7 异步陷阱

每条都需要专门设计:

  • 不要 blocking:浏览器没有 std::thread::sleep,必须用 async timer
  • 同步回调返回 Promise:JS 调用 async Rust 函数得到 Promise,要 await 才能拿值
  • 长任务分片:用 yield_now 让出控制权
  • 取消:用 Drop + state flag 自己实现

5.16.8 实战:异步 IO 的工程模式

rust
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. 让出控制权给浏览器,避免长任务
            wait_next_microtask().await;

            // 3. 处理事件
            process(event).await;

            // 4. 中间多次让出
            yield_now().await;
        }
    });
}

这种"每步骤都让出控制"的模式是 WASM 异步的关键——不让出会导致主线程长时间占用。

5.16.9 异步生态的成熟度

主要工具的成熟度:

  • wasm-bindgen-futures:浏览器主流,5+ 年生产级
  • worker-rs:Cloudflare Workers 标准
  • WASI P2 异步:可用但工具链跟进慢
  • P3 原生 async:未来主流方向

工程建议:当前用 wasm-bindgen-futures,关注 P3 演进。

5.16.10 异步代码的工程清单

每条都对应过去的 bug——遵循这套清单,WASM 异步代码能像普通 Rust 一样可靠。

5.17 Rust → WASM 项目的安全工程

Rust 因内存安全闻名——但 Rust + WASM 的组合在安全性上仍有盲点。这一节系统整理 Rust → WASM 项目的安全考虑。

5.17.1 Rust + WASM 的安全分层

每层都有独立的安全保证——但盲点也在每层。

5.17.2 Rust 内存安全在 WASM 中的局限

rust
// 这段代码在 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 整数溢出的处理

rust
// 反模式:依赖默认行为
fn calculate(a: u32, b: u32) -> u32 {
    a + b  // release 模式:溢出回绕到 0
}

// 推荐:显式处理
fn calculate(a: u32, b: u32) -> Option<u32> {
    a.checked_add(b)
}

Rust 的 checked_* / saturating_* / wrapping_* 系列方法明确意图——避免依赖默认行为产生 bug。

5.17.4 unsafe 代码的特殊审查

WASM 的沙箱保护宿主——但不保护 WASM 模块自己。unsafe 代码的 bug 仍能让 WASM 模块内部数据错乱(包括用户敏感数据)。

工程纪律:

  • unsafe 代码必须有详细注释解释 invariant
  • code review 必须双人审查
  • cargo miri 检测 UB

5.17.5 依赖审计

bash
# cargo-audit 检查已知漏洞
cargo install cargo-audit
cargo audit

# 输出示例:
# Crate:     foo
# Version:   1.2.3
# Title:     Buffer overflow in foo::bar
# Date:      2024-01-15
# ID:        RUSTSEC-2024-0001

Rust 的 RustSec 数据库收集 crate 漏洞——cargo audit 在 CI 中自动检查。

yaml
- name: Audit dependencies
  run: |
    cargo install cargo-audit
    cargo audit --deny warnings

5.17.6 供应链安全(深入)

每条都有发生过的真实事件:

  • 恶意 crate:模仿热门 crate 名(typosquatting),植入恶意代码
  • 依赖污染:上游 crate 被攻击者接管,发布恶意版本
  • 编译器后门:理论上 rustc 可被植入后门
  • 注册中心攻击:crates.io 本身被攻击

防御措施:

  • Cargo.lock 锁定具体版本 + hash
  • cargo deny 限制依赖
  • sigstore 签名验证
  • 关注社区安全公告

5.17.7 WASM 输入校验

rust
// 即使 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 加密与密钥管理

密码学的工程考虑(§19.3 1Password 案例已涉及):

  • 用成熟库(ring/Web Crypto),不要自己实现
  • 密钥不出 host(用 keyHandle 引用而非密钥本身)
  • 关键操作用 host 硬件加速

5.17.9 拒绝服务(DoS)防御

WASM 应用必须有完整的资源限制——单一防御不够:

rust
// host 设置 fuel
store.set_fuel(10_000_000)?;

// host 设置内存上限
config.max_wasm_stack(1_000_000);

// 业务级别速率限制
let rate_limiter = RateLimiter::new(100, Duration::from_secs(1));
if !rate_limiter.check() {
    return Err("rate limit exceeded");
}

5.17.10 安全工程清单

每条都对应一类常见漏洞——遵循这套清单大幅减少安全 bug。

把这套安全工程嵌入项目从立项到上线的每个阶段——而不是上线前才补。

5.18 跨书关联:与 Tokio 运行时的异步对比

本章讨论的 Rust → WASM 编译路径,和《Tokio 源码深度解析》第 3 章"运行时模型"有一个关键对照——异步执行模型的差异:

在原生平台上,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 无缝互操作。

基于 VitePress 构建