ESP32 OTA双分区与自动回滚机制详解:原理、配置与实战 1. 从一次刷机翻车说起ESP32 到底会不会变砖很多人第一次给 ESP32 刷固件手都是抖的。尤其是用 USB-TTL 接上开发板、敲下esptool.py write_flash回车的那一刻脑子里全是论坛里那些“刷废了”“板子不亮了”“变砖了”的帖子。我当年也一样第一次刷一块 ESP32-WROOM-32 的时候因为分区表地址填错直接把 bootloader 覆盖了上电之后串口一片安静那种感觉确实像手里攥着一块废铁。但这里要先给一个明确结论ESP32 在绝大多数情况下不会真正“变砖”。所谓变砖通常分两种。一种是“软砖”也就是固件跑飞了、分区表错了、OTA 升级失败导致应用启动不了但芯片内部的 ROM bootloader 还在你依然可以通过串口重新烧录另一种才是“硬砖”比如把 eFuse 里的加密密钥、安全启动配置写错且不可逆或者供电异常把 Flash 芯片打坏这种才是真正意义上的物理或不可逆损坏。日常开发中遇到的 99% 都是软砖完全救得回来。那为什么标题里要提“双分区”和“自动回滚”因为这两个机制解决的正是最让人头疼的场景设备已经部署到现场你不可能每次都拆下来插 USB 重刷。这时候 OTA 升级一旦失败如果没有回滚机制设备就真的“失联”了。ESP32 的 OTA 体系里双分区也叫 A/B 分区或 OTA 分区加上自动回滚就是给固件升级上的一道保险。这篇文章我就围绕这套机制把原理、分区表配置、代码实现、踩坑经验完整讲一遍适合正在做 ESP32 OTA 功能、或者被升级失败搞怕了的嵌入式开发者参考。2. 双分区与自动回滚的整体设计思路2.1 为什么单分区 OTA 是个危险动作先说说最朴素的 OTA 做法只有一个 app 分区新固件下载下来直接覆盖旧固件。这个方案在资源紧张的年代很常见但它有个致命问题——覆盖过程中一旦断电、网络中断或者新固件本身有问题旧固件已经没了新固件又不完整设备直接启动不了。这就像你只有一把钥匙换锁的时候把旧钥匙扔了新钥匙还没配好门就彻底打不开了。ESP32 的 Flash 通常有 4MB 或 8MB完全有空间放两份应用。所以官方推荐的做法是划分出两个 app 分区比如app0和app1再加上一个otadata分区记录当前该从哪个分区启动。升级时把新固件写到“非当前运行”的那个分区写完之后修改otadata指向新分区重启即可。这样旧固件始终完整保留随时可以退回去。2.2 自动回滚解决的是“新固件能启动但跑不起来”双分区解决了“写入失败”的问题但还有一种更隐蔽的情况新固件写入成功、也能启动但运行几秒后崩溃、或者连不上 WiFi、或者业务逻辑有 bug。这时候设备虽然启动了但功能是废的。自动回滚机制就是针对这个场景新固件第一次启动后必须在规定时间内主动“报到”确认自己健康否则 bootloader 认为它有问题自动切回旧分区。这套逻辑在 ESP-IDF 里叫rollback核心是esp_ota_mark_app_valid_cancel_rollback()这个调用。你把它放在固件初始化成功、网络连上、业务自检通过之后就相当于告诉系统“我这次升级没问题别回滚了”。如果没调用或者调用之前就重启了bootloader 就会在下一次启动时回退到上一个可用分区。2.3 方案选型的几个关键取舍实际做的时候有几个选择要想清楚。第一是分区大小怎么分两个 app 分区要一样大且都要能装下你的固件如果固件接近 1.5MB那每个分区至少留 1.6MB两个就是 3.2MB4MB Flash 就很紧张了可能要考虑 8MB。第二是回滚的判定时机太早调用可能固件还没真正稳定太晚调用又浪费一次重启。第三是otadata分区的可靠性它记录启动状态如果这个分区写坏了启动逻辑会乱。我的经验是回滚确认点放在“网络连接成功 关键外设初始化成功 首次业务心跳上报成功”之后这样能覆盖绝大多数“能启动但不可用”的情况。至于分区大小建议 app 分区留出固件体积 1.3 倍以上的余量给未来功能增长留空间。3. 分区表配置与核心参数详解3.1 分区表怎么写才不出错ESP32 的分区表是一个 CSV 文件通常叫partitions.csv编译时通过menuconfig里的Partition Table选项指定自定义文件。一个支持双分区 OTA 的典型配置如下# 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, 0x140000, ota_0, app, ota_0, 0x150000,0x140000, ota_1, app, ota_1, 0x290000,0x140000,这里几个关键点必须说清楚。otadata大小固定0x2000它存两份ota_select结构做冗余。factory分区是可选的如果你希望有一个“永远不参与 OTA 的出厂固件”可以保留但很多项目为了省空间会去掉 factory直接让ota_0作为初始分区。注意Offset必须和前面分区的大小严格对齐否则烧录会报重叠错误。提示分区表里所有地址都必须是 0x1000 的整数倍这是 Flash 扇区对齐要求写错会导致esptool直接拒绝烧录。3.2 otadata 的工作机制otadata分区里存的是两个esp_ota_select_entry_t结构每个包含一个ota_seq序号和一个crc校验。系统启动时读取这两个 entry选择序号更大且 CRC 正确的那个作为当前启动分区。每次 OTA 成功写入新固件后序号加一指向另一个分区。这种“乒乓”式设计保证了即使写入过程中断电至少有一个 entry 是完好的。这里有个容易忽略的细节序号是循环使用的当序号达到最大值会回绕。所以不要试图通过序号大小去判断固件新旧它只是一个选择依据。另外如果你手动擦除了otadata系统会回到默认启动分区通常是 factory 或 ota_0这其实也是一个救砖手段。3.3 回滚相关的配置项在menuconfig里Component config - ESP System Settings下面有个Enable app rollback support必须打开。打开之后bootloader 会在启动时检查当前分区的状态。如果这个分区被标记为ESP_OTA_IMG_PENDING_VERIFY就会启动一个倒计时等待应用调用确认接口。还有一个配置是Number of attempts to boot the app before rollback默认是 3 次。意思是如果新固件连续启动 3 次都没能确认健康就回滚。这个值可以调但我不建议调太小因为有些固件首次启动需要初始化文件系统、校准传感器耗时较长可能被误判。我一般保持默认或者设成 5。4. OTA 升级与自动回滚的代码实现4.1 基础 OTA 流程的代码骨架先看一段基于 ESP-IDF 的 OTA 核心代码这是整个机制落地的基础#include esp_ota_ops.h #include esp_http_client.h #include esp_https_ota.h void ota_task(void *pvParameter) { esp_http_client_config_t config { .url http://your-server/firmware.bin, .timeout_ms 10000, }; esp_https_ota_config_t ota_config { .http_config config, }; esp_err_t ret esp_https_ota(ota_config); if (ret ESP_OK) { esp_restart(); } else { ESP_LOGE(TAG, OTA failed: %s, esp_err_to_name(ret)); } }这段代码看起来简单但esp_https_ota内部已经帮你做了很多事它会自动获取当前运行分区、找到下一个可用的 OTA 分区、分块写入、校验 SHA256、最后更新otadata。你不需要手动去操作分区。但要注意这个函数返回 ESP_OK 只代表写入成功不代表新固件能跑起来真正的健康确认还得靠回滚机制。4.2 回滚确认接口的正确调用位置新固件启动后你需要在合适的地方调用确认接口。我通常写一个app_self_check()函数在里面依次检查关键项void app_self_check(void) { // 1. 检查是否处于待验证状态 const esp_partition_t *running esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; if (esp_ota_get_state_partition(running, ota_state) ESP_OK) { if (ota_state ESP_OTA_IMG_PENDING_VERIFY) { // 2. 执行自检网络、外设、业务 if (wifi_connect() ESP_OK sensor_init() ESP_OK) { esp_ota_mark_app_valid_cancel_rollback(); ESP_LOGI(TAG, Firmware marked valid); } else { ESP_LOGE(TAG, Self check failed, will rollback); esp_ota_mark_app_invalid_rollback_and_reboot(); } } } }这里有个关键判断esp_ota_get_state_partition返回ESP_OTA_IMG_PENDING_VERIFY才说明当前是待验证的新固件。如果是正常运行的旧固件状态是ESP_OTA_IMG_VALID不需要做任何事。这个判断避免了每次启动都去调用确认接口。4.3 主动回滚与被动回滚的区别上面代码里用了esp_ota_mark_app_invalid_rollback_and_reboot()这是主动回滚——自检发现有问题自己触发回退。而被动回滚是自检代码根本没执行到比如启动就崩溃bootloader 等超时后自动回退。两种都要考虑。主动回滚的好处是快不用等 3 次启动失败。但要注意调用这个函数后设备会立即重启并切回旧分区所以调用前要确保日志已经输出、必要的数据已经保存。我踩过一次坑在主动回滚前没把错误原因写进 NVS结果回滚后完全不知道新固件为什么失败只能靠串口日志而现场设备根本没有串口。提示建议在回滚前把失败原因、时间戳写进 NVS 或 RTC 内存回滚后旧固件启动时读取并上报这样才有排查依据。5. 实操过程与关键环节记录5.1 从零搭建一个可回滚的 OTA 工程我以 ESP-IDF v5.x 为例把完整流程走一遍。第一步是创建工程用idf.py create-project ota_demo然后进入目录。第二步是准备分区表把上面的 CSV 内容保存为partitions.csv然后idf.py menuconfig在Partition Table里选择Custom partition table CSV填入文件名。第三步是配置回滚支持在Component config - ESP System Settings里勾选Enable app rollback support。第四步是写代码把 OTA 任务和自检函数加进去。第五步是编译烧录idf.py build flash monitor。第一次烧录会写入 factory 或 ota_0 分区具体取决于你的分区表。这里有个实操细节第一次烧录时otadata 是空的系统会启动 factory 分区如果有或者 ota_0。如果你没有 factory 分区第一次烧录的目标地址要指向 ota_0 的 offset否则会烧到错误位置。用idf.py flash时它会自动处理但如果你用esptool.py手动烧就要注意--flash_size和地址参数。5.2 模拟一次 OTA 失败并观察回滚验证回滚机制最直接的方法就是故意让新固件“不健康”。我的做法是在新固件里加一个编译宏SIMULATE_FAILURE打开后自检函数直接返回失败不调用确认接口。然后通过 HTTP 服务器推送这个固件观察设备行为。预期现象是设备下载新固件、写入 ota_1、重启、启动新固件、自检失败、调用主动回滚、再次重启、回到 ota_0 旧固件。整个过程串口日志会清晰打印分区切换。如果主动回滚没触发bootloader 会在 3 次启动失败后被动回滚日志里会看到Rollback to previous app之类的信息。实测下来主动回滚从失败到恢复大约 5 到 8 秒被动回滚因为要等 3 次启动大概 15 到 20 秒。对于现场设备这个时间可以接受但如果你的设备有看门狗或者心跳超时告警要确保告警阈值大于回滚时间否则会误报。5.3 参数计算分区大小与固件体积的匹配假设你的固件编译出来是 1.2MB那么每个 app 分区至少要 1.2MB但考虑到未来功能增加和 OTA 写入时的临时空间建议留 1.5 到 1.6MB。两个分区就是 3.0 到 3.2MB。加上 nvs、otadata、phy_init 这些4MB Flash 刚好够但几乎没有余量给文件系统或数据存储。如果你需要 SPIFFS 或 FATFS 存数据4MB 就不够了必须上 8MB 或 16MB。我一般推荐 8MB 起步分区可以这样分两个 1.8MB 的 app 分区1MB 的 SPIFFS剩下的给 nvs 和 otadata。这样既有回滚能力又有数据存储空间。计算的时候记住Flash 实际可用容量比标称小因为 bootloader、分区表、RF 校准数据都要占位置8MB 实际能用大约 7.5MB。6. 常见问题与排查技巧实录6.1 OTA 升级后设备反复重启这是最典型的问题通常有三个原因。第一是自检函数里调用了确认接口但确认接口之前就崩溃了导致每次启动都进入待验证状态又每次崩溃最终被动回滚。排查方法是看串口日志里有没有PENDING_VERIFY和rollback关键字。第二是分区表里两个 app 分区大小不一致导致写入越界。第三是otadata分区被意外擦除启动逻辑混乱。我的排查顺序是先确认分区表用esptool.py read_flash 0x8000 0x1000 partition.bin读出来解析再看 otadata用esptool.py read_flash 0xd000 0x2000 otadata.bin检查内容最后看应用日志确认自检流程走到哪一步。6.2 回滚后旧固件也起不来这种情况一般是旧固件本身也有问题或者回滚目标分区被破坏了。双分区机制的前提是“旧固件是好的”如果你第一次烧录的固件就有 bug那回滚回去也没用。所以出厂固件必须经过严格测试它是最后的保底。另外如果旧固件依赖 NVS 里的某些数据而新固件升级时改了 NVS 结构回滚后旧固件可能读不懂导致启动失败。解决办法是 NVS 数据结构做版本兼容或者升级前备份 NVS。6.3 常见问题速查表现象可能原因排查方法解决手段升级后无法启动新固件崩溃串口日志等待自动回滚或主动回滚回滚不生效未开启 rollback 支持menuconfig 检查勾选 Enable app rollback support分区写入失败分区表地址重叠解析分区表修正 Offset 和 Sizeotadata 损坏断电写入读 otadata 校验擦除 otadata 恢复默认启动回滚后功能异常NVS 结构不兼容对比 NVS 版本做数据迁移或版本判断6.4 几个我踩过的坑第一个坑是在中断里调用 OTA 确认接口。这个接口内部会操作 Flash而 Flash 操作在中断上下文里是不安全的会导致崩溃。一定要放在任务里调用。第二个坑是OTA 任务栈太小。esp_https_ota内部有 TLS 握手和缓冲区栈至少给 8KB我给过 4KB结果握手到一半栈溢出。第三个坑是HTTP 服务器返回 302 重定向esp_http_client默认不跟随重定向导致下载到的是 HTML 而不是固件写入后校验失败。要么服务器直接返回固件要么配置disable_auto_redirect false。还有一个经验OTA 升级时最好关闭 WiFi 省电模式。省电模式下网络会间歇性休眠下载大文件时容易超时。调用esp_wifi_set_ps(WIFI_PS_NONE)可以关闭代价是功耗高一点但升级过程更稳定。7. 固件安全与进阶扩展7.1 固件加密与安全启动对回滚的影响如果你的项目开启了 Flash 加密和安全启动OTA 流程会多一层校验。新固件必须用对应的密钥签名否则 bootloader 拒绝启动。这时候回滚机制依然有效但要注意回滚回去的旧固件也必须是已签名的否则同样起不来。另外加密模式下otadata的读写也走加密通道如果密钥配置错误整个启动链都会断。我的建议是开发阶段先不开加密把 OTA 和回滚跑通量产前再开加密和安全启动并且一定要在开启后重新完整测试一遍回滚流程。因为加密后的烧录方式、签名工具、密钥管理都变了很容易在切换时出问题。7.2 结合看门狗做双重保护自动回滚解决的是启动阶段的问题但设备运行一段时间后死机怎么办这时候需要硬件看门狗或任务看门狗。我通常的做法是在自检确认之后启动一个任务看门狗监控主任务和网络任务。如果某个任务卡死超过阈值看门狗触发重启。重启后如果固件还是有问题下一次启动就会进入回滚流程。这样就形成了“运行监控 启动回滚”的双重保护。7.3 远程诊断与日志回传回滚虽然能恢复设备但如果不把失败原因传回来你永远不知道现场发生了什么。我的做法是在 NVS 里开一个专门的区域存“上次升级失败原因”包括错误码、时间戳、当前分区。回滚后旧固件启动时读取这个区域通过 MQTT 或 HTTP 上报到服务器。这样你就能在后台看到“某台设备在 3 月 5 日升级失败原因是 WiFi 连接超时”针对性去修。这个机制我用了两年多帮我在几十台现场设备上定位过问题。有一次是某批次设备的天线匹配电容贴错导致 WiFi 信号弱OTA 下载总是超时如果没有日志回传我可能会误判为固件 bug。7.4 后续可以扩展的方向这套双分区加回滚的框架搭好之后还能做不少扩展。比如做差分升级只下载新旧固件的差异部分节省流量和时间比如做灰度发布先给 10% 的设备推送新固件观察回滚率再全量比如做多版本共存在 Flash 足够大的情况下保留三个以上分区支持更灵活的版本切换。这些都是在基础机制之上叠加的策略核心的双分区和回滚逻辑不变。我个人在实际操作中的体会是ESP32 的 OTA 回滚机制本身很可靠但它的可靠性建立在“分区表正确、自检逻辑合理、出厂固件稳定”这三个前提上。任何一个环节偷懒回滚都可能失效。所以别嫌麻烦第一次就把分区表规划好把自检点选对把日志通道留出来。后面设备部署到现场你才能真正睡得着觉。