WASM 线性内存管理:为什么 wasm 只有一块内存,rust 怎么安全读写

📅 2026/7/9 9:34:26 👁️ 阅读次数 📝 编程学习
WASM 线性内存管理:为什么 wasm 只有一块内存,rust 怎么安全读写

WASM 线性内存管理:为什么 wasm 只有一块内存,rust 怎么安全读写


一、为什么 Wasm 只有一块线性内存

Wasm 的安全模型决定了一个设计选择:每个 Wasm 模块拥有一个线性的、字节可寻址的内存空间

flowchart TB subgraph 宿主机内存[宿主机内存空间] direction LR HM1[宿主机堆] HM2[宿主机栈] end subgraph Wasm沙箱[Wasm 沙箱] direction TB subgraph LM[线性内存 - 一块连续的大数组] L1[偏移 0: 栈区域 ↓] L2[偏移 N: 堆区域 ↑] L3[偏移 M: 未使用] end end HM1 -.->|隔离| LM 宿主机内存 -->|通过偏移量访问| LM style LM fill:#ffd93d,stroke:#333 style Wasm沙箱 fill:#6bcb7733,stroke:#6bcb77

三个关键特性

  1. 线性寻址:内存就像一个大Vec<u8>,从偏移 0 到最大地址。没有任何"段"或"页表"的概念,就是一整块。

  2. 按页增长:内存以 64KB 为一页(page)增长。初始大小和最大大小在模块加载时指定。Wasm 模块可以通过memory.grow指令动态申请更多内存。

  3. 沙箱隔离:Wasm 模块内部的指针只是线性内存内的偏移量(从 0 开始),绝对无法指向宿主机内存。这种设计从根本上杜绝了缓冲区溢出改写宿主机数据的可能性。


二、Rust 编译成 Wasm 时,堆和栈怎么映射到线性内存

这是理解 Wasm + Rust 的核心。Rust 程序有"栈"和"堆"两个内存区域,编译成 Wasm 后:

flowchart TB subgraph Rust概念[Rust 内存模型] Stack[函数调用栈<br/>局部变量、函数参数] Heap[堆<br/>Box, Vec, String 等] end subgraph Wasm实现[Wasm 线性内存中的实际布局] direction TB A[偏移 0] B[栈空间<br/>向低地址增长] C[空闲区域] D[堆空间<br/>向高地址增长] E[偏移 MAX] end Stack --> B Heap --> D A --- B --- C --- D --- E style Stack fill:#4ecdc4aa,stroke:#333 style Heap fill:#ff6b6baa,stroke:#333 style B fill:#4ecdc4aa,stroke:#333 style D fill:#ff6b6baa,stroke:#333

栈的实现

Wasm 没有硬件栈指针寄存器。栈是通过一个全局变量__stack_pointer来模拟的。函数调用时,编译器生成的 Wasm 指令会手动调整__stack_pointer

// ========== Rust 源码 ========== fn example(a: i32, b: i32) -> i32 { let result = a + b; // result 在"栈"上 result } // ========== 编译为 Wasm 后(简化) ========== // (func $example (param i32 i32) (result i32) // ;; 调整栈指针(分配局部变量空间) // global.get $__stack_pointer // i32.const 16 ;; 分配 16 字节 // i32.sub // global.set $__stack_pointer // // ;; 执行加法 // local.get 0 ;; a // local.get 1 ;; b // i32.add // // ;; 恢复栈指针(释放局部变量空间) // global.get $__stack_pointer // i32.const 16 // i32.add // global.set $__stack_pointer // )

堆的实现

Rust 的Box<T>Vec<T>String等堆分配类型,在 Wasm 中通过一个称为"分配器"(allocator)的机制工作。常用的 Wasm 分配器包括:

  • wee_alloc:轻量级,适合小模块
  • dlmalloc:性能好,适合更大规模的分配
  • lol_alloc:更现代的选项
// ========== 堆分配在 Wasm 中的实际过程 ========== // 导入宿主提供的 memory extern "C" { // Wasm 线性内存由宿主提供 } // 使用 wee_alloc 作为全局分配器 #[global_allocator] static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; fn create_data() -> Vec<u8> { // 1. Vec::with_capacity(1024) 调用分配器 // 2. 分配器在 Wasm 线性内存中找到 1024 字节的空间 // 3. 返回一个指针(线性内存内的偏移量) // 4. Vec 内部记录这个偏移量 + 长度 + 容量 let mut data = Vec::with_capacity(1024); // 5. push 操作写入线性内存的对应位置 data.extend_from_slice(b"Hello Wasm!"); // 6. data 离开作用域,drop 调用分配器的 free data }

三、Rust 怎么安全读写 Wasm 线性内存——从宿主机角度看

宿主机(通常是 Rust 写的)和 Wasm 模块之间的数据交互,本质上是对同一个字节数组的读写

use wasmtime::{Engine, Module, Store, Memory, MemoryType}; // ========== 宿主机读取 Wasm 线性内存 ========== #[derive(Debug)] struct WasmAppState { // 我们可以把 Wasm 的 memory 存在宿主机状态中 wasm_memory: Option<Memory>, } fn read_wasm_string( store: &mut Store<WasmAppState>, memory: &Memory, ptr: i32, // 字符串在线性内存中的偏移 len: i32, // 字符串长度 ) -> Result<String, String> { let offset = ptr as usize; let length = len as usize; // 安全检查:偏移+长度不能超出当前内存大小 let mem_size = memory.data_size(store); if offset + length > mem_size { return Err(format!( "内存越界: 偏移 {} + 长度 {} > 内存大小 {}", offset, length, mem_size )); } // 安全读取(wasmtime 内部做了 bounds check) let mut buffer = vec![0u8; length]; memory .read(store, offset, &mut buffer) .map_err(|e| format!("读取 Wasm 内存失败: {e}"))?; // 转为 UTF-8 字符串 String::from_utf8(buffer) .map_err(|e| format!("无效的 UTF-8: {e}")) } fn write_wasm_string( store: &mut Store<WasmAppState>, memory: &Memory, s: &str, ) -> Result<(i32, i32), String> { let bytes = s.as_bytes(); let len = bytes.len(); // 找到当前内存大小的末尾作为新数据的存放位置 let current_size = memory.data_size(store); let needed = current_size + len; // 如果不够,增长内存 let page_size = 65536; // 64KB let current_pages = memory.size(store); let needed_pages = (needed + page_size - 1) / page_size; if needed_pages > current_pages { let pages_to_grow = needed_pages - current_pages; memory .grow(store, pages_to_grow) .map_err(|e| format!("Wasm 内存增长失败: {e}"))?; } // 写入数据 let offset = current_size; memory .write(store, offset, bytes) .map_err(|e| format!("写入 Wasm 内存失败: {e}"))?; Ok((offset as i32, len as i32)) } // ========== 使用示例 ========== fn use_wasm_memory() -> Result<(), String> { let engine = Engine::default(); let mut store = Store::new( &engine, WasmAppState { wasm_memory: None } ); let memory = Memory::new( &mut store, MemoryType::new(1, Some(256)) // 初始1页,最大256页 ).map_err(|e| format!("创建内存失败: {e}"))?; // 写入字符串 let (ptr, len) = write_wasm_string(&mut store, &memory, "Hello from host!")?; println!("写入: ptr={ptr}, len={len}"); // 读回字符串 let result = read_wasm_string(&mut store, &memory, ptr, len)?; println!("读回: {result}"); Ok(()) }

四、内存共享的高级模式:宿主和 Wasm 双向读写

在实际的 AI 推理场景中,最常见的模式是:

  1. 宿主机将输入数据写入 Wasm 内存
  2. 调用 Wasm 函数进行推理
  3. Wasm 将结果写入内存,返回偏移量和长度
  4. 宿主机读取结果
sequenceDiagram participant H as 宿主机 (Rust) participant M as Wasm 线性内存 participant W as Wasm 模块 Note over H,W: 场景: 图像 AI 推理 H->>M: 1. 写入图片数据到偏移 1024 H->>W: 2. 调用 infer(1024, 图片长度) W->>M: 3. 读取偏移 1024 的图片数据 W->>W: 4. 执行 AI 推理 W->>M: 5. 在偏移 2048 写入结果数据 W-->>H: 6. 返回 (2048, 结果长度) H->>M: 7. 读取偏移 2048 的结果 H->>W: 8. 调用 free(2048, 结果长度) 释放 W->>M: 9. 标记偏移 2048 为可重用 Note over H,W: 关键: 宿主永远只通过偏移量访问,不持有原始指针

对应的代码实现:

// ========== Wasm 插件侧 (编译为 wasm32-unknown-unknown) ========== use std::alloc::{alloc, Layout}; /// 全局推理状态 static mut MODEL_STATE: Option<Vec<f32>> = None; /// 初始化模型 —— 加载权重到线性内存中 #[no_mangle] pub extern "C" fn init() -> i32 { unsafe { MODEL_STATE = Some(vec![0.0_f32; 1024 * 1024]); // 模拟 1MB 模型 } 0 // 成功 } /// AI 推理函数 /// /// # 参数 /// - input_ptr: 输入数据在线性内存中的偏移(宿主机写入的) /// - input_len: 输入数据长度 /// /// # 返回 /// - i64: 高32位=结果数据偏移, 低32位=结果数据长度 /// 返回 -1 表示错误 #[no_mangle] pub extern "C" fn infer(input_ptr: i32, input_len: i32) -> i64 { if input_ptr <= 0 || input_len <= 0 { return -1; } // 1️⃣ 从线性内存读取输入数据 let input = unsafe { // 从偏移量 + 长度构建切片 —— 只读引用,不拥有 std::slice::from_raw_parts(input_ptr as *const u8, input_len as usize) }; // 2️⃣ 执行推理 let output = match run_model_inference(input) { Ok(data) => data, Err(_) => return -1, }; // 3️⃣ 在 Wasm 侧分配内存存放结果 let layout = Layout::array::<u8>(output.len()).unwrap(); let result_ptr = unsafe { alloc(layout) }; if result_ptr.is_null() { return -1; } // 4️⃣ 将结果写入 Wasm 侧分配的内存 let result_len = output.len(); unsafe { std::ptr::copy_nonoverlapping( output.as_ptr(), result_ptr, result_len, ); } // 5️⃣ 打包返回: 高32位=指针, 低32位=长度 ((result_ptr as u64) << 32) | (result_len as u64) } /// 释放由 Wasm 侧分配的内存 /// /// 宿主机必须在读取完结果后调用此函数。 /// 如果宿主机不调用,这块内存永远不会释放(因为 Wasm 不知道宿主是否还在用)。 #[no_mangle] pub extern "C" fn dealloc(ptr: i32, len: i32) { if ptr == 0 || len <= 0 { return; } unsafe { let layout = Layout::array::<u8>(len as usize).unwrap(); std::alloc::dealloc(ptr as *mut u8, layout); } } fn run_model_inference(input: &[u8]) -> Result<Vec<u8>, String> { // 模拟 AI 推理 let sum: usize = input.iter().map(|&b| b as usize).sum(); let result = format!("推理结果: 输入 {} 字节, 校验和={}", input.len(), sum); Ok(result.into_bytes()) }

在 Rust 中安全读写的关键原则

原则说明代码体现
边界检查每次读取前验证偏移+长度不越界if offset + length > mem_size
不跨越所有权宿主机写入的数据由宿主机管理,Wasm 内部分配的由宿主机通知释放dealloc函数
UTF-8 校验从字节到字符串的转换必须验证编码String::from_utf8()
按页增长写入前确保内存够大memory.grow()

五、总结

这篇文章梳理了 Wasm 线性内存的核心概念和 Rust 安全读写的最佳实践:

  1. Wasm 只有一块线性内存:这是一个设计选择,用"所有地址都在一个大数组内"的极简模型换取安全性。Wasm 模块的指针绝对无法访问宿主机内存。

  2. Rust 编译成 Wasm 后,堆和栈都映射到这块内存:栈通过__stack_pointer全局变量模拟,堆通过分配器(如wee_alloc)管理。编译后的 Wasm 指令会在函数调用时手动调整栈指针。

  3. 宿主和 Wasm 通过偏移量+长度交换数据:双方都读写同一块字节数组。核心安全实践是:每次读取前做边界检查、UTF-8 校验、"谁分配谁释放"。

  4. 实际 AI 推理的流程:宿主写输入 → 调用 Wasm 函数 → Wasm 内部处理 → 分配结果内存 → 返回偏移+长度 → 宿主读结果 → 通知 Wasm 释放。

作为独立开发者,理解 Wasm 的线性内存让我对"内存"这个概念有了更具体的认识。之前总觉得内存是操作系统管的事情,Wasm 让我看到:内存本质上就是一大块有编号的字节,指针就是编号。Rust 的类型系统在这个基础上做的事情,是让你在写代码时不用时刻担心"这个编号还合法吗"。

希望这篇文章帮你理解 Wasm 内存管理。有问题欢迎评论区交流!