ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESP32 OTA双分区与自动回滚机制详解:原理、配置与实战

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.2MB,4MB 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 和 Size
otadata 损坏断电写入读 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 回滚机制本身很可靠,但它的可靠性建立在“分区表正确、自检逻辑合理、出厂固件稳定”这三个前提上。任何一个环节偷懒,回滚都可能失效。所以别嫌麻烦,第一次就把分区表规划好,把自检点选对,把日志通道留出来。后面设备部署到现场,你才能真正睡得着觉。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进