news 2026/10/6 11:56:28

ESP32固件刷坏防变砖:双分区自动回滚原理与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32固件刷坏防变砖:双分区自动回滚原理与实战

我一直觉得,“ESP32 固件刷坏了会变砖吗”这种问题,答案得分场合说。前两天群里有人把新固件烧进去,板子串口一只循环打印乱码,他急得喊“砖了砖了”。其实那只是应用分区里的程序起不来,bootloader 还活着,用双分区和自动回滚就能把这种“假砖”挡住。我的做法很朴素:旧固件永远留在另一个分区,新固件上线后先跑一个健康检查,检查不过就自己把自己标记成无效,重启后自动退回旧版。整个过程不需要拆壳、不需要短接,远程设备也能自救。这篇文章就聊聊这套方案的原理、配置和我在实践里踩过的坑,适合正在做远程 OTA 升级、又怕把设备刷成砖的朋友。

1. 先给结论:ESP32 的“砖”其实分四种,双分区能救的是其中一大半

先说最直接的结论:普通应用固件刷坏了,绝大多数情况下 ESP32 不会变成真正的砖。真正的砖是那种 USB 识别不到芯片、电脑上没有任何串口设备、只能换 Flash 芯片或者扔垃圾桶的物理级损坏。日常大家嘴上说的“刷砖了”,九成是“系统起不来”的软错误。

我把常见的“砖”按危害程度排了一下:

砖的类型具体现象损坏位置双分区是否救得了
应用固件崩溃开机黑屏、循环重启、串口打印异常APP 分区能,自动回滚就能处理
应用固件被擦空升级中断,分区里全是 0xFFAPP 分区部分能,取决于 bootloader 回滚策略
分区表损坏芯片反复引导失败,无法进入正常逻辑0x8000 地址附近救不了,需要重新烧分区表
bootloader 被写坏完全无法启动,只能进下载模式重写0x1000 地址附近救不了,但可以手动重烧
Flash 芯片物理损坏擦除失败、写入校验失败、寿命耗尽Flash 芯片本身救不了,只能换芯片

做远程 OTA 最怕的就是第一种和第二种。单分区方案里,新固件覆盖老固件,一旦新固件起不来,设备就真的暂时“砖”了,必须有人到现场用串口烧。双分区方案相当于给系统留了一条退路:旧固件住在一个固定房间,新固件住进隔壁房间,启动时先试新的,试不动就切回旧的。

所以标题里的问题我的答案是:只要你有两个可启动的分区,并且正确配置了自动回滚,固件刷坏的后果会从“变砖”降级成“重启失败”,而且这个失败还能自己修复。这也是现在很多商业 IoT 设备普遍采用的 A/B 升级思路,ESP32 的分区表天然支持这种玩法,只是很多人不知道或者没用起来。

1.1 官方术语里没有“双分区”这个说法,它对应的是 A/B OTA

在 ESP-IDF 的文档里,官方不会说“双分区”,而是说 Application OTA 升级时,可以有factory、ota_0、ota_1这样多个app类型分区。简单理解:factory是出厂固件,ota_0和ota_1是后续 OTA 将要写入的槽位。所谓双分区,就是你至少保留了两个可以引导的应用分区,一个跑新固件,一个跑旧固件。

我习惯把这种设计叫“双保险启动”。一颗 4MB Flash 的普通 ESP32 开发板,完全可以划出两个 1.8MB 左右的应用分区。现在的 ESP32 固件一般控制在 1MB 到 1.5MB 以内,所以这种划分在绝大多数项目里都够用。如果你用的是 8MB、16MB Flash 的模组,那就更宽裕了,甚至可以保留 factory 加两个 OTA 槽,做滚动升级。

1.2 为什么“变砖”这个词被滥用得太厉害了

很多人第一次接触 ESP32 刷机,用的是 ArduinIDE 一键烧录,烧录失败或者固件本身有 bug,板子不干活了,就认为自己把板子“烧废了”。实际上只要下载模式还在,按住 BOOT 键能识别出一个COM口,那就完全能救。双分区存在的意义,就是让这种软故障连人工干预都不需要,设备自己就能决定“刚才那个新版本不行,我用旧版本继续干活”。

2. 双分区能不能自动切换,关键看分区表、otadata 和引导选择机制

要理解双分区,得先知道 ESP32 加电之后到底是怎么找到固件的。很多人只会在 IDE 里点上传,很少看自己烧进去的东西包含几块。其实 ESP32 的 Flash 里不是一个完整的大固件,而是好几个不同功能的区域。

2.1 分区表是 Flash 的地基

ESP32 至少要有一个分区表,它记录了 Flash 上每个区域的名称、类型、子类型、偏移量和大小。这个表本身存在 Flash 的0x8000地址。你在 Arduino 或者 PlatformIO 的编译输出里看到partitions.bin,就是这个地基。

一个典型的分区表长这样:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, factory, app, factory, 0x20000, 0x1F0000, ota_0, app, ota_0, 0x210000, 0x1E0000,

这里面的关键点有三个:

  • nvs分区用来存各种键值对,比如 WiFi 配置、设备参数。两个固件可以共享这个分区,但共享也会带来兼容性问题,后面第五部分再细说。
  • otadata分区专门记录“当前该从哪个 app 分区启动”,以及这个分区的回滚状态。没有它,双分区根本不知道自己该引导谁。
  • factory和ota_0是两个独立的应用槽。一个放稳定版,一个放测试版。

对于 4MB Flash 的板子,两个 app 分区各给 1.8MB 左右已经比较均衡。如果你用 PlatformIO,直接在platformio.ini里指定这个 CSV 文件路径就行:

[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino board_build.partitions = partitions/ota.csv

2.2 bootloader 是如何决定跑哪个固件的

ESP32 上电后,ROM 引导程序先运行,它负责加载二级 bootloader。二级 bootloader 读取otadata分区,看当前推荐的引导目标是谁,然后跳转到对应的 app 分区执行代码。这套流程写死了,你不需要自己改 bootloader,只需要维护好otadata里的状态。

这个流程跟电脑开机有点像。ROM 是主板 BIOS,二级 bootloader 是引导程序,otadata是启动项配置,factory和ota_0就相当于 C 盘和 D 盘各装了一个操作系统。你觉得新版系统不好用,可以靠引导配置切回 D 盘的老系统。ESP32 的双分区自动回滚,本质上就是在开机时自动完成这个“切回 D 盘”的动作。

2.3 手动烧录时的注意事项

如果你第一次往一块板子上烧双分区方案,不能只烧 app 固件。也要确保分区表、bootloader 和 otadata 都被正确写入。PlatformIO 的pio run -t upload默认会处理这些,但如果你像我喜欢用 esptool 直接照着地址写,至少要注意:

  • bootloader 写到0x1000
  • 分区表写到0x8000
  • otadata 分区里面是空白数据也没关系,bootloader 第一次会默认找factory
  • 应用固件写到对应的 app 偏移量

如果手滑把空白数据写到了0x8000,分区表就没了,此时虽然能通过下载模式重刷,但不会自动回滚。

3. 自动回滚的底层原理:不是检测到故障才回滚,而是没收到“确认信号”就回滚

我看到很多人在讨论自动回滚时,第一反应是问“bootloader 怎么判断新固件坏了”。其实 bootloader 本身不跑你的业务代码,它没法判断新固件逻辑上有没有问题。它的工作方式更像一个倒计时机制:新固件启动后,必须在规定时间内给系统发一个“我已经确认没问题”的信号,否则就认为这版固件不可靠,重新引导旧版本。

这个设计非常关键。理解它之后,很多奇怪的现象就解释得通了:为什么某次新固件已经能正常跑业务了,设备一重启又回到旧版?多半就是新固件里没有调用确认函数,导致倒计时一直没被取消。

3.1 otadata 里的状态机

ESP-IDF 把 app 分区的状态记录在otadata里,每个可引导分区都有对应的标记位。大体可以理解成三种状态:

  • UNDEFINED:刚烧录进去,还没确定好坏
  • VALID:应用主动确认过,表示“这版固件可靠,继续用”
  • INVALID:应用主动标记,或者倒计时到了,表示“这版固件不行,回滚”

当你在一个空分区里写完新固件,bootloader 引导它之后,它并不立即是VALID。此时你的应用代码就该承担起“自我检查”和“自我确认”的责任。如果你在新固件里什么都不做,系统会在多次重启后认为它始终未确认,最终把它标记成INVALID,并引导回旧分区。

在 ESP-IDF 里,开启这个功能需要配置:

CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y

在menuconfig里可以勾选。我自己的经验是,做远程 OTA 项目时千万别把这个开关关掉,否则双分区就变成了手动切换分区,失去了“自动”的意义。

3.2 健康检查窗口:确认动作不该放在 setup 第一行

很多初学者写的自动回滚代码长这样:启动后立刻调用esp_ota_mark_app_valid_cancel_rollback(),把新固件标记成有效。这等于考试还没开始就先交卷,任何隐藏问题都没有经过验证。正确做法是给新固件一个健康检查窗口,在这段时间里检查设备的关键能力。

比如你的设备是一块采集环境温湿度的传感器,那健康检查就不能只看 MCU 能不能运行setup(),而要确认真实业务链路是通的:

  • 传感器 I2C 能不能正常读回数据
  • 读回的数据是否在合理范围内
  • WiFi 能不能连接上
  • 能不能与服务器建立会话

这些检查全部通过,才执行确认动作。如果任何一个环节失败,就主动把当前固件标记为无效并重启,让系统回滚到上一版。整个过程可以用一句话总结:先上岗,再验收,验收不过就换人。

3.3 ESP-IDF 和 Arduino 的 API 对应关系

Arduino 框架和 ESP-IDF 底层共用同一个 ESP-IDF 系统,所以相关函数在 Arduino 里也能直接用。只需要引入头文件:

#include "esp_ota_ops.h"

三个核心操作:

操作函数作用
确认当前固件有效esp_ota_mark_app_valid_cancel_rollback()取消回滚倒计时,固定使用当前分区
标记当前固件无效并重启esp_ota_mark_app_invalid_rollback_and_reboot()立即回退到上一有效固件
获取当前运行分区esp_ota_get_running_partition()判断当前是从 factory 还是 ota_0 启动

在 PlatformIO 的 Arduino 框架工程里,直接写这段代码就能调用。这些函数就在 ESP-IDF 组件里,Arduino core 编译时会一起链接进去。

4. 完整示例:一个自带健康检查的自动回滚工程

上面讲的都是原理,这一节给出一套可以直接抄作业的工程结构。我以 PlatformIO 加 Arduino 框架为例,因为这是目前最主流的开发组合。

4.1 工程目录和分区文件

工程结构很简单:

my_project/ ├── platformio.ini ├── partitions/ │ └── ota.csv └── src/ └── main.cpp

ota.csv的内容用我前面给的那份分区表。platformio.ini里除了指定分区文件,还可以把串口监视波特率固定下来:

[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino board_build.partitions = partitions/ota.csv monitor_speed = 115200

4.2 健康检查与回滚代码

main.cpp里我写了一个非常典型的健康检查流程。为了让演示更直观,我故意把“健康检查通过条件”做成一个变量,正常情况下你把它替换成真实的传感器读取、服务器连接检测就行。

#include <Arduino.h> #include "esp_ota_ops.h" #define HEALTH_TIMEOUT_MS 30000UL // 模拟一项需要验证的外设检查 bool checkSensor() { // 实际项目里这里会读 I2C 或者 ADC,并判断数值是否合理 // 这里直接模拟成功 return true; } bool checkNetwork() { // 实际项目里这里会检测 WiFi 是否连上、能否 ping 通服务器 // 这里直接模拟成功 return true; } bool healthCheckPassed() { if (!checkSensor()) return false; if (!checkNetwork()) return false; // 还可以加更多业务自检 return true; } void setup() { Serial.begin(115200); delay(300); Serial.printf("Booting from: %s\r\n", esp_ota_get_running_partition()->label); unsigned long start = millis(); bool ok = false; while (millis() - start < HEALTH_TIMEOUT_MS) { if (healthCheckPassed()) { ok = true; break; } delay(200); } if (ok) { esp_ota_mark_app_valid_cancel_rollback(); Serial.println("Health check passed, firmware confirmed."); } else { Serial.println("Health check failed, rolling back..."); delay(100); esp_ota_mark_app_invalid_rollback_and_reboot(); } } void loop() { // 正常业务逻辑 }

这段代码的精髓在while循环。它既给了新固件 30 秒的验证时间,又不会让设备在故障状态下无限等下去。条件不通过,就调用esp_ota_mark_app_invalid_rollback_and_reboot(),系统会立刻重启并引导到上一个有效分区。

顺带提醒一句:正式项目里,healthCheckPassed()里的检查一定要有真实业务意义,不要只是return true。我见过有人把所有检查函数都写成空返回,最后回滚机制一次都没触发过,新固件把传感器接反的硬件问题带到了所有设备上。

4.3 故意刷一个“坏固件”来验证自动回滚

写完代码之后,我强烈建议你先在本地做一次“自杀式测试”。这比在生产设备上直接踩雷舒服多了。做法分三步:

第一步,把当前代码烧进factory分区,让它作为稳定版本。第二步,修改healthCheckPassed(),让网络检查永远失败,然后编译固件,通过 OTA 方式烧到ota_0分区。第三步,重启设备,观察串口日志。

正常情况下你会看到类似这样的过程:新固件启动,健康检查失败,串口打印回滚提示,系统重启,随后 bootloader 引导回factory分区的旧固件。整个过程设备不需要连接电脑,完全靠自己的状态判断完成了版本切换。

如果你的板子没有预先烧录好factory固件,只是通过 USB 线直接上传新固件,这期间的逻辑也成立,但建议把第一版确认过的固件先放进factory。这样即使后续 OTA 的ota_0分区被反复写废,只要factory没动,设备永远有一条保底路线。

5. 回滚方案里最容易踩的坑:空间、otadata 和 NVS 兼容性

理论说再多,不如实际摔两跤。这套方案我跑了几个月,有些坑属于文档里不会写、但一踩一个准的类型。

5.1 分区大小和编译产物不匹配

我最早用 PlatformIO 默认的 1.2MB app 分区,跑一个小型固件绰绰有余。后来固件引入了蓝牙、网页前端资源,体积瞬间膨胀到 2MB 以上。编译是过了,但烧写完启动就失败,因为分区根本装不下完整的镜像。检查方式是编译完成后看firmware.bin的大小,再用它和分区表 CSV 里的分区大小对比。别只看 CSV 的总空间,更要看你是从什么地址开始写的。

5.2 otadata 被擦掉或者写坏会导致启动选择混乱

有一种情况非常隐蔽:你在测试回滚时,习惯性用esptool.py erase_flash把整个 Flash 清空,然后只烧bootloader、partitions和app。此时otadata分区是空的,bootloader 还能按默认逻辑去找factory,但这不代表自动回滚机制还能正常工作。真正干净的首次烧录,应该让 IDE 或 esptool 按照完整流程把分区表、bootloader、otadata 和 app 都写入,而不是只烧一个 app。

5.3 NVS 分区被新旧固件共用带来的脏数据

这是我觉得最坑的一点。分段回滚保证了固件版本能退回,但 NVS 里的数据不会跟着回滚。假设新固件在运行过程中写入了新版的数据结构,把某个键值的格式改掉了,然后它因为健康检查失败回滚到旧固件,旧固件读取到新格式的数据,可能会直接崩掉。这就出现了一个尴尬现象:固件版本已经回滚了,但设备依然处于不可用状态。

我的解决思路是给 NVS 键值加版本前缀,比如"v2_calib_value"和"v1_calib_value"。旧固件只读自己认识的键,新固件写自己的键,两边互不干扰。等新版本在线上稳定运行一段时间后,再通过一次干净的升级把废弃键清掉。

5.4 确认时机太早或太晚

确认太早,健康检查形同虚设;确认太晚,设备每次重启都要等完整个健康检查窗口,生产环境会显得很迟钝。更好的做法是把确认时机放在业务真正就绪之后。比如你的设备每次启动后需要先联网、再同步一次服务器时间、最后打开某个执行器,那么确认动作就应该放在“执行器打开”之后,而不是setup()开头。

5.5 注意死循环不一定会触发自动回滚

很多人以为新固件一旦 “hang” 住,bootloader 就会主动回滚。严谨地说,这取决于你有没有开启看门狗以及系统的复位路径。如果新固件陷入一个死循环但一直没有喂狗,也没有任何中断触发复位,bootloader 的倒计时可能根本不会推进。所以你的健康检查里至少要有一个 loop 周期计时或看门狗刷新逻辑,确保异常时能产生复位事件,让 bootloader 有机会介入。

6. 双分区和自动回滚救不了的砖,你得知道怎么手动自救

双分区不是万能药,它保护的是应用分区这一层。下面这几种情况,双分区插件也拦不住,但通过合理的手动操作完全可以救回来。

6.1 bootloader 和分区表被写坏,怎么进下载模式

不管你的应用分区里放着什么,只要 bootloader 或分区表被写坏,芯片都无法正常进入业务逻辑。好在 ESP32 的 ROM 引导程序是最底层的,它不受分区表影响,只要你把 IO0 拉低,在供电状态下按住 BOOT 再短按 EN,芯片就会进入串口下载模式。这个时候 Windows 或 Linux 下会识别出一个新的串口设备,esptool 可以和它通信。

重新从头烧录的命令大致是这样的:

esptool.py --port COMx erase_flash esptool.py --port COMx write_flash 0x1000 bootloader.bin 0x8000 partitions.bin 0xe000 boot_app0.bin 0x10000 firmware.bin

不同工具链生成的文件名略有差异,但思路一致。关键是不管当前 Flash 里有多乱,下载模式永远给你留了最后一扇门。

6.2 养成备份原始 bootloader 和分区表的习惯

很多厂家的 ESP32 模组出厂时可能预置了特殊固件,bootloader 的配置也可能和官方默认不同。玩刷机之前,我建议先把整颗 Flash 的内容备份一份。最简单的备份命令是:

esptool.py --port COMx read_flash 0x00000 0x400000 flash_backup.bin

这样即使后面把 bootloader 擦得干干净净,也能从备份里把这些部分捞回来,而不是满网找别人分享的镜像。

6.3 回滚确认后的进阶思路

自动回滚能解决“新固件起不来”的问题,但它不能解决“新固件起来了但是业务逻辑有缺陷”的问题,因为这种缺陷不稳定,有可能健康检查通过了,运行几个小时后才出错。对这种故障,比较务实的做法是继续叠加远程控制命令:设备每隔一段时间上报版本号,服务器如果发现设备在重复上报同一个高版本号,可以下发“强制切回旧分区”的指令。这一层属于运维策略,不依赖 bootloader,但对远程设备管理很有价值。

归根结底,我自己的体会是:ESP32 本身就是一颗做了不少容错设计的芯片,给它配上双分区和自动回滚,相当于是给系统上了一道非常便宜但又非常牢靠的保险。设备的侥幸心理不能靠烧录前反复检查来弥补,因为散落在真实环境里的设备不可能每次都亲自上手。把“最坏情况”写进系统设计里,让设备自己具备恢复能力,才是做 IoT 升级该有的觉悟。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 11:54:39

ESP32-P4与ESP32-C5双芯带屏网关设计实战:从架构到避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 11:53:47

Cadence Allegro高速PCB等长设计:蛇形走线与差分等长实战技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 11:50:21

SOEM控制汇川SV660N实战:PDO映射与CiA402状态机详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 11:50:21

PCB表面缺陷检测数据集:1000张图六类缺陷与YOLO11训练实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 11:50:20

激光雷达与RTK标定实战:从坐标系对齐到轨迹精度闭环

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华