1. 从一次刷机翻车说起:ESP32 到底会不会变砖
很多人第一次给 ESP32 刷固件,手都是抖的。尤其是用 USB-TTL 接上开发板、敲下esptool.py write_flash回车的那一刻,脑子里全是“万一刷坏了怎么办”。我当年也是这么过来的,第一块 ESP32-WROOM-32 因为分区表填错,上电之后串口只吐乱码,当时真以为它彻底废了。
先把结论摆在前面:ESP32 在绝大多数情况下不会真正变砖。它和手机、路由器那种一旦引导程序损坏就只能拆机上编程器的设备不太一样。ESP32 芯片内部固化了一段 ROM Bootloader,这段代码是出厂就烧死在硅片里的,用户改不了也擦不掉。只要芯片没被物理损坏、供电没出大问题,你永远可以通过正确的引脚组合把它拉进下载模式,重新烧录。
那为什么还有那么多人喊“变砖”?因为大家说的“砖”其实分两种。一种是假砖,程序跑飞了、分区表错了、OTA 升级到一半断电了,表现是串口刷屏乱码或者一直重启,但芯片本身还能进下载模式,重新烧一次就好。另一种是真砖,比如烧录时把 eFuse 里的加密位、安全启动位误烧了,或者供电不稳导致 Flash 芯片损坏,这种才是真的麻烦,往往需要换 Flash 甚至换芯片。
我这次要聊的,就是怎么用**双分区(Dual OTA Partition)加自动回滚(Rollback)**这套机制,把“假砖”的概率压到几乎为零。这套方案的核心思路很朴素:固件永远留一份能跑的备份,新固件启动失败就自动退回旧版本。听起来简单,但里面的分区表设计、回滚标志位、看门狗配合,每一步都有坑。下面我把整个设计思路、实操步骤和踩过的坑一次性讲清楚。
这套内容适合谁看?如果你正在用 ESP-IDF 或者 Arduino 框架做 ESP32 的 OTA 升级,或者你手上有个部署在户外、上墙、装进设备里的 ESP32,不方便每次升级都拆下来插 USB,那这套机制基本是刚需。哪怕你只是想让自己的开发板刷不死,也值得花时间把双分区和回滚搞明白。
2. 双分区与自动回滚的整体设计思路
2.1 为什么单分区 OTA 是个危险游戏
先说说最朴素的 OTA 做法:整个 Flash 只放一个 app 分区,新固件下载下来直接覆盖旧固件。这种做法在实验室里跑跑没问题,一旦放到真实环境就是灾难。原因很简单,覆盖写 Flash 的过程中如果断电、网络中断、或者新固件本身有 bug,你手上就只剩一个半残的固件,设备再也起不来,只能拆机重刷。
我见过太多项目栽在这一步。有个做温湿度采集的朋友,设备装在冷库顶上,OTA 升级到 90% 的时候路由器重启了,结果二十多台设备全部失联,最后请了吊车才把设备取下来。这种代价,一次就够记一辈子。
双分区的思路就是永远保留一份完整的、验证过能跑的固件。Flash 里划出两个 app 分区,比如ota_0和ota_1,当前运行的在ota_0,新固件就写到ota_1。写完之后改一下启动标志,下次重启从ota_1启动。如果ota_1启动失败,自动回滚到ota_0。这样无论升级过程出什么问题,设备至少还有一个能跑的版本兜底。
2.2 自动回滚到底靠什么判断“启动失败”
这是整套机制里最容易被误解的地方。很多人以为“回滚”是芯片自己判断固件好不好,其实不是。ESP32 的回滚机制依赖两个东西:Bootloader 的启动计数和应用层的主动确认。
具体流程是这样的:Bootloader 每次尝试从新分区启动时,会先检查这个分区是否被标记为“待验证”。如果是,它就把启动次数加一,然后跳转过去执行。应用启动之后,如果一切正常,需要在规定时间内调用esp_ota_mark_app_valid_cancel_rollback()告诉系统“我跑起来了,别回滚了”。如果应用启动后崩溃、卡死、或者根本没来得及调用这个函数,Bootloader 在下次重启时发现启动次数超了阈值,就会自动切回上一个正常分区。
这个设计的关键在于**“确认”这个动作必须由应用主动完成**。也就是说,你得在代码里显式地告诉系统“我活着”。如果你忘了写这一句,那设备会陷入“启动—计数—回滚—再启动”的循环,表现就是反复重启。我第一次做的时候就在这里卡了半天,串口日志里一直看到rollback字样,后来才发现是忘了加确认函数。
2.3 分区表怎么划才合理
分区表是整套机制的骨架,划错了后面全白搭。一个典型的支持双分区 OTA 的分区表大概长这样:
| 名称 | 类型 | 子类型 | 偏移 | 大小 | 说明 |
|---|---|---|---|---|---|
| nvs | data | nvs | 0x9000 | 0x6000 | 存储 WiFi 配置等 |
| 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 | 第二个应用分区 |
| nvs_key | data | nvs_keys | 0x320000 | 0x1000 | 加密密钥(可选) |
这里有几个数字值得掰开说。otadata分区只有 8KB,但它极其关键,里面存着两个 OTA 槽的状态:哪个是当前启动的、哪个是待验证的、启动计数是多少。这个分区一旦损坏,Bootloader 就不知道该从哪启动,所以它必须放在靠前且稳定的位置。
两个 app 分区大小要一致,而且必须能装下你最大的固件。ESP32 常见的 4MB Flash,扣掉 Bootloader、分区表、NVS 和各种数据区,留给每个 app 分区大概 1.5MB 左右。如果你的固件带 WiFi、蓝牙、还有一堆库,很容易就顶到 1.2MB 以上,这时候就得考虑换 8MB 或 16MB 的模组。我手上这块 ESP32-S3-N16R8 是 16MB Flash 加 8MB PSRAM,分区可以划得很宽裕,每个 app 分区给 2MB 都行。
注意:分区表里
otadata的偏移和大小不能随便改,它和 Bootloader 里的逻辑是绑定的。改错了会导致 Bootloader 找不到 OTA 状态,直接回退到出厂分区或者报错。
3. 核心细节解析与实操要点
3.1 otadata 里的状态机到底怎么转
otadata分区里其实存了两份esp_ota_select_entry_t结构,每份 4KB,互为备份。这个结构里最关键的两个字段是ota_seq和ota_state。ota_seq是个递增的序号,用来判断哪个分区更新;ota_state则有几种取值,对应不同的启动状态。
当你第一次烧录固件时,如果用的是idf.py flash,它会把ota_0设为出厂分区,otadata里两个槽都是空的。第一次 OTA 升级时,系统把新固件写到ota_1,然后把ota_1对应的ota_seq设为比ota_0大 1,状态设为ESP_OTA_IMG_NEW。重启后 Bootloader 看到ota_1是 NEW 状态,就把它标记为ESP_OTA_IMG_PENDING_VERIFY,启动计数加一,然后跳过去执行。
应用跑起来之后调用确认函数,状态变成ESP_OTA_IMG_VALID,回滚取消。如果应用没确认就重启了,Bootloader 发现PENDING_VERIFY且计数超限,就把ota_1标记为ESP_OTA_IMG_ABORTED,切回ota_0。整个过程的状态流转是严格定义的,你可以在 ESP-IDF 的esp_ota_ops.h里看到完整的枚举。
3.2 启动计数阈值和看门狗的配合
启动计数的阈值默认是 3 次,这个值可以通过CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK相关的配置调整,但一般不建议改。3 次的意思是:新固件连续启动失败 3 次,就判定为坏固件,执行回滚。为什么是 3 次而不是 1 次?因为有些固件启动慢,或者第一次启动要做一些初始化,偶尔会超时重启一次,给 3 次机会能避免误判。
但这里有个坑:如果你的应用启动后卡在某个死循环里,既没崩溃也没重启,Bootloader 是没机会计数的。这时候就需要看门狗(Task Watchdog 或 Interrupt Watchdog)出手。我通常会在应用启动的关键路径上喂狗,如果主任务卡死超过设定时间,看门狗触发重启,重启后 Bootloader 才会把这次算作一次失败。所以看门狗和回滚机制是配套使用的,缺一不可。
实操心得:确认函数不要放在
app_main的第一行。我一般会等 WiFi 连上、主要外设初始化完成、系统稳定运行 10 秒后再调用确认。这样能确保固件是真的能干活,而不是仅仅“能启动”。
3.3 固件签名与安全启动对回滚的影响
如果你启用了安全启动(Secure Boot)和 Flash 加密,回滚机制依然有效,但流程会多一层签名校验。Bootloader 在跳转前会验证新固件的签名,签名不对直接拒绝启动,连计数都不会加,直接回滚。这其实是好事,能防止别人往你设备里刷恶意固件。
但要注意,启用安全启动后,eFuse 里的密钥位是不可逆的。一旦烧录,这块芯片就只能跑签名过的固件。如果你在开发阶段不小心烧了安全启动的 eFuse,后面想刷自己的测试固件就会很麻烦。我的建议是:开发阶段绝对不要碰 eFuse 的安全相关位,等产品定型量产时再统一烧录。这个坑我踩过,一块 ESP32-S3 因为误烧了密钥,最后只能当普通芯片用,安全启动功能再也开不了。
4. 实操过程与核心环节实现
4.1 环境准备与分区表配置
我这次用的是 ESP-IDF v5.1,开发板是 ESP32-S3-N16R8 核心板。如果你用 Arduino 框架,思路一样,但分区表的配置方式不同,后面我会单独说。先看 ESP-IDF 下的操作。
第一步是创建自定义分区表。在项目根目录新建partitions.csv,内容如下:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, otadata, data, ota, 0xf000, 0x2000, phy_init, data, phy, 0x11000, 0x1000, ota_0, app, ota_0, 0x20000, 0x200000, ota_1, app, ota_1, 0x220000,0x200000,然后在menuconfig里把分区表指向这个文件:Partition Table -> Custom partition table CSV,填上文件名。同时确认Bootloader config -> Enable app rollback support是打开的,这个选项默认就是开的,但值得检查一遍。
编译之后用idf.py partition-table可以查看最终的分区布局,确认两个 app 分区大小一致、偏移正确。这一步千万别跳过,我有一次因为 CSV 里多打了个空格,导致分区表解析失败,烧录后设备一直重启,查了半天才发现是格式问题。
4.2 OTA 升级代码的完整实现
下面是我实际项目里用的 OTA 升级核心代码,基于 ESP-IDF 的esp_https_ota组件。为了通用性,我把 URL 和证书校验部分做了简化,重点看回滚相关的逻辑。
#include "esp_ota_ops.h" #include "esp_http_client.h" #include "esp_https_ota.h" #include "esp_log.h" static const char *TAG = "OTA"; void ota_task(void *pvParameter) { esp_http_client_config_t config = { .url = "http://your-server/firmware.bin", .timeout_ms = 10000, .keep_alive_enable = true, }; esp_https_ota_config_t ota_config = { .http_config = &config, }; ESP_LOGI(TAG, "开始 OTA 升级"); 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)); } vTaskDelete(NULL); }这段代码负责把新固件写到备用分区。注意esp_https_ota内部会自动处理分区切换和otadata更新,你不需要手动去写otadata。升级完成后调用esp_restart(),Bootloader 就会从新分区启动。
接下来是应用启动后的确认逻辑,这个必须加:
void app_main(void) { // 初始化各种外设 init_nvs(); init_wifi(); init_sensors(); // 等待系统稳定 vTaskDelay(pdMS_TO_TICKS(10000)); // 确认固件有效,取消回滚 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) { ESP_LOGI(TAG, "固件运行正常,标记为有效"); esp_ota_mark_app_valid_cancel_rollback(); } } // 启动主循环 main_loop(); }这里的关键是esp_ota_get_state_partition先判断当前分区是不是处于PENDING_VERIFY状态。只有在这个状态下才需要确认。如果固件是出厂烧录的,状态是VALID,就不需要重复确认。这个判断能避免一些边界情况下的误操作。
4.3 模拟升级失败与回滚验证
代码写完了,怎么验证回滚真的有效?总不能真等设备出故障。我的做法是故意制造一次启动失败。具体操作是:在 OTA 升级完成后,先不调用确认函数,然后在应用启动后 5 秒主动调用esp_restart()。这样 Bootloader 会认为新固件启动失败,连续 3 次之后自动回滚。
验证步骤我整理成了一张表:
| 步骤 | 操作 | 预期现象 |
|---|---|---|
| 1 | 烧录 v1 固件,正常运行 | 串口打印 v1 版本号 |
| 2 | 触发 OTA 升级到 v2 | 下载完成,设备重启 |
| 3 | v2 启动后不确认,5 秒后重启 | 串口打印 v2,然后重启 |
| 4 | 重复 3 次 | 第 3 次重启后,Bootloader 回滚 |
| 5 | 观察最终运行版本 | 串口打印 v1 版本号,回滚成功 |
这个测试我跑了不下十遍,每次都能稳定回滚。唯一需要注意的是,回滚之后otadata里 v2 的状态会变成ABORTED,下次再 OTA 升级时,系统会优先选择另一个分区,不会重复往坏分区写。
注意:测试回滚时,确保设备供电稳定。如果测试过程中断电,
otadata可能处于中间状态,虽然 Bootloader 有容错机制,但多一事不如少一事。
4.4 Arduino 框架下的双分区配置
如果你用 Arduino IDE 或 PlatformIO,双分区 OTA 也能做,但配置方式不同。Arduino 框架默认的分区方案是Default,只有一个 app 分区。你需要切换到Minimal SPIFFS (1.9MB APP with OTA)或者自定义分区表。
在 Arduino IDE 里,工具 -> Partition Scheme选择带 OTA 的方案即可。PlatformIO 则需要在platformio.ini里指定:
board_build.partitions = min_spiffs.csv或者自己写一个partitions.csv放到项目目录,然后在配置里引用。Arduino 框架的Update库内部也实现了类似的回滚逻辑,但确认函数需要手动调用Update.markValid(),否则同样会回滚。很多人用 Arduino 做 OTA 时忘了这一步,结果设备升级后反复重启,还以为是固件问题。
5. 常见问题与排查技巧实录
5.1 设备升级后反复重启,串口一直打印回滚日志
这是最常见的问题,九成以上的原因是应用没有调用确认函数。串口日志里通常会看到Rollback或者Pending verify字样。解决办法就是在应用启动稳定后调用esp_ota_mark_app_valid_cancel_rollback()。如果你用的是 Arduino,检查有没有调用Update.markValid()。
还有一种可能是确认函数调用了,但调用得太早,应用还没真正稳定就确认了,结果后面崩溃了。这种情况回滚机制救不了你,因为已经标记为 VALID 了。所以确认时机很重要,我一般等 10 秒以上。
5.2 OTA 下载到一半失败,设备还能正常启动吗
能。因为新固件是写到备用分区的,下载失败只会导致备用分区数据不完整,otadata不会被更新,Bootloader 依然从原分区启动。下次重新 OTA 时,系统会重新擦写备用分区。这就是双分区的价值所在,升级失败不影响当前运行。
但要注意,如果备用分区之前有旧数据,下载失败后可能残留半截固件。虽然 Bootloader 不会去启动它,但会占用 Flash 空间。ESP-IDF 的 OTA 组件在开始写入前会自动擦除目标分区,所以这个问题不用太担心。
5.3 分区表改了之后,原来的 OTA 数据还能用吗
不能。分区表一变,otadata里的偏移和分区信息就对不上了,Bootloader 会认为 OTA 数据无效,直接回退到出厂分区。如果你改了分区表,必须重新烧录一次完整固件,让系统重新初始化otadata。这个操作会清空 OTA 历史,但不会影响 NVS 里的用户数据(前提是 NVS 分区没动)。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 升级后反复重启 | 未调用确认函数 | 检查esp_ota_mark_app_valid_cancel_rollback |
| 回滚不生效 | 回滚支持未开启 | 检查 menuconfig 中Enable app rollback support |
| OTA 下载失败 | 网络或服务器问题 | 检查 URL、超时时间、服务器可达性 |
| 分区表报错 | CSV 格式错误 | 检查逗号、空格、偏移对齐 |
| 确认后仍回滚 | 确认后应用崩溃 | 检查确认后的代码路径,延后确认时机 |
| 串口无输出 | 启动分区错误 | 用esptool.py read_flash读 otadata 分析 |
5.5 几个我踩过的坑和独家技巧
第一个坑是确认函数放在app_main开头。我一开始图省事,app_main第一行就调用确认,结果有次 WiFi 初始化失败导致系统卡死,但固件已经被标记为 VALID,回滚机制失效,设备彻底失联。后来我把确认挪到所有关键初始化之后,并且加了 10 秒延时,再没出过问题。
第二个坑是看门狗没开。有次固件里有个死循环,设备不重启也不崩溃,就是卡在那里。因为没开看门狗,Bootloader 根本没机会计数,回滚自然不触发。后来我养成了习惯,所有正式固件必须开 Task Watchdog,并且把超时时间设成 30 秒以内。
第三个技巧是在 OTA 升级前先检查电池电量或供电状态。如果是电池供电的设备,电量低于 30% 就别升级了,万一升级到一半没电,虽然不会变砖,但备用分区会残留脏数据,下次升级还得先擦除,浪费时间。我现在会在 OTA 前读一次电压,低于阈值直接拒绝升级并上报服务器。
第四个技巧是保留一个“救砖”固件。我会在ota_0里烧一个功能极简但绝对稳定的固件,只负责连 WiFi 和接收 OTA 指令。日常运行在ota_1,升级也往ota_0写。这样即使ota_1彻底跑不起来,回滚到ota_0也能保证设备在线,还能远程重新升级。这个思路特别适合部署在难以物理接触的场景。
6. 关于固件安全与长期维护的几点体会
双分区加自动回滚解决的是“升级失败”的问题,但它不解决“固件本身有 bug”的问题。如果你的新固件逻辑上就是错的,比如连不上服务器、传感器读数全错,回滚机制不会触发,因为固件“能启动”。这时候需要的是应用层的健康检查。我的做法是在确认函数之前加一段自检:读一次传感器、连一次服务器、发一条心跳,全部通过才确认。任何一项失败就主动重启,让 Bootloader 计数,最终触发回滚。
另外,OTA 服务器的固件版本管理也很重要。我见过有人把测试固件传到生产服务器,结果所有设备升级后都出了问题。建议在服务器端做版本校验,设备上报当前版本,服务器只下发比当前版本高的固件,并且支持灰度发布。这些虽然超出了 ESP32 本身的范围,但和回滚机制配合起来,才能构成一个完整的远程升级方案。
固件加密和安全启动是另一个维度的话题。如果你的设备涉及商业部署,建议开启 Flash 加密,防止别人直接读 Flash 提取固件。但记住,加密和回滚不冲突,只是多了一层签名校验。开启加密后,OTA 固件必须是加密后的 bin 文件,服务器下发时也要注意格式。
最后说个实际数据。我这套双分区加回滚的方案,在一个两百多台设备的部署里跑了半年,经历了十几次 OTA 升级,其中三次因为网络问题下载失败,一次因为固件 bug 触发回滚,没有一台设备需要人工干预。这个稳定性,单分区方案是给不了的。如果你还在用单分区做 OTA,真的建议花半天时间把双分区配上,后面省下的维护成本远超这点投入。