☰
ESP32双分区与自动回滚:OTA升级失败自救指南
2026/10/30 3:58:28 网站建设 项目流程

1. 从一次“刷废了”的深夜事故说起

凌晨两点,我盯着串口监视器里不断滚动的乱码,手里的开发板已经第三次重启失败。那天晚上我只是想给一个跑了半年的 ESP32 温湿度采集节点加个蓝牙配网功能,结果一次错误的烧录把分区表冲掉了,设备彻底不响应,连串口都进不去。当时脑子里只有一个念头:这玩意儿是不是变砖了?

后来折腾到天亮才把它救回来,也是从那次开始,我把手头所有 ESP32 项目都改成了双分区加自动回滚的方案。这篇文章就把这套东西从头到尾讲清楚——ESP32 固件刷坏了到底会不会变砖、双分区是怎么工作的、自动回滚靠什么触发、OTA 升级怎么配合这套机制做到“升坏了也能自己退回来”。不管你是刚拿到第一块 ESP32 的新手,还是已经在做量产固件的老手,这套思路都能直接用。

先说结论:ESP32 在绝大多数情况下不会真正变砖,因为它出厂就带了一段不可擦写的 ROM bootloader,只要硬件没坏、串口没被禁用,你永远可以用烧录工具把它救回来。但“能救回来”和“不用救”是两码事。真正让人头疼的不是变砖,而是设备装在现场、装在客户手里、装在你够不着的地方,这时候一次失败的 OTA 就是一场灾难。双分区和自动回滚解决的正是这个问题。

2. ESP32 到底会不会变砖:先搞清楚“砖”的几种形态

2.1 软砖、硬砖和“假砖”的区别

很多人一说变砖就慌,其实“砖”分好几种。我按严重程度排一下:

类型表现能否自救常见原因
假砖串口有输出但一直重启能,重新烧录即可固件逻辑死循环、看门狗复位
软砖串口无输出,但能进下载模式能,用烧录工具救分区表损坏、bootloader 被覆盖
硬砖完全无响应,下载模式也进不去基本不能,需换芯片烧录时断电、eFuse 误操作
逻辑砖能运行但功能全废能,OTA 或串口重刷配置错误、密钥丢失

ESP32 的 ROM bootloader 是固化在芯片内部 ROM 里的,用户代码永远改不了它。这就意味着只要芯片没物理损坏,你总能用串口把它拉进下载模式重新烧。真正意义上的“硬砖”只有两种情况:一是烧录过程中断电导致 Flash 里的 bootloader 和分区表同时损坏且无法进入下载模式(极少见),二是误烧了 eFuse 里的安全启动或 Flash 加密密钥,把芯片锁死了。

注意:eFuse 是一次性可编程的,烧进去就改不了。如果你在量产阶段开了 Secure Boot 和 Flash Encryption,一定要先把密钥备份好,否则芯片真的会变成一块只能看的“砖”。

2.2 为什么说“刷坏了”通常不是砖

我见过太多人把“刷坏了”等同于“变砖”。实际上 ESP32 的启动流程是这样的:上电后先跑 ROM bootloader,它检查 GPIO0 等引脚的电平决定进下载模式还是正常启动,然后从 Flash 的 0x1000 偏移处加载二级 bootloader,再由二级 bootloader 读分区表、找 app 分区、跳转执行。

这个链条里,只要 ROM bootloader 还在,你就能通过串口重新写入。而 ROM bootloader 在芯片内部,擦不掉。所以哪怕你把整个 Flash 全擦成 0xFF,插上 USB 线、按住 BOOT 键、点一下烧录,它照样能活过来。

真正会让人误判为“砖”的,往往是这几种情况:USB 转串口芯片驱动没装好、开发板的自动下载电路有问题、串口被其他程序占用、或者供电不足导致芯片根本没启动。这些都不是砖,是环境问题。

2.3 现场设备才是真正的痛点

实验室里刷坏了,拔下来插电脑上重刷就行。但如果设备已经装在墙上、埋在地下、放在客户机房,你不可能每次都跑现场。这时候一次失败的 OTA 升级,就意味着设备失联、数据中断、客户投诉。

这就是双分区和自动回滚存在的意义:让设备在升级失败时能自己退回到上一个能跑的固件,不需要人去现场,甚至不需要人知道。这套机制不是锦上添花,是量产设备的标配。

3. 双分区机制:给固件准备两个“房间”

3.1 分区表是什么,为什么它决定了生死

ESP32 的 Flash 不是一整块随便用的空间,它被划分成若干个分区,每个分区有固定的偏移地址和大小。分区表本身也是一块数据,通常放在 Flash 的 0x8000 偏移处。二级 bootloader 启动时会先读这张表,然后根据表里的信息找到该加载哪个 app。

一个最基础的单 app 分区表长这样:

# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x6000 phy_init, data, phy, 0xf000, 0x1000 factory, app, factory, 0x10000, 0x140000

这里只有一个factory分区,所有固件都往这里烧。一旦这个分区里的固件有问题,设备就起不来,因为 bootloader 没有别的选择。

双分区的思路很简单:把 app 区域切成两块,一块叫ota_0,一块叫ota_1,再加一个otadata分区记录当前该从哪块启动。分区表变成这样:

# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x6000 otadata, data, ota, 0xf000, 0x2000 phy_init, data, phy, 0x11000, 0x1000 ota_0, app, ota_0, 0x20000, 0x180000 ota_1, app, ota_1, 0x1A0000,0x180000

两块 app 分区大小一样,轮流使用。当前跑的是ota_0,新固件就写到ota_1;下次升级再写回ota_0。otadata分区里记录着“当前有效的是哪块、下次该启动哪块、有没有正在等待验证的固件”。

3.2 双分区怎么保证“至少有一个能跑”

关键在于 bootloader 的启动逻辑。每次上电,二级 bootloader 会读otadata,然后按下面的规则决定加载哪个分区:

  1. 如果otadata里标记了某个分区为“有效”,就加载它。
  2. 如果标记了某个分区为“待验证”,也加载它,但会设置一个标志,告诉应用“你是新固件,需要自己确认能跑”。
  3. 如果otadata是空的或者损坏,就回退到第一个可用的 app 分区。

这套逻辑保证了:只要有一块 app 分区里的固件是完整的、被标记为有效的,设备就一定能启动。新固件写进去之后,在它自己确认“我能跑”之前,bootloader 不会把旧固件标记为无效。也就是说,旧固件始终躺在另一块分区里,随时可以退回去。

我一般会把两块分区都设成 1.5MB,对于大多数带 WiFi 和蓝牙的应用来说够用了。如果你的固件特别大,比如带了语音模型或者大量字库,那就得算一下 Flash 总容量,别把分区表撑爆了。

3.3 分区大小怎么算,别拍脑袋

分区大小不是随便填的。假设你用的是 ESP32-WROOM-32,Flash 是 4MB,也就是 0x400000。分区表从 0x8000 开始,前面还有 bootloader 和分区表本身占的空间。我一般这样分配:

  • bootloader:0x1000 开始,占 0x7000
  • 分区表:0x8000 开始,占 0x1000
  • nvs:0x9000,占 0x6000
  • otadata:0xf000,占 0x2000
  • phy_init:0x11000,占 0x1000
  • ota_0:0x20000,占 0x1A0000
  • ota_1:0x1C0000,占 0x1A0000
  • 剩余:0x360000 到 0x400000,约 640KB,可以留给 SPIFFS 或 FATFS 存数据

两块 app 分区各 1.625MB,加起来 3.25MB,加上前面的系统区和后面的数据区,刚好把 4MB 用完。如果你用的是 8MB 或 16MB 的模组,可以适当放大 app 分区,但没必要太大,因为固件本身通常也就 1MB 出头。

实操心得:分区表一旦确定,量产之后就不要随便改。改分区表意味着 OTA 升级时连分区表一起更新,风险很高。我一般会在项目初期就把分区表定死,后面只动应用代码。

4. 自动回滚:让设备自己判断“新固件行不行”

4.1 回滚的触发条件是什么

自动回滚不是魔法,它依赖一套“标记-验证-确认”的流程。ESP-IDF 里把这套机制叫esp_ota和esp_ota_img_ops,核心逻辑是这样的:

  1. 新固件被写入ota_1分区,otadata里把ota_1标记为“待验证”,同时记录下次启动从ota_1开始。
  2. 设备重启,bootloader 加载ota_1,应用开始运行。
  3. 应用启动后,调用esp_ota_mark_app_valid_cancel_rollback()告诉系统“我跑起来了,没问题”。
  4. 如果应用在启动过程中崩溃、重启、或者主动调用esp_ota_mark_app_invalid_rollback_and_reboot(),系统就会把ota_1标记为无效,下次启动回到ota_0。

关键点在于第 3 步:新固件必须主动确认自己有效。如果它没来得及确认就挂了,bootloader 下次启动时会发现“待验证”状态还在,于是自动回滚到旧分区。

这个“没来得及确认”的窗口期是可以控制的。ESP-IDF 默认会在启动时检查otadata,如果发现有待验证的固件,就启动一个倒计时。应用需要在倒计时结束前调用确认函数,否则系统认为新固件有问题,自动回滚。

4.2 代码里怎么实现确认和回滚

在 ESP-IDF 里,确认新固件的代码大概长这样:

#include "esp_ota_ops.h" void app_main(void) { const esp_partition_t *running = esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { // 这里做自检:WiFi 能不能连、传感器能不能读、关键任务能不能跑 if (self_test_passed()) { esp_ota_mark_app_valid_cancel_rollback(); ESP_LOGI(TAG, "新固件自检通过,已确认有效"); } else { ESP_LOGE(TAG, "新固件自检失败,准备回滚"); esp_ota_mark_app_invalid_rollback_and_reboot(); } } } // 正常业务逻辑 ... }

self_test_passed()是你自己写的自检函数。我一般会检查这几项:WiFi 能否在 10 秒内连上、MQTT 能否订阅成功、关键传感器能否读到合理数值、看门狗有没有被意外触发。只有全部通过,才调用确认函数。

如果你用的是 Arduino 框架,ESP32 的 Arduino 核心也封装了类似的 API:

#include <Update.h> #include <esp_ota_ops.h> void setup() { const esp_partition_t *running = esp_ota_get_running_partition(); esp_ota_img_states_t ota_state; if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) { if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) { if (selfTest()) { esp_ota_mark_app_valid_cancel_rollback(); } else { esp_ota_mark_app_invalid_rollback_and_reboot(); } } } }

Arduino 环境下这套 API 是可用的,但要注意版本。我实测 ESP32 Arduino Core 2.0.5 以上都支持,太老的版本可能没有esp_ota_mark_app_invalid_rollback_and_reboot。

4.3 自检逻辑怎么写才靠谱

自检不是走形式,它决定了设备会不会误回滚。写得太严,网络稍微抖一下就把好固件判死刑;写得太松,坏固件也能蒙混过关。我的经验是分层次:

  • 硬性检查:芯片能不能正常初始化、看门狗能不能喂、关键外设能不能通信。这些不过,直接回滚。
  • 软性检查:WiFi 连接、云端握手、数据上报。这些给重试机会,比如重试 3 次都失败才回滚。
  • 延时确认:不要一启动就确认,让设备跑个 30 秒到 1 分钟,观察有没有异常重启。我一般会在app_main里起一个定时器,60 秒后如果系统还稳定,再调用确认函数。

注意:确认函数只能调用一次,调用之后otadata里的状态就变成“有效”了。如果你在确认之后又发现固件有问题,那就只能靠下一次 OTA 来修,自动回滚已经帮不了你了。

5. OTA 升级全流程:从服务器到设备落地

5.1 OTA 的两种模式:推和拉

OTA 升级本质上就是把新固件传到设备上,然后写入备用分区。传输方式有两种:

  • 推模式:服务器主动把固件推给设备,比如通过 MQTT 下发升级指令,设备收到后去指定 URL 下载。
  • 拉模式:设备定期检查服务器有没有新版本,有就自己下载。

我一般用拉模式,因为设备数量多了之后,推模式对服务器压力大,而且设备离线时推不过去。拉模式让设备自己控制节奏,服务器只提供一个版本查询接口和固件下载地址。

一个典型的拉模式流程:

  1. 设备启动后,向服务器请求当前最新版本号。
  2. 比较本地版本号和服务器版本号,如果服务器更新,进入升级流程。
  3. 设备调用esp_http_client从服务器下载固件,边下边写ota_1分区。
  4. 下载完成后校验固件完整性(通常是 SHA-256)。
  5. 设置下次启动分区为ota_1,重启。
  6. 新固件启动,自检,确认或回滚。

5.2 固件下载和写入的关键代码

ESP-IDF 提供了esp_https_ota组件,把下载和写入封装得很简单:

esp_http_client_config_t config = { .url = "https://your-server.com/firmware.bin", .timeout_ms = 10000, }; esp_https_ota_config_t ota_config = { .http_config = &config, }; esp_err_t ret = esp_https_ota(&ota_config); if (ret == ESP_OK) { ESP_LOGI(TAG, "OTA 成功,准备重启"); esp_restart(); } else { ESP_LOGE(TAG, "OTA 失败: %s", esp_err_to_name(ret)); }

这段代码看起来简单,但背后做了很多事:建立 HTTPS 连接、分块读取、写入备用分区、校验、更新otadata。如果你用 HTTP 而不是 HTTPS,把esp_https_ota换成esp_ota_ops里的基础 API 也行,但安全性差一些。

写入过程中有几个坑要注意:

  • 分区大小:新固件不能超过备用分区的大小,否则写入会失败。我一般会在服务器端就检查固件大小,超过阈值直接拒绝。
  • 网络中断:下载到一半断网,esp_https_ota会返回错误,但已经写入的部分数据会留在备用分区里。下次升级会重新覆盖,不影响。
  • 电源稳定:写入 Flash 时断电,备用分区可能写坏,但旧分区不受影响,设备重启后还是跑旧固件。这就是双分区的价值。

5.3 版本管理和回滚策略

版本号我一般用三段式:主版本.次版本.补丁号,比如1.2.3。设备本地存一个版本号,服务器返回最新版本号,比较之后决定要不要升级。

但版本号比较不能只看大小,还要考虑回滚。比如设备当前跑的是1.2.3,服务器最新是1.2.2,这算升级还是降级?如果1.2.3有问题,你想让设备退回1.2.2,那就得允许降级。我的做法是在服务器端维护一个“目标版本”字段,设备无条件向目标版本靠拢,不管当前版本是更高还是更低。

回滚策略上,我一般设置两级:

  • 自动回滚:新固件自检失败,自动退回旧分区。这是第一道防线。
  • 远程回滚:如果新固件自检通过但运行一段时间后发现问题,服务器可以下发指令让设备切回旧分区。这需要设备在确认新固件有效之前,保留旧分区的引用。

第二级回滚的实现稍微复杂一点,需要在otadata里保留旧分区的信息,或者把旧固件备份到文件系统里。我一般只在关键设备上做这个,普通节点用自动回滚就够了。

6. 实操中踩过的坑和排查技巧

6.1 常见问题速查表

现象可能原因排查方法解决方式
升级后设备不断重启新固件自检失败但没触发回滚看串口日志有没有PENDING_VERIFY检查自检逻辑,确保失败时调用回滚函数
回滚后还是跑新固件otadata没更新读otadata分区内容确认esp_ota_set_boot_partition被调用
OTA 下载到一半失败网络不稳定或分区太小看错误码,检查固件大小增加重试,放大分区
设备完全无响应供电不足或串口被占用换线、换口、量电压用稳定电源,关闭占用串口的程序
分区表冲突分区偏移重叠用esptool.py read_flash读分区表重新生成分区表,确保不重叠

6.2 几个让我印象深刻的坑

坑一:自检通过太早,坏固件被确认。有一次我写的自检只检查了 WiFi 连接,结果新固件 WiFi 能连但 MQTT 一直掉线。因为自检通过了,系统把新固件标记为有效,旧固件被覆盖。后来我加了 MQTT 心跳检查,跑满 60 秒才确认。

坑二:分区表改了但没擦 Flash。我调整了分区大小,重新烧录时只烧了 app,没擦整片 Flash。结果旧的otadata还在,bootloader 按旧分区表找 app,找不到,设备起不来。后来养成习惯:改分区表一定先erase_flash。

坑三:OTA 服务器返回 302 重定向。我用了一个对象存储的下载链接,结果esp_https_ota不自动跟随重定向,下载失败。后来在esp_http_client_config_t里加了.disable_auto_redirect = false,问题解决。

坑四:看门狗和 OTA 冲突。OTA 下载时间比较长,如果任务看门狗超时时间设得太短,下载过程中会被复位。我把 OTA 任务单独放在一个不喂狗的任务里,或者临时延长看门狗超时。

6.3 调试 OTA 的实用技巧

  • 串口日志分级:把 OTA 相关的日志级别调到DEBUG,能看到每一步的详细输出。
  • 模拟失败:故意在自检里返回失败,验证回滚流程能不能走通。这个测试一定要做,不然真出问题时你都不知道回滚有没有生效。
  • 读otadata:用esptool.py read_flash 0xf000 0x2000 otadata.bin把otadata读出来,用十六进制工具看里面的状态标记。
  • 版本号打印:设备启动时打印当前版本号和分区名,方便确认到底跑的是哪块固件。

实操心得:我习惯在固件里加一个“强制回滚”的串口命令,调试时输入命令就能触发回滚,不用真的等自检失败。量产固件里把这个命令去掉或者加密码保护。

7. 双分区和自动回滚的边界:什么情况下也救不了

这套机制很强,但不是万能的。有几种情况它也无能为力:

  • bootloader 被写坏:如果 OTA 过程中连 bootloader 一起更新,而更新失败,设备可能连二级 bootloader 都加载不了。所以除非必要,不要 OTA 更新 bootloader。
  • 分区表被写坏:同理,分区表更新风险极高。我一般只在出厂时烧一次分区表,后面再也不动。
  • eFuse 被误烧:Secure Boot 和 Flash Encryption 的密钥烧错了,芯片直接锁死,什么回滚都救不了。
  • 硬件损坏:Flash 芯片物理损坏、电源烧毁,这些是硬件问题,软件机制无能为力。

所以我的原则是:双分区和自动回滚解决的是“应用固件升级失败”的问题,不解决“系统级组件损坏”的问题。系统级的东西,能不 OTA 就不 OTA。

8. 我个人在实际项目中的体会

我手头有一个跑了两年多的农业监测项目,几十个 ESP32 节点分布在不同的温室里。最开始没有双分区,每次升级都提心吊胆,有一次升级失败导致三个节点失联,开车跑了一天才修好。后来改成双分区加自动回滚,两年里升级了十几次,有两次新固件确实有问题,设备自己退回了旧版本,我第二天看日志才知道。那种“不用跑现场”的踏实感,是这套机制最大的价值。

如果你正在做需要远程升级的 ESP32 项目,我的建议是:从第一版固件就把双分区和自动回滚加上,不要等到出事了再补。分区表定好、自检逻辑写好、回滚流程测好,后面升级就是一件很轻松的事。至于那些担心“刷坏了变砖”的新手,先把串口烧录练熟,知道怎么进下载模式、怎么擦 Flash、怎么读分区表,心里就有底了。ESP32 没那么脆弱,真正脆弱的是没有备份方案的升级流程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询