WebAssembly 与 Rust 字符串传递:跨边界之前先想清内存所有权

📅 2026/7/3 17:40:03 👁️ 阅读次数 📝 编程学习
WebAssembly 与 Rust 字符串传递:跨边界之前先想清内存所有权

WebAssembly 与 Rust 字符串传递:跨边界之前先想清内存所有权

把 Rust 编译成 WebAssembly 后做的第一件事通常是"传个字符串试试"。我当时的做法特别粗暴:在导出的函数里写死一个&str的返回值,然后满怀期待地在 JavaScript 侧调用——结果拿到的是一串看不懂的数字。那一刻我才意识到,WASM 边界上只有线性内存和整数,字符串这个概念在跨边界时是不存在的。

作为自学 Rust 的人,Rust 内部的所有权机制已经让我花了不少时间适应。当涉及到 WASM 跨语言边界时,所有权问题从"编译器帮你管"变成了"你自己写协议来管"——宿主和 WASM 之间,谁分配内存、谁写入数据、谁负责释放,每一步都要在代码里显式约定。今天这篇是我在折腾 WASM 字符串传递时的学习笔记,希望对正在入坑的朋友有帮助。

一、理解 WASM 边界:只有整数,没有字符串

先看一眼宿主(JS/其他运行时)和 WASM 模块之间传递数据的真实路径:

flowchart TD A[宿主侧字符串 Host String] --> B[编码为 UTF-8 字节 UTF-8 Encode] B --> C[调用 WASM alloc 分配内存 Call alloc] C --> D[将字节写入 WASM 线性内存 Write Bytes] D --> E[传递指针+长度给 WASM 函数 Pass ptr+len] E --> F[WASM 函数读取字节 Read Bytes from Memory] F --> G[解码为 Rust String Decode to String] G --> H[执行核心逻辑 Core Logic] H --> I[结果编码为 UTF-8 Encode Result] I --> J[WASM 分配新内存 alloc for Result] J --> K[返回指针+长度给宿主 Return ptr+len] K --> L[宿主读取字节 Read Bytes] L --> M{宿主调用 dealloc 释放 Release Memory} M -->|已释放 Freed| N[完成 Done] M -->|忘记释放 Leak| O[内存泄漏 Memory Leak] style E fill:#bbf,stroke:#333 style K fill:#bbf,stroke:#333 style O fill:#f66,stroke:#333

整个流程里,字符串在两边都是自然的类型,在边界上却变成了(ptr, len)两个数字。每一步的"谁分配、谁释放"都不能靠编译器检查,全凭开发者写清楚协议。

二、导出分配和释放函数 — 把内存协议交给调用方

要让宿主能把字符串传进 WASM,WASM 侧必须先提供一个分配内存的函数:

use std::alloc::{alloc, Layout}; /// 暴露给宿主的内存分配函数 /// 宿主调用此函数获取 WASM 内存中的一段空白区域 #[no_mangle] pub extern "C" fn alloc(len: usize) -> *mut u8 { // 按指定长度用系统分配器分配对齐内存 let layout = Layout::from_size_align(len, 1).expect("无法创建内存布局"); unsafe { alloc(layout) } // 注意:这里不释放,由宿主负责后续调用 dealloc }

更简单的做法是用Vec来借它的分配能力,然后用forget防止自动释放:

/// 简化版:利用 Vec 自动分配,但 forget 掉防止 Drop #[no_mangle] pub extern "C" fn alloc_simple(len: usize) -> *mut u8 { let mut buf: Vec<u8> = Vec::with_capacity(len); let ptr = buf.as_mut_ptr(); // forget 阻止 Vec 在函数返回时自动释放内存 // 所有权转移给调用方 std::mem::forget(buf); ptr }

光分配不释放是内存泄漏。所以对应地,还要导出一个释放函数:

/// 释放由 alloc 分配的内存 /// 安全前提:调用方必须传回 alloc 返回的指针和原始长度 #[no_mangle] pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: usize) { if ptr.is_null() { return; // 空指针不需要释放 } // 用 Vec::from_raw_parts 重新接管所有权,让 Drop 自动释放 let _ = Vec::from_raw_parts(ptr, len, len); // _ 离开作用域后自动调用 Drop,释放内存 }

这里有一个非常危险的隐藏约束:调用方必须对每个alloc返回的指针恰好调用一次dealloc,而且长度必须一致。如果宿主传错了长度,或者在同一个指针上调用了两次dealloc,就可能导致未定义行为——程序不会 panic,而是静默地损坏内存。我在测试时就用一个错误的长度参数把 dev server 搞崩过,排查了半天。

三、读入和输出都要有明确的协议

当字符串从宿主传入时,WASM 侧用指针和长度读取:

/// 从 WASM 线性内存中读取宿主传入的字符串 /// # 安全 /// 调用者必须保证 ptr 和 len 指向合法的 UTF-8 字节 unsafe fn read_host_string(ptr: *const u8, len: usize) -> Result<String, String> { if ptr.is_null() || len == 0 { return Err("输入指针为空或长度为零".to_string()); } // 从原始指针创建字节切片 let bytes = std::slice::from_raw_parts(ptr, len); // 尝试解码为 UTF-8 String::from_utf8(bytes.to_vec()) .map_err(|e| format!("输入不是有效的 UTF-8 编码: {}", e)) }

返回值的处理更复杂一些。WASM 不能直接返回一个 Rust String,需要把结果编码后放在线性内存里,然后告诉宿主"结果在地址 X,长度 Y,你读完后记得调用dealloc":

/// 一个完整的往返调用示例 #[no_mangle] pub unsafe extern "C" fn process(ptr: u32, len: u32) -> u64 { // 1. 从宿主读入字符串 let input = read_host_string(ptr as *const u8, len as usize) .unwrap_or_else(|e| format!("[错误] {}", e)); // 2. 执行业务逻辑 let output = format!("WASM 收到: {}", input); // 3. 分配输出内存 let out_bytes = output.into_bytes(); let out_len = out_bytes.len(); let out_ptr = alloc(out_len); // 4. 把结果写入分配的内存 let out_slice = std::slice::from_raw_parts_mut(out_ptr, out_len); out_slice.copy_from_slice(&out_bytes); // 5. 把指针和长度打包进一个 u64 返回 // 高 32 位放长度,低 32 位放指针 ((out_len as u64) << 32) | (out_ptr as u64) }

对于复杂数据(结构化参数、嵌套对象等),我更推荐用 JSON 或 MessagePack 序列化:

use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] struct PluginInput { prompt: String, max_tokens: u32, temperature: f32, }

协议简单、可调试、不容易出错。序列化有性能开销,但在插件场景下通常不是瓶颈。等性能真的不够用了再考虑更紧凑的方案,而不是一开始就把内存布局搞得很复杂。

四、测试先跑一个最小 Roundtrip

跨边界传字符串最容易出问题的是"协议不对齐"。我调试 WASM 字符串问题时的习惯是:先写一个最简单的往返测试:

/// 最小 Roundtrip 测试:验证整条字符串传递链路 #[no_mangle] pub unsafe extern "C" fn echo(ptr: u32, len: u32) -> u64 { // 读入 → 原样返回 let input = read_host_string(ptr as *const u8, len as usize) .unwrap_or_else(|_| String::new()); // 分配并返回同样内容 let bytes = input.into_bytes(); let out_len = bytes.len(); let out_ptr = alloc(out_len); std::slice::from_raw_parts_mut(out_ptr, out_len) .copy_from_slice(&bytes); ((out_len as u64) << 32) | (out_ptr as u64) }

这个测试虽然简单,但能验证 UTF-8 编解码、内存分配/释放、指针传递和长度计算四条链路。如果echo("hello")都跑不稳,就不要继续堆 AI 插件逻辑了——边界协议不稳,业务代码越多越难排查。

五、总结

WebAssembly 与 Rust 传递字符串的核心不是"传个 String 就行",而是要在跨边界之前先定义清楚内存所有权协议:谁分配、谁写入、谁读取、谁释放,每一步都要有对应的代码约束。导出的 alloc/dealloc 函数是协议的骨架,UTF-8 编解码和序列化方案是通道里的翻译层。

作为自学者,WASM 的字符串传递让我真正理解了"所有权跨越编译器边界时,就变成了程序员自己的责任"。Rust 的 borrow checker 帮我们管住了 Rust 内部的内存安全,但跨语言的桥梁两端,还是要靠我们自己写清楚协议。测试时先跑最小 roundtrip,协议稳了再往上堆功能——这是我在 WASM 上栽过的最管用的教训。