上电之后,串口终端打印出一个菜单:1号槽 LED_Blink,2号槽 温湿度采集,3号槽 WebServer_Demo。你没看错,这不是 Linux,是那颗 ESP32。输入 1,回车,单片机重启,几秒后 LED 按新的逻辑闪烁;下次想换功能,再按一下复位,菜单又回来了。整个过程不插 USB、不重新烧录,芯片里像是装了好几个应用,想用哪个启动哪个。这就是这个小项目的核心:在 ESP32 上做一个“分区级小型应用平台”,让开发板像手机一样“安装应用”。
这个项目解决的是我自己折腾 ESP32 时最大的痛点:每次换功能都要连电脑、改代码、编译、烧录,调试一个多功能板子时尤其崩溃。做出来之后,我可以在现场用串口菜单或手机网页直接切换应用,甚至能通过浏览器往空槽里传一个新的固件包,这在别人看来就像在“装 App”,实际原理是 ESP-IDF 的 OTA 分区机制加一个常驻 Launcher。今天把完整设计和踩坑记录都写出来,适合有一定 Arduino/ESP32 基础、想进一步理解分区与 OTA 机制的开发爱好者,也适合所有嫌烧录太麻烦的 DIY 玩家。
1. 这个项目的动机与边界:给 ESP32 装上“App Store”
1.1 反复烧录的痛点,做过三五个项目的人都懂
ESP32 是很流行的开发板,自带 WiFi 和蓝牙,Flash 空间通常是 4MB 起步,性能也够跑不少 IoT 应用。但我过去很长一段时间都把它当“高级单片机”用,每次改功能都要重新编译、连接 USB、手动烧录。这在一个项目从零到完成的过程中还算能接受,可一旦你手上同时维护好几个小项目,比如一个做 LED 氛围灯、一个做温湿度传感器、一个做网页服务器,你会发现一个很尴尬的情况:功能都没有坏,但你想在同一个板子上切换它们,只能一遍遍重新烧录。
刚开始我的应对办法是多买几块板子,一个项目用一块。但板子多了以后,找起来麻烦,而且每块板子还得单独配电源、外壳,桌面全是线。后来我做了一个网络摄像头项目,发现 ESP32 本身就有 OTA 升级能力,可以通过网络更新固件,于是我开始想:既然网络都能更新固件了,那么能不能更“暴力”一点——把 Flash 里划分出好几个槽位,每个槽位放一个完整可启动的固件,再做一个引导菜单,开机让你选跑哪一个。
本质上,手机“安装 App”是把应用安装到外部存储器,然后由操作系统调度运行。ESP32 因为资源限制,做不到真正意义上的多进程并发,但我也不需要它那么复杂。我需要的只是“多个固件轮流跑,切换尽量不要插线”,这就完全可以用分区方案解决。
1.2 平台化设计的边界:这不是操作系统,但能解决 80% 的折腾
在动手之前,我先把期望拉回地面。手机上的 App 是可以同时运行、后台切换的,而我要在 ESP32 上做的,其实更接近“多系统引导”,或者用嵌入式行业的话说,是多镜像分区管理。每个“应用”本质上是一个独立的 ESP32 固件镜像,里面可以包含 Arduino 代码、ESP-IDF 应用、MicroPython 固件等等,只要它能被引导并启动,就可以作为“应用”被安装。
这个方案有几个明显的约束:
- 一个时刻只能跑一个应用,切换必须重启,没有后台运行。
- 每个应用的大小受分区大小限制,我这次示例里每个槽位给 512KB,大多数小项目都够用,但跑带完整 Web UI 的大工程会有点紧。
- 应用没有沙箱概念,它可以直接读写全部外设、甚至直接通过 esp_partition 读写其他分区,安全性靠自觉。
这些约束决定了它的定位:适合做开发调试、现场演示、多教室项目的“口袋多合一”,不适合做严肃的产品级动态更新。我心里很清楚,这个方案就像给开发板装了一个“简易抽屉”,它的价值不是技术上的突破,而是把开发中的切换成本降到最低。
1.3 为什么选 ESP32,而不是 STM32 或树莓派 Pico
好多人在评论区会问:能不能用 STM32 实现?能,但要别扭得多。STM32 的 Flash 也可以分区,也可以用 IAP 方式跳转引导多个固件,但 STM32 没有自带 WiFi/BLE,你要想从网页或手机传一个新应用进去,还得外接网络模块或者自己写 USB 协议。ESP32 天生就带 WiFi 和蓝牙,让“应用分发”这件事变得非常顺手。
还有一个很重要的点是 ESP-IDF 已经提供了比较完整的 OTA 基础设施。它内置了 otadata 分区用来记录“下次从哪个槽启动”,也有 esp_ota_set_boot_partition、esp_image_verify 这些 API,我不用自己从零造轮子。这比我早年在 STM32 上自己写 Flash 擦写跳转函数省了太多功夫。STM32 的优势是实时性、丰富的外设和低功耗场景,但在做“小型应用平台”这个玩法上,ESP32 的开发体验确实更合适。
1.4 和真正的 App Store 差在哪里
如果把这个项目介绍给不懂硬件的朋友,最准确的类比其实是:不是 Android 的 App Store,更像电脑上的“双系统引导器”。你在开机时选择一个系统进入,只是这里每个“系统”都很轻量,一共 4 个槽位,加起来也就 2MB 左右。
和真正的应用商店相比,我还没有做在线商店页面、版本管理、签名机制。现在能做到的是:
- 开机进入 Launcher 菜单。
- 在菜单里选定某个应用启动。
- 通过 Web 页面上传新的应用镜像到指定空槽位。
- 校验镜像合法性,非法的镜像不会被启动。
这就已经解决了“减少折腾”的核心问题。后面如果你想做得更完整,可以继续加网络下载、远程更新,甚至做一个简单的云端签名校验,我在文章最后会补充一些扩展思路。
2. 底层机制拆解:分区表、启动链路与镜像校验
2.1 Flash 分区表是什么,为什么它决定整个方案
ESP32 的 Flash 不是一整块乱用的,而是通过一张“分区表”来划分区域。分区表里每行定义一个分区的名字、类型、子类型、起始偏移地址和大小。默认情况下,一套常见的 Arduino 分区表大概是这样的:
| 分区名 | 类型 | 子类型 | 说明 |
|---|---|---|---|
| nvs | data | nvs | 存储密钥、校准数据、NVS 变量 |
| otadata | data | ota | 记录当前/下次 OTA 启动槽 |
| app0 | app | factory | 默认出厂应用,烧录时的默认入口 |
| app1 | app | ota_0 | OTA 应用槽 0 |
| spiffs | data | spiffs | 文件系统存储 |
bootloader 上电后会读取 otadata 分区,根据里面的启动信息决定加载 factory 还是某个 ota 槽。这才是整个方案的“地基”:如果把应用槽命名为 ota_0、ota_1、ota_2、ota_3,那么标准 bootloader 就能直接识别并引导它们,不需要改 bootloader 源码。
这里补充一个概念:otadata 是 ESP-IDF OTA 机制里的“指针记录”,它只有 8KB,不存应用内容,只存“下次该启动谁”的索引。你可以通过esp_ota_mark_app_valid_cancel_rollback等函数控制回滚,也可以直接用esp_ota_set_boot_partition指定下次启动的分区。我做的 Launcher 大量依赖这些 API。
2.2 我给 4MB Flash 做的分配方案
为了照顾最常见的 4MB Flash 开发板,我把分区表设计成如下样子:
# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x5000 otadata, data, ota, 0xE000, 0x2000 phy_init, data, phy, 0xF000, 0x1000 factory, app, factory, 0x10000, 0xC0000 ota_0, app, ota_0, 0xD0000, 0x80000 ota_1, app, ota_1, 0x150000, 0x80000 ota_2, app, ota_2, 0x1D0000, 0x80000 ota_3, app, ota_3, 0x250000, 0x80000 spiffs, data, spiffs, 0x2D0000, 0x130000简单算一下空间:
- factory 分区也就是 Launcher,占用 0x10000 到 0xD0000,大小 0xC0000,等于 768KB,足够放一个带 Web 上传和 BLE 传输的引导程序。
- 四个 ota 槽位,每个 512KB,从 0xD0000 一直排到 0x2D0000,一共 2MB,正好可以装下四五个小型应用。
- 最后从 0x2D0000 到 0x400000 留了约 1.2MB 给 spiffs 文件系统,应用运行时可以把配置、图标或日志写进去。
选择 512KB 一个槽位是我实际测试后的权衡:一个带 WiFi 的 Arduino 应用,编译产物通常在 200KB 到 400KB 之间,512KB 比较安全;如果扩大槽位,Launcher 空间就会变小,或者总槽位数减少。对于 4MB Flash 的开发板,这个比例是我试下来比较均衡的。
如果你手头是 8MB / 16MB Flash,可以把每个槽位扩大到 1MB 甚至 1.5MB,分区表结构完全不用变,只需调整偏移即可。
2.3 启动链路:上电之后到底发生了什么
很多初学者第一次看到分区表会疑惑:bootloader 怎么知道先跑哪个?实际流程比想象中简单:
- 芯片上电后,ROM 里固化的启动代码跳到 0x1000 地址的一级 bootloader。
- 一级 bootloader 读取分区表,并根据 otadata 里的信息决定加载哪个 app 分区。
- 如果 otadata 无效或为空,默认加载 factory 分区。
- factory 分区就是我们的 Launcher,它扫描所有 ota_0 到 ota_3 槽位,把状态和菜单打印出来。
- 用户选择某个应用后,Launcher 调用
esp_ota_set_boot_partition把目标槽记录到 otadata,然后主动esp_restart。 - 再次上电,bootloader 发现 otadata 指向 ota_1,于是直接启动 ota_1 里的应用。
这套链路唯一的“机关”就是 Launcher 在每次选择后都要写 otadata 并重启,因为 ESP32 的 bootloader 一般不支持运行时跨分区跳转。虽然能做到不重启直接用app_main加载,但会破坏应用的堆栈和初始化假设,非常容易遇到玄学问题。所以我的方案是“宁可重启几十毫秒,也不要在内存里硬跳”。
2.4 镜像校验:防止把半截文件写进 Flash
如果你曾经用手机下载安装包,一定碰到过“空间不足”或者“下载失败”的提示,系统不会让你安装一个残缺的 APK。在 ESP32 上,这个“检查完整性”的角色由esp_image_verify承担。它会在启动前检查分区头部、段表、App Descriptor,甚至计算哈希,确认这个镜像格式正确、内容完整。
我在 Web 上传功能里,每次写完整个镜像后都会调用esp_image_verify,只有校验通过才更新菜单里的状态。这样即使传输中途断网,或者写入时突然断电,也不会在重启时走进一个坏分区导致“砖机”。你可能会觉得多此一举,但我就因为测试时断电,把某个槽写成了半截,后来每次启动那个槽都会 crash,排查了很久才发现是镜像校验没做。
3. 代码实操:从零写出一个可用的 Launcher
3.1 开发环境准备与自定义分区表生效
我用的是 Arduino + 官方 esp32 支持包的组合,因为社区资料最多,坑相对少。开发环境可以选 Arduino IDE 也可以选 PlatformIO,关键步骤是让编译系统使用我们自定义的分区表。
在 Arduino IDE 里,只要把上面那段分区表存成一个partitions.csv文件,然后在Tools → Partition Scheme里选择Custom Partition Table,并确认Flash Size选的是4MB (Default)或对应你开发板的实际大小。如果你用 PlatformIO,直接在platformio.ini里加一行:
board_build.partitions = partitions.csv很多第一次做这个项目的人都会忘记切换分区方案,结果烧进去以后发现 Flash 布局还是默认的,Launcher 里扫描不到任何 ota 槽位。这一点我在文章后面还会重点提。
Launcher 本身建议用普通 Arduino 工程编译,不需要魔改任何库。核心依赖是esp_partition.h、esp_ota.h以及一个 Web Server 库。我推荐ESPAsyncWebServer,因为它支持异步接收请求体,配合我们的“边收边写 Flash”策略最方便。
3.2 实现应用列表与菜单选择
Launcher 最重要的任务是扫描分区并让用户选择启动哪个应用。下面是精简版的 Arduino 代码:
#include <esp_partition.h> #include <esp_ota.h> const char* slot_names[4] = { "ota_0", "ota_1", "ota_2", "ota_3" }; void printAppMenu() { Serial.println("\n========== ESP32 App Launcher =========="); for (int i = 0; i < 4; i++) { const esp_partition_t* part = esp_partition_find_first( ESP_PARTITION_TYPE_APP, static_cast<esp_partition_subtype_t>(ESP_PARTITION_SUBTYPE_APP_OTA_MIN + i), NULL); if (!part) { Serial.printf("%d: Invalid slot\n", i); continue; } // 简单判断分区里有没有合法镜像 bool is_valid = esp_image_verify(ESP_IMAGE_VERIFY, NULL, part) == ESP_OK; Serial.printf("%d: %-12s size=0x%X %s\n", i, slot_names[i], part->size, is_valid ? "[ok]" : "[empty]"); } Serial.println("Input slot number to boot, 'U' to upload via Web"); } esp_partition_subtype_t slotToSubtype(int slot) { return static_cast<esp_partition_subtype_t>(ESP_PARTITION_SUBTYPE_APP_OTA_MIN + slot); }这里有两个地方需要解释。
第一,ESP_PARTITION_SUBTYPE_APP_OTA_MIN + i是枚举 ota_0、ota_1、ota_2、ota_3 的标准做法,它要求分区表里的子类型必须连续,并且从ota_0开始。我最初偷懒直接写死字符串"ota_1"然后用esp_partition_find_first查,也可以,但不如这种索引方式干净。
第二,esp_image_verify不仅用来校验上传后的文件,也可以用来判断一个槽位当前是否已经有可启动的镜像。如果分区还没写过,或者写坏了,这个函数会返回错误,菜单里就显示[empty],这样你就不会选定一个坏槽。
选择启动的代码更简单:
void bootSlot(int slot) { esp_partition_subtype_t subtype = slotToSubtype(slot); const esp_partition_t* target = esp_partition_find_first( ESP_PARTITION_TYPE_APP, subtype, NULL); if (!target) { Serial.printf("Slot %d not found\n", slot); return; } esp_err_t err = esp_ota_set_boot_partition(target); if (err != ESP_OK) { Serial.printf("Set boot partition failed: %s\n", esp_err_to_name(err)); return; } Serial.println("Rebooting into selected app..."); delay(200); esp_restart(); }esp_ota_set_boot_partition是 ESP-IDF 提供的标准接口,它会写 otadata,让 bootloader 下一次启动时加载目标分区。
3.3 通过 Web 上传一个“应用安装包”
菜单只是第一步,真正让这个平台变得好玩的是能通过 WiFi 往空槽里装新应用。我在这里选了 ESPAsyncWebServer,因为普通 WebServer 在处理大文件上容易导致内存紧张,而异步库可以在回调里把数据分块写进 Flash。
核心逻辑是注册一个PUT请求,接收方根据 URL 参数slot判断目标槽位,然后把请求体里的二进制内容按 4KB 的粒度擦写分区。
#include <ESPAsyncWebServer.h> #include <esp_partition.h> AsyncWebServer server(80); void setupWebUpload() { server.onRequestBody([](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { if (!request->hasParam("slot")) { request->send(400, "text/plain", "Missing slot"); return; } int slot = request->getParam("slot")->value().toInt(); if (slot < 0 || slot > 3) { request->send(400, "text/plain", "Bad slot"); return; } esp_partition_subtype_t subtype = slotToSubtype(slot); const esp_partition_t* part = esp_partition_find_first( ESP_PARTITION_TYPE_APP, subtype, NULL); if (!part) { request->send(400, "text/plain", "Partition not found"); return; } // 每次第一个分片到达时,先整体擦除目标分区 if (index == 0) { esp_partition_erase_range(part, 0, part->size); Serial.printf("Erasing slot %d, size=0x%X\n", slot, part->size); } // 按 4KB 扇区边界擦除,然后写入 if (index % 0x1000 == 0) { esp_partition_erase_range(part, index, 0x1000); } esp_partition_write(part, index, data, len); Serial.printf("Written %u bytes at offset %u / %u\n", len, index, total); // 最后一个分片到达,做整体校验 if (index + len == total) { bool ok = esp_image_verify(ESP_IMAGE_VERIFY, NULL, part) == ESP_OK; if (ok) { request->send(200, "text/plain", "OK"); } else { request->send(400, "text/plain", "Invalid image"); } } }); server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) { request->send(200, "text/html", "<h2>ESP32 App Upload</h2>" "<form id='f'>" "Slot: <select name='slot'>" "<option value='0'>0</option><option value='1'>1</option>" "<option value='2'>2</option><option value='3'>3</option>" "</select><br>" "<input type='file' name='bin' id='bin'><br>" "<button type='button' onclick='upload()'>Install</button>" "</form>" "<script>" "function upload(){" "let f=document.getElementById('bin').files[0];" "let s=document.querySelector('[name=slot]').value;" "fetch('/upload?slot='+s,{method:'PUT',body:f}).then(r=>r.text().then(t=>alert(t))).catch(e=>alert('ERR '+e));" "}" "</script>"); }); server.begin(); }这套流程的实际体验是:电脑连上 ESP32 发射的热点,打开192.168.4.1,选一个槽,选择一个app.bin,点上传,几秒钟后提示OK。然后回到串口菜单选择那个槽,应用就“装”上了。
需要提醒的是,ESP32 的 Flash 擦写是有磨损寿命的,我为了测试反复擦写了同一个槽几百次,目前还没有出问题,但量产场景必须考虑寿命问题。在做实验时,建议尽量把常用应用放固定槽,减少频繁整片擦写。
3.4 BLE 上传的思路
除了 Web,还可以通过蓝牙 BLE 上传,因为 ESP32 自带经典蓝牙和低功耗蓝牙。我在早期版本里用 BLE 做过一次:把 BLE 建立一个 Write characteristic,手机端用 nRF Connect 这类工具连接后,选择固件文件,通过 Write Long 分块写入。
BLE 的好处是不需要电脑也没有路由器,手机就能操作,适合现场演示。坏处是 MTU 通常很小,速度比 WiFi 慢不少,而且要处理长包分段、粘包等问题,代码量会多不少。如果你的应用场景主要是“电池供电的野外设备”,可以考虑 BLE;如果只是想省事,Web 比 BLE 实用得多。
3.5 让应用能“返回桌面”
手机 App 一般都有返回按键,但是 ESP32 应用自己跑起来之后,如果不做一个通行的返回机制,你只能复位物理按键——而复位后 bootloader 还是会根据 otadata 启动上次选中的应用,回不到 Launcher。
解决办法是给每个应用加一个“返回 Launcher”函数。下面是标准代码,直接放到应用的setup()或按键回调里:
#include <esp_partition.h> #include <esp_ota.h> void backToLauncher() { Serial.println("Returning to Launcher..."); const esp_partition_t* launcher = esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_FACTORY, NULL); if (launcher) { esp_ota_set_boot_partition(launcher); } delay(200); esp_restart(); }我在所有测试应用里,习惯用一个按钮接在某个 GPIO 上,长按 2 秒调用这个函数。这样 Launcher → 应用 → Launcher 的循环就跑通了,整个平台的操作逻辑和手机桌面几乎一致。
4. 实测记录:装两个 App 并用菜单切换
4.1 准备测试应用
为了验证平台,我准备了两个小应用。
第一个是传统的 LED 闪烁,没什么特殊之处,但结构上能说明问题。我加了backToLauncher,把 GPIO2 接一个按键,按下去就返回 Launcher。编译后生成的二进制文件叫LED_Blink.ino.bin,大小约 180KB。
第二个是我在项目热词里经常提到的 WebServer Demo:启动后连上局域网,提供一些 JSON 接口并控制一个 GPIO 点灯。这个应用编译出来接近 280KB,放在 512KB 槽位里很宽裕。
4.2 烧录 Launcher 并上传应用
第一步,把自定义partitions.csv和 Launcher 工程一起编译、烧录。注意烧录后首次启动,四个应用槽都是空的,菜单会显示[empty]。
第二步,启动 Web 上传服务。Launcher 上电后启动 WiFi AP,热点名是ESP32-AppLauncher,电脑连接后访问192.168.4.1,选择 slot 0 和LED_Blink.ino.bin,点击安装。上传过程中串口会输出分片写入日志,几秒后页面提示OK。
第三步,把第二个应用装到 slot 1。这里有一个小细节:同一台电脑如果之前连的是路由器,需要断开再连回 AP,或者让 Launcher 同时连接已有 WiFi 并且你与开发板在同一局域网内。为了简单我直接开了 AP 模式。
4.3 启动切换实测
上传完成后复位 Launcher,串口输出大概是:
========== ESP32 App Launcher ========== 0: ota_0 size=0x80000 [ok] 1: ota_1 size=0x80000 [ok] 2: ota_2 size=0x80000 [empty] 3: ota_3 size=0x80000 [empty] Input slot number to boot, 'U' to upload via Web输入0,开发板重启,LED 开始闪烁。进入应用后,我按下 GPIO2 键,串口打印 “Returning to Launcher…” 并重启,菜单再次出现;输入1再重启,进入 WebServer Demo,手机浏览器访问 ESP32 的 IP 就能看到测试页面。
这次实测让我最满意的一点是:从“决定切换功能”到“进入另一个应用”,整个过程只需要一个串口输入或一次按键,不碰 USB,不碰 IDE,也不重新编译。
4.4 实测过程中的资源占用
Launcher 本身包含菜单、OTA、Web Server、分区扫描,编译后大约 260KB,放在 768KB 的 factory 分区里绰绰有余。运行内存方面,Launcher 启动后空闲堆大约 170KB 到 190KB,处理 4KB 一包的上传不会出现内存吃紧。
实际上,如果你愿意把 Web Server 换成极简铁板烧式的原生 TCP 服务器,Launcher 还能再瘦身。但考虑到开发效率和可维护性,我都尽量使用成熟库,这比无谓地压空间更重要。
5. 避坑指南:这个方案最容易翻车的几个地方
5.1 自定义分区表没有生效
这是新手最容易踩的坑。你在 Arduino IDE 里存了自定义 CSV,也填好了partitions.csv路径,但烧录后菜单里依然扫描不到任何 ota 槽,或者启动地址错误直接无法开机。
解决办法有两步。第一步,检查 Tools 菜单里的Partition Scheme是否选中了Custom Partition Table,如果没有,Arduino 会继续用默认分区表。第二步,区分“Bootloader + Partition Table + App”三种 bin,烧录时不要只烧 App,开机会跑飞。最稳妥的方法是先用 Arduino IDE 的Sketch → Export Compiled Binary导出 bin,再用esptool.py或者支持“合并烧录”的 GUI 工具,把 bootloader、partition table、Launcher 一起写入指定地址。如果你对烧录方式不熟,建议看一遍官方的烧录地址表,避免分区表地址写错。
5.2 Web 上传中断导致镜像损坏
我在最初版本里没有做esp_image_verify,结果测试时中途拔掉了网线,或者网页上传到一半浏览器自动断开,槽位就写了一半。之后启动到那个槽位,bootloader 会提示“invalid app image”,然后继续回退到 Launcher,也就是说不会直接变砖,但它会反复启动失败,看起来像卡死。
解决办法就是文章里写的:上传完成后必须调用esp_image_verify。同时我还养成了一个习惯:上传前在上位机端先记录原始二进制的 MD5,上传后再从 Flash 读出来算一次 MD5,两边对比。虽然esp_image_verify已经能证明“可以启动”,但 MD5 能更直观地确认字节完全一致。在关键应用上,我强烈建议两者都做。
5.3 Flash 擦写顺序与寿命问题
ESP32 的 Flash 擦写不是按字节擦的,而是按扇区(通常 4KB)擦。我在上传代码里是先从 offset 0 开始,按 4KB 粒度一边擦一边写。这里有个细节:esp_partition_erase_range的起始地址和长度必须是 4KB 对齐,否则会返回错误。如果你传的 bin 长度不是 4KB 的整数倍,最后几个字节也是安全的,因为写入不要求按扇区对齐,只要分区边界对齐就行。
寿命问题也要多说两句。Flash 的擦除次数通常是 1 万到 10 万次,具体取决于芯片。我为了测试分区表、上传功能,两个星期里擦了同一个槽可能有三四百次,目前还能跑,但这个方向只能用于开发调试。如果做成产品,我建议把常用功能做成只读,把动态更新的槽位控制在少数几次更新以内,或者改用 8MB / 16MB 的 Flash,并在上层做版本限制。
5.4 常见问题速查表
| 症状 | 可能原因 | 排查与修复 |
|---|---|---|
| 启动后只跑 Launcher,应用菜单全是 empty | 分区表没换成自定义方案 | 确认 Tools → Partition Scheme = Custom Partition Table |
| 上传后提示 Invalid image | 文件不是完整 app bin,或传输被中断 | 重新导出 bin;换一条稳定的 WiFi;增加 MD5 校验 |
| 选择了某个 slot,启动后反复重启 | 该槽镜像损坏或分区地址越界 | 用esp_image_verify检查;确认 bin 大小不超过分区容量 |
| Web 上传大文件时 OOM | 把整个文件读进了内存 | 改成分片写入,不要用普通 WebServer 一次性读取 body |
| 应用跑起来后没有返回菜单的方法 | 应用里没加返回 Launcher 的逻辑 | 在应用里调用esp_ota_set_boot_partition(factory)后重启 |
| WiFi 上传速度太慢 | AP 模式信道干扰或者 MTU 设置不合适 | 切换到 STA 模式并和电脑在同一个局域网;或者用以太网扩展模块 LAN8720,把上传服务架在有线网络上 |
5.5 可以继续扩展的方向
这个平台做完以后,我自己又往里加了几样东西:一个是局域网 OTA,Launcher 主动连接家里的路由器,不是开 AP,这样手机和电脑都不用切换网络,体验更顺;另一个是把 Launcher 的串口菜单做成 Web 页面,既能看状态也能在网页上直接点选启动哪个应用。
如果你研究网络拓扑,还可以把 WiFi Mesh 的思路加进来:多个 ESP32 节点都刷同一套 Launcher,通过 Mesh 网络分发应用镜像,这样就连每台设备单独连接路由器都不用了。对于分布在多个房间的设备,这套玩法特别省事。另外,如果有项目必须用有线网络,LAN8720 以太网模块搭配 ESP32 也很常见,Launcher 里可以加一个有线优先的判定逻辑,局域网内做远程批量更新会稳定很多。
我的几点体会
做完这个项目,再回头看最初的问题“ESP32 能不能像手机一样安装应用”,我的答案是:能,但要让它的“应用”跑在独立的 Flash 分区里,而不是像手机那样动态加载到内存。分区级方案换来了稳定和隔离:某个应用崩溃了,拉闸复位,Launcher 还在;上传写坏了某个 slot,只影响那个槽,不影响其他应用。代价是切换必须重启,而且不能用系统调用去共享外设,但对于绝大多数单机 IoT 项目,这已经够了。
我目前把常玩的几个小项目都烧进了同一块板子,现场演示的时候不用带笔记本,只带一块板子和手机。你需要什么功能,就开机选对应的“应用”。这个思路放在开发调试阶段尤其好用,你甚至可以把它当成一个简易的“固件备份管理工具”,把好的版本留在某个槽里,随时切回去。最后想提醒一句:折腾归折腾,量产产品别这么玩,Flash 寿命和引导安全性都需要更严肃的设计。如果你也做了类似的 ESP32 应用平台,欢迎告诉我你踩过的最离谱的坑。