
ESP32 这芯片玩了这么多年被问得最多的一个问题就是“固件刷坏了会不会变砖”。每次听到这个问题我都想说你先别慌砖的方式有很多种而且大部分“砖”其实都救得回来。更关键的是如果你在一开始设计固件更新方案时就用上双分区加自动回滚连“逻辑砖”都能在很大程度上避免。这篇文章我就把这件事彻底讲清楚从启动链路说起到分区设计、OTA状态机、代码层面的自检回滚最后把容易踩的坑也一并列出来。这既是填坑帖也算是我近期一个项目的完整复盘。1. 先说结论ESP32 到底会不会变砖直接回答硬件层几乎不会变砖但你完全有可能把它搞成一个“逻辑砖”——表现为上电后不断重启、日志刷屏但不进系统或者一直卡在下载模式不跑固件。这两件事必须区分开。ESP32 的启动流程是 ROM Bootloader → 二级 Bootloader → App 固件。ROM Bootloader 出厂时就被乐鑫固化在芯片里普通用户没法擦掉它负责最底层的串口下载、Flash 读取和引导。这意味着只要芯片本身没坏、Flash 硬件没坏你始终可以用 UART 或者 USB 转串口工具通过 ROM Bootloader 重新烧录哪怕已经“刷坏”的固件。换句话说只要你能把开发板用串口连上电脑出现“串口打印正常、能进入下载模式”的反馈那这块板子就死不了。但问题在于很多人所说的“变砖”其实是另一种体验固件刷到一半断电、刷了不匹配的固件、或者分区表动错了导致上电后要么反复重启要么干脆没日志。这种状态我称它为“逻辑砖”——物理上没死但如果你不知道烧录方法和恢复流程手上又恰恰没有串口工具那它跟砖也差不多的待遇。所以对“ES32 会变砖吗”这个问题的完整回答是芯片硬件层有保护但它不会阻止你把自己锁在一个错误的状态里。要想真正避免“逻辑砖”核心手段就是分区冗余和启动自恢复也就是文章标题里的双分区和自动回滚。2. 双分区方案让系统里永远有一个“可用固件”2.1 双分区的本质是什么双分区是从计算机裸机恢复思路借鉴来的。你在电脑上装系统通常不会只有一份引导文件总得有恢复分区、备份镜像。ESP32 的分区表机制也差不多你可以在 Flash 里同时放两份应用程序固件一份作为当前运行版本一份作为备用回退版本。从分区表层面看ESP-IDF 默认支持三种应用分区分区类型分区名作用appfactory出厂固件通常作为初始版本不参与OTA轮换appota_0OTA 通道0可运行可回滚用于存放新版本appota_1OTA 通道1与 ota_0 互为备份如果只烧录 factory 分区那你的设备就只有一套固件升级时必须直接覆盖。一旦新固件有问题没有回退来源。而如果你像下面这样设计分区表每次 OTA 升级新固件写进空闲的某个 OTA 分区另一个分区始终保持上一次能正常运行的版本系统就永远存在可回退路径。2.2 一个可直接抄作业的分区表以 4MB Flash 为例我常用的分区表是这样写的# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x200000, ota_0, app, ota_0, 0x210000, 0x200000, ota_1, app, ota_1, 0x410000, 0x200000,这里 factory、ota_0、ota_1 各占 2MB这个尺寸足够容纳绝大多数 ESP32 应用固件。注意我把 factory 也预留了 2MB即便 OTA 分区全挂还可以手动切回 factory。如果你的固件尺寸超过 1.5MB建议改用 8MB 或 16MB Flash 的模组并把分区大小设置到 3MB 以上。分区表写好之后在 menuconfig 里指定使用这个 CSV 文件。方法是idf.py menuconfig # 进入 Partition Table 选项选择 Custom partition table CSV # 填入分区表文件的路径然后重新编译烧录。这里有个非常容易忽略的坑第一次烧录时必须用全量烧录方式把 bootloader、分区表、boot_app0.bin 以及 app 一起烧进去而不是只烧 app。否则分区表没更新后续 OTA 会写入错误的位置轻则升级失败重则覆盖其他分区。2.3 为什么是“双分区”而不是“单分区备份文件”有人会问我能不能只用一个 app 分区再把旧固件压缩一下存到某个 data 分区技术上可以但工程上非常不推荐。因为恢复旧固件的动作需要一段可靠的代码去完成解压、擦除、写入、校验这段代码本身就必须运行在一个独立且稳定的分区里。这不又回到了“你得有一个不会坏的引导程序”这个前提与其搞复杂的恢复逻辑不如直接让硬件分区表帮你做轮换让 bootloader 层面的机制来决定引导谁简单可靠得多。3. 自动回滚是怎么做到的3.1 分区状态机pending、valid、invalid有了双分区还得有一套“判断哪个分区能用”的机制。ESP-IDF 的 OTA 机制在 data 分区里专门划分了一个名为 otadata 的分区它记录当前应该引导哪个应用分区以及这个分区固件的状态。固件状态一共有几种Undefined未定义、Pending待确认、Valid有效、Invalid无效。烧录新固件后首次启动该固件处于 Pending 状态。此时如果应用代码里执行了“确认没问题”的操作状态就转成 Valid之后不论怎么重启都会继续引导这个分区。如果应用没有执行确认同时又设置并启用了自动回滚功能那么下一次重启时 bootloader 会把这个分区标记为 Invalid然后自动引导另一个可用分区。整个过程可以类比成你给手机装新系统装完先试用三天试用期内你没点“确认没问题”重启后系统自动回到上一个旧版本。你点了确认那新系统就成了正式系统。3.2 用作系统升级的“新固件自检”流程在实际应用里我不建议一上电就调用“确认有效”接口。必须等系统所有关键初始化完成后再确认有效。这是我项目里总结出来的最佳实践流程检查当前启动原因判断是否刚刚发生过回滚。初始化外设比如 WiFi、传感器、屏幕、电机驱动。执行功能自检比如读取传感器数值、连接 MQTT 服务器成功。走完上述逻辑后调用esp_ota_mark_app_valid_cancel_rollback()确认固件有效。如果步骤 2 或 3 失败则不调用确认接口直接让设备重启让 bootloader 执行回滚。流程本身不复杂但有一个原则要记住确认动作应该尽可能延后延后到你确认产品核心功能真正可用为止。如果上电不到 1 秒就确认那自动回滚的意义就少了一半——因为很多故障要跑一段时间才会暴露比如内存泄漏、温度过高、外设反复掉线。3.3 配置项怎么开在 ESP-IDF 里自动回滚依赖于三个配置项。打开 menuconfig按下面路径逐项设置idf.py menuconfig # → Component config → ESP System Settings # → Enable OTA update rollback # → Set number of boot attempts to rollback # → Enable anti-rollback supportEnable OTA update rollback是核心开关默认关闭必须打开。Set number of boot attempts to rollback决定系统允许尝试引导新固件几次后才判定失败默认值是 1意思是最多试一次失败就回滚。如果你担心新固件启动时因为偶发因素导致误判可以设成 2 或 3但要注意每个引导周期内都要重新计数所以实际容错能力是足够的。Enable anti-rollback support则是更严格的机制它会让旧版本固件在启动时直接被拒。这个适合用在安全要求高的场景比如固件已经修复了一个安全漏洞你不想让设备退回老版本重新暴露漏洞。在普通项目里我建议先不打开这项等回滚逻辑成熟后再加上。3.4 bootloader 层到底怎么“知道”要回滚很多人以为回滚是 App 层代码在做其实关键逻辑在 bootloader 里。ESP-IDF 的 bootloader 启动时会读取 otadata 分区检查待引导分区的状态。如果状态是 Pending 且启动次数超标或者是 Invalidbootloader 会切换到另一个合法分区引导。这个判断发生在 App 运行之前所以哪怕 App 代码写得再烂、启动即崩溃bootloader 都能察觉并切换。所以在设计响应式回滚逻辑时你完全不需要自己在 App 里做分区切换只要让 App 在“能正常跑”时明确说一句“我没事”剩下的交给 bootloader。3.5 用代码演示一个最小自检与回滚逻辑下面这段代码是基于 ESP-IDF 的典型实现适用于支持自动回滚配置的工程#include esp_ota_ops.h #include esp_system.h #include nvs_flash.h #include freertos/FreeRTOS.h #include freertos/task.h #define SENSOR_ERROR_THRESHOLD 10 static int read_sensor(void) { // 模拟读取传感器0 表示正常1 表示异常 return 0; } 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) { ESP_ERROR_CHECK(nvs_flash_erase()); nvs_flash_init(); } const esp_partition_t *running esp_ota_get_running_partition(); ESP_LOGI(MAIN, 当前运行分区: %s, running-label); // 检查是否发生了回滚 if (esp_ota_get_last_boot_partition() ! running) { ESP_LOGW(MAIN, 检测到上次引导的分区不是当前分区可能刚刚发生过回滚); } // 模拟外设初始化和功能自检 int sensor_fail_count 0; for (int i 0; i 5; i) { if (read_sensor() ! 0) { sensor_fail_count; } vTaskDelay(pdMS_TO_TICKS(100)); } if (sensor_fail_count SENSOR_ERROR_THRESHOLD) { ESP_LOGE(MAIN, 自检失败触发回滚); esp_ota_mark_app_invalid_reboot(); return; } // 自检通过标记固件有效取消回滚 esp_ota_mark_app_valid_cancel_rollback(); ESP_LOGI(MAIN, 固件验证通过下次重启不会回滚); // 业务主循环 while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); } }这个代码里两个最关键的函数就是esp_ota_mark_app_valid_cancel_rollback()和esp_ota_mark_app_invalid_reboot()。前者标记当前固件有效取消回滚后者主动告诉系统当前固件不可用请立刻重启并切换到备用分区。使用这个组合时注意不要误解函数名里的 cancel_rollback 是取消“所有回滚”它只是取消针对当前这个待确认固件的回滚行为。4. 实操过程从编译到模拟一次完整回滚4.1 准备一份可验证的测试工程为了演示完整流程我建议单独建一个测试工程不要直接在你的成熟项目上做实验。先让工程控制板载 LED 闪烁并把闪烁次数记录到 NVS。初始版本 LED 闪 1 次升级版本 LED 闪 2 次这样你一眼就能分辨当前运行的是哪个固件从而验证回滚是否生效。目录结构就是标准的 ESP-IDF 工程rollback_demo/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── main.c └── partitions.csvpartitions.csv用前面给的 4MB 分区表。main.c里实现 LED 闪烁逻辑以及在第 3 节写的自检和确认流程。4.2 编译、烧录初始版本全量烧录初始版本的命令idf.py set-target esp32 idf.py build idf.py -p /dev/ttyUSB0 erase-flash flash monitorerase-flash这步很关键它可以清除 Flash 中可能残留的旧分区表、旧 OTA 状态。第一次全量烧录完成后设备会引导 factory 分区串口日志能看到当前运行分区: factory。这时你的设备处于原始版本状态。为了测试回滚接下来要手动做一次 OTA 升级。这里我不打算走网络 OTA直接用串口烧录到 ota_0 分区模拟一次升级操作。修改代码里的 LED 闪烁次数为 2然后编译idf.py build不全量烧录只把新固件写入 ota_0esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 921600 write_flash 0x210000 build/rollback_demo.bin注意地址 0x210000 对应分区表里ota_0的偏移量。但这里有一个容易踩的坑直接往 ota_0 分区写入固件是不会自动把 otadata 切换到 ota_0 的。因为 otadata 记录选择逻辑通常由 OTA 升级接口来更新比如esp_ota_ops里的esp_ota_begin、esp_ota_write和esp_ota_end流程。你手动用 esptool 写分区相当于只替换了分区内容却没有通知 bootloader 下次引导它。要让 bootloader 引导 ota_0可以借助 IDF 自带的 OTA 操作演示也可以用一个简单的技巧在 menuconfig 里打开Component config → ESP System Settings → Support APP_ROLLBACK_ENABLE之后再打开Component config → ESP System Settings → OTA initialization相关的自动选择功能或者直接通过命令操作 NVS 设置下一个引导分区。其实最方便也最符合实际 OTA 路径的测试方法是用 ESP-IDF 官方示例native_ota_example把 OTA 数据通过串口或者 HTTP 下载写入 ota_0。这样 otadata 会被正确切换。如果你只是想快速验证双分区回滚那手动修改 otadata 是一种可行的捷径但不如走 OTA 流程直观。4.3 触发一次真正的回滚现在假设你的 ota_0 已经被正确引导了日志显示当前运行分区: ota_0。接下来让代码里的自检失败触发回滚。修改 main.c 里的read_sensor()强制返回 1然后重新编译、再次 OTA 升级到 ota_0或者手动写分区并正确切换 otadata。重启后观察串口日志当前运行分区: ota_0 自检失败触发回滚设备会立即重启再次启动时 bootloader 检测到 ota_0 状态是 Invalid自动引导 ota_1 或 factory。日志里会看到 bootloader 层输出的“The currently selected app partition is invalid, rolling back to the last valid partition”类似信息随后进入 factory 分区。这整个过程就是一次完整的“刷了坏固件→自动回滚→设备恢复正常”的演示。4.4 有没有可能回滚到错误分区有一种情况我实际遇到过你的 Flash 里只有 factory 和 ota_0 两个 app 分区如果 factory 是用旧工具链编译的老版本而 ota_0 是用新工具链编译的两者版本的 NVS 结构或启动参数不兼容回滚到 factory 后可能也会出现异常。回滚机制的作用是让设备回到一个“之前能跑”的固件但如果你之前那个固件实际上就存在潜在问题那回滚只是暂时恢复了可用状态并不代表问题不存在。因此我在生产环境的设计原则是至少保留一个经过充分验证、能完整提供基础功能的“救命固件”在 Flash 里并且这个固件最好保持不变。这样无论 OTA 再怎么翻车设备都能回到一个行为稳定的基准版本。5. 常见问题与避坑实录5.1 已烧死的板子恢复方式回到文章最开始的问题如果已经刷坏没有双分区机制做保护板子现在反复重启怎么救第一步永远是连接串口看日志。ESP32 芯片上电后即使 App 崩溃ROM Bootloader 和二级 Bootloader 的日志通常还是能打印出来的。CtrlC 中断当前运行或者在上电瞬间按住 BOOT 引脚就能进入下载模式esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash 0x10000 build/app.bin如果是分区表损坏那就需要先全量擦除esptool.py --chip esp32 --port /dev/ttyUSB0 erase_flash擦除后芯片回到出厂状态之后再全量烧录 bootloader、分区表和固件。5.2 otadata 没切换怎么确认当前到底引导哪个分区用串口监视器看日志是最直接的代码里调用esp_ota_get_running_partition()也能确认。另外还有个物理判断法不同版本固件让 LED 闪烁模式不同一眼能分辨。不过我发现很多人更喜欢用 esptool 读 Flash 来确认操作如下esptool.py --chip esp32 --port /dev/ttyUSB0 read_flash 0x0 0x10000 dump_bootloader.bin遇到问题时排查 otadata 也靠谱它在分区表对应偏移位置大小通常是 0x2000内容包含当前引导分区序号和状态。5.3 为什么我的 ESP32-C3 回滚效果和文档不一样ESP32-C3、S3 等芯片在启动流程和 eFuse 机制上和经典 ESP32 略有差异比如某些型号的 ROM 串口下载模式触发方式不同。如果你的开发板没有板载 USB 转串口而是原生 USB 接口需要留意是否需要按住 BOOT 按键再上电否则可能一直进不了下载模式。回滚逻辑方面API 是统一的但 bootloader 日志输出可能会因为 ROM 版本原因被裁剪所以不要完全依赖日志要以实际现象为准。5.4 回滚太频繁设备处于反复重启循环怎么办如果新固件启动即崩且回滚条件设置得太宽松设备可能会出现“启动 → 失败 → 回滚 → 再启动 → 失败”的循环。解决办法是仔细检查Set number of boot attempts to rollback这个参数适当调大一点比如设为 3同时确认每个 App 在自检失败后都能主动调用esp_ota_mark_app_invalid_reboot()。还有就是要保证备用分区里的固件是真的能稳定运行的否则回滚机制形同虚设。5.5 OTA 升级过程中断电了怎么办OTA 写入过程中断电最坏的情况是目标分区数据不完整。但因为有双分区bootloader 在下次启动时会发现当前引导分区内容校验失败自动切到备用分区设备依然能启动。唯一的风险是如果你把 OTA 目标分区选错了、覆盖了正在运行的只读分区那麻烦就大了。所以无论是使用esp_ota_begin还是别的接口都要确认目标分区必须是 ota_x 类型的数据分区不是 factory 或当前运行分区。6. 从“能跑”到“跑得稳”还差一个固件管理思维双分区加自动回滚本质上是在用系统的“可回退性”对抗固件升级这种高风险操作。它不能解决固件本身 bug不能替你写好业务代码但它能保证你的产品在升级失败后不至于变成一块废板。如果你在做一个远程维护设备、智能家居网关、或者无人机地面站这类需要长期稳定运行的项目这套机制不是锦上添花而是刚需。我建议你在设计固件更新方案时第一件事就是划分好分区表把“救命分区”和“升级分区”的空间预留好并在第一版固件里就把自检确认逻辑做好。每次发布新版本固件先在小批量设备上观察确认有效后的运行情况再全量推送。这个习惯能让你省掉一大堆“设备离线需要返厂重刷”的售后噩梦。最后分享一个小技巧我在实际调试回滚逻辑时会特意写一个测试项通过长按某个 GPIO 模拟外设故障从而触发回滚。这样就能在不连电脑的情况下随时验证整个回滚链路是否正常。有条件的还可以在 CI 流水线里跑一个 QEMU 或真实硬件测试脚本每次发布都自动验证一次“升级失败能否回滚”。固件安全这件事真不是编译过了就万事大吉多给系统留一条后路比什么都重要。