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
三个关键特性:
线性寻址:内存就像一个大
Vec<u8>,从偏移 0 到最大地址。没有任何"段"或"页表"的概念,就是一整块。按页增长:内存以 64KB 为一页(page)增长。初始大小和最大大小在模块加载时指定。Wasm 模块可以通过
memory.grow指令动态申请更多内存。沙箱隔离: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 推理场景中,最常见的模式是:
- 宿主机将输入数据写入 Wasm 内存
- 调用 Wasm 函数进行推理
- Wasm 将结果写入内存,返回偏移量和长度
- 宿主机读取结果
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 安全读写的最佳实践:
Wasm 只有一块线性内存:这是一个设计选择,用"所有地址都在一个大数组内"的极简模型换取安全性。Wasm 模块的指针绝对无法访问宿主机内存。
Rust 编译成 Wasm 后,堆和栈都映射到这块内存:栈通过
__stack_pointer全局变量模拟,堆通过分配器(如wee_alloc)管理。编译后的 Wasm 指令会在函数调用时手动调整栈指针。宿主和 Wasm 通过偏移量+长度交换数据:双方都读写同一块字节数组。核心安全实践是:每次读取前做边界检查、UTF-8 校验、"谁分配谁释放"。
实际 AI 推理的流程:宿主写输入 → 调用 Wasm 函数 → Wasm 内部处理 → 分配结果内存 → 返回偏移+长度 → 宿主读结果 → 通知 Wasm 释放。
作为独立开发者,理解 Wasm 的线性内存让我对"内存"这个概念有了更具体的认识。之前总觉得内存是操作系统管的事情,Wasm 让我看到:内存本质上就是一大块有编号的字节,指针就是编号。Rust 的类型系统在这个基础上做的事情,是让你在写代码时不用时刻担心"这个编号还合法吗"。
希望这篇文章帮你理解 Wasm 内存管理。有问题欢迎评论区交流!
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/no1coder/article/details/162717444



