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

发布时间:2026/7/3 17:29:07
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 ptrlen] 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 ptrlen] 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)两个数字。每一步的谁分配、谁释放都不能靠编译器检查全凭开发者写清楚协议。二、导出分配和释放函数 — 把内存协议交给调用方要让宿主能把字符串传进 WASMWASM 侧必须先提供一个分配内存的函数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: Vecu8 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) - ResultString, 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 上栽过的最管用的教训。