ARTICLE · 1058987
《我为 ESP32 设计了一种“小应用安装包”》
我为 ESP32 设计了一种
“小应用安装包”
从一个 WASM 文件,到可识别、可验证、可更新的 .app。
.APP
应用包,是程序进入平台前的可信边界
EDITOR'S NOTE
SERIES 04一个 .wasm 文件只能告诉设备“要执行什么代码”。要让它成为可以安装、识别、升级和校验的小应用,还需要一个真正的应用包。
上一篇文章里,我介绍了系统 API:小应用不直接操作屏幕、触摸芯片和存储,而是通过平台提供的统一入口申请能力。
到这里,一个小应用已经有了两块重要拼图:
用 WebAssembly 承载可以执行的程序;
用系统 API 连接屏幕、触摸和存储。
但如果我现在把一个 snake.wasm 文件直接丢给 ESP32,设备仍然会遇到一连串问题:
它是谁?版本是多少?需要哪些权限?适不适合当前平台?文件在下载时有没有损坏?又是谁发布了它?
这些信息,都不在单独的 WASM 文件里。
所以,我又给这个平台补上了一层:把应用代码、应用说明和可信校验封装成一个 .app 文件。
这篇不讲复杂的密码学推导,先从一个最直观的问题开始:一个能被 ESP32 安装的小应用包,里面到底应该装什么?
01
PART
为什么不能直接安装 `.wasm`?
WHY WASM IS NOT ENOUGH
WASM 文件很像一台机器能执行的“程序本体”。它包含函数、内存、指令和导入导出信息,却不负责描述完整的应用身份。
如果设备只拿到一段 WASM,它最多能判断这是不是一个 WebAssembly 模块,却很难回答下面这些问题:
应用在系统里的唯一 ID 是什么?
桌面上应该显示什么名字?
当前安装的是哪个版本?
它需要屏幕、触摸还是持久化存储?
它要求的平台最低版本是多少?
下载到的代码是否完整?
这个包是不是由我信任的人签发的?
电脑和手机上的安装包也不只有可执行代码。它们通常还会带上名称、版本、权限、图标、资源和签名等信息。
ESP32 的资源更有限,安装包可以做得更小,但这些基本问题同样绕不开。
因此,我没有把“能加载 WASM”直接等同于“能安装应用”。在两者之间,还需要一个应用包格式。
02
PART
我的 `.app` 文件并不是 ZIP
EMAP V1 PACKAGE
INSIDE AN .APP PACKAGE
EMAP
16 B
Manifest
身份 · 版本 · 权限
SIGN
P-256
WASM
main.wasm
固定顺序、明确长度、无压缩:先把每个字节的含义说清楚。
很多人看到“应用包”,第一反应可能是压缩包:把配置文件、WASM 和资源一起压缩,再改一个扩展名。
这当然能做,但当前版本的 ESP32 平台没有采用这种方式。
我设计的第一版格式叫 EMAP v1。它是一个结构非常固定的二进制文件,从前到后只有四部分:
┌──────────────┬──────────────┬──────────────┬──────────────┐│ 16 字节头部 │ Manifest │ ECDSA 签名 │ WASM 程序 ││ EMAP + 长度 │ 应用说明 │ 可信校验 │ main.wasm │└──────────────┴──────────────┴──────────────┴──────────────┘当前 v1 没有压缩,也没有单独的图片和音频资源区。一个包里只承载一个 WASM 主程序。
这看起来不够“豪华”,却很适合第一版:
不需要在 ESP32 上实现复杂的解压和目录处理;
每一部分的起点和长度都能直接计算;
打包工具和设备端可以使用同一套明确规则;
出错时更容易知道问题发生在哪一层。
先让格式简单、确定、可验证,再考虑资源文件和压缩,是我更愿意采用的顺序。
03
PART
前 16 个字节,是整个包的“目录”
THE 16-BYTE HEADER
.app 文件最前面是一个 16 字节头部,结构可以写成:
<4sIII这不是写进 .app 文件的一行文字,而是 Python struct 模块用来描述二进制布局的格式表达式。拆开来看:
后面的整数采用小端序保存
连续 4 个字节,内容固定为 EMAP
一个 4 字节无符号整数;三个 I 依次记录 Manifest、WASM 和签名长度
EMAP | Manifest 长度 | WASM 长度 | 签名长度
4 B | 4 B | 4 B | 4 B
这里采用小端序,依次包含:
| EMAP | |
设备先读取这 16 个字节,就能算出后面每一段应该从哪里开始、在哪里结束。
它还会检查:
Manifest 不能超过 2048 字节;
WASM 主体不能超过 256 KiB;
签名长度必须落在允许范围内;
四部分相加必须刚好等于文件总长度。
最后一条很重要。如果声明的内容已经结束,文件尾部却还有多余字节,设备会直接拒绝它。
在资源有限的 MCU 上,越早排除长度异常,后面越不容易在解析和内存使用上出问题。
04
PART
Manifest 是应用的“身份证”
MANIFEST AS IDENTITY
头部只负责描述结构,真正说明“这是什么应用”的,是 Manifest。
它采用 JSON。以目前平台里的 Snake 为例,核心内容大致如下:
{ "manifest_version": 1, "api_version": 1, "id": "com.example.snake", "name": "Snake", "version": "0.1.0", "platform_min": "0.1.0", "entry": "main.wasm", "permissions": ["display", "touch", "storage"], "hardware": ["display", "touch"], "wasm_size": 12345, "sha256": "……"}其中几组字段承担了不同职责。
1. 身份与版本
id 是应用在系统里的唯一身份。安装新版本时,平台也是通过这个 ID 判断“这是一个新应用”还是“已有应用的更新”。
name 用于展示,version 则记录应用自己的版本号。
我对 ID 也做了限制:它至少由三段小写字母或数字组成,每段都必须以字母开头,例如 com.example.snake。这样可以减少随意命名带来的冲突和解析歧义。
2. 平台兼容性
api_version 表示应用针对哪一版系统 API 构建。
platform_min 表示它要求的平台最低版本。如果某个应用依赖 0.4.0 才提供的新能力,而设备还停留在 0.3.0,安装阶段就会拒绝,而不是等到启动后才崩溃。
3. 权限与硬件需求
permissions 和 hardware 看起来相近,含义并不一样。
permissions 表示应用申请使用哪些系统能力,例如显示、触摸、存储;
hardware 表示应用运行所必需的设备条件,例如必须有屏幕和触摸面板。
前者回答“允许它做什么”,后者回答“设备要具备什么”。
当前平台只接受已经定义的能力,重复项和未知项都会被拒绝。
4. 程序入口与完整性
entry 当前固定为 main.wasm。
wasm_size 和 sha256 则分别记录 WASM 主体的长度与 SHA-256 摘要。设备可以用它们确认:包里实际收到的程序,与打包时描述的程序完全一致。
Manifest 因此不只是“给人看的配置”。它是设备作出安装决定时使用的一份机器可读合同。
05
PART
SHA-256 能发现损坏,却不能确定发布者
INTEGRITY
当打包工具生成 .app 时,会先计算 WASM 主体的 SHA-256,并把结果写进 Manifest。
设备安装时再计算一次。如果两个结果不同,就说明程序内容已经发生变化。
它可以发现:
下载不完整;
传输过程中某些字节被破坏;
文件被替换或修改;
Manifest 声明的大小与实际内容不一致。
但哈希只能回答“内容有没有变”,不能单独回答“是谁发布的”。
如果有人同时替换 WASM,并重新计算一个新的 SHA-256 写回 Manifest,哈希本身依旧能够对上。
所以,完整性校验之后,还需要可信来源校验。
06
PART
P-256 签名,给 Manifest 盖一个数字印章
TRUSTED SIGNATURE
当前格式使用 ECDSA P-256 + SHA-256 做签名。
可以把它理解成一对配套的钥匙:
私钥只保存在打包端,用来签发应用;
公钥内置在设备固件里,用来验证签名。
打包时,工具会对 Manifest 的原始字节计算摘要,再用私钥生成签名。设备收到包以后,用固件中的公钥验证。
如果 Manifest 中任何一个字符发生变化,签名都会失效。
又因为 Manifest 里保存了 WASM 的长度和 SHA-256,所以这枚签名也间接绑定了后面的程序主体。想替换 WASM,就必须修改哈希;一旦修改哈希,原来的签名又无法通过。
这里容易混淆的一点是:签名不等于加密。
.app 里的 Manifest 和 WASM 并没有被隐藏。签名解决的是“内容是否由受信任的私钥签发、签发后是否被改动”,不是“别人能不能看到内容”。
07
PART
设备安装前,会按顺序做一轮验证
VERIFY BEFORE INSTALL
VERIFY BEFORE COMMIT
一个包进入设备后,并不会立刻被写进应用列表。
当前验证流程可以概括为:
识别 EMAP 与长度 ↓验证 Manifest 签名 ↓解析并检查 Manifest ↓检查 API / 平台版本 / 权限与硬件声明 ↓核对 WASM 大小、SHA-256 与魔数 ↓写入存储并回读复验 ↓最后更新应用注册表其中还有一些不太显眼但很重要的检查:
JSON 顶层字段不能重名;
应用版本必须是合法的 X.Y.Z;
权限和硬件列表不能出现重复或未知值;
WASM 必须以标准的 \0asm 头开始;
包的尾部不能夹带未声明内容。
08
PART
安装不是一次简单的“复制文件”
TRANSACTIONAL INSTALL
TWO-SLOT UPDATE
slot 0
当前可用版本
slot 1
写入并复验新包
Registry
最后切换引用
通过 USB 或网络收到完整包以后,平台会先在内存中完成验证,再写入应用存储。
写入之后,设备还会把文件重新读回来,比较字节并再次验证包。只有这些步骤全部成功,才会更新保存在 NVS 里的应用注册表。
同一个应用 ID 更新时,平台会在两个文件槽位之间切换:
com.example.snake.0.appcom.example.snake.1.app新的包先写到另一个槽位,验证通过后,再把注册表指向它。这样,旧版本不会在新文件尚未可靠落盘时就被覆盖。
USB 上传也采用 开始 → 分块 → 提交 的流程。没有收齐全部数据,就不能进入安装步骤;取消或超时只会丢弃本次上传,不会修改已经安装的应用。
这已经具备事务式安装的基本结构。不过针对突然断电的完整故障注入测试,我还在继续补充,所以这一部分暂时不展开宣称“任何情况下都绝对安全”。等验证完整后,我会单独写一篇安装与恢复机制。
09
PART
从源码到 `.app`,经历了什么?
BUILD TO PACKAGE
以 Snake 为例,完整过程可以简化成三步:
C 源码 ↓ 编译snake.wasm ↓ 加入 Manifest、哈希与签名snake.app ↓ USB / HTTPSESP32 验证并安装项目里的打包命令类似这样:
python tools/app_package.py pack \ apps/snake/manifest.json \ dist/snake.wasm \ dist/snake.app \ --key local/dev_private.pem生成以后,还可以先在电脑上用同一套规则检查:
python tools/app_package.py verify \ dist/snake.app \ --key local/dev_public.pem打包工具会检查 WASM 魔数、字段格式和大小限制,自动写入 wasm_size 与 sha256,再对最终 Manifest 签名。
这一步把“编译得到的程序”变成了“平台能够管理的应用”。
10
PART
为什么第一版要刻意保持简单?
KEEP V1 SIMPLE
现在的 .app 还没有图标、图片资源、压缩算法、多模块依赖和增量更新。
这些都可以继续加,但它们会同时增加打包工具、设备解析、存储管理和兼容策略的复杂度。
第一版我更关心四件事:
设备能明确识别包的边界;
应用有稳定的身份、版本和能力声明;
下载损坏和未受信任的包会被拒绝;
安装失败时,不轻易破坏已有应用。
这四件事成立以后,图标和资源才有一个可靠的容器可以依附。
对 MCU 平台来说,简单并不代表随便。恰恰相反,格式越简单,每一个字节的含义越应该明确。
END
NOTE
写在最后
CLOSING
做完这一步以后,我对“ESP32 上的小应用”有了一个更完整的定义:
它不是一段能够被 WAMR 执行的 WASM,也不只是几组系统 API 调用。
它还应该拥有:
可以被系统识别的身份;
可以被判断的版本与兼容范围;
明确声明的权限和硬件需求;
可验证的内容完整性与签发来源;
安装失败时尽量不伤及旧版本的更新路径。
.app 文件,就是把这些信息装进同一个边界清楚、能够验证的容器里。
下一篇,我准备继续拆解权限模型:Manifest 里写了 display、touch 和 storage 以后,系统 API 到底怎样在每一次调用时把权限真正执行起来。
如果让你给 ESP32 的小应用包增加一个新能力,你最希望先加入图标、资源文件,还是自动更新?欢迎留言告诉我。
我是阿白,感谢你的观看。
— END —