
最近我在做一个带远程升级的小项目顺手把 ESP32 的固件升级方案改成了“双分区 自动回滚”正好可以正面回答广大玩 ESP32 的小伙伴最关心的问题ESP32 固件刷坏了会不会变砖先给结论绝大多数情况下不会真正变砖最多是“软砖”——也就是设备起不来、莫名重启但串口下载模式还能进重新烧录就能救回来。真正值得担心的是设备已经部署到现场、没法用串口救的时候这时候双分区和自动回滚就能救命。这篇文章不玩虚的直接从原理、分区表、代码、实测过程一步步讲清楚适合做 ESP32 项目、准备上 OTA 升级、或者单纯怕把板子刷坏的朋友阅读。1. ESP32 真的会被刷成砖吗先搞清楚“砖”的分类1.1 “变砖”其实分两种圈里常说“变砖”但很少人认真区分。在我看变砖至少分两层硬砖芯片根本没法启动也无法进入任何可编程状态。比如说 Flash 彻底损坏、电源问题、eFuse 熔断错误等。这种基本只能换芯片不是刷固件能解决的。软砖内部 Boot ROM 还能运行但二级引导程序或用户固件坏了设备表现为无法正常启动、反复重启、亮灯不跑程序。不过串口下载模式仍然有效用一根 USB 线连接电脑重新烧录就能恢复。ESP32 刷固件“变砖”绝大多数属于软砖。因为 ESP32 芯片内部固化了一段 Boot ROM也就是一级引导这段程序烧不掉、改不了。上电后芯片先执行 ROM 里的代码检查芯片引脚状态决定进入下载模式还是从外部 Flash 启动用户固件。只要这段 ROM 完好芯片就不会变成硬砖。1.2 ESP32 的引导流程为什么没这么容易“硬砖”理解这个问题的关键是搞清楚 ESP32 的上电引导顺序。我把流程简化成一个三步链路芯片上电复位CPU 从内部 ROM 执行一级引导代码。一级引导检查 GPIO0、GPIO2 等引脚的电平。如果满足特定条件就进入串口下载模式等待 esptool、ESP-IDF、Arduino IDE 等工具通过 UART 烧录否则继续执行 Flash 里的二级引导程序。二级引导程序负责加载分区表找到用户应用分区跳转执行真正的固件。所以哪怕你把 Flash 里的内容清得干干净净只要 GPIO0 在复位时被拉低ROM 里的一级引导程序还是会运行并进入下载模式。这时候用 esptool 写入 bootloader、分区表和固件芯片马上就能复活。这就是我不怕刷坏 ESP32 的原因它远比想象中抗造。要特别注意一点Flash 加密、Secure Boot 这类高级安全功能开启后情况会变得复杂。如果开启了 Flash 加密但密钥丢失或 eFuse 状态异常可能导致常规方式连不上芯片、无法烧录。所以如果是量产项目安全特性一定要做好密钥备份和烧录流程设计个人开发调试阶段暂时不用碰这些。1.3 真正会导致“砖”的操作虽然 ESP32 不容易硬砖但有一些操作确实会让板子进入“看起来彻底没救”的状态Flash 加密配置错误开了 Flash 加密后随意更换 Flash导致芯片读不到有效数据。eFuse 熔断失误eFuse 是一次性编程的熔断了就回不去了。比如误把某位设成不启动 Boot ROM芯片就真的起不来了。烧错 bootloader 或分区表虽然不会弄死硬件但如果 bootloader 本身损坏设备可能一直无法从 Flash 启动反复重启。这依然可以通过下载模式重新烧录解决。使用不兼容的 Flash 参数比如把 Flash 频率、模式配置成硬件不支持的参数导致读 Flash 失败。双分区和自动回滚解决的核心问题是“用户固件写坏导致设备起不来”的场景。它不能修复硬件级别的损坏但它能把最频繁、最容易出现、也最让人头疼的“软砖”问题消灭在萌芽状态——尤其是设备部署到现场后没法拆壳插串口的时候。2. 双分区方案OTA 分区表与原理拆解2.1 为什么升级/刷机要配双分区很多人刷固件的习惯是“全盘抹掉再写入”这在本地调试时没毛病但如果你已经做了 OTA或者设备放在远程现场这种单分区方案就非常危险。想象一下设备只有一个应用分区OTA 写入过程中断电、网络中断、固件包损坏Flash 里的旧固件可能已经被覆盖了一部分剩下的是一份残缺的新固件重启后系统直接起不来。没有现场人工干预这台设备就只能躺着等维修。双分区方案相当于给设备准备了两个应用槽位。例如 app0 和 app1升级时把新固件写入当前没有运行的那个分区写完后先不急着切换等新固件自己确认“我一切正常”下一次启动才正式切换到新分区。如果新固件有问题启动失败引导程序会自动回退到旧分区运行。这套逻辑很像电脑的双系统引导无非是嵌入式设备把它做得更自动、更严格。所以做 OTA 升级、远程维护类的 ESP32 项目我的建议非常明确务必上双分区务必打开自动回滚。否则那一次“升级失败”就会变成一次现场差旅成本和体验都很难看。2.2 分区表怎么设计ESP32 用一张分区表partition table来管理 Flash 布局。默认的单分区方案通常只有 nvs、phy_init、factory 三个分区双分区方案在此基础上增加 otadata、app0、app1。我以一个常见的 4MB Flash 配置为例给出一个可用的分区表# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, app0, app, ota_0, 0x20000, 0x1C0000, app1, app, ota_1, 0x1E0000, 0x1C0000,各分区的作用我简单拆一下nvs非易失存储用来保存 Wi-Fi 配置、校准数据、用户参数等。大小 24KB 起步。otadataOTA 数据分区记录当前激活的应用槽位、下一次要启动的槽位以及回滚状态。分区很小通常 8KB 就够了。phy_initPHY 初始化数据Wi-Fi 射频校准相关参数。app0 / app1两个应用分区subtype 分别是 ota_0 和 ota_1。注意分区大小要一致而且不能小于实际固件大小。分区表中各分区的偏移量一般需要按 0x1000064KB对齐。上面这个表里 app0 从 0x20000 开始就是在 0x10000 的整数倍位置。如果你随便写偏移导致对齐错误烧录后 bootloader 很可能无法正确读取分区表设备就真的“起不来”了。有一点要提醒如果用 4MB Flash 的模组做双分区每个应用分区的可用空间大约是 1.7MB 左右。如果你的固件编译出来超过这个大小就要考虑换 8MB 甚至 16MB 的 Flash 模组或者精简固件功能。这点在选型时就该想清楚免得后面改分区表改到怀疑人生。2.3 bootloader 如何决定从哪个分区启动把分区表烧进去后bootloader 的工作流程就变成这样读取 otadata查看当前激活的分区。如果没有有效的 otadata默认从 factory 分区启动如果分区表里有 factory否则从第一个 ota 分区启动。读取对应 app 分区的头部信息校验固件合法性。跳转执行用户固件。在代码层面ESP-IDF 提供了几个关键 API 来操作这件流程esp_ota_get_running_partition()获取当前正在运行的固件所在分区。esp_ota_get_next_update_partition()获取下一个用于写入新固件的空分区。esp_ota_begin/esp_ota_write/esp_ota_end写入 OTA 固件数据。esp_ota_set_boot_partition()设置下一次启动要使用的分区。我用一个生活化的类比来解释双分区就像厨房里有两个灶台。你正在用 1 号灶台做饭发现需要换个锅但不想把正在做的菜倒掉于是先把新锅放上 2 号灶台检查没问题后再把火关掉、把菜移到 2 号灶台继续。如果 2 号灶台的火怎么都点不着你还可以回到 1 号灶台继续吃旧菜。这就是双分区和 OTA 切换的整个过程。3. 自动回滚机制配置、代码与完整实操3.1 回滚判定的核心逻辑双分区的存在只是给了“退回旧版本”的空间真正自动判断“新固件是否健康”的是自动回滚机制。ESP-IDF 的自动回滚逻辑不复杂我用大白话描述一下bootloader 切换到新应用分区并启动新固件。新固件启动后如果在规定时间内没有调用“确认健康”的 APIbootloader 会认为新固件不可用。下一次复位/重启时bootloader 检查到新固件没有被打上“有效”标记就自动回退到旧分区启动。这个“规定时间”由配置项CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE以及应用代码的确认逻辑共同决定。默认情况下新固件启动后需要尽快验证自身状态然后调用esp_ota_mark_app_valid_cancel_rollback()来标记固件可用。如果固件在确认之前崩溃、重启回滚就会自动触发。我首次看到这套机制时觉得它很妙它不是要求开发者写一大堆判断逻辑而是把“健康检查”和“回滚决定”分离开。系统只管你有没有在预期窗口内确认健康至于怎么确认健康由你的业务代码决定。比如我可以把确认动作放在“传感器初始化完成”“网络连接成功”或“关键状态正常上报”之后一旦这些关键步骤执行完毕就调用确认接口。还要补充一个和回滚相关的配置CONFIG_APP_ANTI_ROLLBACK这是防止固件版本回退的选项。打开后升级固件时 bootloader 会检查版本号禁止刷入比当前版本更老的固件。这主要面向量产设备的安全需求防止固件被降级到有漏洞的旧版本。个人项目和调试阶段不一定要开但做产品一定要了解。3.2 代码实现三步走讲完原理直接上代码。以下配置和示例默认使用ESP-IDF v5.x在 menuconfig 里操作。带 PlatformIO 或 Arduino 环境的朋友也可以对应迁移核心逻辑是一样的。第一步配置分区表在项目菜单中设置idf.py menuconfig进入Partition Table - Partition Table选择Custom partition table CSV然后指定partitions.csv路径。同时打开自动回滚Boot ROM - Bootloader - App rollback support - Enable app rollback support如果希望在确认前崩溃会自动回滚还需要检查Boot ROM - Bootloader - Keep the bootloader in the log相关配置或者保持默认即可。第二步编写健康确认代码在一个主任务里把最关键的初始化流程跑完后调用esp_ota_mark_app_valid_cancel_rollback()。示例代码如下#include esp_ota_ops.h void app_main(void) { // 模拟关键初始化流程 bool key_services_ok init_key_services(); if (!key_services_ok) { ESP_LOGE(APP, key services init failed, let bootloader rollback); abort(); // 触发崩溃等待重启回滚 } // 所有关键检查通过确认固件有效取消回滚 esp_err_t err esp_ota_mark_app_valid_cancel_rollback(); if (err ESP_OK) { ESP_LOGI(APP, App is valid, rollback cancelled successfully); } else { ESP_LOGW(APP, Failed to mark app as valid, err%s, esp_err_to_name(err)); } // 继续执行业务逻辑 start_business_loop(); }这里我故意在初始化失败时调用abort()让系统崩溃。如果打开了自动回滚下一次重启 bootloader 就会因为新固件没有确认健康而回退到旧分区。第三步设置升级路径如果你希望通过 OTA 通道把新固件传到另一个分区核心步骤是找到下一个可用分区写入数据然后设置启动分区const esp_partition_t *update_part esp_ota_get_next_update_partition(NULL); esp_ota_handle_t ota_handle; esp_ota_begin(update_part, OTA_WITH_SEQUENTIAL_WRITES, ota_handle); // 从网络或存储中读取固件数据循环调用 esp_ota_write(ota_handle, buf, len) esp_ota_end(ota_handle); esp_ota_set_boot_partition(update_part); esp_restart();注意esp_ota_end返回值要检查如果写入失败不应该调用esp_ota_set_boot_partition否则会切到一个残缺固件。如果开启了回滚保护即便切换过去启动失败后也能回退但多一层检查总归更稳妥。3.3 完整烧录和 OTA 升级流程下面是我在实验过程中实际使用的操作流程包括首次烧录和 OTA 模拟升级。首次烧录烧录完整三件套——bootloader、分区表、应用固件idf.py -p /dev/ttyUSB0 flash monitor这条命令会把 bootloader、分区表、应用固件按正确地址写入 Flash。如果是 PlatformIO对应命令是pio run -t upload它会自动处理分区表烧录。OTA 模拟升级我的做法是先编译一个新版本固件把它生成 OTA 升级包然后通过本地 HTTP 服务让设备下载并写入另一个分区。具体命令要看你的传输方式这里不展开。关键的观察点是日志输出。正常升级成功后日志会看到类似这样的内容I (1234) esp_ota_ops: Updating to app partition ota_1 I (1234) esp_ota_ops: Single OTA write: 0x100 bytes, erased 0x100 bytes ... I (5678) APP: App is valid, rollback cancelled successfully如果我在新固件里故意不调用确认接口或者让它在初始化时崩溃重启后可以看到 bootloader 输出类似I (789) boot: Previous app invalid, rolling back to previous app看到这行日志说明自动回滚生效了设备又回到了旧固件运行。整个实验做完后我对“刷坏固件会变砖吗”这个问题彻底放心了。4. 实测刻意把固件写坏它真的回滚了4.1 制造故障的三种方法理论说得再好不如亲自把设备“搞坏”一次。我建议每个人都做一轮故障注入实验不是折腾自己而是验证回滚机制真的会被触发。我实测时用了三种方式方式一不调用确认接口。新固件启动后什么都不做也不调用esp_ota_mark_app_valid_cancel_rollback()。默认的确认窗口到了之后 bootloader 会判定为新固件无效。方式二初始化时主动崩溃。在 app_main 里直接abort()或esp_system_abort()制造一个系统崩溃场景。方式三关键任务起不来。让 WiFi 或者传感器初始化失败后进入死循环或复位。这样既模拟了真实故障又不至于让系统看起来是故意崩溃。三种方式我都试过触发回滚的路径是一样的新固件没有被打上有效标记重启后回退旧分区。区别只在于崩溃的时间点不同。建议你在自己的项目里至少做一次“不调用确认接口”的测试这是最接近真实误操作的情况。4.2 回滚过程现象记录下面是我实际实验时的一次串口日志摘录去掉时间戳和无关信息后的关键内容I (400) boot: Enabling RNG early entropy source... I (410) boot: Partition table loaded I (410) boot: OTA slot: 0 I (410) boot: Loading app partition app1 at offset 0x1e0000 I (420) boot: Application valid, confirming boot ... I (1250) main: Start to do some critical checks I (1250) main: Something wrong in sensor init E (1250) main: Aborting, let bootloader roll back abort() was called at PC ...崩溃后芯片自动重启。第二次启动的日志里出现了关键行I (410) boot: OTA slot: 1 I (410) boot: Loading app partition app0 at offset 0x20000 W (420) boot: App (slot 1) is invalid, rolling back to slot 0 I (430) boot: Loading app partition app0 at offset 0x20000注意我当时设置的当前运行分区是 app1OTA slot 0 对应的固件写到了 app1所以“回滚到 slot 0”实际上是指 otadata 中记录的下一个候选槽位从切回原来的运行分区。不同版本的日志措辞略有差异但核心逻辑一致新分区固件被判定为无效bootloader 改从旧分区启动。这个实验最让我欣慰的是整个过程完全不需要人工干预也不需要拆开设备去按键、接线设备自己就把自己“救”回来了。这就是自动回滚最大的价值。4.3 如果真起不来了怎么办串口下载模式救砖即使双分区回滚失败比如你第一次烧录就刷坏了 bootloader也别慌。只要芯片还能进入下载模式我们就能救。操作步骤如下拉低 GPIO0把板子的 GPIO0 引脚接地。大多数开发板上标着 BOOT 按钮按住不放就行。给板上电/复位保持 GPIO0 为低电平同时按下复位键或重新插拔 USB。芯片会进入串口下载模式。连接串口确认电脑识别到新的串口设备。用 esptool 烧录esptool.py --port /dev/ttyUSB0 erase_flash esptool.py --port /dev/ttyUSB0 --baud 460800 write_flash 0x0 bootloader.bin 0x10000 partition-table.bin 0x10000 app.bin这里最关键的是把 bootloader、分区表、应用固件的地址写对。bootloader 一般从 0x0 开始分区表默认在 0x10000应用固件的地址要看分区表里 app 分区的偏移。如果定义了 factory 分区就把 app.bin 烧到 factory 分区对应的偏移量。还有一个小技巧如果 erase_flash 后设备依然异常可以检查串口驱动、USB 线、以及是否有其他程序占用串口。实测下来很多“救不活”的板子其实是 USB 转串口芯片驱动没装好或者用了只充电不通数据的线。5. 常见问题与避坑指南5.1 我遇到过的几个典型问题做这套方案时我踩过的坑也不少整理成表格方便对照排查问题现象根本原因解决方式修改分区表后无法启动分区偏移未按 0x10000 对齐重新规划偏移量保持对齐OTA 写入成功但重启后仍走旧分区otadata 分区被误擦除或未写入确认分区表里有 otadata调用 set_boot_partition新固件每次都被判定无效应用中没调用确认接口在关键初始化完成后调用esp_ota_mark_app_valid_cancel_rollback()确认窗口太短正常启动也被回滚初始化流程超过窗口时间增大CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE相关超时设置或把确认动作提前回滚时日志显示版本号不匹配打开了反回滚但版本号未递增升级新固件时同步升级版本号确保高于旧版本其中“确认窗口太短”这个问题比较容易被人忽略。ESP-IDF 的默认设置里固件启动后会在一定时间内等待确认。如果你的固件启动流程本身就要跑很久比如要等 GPS 搜星、连服务器上报数据可千万别把确认动作放在最后一步否则回滚判定可能先于确认触发。我的经验是把确认动作放在“核心系统初始化完成业务可以短期自恢复”的位置而不是等所有网络业务都跑通后才确认。5.2 什么时候适合用双分区什么时候没必要双分区不是银弹它要占用双倍的应用存储空间。我的看法是这样的必须要做双分区的场景需要 OTA 升级的设备、部署在远程或户外的设备、无法人工拆机复位的产品。这种情况下双分区带来的冗余是值得的。没必要做双分区的场景纯本地调试、教学实验、一次性烧录的固定逻辑板卡。你只需要单分区 串口重新烧录就行没必要为固定程序浪费一倍 Flash。可选项如果你担心 Bootloader 本身损坏可以再做一份 Bootloader 的冗余设计但大多数 ESP32 项目不需要这么激进因为 Bootloader 在正常烧录后不会被频繁改写。另外选型时要提前看模组 Flash 容量。ESP32-C3 常见 4MB 模组双分区单槽位大约 1.7MBESP32-S3 有 8MB、16MB 版本开发 OTA 项目时尽量选容量余量大的型号给自己留后路。5.3 操作经验小结最后分享几条我在实际项目中反复验证过的经验升级前先记录当前固件状态。用esp_ota_get_running_partition()和esp_ota_get_state_partition()确认当前分区和状态千万别在状态混乱时做覆盖式写入。把回滚确认和版本检查结合起来。生产环境建议同时打开CONFIG_APP_ANTI_ROLLBACK并给固件设置版本宏比如PROJECT_VER在代码里打印出来。方便排查也防止误刷旧包。OTA 写入期间做好数据保护。如果从网络下载固件建议先下载到独立的存储区校验完再写入应用分区。没有独立存储的话至少要在写入时加上分段校验和。经常做“断电写半截”实验。写 OTA 固件写到一半直接断电重启后看系统能否进入旧固件。这是对双分区和回滚机制最严格的测试建议量产前必测。我个人在实际操作中最深的体会是双分区 自动回滚的最大价值不是“让新固件能成功升级”而是“让升级失败不再成为事故”。我之前做过一台部署在现场的设备网络抖动导致 OTA 包下载不完整整个应用分区被覆盖了一半如果没有双分区和回滚那台设备就只能等我跑一趟现场。现在它自己会退回旧固件继续工作我只需要重新推送一次完整升级包。后续做量产设备我还建议在回滚机制之上叠加一套“远程状态上报”把固件版本、分区状态、回滚原因上报到服务器这样就算升级失败也只需要在后台看日志不需要跑到设备旁边折腾串口了。