第一程序员头像
关注

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 内存管理。有问题欢迎评论区交流!


转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/no1coder/article/details/162717444

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--