1. 从一个真实需求说起:为什么要在 ESP32 上谈“沙箱”
很多人第一次听到“ESP32 沙箱”这个词,反应都是:一个几十块钱的 MCU,跑的是裸机或者 FreeRTOS,连 MMU 都没有,谈什么沙箱?这个反应其实是对的——ESP32 上确实没有 Linux 那种进程隔离、地址空间隔离、系统调用过滤的沙箱机制。但问题在于,没有进程沙箱,不等于不能做权限约束。
我接触这个问题的起点很具体:手上有几个基于 ESP32 的小项目,需要让第三方或者非核心团队写一些“小应用”跑在设备上,比如某个传感器采集逻辑、某个屏幕显示逻辑、某个简单的控制策略。这些应用不能碰 Wi-Fi 配置、不能碰 NVS 里的密钥、不能随便调用 GPIO、更不能把整个固件搞崩。但 ESP32 的资源摆在那里:几百 KB 的 RAM,没有 MMU,没有独立的用户态,所有代码都在同一个地址空间里跑。这种情况下,怎么限制一个“小应用”能做什么?
这个问题的本质不是“怎么在 ESP32 上实现 Linux 沙箱”,而是在资源极度受限、没有硬件隔离的 MCU 上,如何通过软件架构和运行时约束,把一段不可信或者半可信代码的能力边界划清楚。它涉及几个层面的东西:代码怎么加载、能调用哪些接口、内存怎么管、崩溃了怎么办、权限怎么声明和检查。下面我按自己实际踩过的路子,把这件事拆开讲。
1.1 先明确“小应用”到底指什么
在 ESP32 语境下,“小应用”通常不是指一个完整的独立固件,而是指一段可以被主固件动态加载或者静态集成、但逻辑上独立的代码单元。它可能是:
- 一段用 C 写的业务逻辑函数,编译成静态库链接进主固件;
- 一段用 C 写的代码,编译成位置无关的二进制,运行时加载到指定内存区域;
- 一段用 WebAssembly 编译出来的字节码,由主固件里的 WASM 运行时解释执行;
- 一段用脚本语言(如 MicroPython、Lua)写的脚本,由主固件里的解释器执行。
这四种形态的隔离能力完全不同。静态链接的 C 代码基本没有隔离,它和主固件共享一切;位置无关二进制稍微好一点,至少加载地址可控;WASM 和脚本语言则天然带一层运行时边界,因为它们不直接操作硬件,所有能力都得通过宿主暴露的接口。所以谈“限制小应用能做什么”,第一步是选形态。形态选错了,后面做再多权限检查都是补丁摞补丁。
1.2 为什么 WebAssembly 会被反复提起
热搜词里出现了 WebAssembly,这不是偶然。WASM 的设计目标之一就是在不可信代码和宿主之间建立一层可控的执行边界。它的内存模型是线性的、独立的,代码不能直接访问宿主内存;它只能调用宿主显式导入的函数。这两点放在 ESP32 上就很有价值:小应用跑在 WASM 运行时里,它想点个灯,不能直接写 GPIO 寄存器,只能调用宿主提供的gpio_set_level导入函数;它想读 NVS,也只能调用宿主提供的接口。宿主在提供这些导入函数的时候,就可以做权限检查。
但 WASM 在 ESP32 上不是没有代价。一个 WASM 运行时本身要占几十到上百 KB 的 Flash 和 RAM,解释执行或者 JIT 的性能也比原生 C 差不少。对于一颗 240MHz 的 Xtensa 核心来说,这个开销必须算清楚。我后面会专门讲选型和资源账。
2. 没有 MMU 的情况下,隔离到底靠什么
2.1 硬件隔离的缺失意味着什么
Linux 沙箱的底气来自硬件:每个进程有独立的虚拟地址空间,MMU 负责把虚拟地址翻译成物理地址,内核通过页表把不同进程的物理内存隔开。一个进程想访问另一个进程的内存,硬件直接触发异常。ESP32 没有 MMU,只有 MPU(Memory Protection Unit)在某些型号上可用,而且 ESP32 经典款和 ESP32-S3 的情况还不一样。没有 MMU 意味着所有代码共享同一个物理地址空间,一段恶意代码理论上可以读写任何它知道地址的内存。
所以 ESP32 上的“沙箱”必须换一个思路:不追求内存的硬隔离,而是追求能力的软约束。也就是说,我不指望阻止小应用去读某块内存,而是让小应用根本没有机会执行任意内存访问——因为它跑在一个受控的运行时里,它写的每一行代码都只能通过运行时提供的接口来产生副作用。
2.2 能力约束的三个层次
我把实际能做的约束分成三层,从弱到强:
第一层是编译期约束。小应用在编译时就被限制只能链接特定的头文件和库,不能直接包含driver/gpio.h、nvs.h这些底层头文件。它只能包含宿主提供的 API 头文件。这一层靠构建系统来保证,比如用独立的 CMake 目标、独立的 include 路径、独立的链接脚本。优点是零运行时开销,缺点是只能约束“正规写法”,防不住故意绕过。
第二层是运行时接口约束。小应用运行在一个运行时里,运行时只暴露有限的导入函数。小应用想产生任何副作用,都必须经过这些函数。宿主在这些函数里做权限检查、参数校验、频率限制。这一层是核心,WASM 和脚本语言天然支持,原生 C 代码则需要自己搭一套函数表。
第三层是资源配额约束。给小应用分配固定的内存池、固定的栈、固定的执行时间片、固定的调用次数。超出配额就终止它。这一层防的是“小应用虽然没干坏事,但把资源吃光了导致主系统崩溃”。
三层叠起来,才能在没有 MMU 的情况下,做到“小应用能做什么”是可控的、可预期的。
2.3 一个常见的误区:把权限检查写在业务代码里
我见过一些实现,权限检查是散落在各个业务函数里的,比如if (app_has_permission(GPIO)) { gpio_set_level(...) }。这种写法的问题是,只要有一个地方漏了检查,整个约束就破了。而且随着接口变多,检查逻辑会越来越难维护。
更稳的做法是把权限检查收敛到宿主导入函数的入口。小应用永远拿不到裸的gpio_set_level,它拿到的是宿主包装过的host_gpio_set_level,这个包装函数第一件事就是查当前小应用的权限表。这样权限检查只有一个地方要做,漏检的概率大大降低。
3. 用 WebAssembly 做运行时边界的实操拆解
3.1 运行时选型:WAMR、wasm3 还是别的
在 ESP32 上跑 WASM,目前社区里比较常见的选择是 WAMR(WebAssembly Micro Runtime)和 wasm3。两者定位不太一样:
| 运行时 | 解释/JIT | 典型内存占用 | 适合场景 |
|---|---|---|---|
| wasm3 | 解释执行 | 约 30-60KB | 资源极紧、逻辑简单 |
| WAMR 解释模式 | 解释执行 | 约 80-150KB | 需要较完整 WASM 特性 |
| WAMR AOT | 预编译 | 更大 | 性能敏感、Flash 充足 |
我自己的选择逻辑是:如果小应用逻辑简单、调用频率低,wasm3 够用,省下来的 RAM 可以留给业务;如果小应用需要较完整的 WASM 特性(比如浮点、较复杂的控制流),或者未来可能换 AOT,WAMR 更稳。ESP32 经典款 RAM 紧张,我一般优先 wasm3;ESP32-S3 带 PSRAM 的话,WAMR 也可以考虑。
注意:WASM 运行时本身也是代码,它自己如果有漏洞,小应用可能通过构造恶意字节码来逃逸。所以运行时要选维护活跃、有安全审计的版本,不要用几年没更新的 fork。
3.2 宿主导入函数的设计
WASM 小应用能做什么,完全取决于宿主导入了哪些函数。设计导入函数时,我遵循几个原则:
- 最小暴露:只导入小应用真正需要的函数,不要图省事把整个驱动层都导进去。
- 语义清晰:导入函数应该是业务语义的,比如
set_led_state,而不是write_gpio_register。前者宿主可以检查“这个应用有没有点灯的权限”,后者基本等于把硬件交出去了。 - 参数校验:所有参数都要校验范围。比如
set_led_state(index, state),index 必须在合法范围内,state 只能是 0 或 1。 - 频率限制:对可能被滥用的接口做调用频率限制,比如每秒最多调用 N 次,防止小应用死循环刷接口拖垮系统。
一个典型的导入函数表大概长这样:
static const wasm_import_t imports[] = { { "env", "log_info", host_log_info, NULL }, { "env", "set_led", host_set_led, NULL }, { "env", "read_temp", host_read_temp, NULL }, { "env", "get_time_ms",host_get_time_ms,NULL }, };每个host_*函数内部第一件事就是取当前小应用的上下文,查权限位图,然后做参数校验和频率检查。
3.3 权限声明与检查的实现
权限怎么声明?我一般用一个位图或者一个结构体数组。位图简单省空间,适合权限项固定的场景;结构体数组灵活,适合权限项可能扩展的场景。
以位图为例,定义一组权限位:
#define PERM_LED (1 << 0) #define PERM_TEMP_READ (1 << 1) #define PERM_LOG (1 << 2) #define PERM_NVS_READ (1 << 3)每个小应用在加载时带一个权限掩码,比如PERM_LED | PERM_LOG。宿主导入函数检查时:
static bool check_perm(uint32_t perm) { app_ctx_t *ctx = get_current_app(); return (ctx->perms & perm) != 0; }host_set_led里就是:
int host_set_led(int index, int state) { if (!check_perm(PERM_LED)) return -EPERM; if (index < 0 || index >= LED_COUNT) return -EINVAL; if (state != 0 && state != 1) return -EINVAL; if (!rate_limit_ok()) return -EBUSY; led_set(index, state); return 0; }这套东西不复杂,但关键是所有导入函数都要走这个模式,不能有例外。
3.4 内存与栈的配额
WASM 实例有自己的线性内存,宿主在创建实例时指定大小。这个大小就是小应用能用的堆上限。栈的话,WASM 运行时一般有自己的栈管理,宿主可以配置栈大小。我一般给小应用的线性内存 16KB 到 64KB,栈 4KB 到 8KB,具体看应用复杂度。
执行时间片这块,WASM 运行时通常支持指令计数或者超时中断。wasm3 可以通过在导入函数里检查时间来实现粗粒度的超时;WAMR 有更细的机制。我的做法是在宿主的主循环里给小应用一个时间预算,比如单次调用最多跑 50ms,超了就终止这个实例并记录日志。
4. 如果不用 WASM,原生 C 小应用怎么约束
4.1 静态链接形态下的约束
有些场景下,WASM 的开销不可接受,小应用必须是原生 C。这时候隔离能力会弱很多,但也不是完全没办法。
静态链接形态下,小应用和主固件在同一个地址空间,理论上它能调用任何它链接到的函数。约束主要靠构建系统:
- 给小应用独立的 CMake 目标,只允许它 include 宿主提供的 API 头文件目录;
- 小应用的源文件里禁止直接 include 底层驱动头文件,这个可以用脚本做静态检查;
- 链接时只把宿主 API 库链接给小应用,底层驱动库不暴露给小应用目标。
这套东西防的是“无意越界”,防不住“故意越界”。如果小应用来源完全不可信,静态链接形态不适合。
4.2 函数表形态的约束
比静态链接好一点的做法是,小应用不直接链接宿主函数,而是通过一个函数表来调用。宿主在初始化时把允许小应用调用的函数指针填进一个结构体,小应用只能通过这个结构体来产生副作用。
typedef struct { int (*set_led)(int index, int state); int (*read_temp)(float *out); void (*log)(const char *msg); } app_api_t;小应用的入口函数接收这个结构体指针:
void app_main(const app_api_t *api) { api->set_led(0, 1); api->log("hello"); }宿主在填充函数表时,可以根据权限决定填哪些、不填哪些。没填的函数指针是 NULL,小应用调用就会崩——但至少它崩的是自己,宿主可以在调用小应用入口时加保护。
这种形态下,小应用仍然可以通过其他方式拿到宿主地址(比如扫描内存),但门槛比直接链接高了不少。对于半可信的小应用,这个方案在资源开销和隔离强度之间是个不错的平衡。
4.3 崩溃隔离与恢复
不管哪种形态,小应用崩溃不能拖垮主系统。ESP32 上可以用几种手段:
- 如果小应用跑在独立任务里,任务崩溃可以用看门狗或者任务级异常处理来捕获,然后重启这个任务;
- 如果小应用是同步调用的,宿主可以在调用前后做栈保护检查、堆完整性检查;
- 对于 WASM 形态,运行时本身会捕获小应用的异常,宿主只需要处理运行时返回的错误码。
我一般会给小应用单独开一个任务,任务里跑一个循环,从队列取调用请求,执行,返回结果。任务崩溃了,宿主检测到之后重新创建任务,并把这个小应用标记为“异常”,必要时降级或者禁用。
5. 常见问题与排查实录
5.1 小应用加载后系统重启
这是最常见的问题。原因通常有几类:
- 线性内存给太小,小应用一跑就越界,运行时没处理好就崩了;
- 栈给太小,递归或者深调用把栈打穿;
- 导入函数里有 bug,比如空指针解引用;
- 小应用字节码本身有问题,运行时校验没拦住。
排查顺序:先看串口日志里崩溃前的最后一条信息,定位是加载阶段还是执行阶段;然后逐步放大内存和栈,看是否还崩;再用最小化的测试小应用(只调用一个 log)验证运行时本身是否正常。
5.2 权限检查没生效
有时候明明配了权限,小应用还是能调用某个接口。常见原因:
- 导入函数表里某个函数忘了加检查;
- 权限掩码在加载时被覆盖或者没正确传递;
- 有多个路径可以到达同一个底层函数,只堵了一条。
我的做法是给每个导入函数写单元测试,用一个没有权限的小应用去调用,确认返回-EPERM。这个测试要覆盖所有导入函数,不能漏。
5.3 性能不达预期
WASM 解释执行的性能大概是原生 C 的十分之一到五分之一,具体看运行时和代码特征。如果小应用里有密集计算,解释执行会明显拖慢。这时候可以考虑:
- 把密集计算移到宿主侧,小应用只做逻辑编排;
- 换 AOT 模式(如果 Flash 和 RAM 允许);
- 降低小应用调用频率,把批量处理放到宿主。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 加载即崩 | 内存/栈不足、字节码非法 | 放大配额、校验字节码 |
| 调用返回 -EPERM | 权限未配置或检查逻辑错 | 查权限掩码、查导入函数 |
| 系统变慢 | 解释执行开销、死循环 | 看时间片、看调用频率 |
| 小应用崩溃影响主系统 | 未做任务隔离 | 独立任务、异常捕获 |
| 内存逐渐减少 | 小应用泄漏、宿主未释放 | 查实例销毁、查堆监控 |
6. 一些实操心得
第一,不要追求“绝对安全”。ESP32 上没有 MMU,任何软件层面的隔离都有被绕过的可能。目标应该是“让越界的成本和难度足够高,让无意越界基本不可能,让故意越界需要相当的专业能力”。对于大多数产品场景,这个强度就够了。
第二,权限设计要从业务出发,不要从硬件出发。不要问“这个应用能不能访问 GPIO”,要问“这个应用需不需要点灯”。前者太底层,后者才是业务语义。业务语义的权限更容易检查,也更容易向非技术方解释。
第三,小应用的入口要简单。我一般只给小应用一个入口函数,参数是 API 结构体指针,返回值是错误码。小应用内部怎么组织是它自己的事,但和宿主的交互面越小越好。
第四,日志要能区分来源。小应用打的日志要带上应用标识,不然出了问题根本分不清是宿主还是小应用干的。这个在调试阶段特别有用。
第五,配额要留余量。给小应用的内存、栈、时间片,不要卡着理论值给,要留 30% 到 50% 的余量。MCU 上的内存碎片、运行时开销、中断抢占都会吃掉额外资源,卡太紧容易出玄学问题。
第六,版本要能回滚。小应用更新后如果出问题,要能快速回滚到上一个版本。这个在 OTA 场景下尤其重要,NVS 里存两份,启动时选可用的那份。
这套东西我在几个项目里跑过,从最简单的函数表形态到 WASM 形态都试过。选哪种,最终还是看小应用的可信程度、资源预算和团队的技术栈。没有银弹,只有权衡。