
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占0x20008KB这是 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大小0x10004KB。如果你自定义分区表记得在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 是否为0x10000partition table not found分区表 offset 设置错误menuconfig中确认Partition Tableoffset 为0x8000OTA 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()让它跑一个“试用期”。如果试用期内没问题再通过一个远程命令让它确认如果有问题重启就自动回滚。这个“延迟确认”的策略在灰度发布场景下特别好用。