1. 从一次“刷废了”的深夜事故说起
凌晨两点,我盯着串口监视器里不断滚动的乱码,手里的开发板已经第三次重启失败。那是我第一次给 ESP32 刷自定义固件,分区表改错了一个偏移量,结果设备直接卡在 bootloader 阶段,连串口都只能看到几行残缺的启动日志。当时脑子里只有一个念头:这板子是不是彻底废了?
后来我才明白,ESP32 这类芯片在设计之初就考虑过“固件刷坏”这件事。它不像早年某些单片机,一旦烧录出错就真的只能上编程器硬救。ESP32 的 flash 分区机制、bootloader 回滚逻辑、以及 OTA 升级时的双分区设计,本质上就是给开发者留了后路。这篇文章不打算复述数据手册,而是想把我自己踩过的坑、验证过的方案、以及实际项目中跑通的自动回滚流程完整拆开讲一遍。如果你正在用 ESP32 做产品、做原型,或者只是刚接触 Arduino 和 ESP-IDF 的玩家,这些内容应该能帮你省下几个不眠之夜。
核心关键词就几个:ESP32、固件、双分区、自动回滚、OTA。我会围绕它们展开,但不会只停留在概念层面。每一段都会落到具体的分区表配置、代码逻辑、以及实测现象上。
2. 为什么“变砖”这件事在 ESP32 上没那么可怕
2.1 先搞清楚“砖”的定义到底是什么
很多人把“刷坏固件”和“变砖”混为一谈。严格来说,砖分两种:一种是软砖,固件跑不起来,但芯片的 ROM bootloader 还能工作,串口能连、能重新烧录;另一种是硬砖,连 ROM bootloader 都进不去,通常是因为烧录时把 bootloader 区域或者 eFuse 配置写坏了。
ESP32 的 ROM bootloader 是固化在芯片内部的,用户无法擦除。只要这个部分没坏,你永远可以通过串口重新烧录。真正会导致硬砖的操作极少,比如故意烧写错误的 flash 加密密钥、或者把 eFuse 里的下载模式禁用位写死。日常开发中遇到的“刷坏了”,99% 都是软砖。
我实测过最极端的情况:把分区表整个擦掉,只留一个空的 flash。ESP32 上电后 ROM bootloader 依然会尝试从默认地址读取,失败后进入下载模式,串口工具照样能识别。所以从物理层面讲,ESP32 的“抗砖能力”比很多人想象的要强得多。
2.2 双分区机制是怎么给你兜底的
ESP32 的 OTA 升级依赖一套分区表设计。简单说,flash 里不是只放一份固件,而是至少放两份:一份是当前正在运行的,另一份是待升级或备份的。常见的分区表里会有factory、ota_0、ota_1这几个 app 分区,再加上otadata分区用来记录当前该从哪个分区启动。
这种设计的精妙之处在于:升级时新固件写入空闲分区,写完后修改otadata指向新分区,然后重启。如果新固件启动失败,bootloader 会根据otadata里的状态标记决定是否回滚到旧分区。整个过程不需要人工干预,也不需要拆机接线。
我刚开始用 Arduino 的时候,默认分区表只有一个 app 分区,根本没有 OTA 空间。后来换成Minimal SPIFFS或者自定义分区表,才真正用上双分区。这个切换本身不复杂,但如果不做,后面所有自动回滚都无从谈起。
2.3 自动回滚不是“魔法”,而是状态机
很多人以为自动回滚是芯片硬件自动完成的,其实不是。它依赖 bootloader 和应用程序之间的配合。ESP-IDF 里有一套esp_ota_get_state_partition和esp_ota_mark_app_valid_cancel_rollback的接口,Arduino 环境下也有对应的Update库和ESP32核心支持。
基本流程是这样的:新固件启动后,应用程序需要在规定时间内调用“标记为有效”的接口。如果超时没有标记,bootloader 就认为这次启动失败,下次重启时自动切回旧分区。这个“规定时间”和“失败判定”逻辑,就是自动回滚的核心。
我见过不少项目只做了 OTA 写入,没做有效性标记,结果新固件第一次启动时因为网络没连上、传感器没初始化成功,直接被判定为失败,设备反复回滚,用户看到的现象就是“升级后版本号没变”。这不是回滚机制坏了,而是应用程序没有正确配合。
3. 分区表设计:双分区的具体配置与参数计算
3.1 分区表的基本结构和字段含义
ESP32 的分区表是一个 CSV 文件,编译时被转换成二进制并烧录到 flash 的固定偏移处。每一行代表一个分区,字段包括 Name、Type、SubType、Offset、Size、Flags。对于 OTA 双分区方案,最关键的是 app 分区和 otadata 分区。
一个典型的双分区表长这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x140000, app1, app, ota_1, 0x150000,0x140000, spiffs, data, spiffs, 0x290000,0x160000,这里app0和app1各占 1.25MB,otadata占 8KB。otadata分区虽然小,但它决定了 bootloader 从哪个 app 分区启动。它内部维护两个扇区,轮流写入启动计数和状态标记。
注意:
otadata的偏移必须紧跟在nvs之后,且大小必须是 0x2000 的整数倍。我试过把它设成 0x1000,结果编译报错,bootloader 直接不识别。
3.2 分区大小怎么算才够用
分区大小不是拍脑袋定的。你需要先估算固件编译后的 bin 文件大小。在 Arduino IDE 里,编译完成后底部会显示 “Sketch uses xxx bytes”。在 ESP-IDF 里,idf.py size会给出更详细的分区占用。
假设你的固件编译出来是 1.1MB,那么每个 app 分区至少要给到 1.2MB 以上,留 10% 余量。如果用了 WiFi、蓝牙、文件系统、OTA 库,固件很容易超过 1.5MB。这时候要么换更大的 flash 芯片(比如 8MB 或 16MB),要么裁剪功能。
我自己的经验是:4MB flash 是双分区 OTA 的底线。低于这个容量,两个 app 分区加上 nvs、otadata、spiffs 之后会非常紧张。8MB 会舒服很多,16MB 基本可以随便折腾。
3.3 自定义分区表的实操步骤
在 Arduino IDE 里,分区表可以通过Tools -> Partition Scheme选择预设,也可以自己写 CSV 文件放到项目目录,然后在boards.txt或平台配置里指定。更通用的做法是直接在项目根目录放一个partitions.csv,然后在编译配置里引用。
ESP-IDF 下更直接:idf.py menuconfig -> Partition Table -> Custom partition table CSV,填入文件名即可。编译时系统会自动生成partition-table.bin并烧录到 0x8000。
我踩过的一个坑是:改了分区表之后没有执行idf.py fullclean,结果旧的分区表二进制还在 build 目录里,烧录后设备行为诡异。后来养成习惯,每次动分区表必先全量清理。
4. 自动回滚的代码实现与关键接口
4.1 ESP-IDF 下的完整回滚逻辑
在 ESP-IDF 里,自动回滚的核心接口有三个:
esp_ota_get_state_partition:查询当前分区的 OTA 状态。esp_ota_mark_app_valid_cancel_rollback:标记当前固件为有效,取消回滚。esp_ota_mark_app_invalid_rollback_and_reboot:主动标记为无效并重启回滚。
典型流程是:设备启动后,先执行自检(网络连接、外设初始化、关键任务启动),全部通过后调用esp_ota_mark_app_valid_cancel_rollback()。如果自检失败,可以主动调用esp_ota_mark_app_invalid_rollback_and_reboot(),也可以直接重启,让 bootloader 根据超时机制自动回滚。
#include "esp_ota_ops.h" void app_main(void) { 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) { // 执行自检 if (self_test() == ESP_OK) { esp_ota_mark_app_valid_cancel_rollback(); } else { esp_ota_mark_app_invalid_rollback_and_reboot(); } } } // 正常业务逻辑 }这段代码我用了很多次,稳定可靠。关键是self_test()要覆盖所有可能导致新固件不可用的因素,比如 WiFi 连接、MQTT 握手、传感器读取、文件系统挂载。
4.2 Arduino 环境下的简化实现
Arduino 核心对 OTA 回滚的封装没有 ESP-IDF 那么细,但依然可以用Update库和esp_ota_ops.h。很多 Arduino 项目直接用ArduinoOTA库,它内部已经处理了部分回滚逻辑,但默认行为不一定符合你的需求。
我通常会在 Arduino 项目里手动引入esp_ota_ops.h,然后仿照 IDF 的流程写。需要注意的是,Arduino 的setup()里如果执行时间过长,可能会触发看门狗复位,导致还没来得及标记有效就重启了。解决办法是在自检前先喂狗,或者把自检拆成异步任务。
#include <esp_ota_ops.h> void setup() { 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) { if (performSelfTest()) { esp_ota_mark_app_valid_cancel_rollback(); } else { esp_ota_mark_app_invalid_rollback_and_reboot(); } } } }提示:Arduino 环境下调用
esp_ota_mark_app_invalid_rollback_and_reboot()后设备会立即重启,后面的代码不会执行。所以这个调用要放在自检失败分支的最后。
4.3 回滚超时机制与看门狗配合
ESP32 的 bootloader 有一个CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE选项,开启后新固件启动时会被标记为PENDING_VERIFY。如果应用程序没有在下次重启前标记有效,bootloader 就认为启动失败并回滚。
这个“下次重启前”并没有一个固定的秒数,而是依赖重启次数或手动触发。实际项目中,我通常会在自检失败时主动重启,或者设置一个看门狗任务,如果 30 秒内没有收到有效标记,就强制重启。
看门狗配置要注意:任务看门狗(TWDT)和中断看门狗(IWDT)的超时时间要大于自检最长耗时。我试过把 TWDT 设成 10 秒,结果 WiFi 连接花了 12 秒,设备直接复位,回滚逻辑根本没机会执行。
5. OTA 升级全流程与双分区切换实录
5.1 从服务器下载固件到写入空闲分区
OTA 升级的第一步是获取新固件。常见方式有 HTTP(S) 下载、MQTT 推送、蓝牙传输。无论哪种方式,最终都是拿到一个 bin 文件流,然后通过esp_ota_begin、esp_ota_write、esp_ota_end写入空闲分区。
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); esp_ota_handle_t update_handle; esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); while (data_available) { esp_ota_write(update_handle, data_chunk, chunk_len); } esp_ota_end(update_handle); esp_ota_set_boot_partition(update_partition); esp_restart();这段代码的关键是esp_ota_get_next_update_partition(NULL),它会自动返回当前未运行的那个 app 分区。比如当前跑在app0,它就返回app1。写入完成后esp_ota_set_boot_partition修改otadata,下次重启就从新分区启动。
我实测过写入过程中断电的情况:因为写入的是空闲分区,当前运行的固件不受影响,重启后依然从旧分区启动,设备功能正常。这就是双分区最大的价值——升级失败不影响现有功能。
5.2 升级后的首次启动与状态验证
新固件首次启动时,bootloader 会检查otadata里的状态。如果是PENDING_VERIFY,就会给应用程序一次“自证清白”的机会。这时候串口日志里会看到类似ota_0: PENDING_VERIFY的输出。
应用程序自检通过后调用esp_ota_mark_app_valid_cancel_rollback(),otadata状态变为VALID。之后每次启动都直接进入正常流程,不再有回滚判定。
如果自检失败,调用esp_ota_mark_app_invalid_rollback_and_reboot(),otadata状态变为INVALID,bootloader 下次启动时切回旧分区,并把旧分区的状态恢复为VALID。整个过程在几百毫秒内完成,用户几乎无感。
5.3 回滚后的现场恢复与日志记录
回滚发生后,设备跑的是旧固件,但otadata里会留下这次失败的记录。我通常会在旧固件启动时读取esp_ota_get_state_partition,如果发现曾经有回滚发生,就把事件写入 NVS 或上传到服务器,方便后续分析。
esp_ota_img_states_t state; if (esp_ota_get_state_partition(running, &state) == ESP_OK) { if (state == ESP_OTA_IMG_INVALID || state == ESP_OTA_IMG_ABORTED) { nvs_save_rollback_event(); } }这个日志非常有用。我曾经遇到一批设备反复回滚,最后通过日志发现是新固件里某个传感器初始化超时,导致自检永远不通过。如果没有这个记录,根本无从查起。
6. 常见问题与排查技巧实录
6.1 设备反复重启但版本号不变
这是最典型的回滚现象。原因通常是新固件自检失败,bootloader 自动切回旧分区。排查步骤:
- 串口抓取完整启动日志,看是否有
PENDING_VERIFY和rollback关键字。 - 检查自检逻辑是否覆盖了所有关键初始化。
- 确认
esp_ota_mark_app_valid_cancel_rollback()是否被调用。 - 检查看门狗超时是否过短。
我遇到过一次是因为 WiFi 连接在自检时失败,但实际网络是好的,只是路由器响应慢。后来把自检改成异步,先标记有效再继续连接,问题解决。
6.2 分区表编译报错或烧录后不生效
常见原因有:CSV 格式错误、偏移重叠、大小不是 0x1000 的整数倍、没有执行 fullclean。排查时先用idf.py partition-table查看生成的二进制,再用esptool.py read_flash读回设备实际分区表对比。
注意:Arduino IDE 下修改分区表后,有时需要手动删除
build目录,否则缓存会导致旧分区表被烧录。
6.3 OTA 写入过程中断导致设备无响应
如果写入的是空闲分区,中断不会影响当前运行。但如果写入过程中恰好遇到otadata更新,而这时断电,可能导致otadata状态不一致。ESP32 的otadata设计有两个扇区,轮流写入,就是为了防止这种情况。实测中,即使写入otadata时断电,bootloader 也能从另一个扇区恢复。
不过为了保险,我通常会在 OTA 开始前先擦除目标分区,写入完成后再更新otadata。这样即使中途失败,otadata依然指向旧分区。
6.4 自动回滚没有触发
检查menuconfig里的CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE是否开启。这个选项默认可能是关闭的。另外,如果应用程序在启动后立刻调用了esp_ota_mark_app_valid_cancel_rollback(),回滚机制就不会有机会介入。
还有一种情况:设备启动后直接进入 deep sleep,没有执行任何标记逻辑。这时候 bootloader 会在下次唤醒时判定为失败并回滚。如果这不是你想要的,需要在 deep sleep 前完成标记。
7. 几个我踩过的坑和实测建议
第一个坑是分区表偏移写错。我曾经把app1的偏移写成0x150000,但app0大小是0x140000,算下来app0结束于0x150000,刚好衔接。但如果app0大小改成0x160000,就会重叠,编译直接报错。所以每次改大小都要重新算偏移。
第二个坑是自检逻辑太严格。有一次我把 MQTT 连接成功作为自检必要条件,结果服务器维护期间所有设备升级后全部回滚。后来改成“网络可用即可”,MQTT 异步重连,问题消失。
第三个坑是看门狗和 OTA 写入冲突。OTA 写入大文件时耗时较长,如果看门狗超时太短,写入过程中设备复位,导致升级失败。解决办法是在 OTA 写入任务里定期喂狗,或者临时延长看门狗超时。
实测下来,最稳的方案是:双分区 + 自检标记 + 主动回滚 + 日志记录。这套组合在我自己的几个项目中跑了两年多,经历过多次远程升级,没有出现过设备彻底失联的情况。即使新固件有问题,设备也能自动回到旧版本,等待下一次修复后的升级。
最后分享一个小技巧:在otadata分区之外,我还会在 NVS 里存一个“升级尝试计数器”。如果连续三次升级都回滚,就停止自动升级,等待人工介入。这个计数器用nvs_set_u8和nvs_get_u8就能实现,几行代码的事,但能避免设备陷入无限升级-回滚循环。