
1. 从一次刷机翻车说起ESP32 到底会不会变砖很多人第一次给 ESP32 刷固件手都是抖的。尤其是用 USB-TTL 接上开发板、敲下esptool.py write_flash回车的那一刻脑子里想的全是“万一刷坏了怎么办”。我当年也是这么过来的第一次把一块 ESP32-WROOM-32 刷到一半拔了线重新上电后串口一片乱码当时真以为板子废了。先说结论ESP32 在绝大多数情况下不会真正变砖。它和某些一次性烧录的芯片不一样ESP32 的 Bootloader 固化在芯片内部的 ROM 里这段 ROM 代码是出厂就写死的你刷固件刷的是外部 Flash 里的内容动不了 ROM。只要 ROM 还在芯片就能进入下载模式就能重新烧录。真正意义上的“砖”只有极少数情况才会出现比如把 eFuse 里的加密位、下载禁用位烧错了或者供电异常导致 Flash 物理损坏。但“不会变砖”不等于“不会出事”。固件刷坏之后设备可能起不来、可能反复重启、可能连不上 WiFi对于已经部署到现场、装在天花板或者配电箱里的设备来说跑一趟现场重新插线烧录的成本远比“砖”这个字本身更让人头疼。所以真正要解决的问题不是“会不会变砖”而是怎么让设备在固件出问题时自己救自己。这就是标题里“双分区”和“自动回滚”要干的事。ESP32 的 OTA 机制天生支持多分区配合 ESP-IDF 里的回滚逻辑可以让设备在新固件启动失败时自动退回上一个能用的版本。我这篇文章就把这套东西从头到尾拆一遍分区表怎么设计、OTA 状态怎么标记、回滚在什么条件下触发、代码怎么写、坑在哪里。看完你就能给自己的项目加上这套“后悔药”。2. 双分区与自动回滚的整体设计思路2.1 为什么单分区 OTA 是个危险动作先说说最朴素的 OTA 做法设备只有一个 app 分区新固件下载下来直接覆盖旧的。这个方案在 demo 里跑得通但放到真实产品里就是定时炸弹。原因很简单——覆盖是不可逆的。如果新固件下载过程中断电或者新固件本身有 bug 起不来那旧固件已经被擦掉了设备就卡在“既没有新固件能跑也没有旧固件可退”的状态。我见过不少团队在项目初期图省事用单分区结果量产之后每次发版都提心吊胆。有一次一个客户的项目设备装在户外灯杆上一次 OTA 推了个有内存泄漏的固件设备跑两天就死机重启最后只能派人爬杆子插串口线。那次之后他们才老老实实改成双分区。双分区的核心思想是新旧固件同时存在。Flash 里划出两个 app 分区比如ota_0和ota_1当前运行的在ota_0新固件写到ota_1写完之后把启动目标切到ota_1再重启。如果ota_1跑不起来Bootloader 还能回到ota_0。旧固件始终没被破坏这就是“后悔药”的物理基础。2.2 自动回滚靠的是什么机制光有两个分区还不够还得有人判断“新固件到底行不行”。ESP-IDF 提供了一套OTA 状态机配合esp_ota_mark_app_valid_cancel_rollback()这个接口来工作。逻辑是这样的新固件第一次启动时Bootloader 会把它标记为“待验证”状态。此时如果设备重启Bootloader 会认为这个固件没通过验证自动切回上一个分区。只有当新固件在代码里主动调用esp_ota_mark_app_valid_cancel_rollback()告诉系统“我跑起来了没问题”这个固件才会被标记为有效回滚状态才会被取消。这个设计非常巧妙。它把“固件是否健康”的判断权交给了固件自己。你可以在调用这个接口之前做各种自检WiFi 能不能连上、传感器能不能读到数据、关键任务有没有正常启动。任何一项不通过你就不调用让设备重启Bootloader 自然帮你回滚。注意回滚的触发条件是“新固件启动后没有标记有效就发生了重启”。所以如果你在自检里发现异常正确的做法是主动调用esp_restart()而不是死循环卡在那里。2.3 分区表怎么规划才合理分区表是整个方案的地基规划不好后面全是坑。一个典型的支持双分区 OTA 的分区表长这样# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, ota_0, app, ota_0, 0x20000, 0x180000, ota_1, app, ota_1, 0x1A0000, 0x180000,几个关键点值得说清楚。otadata分区是 OTA 状态机的存储位置它记录当前该从哪个 app 分区启动、哪个分区是待验证状态。这个分区不大8KB 足够但绝对不能省。ota_0和ota_1大小必须一致而且都要能装下你最大的固件。我一般会留 20% 余量因为固件会随着功能迭代慢慢变大分区划得太紧后面改起来很痛苦。Flash 总容量决定了你能怎么划。4MB 的 Flash 划两个 1.5MB 的 app 分区基本就到顶了如果你的固件超过 1.5MB就得考虑换 8MB 或 16MB 的模组。我实测过一个带 WiFi、MQTT、JSON 解析和 OTA 功能的固件编译出来大概 1.1MB所以 1.5MB 分区对中等复杂度的项目是够用的。3. 核心细节解析与实操要点3.1 otadata 分区的状态流转otadata分区里存的是两个esp_ota_select_entry_t结构每个 4KB轮流写入。每个 entry 记录了ota_seq序列号、ota_state状态和 CRC 校验。状态主要有几种ESP_OTA_IMG_NEW、ESP_OTA_IMG_PENDING_VERIFY、ESP_OTA_IMG_VALID、ESP_OTA_IMG_INVALID、ESP_OTA_IMG_ABORTED。新固件刚写完时是NEW第一次启动后变成PENDING_VERIFY。这时候如果重启Bootloader 读到PENDING_VERIFY就会判定验证失败切回另一个分区并把失败的分区标记为ABORTED。如果固件调用了确认接口状态变成VALID回滚取消。理解这个流转很重要因为很多“回滚没生效”的问题根源都在状态没搞对。比如你在新固件里调用了确认接口但调用时机太早——WiFi 还没连上就确认了——那后面 WiFi 挂了也不会回滚因为系统认为这个固件已经有效了。3.2 确认接口的调用时机这是整个方案里最需要动脑子的地方。esp_ota_mark_app_valid_cancel_rollback()调早了回滚形同虚设调晚了设备可能因为自检逻辑本身有 bug 而反复回滚。我的经验是分两层自检。第一层是硬性自检在app_main启动后尽快做比如关键外设初始化、NVS 读写、看门狗喂狗是否正常。这些是固件能不能跑的基本条件不通过就直接重启。第二层是业务自检比如 WiFi 连接、服务器握手、传感器数据有效性这些可以给一个超时窗口比如 30 秒内必须完成完不成就重启触发回滚。具体代码大概长这样void app_main(void) { // 硬性自检 esp_err_t ret nvs_flash_init(); if (ret ! ESP_OK) { esp_restart(); } // 启动业务逻辑 xTaskCreate(business_task, biz, 4096, NULL, 5, NULL); // 等待业务自检结果最多 30 秒 EventBits_t bits xEventGroupWaitBits(s_biz_event, BIZ_READY_BIT, pdFALSE, pdTRUE, pdMS_TO_TICKS(30000)); if (bits BIZ_READY_BIT) { // 自检通过确认固件有效 esp_ota_mark_app_valid_cancel_rollback(); } else { // 自检失败重启触发回滚 esp_restart(); } }提示esp_ota_mark_app_valid_cancel_rollback()只需要调用一次重复调用不会报错但也没意义。建议在确认成功后打个日志方便现场排查。3.3 写入新固件时的注意事项OTA 写入过程本身也有讲究。esp_ota_begin()之后你要分块调用esp_ota_write()最后esp_ota_end()。这里有几个坑第一写入前必须确保新固件大小不超过目标分区。esp_ota_begin()会检查但如果你的分区表划得不合理可能到写入一半才发现空间不够。我一般会在下载前先读一下 HTTP 头里的Content-Length和分区大小比一下超了直接放弃。第二写入过程中要喂看门狗。OTA 写 Flash 是耗时操作如果任务优先级设置不当可能触发看门狗复位。建议把 OTA 任务优先级设高一点或者在写入循环里定期vTaskDelay(1)。第三写完必须校验。esp_ota_end()会做镜像校验但如果你下载的固件本身在传输中损坏了校验会失败。这时候不要切换启动分区保持当前固件继续运行等下次重试。4. 完整实操流程与关键环节实现4.1 环境准备与分区表配置我用的环境是 ESP-IDF v5.1Arduino 环境下也能做但分区表配置没那么灵活建议还是用 IDF。先创建工程idf.py create-project ota_rollback_demo cd ota_rollback_demo然后在工程根目录新建partitions.csv内容就是前面那个分区表。接着在menuconfig里配置idf.py menuconfig进入Partition Table菜单把Partition Table设为Custom partition table CSV文件名填partitions.csv。再进入Component config - ESP System Settings确认Bootloader config里的Bootloader log verbosity至少是Info方便看回滚日志。4.2 OTA 下载与写入的代码实现我用的是 HTTP 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_http_client_cleanup(client); return err; } int content_len esp_http_client_fetch_headers(client); if (content_len 0) { esp_http_client_cleanup(client); return ESP_FAIL; } const esp_partition_t *update_partition esp_ota_get_next_update_partition(NULL); if (content_len update_partition-size) { esp_http_client_cleanup(client); return ESP_ERR_INVALID_SIZE; } esp_ota_handle_t ota_handle; err esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, ota_handle); if (err ! ESP_OK) { esp_http_client_cleanup(client); return err; } char buf[1024]; int total_read 0; while (total_read content_len) { int read_len esp_http_client_read(client, buf, sizeof(buf)); if (read_len 0) break; err esp_ota_write(ota_handle, buf, read_len); if (err ! ESP_OK) { esp_ota_abort(ota_handle); esp_http_client_cleanup(client); return err; } total_read read_len; vTaskDelay(1); // 喂狗 } esp_http_client_close(client); esp_http_client_cleanup(client); if (total_read ! content_len) { esp_ota_abort(ota_handle); return ESP_FAIL; } err esp_ota_end(ota_handle); if (err ! ESP_OK) return err; err esp_ota_set_boot_partition(update_partition); if (err ! ESP_OK) return err; esp_restart(); return ESP_OK; }这段代码里esp_ota_get_next_update_partition(NULL)会自动返回当前没在运行的那个 app 分区省得你自己算。esp_ota_set_boot_partition()把启动目标切过去然后重启新固件就开始跑了。4.3 回滚验证的实测过程为了验证回滚真的有效我故意做了一个“坏固件”在app_main里直接进入死循环不调用确认接口。烧录流程是这样的先烧一个正常固件到ota_0确认设备能正常跑。然后通过 OTA 推一个坏固件到ota_1。设备重启后跑坏固件因为没调确认接口看门狗超时触发重启。Bootloader 检测到ota_1处于PENDING_VERIFY状态且发生了重启判定验证失败自动切回ota_0。串口日志大概是这样I (30) boot: Loaded app from partition at offset 0x1A0000 I (30) boot: Set actual ota_seq2 ... I (5000) boot: ota_1 app is pending verify I (5000) boot: Rollback to ota_0 I (5000) boot: Loaded app from partition at offset 0x20000看到Rollback to ota_0这行日志的时候心里那块石头就落地了。整个过程不需要人工干预设备自己完成了救援。4.4 分区大小与 Flash 容量的匹配计算这里补充一个实际项目里的计算过程。假设你用 ESP32-WROOM-32E4MB Flash。Bootloader 占 0x8000分区表占 0x1000NVS 给 0x5000otadata 给 0x2000phy_init 给 0x1000。这些加起来大概 0x11000也就是 68KB。剩下约 4MB - 68KB ≈ 3.93MB 给两个 app 分区每个约 1.96MB。但实际不能这么满打满算因为 Flash 末尾通常还要留一点给文件系统或者参数存储。我一般会留 256KB 给 SPIFFS 或 LittleFS这样每个 app 分区大概 1.8MB。如果你的固件编译出来超过 1.8MB就得考虑换 8MB Flash 的模组或者裁剪功能。注意分区表里的 offset 必须 4KB 对齐否则idf.py build会报错。我见过有人把 offset 写成 0x21000 这种非对齐值编译直接失败。5. 常见问题与排查技巧实录5.1 回滚没生效的几种典型情况情况一确认接口调用太早。有人在app_main第一行就调了确认接口结果后面 WiFi 连不上也不回滚。解决办法是把确认接口放到业务自检通过之后。情况二otadata 分区被擦除。如果你在代码里调了nvs_flash_erase()或者手动擦了整个 Flashotadata 里的状态就丢了Bootloader 会按默认分区启动回滚逻辑失效。解决办法是擦 Flash 时避开 otadata 区域。情况三分区表里没有 otadata。有些人为了省空间把 otadata 删了结果 OTA 状态机没法工作回滚自然不生效。otadata 这 8KB 绝对不能省。情况四新固件启动后没重启而是卡死。回滚的触发条件是“重启”如果固件卡在死循环里连看门狗都不触发Bootloader 没机会介入。解决办法是确保看门狗正常工作或者在自检失败时主动esp_restart()。5.2 OTA 下载失败的排查思路OTA 下载失败的原因很多我整理了一个速查表现象可能原因排查方法连接超时服务器地址错、网络不通ping 服务器、检查 URL返回 404固件路径错浏览器直接访问 URL写入一半失败Flash 空间不足检查分区大小和固件大小校验失败固件损坏对比服务器和本地文件 MD5重启后回滚新固件自检没过看串口日志确认自检哪步失败我遇到最多的是“写入一半失败”基本都是分区划小了。有一次一个项目固件 1.6MB分区只给了 1.5MBesp_ota_begin没报错写到 1.5MB 的时候esp_ota_write返回ESP_ERR_OTA_VALIDATE_FAILED查了半天才发现是空间不够。5.3 现场部署的避坑经验设备一旦部署到现场OTA 就成了远程操作出问题的代价很高。我总结了几条经验第一首次 OTA 一定要留后路。新设备出厂时烧的固件要确保能正常 OTA并且分区表要支持回滚。我见过出厂固件分区表就是错的后面想 OTA 都 OTA 不了。第二OTA 推送要分批。不要一次性把所有设备都推新固件先推 5% 观察一天没问题再全量。ESP32 的回滚虽然可靠但批量出问题的时候回滚日志能把你淹了。第三保留串口日志输出。即使设备部署了也要留 UART 测试点万一出问题能接串口看日志。纯靠 WiFi 日志有时候不够因为网络本身可能就有问题。第四otadata 状态要能远程查询。我在固件里加了一个接口可以返回当前运行的分区和 OTA 状态这样远程就能判断设备是不是回滚过。5.4 关于固件加密与安全启动的补充有些项目会用到 Flash 加密和安全启动这两个功能会影响 OTA 和回滚。Flash 加密之后OTA 写入的固件必须是加密的否则 Bootloader 解不开。安全启动则要求固件签名签名不对直接拒绝启动连回滚机会都没有。如果你要用这两个功能建议先在开发板上把 OTA 和回滚跑通再开加密和签名。因为一旦开了烧录和调试都会变麻烦出问题排查成本很高。我一般建议中小项目先不上加密等产品成熟了再考虑。6. 写在最后的一些个人体会这套双分区加自动回滚的方案我从 2019 年用到现在经手的项目少说也有十几个真正因为固件问题需要现场救援的次数从之前的每年好几次降到了零。不是说固件就不出 bug 了而是出了 bug 设备自己能退回去用户甚至感知不到。最让我印象深刻的一次是一个农业大棚里的环境监测项目设备装在棚顶夏天棚内温度能到 50 度。有一次推了个新固件结果 WiFi 驱动在高温下不稳定设备连不上服务器。因为加了回滚设备重启两次之后自动退回了旧固件继续正常上报数据。后来我们分析日志才发现是驱动问题修好之后重新推全程没派人去现场。如果你正在做 ESP32 的 OTA 功能我强烈建议把双分区和回滚加上。多花半天时间配置分区表和写自检逻辑能省掉后面无数个提心吊胆的夜晚。分区表规划的时候宁可多留点余量确认接口的调用时机多想想业务场景看门狗一定要确保正常工作。这几件事做到位ESP32 就真的很难变砖了。