在写完 ESP8266 IoT 架构之后,我就在想一件事情:既然代码开源,IoT 网关岂不是谁都能连?
于是我试图在微信小程序端做文章。但开源的小程序不能硬编码密钥,也不能仅靠接口鉴权。
我想到了很早之前预研的 WebAssembly(WASM)。配合后端防护,可以大幅提升攻击者的成本。

一、背景与目标

实现的功能非常简单:

传入 小程序 APPID + 设备 ID + 时间戳,使用 Ed25519 签名,返回 Token。

以前在微信小程序调通过 WxWebAssembly(简称 wxwasm),但当时主要处理 Int 类型,这次需要返回字符串。

当我拿出以前的预研代码进行修改时,我才意识到:

“能用 Rust 写 WASM”“能在微信小程序里稳定运行”,完全是两回事。


二、第一阶段:理想很丰满 —— Rust + wasm-bindgen

最初的环境是:

  • Rust 1.95
  • wasm-bindgen
  • wasm-pack

Cargo.toml


[dependencies]

wasm-bindgen = "0.2"

ed25519-dalek = { version = "2.1", features = ["rand_core"] }

hex = "0.4"

lib.rs


[wasm_bindgen]
pub fn generate_token(appid: &str, device_id: &str, timestamp: &str) -> Result<String, JsValue> {

// Ed25519 签名逻辑

}

编译很顺利(就是有点慢 😭),本地 test.html 测试一切正常。

但一进微信小程序,噩梦开始了。


三、第二阶段:现实很骨感 —— 版本地狱

1. externref 错误

加载时报错:不支持 externref 类型

网上 AI 搜索建议降级 Rust。于是我安装了 Rust 1.75.0

结果:

  • wasm-bindgen 开始报错
  • AI 建议锁定版本:wasm-bindgen = "=0.2.87"
  • 接着 ed25519-dalek 报错,要求更高版本 Rust

一番折腾后,配置变成了:


ed25519-dalek = { version = "=2.1.0", default-features = false, features = ["rand_core"] }

编译通过了 ✅
复制到微信开发者工具……

还是报错 ❌


TypeError: Cannot read property '__wbindgen_add_to_stack_pointer' of undefined

那一刻,我已经有点生气了 😤


四、第三阶段:换 C?又一条死路

一气之下,我决定换 C + Emscripten

  • 安装 Emscripten SDK
  • 各种依赖下载失败
  • 想到 C 的第三方库管理远不如 Rust

于是又放弃了。


五、第四阶段:冷静下来,从头再来

休息一天后,我重新回到 Rust。

这次的目标只有一个:先把字符串跑通,再谈业务。

最终确定的“稳定组合”是:

  • Rust 1.72.1
  • wasm-bindgen 0.2.87

Cargo.toml(关键点)


[package]

name = "wasm_reverse"

version = "0.1.0"

edition = "2021" # ⚠️ 不能是 2024

lib.rs


use wasm_bindgen::prelude::*;

[wasm_bindgen]
pub fn reverse_string(input: &str) -> String {

input.chars().rev().collect()

}

本以为这次稳了,结果报错:


SyntaxError: Cannot use 'import.meta' outside a module

这句话的内容:

微信小程序逻辑层 ≠ 标准浏览器

  • ❌ 不支持 ESM
  • ❌ 不支持动态 import
  • ❌ 更不是 Node

也就是说:wasm-pack 生成的胶水代码,在小程序里几乎不可用。


六、第五阶段:WXWebAssembly + RuntimeError

我改用手动重写胶水代码,使用 微信官方的 WXWebAssembly,手动加载 .wasm 文件。

WASM 能加载了 ✅
但一调用函数,立刻崩溃 ❌:


RuntimeError: unreachable

at __wbindgen_malloc

查了一圈才明白:

  • wasm-bindgen 依赖完整的初始化流程
  • 小程序里我只做了 instantiate
  • 内存分配器根本没有准备好

👉 我在跑一个 “半初始化” 的 WASM 实例


七、第六阶段:放弃wasm-bindgen,使用 C ABI

为了彻底稳定,我做了一个重要决定:

放弃 wasm-bindgen,手写 C ABI。

新规则非常简单:

  1. 所有数据通过 指针 + 长度 传递
  2. 内存由 Rust 分配
  3. JS 只负责拷贝和释放

虽然写起来麻烦了很多,但好处是:

  • ✅ 我知道每一块内存在哪里
  • ✅ 知道谁在管它
  • ✅ 知道什么时候释放

编译命令:

cargo build --target wasm32-unknown-unknown --release

胶水代码:

const WASM_PATH = '/utils/btwasm.wasm'

let wasm = null

function getWasm() {
  if (wasm) return Promise.resolve(wasm)
  return new Promise((resolve, reject) => {
    WXWebAssembly.instantiate(WASM_PATH, {})
      .then(res => {
        wasm = res.instance.exports
        resolve(wasm)
      })
      .catch(err => {
        console.error('[WASM] load failed', err)
        reject(err)
      })
  })
}

八、第七阶段:JS 传参的隐形坑

终于调通了自己的逻辑,却又遇到诡异问题:


参数不正确

token: |wx123456

调用成功 ✅
返回值也有 ✅
但内容明显不对 ❌

排查后才恍然大悟:

我传给 WASM 的,并不是内存里的数据,而是 JS 对象本身

Rust 把 JS 对象的地址当成指针去读,自然全是垃圾数据。

这也是为什么很多人第一次写 WASM 都会觉得:

“明明调用成功了,结果却莫名其妙。”

胶水代码:

async generateToken(appid, agentId, timestamp) {
  const w = await getWasm()

  const enc = new TextEncoder()
  const dec = new TextDecoder()

  const appidBytes = enc.encode(appid)
  const agentBytes = enc.encode(agentId)
  const timeBytes = enc.encode(timestamp)

  // 1️⃣ 申请 WASM 内存
  const appidPtr = w.malloc(appidBytes.length)
  const agentPtr = w.malloc(agentBytes.length)
  const timePtr = w.malloc(timeBytes.length)

  // 2️⃣ 拷贝到 WASM 内存
  const mem = new Uint8Array(w.memory.buffer)
  mem.set(appidBytes, appidPtr)
  mem.set(agentBytes, agentPtr)
  mem.set(timeBytes, timePtr)

  // 3️⃣ 输出长度位置
  const outLenPtr = w.memory.buffer.byteLength - 8

  // 4️⃣ 调用
  const ptr = w.generate_token(
    appidPtr,
    appidBytes.length,
    agentPtr,
    agentBytes.length,
    timePtr,
    timeBytes.length,
    outLenPtr
  )

  const outLen = new Uint32Array(w.memory.buffer)[outLenPtr / 4]
  const result = new Uint8Array(w.memory.buffer, ptr, outLen)
  const text = dec.decode(result)

  // 5️⃣ 释放
  w.free_buf(ptr, outLen)
  w.free(appidPtr)
  w.free(agentPtr)
  w.free(timePtr)

  return text
}

九、第八阶段:体积反而变大了

原本 wasm-bindgen 只有 70KB
自己搭完这套体系,变成了 120KB+

原因很现实:

  • 引入了分配器
  • 标准库没有完全裁剪
  • panic / format / string 都在悄悄占空间

最终通过激进优化才压下来:


[lib]

crate-type = ["cdylib"]

[profile.release]

opt-level = "z"

lto = true

codegen-units = 1

panic = "abort"

strip = "symbols"

最终生成的wasm不到50KB。


十、总结

这次折腾下来,我最大的感受是:

WASM 不是“写完就跑”,而是“写完还要适配运行环境”。

尤其是 微信小程序这种非标准 Web 容器

  • 胶水代码越少,稳定性越高
  • 越接近 C ABI,越不容易翻车
  • 内存模型不清,一切都是玄学

如果你也在考虑 Rust + WASM + 小程序,我的建议是:

  1. 别迷信官方示例
  2. 别急着写业务
  3. 先把内存模型和环境限制搞清楚

那一步,才是最贵的。


本文为真实项目复盘,未删减、未美化。