
1. 从一次刷废了的深夜事故说起凌晨两点我盯着串口监视器里不断滚动的乱码心里只有一个念头这块 ESP32 是不是彻底废了。事情的起因很简单我在调一个带 Web 配置页的固件改完 WiFi 连接逻辑之后顺手点了一下烧录结果新固件在初始化 NVS 的时候卡死看门狗反复复位串口每隔几秒就吐出一段rst:0x3 (SW_RESET)。板子上的 LED 不再闪烁Web 页面打不开连串口命令都没反应。那一刻我脑子里蹦出来的词就是变砖。后来我把这个问题彻底解决掉并且在此之后的所有 ESP32 项目里都加上了双分区加自动回滚的机制。从那以后无论固件写得多烂最坏的情况也只是设备自己回滚到上一个能跑的版本再也不会出现必须拆下来接 USB 重新烧录的尴尬。这篇文章就把这套机制从头到尾讲清楚ESP32 到底会不会变砖、双分区是怎么工作的、自动回滚的判定逻辑在哪里、OTA 升级怎么和回滚配合、以及我在实际项目里踩过的那些坑。先给一个结论让你安心ESP32 在正常使用场景下几乎不会真正变砖。它内部有一套 ROM 引导程序这部分代码是出厂固化在芯片里的你刷写应用固件的行为根本碰不到它。所谓变砖绝大多数情况只是应用分区里的固件跑不起来导致设备无法正常启动或无法联网但芯片本身还是好的重新烧录就能救回来。真正意义上的砖只有在你把 bootloader、分区表、eFuse 这些底层东西写坏或者启用了安全启动却把密钥弄丢的情况下才会出现。理解了这一点我们才能理性地讨论怎么防止设备跑不起来这个问题。适合读这篇内容的人正在用 ESP32 做联网设备、准备上 OTA 升级、或者已经被刷坏固件折腾过的开发者。不管你是用 Arduino IDE 还是 ESP-IDF双分区和回滚的底层逻辑是一样的区别只在配置方式。下面我会把原理、配置、代码、验证、排错全部串起来讲尽量让你看完就能在自己的项目里落地。2. ESP32 的启动链路决定了它为什么不容易变砖2.1 从上电到 app_main 的完整路径要理解会不会变砖得先搞清楚 ESP32 上电之后到底发生了什么。整个过程大致是这样的芯片上电复位后首先运行的是固化在 ROM 里的一级引导程序这段代码由芯片厂商在流片时写入用户无法修改也无法擦除。它做的事情很有限主要是根据 strapping 引脚的电平决定从哪个接口加载下一阶段代码通常是 SPI Flash。接着 ROM 引导程序会从 Flash 的固定偏移默认 0x1000加载二级引导程序也就是我们常说的 bootloader。bootloader 才是真正干活的角色它读取分区表、检查各个分区的状态、决定启动哪个应用分区、必要时执行回滚。bootloader 之后才轮到应用程序最终调用到你的app_main或者 Arduino 的setup。这条链路里ROM 部分是绝对安全的bootloader 和分区表是可以被刷写的应用分区更是随便刷。所以变砖的风险点其实集中在 bootloader 和分区表这两块。只要你不动它们应用固件刷多少次都不会让芯片彻底没救。2.2 应用分区跑不起来时会发生什么假设你刷了一个有 bug 的应用固件进去它在启动早期就崩溃了。这时候会发生什么默认情况下bootloader 会尝试启动这个分区如果连续失败达到一定次数芯片会进入一种特殊的下载模式等待重新烧录。但这里有个关键点默认配置下 ESP32 并不会自动回滚它只是反复重启直到你手动介入。这就是很多人误以为变砖的场景。设备确实起不来了但芯片没坏你只要用 USB 线接上按住 BOOT 键重新烧录一个正常固件就能恢复。问题在于如果设备装在墙上、埋在天花板里、或者部署在远程现场你根本没法物理接触它这时候能自动回滚就变成了刚需。2.3 真正会变砖的几种极端情况虽然日常开发几乎碰不到但为了严谨还是列一下真正可能导致不可恢复的情况刷写 bootloader 时断电bootloader 分区被写坏ROM 找不到有效的二级引导程序芯片会一直卡在下载模式。这种情况通常还能通过串口重新烧录救回来除非硬件层面也出了问题。分区表损坏且没有备份分区表告诉 bootloader 各个分区在哪里如果它坏了bootloader 就不知道去哪找应用。同样重新烧录分区表即可恢复。启用安全启动后密钥丢失这是最麻烦的一种。安全启动会用 eFuse 里的一次性密钥校验固件签名如果密钥丢了或者 eFuse 被错误烧写设备会拒绝启动任何未签名的固件而 eFuse 是不可逆的。这种才是真正意义上的砖。Flash 加密配置错误类似安全启动加密密钥和 eFuse 配置一旦出错设备可能无法解密自己的固件。看到这里你应该明白了普通开发者日常刷应用固件根本碰不到这些底层区域。所以ESP32 刷固件会变砖这个说法在应用层开发语境下基本是个伪命题。真正要解决的是设备跑不起来又没法现场救的问题而双分区加自动回滚正是为此而生。3. 双分区机制给固件留一条退路3.1 分区表里到底写了什么ESP32 的 Flash 布局由一张分区表描述它默认位于偏移 0x8000长度 0xC00。分区表用一系列条目定义每个分区的名称、类型、子类型、偏移和大小。一个典型的支持 OTA 的分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x140000, ota_0, app, ota_0, 0x150000,0x140000, ota_1, app, ota_1, 0x290000,0x140000,这里有几个关键角色。factory是出厂固件分区ota_0和ota_1是两个 OTA 应用分区otadata则是一个很小的数据分区专门用来记录当前该启动哪个 OTA 分区以及这个分区的状态。双分区的核心就是ota_0和ota_1这两个槽位它们轮流承载固件永远保留一个可用的旧版本。3.2 otadata 是怎么记录启动状态的otadata分区里存的是两个esp_ota_select_entry_t结构每个结构记录一个 OTA 分区的序号、状态和 CRC 校验值。状态主要有几种NEW表示刚写入还没验证、PENDING_VERIFY表示等待验证、VALID表示已验证可用、INVALID表示已损坏、ABORTED表示升级被中止。bootloader 启动时会读otadata按照优先选 VALID、其次选 PENDING_VERIFY、最后回退到 factory的规则决定启动哪个分区。这个规则是整个回滚机制的地基。当你通过 OTA 写入新固件时系统会把新分区标记为PENDING_VERIFY然后重启。如果新固件能正常跑起来并主动调用标记为有效的接口状态就变成VALID如果它跑不起来bootloader 在下次启动时发现它还是PENDING_VERIFY就会自动切回上一个VALID的分区。3.3 为什么是两个分区而不是一个有人会问为什么不能只用一个 OTA 分区加一个 factory 分区答案在于升级过程中的原子性。OTA 升级本质上是把新固件写到 Flash 的某个区域这个过程可能持续几十秒甚至几分钟。如果只有一个 OTA 分区你写新固件的时候就把旧固件覆盖了万一写到一半断电或者新固件本身有问题你就没有任何可回退的版本。两个 OTA 分区的好处是写新固件时旧固件原封不动地待在另一个分区里。新固件写完之后otadata指向新分区如果新分区验证失败otadata再指回旧分区。整个过程旧固件始终是完整的这就是退路的含义。代价是 Flash 空间要翻倍给应用分区对于 4MB Flash 的模组来说每个 OTA 分区大概能分到 1.2MB 到 1.4MB够大多数应用用了。3.4 分区大小怎么算才不浪费分区大小不是随便填的填小了固件放不下填大了浪费 Flash。我的经验是这样算先编译一次完整固件看生成的 bin 文件多大然后留出 30% 到 50% 的余量。比如固件 900KB那每个 OTA 分区至少给 1.3MB。同时要注意分区偏移必须对齐到 0x1000否则烧录会报错。还有一个容易忽略的点otadata分区虽然只有 0x2000但它必须存在且大小正确否则 OTA 相关 API 会直接返回错误。我见过有人为了省空间把otadata删掉结果 OTA 功能整个失效排查了半天才发现是分区表的问题。4. 自动回滚的判定逻辑与代码落地4.1 回滚不是自动发生的需要你主动确认这是最容易踩的坑很多人以为只要配了双分区固件跑不起来就会自动回滚。实际上回滚的触发条件是新固件没有在超时前把自己标记为有效。也就是说你的新固件必须在启动成功后主动调用一个接口告诉系统我跑起来了可以把我设为有效。如果你不调用bootloader 会认为这次升级没成功下次启动就回滚。在 ESP-IDF 里这个接口是esp_ota_mark_app_valid_cancel_rollback()。在 Arduino 环境下对应的封装是Update库配合esp_ota的底层调用或者用ESP32的Update.onProgress之外还需要手动处理。这个设计非常合理它把什么算启动成功的判断权交给了开发者。因为有些固件虽然能启动但网络连不上、传感器读不到这种半死不活的状态也应该触发回滚。4.2 一个可复用的回滚确认流程我在项目里通常这样组织这段逻辑设备启动后先做一系列自检包括关键外设初始化、网络连接、配置读取全部通过之后才标记有效。如果任何一步失败就不标记让系统在下次重启时自动回滚。代码大致长这样#include esp_ota_ops.h #include esp_system.h void app_main(void) { // 第一步初始化关键外设 if (!init_peripherals()) { ESP_LOGE(TAG, 外设初始化失败不标记有效等待回滚); esp_restart(); } // 第二步连接网络带超时 if (!connect_network_with_timeout(30000)) { ESP_LOGE(TAG, 网络连接超时不标记有效); esp_restart(); } // 第三步读取配置并校验 if (!load_and_validate_config()) { ESP_LOGE(TAG, 配置校验失败不标记有效); esp_restart(); } // 全部通过标记当前固件为有效取消回滚 esp_err_t err esp_ota_mark_app_valid_cancel_rollback(); if (err ! ESP_OK) { ESP_LOGE(TAG, 标记固件有效失败: %s, esp_err_to_name(err)); } // 进入正常业务逻辑 start_main_loop(); }这段代码的关键在于自检必须覆盖那些跑不起来的典型场景。外设初始化失败、网络连不上、配置读不出来这三类问题占了实际故障的绝大多数。把它们放在标记有效之前就能保证只有真正可用的固件才会被保留。4.3 超时机制给回滚加一道保险光靠主动标记还不够因为有一种情况是固件卡在某个死循环里既不崩溃也不调用标记接口。这时候设备会一直卡着永远不会触发回滚。解决办法是加一个看门狗或者启动超时。我的做法是在 bootloader 层面配置一个启动超时或者在应用里用一个独立的定时器如果启动后 N 秒内没有完成自检就主动调用esp_ota_mark_app_invalid_rollback_and_reboot()强制回滚。这个接口会把当前分区标记为无效并立即重启bootloader 下次就会选另一个分区。static void startup_timeout_cb(void *arg) { ESP_LOGW(TAG, 启动超时强制回滚); esp_ota_mark_app_invalid_rollback_and_reboot(); } void app_main(void) { const esp_timer_create_args_t timer_args { .callback startup_timeout_cb, .name startup_timeout }; esp_timer_handle_t timer; esp_timer_create(timer_args, timer); esp_timer_start_once(timer, 60 * 1000 * 1000); // 60 秒 // ... 自检逻辑 ... esp_timer_stop(timer); // 自检通过后停掉定时器 esp_ota_mark_app_valid_cancel_rollback(); }这个超时时间设多少合适我的经验是根据最慢的自检步骤来定再留一倍余量。比如网络连接最慢要 20 秒那超时就设 45 到 60 秒。设太短会误回滚设太长则故障恢复慢。这个值最好做成可配置的方便不同现场调整。4.4 Arduino 环境下的等价实现如果你用 Arduino IDE 开发底层其实也是 ESP-IDF只是封装了一层。回滚相关的接口可以通过esp_ota_ops.h直接调用Arduino 的Update库负责写入标记有效和回滚还是用 IDF 的 API。需要注意的是Arduino 的setup()里如果调用了esp_ota_mark_app_valid_cancel_rollback()要确保它是在所有关键初始化之后。我见过有人在setup()第一行就标记有效结果后面 WiFi 连不上导致设备失联但因为已经标记有效系统不会回滚设备就卡在了一个能启动但没用的状态。这个坑很典型标记有效的时机一定要放在自检之后。5. OTA 升级与回滚的配合实战5.1 一次完整的 OTA 升级流程把双分区、回滚、OTA 串起来一次完整的升级流程是这样的设备当前运行在ota_0状态为VALID。收到升级指令下载新固件到ota_1写入过程中ota_1状态为NEW。写入完成并校验通过把ota_1标记为PENDING_VERIFY更新otadata指向ota_1。设备重启bootloader 读取otadata启动ota_1。新固件启动后执行自检通过则调用esp_ota_mark_app_valid_cancel_rollback()ota_1变为VALID。如果自检失败或超时调用回滚接口otadata重新指向ota_0设备重启回到旧版本。这个流程里第 3 步和第 5 步是最关键的。第 3 步决定了下次启动哪个分区第 5 步决定了新分区是否被认可。任何一步出错回滚机制都会兜底。5.2 写入阶段的校验不能省OTA 写入过程中Flash 可能因为电源波动、网络中断等原因写坏。所以写入完成后必须做校验。ESP-IDF 的esp_ota_end()会校验整个镜像的 SHA256只有校验通过才允许切换分区。如果你自己实现写入逻辑一定要在最后做一次完整校验否则可能把一个损坏的固件标记为待验证导致启动失败。esp_ota_handle_t update_handle; const esp_partition_t *update_partition esp_ota_get_next_update_partition(NULL); esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, update_handle); while (has_more_data()) { esp_ota_write(update_handle, chunk, chunk_len); } esp_err_t err esp_ota_end(update_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, OTA 镜像校验失败放弃本次升级); return; } esp_ota_set_boot_partition(update_partition); esp_restart();注意esp_ota_end()的返回值一定要检查校验失败就放弃升级保持当前分区不变。这一步能挡掉很多因为传输问题导致的坏固件。5.3 回滚之后怎么知道发生过什么设备回滚了但你得知道它为什么回滚否则下次还会踩同样的坑。我的做法是在 NVS 里记录一个启动尝试计数和上次失败原因。每次启动时递增计数标记有效时清零。如果设备回滚了计数会保留下次启动时读到非零值就知道上次升级失败了。更进一步可以在回滚前把失败原因写进 NVS比如网络连接超时配置校验失败然后设备联网后把这些信息上报到服务器。这样你就能在后台看到哪些设备回滚了、回滚原因是什么针对性地修 bug。这个机制在批量部署的时候特别有用我靠它定位过好几次只在特定网络环境下才出现的连接问题。5.4 升级过程中的电源问题OTA 升级最怕断电。如果写到一半断电ota_1分区就是半成品但因为otadata还没切换设备下次启动还是从ota_0启动不受影响。这正是双分区的价值所在。但如果你在切换otadata的瞬间断电情况就复杂了otadata可能处于不一致状态。好在 ESP32 的otadata设计用了两个条目加 CRCbootloader 能识别出哪个是有效的不会因为一次断电就彻底混乱。不过为了保险我建议在 OTA 升级期间检测电源状态如果设备是电池供电且电量低就推迟升级。这个逻辑因项目而异但思路是通用的升级这种高风险操作尽量在设备状态稳定的时候做。6. 那些年我踩过的回滚坑6.1 分区表改了但没重新烧录有一次我给项目加了 OTA 功能改了分区表编译烧录应用固件结果 OTA 一直失败。排查了半天才发现分区表是独立于应用固件的一个 bin 文件改完之后必须单独烧录。Arduino IDE 在某些配置下会自动烧录分区表但 ESP-IDF 用idf.py flash才会一起烧如果你只烧了 app分区表还是旧的OTA 分区根本不存在。这个坑的教训是改分区表之后一定要确认它被烧进去了。可以用esptool.py read_flash把 0x8000 附近的内容读出来看看或者用parttool.py列出当前分区表。6.2 标记有效的时机太早前面提过有人在setup()开头就标记有效。我踩过的变种是把标记有效放在了 WiFi 连接之前结果设备连不上 WiFi但因为已经标记有效系统不回滚设备就永远卡在启动成功但无法通信的状态。后来我把标记有效挪到了 MQTT 连接成功之后问题才解决。这里的判断标准是什么状态算这个固件可用。对联网设备来说能连上服务器才算可用对本地控制设备来说能响应按键才算可用。这个标准因项目而异但一定要想清楚别随便找个位置就标记。6.3 回滚后旧固件也有问题双分区只能保证回滚到上一个版本如果上一个版本本身也有问题回滚过去照样跑不起来。这种情况虽然少见但确实会发生比如你连续推了两个有问题的版本。解决办法是保留一个factory分区作为最后的兜底或者在回滚逻辑里加一个回滚次数上限超过上限就进入安全模式等待人工干预。我在一个远程部署的项目里就遇到过新固件有问题回滚到旧固件结果旧固件因为服务器地址变了也连不上设备彻底失联。后来加了一个逻辑回滚之后如果还是连不上就启动一个临时的配置热点让人可以现场用手机连上去改配置。这个兜底方案救过我好几次。6.4 看门狗和回滚的冲突ESP32 有任务看门狗和中断看门狗如果自检逻辑里有耗时操作没喂狗看门狗会复位设备。这个复位和回滚的复位混在一起日志里很难区分。我的做法是在自检阶段临时调整看门狗超时或者把耗时操作放到独立任务里并正确喂狗。否则你会看到设备反复重启却搞不清是看门狗还是回滚导致的。排查这类问题的技巧是看复位原因。esp_reset_reason()能告诉你上次复位是软件复位、看门狗复位还是电源复位。结合otadata的状态就能判断这次重启是不是回滚引起的。7. 把回滚机制做成项目标配7.1 一套可复用的配置模板经过几个项目的迭代我现在把双分区加回滚做成了一套标准配置。分区表固定用factory ota_0 ota_1 otadata的结构应用启动流程固定为自检 - 标记有效 - 进入业务回滚超时固定为 60 秒可配置。这套模板复制到新项目里改改自检内容就能用。配置上要注意几个参数CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE必须打开否则PENDING_VERIFY状态不会触发回滚CONFIG_APP_ROLLBACK_ENABLE也要确认otadata分区大小不能小于 0x2000。这些在menuconfig里都能找到Arduino 环境下通过分区方案选择Minimal SPIFFS或自定义分区表来实现。7.2 测试回滚机制的正确姿势回滚机制不测试等于没有。我的测试方法是故意推一个会在自检阶段失败的固件比如把网络连接的超时设成 1 秒或者让配置校验故意返回失败然后观察设备是否在重启后回到旧版本。测试的时候一定要接串口看日志确认otadata的状态变化符合预期。更彻底的测试是模拟断电在 OTA 写入过程中拔电然后重新上电看设备是否能正常启动旧固件。这个测试能验证双分区的原子性。我建议每个项目上线前都做一次这个测试花不了多少时间但能避免很多现场事故。7.3 回滚不是万能药最后说句实在话双分区加回滚解决的是固件跑不起来的问题但它解决不了固件跑起来但逻辑错了的问题。比如你的固件能启动、能联网但把传感器数据算错了这种问题回滚机制发现不了。所以自检逻辑要尽量覆盖业务层面的关键路径不能只检查能不能启动。另外回滚机制会增加 Flash 占用和启动流程的复杂度对于资源极度紧张或者对启动时间要求极高的场景要权衡是否值得。但对绝大多数联网设备来说这套机制的收益远大于成本。我现在做 ESP32 项目第一件事就是把双分区和回滚配好后面开发起来心里踏实得多。这套东西说到底就是给设备留一条退路。固件可以写错升级可以失败但设备不能失联。把回滚机制做扎实你半夜被叫起来救设备的概率会大大降低。