在硬件圈聊起“应用商店”,第一反应多半是手机上的 App Store。但真有人把它搬到 ESP32 这种资源抠抠搜搜的 MCU 上时,第一反应基本是:折腾这个干嘛?资源这么小,能装几个“应用”?这不就是脱裤子放屁吗?
我最早看到这个想法时也是这么想的。直到自己在一批量产设备上吃过固件升级的苦,才意识到“应用商店”这四个字放在 ESP32 上,真正要解决的不是“装几个 app”,而是设备固件分发的效率、安全、和可运维性。它解决的是一类很实际的问题:当设备已经卖出去了、铺开了、跑起来了,你还能不能用一种可靠的手段,把新的功能、新的算法、甚至新的交互界面送到它手里,而不需要返厂、不需要用户插线、不需要开发人员抱着烧录器一台台刷。
这篇文章我想从技术底座、架构拆解、最小实现和踩坑实录几个角度,把“在 ESP32 上做应用商店到底有什么意义”这件事聊透。适合正在做物联网量产设备、做固件 OTA 方案、或者单纯想在 ESP32 上实现多应用动态加载的开发者参考。
1. 把“应用商店”搬上 MCU,核心动机到底是什么
1.1 应用商店的本质是什么
先说个朴素的观点:应用商店从来不等于“下载应用的地方”。商店的本质是三件事:发现、分发、生命周期管理。
发现是让设备知道当前有哪些可用的应用版本;分发是把一个可信的二进制包安全地传送到设备;生命周期管理是应用装上之后的启动、更新、回滚、卸载。
手机上的应用商店把这套东西做到了极致,但它的底层依赖是操作系统和应用沙箱。ESP32 上并没有真正意义上的 OS 多进程,也没有动态链接器。要在这么弱的硬件上谈应用商店,必须把定义“降维”:把“应用”理解为一块独立编译、独立分区的固件镜像,把“安装”理解为把二进制镜像写入指定分区,把“卸载”理解为清理对应分区和元数据,把“更新”理解为分区级增量替换。
所以,这个项目做出来的东西不是 App Store 的复制品,而是一套“面向 MCU 的分区级软件分发系统”。
1.2 没有它的时候,设备更新有多痛
我在做一批联网传感器的时候,最开始只用了最简单的整包 OTA。当时觉得没什么问题:固件编译好,上传云平台,设备检测到新版本就拉下来升级。听起来顺理成章,但真跑起来全是问题。
第一痛是功能耦合。整包固件里塞了采集、上报、配网、UI、OTA 全部代码。想改一个小功能就得重新生成整份固件,几 MB 的 app 镜像每次都要完整下载。在信号差的厂房里,一次下载中断就报废,反复重试能把带宽吃干。
第二痛是灰度验证变成全量赌博。整包升级没法做到“先装一个模块试试看”,一旦新固件里某个模块崩了,整台设备就跟着变砖。对量产设备来说,这种风险是不可接受的。
第三痛是现场维护成本。设备部署之后,不可能派个人拿串口线挨个刷机。后来我们被迫在固件里做了一套 A/B 分区回滚逻辑,才勉强把升级失败率压下来。但维护这套逻辑的复杂度,已经快赶上业务代码本身了。
这个时候我才意识到,如果把多个功能拆成独立“应用”,每个应用独立升级、独立回滚、独立启用/停用,整个固件的迭代节奏和安全边界都会清晰很多。
1.3 “商店”到底解决了谁的什么问题
我拆了三种典型角色,他们对应用商店的需求是完全不同的。
对设备使用者来说,商店提供的是“按需选择”。设备出厂只带基础底座,用到了哪个功能才去拉取哪个应用,不用一次性把所有可能性都塞进 Flash。
对开发团队来说,商店提供的是“独立发布权”。我做采集模块的,只要保证我的模块是独立应用,就可以单独修 bug、单独发版,不用跟别人的模块一起打包、一起排队、一起冒险。
对运维来说,商店提供的是“可控分发”。可以按设备型号、地区、批次精确下发应用版本,出现问题时还能指定某个设备只跑旧版本,甚至远程下线某个有问题的应用。
所以说,ESP32 上做应用商店不是猎奇,而是在硬件资源和运维需求之间寻找一种新的折中方案。意义不在“商店”本身的形态,而在它背后的软件治理能力。
2. 技术底座:ESP32 到底凭什么能撑起一个“商店”
2.1 分区模型:先理解空间如何安排
ESP32 的 Flash 上,一切应用级能力都是建立在分区表之上。分区表本身是一段固定格式的二进制数据,默认烧录在 0x8000 偏移处,它决定了 Flash 上每一块区域的起始地址、大小、类型和名字。
一个典型的“应用商店”分区布局会分成这么几块:
nvs:保存 Wi-Fi 配置、应用元数据、当前启用的应用 ID。otadata:记录当前启动的是哪个 OTA 分区。factory(或ota_0):最小系统底座,负责引导、通信、商店逻辑本身。app_slot_1、app_slot_2等:实际装载各类“应用”镜像的分区。storage:存放应用包缓存、下载临时文件或其他用户数据。
使用idf.py menuconfig里的 Partition Table 配置,或者直接写 CSV 分区表,编译时让构建系统生成最终二进制分区表。分区表设计的核心是“给底座留出足够的空间,同时给应用区留出可替换的余地”。
我在设计一个双应用方案时用的是这样的分区表思路:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x2000, factory, app, factory, 0x20000, 0x300000, app1, app, ota_0, 0x320000, 0x200000, app2, app, ota_1, 0x520000, 0x200000, app3, app, ota_2, 0x720000, 0x200000, storage, data, fat, 0x920000, 0x600000,factory 分区放的是商店本体,也就是一个始终存在的“系统服务”。另外三个 app 分区才是可安装、可替换的应用槽位。
2.2 OTA 与多分区能提供的能力
ESP-IDF 的 OTA 机制天然支持“选择启动哪个 app 分区”。esp_ota_ops组件提供了esp_ota_begin、esp_ota_write、esp_ota_end、esp_ota_set_boot_partition这一整套写镜像和切换启动分区的接口。
结合多分区,可以实现一个非常关键的能力:应用级交换。
底座程序运行在 factory 分区,它可以动态决定下一次重启时引导哪个 app 分区。某个 app 分区里的程序如果崩溃,底座还能根据 otadata 的计数做回滚。这相当于在硬件层面给“应用商店”提供了一个开关和看门人。
另一个关键点是esp_app_desc。每个编译生成的 app 镜像,默认在头部带有一段结构体,里面包含版本号、项目名、编译时间等元数据。商店可以通过读取每个分区头部这一段数据,知道当前哪个应用、什么版本已经被装进了哪个槽位。这个信息在做版本管理和升级判断时非常重要。
2.3 应用包不可能叫“apk”,实际是“槽位镜像 + 清单”
手机上的安装包是一个 zip 容器,里面是 dex、资源、签名和 manifest。ESP32 上没这个条件,最实用的包格式是两部分:二进制固件镜像 + JSON 清单文件。
镜像就是编译出来的app.bin,直接对应目标分区的完整内容。清单就是一个极小 JSON,里面放应用 ID、版本号、目标型号、依赖的 IDF 版本、校验和、签名信息、展示用的名称和描述。
{ "app_id": "sensor_demo", "version": "1.2.0", "target": "esp32s3", "size": 2097152, "sha256": "a3f4...", "signature": "base64...", "min_idf_version": "v5.2", "entry": "app_main", "description": "采集传感器数据并上报" }设备端拿到清单先判断版本和兼容性,再根据 size 判断分区是否放得下,之后才下载镜像。下载完成后做哈希校验,通过后再做一次原子性的分区写入。
所以,“应用”本质上是“烧进独立分区的独立固件”。没有动态加载、没有跨应用函数调用、没有共享内存,应用之间只能通过外设、文件系统或网络接口间接协作。这是 MCU 上应用商店最重要的边界,也是很多人一开始想不明白的地方。
3. 设计拆解:商店该有的模块和流程
3.1 应用清单与版本管理
商店服务端需要维护一份“应用列表”,这里不搞数据库也行,一个静态 JSON 就够起步。每个应用条目里维护所有历史版本,设备端通过 HTTP 拉取catalog.json,然后与本地的已安装版本做比较。
具体比较逻辑是:
- 解析本地分区头部的
esp_app_desc或本地 NVS 里的已装版本号。 - 遍历目录中的版本列表,找出目标型号匹配、版本号高于当前值、且依赖条件满足的候选。
- 如果当前版本已经是最新,什么都不做;否则发起下载。
版本号建议使用三段式major.minor.patch,同时保留一个compatible_with字段标识兼容的底座版本。因为 factory 底座本身也会升级,如果应用依赖了新的底座能力,必须在清单里显式声明,否则会出现装了新应用、跑在旧底座上的兼容性灾难。
3.2 下载与校验
设备端下载应用包,我首选esp_http_client配合esp_partition_write做流式写入。不建议先一次性把整个文件读进 RAM,再写 Flash,因为 ESP32 的 RAM 通常只有 320KB 到 520KB,而一个 2MB 的镜像根本塞不进内存。
流式下载的伪代码思路:
esp_http_client_config_t cfg = { .url = url, .event_handler = http_event_handler, .buffer_size = 4096, .keep_alive_enable = false, }; esp_http_client_handle_t client = esp_http_client_init(&cfg); esp_http_client_open(client, 0); char buf[1024]; int len = 0; esp_partition_t *p = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_OTA_0, NULL); esp_partition_erase_range(p, 0, p->size); esp_ota_handle_t handle; esp_ota_begin(p, p->size, &handle); while ((len = esp_http_client_read(client, buf, sizeof(buf))) > 0) { esp_ota_write(handle, buf, len); } esp_ota_end(handle);下载过程中除了 HTTP 状态码,还要时刻关注Content-Length和实际收到字节数是否一致。我见过不少设备因为服务端返回了 chunked 编码,Content-Length 为 0,设备端就误以为下载成功,直到校验哈希才发现包不完整。
校验安全性的关键在哈希。清单里的sha256必须由服务端签名,设备端用内置公钥验签后,才能信任这个哈希值。只做哈希不做签名的话,中间人可以把恶意包的哈希替换成自己的,照样通过校验。
3.3 安装、启动与卸载
“安装”这一步我推荐用“双阶段提交”的思路:
第一阶段,把新镜像下载到空闲的应用槽位,不修改 otadata,不碰当前运行分区。 第二阶段,在确认镜像有效后,调用esp_ota_set_boot_partition把下次启动指向新槽位,然后重启。
这个方案的好处是天然支持回滚。如果新应用启动后连续崩溃,底座通过esp_ota_mark_app_invalid和 otadata 的计数机制,可以在下次重启时回到旧槽位。
卸载更简单:把应用槽位擦除,并清掉 NVS 中的对应记录即可。应用本身没有自我删除的需求,删除动作由底座代劳。
启动选择则是一个静态策略。底座启动后读取 NVS 中的“enabled app”记录,再检查对应分区是否存在有效镜像,存在则跳转,不存在则进入“商店模式”。
3.4 安全与签名
ESP32 上有两个相关联的安全特性:Secure Boot 和 Flash Encryption。Secure Boot 保证固件真实可信,Flash Encryption 保证 Flash 内容无法被轻易读出来。
对于应用商店来说,签名验证要做到“双保险”:
- 底座自身由 Secure Boot 签名保护。
- 每一个应用包在服务端打包时,用私钥对镜像做签名。设备端底座内置公钥,在安装前验证签名。
应用包签名流程大致是:
# 生成签名文件 openssl dgst -sha256 -sign private.pem -out app.bin.sig app.bin # 发布时把 app.bin + app.bin.sig + manifest.json 一起上传设备端验证:
bool verify_signature(const uint8_t *image, size_t len, const uint8_t *sig, size_t siglen) { mbedtls_pk_context pk; mbedtls_pk_init(&pk); mbedtls_pk_parse_public_key(&pk, public_key, sizeof(public_key)); mbedtls_sha256_context sha_ctx; mbedtls_sha256_init(&sha_ctx); uint8_t digest[32]; // 先对镜像算 SHA256 mbedtls_sha256_starts(&sha_ctx, 0); mbedtls_sha256_update(&sha_ctx, image, len); mbedtls_sha256_finish(&sha_ctx, digest); // 再验证 RSA 签名 int ret = mbedtls_pk_verify(&pk, MBEDTLS_MD_SHA256, digest, 0, sig, siglen); return ret == 0; }这些代码不需要自己从零写,ESP-IDF 的mbedtls组件已经集成好。关键是密钥管理:私钥绝不能放在设备端,也不能放进仓库,一旦泄露,整个应用分发体系就废了。
4. 动手做一个最小实现
4.1 分区表与工程配置
这个示例直接用 ESP32-S3 为实验平台。先创建一个基础工程,选择自定义分区表,内容大致如下:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x2000, factory, app, factory, 0x20000, 0x400000, store1, app, ota_0, 0x420000, 0x300000, store2, app, ota_1, 0x720000, 0x300000, store3, app, ota_2, 0xa20000, 0x300000, store4, app, ota_3, 0xd20000, 0x300000, storage, data, fat, 0x1020000, 0x400000,我留了 4 个应用槽位,每个 3MB,运行期间最多可以同时存在 4 个已安装应用。考虑到大部分 MCU 功能模块的二进制体积在 300KB 到 800KB 之间,3MB 的槽位对绝大多数场景足够。如果工程 flash 空间紧,可以缩减到 1MB 每个。
工程里需要开启CONFIG_PARTITION_TABLE_CUSTOM=y,并把分区表文件路径指过去。开发环境建议用 ESP-IDF v5.2 或更新的版本,分区管理和 OTA 接口都更稳定。
4.2 打包器:把任意 esp-idf 工程打包成“商店应用”
这一步实际上是整个项目里最好玩的部分。任何一个 ESP-IDF 工程,只要编译出了build/xxx.bin和build/xxx.elf,就可以作为“商店应用”发布。
编写一个 Python 打包脚本,核心逻辑如下:
import hashlib, json, subprocess, base64 def build_app(project_dir, output_name): # 编译 subprocess.run(["idf.py", "build"], cwd=project_dir, check=True) bin_file = f"{project_dir}/build/{output_name}.bin" # 读取版本号:可以从 sdkconfig 或者自定义 version.txt 中读取 version = open(f"{project_dir}/version.txt").read().strip() # 计算哈希 digest = hashlib.sha256(open(bin_file, "rb").read()).hexdigest() # 生成签名(使用服务端私钥) sig = sign_file(bin_file) manifest = { "app_id": output_name, "version": version, "target": "esp32s3", "size": os.path.getsize(bin_file), "sha256": digest, "signature": base64.b64encode(sig).decode(), "min_idf_version": "v5.2", } # 将 manifest 和 bin 放到发布目录 os.makedirs(f"release/{output_name}/{version}", exist_ok=True) shutil.copy(bin_file, f"release/{output_name}/{version}/app.bin") json.dump(manifest, open(f"release/{output_name}/{version}/manifest.json", "w"))之后还要写一个catalog.json聚合所有应用的版本信息,云端或本地 HTTP 服务直接把这个目录暴露出来即可。
4.3 设备端商店主程序的骨架
设备端我拆成两个主要模块:目录解析模块和安装执行模块。
目录解析模块的工作:
typedef struct { char app_id[32]; char version[16]; uint32_t size; char sha256[65]; char download_url[256]; } app_entry_t; bool parse_catalog(const char *json_text, app_entry_t *out, int max_count);实现时直接用cJSON解析,注意 JSON 长度边界。ESP32 上解析一个超过 20KB 的 JSON 指不定就会触发堆溢出,所以我在拉取 catalog 时设置了 16KB 的硬上限,超过直接释放内存。
安装执行模块的核心就是流式下载 +esp_ota_ops写入。注意安装过程中要对目标分区做“空闲性检查”,防止某个槽位虽然没在运行,但它里面存储的是上次回滚的“保底镜像”。为了避免把保底镜像冲掉,我在 NVS 里记录每个槽位的属性:slot_state为EMPTY、OCCUPIED或FALLBACK。只有EMPTY和OCCUPIED的槽位才是可覆盖的,FALLBACK槽位不动。
4.4 服务端仓库的简单构建
最早不用搞后端,用阿里云 OSS、腾讯云 COS 或者任何 HTTP 静态文件服务都行。目录结构如下:
catalog.json apps/ sensor_demo/ 1.0.0/ app.bin manifest.json 1.1.0/ app.bin manifest.json ui_theme/ 0.9.0/ app.bin manifest.json设备端配置一个STORE_BASE_URL宏,实际拼 URL 时注意加斜杠和版本号,避免路径拼接错误。
如果希望支持“设备自主上报安装了哪个版本”,可以在底座里加一个上报函数,把当前活着的应用槽位描述发给云端。这个信息对运营很重要,但没有它,商店本身也能转起来。
5. 常见问题与实战避坑
5.1 分区空间不够用
Flash 容量是应用商店的第一个硬约束。常见的是 4MB 甚至 2MB Flash 的模块,要做到 4 个应用槽位非常紧张。我的建议是:
- 把底座功能做到极简:通信协议用 BLE 时可以直接干掉 Wi-Fi,Wi-Fi 时可以把 UI 固件放槽位而不是底座。
- 采用“单应用活动槽 + 两备槽”策略:同一时刻只启用一个应用,其他槽位留给升级缓冲,而不是追求同时装很多应用。
- 如果目标设备只需要两个功能模块,干脆分区表只需要两个 app 槽位,应用商店的逻辑一样适用,只是规模变小了。
5.2 下载了一半突然断电
流式写 flash 和直接写文件不一样,它并没有事务保证。断电可能发生在:
- 擦除过程
esp_ota_write写入过程esp_ota_set_boot_partition写入 otadata 过程中
第一和第二阶段出问题,最多是目标槽位坏掉,下次启动如果 otadata 选择的还是当前运行分区,系统没事。但第三阶段如果 otadata 写了一半,设备可能因“启动分区索引无效”而在启动时卡住。
解决手段是给 otadata 留额外保护。ESP-IDF 的 OTA 数据本身带有状态机和安全计数器,正常情况下能识别出“选择了一个无效分区”,但在极端掉电场景下仍可能出现无法预测的状态。我踩过一次后,会在工厂烧录时设置两个硬编码的 otadata 备份区?其实不行,otadata 分区是固定大小 0x2000,内部已经包含了两份 0x1000 的独立数据。所以更实用的做法是:在安装流程里先读旧 otadata 到内存,等新镜像可运行确认后,再置APP_VALID标记。
5.3 新应用启动后疯狂重启
应用商店最怕的不是装不上,而是装上后设备直接“转圈圈”。我在测试ui_theme应用时,新镜像启动后 3 秒内因为内存分配失败重启,重启后又拉起同一个新镜像,进入死循环。
处理方式是在底座里维护一个启动计数:
- 每次跳转到新应用前,在 NVS 里写入
boot_count = 0。 - 新应用正常运行时,应主动调用一个“心跳 API”(通过 IPC 或者定期写入 NVS),把
boot_count递增。 - 底座在每次启动时检查到指定超时时间内仍未收到心跳,则回滚到上一个已知良好分区,并把该问题应用标记为“禁用”。
具体实现新应用里触发心跳,可以在app_main里加一个简单延时后写 NVS:
void app_main(void) { // 业务初始化 // 关键:通知底座当前应用正在正常运行 nvs_set_u32("store_nvs", "boot_ok", 1); nvs_commit("store_nvs"); }底座启动检测逻辑:
nvs_get_u32("store_nvs", "boot_ok", &val); if (val != 1) { // 回滚到旧槽位 esp_ota_set_boot_partition(old_slot); esp_restart(); }注意,这个心跳机制并不是万无一失。如果应用前 30 秒能正常启动,但运行 5 分钟后因为某个 bug 崩溃,那心跳已经写了,底座不会回滚。对于一些故障率极高的场景,可以做成“每 30 秒崩溃一次,连续 N 次就回滚”的计数式看门狗。
5.4 Flash 磨损与并发访问
频繁下载、擦除、写入应用槽位,对 Flash 的磨损不可忽视。ESP32 的 Flash 擦写寿命通常在 1 万到 10 万次之间,对于零售设备,一次应用更新就消耗一次擦写,远不至于用尽。但如果是开发调试阶段,一天刷几十次,就很容易把某个分区擦“秃”。
调试时尽量用esptool.py整片烧录,避免反复对同一应用分区做擦除。量产阶段如果更新频率很高,可以适当扩大分区大小以降低擦写次数?实际影响不大,因为整块分区擦除时无论内容多少,磨损是按块算的。
另一个要注意的是并发访问。当应用正在运行时,底座进程是不存在的,所以不存在真正意义上的“应用自己写应用分区”的并发问题。但如果在下载过程中触发了esp_restart,而未完成的分区擦写操作会留下脏数据,再次启动时esp_ota_end会告诉你ESP_ERR_OTA_ROLLBACK_INVALID_STATE。遇到这个问题,先擦除对应分区,再重新安装。
5.5 HTTP 断点续传要不要做
MCU 上做断点续传成本很高,因为 RAM 不足以缓存整包,必须用 flash 的临时区域记录偏移量。但这又是增值特性,尤其对弱网环境下的设备。
我的折中方案是“分块下载”:把镜像分成 64KB 一块,每块独立下载、独立写 flash、独立记录进度。中断后从上次完成的块继续。这个实现比整包流式复杂,但一旦跑起来,在信号抖动大的场景确实能救命。
// 伪代码:分块下载的核心状态 typedef struct { uint32_t slot_index; uint32_t block_index; uint32_t block_count; uint8_t block_buf[64 * 1024]; } download_state_t;每次写一个块前,把block_index记录到 NVS。恢复时从 NVS 读出block_index,跳过去继续。
5.6 调试手段要趁早设计
商店系统涉及“底座 + 多应用”的多镜像切换,单靠串口日志基本没法查。我建议调试时给每个应用分配一个独立日志标签,并且把日志输出统一重定向到 TCP 服务端。ESP-IDF 从 v5.x 开始支持esp_log_set_output自定义日志输出,我可以写一个插件式组件,把日志通过网络发出。
另外强烈建议在底座里保留一组“调试指令”接口,最简单的可以是一个串口命令解析器:
store list // 列出所有槽位状态 store install <app_id> store remove <app_id> store switch <slot> store boot_next <slot>开发时用命令控制安装/切换,比靠 UI 点击要高效得多。
6. 这件事后续还能怎么生长
做到这里,ESP32 应用商店的雏形已经完整了。但它的外延可以进一步扩大:
- 将应用包改为支持压缩格式,下载体积能再缩一半,Flash 利用率更高。
- 在底座里加入差分升级支持,只下发
diff包而不是整个新镜像。 - 把应用清单从 JSON 扩展为更紧凑的二进制格式,降低设备解析开销。
- 把商店能力从单一设备推广到“局域网内批量分发”,让同一网络里的设备从网关拉取应用包,避免每台都去公网下载。
我个人在实际操作中有个很深的体会:这件事的关键不是把“商店”这个名字做得有多像手机端,而是把“分区级固件管理”这套逻辑做扎实。分区表、OTA、签名、回滚、心跳,每一项单独拿出来都是物联网开发的老话题,但组合在一起之后,整个设备的迭代方式和运维模型就完全不一样了。
如果只是自己玩板子、做几个 demo,确实没必要搞这种复杂度。但只要有量产设备、有远程维护需求、有多个功能模块要并行演进,在 ESP32 上做一套轻量应用商店,投入产出比会高到让你以后再也不想碰“整包升级”这种原始方案。