玩嵌入式这些年,周围问我最多的问题之一就是:"ESP32 固件刷坏了会不会变砖?"每次群里新入坑的朋友刷完一个固件,WiFi连不上了、屏幕不亮了、串口没输出了,第一反应就是"完了,砖了"。其实这里面有个很大的误区。我自己手头有一批用了好几年的 ESP32 开发板,前后刷了不下一百次固件,至今还没有一块是真正永久报废的。这个标题里提到的"双分区"和"自动回滚",恰恰是我在经历了几次"刷完新固件直接起不来"之后,从 OTA 机制里挖出来的自救方案。这篇文章就把这套东西掰开揉碎讲清楚,顺便说说 ESP32 在"变砖"这件事上真正的底线在哪里。
1. 先别慌:ESP32 的"变砖"到底是怎么回事
1.1 普通 APP 固件刷坏,其实根本不会砖
先说结论:如果你只是烧录 APP 分区里的固件,哪怕这个固件是坏的、崩溃的、起不来的,ESP32 也几乎不可能因此永久变砖。原因是 ESP32 的芯片内部 ROM 里有一段固化的一级 bootloader,这段代码出厂就写死在硅片上,你没法用常规手段把它擦掉。每次上电,芯片先执行 ROM 里的引导代码,再去外部 Flash 的 0x1000 偏移处加载二级 bootloader,然后才轮到你的应用程序。
也就是说,整个启动链是"ROM 引导 -> bootloader -> 分区表 -> APP 固件"。你刷坏了最后一环,前面的环节都还在。最多就是启动循环、卡在 bootloader、串口打印一些看不懂的报错,但这些都可以通过重新烧录恢复。真正麻烦的情况是改动了 bootloader 本身,或者把分区表烧错了位置,甚至误操作烧写了 eFuse——那个才叫接近"砖",后面展开说。
很多人所谓的"变砖",实际是"固件崩溃导致系统一直重启",或者"新固件里 WiFi 配置和原来不兼容,连不上网",这类问题在串口监视器里往往能看到重复的报错日志。而解决手段也简单:重新下载一个能正常跑的固件,用 esptool 或者 Arduino IDE 再烧一次就行。我实测过把某个 GPIO 配置写错导致上电即崩溃的固件,连续烧了好几次,每次都能覆盖回来,板子没有任何永久损伤。
1.2 什么情况下才可能真的救不回来
既然普通 APP 固件不会砖,那网上那些"变砖求助"帖子是怎么回事?复盘一下,大概率是以下几种操作:
第一,烧录时擦除了整个 Flash。比如用 esptool 执行了erase_flash,然后又没有正确烧回 bootloader 和分区表,板子上电后 ROM 引导找不到合法 bootloader,串口就没有任何输出了。这种情况算"半砖",状态严重但不是永久,只要连上串口重新烧录完整镜像就能活过来。
第二,烧录了错误的 bootloader 或改坏了分区表。bootloader 负责加载分区表和 APP,如果它本身损坏或者 Flash 的 SPI 模式设置不对(比如把 DIO 改成 QIO 但 Flash 实际不支持),芯片就会卡在启动早期,表现就是不断重启或者完全无响应。
第三,把 eFuse 里的安全位烧错了。比如开启了 secure boot 却丢失了签名密钥,或者烧写了禁用的下载模式 eFuse。这类改动不可逆,是真正的"物理级砖",常规操作救不回来。
双分区和自动回滚这套方案,解决的是前面说的"APP 固件崩溃"这类最常见的问题,不解决 bootloader 损坏或 eFuse 烧错这种极端情况。想清楚这一点,你才会知道这套机制的价值边界在哪里。
2. 双分区:为什么必须要有两个 APP 区
2.1 一个分区只能跑"单系统",两个分区才能有退路
ESP32 默认出厂的分区表里,通常会有factory、ota_0、ota_1这几个 APP 分区。如果你只用烧录器直接往 0x10000 偏移写一个固件,那确实只需要一个 APP 区就够。但一旦涉及 OTA 升级、远程更新、现场救砖,一个分区的问题就暴露了:新固件写入当前正在运行的分区,万一半途断电或者固件本身有问题,系统就找不到一个"之前能用"的旧版本了。
双分区的思路特别像电脑装双系统。一个分区放"当前已知能跑的旧版本",另一个分区放"准备验证的新版本"。当下次启动时,bootloader 根据分区表里记录的状态决定从哪个分区启动。如果新版本出问题,系统还能切回旧版本继续工作。这个机制在 ESP-IDF 里叫 OTA 机制,核心就是预留多个可启动的 APP 分区。
在 ESP32 的默认出厂分区表里,最常见的布局是:
| 分区名 | 类型 | 子类型 | 偏移地址 | 大小 |
|---|---|---|---|---|
| nvs | data | nvs | 0x9000 | 0x6000 |
| phy_init | data | phy | 0xf000 | 0x1000 |
| factory | app | factory | 0x10000 | 0x200000 |
| otadata | data | ota | 0x210000 | 0x2000 |
| ota_0 | app | ota_0 | 0x220000 | 0x200000 |
| ota_1 | app | ota_1 | 0x420000 | 0x200000 |
注意这里otadata分区,它存的是启动状态记录,而不是固件数据。bootloader 会读取这个分区里的信息,判断该从ota_0还是ota_1启动。有了这个结构,你在做固件升级时就有了完整的冗余。
2.2 分区表要自己写才有意义
很多人不知道,ESP-IDF 的分区表是可以用一个 CSV 文件自定义的。默认分区表里factory分区存在时,系统会优先从factory启动,直到你第一次执行 OTA 操作之后,才根据otadata的指示从 OTA 分区启动。如果你想从一开始就让设备在ota_0和ota_1之间来回切换,可以直接把factory分区去掉,让系统直接走 OTA 启动路径。
我自己的做法是直接去掉 factory,将 Flash 划成 nvs、phy_init、otadata、ota_0、ota_1 这几个区。这样做的好处是空间利用率更高,而且整个设备的运行逻辑从一开始就统一为"OTABoot 流程",不会出现"先跑 factory、再切 OTA"这种两套逻辑混用的情况。我推荐在项目初期就定好分区表,不要等项目做大了再改,因为分区表一变,之前烧录的固件和 NVS 数据的位置可能就全乱了,处理起来非常麻烦。
分区表自定义的 CSV 格式很简单,每一行就是"名称、类型、子类型、偏移、大小"。需要注意otadata分区的大小通常是 0x2000,而 APP 分区的大小必须按 0x10000(64KB)对齐,否则烧录工具会报错。我在 PlatformIO 里习惯把自定义分区表放到partitions.csv,然后在platformio.ini中通过board_build.partitions = partitions.csv指定,同时配置board_build.flash_size = 4MB,确保分区表不超过 Flash 容量。
很多朋友一开始会犯一个错:改了分区表之后,只重新编译烧录 APP 固件,没有烧录分区表本身。结果就是新代码里的偏移地址和 Flash 里实际的分区信息对不上,启动直接失败。尤其用 PlatformIO 时,当你修改了分区表 CSV,必须确保把新的分区表 bin 也写进 Flash,而不是只烧应用固件。
3. 自动回滚:让固件自己"反悔"
3.1 回滚的核心思想:新固件没自证清白,就退回旧版
有了双分区只是第一步,真正让系统"刷坏了也能自动恢复"的,是 ESP-IDF 里的 APP 回滚机制。它的设计非常朴素:一个新固件从 OTA 分区启动后,bootloader 并不会立刻认定它"可用",而是给它一个宽限期。在这个宽限期内,如果新固件主动调用esp_ota_mark_app_valid_cancel_rollback()来声明"我运行正常,可以转正",那系统就把当前分区标记为有效。如果新固件没有在超时时间内做出这个声明,bootloader 在下一次重启时就会自动切换回上一个可用的分区。
这个机制就像餐馆试菜。新菜端上桌之后,如果厨师没在限定时间内说"客人没投诉,这道菜可以保留",后厨就默认这道菜不行,下次直接换回原来的招牌菜。这样哪怕新固件刷进去之后连 WiFi 都连不上、屏幕点不亮,只要它没能跑到"标记有效"那一步,重启后设备就会自己回到旧固件,整体服务不中断。
回滚机制在 ESP-IDF 的 menuconfig 里默认是关闭的,你需要手动打开。关键配置项是CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE。打开这个选项之后,还需要配置CONFIG_BOOTLOADER_APP_ROLLBACK_TIMEOUT,这个值表示新固件启动后多少毫秒内必须完成"有效确认",默认是 3000 毫秒。我实际项目中一般会把这个时间调大一点,比如设成 5000 到 10000 毫秒,因为如果你的新固件启动时要做一些耗时的初始化,比如连接 WiFi、同步传感器、挂载文件系统,3 秒可能不够跑完整个确认流程。
3.2 有效确认的准确时机:不是越快越好
这里有一个非常关键的实操细节:esp_ota_mark_app_valid_cancel_rollback()并不能一进app_main就立刻调用。如果你把它放在系统初始化最开始的位置,那它只证明了"固件能启动",并不能证明"固件能正常工作"。我曾经就犯过这个错:把确认函数放在 GPIO 初始化的前一秒,结果那个版本固件有一个隐蔽 bug,运行到后面某个中断里会死循环,但因为我已经标记了有效,系统永远不会回滚,只能等我在现场手动刷回旧版本。
所以正确的做法是:把"有效确认"放在所有关键子系统初始化完成之后,最好再加上一轮自检。比如检查 WiFi 是否连上了、传感器是否能读到有效数据、内存分配是否正常。只有这些核心功能都过了,才调用esp_ota_mark_app_valid_cancel_rollback(),这时候才算真正确认新固件可用。
我还见过一种方案:把回滚确认做成一个"看门狗加心跳"的配合。系统启动后先不确认,而是启动一个定时任务,周期性地在某个标志位里写"我还活着"。当主逻辑跑满一定时长(比如 60 秒)没有问题,那个标志位才会触发最终确认。这种方式比单纯在初始化末尾确认更可靠,因为它覆盖了"初始化后运行到一半挂掉"的场景。这个思路对长周期运行的设备特别有价值,比如门口的人脸识别终端、农业大棚的采集节点,半夜出现问题必须能自己回滚而不是等你去现场。
4. 完整实操:从分区表到回滚代码的一次实战
4.1 用 ESP-IDF 搭建一个带自动回滚的最小工程
我常用的开发环境是 ESP-IDF,因为它的 OTA 和回滚支持最完整。Arduino 环境下虽然有Update库可以做 OTA,但要精细控制回滚确认和启动分区,还是 ESP-IDF 的原生 API 更顺手。下面用一个实际的最小工程来演示整个流程。
第一步是编写分区表。在项目根目录新建partitions.csv,内容如下:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, otadata, data, ota, 0x10000, 0x2000, app0, app, ota_0, 0x12000, 0x300000, app1, app, ota_1, 0x42000, 0x300000,这里我给 app0 和 app1 各分配了 3MB 空间,适合较大的固件,你也可以根据实际固件大小调整。注意otadata的偏移必须正确,通常放在 bootloader 和分区表之后、APP 区之前。还要在项目配置里指定使用这份分区表。在 ESP-IDF 里可以通过idf.py menuconfig,在 "Partition Table" 页面选择 "Custom partition table CSV",并填上文件名。在 PlatformIO 里则是修改platformio.ini:
[env:esp32dev] platform = espressif32 board = esp32dev framework = espidf board_build.partitions = partitions.csv board_build.flash_size = 4MB第二步是打开回滚配置。在 menuconfig 中进入Component config -> ESP System Settings -> Bootloader settings,找到Enable app rollback support并勾选。这个选项对应CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE。同时设置App rollback timeout为一个合理的毫秒数,比如 10000。如果你用 PlatformIO 且不想每次都进 menuconfig,可以在sdkconfig.defaults里直接写:
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y CONFIG_BOOTLOADER_APP_ROLLBACK_TIMEOUT=10000设置完记得要让配置生效,PlatformIO 会重新生成 sdkconfig。
4.2 写固件确认代码时最容易踩的坑
新建一个主程序main.c,逻辑很简单:上电后初始化关键外设,然后调用esp_ota_mark_app_valid_cancel_rollback()确认固件有效。
#include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_system.h" #include "esp_ota_ops.h" #include "nvs_flash.h" void app_main(void) { // 初始化 NVS,这是很多操作的前提 esp_err_t err = nvs_flash_init(); if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { nvs_flash_erase(); nvs_flash_init(); } // 模拟关键初始化,比如连接 WiFi、读取传感器等 vTaskDelay(pdMS_TO_TICKS(2000)); // 在所有关键初始化完成后再确认固件有效 esp_ota_mark_app_valid_cancel_rollback(); while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); } }注意,如果这个固件是从 factory 分区启动的,调用esp_ota_mark_app_valid_cancel_rollback()是无效的,因为回滚机制只作用于 OTA 分区。所以你需要先用 OTA 流程把固件写到某个 OTA 分区,或者从一开始就去掉 factory 分区,直接让设备从 OTA 分区启动。这也是我在前面强调"从项目初期就统一用 OTA 启动路径"的原因。
如果你需要在固件里主动触发回滚,可以调用esp_ota_mark_app_invalid_rollback_and_reboot()。这个函数会把当前分区标记为无效,然后立即重启,bootloader 会自动切到另一个可用的 OTA 分区。我在做远程诊断功能时,会在固件里保存一个诊断标记,如果连续几次启动都检测到同样的致命错误,就让当前固件主动宣布自己无效,从而回滚到上一个稳定版本。
4.3 OTA 升级流程和回滚机制的衔接
如果只是用串口烧录新固件,双分区的好处还体现得不明显。真正能完全发挥这套架构的,是配合 OTA 升级。ESP-IDF 提供了一套完整的 OTA API,核心流程是:先获取下一个可写的 OTA 分区,然后写入固件数据,写完后调用esp_ota_set_boot_partition()指定下次启动分区,最后重启。简化代码大概是:
#include "esp_ota_ops.h" #include "esp_http_client.h" const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); esp_ota_handle_t ota_handle; esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle); // 从网络或 SD 卡读取固件数据,逐段写入 esp_ota_write(ota_handle, data_buf, data_len); esp_ota_end(ota_handle); esp_ota_set_boot_partition(update_partition); esp_restart();其中esp_ota_get_next_update_partition(NULL)会自动选择一个"非当前启动"的 OTA 分区。比如当前从 app0 运行,这个函数就会给你 app1。写完后重启,bootloader 看到otadata里记录的新分区,就从 app1 启动。App1 启动后,如果一切正常就调用esp_ota_mark_app_valid_cancel_rollback()把自己转正。如果 App1 崩溃了,或者没有在超时时间内完成确认,bootloader 就会把启动目标重新指回 app0。
这里还要注意一个细节:固件包最好带上版本号,在 OTA 下载前先比较版本。不然可能出现"新固件已经回滚了,但服务器又推了一次旧固件"的升级抖动。我在一个项目里加了个简单的版本校验:本地保存app_version,云端下发固件之前先比对,如果版本号不高于当前版本就拒绝下载。这个小逻辑直接避免了大量无效升级和反复回滚。
5. 常见问题与排查实录
5.1 设备反复重启,但串口日志显示的是旧固件
这种问题我遇到过好几次。现象是:OTA 升级后设备反复重启,但通过串口日志看到打印的还是旧版本的启动信息。排查后发现,在menuconfig里打开了回滚支持,但新固件运行后没有调用esp_ota_mark_app_valid_cancel_rollback(),所以每次启动后的超时时间一到,bootloader 就切回旧分区。由于新旧固件打印的日志很相似,光是看串口很难第一时间判断到底跑的是哪个分区。
解决办法:给固件加一个醒目的启动横幅,打印当前固件版本号和启动分区。用esp_ota_get_running_partition()可以拿到当前运行分区,把分区名和标签打出来,启动时一眼就能分辨。我在所有工程里都会加这行日志,排查问题效率提升非常明显。
5.2 打开了回滚支持,但设备还是变"砖"了
回想一下你烧录的是不是只烧了 APP 分区?如果在烧录工具里只勾选了factory.bin或app.bin,而没有烧录分区表和 bootloader,那么otadata里的信息可能是旧的。比如你从 factory 启动,烧了一次固件到 OTA0,但分区表还是旧版,启动流程就会混乱。
更常见的场景是:用 esptool 全量烧录时,把开发板的整个 Flash 都覆盖了,但写入的内容里不包含正确的 otadata 初始化数据。新出厂 Flash 的 otadata 是空的,bootloader 会花更多时间处理,有时表现为启动慢或者反复重启。这种问题不算砖,重新烧录一份完整镜像就能解决。
5.3 串口无输出,怎么判断还有没有救
这个场景最容易让人误判为"变砖"。如果你按住开发板上的 BOOT 按键再上电,并执行esptool.py --port COMx read_mac,能读到 MAC 地址,说明芯片和串口通信正常,ROM bootloader 还活着。这种情况下就能通过串口重新烧录救回。如果串口完全无响应,先检查驱动有没有装好、USB 线是不是只能供电不能传数据,再考虑芯片供电是否正常。很多时候"砖"其实是线的问题。
我自己的救砖经验是:准备一根质量好的 USB 线,把波特率设置成 115200,接好串口后按住 BOOT 上电,然后执行:
esptool.py --port COMx erase_flash擦除后重新烧录完整的 bootloader、分区表和 APP 固件。多数开发板到这个步骤就活过来了。注意,如果你之前开启了 flash 加密或者 secure boot,不要轻易 erase_flash,否则可能触发安全机制导致无法启动。
5.4 自动回滚本身可能引入的问题
回滚机制虽然好用,但它并不是没有代价。我在项目中发现,如果设备在短时间内频繁升级和回滚,otadata里的回滚计数会不断增加,某些 ESP-IDF 版本对回滚次数有内部限制,超过一定次数后会强制回滚到 factory 或直接停止启动。这个机制是为了防止设备陷入"升级 - 回滚 - 再升级 - 再回滚"的死循环,但在开发阶段会让人莫名其妙。
开发调试阶段,我建议在固定好一个稳定固件之后,通过idf.py erase-flash清一次整个 Flash,让 otadata 重新归零。量产设备则要精心设计一个"升级放弃策略":比如连续两次回滚到同一个旧版本之后,暂时屏蔽云端推送的自动升级,等人工介入判断后再恢复。我给一台农田气象站做方案时就是这么处理的,现场没人盯着它,设备白天晚上都在跑,如果因为新版固件一个 bug 导致它每隔几分钟就升级回滚一次,耗电量和日志都会变得非常难看。
最后分享一个我实测很依赖的小经验
不管你的双分区和回滚逻辑设计得多完美,开发阶段一定要把串口日志保留下来。CONFIG_BOOTLOADER_LOG_LEVEL可以调高 bootloader 阶段的日志级别,这样回滚原因、启动分区的切换历史都会在开机时打出来。上线之后再把日志级别调回 Info 甚至 Error,减少不必要的 IO 开销。这套组合我用了很久,从调 prototype 到维护生产设备都靠它撑场子。嵌入式开发这东西,不怕代码写得糙,就怕出问题的时候不知道系统是怎么走到那一步的。回滚机制给了设备一条"退路",而清晰的日志给了你一条"回溯的路",这两条路都通了,现场问题至少能解决一半。