ESP32浏览器装应用:远程OTA固件管理平台的设计与实现
1. 从“插线烧录”到“浏览器装应用”这个项目到底做了啥如果你和我一样用 ESP32 做了几个小项目你大概率经历过这种场景传感器阈值要调一下或者想换个功能试试于是打开 Arduino IDE改几行代码点编译然后弯腰找 USB 线把开发板从外壳里抠出来插上电脑选端口等烧录完成再重新装回外壳最后看了一眼串口监视器嗯跑起来了。整个过程五分钟能搞完算快的如果编译出点幺蛾子半小时就没了。这个流程单看一次没什么但当你手上同时维护三个 ESP32 项目或者设备已经被固定到墙上的接线盒里时每次这样操作都很崩溃。我当时就在想手机装 App 多方便打开应用商店点一下安装重启一下就是新版本。ESP32 虽然只是个单片机但 Flash 里有足够空间也有 OTA 升级能力为什么不能做一套类似“安装应用”的机制于是我就做了个小型应用平台第一次用 USB 烧入一个常驻“管理器固件”之后所有业务固件都叫“应用”通过网络上传安装包在浏览器里点一下安装ESP32 自己完成写入、校验、切换启动分区、重启进入新应用。整个过程不用再碰 USB 线不用再开着 Arduino IDE 反复选端口甚至当设备装在现场时只要在同一个局域网里就能远程换固件。这篇文章就是把这个平台从设计到落地的完整过程写出来包括分区表怎么划、固件包怎么打包、HTTP 上传怎么不爆内存、启动切换和失败回退怎么设计以及实测中踩过的几个坑。如果你也受够了反复插线烧录或者手头正好有多台 ESP32 设备需要频繁换固件这个思路应该能帮上忙。1.1 先给这个平台定个功能清单动手写代码之前我把“像手机安装应用”这件事拆成了几个具体功能设备端常驻管理器固件开机后先进入管理状态。管理器提供 Web 页面能看到当前安装了哪些应用、还剩多少空间。支持上传应用安装包设备端自动校验、写入、重启。支持卸载/切换应用让设备回到管理器或者切换到另一个已安装的应用。升级失败时能自动回退不能让设备变成砖。清单列完我发现这个项目的核心难点其实不在“写固件”而在“怎么保证装完能启动”。毕竟手机的 APK 装坏了顶多闪退MCU 固件装坏了可能连网络都起不来后续想补救都难。所以整个分区表和引导流程的设计比 Web 页面本身重要得多。1.2 为什么现成的 OTA 方案不够用可能有人会说ESP32 不是自带 OTA 吗直接调esp_ota_ops不就行了。确实ESP-IDF 和 Arduino 都内置了 OTA 能力网络升级是没问题的。但默认 OTA 解决的问题是“用新固件覆盖旧固件”本质上是一个版本替换过程而不是“设备上有多个应用我可以选择运行哪一个”。手机装 App 的体验还包括应用列表、安装记录、可回退、可切换。这些状态管理在默认 OTA 例子里是没有的。所以我做的不只是 OTA而是一套基于 OTA 机制之上的“应用管理逻辑”把 Flash 里的分区当成一个个“应用坑位”管理器固件常驻一个分区业务固件安装在另外的分区里。哪个应用要启动就改引导选择器哪个应用要被替换就擦掉对应分区写入新镜像。这样既保留了 OTA 的稳定性又能做出“应用平台”的感觉。2. 整体架构三分法管理器固件、应用镜像、注册表这个平台的角色划分我参考了手机的思路系统层和应用层分开。系统层负责管理、安装、启动应用层只负责干自己的活不需要关心自己是怎么被装上去的。具体拆成三个角色。2.1 管理器固件到底管哪些事管理器固件就是整个平台的地基它烧录在 Flash 的 factory 分区里。它平时不跑任何业务逻辑只做三件事提供 Web 服务让用户上传应用安装包、查看应用列表。把收到的安装包写入相应的 OTA 分区。修改引导信息决定下次启动进入哪个应用。所以管理器固件本质上是个“最小化的系统固件”。它不需要很大但要求稳定。因为一旦它挂了整个设备就彻底失去远程管理能力只能重新拿 USB 线刷。实际开发中管理器固件用了esp_http_server做主 Web 服务配合esp_ota_ops做分区写入。需要注意的是管理器固件里的 Web 服务最好和业务固件里的 Web 服务互不干扰也就是说应用跑起来之后管理器不再占用同一端口资源。这里有两种处理方式一种是把管理器端口固定为 80应用固件尽量用其他端口另一种是应用固件也留一个“返回管理器”的入口让用户随时切回去。我选了后者因为更贴近手机里那种“回到系统设置”的体验。2.2 应用注册表NVS 存状态LittleFS 存清单设备上装了几个应用、每个应用的版本和安装时间是什么、当前应该启动哪个这些信息需要持久化保存。我最初想全部塞进 NVS因为 ESP32 的 NVS 键值存储接口简单、可靠。但很快发现一个问题NVS 适合存小块标量数据单条 value 有大小限制而且不好存结构化的 JSON 描述信息比如应用名、版本号、描述文字、校验值。硬塞也能塞但维护起来非常痛苦。后来我改成了“NVS LittleFS”组合NVS 只存两个关键字段一个是当前目标启动槽位另一个是最后写入状态LittleFS 则放一个registry.json里面记录了已安装应用的完整信息。LittleFS 我划了一个独立分区大小 192KB对存 JSON 清单来说非常充裕。这里有个细节值得说分区表里如果没有专门划分 filesystem 分区LittleFS 是没办法用的。很多人在 Arduino 里点“ESP32 Sketch Data Upload”传 SPIFFS 数据没问题但自己写分区表时却把 SPIFFS/LittleFS 分区漏了导致文件存储相关代码一直报错。后面我会给出完整的分区表。2.3 为什么非要做一个 Web 界面上传做这个平台之前我考虑过用手机 App、电脑客户端、甚至命令行工具来传固件包。后来还是选择纯 Web 方案理由很简单浏览器人人会用不需要装任何环境。ESP32 本身开一个接入点手机连上之后打开浏览器就能访问管理页现场调试时非常方便。Web 页面我用的是纯 HTMLJS所有静态资源直接编译进管理器固件的文件系统里。页面功能不多显示设备基本信息、显示已安装应用列表、一个上传按钮、一个“启动此应用”的按钮。整个页面代码量不大但体验已经很接近一个简化版手机桌面了。2.4 硬件选型4MB Flash 起步想多装应用就上 8MB硬件方面我用的是最常见的 ESP32-WROOM-32 模组板载 4MB Flash。这个容量在做单应用平台时刚好够用管理器固件放工厂分区一个应用放一个 OTA 分区每个应用大约能分到 1MB 出头的空间。如果只是跑跑传感器采集、继电器控制、简易 Web 服务1MB 固件完全够。如果你想把三四个应用同时预置在里面随时切换或者应用本身很复杂例如带 LVGL 界面、带神经网络推理建议直接选 8MB Flash 的 ESP32-S3 开发板或者外挂一颗 SPI Flash。方案上不用变只是分区表里 OTA 分区划得更宽裕。网络模块方面默认用板载 WiFi 最省事想固定网线接入且长时间稳定运行可以外接 LAN8720 以太网模块但这块坑比较多我放到后面单独说。3. 分区表与启动切换确保“装完能启动”的底层细节说句实话整个平台最核心的技术点就是分区表和 OTA 启动流程。Web 上传写代码只是表面功夫真正决定项目能不能稳定运行的是这三个问题应用镜像写到哪里、下次开机从哪里启动、启动失败怎么回来。3.1 4MB Flash 下的分区表划分示例我的分区表是在 ESP-IDFpartitions.csv里定义的内容如下# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x1F0000, ota_0, app, ota_0, 0x200000, 0x100000, ota_1, app, ota_1, 0x300000, 0x100000,简单解释一下nvs分区用来存 WiFi 配网信息等小数据这没什么好说的。otadata是 OTA 引导状态区bootloader 会读它来决定从哪个应用分区启动。这个分区千万不能少。phy_init是射频初始化数据的校准区删了也能跑但每次开机都要重新校准不推荐省掉。factory分区放管理器固件占了 1.94MB足够放 Web 服务和小型文件系统。ota_0和ota_1是两个应用槽位各 1MB。这个布局的逻辑很清晰管理器固件永远在底层它不会被应用覆盖应用装在 OTA 分区里。安装新应用时我默认写入当前的空闲槽位如果两个槽位都被占用了就提示用户先卸载一个。有一点要特别提醒修改分区表后需要给开发板做一次全片擦除Full Erase否则旧的分区数据还在新代码读写分区时会出现各种诡异问题。我确实在这个上面吃过亏后面第 5 章会详细讲。3.2 esp_ota 系列 API 和启动选择器的工作方式ESP-IDF 的 OTA 流程核心就五个步骤const esp_partition_t *partition esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_MIN slot, NULL); esp_ota_handle_t ota_handle; esp_ota_begin(partition, OTA_SIZE_UNKNOWN, ota_handle); // 循环接收数据写入分区 esp_ota_write(ota_handle, buf, received_len); esp_ota_end(ota_handle); esp_ota_set_boot_partition(partition); esp_restart();esp_ota_begin相当于打开一个待写入的分区。第二个参数是镜像大小正常情况下可以传实际大小这样 SDK 会提前校验分区剩余空间我当时图省事传了OTA_SIZE_UNKNOWN也就是不限制代价是如果镜像超过了分区大小写入过程会到后半段才报错。现在我的做法是先让 Web 上传接口把固件包大小解析出来在esp_ota_begin里直接传入这个值提前就能发现空间不足。esp_ota_end会做镜像头校验确保这是个合法的应用镜像。esp_ota_set_boot_partition则把 otadata 里的启动指针指向目标分区。最后调用esp_restart()重启设备就会进入新应用。这里有一个容易理解错的点OTA 分区里写的不是整个 Flash 镜像而是 app 区段镜像。也就是说不能用网上下载的merged_bin.bin直接传上去那个文件包含 bootloader 和分区表传进 OTA 分区后反而会破坏启动流程。关于正确的打包方式第 4 章会专门讲。3.3 启动流程管理器怎么决定启动哪个应用开机之后bootloader 会先读 otadata判断当前指定的启动分区是 factory、ota_0 还是 ota_1。如果想从管理器进入应用manager 固件里会先写 NVS 里的boot_target字段比如“ota_0”然后调用esp_ota_set_boot_partition(ota_0_partition)再重启。下次上电时 bootloader 看到 otadata 指向 ota_0就直接启动应用固件。反过来应用固件如果想回到管理器就调用同一个 API把启动目标设置回 factory 分区。我专门给应用固件做了一个“返回管理器”的小函数让业务开发者在自己的 Arduino 工程里可以直接调用。这样用户在设备上按下某个配置按钮或者通过应用自身的菜单触发就能回到管理器界面再选择安装其他应用。体验上很像手机里“回到设置去重置系统”的逻辑。整个链路看起来不复杂但有个细节必须记住esp_ota_set_boot_partition(factory)之后otadata 会被置成无效状态bootloader 走默认的 factory 启动。这是正常行为不要怀疑自己的代码写错了。3.4 升级失败时的安全回退思路对于一台“能远程安装应用”的设备来说最怕的就是新应用镜像有 bug启动后死机、重启、再死机变成循环。如果 bootloader 没有回退机制设备就相当于变砖了又得物理接触重新刷。ESP-IDF 内置了一个滚动回退机制最推荐的做法是应用固件启动后主动调用esp_ota_mark_app_valid_cancel_rollback()告诉 bootloader“我这次启动成功了下次继续从我这里启动”。如果应用没有主动确认bootloader 会在下一次重启时认为上一次启动失败自动回退到另一个分区。我的平台里还有个自己的保护层管理器在切换分区之前会在 NVS 写一个pending_boot标记。如果设备重启后应用在 30 秒内没有清除这个标记管理器的 watchdog 逻辑就会介入强制把 otadata 指向 factory回到管理器。这样做虽然比 IDF 原生回退粗暴一点但能统一管理所有应用槽位而且对用 Arduino 开发的业务工程来说不需要额外调用任何确认函数就可以获得保护。这两个机制我建议同时保留原生的esp_ota_mark_app_valid_cancel_rollback()防的是“启动即崩溃”NVS 标记防的是“应用长时间无响应”。实际测试下来双重保护能覆盖绝大多数升级失败场景。4. 应用打包与上传链路从 Arduino 工程到流式写入平台搭好了下一步就是解决“应用从哪里来”的问题。这其实也是刚开始搞的时候最烦的地方IDE 编译出来的产物不是一个可以直接拿来安装的东西得先理解 ESP32 的镜像格式。4.1 把 Arduino/IDF 工程变成可安装镜像如果你用的是 PlatformIO在项目根目录执行pio run之后.pio/build/esp32dev/firmware.bin就是 app 区段镜像。如果你用 ESP-IDFidf.py build生成的文件里build/xxx.bin也同样是 app 镜像。但如果你在 Arduino IDE 里直接点“导出二进制”得到的merged.bin往往包含了 bootloader 和分区表不能直接用于 OTA 上传。所以我的流程是业务固件统一用 PlatformIO 编译然后用一个小脚本读取编译产物再加上一个描述文件打包成.espapp文件。这个格式是自己定的结构很简单前四个字节是魔数用来识别文件类型接着是 JSON 头部然后是二进制镜像内容。{ name: weather_station, version: 1.2.0, target_slot: 0, md5: aa11bb22cc33dd44ee55ff6677889900, image_size: 324512 }管理器收到上传文件后先读 JSON 头解析应用名、版本号、目标槽位、MD5 和镜像长度然后才开始真正写入 OTA 分区。这样做的好处是可以在写入前就判断目标槽位是否被占用固件大小是否超限校验信息是否完整。如果等全部写完了再发现问题那回滚流程会很麻烦。4.2 浏览器上传时ESP32 端怎么避免内存爆掉这是整个平台里我认为最重要的一段代码也是最容易写错的地方。ESP32 的 RAM 通常只有几百 KB而一个固件镜像往往有三四百 KB 甚至 1MB。如果 HTTP 上传 handler 里用httpd_req_get_url_query或者直接把整个 body 读进内存百分之百会重启。正确做法是流式处理开一个小缓冲区反复httpd_req_recv每拿到一块就立即调用esp_ota_write写入 Flash内存中始终只保留一个很小的滑动窗口。char *buf heap_caps_malloc(4096, MALLOC_CAP_8BIT); int remaining req-content_len; while (remaining 0) { int ret httpd_req_recv(req, buf, MIN(remaining, 4096)); if (ret 0) { ESP_LOGE(TAG, recv failed: %d, ret); break; } esp_ota_write(ota_handle, buf, ret); remaining - ret; } free(buf);esp_ota_write是同步写 Flash 接口它不会在内存里缓存整份镜像数据到了就直接交给底层 Flash 驱动这一点特别适合内存紧张的 MCU。缓冲区大小 4096 字节是我实测比较稳的数值再大例如 16KB 虽然能提高一点传输速度但高并发或者堆内存碎片严重时反而容易分配失败。如果设备上还挂着其他任务建议把 HTTP server 的任务栈加大到 8KB 以上否则httpd_req_recv在等待数据时可能会因为栈溢出触发 WDT 重启。4.3 安装过程的实际体验整个 Web 上传流程完成后浏览器的表现是文件上传进度条走完然后页面提示“安装成功正在重启”。设备端则开始在串口日志里打印[APP] image size: 324512 bytes [APP] md5: aa11bb22cc33dd44ee55ff6677889900 [OTA] begin to slot 0 [OTA] write 324512 bytes... 100% [OTA] end ok [OTA] set boot partition to ota_0 [SYSTEM] restart now重启后 bootloader 读 otadata进入 ota_0 分区运行应用固件应用里再调用esp_ota_mark_app_valid_cancel_rollback()确认这次的镜像有效。如果一切顺利三五秒后设备就开始执行新业务了。这个过程和手机装 App 的体验已经很像了。区别是手机应用商店的安装包来自互联网而我这个平台的安装包来自同一个局域网里的开发电脑。但核心逻辑——下载、校验、安装、切换到新版本——是完整的。5. 实测踩坑记录最影响体验的四个问题任何一个工具只要真正投入使用就不可能一帆风顺。我把自己实际测下来最困扰的几个问题写在这里希望对想做类似平台的人有参考价值。5.1 改了分区表却忘了全片擦除启动就各种奇怪第一次用自定义分区表时我犯了一个很低级的错误直接在旧固件上烧录了新固件没有做全片擦除。结果是 ESP32 一直打印Invalid partition table甚至提示magic number mismatch折腾了很久。原因很简单开发板原来的分区表结构和我新的不一样旧的 NVS、旧的应用数据残留在 Flash 某些区域bootloader 按新分区表去读发现数据对不上就直接判定分区表无效。解决办法是第一次烧录管理器固件前用esptool.py erase_flash把整块 Flash 清一遍然后再写入新的分区表和固件。后面再升级管理器固件本体时也可以只写 factory 分区不用全片擦除。但凡是分区表结构有变化就必须来一次彻底擦除。5.2 大固件上传时设备反复重启平台做到第二版时我开始尝试把一个带 LVGL 界面的应用装进去固件大小接近 1MB。结果上传到 60% 左右ESP32 直接重启了日志停在某个内存相关的报错上。追查之后发现是两个问题叠加。第一个是httpd_req_recv返回HTTPD_SOCK_ERR_TIMEOUT时我的循环没有正确跳出导致代码继续执行esp_ota_write把不完整的数据写进了 Flash。第二个是我同时开着较大的 LittleFS 缓存做registry.json更新堆内存被挤到很紧张的水平一旦网络稍微慢一点重试分配缓冲区就会失败。解决办法有两个层面。代码层面每次recv后严格检查返回值超时就终止写入并且调用esp_ota_abort放弃这次升级保持原分区不动。工程层面关掉或减小不必要的缓存给 HTTP server 任务单独分配一块 PSRAM 作为接收缓冲如果开发板没有 PSRAM就把CONFIG_HTTPD_MAX_REQ_HDR_LEN和CONFIG_HTTPD_MAX_URI_LEN调小省出内存给数据缓冲区。5.3 OTA 选择器写错开机又回到管理器还有一个让我调试了很久的问题应用安装成功后重启设备又回到了管理器的 Web 页面而不是进入新应用。当时第一反应是esp_ota_set_boot_partition没生效复查了代码也看不出问题。后来才意识到问题出在分区表地址上我的 manager 固件在 factory 分区而esp_ota_set_boot_partition传入的分区结构必须是esp_partition_find_first返回的不能自己硬编码一个地址结构体。看起来是废话但当你手写分区表特别是修改过偏移量之后很容易把地址对错。我一度把ota_0的 offset 写成了0x310000实际上按我的分区表ota_0是从0x200000开始的。这样 bootloader 读 otadata 时确实认为该从0x310000启动但那个地址属于ota_1范围里面是空的于是 bootloader 只能回退到 factory。排查方法其实很简单在esp_ota_set_boot_partition之后打印一下目标分区的address和size和partitions.csv里的值对一下马上就能发现是不是地址错位。5.4 LAN8720 以太网模块的供电和引脚冲突影响上传稳定性如果你的平台像我一样想支持以太网接入避免 WiFi 信号不稳定导致固件传一半断流那大概率会用到 LAN8720。这个模块我自己用下来有三个典型问题网上相关讨论也特别多。第一个是供电不稳。LAN8720 虽然标称 3.3V 供电但实际瞬态电流不小如果直接用开发板自带稳压器供电容易出现 Link 灯亮但 ping 不通、速度忽高忽低的现象。我的做法是外接单独的 3.3V 稳压源并且在模块电源脚旁放一个 100uF 钽电容。就这一个改动以太网稳定性提升非常明显。第二个是 RMII 引脚和 Flash 引脚冲突。ESP32 默认的 RMII 接口用了 GPIO0 做 REF_CLK而 GPIO0 同时也是 boot 模式选择引脚很多开发板还把它接到板载 Flash 的 WP/CS 上冲突就发生了。解决方法是查清开发板的原理图必要时换用其他支持 RMII 时钟输出的 GPIO 组合。第三个是 PHY 复位时序。LAN8720 上电后如果立刻初始化大概率失败。我踩过坑后改成上电后延时 100ms 再执行esp_eth_netif_glue_init和esp_eth_start问题就消失了。这个细节经常被忽略但实际影响很大因为 PHY 的 REG 读不到正确值时以太网驱动会一直处于未连接状态。如果你的平台把安装包上传通道放在以太网上这三个问题任何一个都能导致升级中断值得提前排查。6. 从玩具到工具批量部署和设备应用商店雏形平台跑通之后我越想越觉得这件事不只适用于自己手头这块开发板。只要把思路稍微往外延伸一下就能变成一个很实用的工具。6.1 多应用并存与切换的使用场景我现在会把两个应用分别放进ota_0和ota_1白天跑一个网关应用晚上自动切换到一个低功耗传感器采集应用。切换过程很简单比如网关应用自己在程序里判断时间到点了把 otadata 指向另一个槽位再重启。因为两个应用各自独立编译、独立运行不会互相污染 Flash 里的数据切换可靠性比在单个固件里写复杂的调度逻辑高得多。这个特性在做低功耗设备时特别有用同一个硬件白天需要高功耗的通信功能晚上只需要定时唤醒采集传感器数据两个场景对固件的要求完全不一样。按传统方式你得把两种逻辑揉在一起编译出一个大固件运行时再判断模式有了应用平台之后你可以分别开发两个小固件按需切换内存和 CPU 占用都更少。6.2 把平台用到批量设备上另外我还做了一台“管理电脑”它运行一个简单的 Python 脚本通过局域网扫描同一网段下所有设备的管理器 Web 服务然后批量推送同一个应用包。原来给 10 台设备换固件要插 10 次 USB现在只要在脚本里配置好目标设备 IP 列表一次推送全部完成。配合 NVS 里的唯一设备 ID还能做到不同设备推送不同应用配置。这个批量部署能力对做小规模 IoT 项目或者产品原型验证的人来说价值远大于单设备下载。设备已经安装到客户现场后只要能够连上局域网或者通过 4G 路由桥接就能远程完成固件更新和回退不再需要派人跑现场了。6.3 一个设备端应用商店雏形最后如果管理器里增加一个“在线商店”页面向局域网内的一台服务器请求应用列表那这台 ESP32 就真的变成一个可以浏览、安装、更新应用的设备端平台了。我在实验里跑通了一个简化版管理器启动时请求http://192.168.1.100/appstore/manifest.json拿到可安装的应用列表和版本信息然后像手机应用商店一样在管理页面里展示出来。虽然这个“商店”还非常简陋只能列出几个固件包没有评分也没有评论但它证明了单片机设备完全可以拥有现代操作系统级别的应用管理体验。你只需要在服务器端维护一份manifest.json所有 ESP32 设备就能自动发现新版本一键安装。对跑在局域网里的设备集群来说这套机制已经很实用了。写到这里平台的核心内容基本都覆盖了。从最初只想省掉插 USB 的麻烦到最后做出一套带引导管理、应用槽位、批量部署能力的小系统整个过程最大的收获是ESP32 的潜力比很多人想象的大得多。它的 Flash 分区、OTA 机制、文件系统这些组合起来完全能支撑“应用平台”这类听起来有点超纲的需求。我在实际操作中最大的体会是先花时间理解分区表和启动流程比急着写 Web 页面重要得多。分区表划好启动回退想明白平台就成功了一半。剩下的事情无非是打包、上传、校验这些水磨工夫。如果你也想做类似的东西建议从partitions.csv开始把 factory 和两个 OTA 槽位的位置关系摸清楚再动手写上传接口后面就会顺畅很多。