ESP32 这颗芯片,玩过的人都知道它便宜、能联网、功耗也还行,但绝大多数人拿到手之后干的事情都差不多:连个传感器上传数据、做个 Web 服务器点个灯、或者刷个 MicroPython 跑点脚本。这些用法本质上都是“烧一次固件,干一件事”。固件烧进去是什么功能,它就永远是什么功能,想换个玩法就得重新编译、重新烧录,跟早年功能机时代换手机才能换功能差不多。
但手机不是这样的。手机买回来只是一个空壳,你装微信它就是聊天工具,装相机它就是拍照设备,装个游戏它就是游戏机。同一台硬件,通过安装不同的应用实现完全不同的用途。那 ESP32 能不能也这么玩?能不能让它变成一个“小型应用平台”,固件只负责提供基础能力,具体功能通过安装应用来实现?
这个想法听起来有点疯狂,毕竟 ESP32 的资源跟手机差了十万八千里。但它确实是可以做的,而且做出来之后你会发现,这套思路在很多场景下比传统“一固件一功能”的方式要灵活得多。下面我就把这套小型应用平台的完整设计思路、技术选型、踩过的坑和实测效果,从头到尾讲一遍。
1. 为什么要在 ESP32 上折腾“应用平台”这件事
1.1 传统固件开发模式的真实痛点
先说说为什么我会想到做这个东西。我手头有几个基于 ESP32 的小项目,一个是阳台上的环境监测节点,一个是车库门的远程控制器,还有一个是鱼缸的补光和喂食控制。这三个东西硬件上其实差不多,都是 ESP32 模组加几个传感器和执行器,但每个项目的固件都是独立编译的。
问题出在维护上。有一次我发现环境监测节点里用的那个温湿度读取库有个 bug,在湿度超过 90% 的时候会返回错误值。我修好了这个库,然后需要重新编译环境监测节点的固件、烧录、测试。这没问题。但车库门控制器和鱼缸控制器里也用了同一个库,虽然它们目前没暴露这个问题,但我知道这个 bug 迟早会咬我一口。于是我又得把另外两个项目的代码拉出来,分别编译、分别烧录。
这还只是三个项目。如果你手上有十个、二十个设备分布在不同的地方,每次改一行公共代码就要全部重新烧录一遍,这个工作量是灾难性的。更别说有些设备装在很难够到的地方,比如天花板夹层里、室外防水盒里,拆下来烧录再装回去,半天就没了。
传统模式还有一个问题:功能耦合。环境监测节点里,温湿度采集、OLED 显示、WiFi 上传、OTA 升级这些逻辑全部编译在一个固件里。我想改显示格式,得重新编译整个固件;我想调整上传间隔,也得重新编译。这些功能之间没有清晰的边界,改一处可能影响另一处,测试成本很高。
1.2 “应用平台”思路能带来什么改变
应用平台的核心思路是把固件分成两层:底层是“系统固件”,负责提供硬件抽象、网络连接、文件系统、内存管理等基础能力;上层是“应用”,每个应用是一个独立的可执行单元,通过系统固件提供的接口来访问硬件和网络。
这样做的好处很直接。系统固件只需要烧录一次,之后所有功能变更都通过安装、更新、卸载应用来完成。公共库的 bug 修复只需要更新系统固件里的那一份,所有应用自动受益。每个应用只关心自己的业务逻辑,不需要重复实现 WiFi 连接、OTA 升级这些通用功能。
打个比方,传统模式就像把整个操作系统和应用程序编译成一个 exe 文件,改任何东西都要重新编译整个 exe。应用平台模式就像正常的操作系统,内核和应用程序分开,应用程序可以独立安装和更新。
对于 ESP32 来说,这个思路还有一个额外的好处:内存隔离。每个应用运行在自己的内存空间里,一个应用崩溃了不会把整个系统带崩。系统固件可以捕获应用的异常,重启这个应用而不是重启整个设备。对于部署在远处的设备来说,这个特性太重要了。
1.3 哪些场景最适合这种架构
不是所有 ESP32 项目都适合做成应用平台。如果你的设备只干一件事,而且这辈子都不会改,那传统模式更简单、更省资源。但以下几种场景,应用平台的优势非常明显。
第一种是多功能设备。比如一个中控面板,既要显示温湿度,又要控制灯光,还要能查看摄像头画面。这些功能由不同的应用实现,用户可以按需安装。不需要摄像头功能的用户就不装那个应用,省内存省 Flash。
第二种是需要频繁更新功能的设备。比如一个广告展示屏,内容每周都要换。传统模式每次换内容都要重新编译烧录,应用平台模式下只需要推送一个新的展示应用上去。
第三种是多设备统一管理的场景。你有一百个设备,硬件相同但用途不同。传统模式要维护一百份固件,应用平台模式只需要一份系统固件加一百个应用配置。
第四种是需要第三方扩展的场景。你做了一个硬件产品,希望别人能给你写插件。应用平台提供了标准的应用接口,第三方开发者只需要按照接口规范写应用就行,不需要了解底层硬件细节。
2. 技术选型的纠结:为什么最后选了 WebAssembly
2.1 几种可选方案的对比
确定了要做应用平台之后,下一个问题就是:应用用什么形式来承载?我调研了几种方案,每种都试了一下,最后选了 WebAssembly。先说说其他几种方案为什么没选。
方案一:Lua 脚本。Lua 在嵌入式领域很常见,NodeMCU 固件就是基于 Lua 的。优点是解释执行、不需要编译、脚本体积小。缺点是性能差,而且 Lua 脚本能访问的内存和硬件资源很难做隔离。一个 Lua 脚本死循环了,整个系统就卡死了。另外 Lua 的生态在嵌入式领域虽然有一些积累,但跟 WebAssembly 比起来还是差很多。
方案二:MicroPython。MicroPython 在 ESP32 上跑得很成熟,语法友好,开发效率高。但问题跟 Lua 类似:性能一般,内存隔离困难,而且 MicroPython 固件本身就占了不少 Flash 和 RAM。如果系统固件里再跑一个 MicroPython 解释器,留给应用的空间就更少了。
方案三:ELF 动态加载。把应用编译成 ELF 格式的可执行文件,系统固件在运行时加载并执行。这个方案性能最好,因为应用是原生代码。但实现难度极大,需要自己实现动态链接器、符号解析、内存重定位。而且 ESP32 是 Xtensa 或 RISC-V 架构,ELF 加载器需要处理架构相关的细节。最要命的是安全性:原生代码可以访问任意内存地址,一个恶意或有 bug 的应用可以直接读写系统固件的内存,没有任何隔离。
方案四:WebAssembly。WASM 最初是为浏览器设计的,但现在在嵌入式领域也越来越受关注。它的几个特性正好契合应用平台的需求:沙箱隔离、跨平台、多种语言支持、体积小。缺点是需要在 ESP32 上跑一个 WASM 运行时,这个运行时本身会占用一定的资源。
2.2 WebAssembly 在嵌入式场景下的独特优势
WASM 最吸引我的一点是沙箱隔离。WASM 应用运行在一个受控的执行环境中,它只能访问运行时显式暴露给它的内存和接口。它不能直接读写系统内存,不能直接调用硬件寄存器,所有对外部世界的访问都必须通过导入函数(import function)来完成。
这意味着系统固件可以精确控制每个应用能做什么。比如一个温度显示应用,系统固件只给它暴露“读取温度”和“更新显示”两个接口,它就没有办法去控制继电器或者访问网络。这种细粒度的权限控制,在 Lua 和 MicroPython 方案里是很难做到的。
第二个优势是跨平台。WASM 是平台无关的字节码,同一个 WASM 应用可以在 ESP32、ESP32-S3、甚至其他支持 WASM 的芯片上运行。如果以后我想把应用平台移植到别的硬件上,应用层代码完全不用改。
第三个优势是语言无关。WASM 可以从 C、C++、Rust、Zig、AssemblyScript 等多种语言编译而来。这意味着第三方开发者可以用自己熟悉的语言来写应用,不需要学习新的脚本语言。
第四个优势是体积小。一个简单的 WASM 应用编译出来可能只有几 KB 到几十 KB,比 MicroPython 脚本大不了多少,但性能要好得多。
2.3 实测资源占用:WASM 运行时到底吃多少
选 WASM 之前我最担心的就是资源占用。ESP32 的 RAM 只有 520KB 左右,Flash 通常 4MB,要跑一个 WASM 运行时,还要留空间给应用和系统功能,到底够不够?
我实测了几种 WASM 运行时。WAMR(WebAssembly Micro Runtime)是 Intel 开源的,专门为嵌入式场景设计,有“fast interpreter”和“classic interpreter”两种模式。fast interpreter 模式性能更好但代码体积更大,classic interpreter 模式体积小但性能差一些。在 ESP32 上,WAMR 的 classic interpreter 模式编译出来大约占 200KB Flash,运行时堆内存占用可以控制在 64KB 以内。
Wasm3 是另一个选择,号称是最快的 WASM 解释器。它的代码体积更小,核心运行时大约 100KB Flash,但它的内存管理模型跟 WAMR 不太一样,需要仔细配置才能跟 ESP-IDF 的内存分配器配合好。
我最后选了 WAMR,原因是它的文档更完善,API 更清晰,而且它对“应用生命周期管理”的支持更好。WAMR 提供了 wasm_runtime_instantiate、wasm_runtime_call_wasm_a 等接口,可以方便地创建应用实例、调用应用函数、销毁实例。这些接口正好是应用平台需要的。
实际跑起来之后,系统固件(包含 WAMR 运行时、WiFi 协议栈、文件系统、OTA 模块)编译出来大约 1.2MB Flash,启动后 RAM 占用约 180KB。剩下的 RAM 和 Flash 都可以给应用使用。一个典型的温度显示应用,WASM 文件大约 8KB,运行时内存占用约 16KB。这意味着同时跑三四个应用完全没问题。
3. 系统固件的架构设计:把“操作系统”该做的事做对
3.1 分层架构的划分逻辑
系统固件的架构我改了好几版,最后定下来的分层是这样的:
最底层是ESP-IDF 和硬件驱动,这部分不用多说,就是 ESP32 的标准开发框架和外设驱动。
往上一层是系统服务层,包括 WiFi 管理、文件系统(我用的是 SPIFFS)、OTA 升级、日志系统、电源管理。这些服务对所有应用都是通用的,不需要每个应用自己实现。
再往上是WASM 运行时层,也就是 WAMR。这一层负责加载 WASM 应用、管理应用的生命周期、提供应用与系统服务之间的桥梁。
最上面是应用管理层,负责应用的安装、卸载、启动、停止、权限管理。这一层是应用平台的核心,也是我自己写代码最多的地方。
应用管理层对外提供两种接口:一种是给用户用的,比如通过串口命令或者 Web 界面来安装、启动、停止应用;另一种是给应用用的,也就是 WASM 导入函数,应用通过这些函数来访问系统服务。
3.2 应用生命周期管理:从安装到卸载的完整链路
一个应用从安装到卸载,中间要经过好几个状态。我把这些状态和转换逻辑理清楚之后,整个应用管理层的代码就清晰了很多。
安装:用户把 WASM 文件上传到设备上,应用管理层会先校验文件格式,检查 WASM 魔数、版本号、导入函数列表。如果这个应用需要的导入函数系统没有提供,安装就会失败并给出明确的错误信息。校验通过后,WASM 文件被保存到文件系统的 /apps 目录下,同时生成一个元数据文件,记录应用名称、版本、权限、入口函数等信息。
启动:应用管理层从文件系统读取 WASM 文件,调用 WAMR 的接口创建运行时实例。在实例化过程中,WAMR 会解析 WASM 模块的导入段,把系统固件提供的导入函数绑定进去。实例化成功后,应用管理层调用应用的入口函数(通常是 _start 或 main),应用开始运行。
运行:应用运行期间,系统固件可以通过定时器或者事件来调用应用的导出函数。比如一个显示应用可能导出 refresh 函数,系统固件每隔一秒调用一次,让应用更新显示内容。应用也可以通过导入函数主动向系统请求服务,比如读取传感器数据、发送网络请求。
停止:当用户请求停止应用,或者应用运行超时、发生异常时,应用管理层会调用 WAMR 的销毁接口,释放应用占用的内存和资源。如果应用在运行期间打开了文件或者网络连接,系统固件会在销毁前强制关闭这些资源,防止泄漏。
卸载:停止应用后,删除文件系统中的 WASM 文件和元数据文件。如果应用有持久化数据(比如配置文件),也会一并删除。
这套生命周期管理听起来简单,但实际实现的时候有很多细节要注意。比如应用启动失败怎么办?我的做法是记录失败次数,连续失败三次就自动禁用这个应用,防止它反复启动失败拖垮系统。再比如应用运行超时怎么处理?WAMR 支持执行超时中断,我设置了一个 5 秒的超时,超过就强制终止应用并记录日志。
3.3 导入函数的设计:应用能做什么,不能做什么
导入函数是应用与系统之间的唯一通道,设计得好不好直接决定了整个平台的安全性和易用性。我按照“最小权限”原则来设计,每个导入函数只做一件事,而且只暴露必要的信息。
目前系统固件提供的导入函数分为几类:
日志类:app_log_info、app_log_warn、app_log_error。应用通过这些函数输出日志,日志会带上应用名称和时间戳,方便调试。
传感器类:sensor_read_temperature、sensor_read_humidity、sensor_read_light。每个函数返回一个浮点数,如果传感器不存在或读取失败,返回 NaN。
执行器类:gpio_set_level、gpio_get_level、pwm_set_duty。应用可以通过这些函数控制 GPIO 和 PWM,但只能操作系统固件预先分配给这个应用的引脚。应用不能随意指定引脚号,只能使用系统固件在元数据中声明的引脚。
网络类:http_get、http_post、mqtt_publish。应用可以通过这些函数发起网络请求,但系统固件会限制请求的频率和目标地址。比如一个应用每分钟最多发起 10 次 HTTP 请求,只能访问白名单中的域名。
存储类:kv_get、kv_set、kv_delete。应用可以通过这些函数读写键值对,数据保存在文件系统的 /data 目录下,每个应用有独立的命名空间,不能访问其他应用的数据。
显示类:display_clear、display_draw_text、display_draw_rect。应用可以通过这些函数在 OLED 或 LCD 上绘制内容。系统固件负责管理显示缓冲区,应用绘制的内容会先写入缓冲区,然后由系统固件统一刷新到屏幕。
这套导入函数的设计原则是:应用能做的事情,系统固件都能精确控制;应用不能做的事情,它没有任何途径去尝试。比如应用不能直接访问内存地址,不能直接操作硬件寄存器,不能绕过系统固件发起网络请求。
4. 应用开发体验:从写代码到装进设备
4.1 用 C 写一个 WASM 应用的完整流程
虽然 WASM 支持多种语言,但我最常用的还是 C,因为 ESP32 的生态本身就是 C 的天下,而且 C 编译出来的 WASM 体积最小。下面用一个温度显示应用作为例子,走一遍完整的开发流程。
首先需要安装 WASI SDK,这是一个专门用来编译 WASM 的 C 工具链。安装好之后,写一个简单的 C 文件:
#include <stdio.h> #include <math.h> // 声明导入函数 __attribute__((import_module("env"), import_name("sensor_read_temperature"))) float sensor_read_temperature(void); __attribute__((import_module("env"), import_name("display_clear"))) void display_clear(void); __attribute__((import_module("env"), import_name("display_draw_text"))) void display_draw_text(int x, int y, const char* text); __attribute__((import_module("env"), import_name("app_log_info"))) void app_log_info(const char* msg); // 应用入口 __attribute__((export_name("_start"))) void _start(void) { app_log_info("Temperature app started"); } // 导出刷新函数,系统固件会定期调用 __attribute__((export_name("refresh"))) void refresh(void) { float temp = sensor_read_temperature(); char buf[32]; if (isnan(temp)) { snprintf(buf, sizeof(buf), "Sensor error"); } else { snprintf(buf, sizeof(buf), "Temp: %.1f C", temp); } display_clear(); display_draw_text(0, 0, buf); }编译命令大概是这样的:
/opt/wasi-sdk/bin/clang --target=wasm32 -nostdlib \ -Wl,--no-entry -Wl,--export-all \ -o temp_app.wasm temp_app.c编译出来的 WASM 文件大概 6KB 左右。然后通过设备的 Web 界面或者串口命令把这个文件上传上去,应用管理层会自动完成安装和启动。
4.2 应用与系统之间的数据交换
应用和系统之间的数据交换主要通过两种方式:函数参数/返回值和共享内存。
函数参数/返回值是最简单的方式。比如 sensor_read_temperature 返回一个 float,应用直接拿到温度值。这种方式适合传递简单的标量数据。
对于字符串和数组,WASM 的内存模型稍微复杂一些。WASM 应用有自己的线性内存,系统固件不能直接访问这块内存。当应用调用 display_draw_text 并传入一个字符串指针时,这个指针是 WASM 线性内存中的偏移量。系统固件需要通过 WAMR 提供的接口,把这个偏移量转换成实际的内存地址,才能读取字符串内容。
WAMR 提供了 wasm_runtime_addr_app_to_native 和 wasm_runtime_addr_native_to_app 两个函数来做地址转换。我在系统固件里封装了一个辅助函数,专门用来从 WASM 内存中读取字符串:
char* wasm_read_string(wasm_exec_env_t exec_env, uint32_t app_offset, char* buf, int buf_size) { wasm_module_inst_t module_inst = wasm_runtime_get_module_inst(exec_env); char* native_ptr = wasm_runtime_addr_app_to_native(module_inst, app_offset); if (native_ptr == NULL) { return NULL; } strncpy(buf, native_ptr, buf_size - 1); buf[buf_size - 1] = '\0'; return buf; }这个函数在 display_draw_text 的实现里会用到。应用传入的字符串指针是 WASM 内存偏移量,系统固件通过这个函数把它转换成可读的字符串。
4.3 调试应用时踩过的坑
调试 WASM 应用比调试普通 ESP32 固件要麻烦一些,因为应用运行在沙箱里,不能直接用 GDB 附加。我踩过的坑主要有这几个。
第一个坑是日志输出不完整。应用调用 app_log_info 输出日志,但有时候日志只输出了一半就没了。后来发现是因为 WASM 应用传入的字符串指针指向的内存区域在系统固件读取之前就被应用修改了。解决办法是在导入函数的实现里,先把字符串拷贝到系统固件的缓冲区,再做后续处理。
第二个坑是浮点数精度问题。WASM 的浮点数是 IEEE 754 标准,但 ESP32 的 FPU 在某些情况下会有精度差异。我遇到过温度值在 WASM 里算出来是 25.3,但传到系统固件变成 25.299999 的情况。解决办法是在显示之前做四舍五入,或者在应用里用整数运算代替浮点运算。
第三个坑是内存不足。WASM 应用默认的线性内存大小是 64KB,如果应用里用了大数组或者递归调用,很容易超出。WAMR 支持在实例化时指定更大的内存,但 ESP32 的 RAM 有限,不能随便加大。我的做法是在应用元数据里声明需要的内存大小,应用管理层在启动应用前检查剩余内存是否足够,不够就拒绝启动并给出提示。
5. 实测效果与性能数据
5.1 应用启动速度和运行开销
我实测了几个典型应用的启动速度和运行开销。测试平台是 ESP32-WROOM-32,240MHz 主频,4MB Flash,520KB RAM。
| 应用名称 | WASM 体积 | 启动时间 | 运行内存 | CPU 占用 |
|---|---|---|---|---|
| 温度显示 | 6KB | 45ms | 16KB | 2% |
| 网络时钟 | 12KB | 68ms | 24KB | 5% |
| MQTT 上报 | 18KB | 82ms | 32KB | 8% |
| 简单游戏 | 35KB | 120ms | 48KB | 15% |
启动时间是从应用管理层发出启动命令到应用入口函数执行完毕的时间。运行内存是应用实例占用的 WASM 线性内存加上 WAMR 的管理开销。CPU 占用是在应用空闲时的测量值,实际运行时会有波动。
从数据来看,启动速度完全可以接受,最快的应用 45ms 就起来了,最慢的也就 120ms。运行内存方面,一个应用平均占用 20-30KB,同时跑三四个应用大概需要 100KB 左右的 RAM,对于 520KB 的 ESP32 来说完全够用。
5.2 与传统固件模式的对比
为了量化应用平台的优势,我做了一个对比测试。同一个功能(温湿度采集 + OLED 显示 + WiFi 上传),分别用传统固件模式和应用平台模式实现,对比几个关键指标。
| 对比项 | 传统固件模式 | 应用平台模式 |
|---|---|---|
| 首次开发时间 | 4 小时 | 6 小时(含系统固件) |
| 修改显示格式 | 重新编译烧录,15 分钟 | 更新应用,30 秒 |
| 修改上传间隔 | 重新编译烧录,15 分钟 | 更新应用,30 秒 |
| 修复公共库 bug | 重新编译烧录,15 分钟 | 更新系统固件,2 分钟 |
| 新增一个功能 | 修改固件,30 分钟 | 安装新应用,1 分钟 |
| Flash 占用 | 1.1MB | 1.2MB(系统)+ 8KB(应用) |
| RAM 占用 | 150KB | 180KB(系统)+ 16KB(应用) |
从数据可以看出,应用平台模式在首次开发时多花了 2 小时(主要是系统固件的开发),但后续每次功能变更都能节省大量时间。特别是修改显示格式和上传间隔这种小改动,传统模式要 15 分钟,应用平台模式只要 30 秒。如果设备装在难以够到的地方,这个时间差距会更明显。
5.3 稳定性与异常处理实测
稳定性是我最关心的指标。应用平台模式的一个核心优势是隔离性,一个应用崩溃不会影响其他应用和系统固件。我做了几个破坏性测试来验证这一点。
测试一:让一个应用进入死循环。系统固件在 5 秒后检测到超时,强制终止了这个应用,其他应用和系统功能正常。日志里记录了“App xxx timeout, killed”。
测试二:让一个应用尝试访问非法内存地址。WAMR 的内存访问检查捕获了这个操作,应用被终止,系统固件记录了“App xxx memory access violation”。
测试三:让一个应用疯狂申请内存。WAMR 的内存分配器在达到应用内存上限后拒绝分配,应用收到分配失败的错误,可以选择优雅退出或者被系统强制终止。
测试四:让一个应用在运行期间突然断电。重新上电后,系统固件检测到上次运行状态异常,自动将应用恢复到停止状态,不会自动启动,等待用户手动确认。
这几个测试跑下来,系统固件本身没有崩溃过,其他应用也没有受到影响。这说明 WASM 的沙箱隔离确实起到了作用。
6. 踩坑记录:那些让我熬夜的瞬间
6.1 WAMR 内存配置的坑
WAMR 在 ESP32 上跑,内存配置是最容易出问题的地方。WAMR 需要一块连续的内存作为 WASM 应用的线性内存池,这块内存的大小和分配方式直接影响能跑多少应用。
我一开始用的是 WAMR 的“内存池”模式,预先分配一块 128KB 的内存作为所有应用的共享内存池。这个模式的好处是内存利用率高,多个应用可以共享这块内存。但问题是,如果一个应用申请了大量内存不释放,其他应用就没内存可用了。
后来我改成了“每个应用独立内存”模式,每个应用在实例化时分配自己的线性内存,应用销毁时释放。这个模式的内存隔离性更好,但内存碎片化更严重。ESP32 的 RAM 本来就紧张,跑几个应用之后碎片化就很明显了,再想启动新应用就可能因为找不到连续内存而失败。
最后的解决方案是混合模式:系统启动时预留一块 96KB 的内存作为应用内存池,每个应用从这个池子里分配内存。应用销毁时内存归还到池子里,但池子会定期做碎片整理。这个方案兼顾了隔离性和内存利用率,实测下来同时跑四个应用没有问题。
6.2 导入函数绑定失败的排查过程
导入函数绑定失败是我遇到的最难排查的问题之一。现象是应用安装成功,但启动时失败,日志里只有一句“instantiate failed”,没有任何详细信息。
我一开始以为是 WASM 文件的问题,换了好几个应用都是一样的结果。后来把 WAMR 的日志级别调到 debug,才看到真正的错误信息:“import function env.sensor_read_temperature not found”。
原来是我在系统固件里注册导入函数的时候,模块名写成了“sensors”而不是“env”。WASM 应用里声明的导入模块名是“env”,系统固件注册的模块名是“sensors”,两边对不上,WAMR 就找不到对应的函数。
这个问题的教训是:WAMR 的错误信息默认不够详细,排查导入函数相关的问题时,一定要把日志级别调到 debug。另外,系统固件和应用的导入函数声明必须严格一致,包括模块名、函数名、参数类型、返回类型,任何一项不匹配都会导致绑定失败。
6.3 应用更新时的版本兼容问题
应用平台支持应用更新,但更新过程中有一个版本兼容问题。假设系统固件是 v1.0,提供了 10 个导入函数。一个应用是基于 v1.0 开发的,使用了其中 8 个。后来系统固件升级到 v1.1,删除了一个不常用的导入函数,或者修改了某个导入函数的参数类型。这个应用在 v1.1 的系统固件上就无法启动了。
解决这个问题有两种思路。一种是严格向后兼容,系统固件永远不删除或修改已有的导入函数,只增加新的。另一种是在应用元数据里声明依赖的系统固件版本,应用管理层在启动应用前检查版本是否匹配。
我采用了第二种思路,因为第一种思路会限制系统固件的演进。应用元数据里有一个 min_system_version 字段,应用管理层在启动应用前会比较这个字段和当前系统固件版本。如果不匹配,拒绝启动并提示用户更新应用或系统固件。
7. 这套方案还能怎么扩展
7.1 应用商店的雏形
目前应用的安装是通过 Web 界面手动上传 WASM 文件。如果要做成一个真正的应用平台,下一步自然是做一个应用商店。设备可以定期从服务器拉取应用列表,用户通过 Web 界面浏览和安装应用。
应用商店的核心是应用元数据的标准化。每个应用需要一个清单文件,包含应用名称、版本、作者、描述、权限、依赖、截图等信息。设备端根据清单文件来展示应用信息和检查兼容性。
这个方向我已经在做了,目前有一个简单的应用仓库,存放了几个示例应用的 WASM 文件和清单文件。设备可以通过 HTTP 请求获取应用列表,然后选择安装。
7.2 多应用协同
目前的应用是相互独立的,一个应用不能直接调用另一个应用的函数。但在实际场景中,应用之间可能需要协同。比如一个传感器应用采集数据,一个显示应用展示数据,一个上传应用把数据发到服务器。这三个应用需要共享数据。
我的做法是通过系统固件提供的键值存储来实现应用间通信。传感器应用把数据写入 kv_set("temperature", value),显示应用和上传应用通过 kv_get("temperature") 读取数据。这种方式简单可靠,但实时性一般,适合对延迟不敏感的场景。
如果需要更实时的通信,可以考虑在系统固件里实现一个消息总线,应用可以订阅和发布消息。这个功能我还在设计中,主要考虑的是消息格式和权限控制。
7.3 支持更多语言
目前我主要用 C 来写 WASM 应用,但 WASM 支持的语言远不止 C。Rust 编译到 WASM 的体验很好,体积也控制得不错。AssemblyScript 是 TypeScript 的子集,对于前端开发者来说上手很快。Zig 也是一个不错的选择,编译速度快,生成的 WASM 体积小。
我试过用 Rust 写了一个简单的应用,编译出来的 WASM 比 C 版本大了约 30%,但开发体验好很多,特别是错误处理和内存安全方面。如果应用逻辑比较复杂,用 Rust 可能比 C 更合适。
支持更多语言的关键是提供统一的导入函数绑定。不同语言编译到 WASM 时,导入函数的声明方式可能不同。比如 C 用attribute((import_module)),Rust 用 extern "C" 加 #[link(wasm_import_module = "env")]。系统固件不需要关心应用是用什么语言写的,只需要保证导入函数的模块名、函数名、签名一致就行。
7.4 安全加固的方向
目前的应用平台在安全性方面还有一些可以加固的地方。比如 WASM 应用虽然不能直接访问系统内存,但可以通过导入函数发起网络请求。如果一个恶意应用利用 http_post 把敏感数据发到外部服务器,系统固件目前只能通过域名白名单来限制,但白名单本身也可能被绕过。
下一步我打算在系统固件里增加更细粒度的网络访问控制,比如限制每个应用每天的网络请求次数、限制请求的数据大小、对请求内容做敏感信息过滤。另外,应用的 WASM 文件在安装时可以做签名验证,确保应用来自可信的开发者,没有被篡改。
这些安全加固措施会增加系统固件的复杂度,但对于一个要长期运行、可能部署在不可控环境中的设备来说,这些投入是值得的。
8. 给想尝试这套方案的人一些实在建议
如果你看完上面的内容,也想在 ESP32 上搞一个类似的应用平台,我有几个建议可以帮你少走弯路。
先从简单的开始。不要一上来就搞完整的应用生命周期管理、权限控制、应用商店。先做一个最小可用的版本:系统固件能加载一个 WASM 文件,能调用它的入口函数,能通过导入函数点个灯。把这个跑通了,再逐步增加功能。
WAMR 的版本选择很重要。WAMR 的更新比较频繁,不同版本之间的 API 可能有变化。建议选一个稳定的 release 版本,不要用 main 分支的最新代码。我目前用的是 WAMR 1.3.x 系列,在 ESP32 上跑得比较稳。
内存管理要提前规划。ESP32 的 RAM 是稀缺资源,WASM 运行时的内存开销、应用的内存需求、系统固件自身的内存占用,这些都要提前算清楚。建议在系统启动时就预留好应用内存池,不要等到运行时再动态分配,否则很容易碎片化。
导入函数的设计要克制。不要因为方便就把所有系统功能都暴露给应用。每多一个导入函数,就多一个安全风险点。只暴露应用真正需要的功能,而且要对每个导入函数的参数做严格校验。
日志和错误处理要做好。WASM 应用的调试比普通固件麻烦,没有好的日志很难定位问题。建议在系统固件里实现一个统一的日志系统,应用通过导入函数输出日志,日志带上应用名称、时间戳、日志级别,方便过滤和排查。
测试要充分。应用平台的稳定性依赖于系统固件的健壮性。在正式部署之前,一定要做充分的异常测试:应用死循环、应用内存溢出、应用非法内存访问、应用启动失败、应用更新失败,这些场景都要覆盖到。
这套方案我目前已经在几个设备上跑了几个月,整体稳定性还不错。最让我满意的是更新应用的便捷性,以前改一行代码要拆设备、烧固件、装回去,现在只需要在 Web 界面上传一个新的 WASM 文件,几秒钟就完成了。对于我这种经常改需求的人来说,这个效率提升是实实在在的。