☰
ESP32固件刷坏会变砖吗?双分区OTA与自动回滚机制详解
2026/10/4 15:41:56 网站建设 项目流程

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

但凡玩过 ESP32 的人,大概率都经历过那种心跳骤停的瞬间:深夜两点,串口日志刷得飞快,你敲下idf.py flash回车,进度条走到 87% 突然卡住,然后——板子重启,串口一片空白,连 bootloader 的打印都没了。那一刻你脑子里只有一个念头:这玩意儿是不是变砖了?

我手上这块 ESP32-WROOM-32 开发板,前前后后被我刷废过至少五次。最惨的一次是改分区表的时候手抖,把factory分区偏移量写错了,结果 bootloader 找不到应用镜像,直接卡在invalid header: 0xffffffff死循环。当时我以为要上 JTAG 救砖了,后来发现只要拉低 GPIO0 进下载模式,重新烧一次完整固件就活了。这件事让我意识到一个关键问题:ESP32 的“变砖”和传统意义上的变砖,根本不是一回事。

这篇文章我想聊的核心,就是标题里那句话——ESP32 固件刷坏了到底会不会变砖?我的答案是:裸刷会,但只要你上了双分区加自动回滚,它几乎永远不会。我会把双分区(Dual OTA Partition)的设计逻辑、自动回滚(Rollback)的触发机制、分区表的参数计算、OTA 升级的完整流程,以及我踩过的那些坑,全部摊开讲清楚。适合正在做 ESP32 OTA 升级的嵌入式开发者、刚接触 ESP-IDF 的初学者,以及被“刷坏一次就要拆机”折磨过的硬件玩家。

先说结论,让你有个底:ESP32 芯片内部固化了一段ROM Bootloader,这段代码是出厂就烧死在硅片里的,你永远刷不掉它。只要这段 ROM 还在,芯片就能通过 UART 下载模式重新接受固件。所以严格来说,ESP32 的“砖”只有一种——硬件损坏或者 eFuse 被误烧导致 ROM 都无法启动。软件层面刷坏固件,本质上只是“应用跑不起来”,不是真砖。而双分区加自动回滚要解决的,就是让“应用跑不起来”这件事,在无人值守的设备上自动恢复,不需要你半夜爬起来插 USB 线。

2. 双分区方案的整体设计与选型考量

2.1 为什么单分区在 OTA 场景下必然翻车

先说说最朴素的方案:整个 Flash 只放一个应用分区,OTA 的时候直接把新固件覆盖写到这个分区上。这个方案在实验室里能跑通,但放到真实产品里就是灾难。原因很简单——写入过程中断电,或者新固件本身有 bug 跑不起来,你就没有任何退路了。旧固件已经被覆盖,新固件又起不来,设备直接失联。

我早期做过一个带 Wi-Fi 上报的温湿度节点,用的就是单分区 OTA。有一次推送了一个改过 Wi-Fi 连接逻辑的固件,结果新固件在esp_wifi_connect()之后死等事件组,看门狗没喂上,30 秒后复位,复位后又跑新固件,又死等,无限重启。因为设备装在吊顶里,我根本够不着,最后只能等它电量耗尽。那次之后我就彻底放弃了单分区方案。

单分区的另一个隐患是回滚成本极高。就算你发现了问题,也得派人到现场,或者指望设备还能进下载模式让你远程重刷。对于部署在野外、井下、车载这些场景的设备,这基本等于判了死刑。

2.2 双分区加回滚的核心思路拆解

ESP-IDF 提供的 OTA 机制,本质上是用空间换可靠性。它在 Flash 上划出两个应用分区:一个叫ota_0,一个叫ota_1,再加一个极小的otadata分区用来记录“当前该从哪个分区启动”。任何时刻,只有一个分区是“活跃”的,另一个是“待更新”的。

升级流程是这样的:设备当前跑在ota_0,收到新固件后,把新固件写到ota_1,写完后在otadata里标记“下次从ota_1启动”,然后重启。重启后 bootloader 读otadata,跳转到ota_1执行新固件。如果新固件能正常跑起来,它会主动调用esp_ota_mark_app_valid_cancel_rollback()确认自己没问题;如果新固件跑不起来(比如崩溃重启),bootloader 发现ota_1没有被标记为 valid,就会自动回滚到ota_0继续跑旧固件。

这套机制的精妙之处在于:回滚的判断权交给了 bootloader,而不是应用本身。应用崩溃了没法自救,但 bootloader 永远清醒。这就是为什么我说“上了双分区加回滚,几乎永远不会变砖”——最坏情况也就是回到旧固件,设备依然在线。

2.3 分区表参数怎么算才不踩坑

双分区方案能不能落地,关键看分区表怎么划。ESP32 常见的 Flash 容量有 4MB、8MB、16MB,分区表必须精确匹配。我以 4MB Flash 为例,给你算一笔账。

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

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x4000, otadata, data, ota, 0xd000, 0x2000, phy_init, data, phy, 0xf000, 0x1000, ota_0, app, ota_0, 0x10000, 0x180000, ota_1, app, ota_1, 0x190000, 0x180000,

这里有几个数字必须理解透。otadata占0x2000(8KB),这是 ESP-IDF 规定的,两个 OTA 槽各占 4KB,用来记录状态。ota_0和ota_1各占0x180000,也就是 1.5MB。两个加起来 3MB,加上前面的 NVS、otadata、phy_init 和 bootloader 占用的空间,刚好塞进 4MB Flash。

注意:ota_0的 Offset 必须是0x10000,因为前面0x10000字节被 bootloader 和分区表本身占用了。这个偏移量写错,bootloader 直接找不到应用。

如果你用的是 8MB Flash,可以把每个 OTA 分区放大到 3MB 甚至更大,给固件留足余量。我一般建议单个 OTA 分区不要小于 1MB,因为 ESP-IDF 编译出来的固件,带 Wi-Fi 和 TCP/IP 栈的通常在 800KB 到 1.2MB 之间,留太小了以后加功能就塞不下。

还有一个容易忽略的点:分区表本身也要占空间。默认分区表在0x8000,大小0x1000(4KB)。如果你自定义分区表,记得在menuconfig里把Partition Table的 offset 设对,否则会出现“分区表找不到”的报错。

3. 自动回滚机制的底层原理与触发条件

3.1 bootloader 是怎么判断“该回滚”的

很多人以为自动回滚是应用层做的,其实不是。真正干活的是二级 bootloader,它每次启动都会读otadata分区,然后做决策。otadata里存的是两个esp_ota_select_entry_t结构体,每个结构体里有一个ota_seq字段和一个crc校验。bootloader 会比较两个槽的ota_seq,选序号大的那个启动。

关键来了:当新固件被写入ota_1后,otadata里ota_1对应的状态是ESP_OTA_IMG_NEW或者ESP_OTA_IMG_PENDING_VERIFY。bootloader 看到这个状态,就知道“这个固件还没被确认过”。它会正常启动这个固件,但心里记着一笔账。如果这个固件在启动后没有主动调用esp_ota_mark_app_valid_cancel_rollback()来“报到”,那么下次重启时,bootloader 就会把它的状态改成ESP_OTA_IMG_INVALID,然后回滚到另一个槽。

这里有个细节特别重要:回滚不是立刻发生的,而是“下次重启时”发生。也就是说,新固件第一次启动时,bootloader 是给它机会的。如果新固件能跑起来并且主动确认,那就转正;如果新固件跑起来后崩溃了,触发看门狗复位,重启时 bootloader 发现它没确认,就回滚。这个设计给了新固件一个“试用期”。

3.2 应用层要做的三件事

自动回滚要生效,应用层必须配合做三件事,缺一不可。

第一件,在 OTA 写入完成后设置启动分区。调用esp_ota_set_boot_partition(),把otadata里的启动目标指向新分区。这一步不做,重启后还是跑旧固件。

第二件,新固件启动后尽快确认自己有效。在app_main()里,等系统初始化完成、关键外设(Wi-Fi、传感器、通信接口)都正常工作了,再调用esp_ota_mark_app_valid_cancel_rollback()。这个调用时机很讲究——太早了,万一后面初始化失败,你已经确认了,回滚就失效了;太晚了,万一在确认前崩溃,又会被回滚。我的经验是:在 Wi-Fi 连上并且第一次成功上报数据之后确认,这样能最大程度保证“确认的固件是真的能用”。

第三件,处理回滚后的状态。如果发生了回滚,旧固件重新跑起来后,可以通过esp_ota_get_state_partition()查询到另一个分区是ESP_OTA_IMG_INVALID状态。这时候你应该上报一个告警,告诉运维“上次升级失败了”,同时清理掉那个无效分区,为下次升级腾地方。

3.3 回滚的边界条件与失效场景

自动回滚不是万能的,有几个场景它会失效,你必须心里有数。

场景一:新固件把 bootloader 也刷坏了。如果你在 OTA 的时候连 bootloader 一起更新,而且更新过程中断电,那 bootloader 可能损坏,这时候回滚机制本身就没法运行了。所以我的建议是:OTA 只更新应用分区,不要动 bootloader 和分区表。这两个东西在量产时烧一次就够了,后续升级没必要碰。

场景二:新固件把otadata写坏了。虽然概率极低,但如果otadata分区的 CRC 校验失败,bootloader 会认为两个槽都无效,然后尝试从factory分区启动。如果你没有factory分区,那就真的起不来了。所以我在分区表里通常会保留一个极小的factory分区作为最后兜底,哪怕它只放一个最简单的“救砖固件”。

场景三:eFuse 被误烧。ESP32 的 eFuse 里有安全启动、Flash 加密等配置位,一旦烧录就不可逆。如果你误烧了安全启动相关的 eFuse,但没有正确签名固件,芯片会拒绝启动任何未签名的固件,这时候就真砖了。这个坑我在后面会详细讲。

4. OTA 升级完整实操流程与关键代码

4.1 环境准备与分区表配置

先把环境搭起来。我用的是 ESP-IDF v5.1,安装过程不赘述,重点说分区表配置。在项目根目录新建partitions.csv,内容就是前面那个双分区方案。然后在menuconfig里找到Partition Table,把Partition Table设为Custom partition table CSV,文件名填partitions.csv。

编译前先确认 Flash 大小设置正确。menuconfig里Serial flasher config下的Flash size要选4MB(或者你实际用的容量)。这个设置会影响链接脚本对 Flash 地址的映射,设错了会出现“固件超出分区大小”的编译错误。

提示:如果你用的是 ESP32-C3 或 ESP32-S3,分区表的偏移量可能略有不同,因为它们的 bootloader 大小不一样。建议直接用idf.py partition-table命令生成默认分区表,再基于它改,别自己从零写。

4.2 OTA 写入的核心代码实现

下面这段代码是我在实际项目里用的 OTA 写入逻辑,基于esp_https_ota组件,但为了讲清楚原理,我把它拆成了手动写入的版本,方便你理解每一步在干什么。

#include "esp_ota_ops.h" #include "esp_http_client.h" #include "esp_flash_partitions.h" #include "esp_partition.h" #define OTA_BUFF_SIZE 4096 esp_err_t do_firmware_upgrade(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_http_client_cleanup(client); return err; } int content_length = esp_http_client_fetch_headers(client); const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); ESP_LOGI("OTA", "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_http_client_cleanup(client); return err; } char *buf = malloc(OTA_BUFF_SIZE); int total_read = 0; while (total_read < content_length) { int read_len = esp_http_client_read(client, buf, OTA_BUFF_SIZE); if (read_len <= 0) break; err = esp_ota_write(update_handle, buf, read_len); if (err != ESP_OK) { free(buf); esp_ota_abort(update_handle); esp_http_client_cleanup(client); return err; } total_read += read_len; } free(buf); err = esp_ota_end(update_handle); if (err != ESP_OK) { esp_http_client_cleanup(client); return err; } err = esp_ota_set_boot_partition(update_partition); esp_http_client_cleanup(client); return err; }

这段代码的关键点有三个。第一,esp_ota_get_next_update_partition(NULL)会自动帮你找到“当前没在跑的那个分区”,你不需要手动指定ota_0还是ota_1。第二,esp_ota_begin()会先擦除目标分区,擦除过程中断电是安全的,因为otadata还没改,重启后还是跑旧固件。第三,esp_ota_end()会校验写入固件的完整性(包括 SHA256 和镜像头),校验不过会返回错误,不会设置启动分区。

4.3 确认有效与回滚处理

新固件跑起来后,在app_main()里加这么一段:

void app_main(void) { // 初始化 NVS、Wi-Fi、传感器等 initialize_all(); // 等待关键功能就绪,比如 Wi-Fi 连上 if (wait_for_wifi_connected(30000)) { // 确认固件有效,取消回滚 esp_ota_mark_app_valid_cancel_rollback(); ESP_LOGI("OTA", "Firmware marked valid"); } else { // Wi-Fi 没连上,不确认,让 bootloader 下次回滚 ESP_LOGE("OTA", "Wi-Fi failed, not marking valid"); } // 检查是否发生过回滚 const esp_partition_t *running = esp_ota_get_running_partition(); esp_ota_img_states_t state; if (esp_ota_get_state_partition(running, &state) == ESP_OK) { if (state == ESP_OTA_IMG_PENDING_VERIFY) { ESP_LOGW("OTA", "Running pending verify image"); } } // 主循环 while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); } }

这里有个我踩过的坑:不要在app_main()一开始就调用esp_ota_mark_app_valid_cancel_rollback()。我早期就是这么干的,结果有一次新固件在 Wi-Fi 初始化时崩溃,但因为已经确认了,bootloader 不回滚,设备就一直重启。后来我把确认时机挪到 Wi-Fi 连上之后,就再没出现过这个问题。

4.4 实测数据与回滚验证

为了验证回滚机制真的有效,我做了个破坏性测试:故意写一个在app_main()里abort()的固件,通过 OTA 推上去。串口日志清楚地记录了整个过程:

I (1234) OTA: Writing to partition subtype 17 at offset 0x190000 I (5678) OTA: OTA write done, setting boot partition I (5679) OTA: Rebooting... I (100) boot: Loaded app from partition at offset 0x190000 I (101) boot: Checking app image... I (200) app: Starting... I (201) app: About to abort for test abort() was called at PC 0x400d1234 ... I (100) boot: Loaded app from partition at offset 0x10000 I (101) boot: Rollback to previous app

可以看到,第二次重启时 bootloader 检测到ota_1没有确认,自动回滚到了ota_0。整个过程不需要人工干预,设备在 3 秒内恢复了正常。这个测试我重复了十次,每次都能正确回滚。

5. 常见问题排查与避坑经验实录

5.1 分区表相关的典型报错

报错信息原因解决方法
invalid header: 0xffffffff应用分区偏移量错误或分区表未烧录检查partitions.csv中ota_0的 Offset 是否为0x10000
partition table not found分区表 offset 设置错误menuconfig中确认Partition Tableoffset 为0x8000
OTA image has invalid magic byte固件文件损坏或不是 ESP32 格式重新编译固件,确认烧录的是.bin文件
No more free OTA partition两个 OTA 分区都被标记为无效调用esp_ota_erase_last_boot_app_partition()清理无效分区

5.2 OTA 升级失败的排查思路

OTA 失败的原因五花八门,我总结了一个排查顺序,按这个顺序走,基本能定位到问题。

第一步,看串口日志。esp_ota_begin()失败通常是分区找不到或者空间不够;esp_ota_write()失败通常是网络中断或者 Flash 写入错误;esp_ota_end()失败通常是固件校验不过。

第二步,确认固件大小。编译完看idf.py size的输出,如果Total image size超过了单个 OTA 分区的大小,那肯定写不进去。这时候要么精简固件,要么扩大分区。

第三步,检查网络稳定性。我用 HTTP OTA 的时候遇到过服务器返回 302 重定向,但esp_http_client默认不跟随重定向,导致下载到一半断了。后来在esp_http_client_config_t里加了.disable_auto_redirect = false才解决。

第四步,确认otadata状态。如果设备反复回滚,可以在启动时打印otadata的内容,看看两个槽的状态是不是都变成了ESP_OTA_IMG_INVALID。如果是,说明两次升级都失败了,需要手动清理。

5.3 我踩过的三个真实坑

坑一:Flash 加密和 OTA 的冲突。我有个项目开了 Flash 加密,结果 OTA 写入的固件是加密的,但 bootloader 解密时密钥不匹配,导致新固件起不来。后来发现,开启 Flash 加密后,OTA 固件必须用加密后的版本,而且otadata分区也要加密。这个配置在menuconfig的Security features里,勾选Enable flash encryption on boot之后,OTA 流程会自动适配,但前提是你得用idf.py encrypted-flash来烧录初始固件。

坑二:看门狗超时导致误回滚。有一次新固件在app_main()里做了一个耗时的 Flash 操作,超过了任务看门狗的默认超时时间,结果触发复位。复位后 bootloader 以为新固件有问题,直接回滚了。但实际上新固件是好的,只是初始化慢了点。解决办法是在耗时操作前调用esp_task_wdt_reset()喂狗,或者把确认有效的时机提前到耗时操作之前。

坑三:OTA 分区大小不够导致编译失败。我一开始给每个 OTA 分区只留了 1MB,结果加了 HTTPS 和 MQTT 之后固件涨到 1.1MB,编译直接报region 'iram0_0_seg' overflowed。后来把分区扩大到 1.5MB 才解决。所以我的建议是:分区大小至少留 30% 的余量,别卡着固件大小来划。

5.4 关于“变砖”的最终结论

回到标题那个问题:ESP32 固件刷坏了会变砖吗?我的实测结论是:只要 ROM bootloader 没坏、eFuse 没误烧,软件层面的刷坏都能救。双分区加自动回滚的价值,不是防止“刷坏”,而是让“刷坏”这件事在无人值守的场景下自动恢复。你依然可能因为分区表写错、Flash 加密配置错误、eFuse 误烧而让设备彻底起不来,但这些都属于“配置错误”,不是“固件刷坏”。

我在实际项目里,所有带 OTA 功能的设备都强制要求双分区加回滚,并且会在出厂前做一次“破坏性回滚测试”——故意推一个坏固件,确认设备能自动恢复。这个测试花不了十分钟,但能帮你避免无数次现场救砖的尴尬。最后分享一个小技巧:如果你不确定新固件是否稳定,可以先把它推到ota_1,但不调用esp_ota_mark_app_valid_cancel_rollback(),让它跑一个“试用期”。如果试用期内没问题,再通过一个远程命令让它确认;如果有问题,重启就自动回滚。这个“延迟确认”的策略,在灰度发布场景下特别好用。

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

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

立即咨询