
先说结论ESP32 开发板基本不存在“固件刷坏了就报废”这回事。绝大多数“砖”是软砖——应用固件循环重启、怎么都起不来但芯片 ROM 里那套最底层引导逻辑还活着随时可以用串口救回来。真正能造成硬砖的是另外三类操作烧错 eFuse、Secure Boot 密钥丢失、Flash 物理损坏。所以“双分区 自动回滚”这套方案保护的不是芯片而是你的应用固件当新固件升级失败、反复崩溃时bootloader 能自动回到上一个好版本不用派人到现场拆机。这篇整理适合正在做产品 OTA、担心现场升级失败救不回来的同学如果你刚接触 ESP32用 Arduino 或 ESP-IDF 开发也能通过它把分区表和 OTA 的坑提前排掉。无论你手里是 ROS2 小车、温湿度采集节点还是内嵌 Web 控制页的设备这套机制都通用。1. 先把“变砖”分成两类软砖和硬砖1.1 软砖是什么为什么它不可怕我在开发群里见过太多“我的板子刷坏了变砖了”的求助点进去一看绝大多数是软砖。软砖的表现通常是上电后串口疯狂输出 boot 日志然后进入 reboot loop或者屏幕有反应、LED 会亮但应用功能完全不正常又或者 bootloader 报一句Falling back to factory然后设备在两个固件之间来回切换。软砖的本质是应用程序或第二级 bootloader 出了问题但芯片最底层的 ROM Bootloader 一直健康。ESP32 上电后第一步永远是执行 ROM 里那段固化代码它根据 GPIO0 等启动引脚的电平决定是进入 UART 下载模式还是从 Flash 加载第二级 bootloader。只要这段 ROM 逻辑还在你随时可以用 USB-TTL 接上 EN、TX、RX、GND把 GPIO0 拉低后重新上电再通过 esptool 重新烧写完整固件。所以软砖的代价从来不是板子报废而是“花几分钟重新刷一遍”。我在早期项目里经常为了省时间直接烧错分区地址结果一连串 reboot loop。后来养成了习惯任何板子都先在外壳上贴好烧录串口的引脚定义省得每次翻原理图。对于纯开发板esptool.py write_flash配合正确的分区表就能解决对于已经量产的设备如果有远程 OTA 通道连拆机都不用直接远程刷回。1.2 真正能产生硬砖的三种原因硬砖是另一码事指芯片几乎不再响应任何烧录请求。结合这几年的经验能把 ESP32 弄成硬砖的基本只有三类第一类乱烧 eFuse。eFuse 是一次性熔丝烧进去就回不来。比如你把Disable UART Download这类位烧进去UART 下载口直接被禁用后面想通过串口救砖就难了。还有烧错 SPI Boot 禁止位、Flash 加密密钥位都会造成不同程度的锁定。网上那些“电视刷机固件包”“路由器过度固件”的救砖教程里真正危险的往往是最后一步对 bootloader 或 eFuse 区域的操作而不是刷应用分区。第二类Secure Boot 或 Flash 加密配置到一半把密钥搞丢。一旦启用了 Secure Bootbootloader 只认带合法签名的固件密钥丢了等于固件永远无法通过校验如果同时没有妥善配置下载保护整块板子基本就废了。这类问题在量产产线上偶尔会出现属于流程事故不是代码 bug。第三类Flash 芯片物理损坏或者接线错误。比如 3.3V 接错到 5V、Flash 的 WP/Hold 引脚被误接地、SPI 走线过长导致信号质量差都可能让 Flash 彻底没法读出来。这种问题跟固件无关双分区方案救不了只能换硬件。这也回答了这个标题背后最关键的问题双分区和自动回滚解决的是软砖也就是应用固件的失败不是 eFuse 等底层配置失误。你一定要清楚这个边界才能把防护做在正确的位置。2. 双分区到底怎么设计分区表、ota data 与启动链路2.1 一张分区表 CSV 看懂整套布局ESP-IDF 里分区布局由分区表决定。很多人在 Arduino 环境里从未意识到这个文件的存在但它直接影响 OTA 能不能回滚。以最常见的 4MB Flash 为例我在量产产品里常用的带救急区布局如下# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x100000, ota_0, app, ota_0, 0x110000, 0x170000, ota_1, app, ota_1, 0x280000, 0x170000,几个关键点要解释一下。nvs存 Wi-Fi 校准数据和各类 key-value 配置尺寸建议至少 4KB0x4000。很多看似玄学的 bug 其实是 NVS 太小nvs_flash_init反复返回空间不足。otadata是整个 OTA 机制的状态数据库它记录当前应该启动哪个 OTA 分区、以及每个分区的固件校验状态bootloader 每次启动都要读它。factory是出厂固件也是最后一道保险。ota_0和ota_1是两个等大的应用槽位正常升级流程就是“旧固件跑在 A新固件写入 B写入完成后让 bootloader 下一次从 B 启动”。分区表的 offset 和 size 都不能随意写。bootloader 单独占一个区域通常在 0x1000 开始分区表自身在 0x8000应用分区要求偏移量按 0x1000064KB对齐Size 也最好是 64KB 的整数倍。这个对齐是 Flash 的 SPI 读写机制决定的偷懒不按 64KB 对齐后面很容易出现莫名其妙的 boot 失败。如果你不需要factory救急区可以把上面三段合并成两个 OTA 槽位每个大约 1.875MB开发阶段绝对够用但我建议至少保留一个factory槽位理由后面会讲。2.2 Bootloader 到底怎么决定这次跑哪个固件很多教程只会教你“把分区表填好”但不解释启动链路。这里需要把机制压实一点。芯片上电后ROM Bootloader 加载第二级 bootloader第二级 bootloader 先读分区表然后读otadata分区里的 ota 状态记录。记录里包含每个 OTA 槽位对应的状态IDF 内部用esp_ota_select_entry_t这类结构管理状态值大致是这么几档状态含义什么时候出现ESP_IMG_UNDEFINED未定义等同于无效otadata 被擦除时ESP_IMG_NEW新写入还没验证过OTA 写入完成、切到新分区时ESP_IMG_PENDING_VERIFY等待验证新固件首次启动bootloader 会把 NEW 转成 PENDING_VERIFYESP_IMG_VALID已验证有效应用主动调用esp_ota_mark_app_valid_cancel_rollback()后ESP_IMG_INVALID明确无效应用主动标记或回滚机制判断失败后bootloader 的策略简单说优先选择编号最大且状态合法的 OTA 分区如果没有可用 OTA 分区就回退到factory。所以factory分区存在的意义不是给开发板做一个“出厂演示”而是当一个兜底锚点——即使两个 OTA 槽位都出了问题bootloader 还能加载出厂固件。如果产品只留了ota_0和ota_1而没有factory回滚依然可以在两个槽位之间发生系统会回到上一个 VALID 的固件只是少了终极端点我建议至少保留一个出厂槽位。2.3 为什么是“双”分区而不是“单”分区加备份可能有人会问直接维护一份固件再加一个备份区不就行了如果只是静态备份你是没法回答“哪一份算最新”“哪一份算有效”这类问题的。双分区方案最大的价值是它把“写入过程”和“启动选择”这两个动作解耦了新固件在写入过程中哪怕写了一半断电otadata里的状态也不会被提交旧分区依然是可选启动项下一个循环还是能正常进入旧固件。如果你只有一个应用分区写一半断电Flash 里的代码已经残缺板上唯一的启动对象就是这次坏数据那就真的只能靠外部救砖了。双分区方案里每个槽位还需要处理写入前的擦除、写入后的完整性校验这些 ESP-IDF 都封装好了 API。但从分区表这一层就要想清楚两个槽位必须等大否则未来的固件体积一旦超过槽位容量OTA 会在esp_ota_write阶段直接报错甚至写越界。这也是为什么我在分区表里坚持给每个 OTA 槽位留足余量的原因。固件体积会随着功能迭代不断变大预留空间一定要比当前版本大 30% 以上。3. 自动回滚的关键开关与代码落地从 menuconfig 到 mark valid3.1 menuconfig 里先打开这个总开关双分区只是给回滚提供了“空间基础”真正让系统具备“回滚”能力的是 bootloader 里的一个选项CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE。在 ESP-IDF 里进入idf.py menuconfig路径是Bootloader config - APP rollback support选Enable。这一步不做后面的代码写再多都不会生效因为 bootloader 根本不会去检查 OTA 状态也不会在新固件验证失败时回退。如果你做的是量产产品还要考虑CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK它在Security features菜单里。这个选项配合 eFuse 记录最低允许版本号防止设备被刷回有漏洞的旧固件。它很强大但也很危险因为版本号只能往上烧不可降级。我后面会专门讲它的坑这里先记住测试板别开 anti-rollback量产板设计时要提前留好版本命名规则。3.2 升级侧代码写入新固件并切换启动分区ESP-IDF 的 OTA 流程可以用一串 API 串起来esp_ota_begin开启一次 OTA 写入会话esp_ota_write分块写入数据esp_ota_end收尾做校验最后esp_ota_set_boot_partition指定下一次要启动的分区。下面这段是我在 HTTP OTA 教程基础上精简过的核心流程#include esp_ota_ops.h #include esp_http_client.h #include freertos/FreeRTOS.h #include freertos/task.h #define TAG OTA static void ota_task(void *arg) { esp_http_client_config_t cfg { .url https://your-server/firmware.bin, .timeout_ms 10000, }; esp_http_client_handle_t client esp_http_client_init(cfg); esp_http_client_open(client, 0); esp_http_client_fetch_headers(client); const esp_partition_t *update_part esp_ota_get_next_update_partition(NULL); if (update_part NULL) { ESP_LOGE(TAG, no update partition available); vTaskDelete(NULL); return; } esp_ota_handle_t ota_handle 0; esp_err_t err esp_ota_begin(update_part, OTA_WITH_SEQUENTIAL_WRITES, ota_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_begin failed: %s, esp_err_to_name(err)); vTaskDelete(NULL); return; } char buf[1024]; int len; while ((len esp_http_client_read(client, buf, sizeof(buf))) 0) { err esp_ota_write(ota_handle, buf, len); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_write failed: %s, esp_err_to_name(err)); esp_ota_abort(ota_handle); vTaskDelete(NULL); return; } } esp_http_client_close(client); err esp_ota_end(ota_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_end failed: %s, esp_err_to_name(err)); vTaskDelete(NULL); return; } err esp_ota_set_boot_partition(update_part); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_set_boot_partition failed: %s, esp_err_to_name(err)); vTaskDelete(NULL); return; } ESP_LOGI(TAG, OTA done, reboot in 2s); vTaskDelay(pdMS_TO_TICKS(2000)); esp_restart(); }这段代码有两点容易被复制后踩坑。第一esp_http_client_read的返回值才是真正读到的字节数我见过不少人直接拿sizeof(buf)传给esp_ota_write当 TCP 分包小于 1024 字节时就会写入垃圾数据esp_ota_end时校验失败。第二esp_ota_get_next_update_partition(NULL)会自动判断当前在跑哪个分区然后返回另一个空槽位根本不用你自己去猜分区名这点比手动填 offset 靠谱太多。3.3 运行侧代码到底什么时候 mark valid 才安全升级流程完成了只是第一步。bootloader 的总开关打开后新固件启动时会处于PENDING_VERIFY状态意思是“我给你几轮机会证明自己能用如果你一直没有主动说 VALID我就在次数耗尽后回到上一个好固件”。让新固件“证明自己”的 API 是esp_ota_mark_app_valid_cancel_rollback()。关键问题是调用时机我的经验是不要在app_main一开始就调。因为你刚开机时 WiFi 没连上、外设没初始化、NVS 都没加载完这时候 mark valid 跟没 mark 差不多。我通常的做法是给应用定义一个“健康标准”比如 Wi-Fi 连接成功并成功上报了一次状态关键外设初始化完成且自检通过达到标准后再调用。同时再加一层保险——用一个 10 秒的看门狗定时器做兜底如果 10 秒内没走到 mark valid 的逻辑就主动调用esp_restart()让 bootloader 去扣验证次数。这样做既能给新固件完整启动时间又不会让设备无限卡死在半残状态里。反过来如果你在自检阶段就铁了心认为新固件有问题可以主动执行esp_ota_mark_app_invalid_rollback_and_reboot();它会直接把当前固件标记为INVALID然后立刻重启并回到上一个 VALID 分区。这个函数虽然听起来像“自杀”但它是调试回滚链路最方便的工具也是产品里做“远程救援开关”的常用接口。另外升级前最好先问一句当前 OTA 状态允不允许回滚esp_ota_check_rollback_is_possible()返回true才代表存在一个可回退的好固件如果返回false说明现在跑的就是唯一的好固件继续升级前要提示用户或者放弃升级。这个 API 常被忽略我的一些早期 OTA 失败恰恰是因为升级到了唯一的槽位把自己和工厂分区都占了回滚根本没地方可选。4. 我在真实项目里踩过的回滚失效坑4.1 开了开关却忘了 mark valid新固件每轮都在“扣分”第一个坑最隐蔽。我在做一款环境监测产品时发现升级后设备会出现“能用两三天然后自己回退到旧版”的现象。第一反应是怀疑固件有随机崩溃后来查 bootloader 日志才明白我在代码里加了CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE但整个工程里居然没有一行esp_ota_mark_app_valid_cancel_rollback()。这意味着每次启动新固件bootloader 都在等它“自证”等几轮之后就把新固件标记为 INVALID回退到旧版然后旧版又跑得好好的完全不报错。这个行为最折腾人的地方是它不像崩溃那样有明显症状而是像“随机被时光回溯”。解法很简单在应用达到健康状态后调用 mark valid。但为了不再次遗漏我把 mark valid 的调用点封装成一个独立函数mark_firmware_healthy()在 Wi-Fi 连接、NVS 初始化、传感器自检三步全部通过后统一调用不允许散落在各处。4.2 mark valid 放太早外设还没起来标签已经贴好与之相对的另一个极端是放太早。有一次我把 mark valid 放在app_main的第一行之后结果某个外设驱动在 3 秒后才初始化完成而它初始化失败时会进入abort()整个系统崩溃重启。崩溃发生在 mark valid 之后bootloader 认为新固件已经是 VALID 状态回滚机制直接被跳过这台设备从此只能手动救砖。这就是 3.3 节强调“健康标准”的原因。mark valid 语义上是“我认为这套系统达到可运行状态了”而不是“代码走到这行了”。对产品而言标准就是产品对外可用、能持续服务用户的状态。宁可用更保守的标准也不要为了省几秒钟把回滚保护丢掉。4.3 分区表不一致升级包写进了一个“不存在”的分区还有一次是前端生成 bin 文件时用错了分区表。他按 8MB Flash 的布局生成了 app但目标设备的第二个 OTA 分区只分配了 3MB结果esp_ota_write写到一半越界。开机后 bootloader 在 otadata 里看到的还是老状态一直起旧固件表面上看是“OTA 失败但不报错”。排查下来最有效的办法是升级后立刻看串口日志里的分区选择行确认实际启动的分区 offset 和你预期写入的 offset 一致另外在esp_ota_begin前用esp_partition_get_actual_size校验目标分区大小宁可大于也不小于固件体积。4.4 手动烧录破坏了 otadata 的顺序开发阶段为了省时间我有时会直接用esptool.py write_flash 0x310000 app1.bin把新固件硬写到ota_0而完全不动otadata。这种操作的问题在于otadata 里记录的当前启用分区可能还是ota_1你写的ota_0根本没被选上更糟糕的是如果 otadata 的状态字段是乱序的bootloader 会选择它认为“最新”的序列号导致设备从一个你完全没预期过的分区启动。开发调试时可以不在乎但如果拿这个手法去模拟“生产升级”你会得到一堆误导性的结果。正确模拟 OTA 的方式还是走esp_ota_set_boot_partition接口或者给otadata分区做一次干净 erase让 bootloader 强制回退到 factory。4.5 anti-rollback 开着测试板变一次性用品最后这个坑要单独拎出来说。CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK会把最低允许版本号写进 eFuse每升级一次版本号就烧高一次而且它只升不降。我在一台测试板上打开过这个选项后来想验证“能不能降级回旧固件”来排查问题结果发现旧固件根本不被接受bootloader 直接拒绝启动。这块测试板从此只能往高版本走再也不能做降级测试。所以我的建议是anti-rollback 只在量产固件里开测试板严格与量产配置分开如果你必须在测试板上验证那就要准备好几块板子并且把 eFuse 烧录操作纳入版本管理谁烧了什么版本都记录在案。5. 实测记录故意刷坏固件看它怎么自救5.1 实验设计一个“故意自杀”的新固件理论讲再多都不如做一次实验。我给一块 ESP32 开发板设计了两个固件app_old跑在factory或ota_0上电后 LED 慢闪串口每 2 秒打印一次OLD APP RUNNING。这个作为“已知好固件”。app_bad跑在ota_1上电后故意打印几行日志然后循环触发某个空指针操作或者干脆调用esp_ota_mark_app_invalid_rollback_and_reboot()模拟“升级后完全不可用”。实验流程是先通过 OTA 接口把app_bad写入ota_1并让系统重启然后我在串口终端观察 bootloader 和两个固件各自的日志。整个过程不需要改硬件只用到 ESP-IDF 的esp_ota_*API 和一个简单的 HTTP 服务或者直接用串口 OTA 也行。5.2 正常升级侧观察到的日志第一次实验我先让app_bad稳定运行 30 秒再自杀相当于模拟“新固件启动了但一直没 mark valid”。串口日志大概是这样的关键行我保留真实函数名省得你查不到出处I (238) boot: Loaded app from partition at offset 0x410000 I (238) boot: Checking flash encryption... I (244) boot: App is pending verification, boot counter: 4 I (250) app_bad: BAD APP RUNNING, wait for watchdog... W (382) app_bad: watchdog timeout, invalidate myself... I (388) app_bad: calling esp_ota_mark_app_invalid_rollback_and_reboot然后看到 bootloader 再次工作最终打印I (820) boot: Loaded app from partition at offset 0x10000 I (820) boot: Falling back to factory I (826) app_old: OLD APP RUNNING这里要注意日志里的boot counter: 4是剩余可尝试次数不是成功次数。如果你看到它从 5 一路递减到 0说明新固件一直没拿到 VALID 状态bootloader 正在按计划准备回退。很多人在第一次看到Falling back to factory时会误以为系统出大事了其实这恰恰是保护机制在正常工作。5.3 验证 mark valid 后回滚被取消第二次实验我改了一下app_bad让它成功初始化所有外设后调用esp_ota_mark_app_valid_cancel_rollback()然后再故意重启。此时 otadata 里状态已经变成ESP_IMG_VALIDbootloader 会继续选择ota_1启动而不是回退。日志里能看到类似I (238) boot: Loaded app from partition at offset 0x410000 I (244) boot: App is valid I (250) app_bad: BAD APP RUNNING, but marked VALID这两组实验放在一起正好把双分区加自动回滚的完整行为闭环演示清楚。你也可以在发布新固件前专门做一轮“最坏情况演练”往设备刷一个故意崩溃的固件观察它是否能在无人干预的情况下回退成功、以及回退耗时是多少。这个数据对产品现场的升级故障率评估非常有用我建议有条件的团队每个发布版本都跑一遍。5.4 小型测试台搭建建议要反复做这种实验建议固定一套最小硬件一张 ESP32 DevKitC、一个 USB-TTL、几根杜邦线。不要图方便直接用板载 USB 转串口做全流程因为 OTA 实验经常要断电重启板载串口芯片有时会被反复上电搞出异常状态。外接 USB-TTL 的另一个好处是可以稳定看到 bootloader 早期的日志方便你判断到底是谁在决定启动哪个分区。日志保存建议按实验编号归档后期做故障分析时比你在终端里滚动翻找靠谱得多。6. 从开发板到量产双分区方案还要配哪些措施6.1 版本管理与“不允许降级”的产品策略双分区方案在生产环境里要配合版本管理。至少要在固件里定义一个版本号宏比如#define APP_VERSION 1.4.2并且把版本号、编译时间、Git commit 刷进日志和 NVS。升级前用版本号做比较避免把高版本固件回传到一个低版本设备上造成混乱。如果你开了 anti-rollback版本号的命名规则就得更严格因为 eFuse 里的最低版本是只升不降的一个不规范的版本号一旦烧进去可能直接把整批设备锁死在旧版本上。6.2 出厂槽位的定位从“演示固件”变成“救援固件”很多开发板默认的 factory 分区里放的是点灯 Demo量产后没人管它。但我建议把 factory 分区设计成一个极简的“救援固件”功能只有串口控制台、NVS 检查、以及通过 Wi-Fi/HTTP、BLE 或蓝牙 App 完成一次基础 OTA 更新。当两个 OTA 槽位都失效时用户至少可以通过 GPIO 触发进入 factory再重新联网升级而不是把整台设备退回工厂。代价是多一个工厂测试步骤但换来的是一台设备在线救援的能力对无人值守的物联网产品来说很值。这个分区平时也可以作为 A/B 测试里的“出厂健康基线”。6.3 固件加密与 Secure Boot 的交互升级流程稳定后很多团队会开始想固件安全。Flash Encryption会加密 Flash 里存放的固件防止别人把 Flash 拆下来直接读取代码Secure Boot则要求 app 必须有合法签名才能启动。它们与 OTA 的配合点是每次升级包都必须先用项目密钥签名、再经过加密处理否则 bootloader 会拒绝启动。这里要特别提醒Flash Encryption 和 Secure Boot 的启用决定要在第一次量产烧录前定好因为它们涉及的 eFuse 烧录动作不可逆。密钥一定要分成多份离线保存绝对不要只放在某个工程师的电脑里。真到了密钥丢失那天你只能看着整条产线的产品慢慢变成硬砖。6.4 开发环境下载提速的小建议回到开发源头ESP-IDF 和 Arduino 的 ESP32 开发包体积都很大官方下载源有时候不稳定。我习惯用乐鑫在国内的镜像源下载工具链和开发包esp32国内源这个词在社区里很热生态工具如 Arduino IDE 的 ESP32 开发管理器下载、arduino-esp32离线包也有现成镜像能省掉大量等下载的时间。注意镜像源只解决下载速度问题固件代码本身还是要在你自己的构建服务器上做哈希和签名校验别因为下载快了就走捷径。6.5 升级失败率的现场观测量产之后建议设备把每次 OTA 的结果成功、失败、回滚触发上报到后端不只是状态码还包括 bootloader 日志里的关键行。我自己用过的最小方案是在 NVS 里记录最近 5 次 OTA 的esp_ota_end返回值、esp_ota_set_boot_partition是否成功、以及完成 mark valid 的时间戳。出现大面积回滚时先看这批设备的日志里 boot counter 是否是递减的如果是说明新固件整体处于“启动即崩溃”的状态回滚机制救了现场如果某些设备没有回滚而是永久卡死那就要回头查分区表对齐和 mark valid 的时机了。数据上报做好之后我的体会是双分区加自动回滚这套机制真正价值不是“防止刷坏”而是把“刷坏了”这件事从一场灾难变成一次可观测、可恢复、可复盘的事件。我现在的产品都默认开启 rollback甚至在调试阶段也会保留两槽位而不是贪图空间只留一个区。最后再给一个实用小技巧把你的串口日志里Loaded app from partition at offset和Falling back to factory这两行做成终端关键字高亮调试 OTA 时你会感谢这个决定。