ESP32 OTA升级防变砖指南:双分区与自动回滚机制详解

发布时间:2026/10/30 3:53:20
ESP32 OTA升级防变砖指南:双分区与自动回滚机制详解

1. 从一次OTA翻车说起:ESP32到底会不会变砖

很多人第一次给ESP32做OTA升级的时候,心里都会有一个挥之不去的疑问:万一固件刷坏了,板子是不是就废了?我当初也是这么想的,甚至第一次做远程升级的时候,特意开车到现场守着,生怕刷完设备就再也连不上,得拆机接串口重新烧录。

先说结论:ESP32在合理的分区设计和引导配置下,几乎不会真正变砖。注意这里的限定条件——“合理的分区设计和引导配置”。如果你用的是默认的单应用分区,OTA写坏了就是写坏了,设备启动时找不到有效固件,就会一直重启或者卡在bootloader,这时候确实只能靠串口救回来。但如果用上双分区(dual OTA partition)加自动回滚(rollback),情况就完全不一样了:新固件启动失败,芯片会自动切回上一个能正常运行的版本,设备照样在线,你还有机会远程重新推一次。

这套机制的核心依赖三个东西:ESP-IDF的分区表设计、OTA数据分区里记录的状态标志位、以及bootloader在启动时的校验逻辑。三者配合起来,才构成了“刷不死”的底气。很多人只知道“ESP32支持OTA”,但不知道OTA背后还有一套状态机在管着启动决策,所以一旦出问题就手足无措。

这篇文章我会从分区表怎么划、回滚标志位怎么工作、代码里怎么配置、实测中会遇到哪些坑这几个角度,把双分区加自动回滚这套方案讲透。适合已经会用Arduino或ESP-IDF烧录、准备做远程升级、或者已经被OTA坑过一次的开发者。看完你至少能做到:知道自己的分区表够不够安全、知道怎么开启回滚、知道回滚没生效时该往哪里查。

2. 分区表不是随便划的:双分区方案的底层逻辑

2.1 为什么单分区OTA本质上是在赌命

先看默认情况。Arduino IDE给ESP32的默认分区方案通常是“Default 4MB with spiffs”,里面只有一个app分区,大小大概1.2MB到1.5MB。这种配置下做OTA,流程是这样的:新固件下载到一块临时区域,然后擦除原来的app分区,把新固件写进去。问题就出在“擦除再写入”这个动作上——如果写入过程中断电、网络中断导致数据不完整、或者新固件本身有bug起不来,原来的固件已经被擦掉了,设备就失去了可运行的代码。

这就像你只有一把钥匙,换锁的时候把旧锁拆了,新锁还没装好,门就打不开了。单分区OTA的本质是“覆盖式更新”,没有退路。

2.2 双分区OTA的分区表长什么样

双分区方案的核心思路是:永远保留至少一个已知可用的app分区。典型的分区表大概是这样:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, factory, app, factory, 0x11000, 0x140000, ota_0, app, ota_0, 0x151000, 0x140000, ota_1, app, ota_1, 0x291000, 0x140000,

这里有几个关键点。factory分区是出厂固件,ota_0和ota_1是两个OTA槽位。设备正常运行时可能跑在factory,也可能跑在ota_0或ota_1。每次OTA升级,新固件会被写入“当前没在运行的那个OTA分区”。比如现在跑在ota_0,新固件就写到ota_1;写完之后更新otadata,告诉bootloader下次从ota_1启动。如果ota_1启动失败,bootloader根据otadata里的状态判断,自动切回ota_0。

otadata分区只有0x2000大小,但它极其关键。它里面存的是两个ota槽位的状态记录,每个记录包含序列号、状态标志(新固件、待验证、有效、无效等)。bootloader启动时会读这个分区,决定加载哪个app。

2.3 分区大小怎么算才够用

上面例子给每个app分区0x140000,也就是1.25MB。这个大小够不够,取决于你的固件编译出来多大。我一般建议:app分区大小至少是实际固件体积的1.5倍,留出未来功能增长的空间。如果你用ESP-IDF编译,编译结束会打印固件大小,比如“Project build complete. Binary size: 0xE5A30”,换算下来大概918KB,那1.25MB的分区是够的,但余量不算特别充裕。

如果固件超过分区大小,编译阶段就会报错,不会等到OTA才发现。但有一种情况容易忽略:Arduino IDE里选了分区方案,但代码里用了大量库,编译出来的bin比预期大很多,这时候要么换更大的分区方案,要么精简代码。我见过有人用4MB flash划了两个1.25MB的app分区,结果固件编译出来1.3MB,直接卡在编译阶段。

另外,4MB flash做双分区其实是比较紧张的。factory + ota_0 + ota_1三个app分区加起来就3.75MB了,加上nvs、otadata、phy_init,基本把4MB吃满。如果你不需要factory分区,可以去掉它,只保留ota_0和ota_1,这样每个分区能分到接近1.9MB,宽裕很多。去掉factory的代价是:首次烧录必须通过串口烧到ota_0,不能靠factory兜底。但对于量产设备来说,产线本来就要串口烧录,所以问题不大。

3. 自动回滚是怎么判断“这次启动失败了”的

3.1 回滚不是魔法,靠的是状态标志位

很多人以为自动回滚是bootloader自己“感觉”固件有问题就切回去,其实不是。回滚的触发条件非常明确:新固件启动后,必须在规定时间内主动调用esp_ota_mark_app_valid_cancel_rollback(),否则bootloader就认为它有问题,下次重启时回滚。

这个设计很巧妙。新固件启动后,你可以在初始化WiFi、连接服务器、自检外设都成功之后,再调用这个函数“确认自己没问题”。如果固件有严重bug,比如一启动就崩溃、卡在某个循环里出不来、或者WiFi连不上导致无法远程管理,那就没机会调用确认函数,bootloader在下次启动时就会回滚到旧版本。

3.2 回滚的完整状态流转

整个状态流转大概分几步:

  1. OTA写入新固件到ota_1,otadata里ota_1的状态标记为ESP_OTA_IMG_NEW。
  2. 设备重启,bootloader看到ota_1是NEW状态,把它改成ESP_OTA_IMG_PENDING_VERIFY,然后启动ota_1。
  3. 新固件运行,如果一切正常,调用esp_ota_mark_app_valid_cancel_rollback(),状态变成ESP_OTA_IMG_VALID,回滚取消。
  4. 如果新固件没调用确认函数就重启了(比如崩溃重启、看门狗复位),bootloader再次启动时看到ota_1还是PENDING_VERIFY状态,就判定启动失败,把ota_1标记为ESP_OTA_IMG_INVALID或ESP_OTA_IMG_ABORTED,然后切回ota_0。

这里有个细节:PENDING_VERIFY状态下的重启,不一定是崩溃。如果你在新固件里调用了esp_restart()但没先调用确认函数,也会触发回滚。所以确认函数的调用时机很重要,一般放在所有关键初始化完成之后、进入主循环之前。

3.3 确认函数该放在哪里

我一般的做法是在app_main()里,WiFi连接成功、MQTT连上、传感器自检通过之后,再调用确认。这样能最大程度保证“只有真正能用的固件才会被确认”。但也不能太晚,如果确认之前设备因为其他原因重启了,就会误回滚。

有一种情况需要特别注意:如果新固件的功能就是“修复WiFi连接问题”,而旧固件的WiFi是坏的,那新固件启动后可能连不上WiFi,也就没法远程确认。这时候如果没调用确认函数,设备会回滚到WiFi坏的旧固件,陷入死循环。解决办法是在这种场景下,把确认条件放宽,比如只要系统启动没崩溃就确认,WiFi问题通过其他方式排查。

4. 在ESP-IDF里把双分区和回滚跑通

4.1 分区表配置和menuconfig关键项

在ESP-IDF项目里,分区表放在partitions.csv,然后在menuconfig里指定自定义分区表:

Partition Table ---> Partition Table (Custom partition table CSV) ---> (partitions.csv) Custom partition CSV file

同时要确认这几个配置:

Component config ---> ESP System Settings ---> [*] Enable app rollback support [*] Enable app anti-rollback support (可选,用于防止降级到旧版本)

Enable app rollback support必须打开,否则bootloader不会处理回滚逻辑。这个选项在ESP-IDF v4.x之后默认可能是开的,但最好确认一下。

4.2 OTA写入的核心代码

下面是一段典型的OTA写入逻辑,基于ESP-IDF的esp_ota_ops组件:

esp_ota_handle_t ota_handle; const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle); while (data_remaining) { esp_ota_write(ota_handle, data_chunk, chunk_len); } esp_ota_end(ota_handle); esp_ota_set_boot_partition(update_partition); esp_restart();

esp_ota_get_next_update_partition(NULL)会自动返回“当前没在运行的那个OTA分区”,不需要你手动判断。esp_ota_set_boot_partition()会更新otadata,告诉bootloader下次从新分区启动。

写入过程中如果出错,比如esp_ota_write返回非ESP_OK,要调用esp_ota_abort(ota_handle)清理,否则otadata可能处于不一致状态。

4.3 确认函数的调用和超时处理

新固件启动后,在合适的位置调用:

esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to mark app valid: %s", esp_err_to_name(err)); }

如果这个函数返回错误,说明当前运行的分区不是PENDING_VERIFY状态,可能是已经确认过的,也可能是状态异常。一般不影响运行,但值得打日志观察。

另外,ESP-IDF还提供了esp_ota_get_state_partition()可以查询分区状态,调试阶段可以打印出来看:

esp_ota_img_states_t state; esp_ota_get_state_partition(esp_ota_get_running_partition(), &state); ESP_LOGI(TAG, "Current partition state: %d", state);

5. 实测中回滚没生效?这几个坑我替你踩过了

5.1 回滚没触发,设备一直重启

这是最常见的问题。现象是:新固件启动后崩溃,设备反复重启,但就是不回滚。排查下来通常是两个原因。

第一个原因是rollback support没开。在menuconfig里确认Enable app rollback support是勾选的。如果没勾,bootloader根本不看otadata里的PENDING_VERIFY状态,只会一直尝试启动新分区。

第二个原因是新固件崩溃得太早,bootloader还没来得及记录状态。bootloader在启动新固件之前会把状态从NEW改成PENDING_VERIFY,但如果崩溃发生在bootloader完成状态更新之前(比如flash读写异常),状态可能还是NEW。这种情况下,下次启动bootloader会再次尝试启动新分区,形成循环。解决办法是确保flash操作正常,或者在新固件里尽早调用确认函数。

5.2 确认函数调用了,但还是回滚了

这种情况一般是确认函数调用之后、设备又重启了,而且重启时otadata的写入还没完成。esp_ota_mark_app_valid_cancel_rollback()内部会写flash,如果写完立刻断电,可能写入不完整。建议调用确认函数之后,至少等几百毫秒再执行其他可能触发重启的操作。

还有一种可能是调用了确认函数,但当前运行的分区不是OTA分区(比如跑在factory分区),这时候函数会返回错误,状态不会改变。如果你从factory启动,然后OTA到ota_0,确认逻辑要针对ota_0来写。

5.3 OTA写入过程中断电,设备起不来

双分区方案下,OTA写入的是“非当前运行分区”,所以写入过程中断电,当前运行分区不受影响,设备重启后还是跑旧固件。但otadata可能处于中间状态,比如新分区被标记为NEW但没写完。bootloader看到NEW状态会尝试启动,但固件不完整,启动失败,然后回滚。所以理论上不会变砖。

但有一种极端情况:如果otadata本身在写入时损坏,bootloader读不到有效的启动分区信息,可能会卡住。otadata分区有冗余设计,两个记录互为备份,一般不会同时损坏。如果真遇到,只能串口擦除otadata重新烧录。

5.4 Arduino IDE下的回滚支持

Arduino IDE的ESP32核心从2.0版本开始支持OTA回滚,但配置方式和ESP-IDF不同。在Arduino里,你需要:

  1. 选择带OTA的分区方案,比如“Minimal SPIFFS (1.9MB APP with OTA)”。
  2. 在代码里调用Update.begin()、Update.write()、Update.end()完成OTA。
  3. 新固件启动后,调用esp_ota_mark_app_valid_cancel_rollback()确认。

Arduino环境下这个函数可以直接调用,因为底层还是ESP-IDF。但要注意,Arduino的Update库在写入失败时的错误处理不如ESP-IDF细致,建议在Update.end()之后检查返回值,失败时不要调用esp_restart()。

6. 让回滚更可靠:几个进阶配置和习惯

6.1 看门狗和回滚的配合

ESP32有任务看门狗(TWDT)和中断看门狗(IWDT)。如果新固件卡在某个循环里,看门狗会触发重启。重启后bootloader看到PENDING_VERIFY状态,就会回滚。所以看门狗其实是回滚机制的好搭档——它能把“卡死”这种软件问题转化成“重启”,进而触发回滚。

但要注意,如果新固件在启动早期就喂狗失败,可能还没走到确认函数就重启了,这正好是回滚想要的效果。所以不要为了避免重启而盲目关闭看门狗,尤其是在OTA后的首次启动阶段。

6.2 版本号和防降级

Enable app anti-rollback support可以防止设备降级到旧版本固件。它通过在固件里嵌入版本号,bootloader比较版本号来决定是否允许启动。这个功能在安全要求高的场景有用,但调试阶段建议关掉,否则回滚到旧版本会被阻止,反而麻烦。

版本号通过CONFIG_APP_PROJECT_VER或esp_app_desc_t里的版本字段设置。如果开了防降级,每次OTA的版本号必须递增,否则新固件会被拒绝启动。

6.3 串口救砖的兜底方案

即使有双分区和回滚,我还是建议保留串口烧录的兜底能力。具体做法是:在PCB上留出UART0的TX、RX、GND和EN、BOOT按键,或者至少留出测试点。一旦OTA和回滚都失效(比如otadata损坏、分区表被误擦),可以通过串口重新烧录。

ESP32的串口烧录需要拉低BOOT引脚再复位,进入下载模式。如果设备已经装在外壳里,操作起来很麻烦,所以量产设备最好把BOOT和EN引到外部可操作的位置,或者用自动下载电路。

6.4 我的个人习惯:OTA前先备份

每次OTA之前,我会先把当前固件的bin文件备份到服务器,同时记录当前运行的OTA分区和otadata状态。这样即使回滚失败,也能通过串口烧回备份固件。备份命令很简单:

esptool.py --port /dev/ttyUSB0 read_flash 0x10000 0x140000 backup.bin

0x10000是app分区的起始地址,0x140000是大小,根据你的分区表调整。读出来的bin可以直接用esptool.py write_flash烧回去。

这套双分区加自动回滚的方案,我在好几个现场设备上跑了一年多,经历过网络中断、固件bug、意外断电,设备都自己恢复了。唯一一次需要现场救砖,是因为分区表改错了,把otadata的offset写重叠了,那是人为失误,不是机制问题。所以只要你分区表划对、rollback support打开、确认函数放对位置,ESP32真的刷不死。