☰
ESP32无进程沙箱下,如何用WebAssembly与WAMR限制小应用权限
2026/9/27 1:48:03 网站建设 项目流程

1. 当“小应用”跑在 ESP32 上,我们到底在担心什么

把 ESP32 当成一个能跑“小应用”的平台,这几年越来越常见。原因也不复杂:芯片便宜、功耗低、外设全,Wi-Fi 和蓝牙都自带,拿来做智能家居节点、工业采集终端、带屏交互设备都很顺手。但一旦你开始往上面塞第三方写的逻辑,或者让设备支持“远程下发一段功能代码”,问题就来了——ESP32 本身没有进程沙箱。

这句话不是随口一说。ESP32 上跑的主流方案是 FreeRTOS,任务之间共享同一片地址空间,没有 MMU(内存管理单元)做页级隔离,也没有 Linux 那种 fork 出来的独立进程。换句话说,一个任务越界写了别的任务的变量,系统不会拦你,只会默默崩掉或者行为诡异。这和我们在 PC、服务器上习惯的“进程隔离”完全不是一回事。

所以当标题问“怎样限制一个小应用能做什么”时,它真正问的是:在没有操作系统级沙箱的前提下,如何用软件手段给一段不可信或半可信的代码划出边界。这个边界包括它能访问哪些内存、能调用哪些外设、能占用多少 CPU、能开多少网络连接。热搜词里出现的 WebAssembly、WAMR、权限、沙箱,其实都指向同一个思路——既然硬件和 RTOS 不给隔离,那就在应用层自己造一个。

这篇文章适合三类人看:一是正在做 ESP32 可扩展固件、想让设备支持插件化功能的开发者;二是做物联网产品、需要评估“第三方代码上设备”安全风险的技术负责人;三是单纯对嵌入式沙箱、WebAssembly 在 MCU 上落地感兴趣的学习者。我会把“为什么这么设计”“具体怎么落地”“实测踩过哪些坑”讲清楚,代码和配置尽量给到能直接抄的程度。

需要先说明一点:ESP32 上的“沙箱”永远达不到浏览器那种强度。浏览器沙箱背后有操作系统进程、有硬件虚拟化、有几十年的攻防打磨。MCU 上的沙箱是降低风险,不是消除风险。理解这个前提,后面的方案取舍才不会跑偏。

2. 为什么 ESP32 天生给不了进程级隔离

2.1 FreeRTOS 任务共享地址空间的本质

很多人第一次听到“ESP32 没有进程沙箱”会愣一下,因为在 PC 上“进程”是理所当然的。但 FreeRTOS 里根本没有进程这个概念,只有任务(Task)。任务是一个函数加一个栈,调度器在它们之间切换上下文。所有任务看到的是同一份物理内存映射,全局变量、堆、外设寄存器,谁都能碰。

我举个具体的例子。假设你有两个任务,任务 A 里定义了一个static uint8_t buffer[256],任务 B 里有个指针算错了,写到了 A 的 buffer 上。编译器不会报错,链接器不会报错,运行时 FreeRTOS 也不会报错。A 下次读 buffer 时拿到的是被污染的数据,可能触发一个莫名其妙的断言,也可能直接把设备带进死循环。这种问题在 PC 上会被 MMU 拦成段错误,在 ESP32 上就是“玄学 bug”。

这就是没有进程沙箱最直接的代价:故障不可隔离,错误会跨模块传播。你没法说“这个小应用崩了不影响主系统”,因为它和主系统在同一片内存里。

2.2 ESP32 的内存保护单元能做什么、不能做什么

ESP32(经典款)确实有一个 MPU(Memory Protection Unit),ESP32-S2/S3 上也有。但 MPU 和 MMU 不是一回事。MMU 能做地址翻译,让每个进程看到独立的虚拟地址空间;MPU 只能给物理地址区间打属性标签,比如“这段只读”“这段不可执行”“这段用户态不可访问”。

MPU 能帮上忙的地方是:你可以把某段内存标成只读,防止小应用改写关键配置;可以把 Flash 里的代码区标成不可写;可以在任务切换时重配 MPU 区域,做出一种“粗粒度的任务隔离”。但它做不到的是:给每个小应用一套独立的地址空间。所有应用还是共享同一份物理内存布局,只是访问权限被限制了一部分。

而且 MPU 的配置是全局的,任务切换时重配需要时间,区域数量也有限(ESP32 经典款 MPU 区域不多)。你想给每个小应用配一套独立的 MPU 区域,区域数很快就不够用。所以纯靠 MPU 做多应用沙箱,工程上很别扭。

2.3 那为什么还要在 ESP32 上做沙箱

既然硬件不给力,为什么大家还在折腾?因为需求真实存在。设备出厂后想加功能,总不能每次都召回刷固件;客户想自己写点逻辑跑在设备上,你不敢让他直接调gpio_set_level;一个生态里第三方开发者多了,总有人写出会崩的代码。这些场景都需要“让代码进来,但别让它乱来”。

热搜词里的 WebAssembly、WAMR 就是冲着这个来的。WebAssembly 的设计目标之一就是沙箱化执行:字节码只能访问线性内存,不能直接碰宿主地址空间,所有系统调用都要经过导入函数。把它搬到 ESP32 上,等于借用了 Wasm 这套成熟的隔离模型,绕开 FreeRTOS 没有进程隔离的短板。这是目前 MCU 上做应用沙箱最主流、也最被看好的路线。

3. WebAssembly + WAMR:在 MCU 上借一套现成的隔离模型

3.1 Wasm 的沙箱模型为什么适合 MCU

WebAssembly 的隔离靠三件事:线性内存、导入导出、类型化指令。一个 Wasm 模块运行起来,它能看到的内存只有一块连续的线性内存(本质是一个uint8_t数组),它想访问宿主的内存、外设、文件系统,必须通过模块导入的函数。模块自己不能凭空产生一个指针去读写任意地址。

这个模型对 MCU 特别友好。因为线性内存就是一块普通数组,不需要 MMU 参与,MPU 都不用开。Wasm 运行时在解释或 JIT 字节码时,天然会把所有内存访问限制在这块数组范围内。越界访问会被运行时拦下来,而不是写到隔壁任务的栈上。这就用软件实现了一种“地址空间隔离”的效果。

再加上 Wasm 是字节码,不是原生机器码。宿主可以决定暴露哪些导入函数给小应用。你只导入gpio_write和delay_ms,那小应用就只会这两件事,它拿不到esp_wifi_connect的符号,也没法直接操作寄存器。能力边界由导入表决定,这是 Wasm 沙箱最核心的机制。

3.2 WAMR 在 ESP32 上的资源账

WAMR(WebAssembly Micro Runtime)是 Intel 开源的一个轻量 Wasm 运行时,专门为嵌入式设计。它有几个执行模式:解释器、AOT、JIT。ESP32 上一般用解释器或者 AOT,因为 JIT 需要可写可执行内存,ESP32 的 Xtensa 架构和内存布局做 JIT 比较麻烦。

资源占用是大家最关心的。我实测下来,WAMR 解释器模式在 ESP32 上,运行时本身大概占几十 KB 的 Flash 和十几 KB 的 RAM(具体取决于你裁剪掉多少特性)。一个简单的 Wasm 模块实例,线性内存开 64KB 的话,RAM 占用就是 64KB 加上运行时的一些结构体。ESP32 经典款有 520KB SRAM,跑一两个小应用是够的,但你要同时跑很多个就得精打细算。

这里有个容易忽略的点:WAMR 的线性内存是在堆上分配的。ESP32 的堆分内部和外部(PSRAM),如果你开了 PSRAM,可以把线性内存放到 PSRAM 里,省下宝贵的内部 RAM。但 PSRAM 访问速度慢,对性能敏感的应用要权衡。我一般建议把 Wasm 线性内存放 PSRAM,运行时结构体放内部 RAM。

3.3 导入表就是权限清单

这是整个方案里我最想强调的一点。很多人做沙箱,第一反应是“写个权限检查函数”,但 Wasm 的思路更彻底:你不导入,它就没有。小应用能做什么,完全由你传给 WAMR 的导入函数列表决定。

比如你只注册这几个导入:

  • host_gpio_set(pin, level)
  • host_gpio_get(pin)
  • host_delay_ms(ms)
  • host_log(ptr, len)

那小应用就只能操作 GPIO 和延时、打日志。它想联网?导入表里没有网络函数,它连socket这个符号都找不到。它想读 Flash?没有对应的导入,它拿不到任何文件句柄。这种“默认拒绝”的模型,比“默认允许再检查”要安全得多。

而且导入函数内部你还能再做一层校验。比如host_gpio_set里可以检查 pin 号是否在白名单内,防止小应用去动那些接了什么关键外设的引脚。这样权限就分成了两层:导入表决定能力大类,导入函数内部决定细粒度策略。

4. 从零搭一个最小可用的 ESP32 Wasm 沙箱

4.1 环境准备与 WAMR 裁剪

先说环境。我用的是 ESP-IDF v5.x,WAMR 从官方仓库拉下来,作为组件放进components/目录。WAMR 的 CMake 支持裁剪,CMakeLists.txt里有一堆WAMR_BUILD_*开关。ESP32 上必须关掉 JIT(WAMR_BUILD_JIT=0),AOT 可选,解释器必须开。

裁剪的时候有几个开关直接影响体积,我列个表:

配置项建议值原因
WAMR_BUILD_INTERP1解释器是基础,必须开
WAMR_BUILD_FAST_INTERP1比经典解释器快,体积略大,推荐
WAMR_BUILD_AOT0除非你预编译 Wasm,否则关掉省空间
WAMR_BUILD_JIT0ESP32 上基本不可用
WAMR_BUILD_LIBC_WASI0WASI 太重,自己实现导入更可控
WAMR_BUILD_MULTI_MODULE0单模块够用,多模块增加复杂度
WAMR_BUILD_REF_TYPES0用不到引用类型就关

关掉 WASI 是个关键决策。WASI 提供了一套标准的文件、网络、时钟接口,听起来很方便,但它太大了,而且默认暴露的能力太多。做沙箱要的是最小能力集,自己写导入函数反而更清楚每个能力给了什么。这是我踩过坑之后的结论:一开始图省事开了 WASI,结果发现小应用能通过 WASI 拿到一堆我不想要的能力,还得反过来做限制,不如一开始就不开。

4.2 初始化运行时与模块加载

初始化的流程大概是:启动 WAMR 运行时,创建模块,实例化,然后调用模块的入口函数。核心代码结构如下(省略了错误处理,实际要加):

#include "wasm_export.h" static wasm_runtime_t runtime; static wasm_module_t module; static wasm_module_inst_t inst; void wasm_sandbox_init(void) { RuntimeInitArgs init_args; memset(&init_args, 0, sizeof(init_args)); init_args.mem_alloc_type = Alloc_With_Allocator; init_args.mem_allocator.alloc_func = my_malloc; init_args.mem_allocator.realloc_func = my_realloc; init_args.mem_allocator.free_func = my_free; if (!wasm_runtime_full_init(&init_args)) { ESP_LOGE(TAG, "runtime init failed"); return; } } bool wasm_load_app(const uint8_t *wasm_bytes, uint32_t size) { char error_buf[128]; module = wasm_runtime_load(wasm_bytes, size, error_buf, sizeof(error_buf)); if (!module) { ESP_LOGE(TAG, "load failed: %s", error_buf); return false; } // 线性内存给 64KB,堆给 8KB inst = wasm_runtime_instantiate(module, 8192, 65536, error_buf, sizeof(error_buf)); if (!inst) { ESP_LOGE(TAG, "instantiate failed: %s", error_buf); return false; } return true; }

这里wasm_runtime_instantiate的两个参数分别是栈大小和堆大小。栈给小应用自己用,堆是它malloc的来源。这两个值要按应用需求给,给大了浪费 RAM,给小了应用一跑就崩。我的经验是:先给一个保守值,跑起来看日志里的内存告警,再逐步调。WAMR 在内存不足时会打日志,别一上来就给 256KB,ESP32 扛不住几个。

4.3 注册导入函数:把能力一个一个放出去

导入函数的注册是沙箱的核心。WAMR 用NativeSymbol数组来描述导入,每个符号有名字、函数指针、参数个数、参数类型。下面是一个最小示例:

static int32_t host_gpio_set(wasm_exec_env_t exec_env, int32_t pin, int32_t level) { if (pin < 0 || pin > 39) return -1; if (!pin_is_allowed(pin)) return -2; // 白名单校验 gpio_set_level(pin, level); return 0; } static NativeSymbol native_symbols[] = { { "gpio_set", host_gpio_set, "(ii)i", NULL }, { "gpio_get", host_gpio_get, "(i)i", NULL }, { "delay_ms", host_delay_ms, "(i)", NULL }, { "log", host_log, "(*~)", NULL }, }; wasm_runtime_register_natives("env", native_symbols, sizeof(native_symbols) / sizeof(NativeSymbol));

签名里的(ii)i表示两个 int32 参数、返回 int32。(*~)表示一个指针加长度,用于传字符串或字节数组。这些签名必须和 Wasm 模块里导入的声明完全一致,否则实例化时会报签名不匹配。我调试时最常犯的错就是签名写错,比如把(i)i写成(i)I,大小写敏感,一错就加载失败。

pin_is_allowed这个白名单函数是我强烈建议加的。导入表决定了小应用“能调 gpio_set”,但白名单决定了它“能调哪些引脚”。有些引脚接了 Flash、接了关键外设,绝对不能给小应用碰。把这两层分开,权限管理会清晰很多。

4.4 调用与回收:别让应用跑飞

实例化之后,调用模块的导出函数:

wasm_function_inst_t func = wasm_runtime_lookup_function(inst, "app_main"); if (func) { uint32_t argv[1] = { 0 }; if (!wasm_runtime_call_wasm(exec_env, func, 0, argv)) { const char *ex = wasm_runtime_get_exception(inst); ESP_LOGE(TAG, "app exception: %s", ex); } }

这里有个关键点:Wasm 执行是同步的。如果小应用里写了个死循环,wasm_runtime_call_wasm就永远不返回,你的主任务就卡死了。WAMR 提供了中断机制,可以在另一个任务里调用wasm_runtime_set_exception或者用wasm_runtime_terminate来打断执行。我的做法是开一个看门狗任务,监控小应用的执行时间,超时就终止实例。

终止之后要记得wasm_runtime_deinstantiate和wasm_runtime_unload,把内存还回去。不回收的话,跑几次应用 RAM 就满了。这一步在文档里写得很轻描淡写,但实际项目里忘了回收是内存泄漏的重灾区。

5. 权限边界怎么划:导入表之外的几道防线

5.1 内存配额:线性内存不是无限的

Wasm 的线性内存虽然隔离,但它的大小是你给的。给 64KB,小应用最多用 64KB。这本身就是一道防线。但要注意,WAMR 在实例化时分配线性内存,如果分配失败会返回错误,你要处理这个错误,而不是假设一定成功。

另外,小应用内部的malloc走的是 Wasm 堆,堆大小也是你定的。堆溢出会被 WAMR 拦住,不会影响到宿主堆。这一点比原生代码安全得多。我实测过一个故意写越界的小应用,WAMR 直接抛异常终止,宿主任务安然无恙。这就是沙箱的价值。

5.2 CPU 时间:用看门狗掐掉死循环

内存能配额,CPU 时间也能。前面说的看门狗任务就是干这个的。具体做法是:调用wasm_runtime_call_wasm之前记录时间戳,在另一个高优先级任务里定期检查,超过阈值就调wasm_runtime_terminate。

阈值定多少要看应用类型。控制类应用一般几十毫秒就该让出,数据处理类可能几百毫秒。我一般给 500ms 作为默认上限,超过就认为应用有问题。这个值可以通过配置下发,不同应用给不同配额。

注意:wasm_runtime_terminate不是异步安全的万能药,它设置一个标志,WAMR 在执行循环里检查这个标志。如果小应用卡在一个不检查标志的原生导入函数里(比如你自己写的阻塞函数),终止不会立即生效。所以导入函数本身也要设计成可中断的,别在里面写死等。

5.3 外设访问:白名单 + 参数校验

外设是 ESP32 上最需要保护的资源。GPIO、I2C、SPI、UART,随便哪个被小应用乱搞都可能影响主系统。我的做法是:

  • 导入函数只暴露必要的操作,比如i2c_write(addr, reg, data),而不是暴露整个 I2C 驱动句柄
  • 在导入函数内部校验地址、寄存器、数据长度
  • 维护一个外设白名单,小应用只能操作列表里的设备

这样即使小应用被恶意构造,它能造成的破坏也被限制在白名单设备上。比如温度传感器可以读,但接在另一个 I2C 地址上的 EEPROM 就不在列表里,小应用碰不到。

5.4 网络能力:要么不给,要么给代理

网络是最敏感的能力之一。我的建议是:默认不给小应用直接网络访问。如果确实需要,不要暴露socket这种底层接口,而是暴露一个高层的http_get(url)之类的函数,在宿主侧做 URL 白名单和响应大小限制。

这样小应用只能访问你允许的地址,不能拿设备当跳板去扫描内网,也不能下载超大文件把内存撑爆。热搜词里那些关于权限、沙箱的讨论,核心都是这个思路:能力要经过宿主代理,不能直通底层。

6. 实测踩过的坑与排查链路

6.1 模块加载失败:签名不匹配的排查

第一次跑的时候,模块加载一直失败,报unknown import。我一开始以为是函数名写错了,对着代码看了半天。后来发现是签名不匹配:Wasm 模块里导入声明是(ii)i,我注册的是(i)i,少了一个参数。WAMR 的报错信息只说找不到匹配的导入,不会告诉你具体哪里不匹配。

排查方法:用wasm-objdump -x app.wasm把模块的导入段打出来,逐个对照你注册的签名。这个工具在 WABT 工具包里,PC 上装一个,调试时非常有用。别靠猜,直接看字节码。

6.2 实例化就崩:线性内存给太大

有一次实例化直接失败,日志显示内存分配错误。查下来是线性内存给了 256KB,而当时内部 RAM 已经用得差不多了,分配不出来。ESP32 经典款内部 RAM 就 520KB,系统本身、Wi-Fi 协议栈、各种缓冲区占掉一大半,留给 Wasm 的没多少。

解决办法有两个:一是把线性内存降到 64KB 甚至 32KB;二是开 PSRAM,把线性内存分配到 PSRAM。我后来统一改成 PSRAM 分配,内部 RAM 只留运行时结构体,稳定多了。但要注意 PSRAM 的访问延迟,对性能敏感的应用要实测。

6.3 应用跑飞:看门狗没生效的坑

前面说用看门狗掐死循环,结果第一次没生效。原因是我的看门狗任务优先级设低了,小应用死循环时占着 CPU,看门狗任务根本调度不上来。后来把看门狗任务优先级设到比小应用任务高,才正常。

这个坑很典型:在 RTOS 里做超时监控,监控任务的优先级必须高于被监控任务,否则被监控任务霸占 CPU 时监控形同虚设。另外,WAMR 的执行是在调用它的任务上下文里跑的,所以小应用任务和看门狗任务要是不同的任务,优先级关系要理清楚。

6.4 内存泄漏:忘了 deinstantiate

跑了几十次应用之后,设备 RAM 越来越少,最后分配失败。查下来是每次加载新应用时,旧的实例没回收。wasm_runtime_deinstantiate和wasm_runtime_unload必须成对调用,而且要在确认没有其他任务还在用这个实例之后调。

我后来封装了一个wasm_unload_app函数,里面统一做回收,并且在回收前后打印堆剩余大小。这样每次加载卸载都能看到内存变化,泄漏一眼就能发现。这个习惯救了我好几次。

7. 除了 Wasm,还有哪些路可以走

7.1 字节码解释器:自己写一个 DSL

Wasm 不是唯一选择。如果你的应用场景很窄,比如只让用户配置一些逻辑规则,完全可以自己设计一个简单的字节码或者 DSL,写个解释器。好处是体积极小、完全可控,坏处是生态为零,用户得学你的语言。

我见过一些项目用 Lua 做类似的事。Lua 解释器在 ESP32 上也能跑,但 Lua 的沙箱能力比 Wasm 弱,它默认能访问的东西更多,需要额外做限制。而且 Lua 解释器本身占的资源不比 WAMR 少。所以如果目标是沙箱,Wasm 的隔离模型更干净。

7.2 原生代码 + MPU:强度有限但开销小

如果你能接受“同一片地址空间,但用 MPU 限制访问区域”,也可以走原生代码路线。把每个小应用编译成独立的二进制,加载到固定地址,用 MPU 把它的代码段标成只读、数据段限制范围。任务切换时重配 MPU。

这条路的问题是:MPU 区域有限,应用数量受限;原生代码一旦有漏洞,逃逸比 Wasm 容易;而且编译、链接、加载的流程比 Wasm 复杂。除非你有特殊需求,否则我不推荐。Wasm 的软件隔离在 MCU 上性价比更高。

7.3 方案对比与选型建议

方案隔离强度资源开销开发复杂度适用场景
WebAssembly + WAMR中高中中通用插件化、第三方应用
自研字节码解释器中低高场景固定、逻辑简单
Lua 等脚本中低中低快速原型、内部使用
原生 + MPU低中低高性能敏感、应用可信

选型的时候先问自己:小应用是谁写的?如果是内部团队,信任度高,Lua 或原生都能接受;如果是第三方甚至用户,Wasm 的默认拒绝模型更合适。再问资源预算:RAM 紧张就自研字节码,RAM 宽裕就上 WAMR。

8. 把沙箱做成产品级还需要补什么

8.1 应用签名与完整性校验

沙箱解决的是“应用跑起来之后别乱来”,但没解决“应用本身是不是被篡改过”。产品级方案里,Wasm 模块在加载前要做签名校验。设备里存一个公钥,模块带签名,加载时验签,不通过就拒绝。这样即使传输过程被篡改,设备也不会执行恶意代码。

签名校验放在加载之前,和沙箱是互补的两层。沙箱管运行时行为,签名管代码来源。两个都做,安全性才完整。

8.2 资源配额的可配置化

不同应用对资源的需求不一样。一个只读传感器的应用可能 16KB 内存就够,一个做数据聚合的可能要 128KB。把内存、CPU 时间、网络流量这些配额做成配置项,随应用一起下发,运行时按配置执行。这样既不会浪费资源,也不会因为配额太紧导致应用跑不起来。

配置本身也要校验,不能让应用自己声明“我要 1MB 内存”。配额上限由设备端策略决定,应用只能申请不超过上限的值。

8.3 日志与可观测性

小应用出问题时,你得知道它干了什么。导入函数里加日志,记录每次外设访问、每次网络请求。日志分级,正常操作记 debug,异常操作记 warn。这样排查问题时能还原应用的行为轨迹。

但日志也要限流,不能让小应用通过疯狂调用导入函数把日志刷爆,撑满 Flash。我的做法是给日志加个速率限制,每秒最多多少条,超了就丢弃并计数。

8.4 升级与回滚

应用要能升级,升级失败要能回滚。Wasm 模块存在 Flash 的独立分区里,升级时先写新分区,校验通过再切换。旧版本保留一份,新版本跑不起来就回滚。这套机制和固件 OTA 类似,但粒度更细,只换应用不换固件。

回滚的触发条件可以是:应用启动后一段时间内崩溃次数超过阈值,或者看门狗终止次数超标。自动回滚比人工干预可靠,尤其是在设备部署在远端的时候。

9. 一些个人体会

做 ESP32 上的应用沙箱,最大的认知转变是:不要指望硬件给你隔离,要在软件层自己造边界。FreeRTOS 没有进程,MPU 能力有限,这些都是事实。但 WebAssembly 这套模型证明了,软件层的隔离在 MCU 上一样能做得不错,代价是接受它比操作系统沙箱弱一些。

WAMR 是我目前最推荐的方案,成熟度、资源占用、隔离强度都比较平衡。但它不是银弹,导入函数的设计才是真正决定安全性的地方。导入表给多了,沙箱形同虚设;给少了,应用没法干活。这个平衡要靠具体场景去调,没有标准答案。

最后分享一个我常用的调试技巧:写一个“恶意应用”专门用来测试沙箱。里面故意做越界访问、死循环、疯狂申请内存、调用未授权的导入。每次改完沙箱配置,先跑这个恶意应用,看它能不能突破边界。这比等真实攻击出现再补要主动得多。沙箱这种东西,不测就等于没有。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询