1. 先讲一个反直觉的事实:ESP32 其实很难真正“变砖”
先说结论:绝大多数你听说的“刷固件变砖”,在 ESP32 上根本不是永久性损坏,而是因为引导程序找不到可用的应用程序分区,导致芯片不断重启或者停在烧录模式。换句话说,你手里那块板子大概率还是好的,只是你把它“搞糊涂”了,不知道接下来该执行哪一份固件。
为什么会这样?这就要从 ESP32 的启动方式说起。芯片上电后,CPU 第一件事是执行固化在只读存储器(ROM)里的第一级引导程序,这部分出厂时写好,用户改不了,也没法被擦除。它负责检查 eFuse 和 flash 头部的启动信息,然后跳转到 flash 里的 bootloader,也就是第二级引导程序。bootloader 再根据分区表(partition table)去找到对应的 app 分区,把应用固件加载到内存里跑起来。
所以“变砖”这个概念,在 ESP32 上分为三种程度:
- 假砖(最常见):app 分区里的固件本身损坏、不完整,或者分区表被清空/写错,bootloader 找不到可用应用,芯片会反复重启。但这种情况下,ROM 引导程序依然存活,你可以通过串口重新烧录,用 ESP32 自带的下载模式(按住 BOOT 键再上电)就能救回来。
- 半砖:bootloader 被刷坏,或者 flash 的启动参数被改错,芯片无法进入正常的串口下载模式。这种情况稍微麻烦一点,但不致命,通常需要用 esptool 手动擦除整个 flash 再重新烧写。
- 真砖(极其罕见):flash 芯片物理损坏,或者关键 eFuse 被错误烧断。比如你把某些安全相关的 eFuse 永久锁死,导致芯片拒绝启动任何未签名的固件,这种才接近“救不回来”。
我在项目里采用双分区和自动回滚的方案,本质上就是把“假砖”变成“可以自己恢复的普通故障”。你不需要拆芯片、不需要外接编程器,甚至不需要打开电脑重新烧录,板子自己就能回到上一个能用的版本。
2. 双分区到底分的是什么:一张分区表把固件分成“主用”和“备用”
双分区在 ESP32 上不是什么神秘技巧,而是在分区表里同时安排两个可以存放应用固件的区域,分别叫ota_0和ota_1。芯片启动时,bootloader 会读取一个特殊的数据区(通常叫 otadata),里面记录了“当前应该从哪个分区启动”以及“上一次启动是否成功”。
2.1 先看懂 ESP32 的分区表结构
ESP32 的 flash 默认是 4MB,分区表是一个定义了 flash 每个区域用途的清单。一个典型的分区表长这样:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1C0000, app1, app, ota_1, 0x1D0000, 0x1C0000, spiffs, data, spiffs, 0x390000, 0x70000,这里的关键信息:
- nvs:非易失性存储区,用于保存 WiFi 配置、校准数据等键值对,刷固件时一般不会动它。
- otadata:只有 8KB,但它记录了 ota_0 和 ota_1 两个分区的状态,包括当前选中的分区、分区里的固件是否通过了完整性校验。
- app0 和 app1:两个大小相同的应用分区。它们前面的 Type 是
app,SubType 是ota_0和ota_1,这两个 SubType 是 ESP32 启动流程识别“这里可以放固件”的标记。 - spiffs:文件系统区域,用来存网页、配置、日志这类数据。
当你在 Arduino IDE 里选择“Partition Scheme: Huge APP (3MB No OTA/1MB SPIFFS)”时,实际上生成的分区表里根本没有ota_0和ota_1,只有一个factory分区。这种方案的应用分区只有一个,固件坏了只能重新串口烧录,不存在回滚的可能。
而选择“Partition Scheme: Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)”时,Arduino 默认会使用带 OTA 的分区表,也就是会生成ota_0和ota_1两个应用区。这就是为什么我强烈建议:哪怕你现在不做 OTA 空中升级,也尽量选带 OTA 的分区方案。因为双分区不只是为了远程升级,更是给固件上了一道“保险”。
2.2 启动流程:bootloader 怎么决定跑哪个固件
芯片启动时,bootloader 会先读 otadata 区域,判断逻辑大致是这样的:
- otadata 里如果记录了“当前启动分区是 ota_0,状态是 pending verify”,就说明上一次选中的是 ota_0 里的固件,但还没确认它工作正常。
- 如果状态是“confirmed”,说明这个固件已经跑过一段时间,确认没毛病,下次继续用它。
- 如果 bootloader 发现当前选中的分区里的固件校验失败(比如 CRC 不对、固件头不合法),它会自动切换到另一个分区。如果两个分区都失败,才会真的卡住,等你去串口救砖。
这套逻辑给了我们一个非常重要的空间:固件启动失败不等于死机,bootloader 有机会在 app 真正跑起来之前帮你“换一个试试”。而我们需要做的,就是让 app 在启动后尽快告诉系统“我还活着”,把 otadata 里的状态从 pending verify 改成 confirmed。
2.3 双分区和 OTA 升级的关系
双分区最常见的应用场景是 OTA 升级。新固件下载后,通常写入当前没有在运行的另一个分区,写完之后把 otadata 指向新分区,然后重启。如果新分区有问题,bootloader 会自动回退到旧分区。这比“直接在原分区上覆盖写”安全得多,因为覆盖写一旦中途断电,旧的也没了,新的又不完整,那才是真的灾难。
而在我的做法里,双分区还有一个额外用途:本地烧录时,也可以主动把新固件写到备用分区,而不是覆盖当前正在用的分区。这样即使新固件有 bug,旧固件依然完好地躺在另一个分区里,随时可以回切。
3. 自动回滚的实现:让板子在“开不了机”和“自动恢复”之间找到平衡
有了双分区,下一步就是让系统具备“自动回滚”能力。ESP-IDF 原生支持一套回滚机制,Arduino 环境则要稍微手动一点。但它们的核心思路是一样的:新固件启动后,必须有一个“确认”动作;如果在规定时间内没有确认,系统就认为新固件有问题,重启后自动回到旧固件。
3.1 ESP-IDF 下的标准做法
如果你用 ESP-IDF 开发,回滚机制是框架自带的。用到的 API 集中在esp_ota_ops.h里,关键函数有三个:
esp_ota_mark_app_valid_cancel_rollback():标记当前固件为“验证通过”。调用后,otadata 会写入 confirmed 状态,之后不会再自动回滚。esp_ota_mark_app_invalid_rollback_and_reboot():把当前固件标记为“无效”,然后立刻重启,bootloader 会切换到另一个分区。esp_ota_get_running_partition():获取当前正在运行的分区信息,用来确认自己是从哪个分区启动的。
一个基本安全的启动流程是这样的:
#include "esp_ota_ops.h" void app_main(void) { // 1. 先拿到当前运行的分区 const esp_partition_t *running = esp_ota_get_running_partition(); ESP_LOGI(TAG, "Running from %s", running->label); // 2. 初始化外设、WiFi、传感器等 init_hardware(); // 3. 关键:等系统稳定运行一小段时间后再确认 vTaskDelay(pdMS_TO_TICKS(10000)); // 4. 确认当前固件有效 esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); if (err == ESP_OK) { ESP_LOGI(TAG, "Firmware marked as valid"); } else { ESP_LOGE(TAG, "Failed to mark as valid: %s", esp_err_to_name(err)); } }这里有个细节值得展开:不要一启动就立刻确认“固件有效”。如果硬件初始化还没完成,或者核心传感器数据异常,你就把它标记成 confirmed,那后面出问题时就失去了回滚机会。我习惯的做法是:
- 初始化基础外设。
- 延时 5 到 10 秒,让系统跑一跑,确认没有死锁、没有反复崩溃。
- 检查关键传感器/网络的初始状态,比如 WiFi 是否连上、ADC 读数是否在合理范围。
- 全部通过后再调用
esp_ota_mark_app_valid_cancel_rollback()。
如果第 2、3 步里发现异常,就调用esp_ota_mark_app_invalid_rollback_and_reboot()主动回滚。这样相当于给了自己一个“观察期”,比单纯靠 bootloader 的被动校验可靠得多。
3.2 Arduino 环境下的轻量级回滚思路
Arduino 环境没有直接暴露 ESP-IDF 那套 OTA 确认 API,但也可以通过操作 otadata 实现类似效果。比较常见的思路是:用Preferences库在 nvs 里保存一个标志位,每次启动时用这个标志位判断“上次固件是否正常退出”。
实现逻辑如下:
#include <Preferences.h> #include <esp_ota_ops.h> Preferences prefs; void setup() { Serial.begin(115200); // 打开 nvs 里的“启动计数”键值对 prefs.begin("fw_check", false); int bootCount = prefs.getInt("boot_count", 0); if (bootCount > 3) { // 如果启动计数超过 3 次,说明每次开机都在中途崩溃,回滚 Serial.println("Too many failed boots, rollback!"); esp_ota_mark_app_invalid_rollback_and_reboot(); } // 硬件初始化 initHardware(); // 运行一段时间后,确认固件正常,重置计数 delay(10000); prefs.putInt("boot_count", 0); // 如果走到这里说明系统稳定,把当前分区标记为 valid const esp_partition_t *running = esp_ota_get_running_partition(); if (running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_0 || running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1) { esp_ota_mark_app_valid_cancel_rollback(); } } void loop() { // 正常业务逻辑 }还有一个更简单的方案:在启动时开启看门狗定时器,业务代码跑通之后再喂狗。如果新固件在启动阶段卡死,看门狗会在超时后强制重启。但看门狗只能帮你重启,不能帮你“切回旧固件”。所以最可靠的做法还是“启动计数 + 主动回滚”的组合:
- 每次上电,boot_count 加 1。
- 系统跑满 10 秒且业务自检通过,把 boot_count 清零。
- 如果 boot_count 连续几次都超过阈值,说明这个固件每次都在验证成功前就崩溃了,调用回滚函数切回旧固件。
这个做法的好处是:即使你的新固件引发的崩溃发生在非常早的阶段,连 OTA 确认函数都没执行到,也能通过连续多次重启识别出“这个固件有问题”。坏处是 nvs 的写入次数是有限寿命的,频繁重启会加速 flash 磨损。不过一般开发板上这点写入量完全不用担心。
3.3 OTA 下载过程中的回滚联动
如果你不只是本地刷写,还要支持 OTA 远程升级,那回滚逻辑要跟下载流程联动。在 IDF 的 OTA 示例native_ota_example基础上,我会额外做两件事:
第一,下载固件前先检查新版本的版本号,如果新版本号低于当前运行版本,直接拒绝写入。这个判断要在业务层做,不是 bootloader 能代劳的。
第二,下载过程中每写一块校验一块。esp_ota_write写入完后,用 OTA 数据里的 digest 做比对。虽然 esp_image_format 在启动时也会做校验,但提前发现坏块能省掉一次无谓的重启。
完整流程大概是这样:
// 伪代码:带回滚的 OTA 升级 esp_ota_handle_t ota_handle; const esp_partition_t *update_part = esp_ota_get_next_update_partition(NULL); esp_ota_begin(update_part, OTA_SIZE_UNKNOWN, &ota_handle); // 分块从服务器读取固件,每读 4KB 写一次 while ((len = http_read(buf, sizeof(buf))) > 0) { esp_ota_write(ota_handle, buf, len); } esp_ota_end(ota_handle); // 设置新分区为待验证启动 esp_ota_set_boot_partition(update_part); // 重启 esp_restart();重启后,如果新固件 10 秒内没确认有效,bootloader 会切换回旧分区。这个方案下,远程升级失败也只是“多重启一次”,用户几乎感知不到。
4. 实测:我故意把固件刷坏,看它怎么自救
理论说得再多,不如亲手制造一场事故。我在实验室里用一块 ESP32-WROOM-32 开发板做了三组实验,分别验证“app 分区损坏”“运行中崩溃”“升级失败”三种情况下的回滚表现。
4.1 实验一:直接把 ota_0 分区写满垃圾数据
先烧录一个版本号 1.0 的正常固件到 ota_0,确认它能跑。然后用 esptool 直接往 ota_0 分区写入无意义的随机数据,模拟“固件刷到一半断电”或者“固件文件损坏”:
# 先擦除 ota_0 分区(地址 0x10000,大小 0x1C0000) esptool.py --port /dev/cu.usbserial-0001 erase_region 0x10000 0x1C0000然后给板子重新上电。观察串口日志,bootloader 打印了类似这样的信息:
I (30) boot: OTA[0] partition selected E (35) esp_image: image at 0x10000 has invalid magic byte (nothing) E (41) boot: OTA image 0 invalid I (46) boot: OTA[1] partition selected I (50) boot: Loading app partition at offset 0x1D0000 I (54) boot: Image loaded, jumps to run注意看:bootloader 发现 ota_0 里的镜像文件头魔术字节不对,直接判定这个分区失效,然后自动跳到 ota_1 启动。整个过程不需要任何代码干预,也不用人工按键。这就是分区表 SubType 设为ota_x带来的硬件级保护。
4.2 实验二:新固件能启动但在第 5 秒崩溃
这次我在 ota_1 里烧录一个故意崩溃的固件——初始化完 WiFi 之后强制进入死循环并触发看门狗复位。otadata 里没有标记 valid,所以每次启动都是 pending verify 状态。
观察到的现象是:新固件启动、崩溃、重启,如此反复。在第 4 次重启时,bootloader 的日志出现了:
W (123) boot: OTA[1] app is not valid, rollback to OTA[0] I (127) boot: OTA[0] partition selected也就是说,ESP32 在一段时间内检测到 ota_1 的固件始终没有被确认有效,自动回退到了 ota_0。这个过程完全自动,我只做了上电操作。这验证了 bootloader 层的“被动回滚”机制是有效的。
4.3 实验三:OTA 下载新固件后无法正常启动
第三个实验模拟真实场景:设备当前运行 ota_0 的 1.0 版本,通过网络下载了 2.0 版本到 ota_1 并设置为启动分区。2.0 版本固件在初始化时读取一个不存在的配置文件,导致空指针异常,启动后立即重启。
由于我在 2.0 版本代码里加入了前面说的“启动计数 + 延时确认”逻辑,虽然 app 崩溃太快,计数逻辑没来得及清零 boot_count,但在连续复位三次后,系统通过esp_ota_mark_app_invalid_rollback_and_reboot()主动切回了 ota_0。之后我特意在串口里加了区分日志:
[1.0] App started, rollback protection armed [2.0] App started but config missing, panic... [2.0] App started but config missing, panic... [2.0] App started but config missing, panic... [1.0] App started, rollback protection armed看到最后一行回到 1.0,我就知道这套方案经受住了实测。1.0 固件在启动时发现了 nvs 里的回滚标记,还额外打印了一行提示。整台设备“自愈”完成的耗时在 15 秒以内,用户在手机上只会看到设备短暂离线,然后恢复正常。
4.4 一个需要警惕的例外:分区表被破坏
双分区和自动回滚能覆盖“app 固件损坏”的大部分场景,但有一个盲区:如果你刷坏了 bootloader 或者分区表本身,双分区方案也无能为力。比如你把 flash 0x1000 处的内容覆盖了,bootloader 没了,芯片根本走不到“读取分区表、对比 ota_0 / ota_1”这一步。
这种情况就是我开头说的“半砖”等级,需要串口进入下载模式重新烧录 bootloader。以下命令可以恢复:
esptool.py --port /dev/cu.usbserial-0001 write_flash 0x1000 bootloader.bin esptool.py --port /dev/cu.usbserial-0001 write_flash 0x8000 partition-table.bin esptool.py --port /dev/cu.usbserial-0001 write_flash 0xe000 otadata.bin只要 flash 芯片物理上没问题,这套组合基本能救回来。所以我在项目里专门备份了一份 bootloader 和 partition-table 的 bin 文件,放在固定目录,就是防止哪天手滑把整个 flash 清了。
5. 踩过的坑:回滚不生效的 6 个原因
说实话,双分区 + 自动回滚方案我第一次跑通时根本没有想象中顺利,前后折腾了两三天。问题不在于机制本身,而在于对 ESP32 启动流程的理解有几个盲区。下面这些坑我都实际踩过,按严重程度排个序。
5.1 分区表必须重新烧写,否则固件写不进预期位置
如果你原本用的是“No OTA”分区的项目,后来改成了带 OTA 的分区表,只重新编译烧录 app 固件是不够的。Arduino IDE 通常会自动帮你烧分区表,但如果你用了自定义烧写脚本,很可能漏掉partition-table.bin。
后果是:app 固件被写到了 flash 里一个新的偏移地址,但 flash 里旧的分区表还在,bootloader 按旧表去加载,结果读到的是无效数据,表现为“刷了新固件后反复重启,而且串口烧录也时好时坏”。
解决办法:在烧写命令里显式加上分区表文件,或者干脆用esptool.py erase_flash把整个 flash 擦干净,再一次性烧写 bootloader、分区表、otadata、app。虽然粗暴,但能排除一切残留问题。
5.2 Arduino 默认分区表里“app0”的长度限制
我第一次在 Arduino 里启用 OTA 分区时,没用默认分区分表,而是自己手工写了一张很小的分区表,只给ota_0和ota_1各分配了 1MB。结果我的固件编译出来 1.1MB,烧写时直接报“Image size exceeds partition size”。
这种问题不算回滚失效,但也容易在测试时造成误判:你会以为新固件刷坏了,其实是编译产物根本塞不进预留分区。最好在工程里加一个编译后检查,比如用脚本读取生成的.bin文件大小,跟分区表里对应分区大小做比较,超了就中止烧写。
5.3 nvs 标志位和 otadata 的确认逻辑互相干扰
有一版代码,我在“启动计数”里用的Preferences键名和另一个功能模块冲突了,导致每次启动时计数被意外重置,回滚永远触发不了。排查了很久才发现,原来是两个库用了同一个 nvs 命名空间(namespace),互相覆盖了键值。
建议:专门给回滚逻辑分配一个独立的 nvs 命名空间,比如prefs.begin("otarollback", false);,不要和 WiFi 配置、用户设置混在一起。这样可以避免键名冲突,也方便以后统一清理。
5.4 在确认有效之前断电,导致每次开机都回滚
这个坑比较隐蔽。我在实验里发现:固件明明没毛病,但每次开机都会回到旧版本,压根进不到新固件。原因是“确认有效”的调用放在了业务逻辑的最末尾,而设备启动后很快就进入休眠,根本没等到确认函数执行,电就断了。
也就是说,新固件一直处于“待验证”状态,下次上电 bootloader 就认为它没通过验证,自动切回旧分区。解决方式有两种:
- 把
esp_ota_mark_app_valid_cancel_rollback()提前到 setup 早期,比如外设初始化完成后立刻调用。 - 如果确实需要更多启动时间,可以在确认之前用
esp_ota_mark_app_valid_cancel_rollback()先做一个“临时确认”,然后再在程序末尾做一次彻底确认。
我最终采用的是“延迟 10 秒后确认”,既给了系统稳定期,又不会因为休眠时序错过确认窗口。
5.5 两个分区的固件版本相同,回滚后看不出效果
测试回滚时,如果你两个分区烧的是同一个版本号的固件,即使回滚成功,你也很难从日志里判断到底跑的是哪个分区。所以我在固件里特意打印当前运行分区的 label 和自定义版本号。
const esp_partition_t *running = esp_ota_get_running_partition(); Serial.printf("Running from: %s, version: %s\n", running->label, FIRMWARE_VERSION);有了这两行输出,回滚是否生效一眼就能判断。
5.6 bootloader 版本太旧,不支持自动回滚
ESP32 早期的 bootloader 对 OTA 回滚的支持并不完善,尤其是一些出厂就带旧 bootloader 的模组。如果你发现 bootloader 日志里没有出现rollback相关的字段,很可能是 bootloader 太老,不认识 otadata 里的 pending verify 状态。
解决方法是升级 bootloader。最简单的方式是用较新版本的 ESP-IDF 重新编译一次整个工程,Arduino 在烧录时也会同时烧录配套的 bootloader。不建议手工单独下载 bootloader 刷写,因为不同芯片版本(ESP32、ESP32-S2、ESP32-C3)和 flash 模式(DIO、QIO)对应的 bootloader 不通用。
6. 双分区之外的最后一层防线:保留串口救砖通道
双分区和自动回滚解决的是“软件层面自动恢复”,但工程上永远要有“人工干预”的兜底方案。我所有的 ESP32 项目板上都保留了一组串口排针,并且强制规定:任何情况下都不能在代码里禁用 ROM 下载模式。
ESP32 的 ROM 下载模式是通过上电时 BOOT 引脚的电平决定的。如果 BOOT 引脚被外部电路拉高或者被代码配置为输出模式,可能导致无法进入下载模式,这时候真砖的概率就大大增加了。所以设计电路时,BOOT 引脚上要加一个上拉电阻,并且留出按键到 GND 的路径。软件层面,不要随意把 GPIO0 配置成其他功能后再断电——因为下一次上电时它必须保持高电平才能正常启动,低电平则会进入下载模式。
还有一个细节:串口烧录线的质量直接决定了救砖的成功率。我吃过不少亏,后来固定使用带 CP2102 芯片的 USB-TTL,接线尽量短,波特率烧录时降为 115200,不追求快,稳定第一。救砖场景下,一次成功比十次快速失败更有意义。
7. 我用这套方案后的真实体会
项目上线到现在,我通过 OTA 远程升级过十几轮固件,期间故意注入过三次故障(模拟配置错误、外设初始化失败、固件文件传输中途中断),双分区 + 自动回滚每次都兜住了,没有一次需要用户手动返厂或者重新烧录。
如果要总结一条最重要的经验,那就是:回滚保护不是 OTA 专属功能,哪怕你所有固件都靠串口烧录,也应该把双分区作为默认配置。它相当于给你的“手滑”上了保险,成本只是分区表里多划出一块区域,换来的是刷机时的心理安全感和实际可恢复性。
对我来说,ESP32 的“变砖恐惧”在真正理解启动流程之后基本就消失了。芯片本身有足够的冗余设计,我们要做的不是小心翼翼避免出错,而是把可能出错的地方都变成可以自动恢复的路径。希望这套实践思路能帮你少走点弯路。