ESP32 刷固件会变砖吗?双分区+自动回滚机制详解
1. 从一次刷机翻车说起ESP32 到底会不会变砖很多人第一次给 ESP32 刷固件手都是抖的。尤其是用 USB-TTL 接上开发板、敲下esptool.py write_flash回车的那一刻脑子里全是同一个问题万一刷到一半断了这板子是不是就废了我当年也是这么想的甚至专门多买了两块 ESP32 备着生怕刷坏一块就没得用。先说结论让你把心放回肚子里ESP32 在绝大多数情况下不会真正变砖。它内部固化了一段 ROM Bootloader这段代码是出厂就烧死在芯片里的你通过常规手段根本改不了它。只要这段 ROM 还在芯片就永远能被重新拉起来哪怕你的应用固件被刷成了一堆乱码。这跟很多老式单片机一旦烧错就彻底没救的情况完全不同ESP32 从设计之初就考虑到了刷坏这件事。那为什么网上还是经常看到有人喊我的 ESP32 变砖了这里面其实混着好几种情况。一种是硬件层面的损坏比如供电接反、IO 口灌了大电流、芯片本身烧了这种是真砖神仙难救。另一种是软件层面的假砖比如刷了不匹配的固件导致不断重启、串口一直打印乱码、或者进了深度睡眠再也醒不过来。这类问题 99% 都能通过重新烧录解决只是很多人不知道正确的进入下载模式的方法误以为板子死了。我写这篇东西的目的就是想把ESP32 刷固件到底会不会变砖这件事彻底讲清楚并且给出一个真正能落地的方案用双分区加自动回滚机制让设备在 OTA 升级失败时自己退回上一个能用的固件。这套方案我在好几个量产项目里用过实测下来非常稳尤其适合那些装到现场之后你根本够不着、只能靠远程升级的设备。不管你是刚入门的新手还是已经在做产品固件的老手这套思路都值得你花时间吃透。关键词里提到了 ESP32、固件、双分区、自动回滚、OTA这几个词基本就是本文的主线。我会从分区表的原理讲起一直讲到代码怎么写、怎么测、怎么避坑尽量让你看完就能直接抄作业。2. 搞懂 ESP32 的启动链路才知道砖是怎么来的2.1 从 ROM Bootloader 到你的 app_main 之间发生了什么要理解为什么 ESP32 不容易变砖得先搞清楚它上电之后到底走了哪几步。整个过程大致是这样的芯片上电复位首先运行的是固化在 ROM 里的Bootloader这段代码由芯片厂商在流片时就写好用户无法修改。ROM Bootloader 会去读eFuse里的配置和几个特定的 strapping 引脚电平决定从哪个接口加载下一阶段代码——通常是 SPI Flash。接着ROM Bootloader 从 Flash 的固定偏移默认 0x1000加载二级 Bootloader也就是我们平时说的 bootloader.bin。这个二级 Bootloader 是可以用idf.py flash或esptool烧写的它才是真正负责选哪个应用分区启动的角色。二级 Bootloader 会读取分区表找到标记为factory或某个ota_x的分区校验它的镜像头然后跳转过去执行。你的app_main就是在这之后才跑起来的。所以你看从 ROM 到二级 Bootloader 再到应用中间有好几层。真正改不了的只有最底层的 ROM。只要 ROM 没坏、Flash 没物理损坏、strapping 引脚没被拉错芯片就一定能被重新烧录。这就是 ESP32 抗造的根本原因。2.2 那些被误判为变砖的典型现象我在论坛和群里见过太多求救帖总结下来被误判成砖的情况主要有这么几类串口一直刷乱码多半是波特率不对或者芯片进了某个异常复位循环。换个波特率、按住 BOOT 键再上电基本都能救回来。烧录时报 Failed to connect这是最常见的假砖。原因通常是没进下载模式。ESP32 需要在上电或复位时把 GPIO0 拉低、GPIO2 保持高电平才能进入串口下载模式。很多开发板有自动下载电路但有些便宜板子没有就得手动按住 BOOT 再点 EN。刷完固件后设备不断重启这是应用层的问题比如分区表不匹配、固件太大超出分区、或者代码里有 panic。这种不是砖重新烧个正确的固件就好。OTA 升级到一半断电设备起不来这才是真正需要自动回滚来兜底的场景也是本文重点要解决的。把这几种情况分清楚你就明白真正意义上的变砖在 ESP32 上极其罕见绝大多数是操作问题或配置问题。而 OTA 升级失败导致设备失联才是实际项目里最要命的痛点。2.3 为什么 OTA 才是真正的高风险环节有线烧录的时候你人就在设备旁边刷坏了拔下来重刷就行。但 OTA 不一样设备可能装在几十米高的塔上、埋在地下的管道里、或者用户家里的墙角。一旦升级失败又没法回滚你就只能派人去现场成本高得离谱。OTA 的风险点在于新固件下载到 Flash 的过程中可能断电、可能网络中断、可能新固件本身有 bug 一启动就崩。如果没有回滚机制设备就会卡在一个永远起不来的状态。而 ESP-IDF 提供的双分区 自动回滚机制正是为了解决这个问题而生的。它的核心思想很简单永远保留一个已知能用的旧固件新固件只有在确认自己能正常跑起来之后才被转正。3. 双分区方案的分区表怎么设计才不踩坑3.1 分区表的基本结构和字段含义ESP32 的分区表是一张放在 Flash 固定位置默认 0x8000的小表每行描述一个分区。一个典型的分区表 CSV 长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x180000, ota_0, app, ota_0, 0x190000,0x180000, ota_1, app, ota_1, 0x310000,0x180000,几个关键字段得说清楚。Type和SubType决定这个分区是干嘛的app类型下面又分factory、ota_0到ota_15。Offset是分区在 Flash 里的起始地址Size是大小。这里有个硬性要求每个 app 分区的大小必须是 64KB 的整数倍因为 Flash 擦除的最小单位是 64KB 的扇区。你要是写个 0x170000 这种不是 64KB 倍数的值编译时就会报错。另外分区表里所有分区的 Offset 必须连续且不重叠从 0x9000 开始0x8000 是分区表本身占的位置。我第一次自己写分区表的时候就因为算错了偏移导致两个分区重叠烧进去之后设备疯狂重启排查了半天才发现是分区表的问题。3.2 双 OTA 分区与 factory 分区的取舍上面那张表里同时有factory和ota_0、ota_1。这是最经典的三分区布局。factory是出厂固件ota_0和ota_1轮流作为 OTA 的目标分区。设备第一次启动跑 factory之后每次 OTA 就在 ota_0 和 ota_1 之间来回切。但这里有个很多人不知道的细节一旦你从 factory 切到了 ota_0就再也回不到 factory 了除非手动擦除 otadata。因为二级 Bootloader 是根据 otadata 分区里的记录来决定启动哪个分区的一旦 otadata 被写成指向 ota_0factory 就被遗忘了。所以如果你想要更省 Flash 空间的方案可以干脆不要 factory只用 ota_0 和 ota_1 两个分区。第一次烧录时直接把固件烧到 ota_0otadata 留空Bootloader 在没有有效 otadata 时会默认启动 ota_0。这样两个分区各占一半空间能塞下更大的固件。我在实际项目里更倾向这种两分区方案简单、省空间、逻辑清晰。注意如果你用的是两分区方案第一次量产烧录时一定要确保 otadata 是空的否则设备可能启动到一个不存在的分区上。3.3 分区大小怎么算才够用分区大小直接决定了你能塞多大的固件。ESP-IDF 编译出来的 app 镜像除了代码本身还包含各种常量、初始化数据、以及可能的文件系统。一个带 WiFi、蓝牙、HTTP 服务的固件轻松就能到 1MB 以上。如果你还用了 LVGL 做界面、或者集成了 TensorFlow Lite Micro2MB 都打不住。我的经验是先编译一次看生成的 .bin 文件多大然后分区大小至少留出 30% 的余量。因为后续加功能、加日志、加 OTA 校验逻辑固件只会越来越大。比如你的固件现在是 1.2MB那分区至少给到 1.6MB也就是 0x1A0000 往上取到 64KB 的倍数实际写 0x1C0000 比较稳妥。Flash 总容量也得算清楚。常见的 ESP32 模块有 4MB、8MB、16MB 几种。4MB 的话两个 1.6MB 的 app 分区加上 nvs、phy、otadata基本就满了没多少空间放文件系统。所以如果你要存音频、图片、日志8MB 起步比较舒服。ESP32-S3 有些型号板载 16MB Flash 加 8MB PSRAM那种就非常宽裕了。4. 自动回滚的底层机制otadata 和 rollback 是怎么配合的4.1 otadata 分区里到底存了什么otadata是一个很小的 data 类型分区通常 0x2000 大小里面存着两个esp_ota_select_entry_t结构体每个 32 字节。这个结构体记录了当前应该启动哪个 OTA 分区、这个分区的状态新固件、有效、无效、待验证等以及一个序列号用于判断哪个 entry 更新。Bootloader 启动时会读这两个 entry选序列号更大、状态有效的那个然后跳转到对应的 ota 分区。如果两个 entry 都无效就回退到 factory 分区如果有的话。这套机制的好处是写入 otadata 是原子性的即使写一半断电另一个 entry 还是完好的Bootloader 总能选出一个可用的。理解这一点很重要因为它解释了为什么自动回滚是可靠的回滚的本质就是在 otadata 里把状态标记回退而不是去动固件本身。固件一直躺在 Flash 里没变变的只是启动哪个这个指针。4.2 新固件的待验证状态与确认流程自动回滚的核心在于一个叫ESP_OTA_IMG_PENDING_VERIFY的状态。当你通过 OTA 把新固件写进 ota_1 并调用esp_ota_set_boot_partition()之后这个分区的状态就被标记为待验证。下次重启Bootloader 会启动这个新固件但此时它处于试用期。新固件启动后你的代码必须主动调用esp_ota_mark_app_valid_cancel_rollback()来转正自己。如果你没调用或者调用之前设备就崩了、重启了那么 Bootloader 下次启动时会发现这个分区还是待验证状态于是自动回滚到上一个有效的分区。这个设计非常巧妙它把确认固件能用的责任交给了固件自己。只有新固件真正跑到了能调用确认函数的那一步才说明它是健康的。如果它在初始化阶段就 panic 了根本没机会调用确认函数回滚就会自动发生。4.3 回滚触发的完整时序把整个流程串起来看是这样的设备当前跑在 ota_0状态为 valid。收到 OTA 任务把新固件写入 ota_1写入完成后调用esp_ota_set_boot_partition(ota_1)。此时 otadata 里 ota_1 的状态是PENDING_VERIFY序列号比 ota_0 大。设备重启Bootloader 读到 ota_1 序列号更大且状态是待验证于是启动 ota_1。情况 Aota_1 固件正常启动跑到某处调用了esp_ota_mark_app_valid_cancel_rollback()状态变为 valid回滚取消升级成功。情况 Bota_1 固件启动过程中崩溃、看门狗复位、或者压根没调用确认函数就重启了。Bootloader 再次启动时发现 ota_1 还是待验证于是回滚启动 ota_0。整个过程不需要人工干预也不需要额外的硬件看门狗纯靠软件状态机完成。这就是为什么我说这套机制实测很稳。5. 手把手写代码从 OTA 下载到确认转正5.1 环境准备和分区表配置先确保你的 ESP-IDF 环境是 4.x 或 5.x这两个大版本在 OTA API 上基本一致。项目根目录下建一个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, 0x1C0000, ota_1, app, ota_1, 0x1E0000,0x1C0000,然后在menuconfig里把分区表设为自定义Partition Table - Custom partition table CSV路径填partitions.csv。同时确认Bootloader config里的Enable app rollback support是打开的这个选项默认就是开的但值得检查一遍。5.2 写一个带校验的 OTA 下载任务下面这段代码是我在实际项目里用的简化版去掉了业务逻辑保留了核心流程#include esp_ota_ops.h #include esp_http_client.h #include esp_log.h static const char *TAG OTA; esp_err_t do_ota_update(const char *url) { esp_http_client_config_t config { .url url, .timeout_ms 10000, }; esp_http_client_handle_t client esp_http_client_init(config); esp_err_t err esp_http_client_open(client, 0); if (err ! ESP_OK) { ESP_LOGE(TAG, Failed to open HTTP connection: %s, esp_err_to_name(err)); esp_http_client_cleanup(client); return err; } int content_length esp_http_client_fetch_headers(client); ESP_LOGI(TAG, Firmware size: %d bytes, content_length); const esp_partition_t *update_partition esp_ota_get_next_update_partition(NULL); ESP_LOGI(TAG, Writing to partition subtype %d at offset 0x%lx, update_partition-subtype, update_partition-address); esp_ota_handle_t update_handle 0; err esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, update_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_begin failed: %s, esp_err_to_name(err)); esp_http_client_cleanup(client); return err; } char buf[1024]; int total_read 0; while (1) { int read_len esp_http_client_read(client, buf, sizeof(buf)); if (read_len 0) { ESP_LOGE(TAG, HTTP read error); esp_ota_abort(update_handle); esp_http_client_cleanup(client); return ESP_FAIL; } else if (read_len 0) { break; } err esp_ota_write(update_handle, buf, read_len); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_write failed: %s, esp_err_to_name(err)); esp_ota_abort(update_handle); esp_http_client_cleanup(client); return err; } total_read read_len; } err esp_ota_end(update_handle); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_end failed: %s, esp_err_to_name(err)); esp_http_client_cleanup(client); return err; } err esp_ota_set_boot_partition(update_partition); if (err ! ESP_OK) { ESP_LOGE(TAG, esp_ota_set_boot_partition failed: %s, esp_err_to_name(err)); esp_http_client_cleanup(client); return err; } ESP_LOGI(TAG, OTA update finished, total %d bytes. Rebooting..., total_read); esp_http_client_cleanup(client); esp_restart(); return ESP_OK; }几个关键点解释一下。esp_ota_get_next_update_partition(NULL)会自动帮你找到下一个可用的 OTA 分区也就是当前没在跑的那个。esp_ota_begin会擦除目标分区并准备写入。esp_ota_write可以多次调用每次写一块。esp_ota_end会做镜像校验如果下载的固件损坏这一步会返回错误此时不要调用set_boot_partition设备继续跑旧固件安全。5.3 在新固件里正确调用确认函数新固件启动后你需要在合适的时机调用确认函数。这个时机很关键不能太早也不能太晚。太早的话如果后面初始化失败你已经把回滚取消了设备就卡住了太晚的话如果设备在确认之前因为别的原因重启会白白触发一次回滚。我的做法是在所有关键外设初始化完成、网络连上、并且成功上报了一次心跳之后再调用确认。这样能最大程度保证能确认的固件确实是健康的。代码大概长这样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_LOGI(TAG, Self test passed, mark app valid); esp_ota_mark_app_valid_cancel_rollback(); } else { ESP_LOGE(TAG, Self test failed, triggering rollback); esp_ota_mark_app_invalid_rollback_and_reboot(); } } } // ... 主循环 ... }注意esp_ota_mark_app_invalid_rollback_and_reboot()这个函数它会主动标记当前固件无效并重启触发回滚。这在你自检失败时非常有用比等着看门狗复位更干净。6. 实测中那些文档没写的坑6.1 回滚后旧固件的状态问题有个细节我第一次遇到时懵了很久设备回滚到旧固件之后旧固件的状态是什么答案是valid。因为旧固件之前已经确认过了它的状态一直是 valid回滚只是把启动指针指回它不会改变它的状态。所以回滚之后设备是正常运行的不会陷入回滚再回滚的死循环。但如果你连续 OTA 失败两次情况就有点微妙了。第一次失败回滚到 ota_0第二次又尝试升级 ota_1 又失败还是回滚到 ota_0。只要 ota_0 一直是 valid就永远有退路。这也是为什么我强烈建议至少保留一个已知能用的分区不要两个分区都处于待验证状态。6.2 看门狗和回滚的相互影响ESP32 默认开了任务看门狗TWDT和中断看门狗IWDT。如果你的新固件在初始化时卡在某个循环里看门狗会复位设备。这个复位会触发回滚看起来是好事。但有个坑如果看门狗复位发生在你调用确认函数之后那回滚就不会发生了设备会一直卡在崩溃循环里。所以确认函数的调用时机一定要在确定不会再卡死之后。我一般会在确认之前把所有可能阻塞的操作都做完并且给关键任务喂狗。另外CONFIG_ESP_TASK_WDT_TIMEOUT_S这个超时时间可以适当调大给新固件更多启动时间避免误判。6.3 Flash 擦写寿命与 OTA 频率Flash 的擦写寿命是有限的NOR Flash 一般标称 10 万次擦写。每次 OTA 都要擦除一整个 app 分区如果你频繁 OTA比如一天升级好几次几年下来也可能把 Flash 写坏。当然正常产品不会这么频繁升级但如果你在做开发调试反复烧录同一个分区还是要注意。我的建议是开发阶段尽量用有线烧录量产设备才用 OTA。另外OTA 下载的固件最好做完整性校验比如在 HTTP 头里带上 MD5 或 SHA256下载完对比一下避免把损坏的固件写进去浪费一次擦写。6.4 分区表改了之后旧设备怎么办这是量产项目里最头疼的问题之一。如果你第一版固件用的分区表和第二版不一样OTA 升级时新固件的分区布局和旧的不匹配会直接导致升级失败甚至设备起不来。因为分区表是在烧录时写进 Flash 固定位置的OTA 只更新 app 分区不会更新分区表。解决办法有两个一是从第一版就把分区表设计好后续不再改二是如果非要改得通过一个特殊的过渡固件来更新分区表这个过渡固件要能同时兼容新旧布局复杂度很高。所以我的经验是分区表在项目启动阶段就要反复推敲留足余量一旦量产就别再动。7. 怎么验证回滚真的生效了7.1 故意做一个会崩溃的固件验证回滚最直接的办法就是故意写一个启动就崩的固件然后 OTA 上去看设备会不会自己退回来。你可以在新固件的app_main开头加一句assert(0)或者esp_restart()死循环让它永远到不了确认函数那一步。具体操作先烧一个正常固件到 ota_0确认它能跑。然后编译一个带assert(0)的固件通过 OTA 推到 ota_1。设备重启后会跑 ota_1立刻崩溃复位。反复几次之后Bootloader 会发现 ota_1 一直是待验证状态于是回滚到 ota_0。你会在串口看到类似Rollback to previously saved partition的日志。7.2 用串口日志确认状态流转ESP-IDF 的 Bootloader 日志会打印当前启动的是哪个分区、状态是什么。把CONFIG_BOOTLOADER_LOG_LEVEL调到 Info 或 Debug你能看到非常详细的状态信息。重点关注这几行boot: Loaded app from partition at offset 0x...当前启动的分区。boot: Set actual ota_seq...otadata 里的序列号。boot: Rollback to previously saved partition回滚发生了。如果你看到回滚日志并且设备最终跑回了旧固件说明整套机制工作正常。7.3 用 otadata 工具直接读状态除了看日志你还可以用esptool.py read_flash把 otadata 分区读出来用十六进制工具看里面的内容。otadata 在 0xf000大小 0x2000读出来之后你能看到两个 entry 的序列号和状态字节。这个方法在设备没法输出日志的时候特别有用比如串口被占用了或者设备装得太深够不着。我一般会在产测工装上集成这个读取步骤出厂前确认 otadata 是干净的避免把调试时的状态带到客户手里。8. 几个容易被忽略的进阶细节8.1 固件加密和签名对回滚的影响如果你的产品开了Secure Boot和Flash EncryptionOTA 的流程会多一层校验。新固件必须用正确的密钥签名否则 Bootloader 会拒绝启动它直接回滚。这其实是好事等于多了一道保险。但要注意开启 Secure Boot 之后回滚的目标分区也必须是签名有效的否则一样起不来。所以密钥管理一定要做好别把签名密钥弄丢了否则连回滚都救不了你。8.2 双分区之外还能怎么玩除了经典的双 OTA 分区ESP-IDF 还支持最多 16 个 OTA 分区ota_0 到 ota_15。理论上你可以做多版本共存、A/B 测试之类的玩法。但分区越多每个分区能分到的空间越小实际项目里两个分区基本够用。如果你真的需要多版本可以考虑把固件存在外部 Flash 或 SD 卡上运行时再加载不过那就复杂多了。8.3 OTA 升级标志位的持久化有时候你需要在 OTA 过程中记录一些状态比如正在升级升级失败次数之类的。这些信息不能存在 otadata 里因为那是 Bootloader 管的。我一般会存在 NVS 里用一个专门的 namespace。注意 NVS 的写入也有寿命限制别在循环里频繁写。升级失败次数可以用来做退避策略比如连续失败三次就停止自动重试等人工介入。9. 我在实际项目里踩过的几个真实坑第一个坑是分区表偏移算错。当时我手动改了一个分区的 offset结果和后面的分区重叠了 0x1000 字节。编译没报错烧进去之后设备启动就崩串口打印一堆看不懂的地址。排查了大半天才用esptool.py read_flash把分区表读出来对比发现重叠。从那以后我每次改分区表都会用idf.py partition-table生成一份人类可读的版本核对一遍。第二个坑是确认函数调用太早。有个项目我在app_main第一行就调了esp_ota_mark_app_valid_cancel_rollback()结果新固件在初始化 WiFi 时因为配置错误崩了但回滚已经被取消设备就卡在崩溃循环里只能现场重刷。后来我把确认挪到了网络连接成功并上报心跳之后再也没出过这个问题。第三个坑是HTTP 下载没有超时。早期代码里esp_http_client没设 timeout结果服务器卡住的时候OTA 任务一直挂着看门狗把设备复位了触发了回滚。虽然回滚救回来了但用户体验很差。后来我加了 10 秒的连接超时和读取超时并且把 OTA 任务放在独立的任务里跑不阻塞主逻辑。第四个坑是Flash 空间不够导致 OTA 写入失败。有个 4MB 的模块我分区给大了两个 app 分区加起来 3.5MB加上其他分区超了。编译时没报错但 OTA 写到一半就失败。后来换成 8MB 模块才解决。所以选型阶段一定要把 Flash 容量算够。10. 关于变砖这件事我的最终看法回到最开始的问题ESP32 刷固件会变砖吗我的答案是只要芯片没物理损坏就几乎不会。ROM Bootloader 是最后的保命符它保证了你永远有机会重新烧录。真正需要担心的是 OTA 升级失败导致设备失联而这恰恰是双分区加自动回滚要解决的问题。这套机制我在多个量产项目里用了两三年经历过现场断电、网络抖动、固件 bug 各种情况回滚每次都稳稳地把设备救回来。它的价值不在于技术多高深而在于把升级失败这个必然会发生的事情变成了一个可以自动恢复的常规事件。对于任何需要远程维护的 ESP32 设备来说这都是值得从一开始就设计进去的能力。如果你现在正在做第一个带 OTA 的项目我的建议是先把分区表设计好把回滚机制跑通再去做业务功能。因为等到设备装到现场再想加回滚代价就大得多了。分区表这东西改一次伤筋动骨不如一开始就想清楚。