1. 从一次“刷废了”的深夜事故说起但凡玩过 ESP32 的人大概率都经历过那种心跳骤停的瞬间串口连上烧录进度条走到 87%手一抖拔了线或者电源纹波一抖再上电串口只剩下一行反复重启的rst:0x3 (SW_RESET)连boot日志都刷不出来。那一刻脑子里只有一个念头——这板子是不是废了是不是得重新买一片。我手上这块 ESP32-WROOM-32 开发板三年前就经历过这么一次。当时在做一个小型的温湿度采集节点用 Arduino IDE 直接烧录图省事没做任何分区规划结果一次 OTA 中途断电板子直接卡在 bootloader 循环里。后来折腾了大半天用 esptool 手动擦除 flash 才救回来。那次之后我就下定决心把双分区加自动回滚这套机制彻底吃透再也没让任何一块板子真正“变砖”过。这篇内容就是把这套东西从头到尾讲清楚。核心关键词是ESP32、固件、双分区、自动回滚、OTA。我会讲清楚为什么双分区能防砖、自动回滚是怎么触发的、分区表怎么配、OTA 流程怎么走、代码怎么写、踩过哪些坑。适合已经上手 ESP32、准备做 OTA 升级或者被刷机事故折磨过的朋友。哪怕你只用 Arduino IDE也能照着落地。先说结论ESP32 固件刷坏了绝大多数情况下不会真变砖前提是你提前把双分区和回滚机制配好。真正意义上的“砖”是指 bootloader 本身被破坏且无法通过串口重新烧录这种情况在 ESP32 上极其罕见因为它的 bootloader 固化在 ROM 里串口下载模式几乎不可能被软件彻底锁死。我们平时说的“变砖”99% 是应用分区被写坏、启动校验失败、或者分区表错乱导致系统找不到可启动镜像。这些问题双分区加自动回滚就是专门用来兜底的。2. 为什么单分区 OTA 是“裸奔”2.1 单分区升级的致命逻辑很多人第一次做 OTA思路很朴素设备里就一个应用分区新固件下载下来直接覆盖写进去写完重启。这个流程在理想环境下没问题但它有一个无法回避的窗口期——写入过程中旧固件已经被部分覆盖新固件还没写完。这时候如果断电、复位、或者写入校验失败设备里剩下的就是一个半成品镜像bootloader 校验不过直接卡死。我用一个生活化的类比来解释单分区 OTA 就像你只有一间房子要换家具必须先把旧家具搬出去再把新家具搬进来。搬到一半停电了房子里空空荡荡你连睡觉的地方都没有。双分区则是你有两间房新家具先搬进空房间布置好了再换门牌旧房间原封不动留着随时能退回去。ESP32 的 flash 分区表就是这个“房间分配图”。默认的 Arduino 分区方案里很多是default或者minimal只给了一个app0分区根本没有app1。你在这个基础上做 OTA本质上就是在裸奔。2.2 分区表到底长什么样ESP32 的分区表是一张写在 flash 固定偏移0x8000处的表格每行描述一个分区的名称、类型、子类型、偏移和大小。一个支持双分区 OTA 的典型分区表大概是这样名称类型子类型偏移大小用途nvsdatanvs0x90000x5000键值存储otadatadataota0xE0000x2000OTA 状态记录app0appota_00x100000x140000应用分区 Aapp1appota_10x1500000x140000应用分区 Bspiffsdataspiffs0x2900000x160000文件系统这里最关键的两个角色是otadata和两个app分区。otadata只有 8KB但它记录了当前应该从哪个 app 分区启动、哪个分区是“待验证”状态、启动失败了几次。bootloader 每次上电都会先读otadata再决定跳转到app0还是app1。注意分区表里所有分区的偏移必须对齐到 0x10004KB大小也建议对齐。偏移算错会导致 bootloader 找不到分区直接启动失败。2.3 双分区如何把“变砖”概率压到极低双分区的核心价值在于原子性切换。新固件永远写入“非当前运行”的那个分区写完之后只改otadata里的一个标志位告诉 bootloader“下次从新分区启动”。这个标志位的写入是一个很小的操作几乎不会失败。即使失败bootloader 读到的还是旧标志继续从旧分区启动设备照常工作。这就把“升级失败导致设备不可用”的概率从“整个固件写入过程都不能出错”降低到了“一个几字节的标志位写入不能出错”。两者可靠性完全不是一个量级。再加上自动回滚机制即使新固件本身有 bug 导致启动后崩溃系统也能在几次失败后自动切回旧分区。这套组合拳下来ESP32 在 OTA 场景下的可用性可以做到非常接近“永不砖”。3. 自动回滚的触发逻辑与状态机3.1 otadata 里的状态到底怎么变自动回滚不是魔法它依赖otadata分区里记录的状态和 bootloader 的启动计数逻辑。ESP-IDF 的 OTA 状态机大致有这几个关键状态ESP_OTA_IMG_NEW新固件刚写入还没验证过ESP_OTA_IMG_PENDING_VERIFYbootloader 已经尝试启动它等待应用自己确认“我没问题”ESP_OTA_IMG_VALID应用主动调用确认接口标记为可用ESP_OTA_IMG_INVALID应用主动标记为无效或者启动失败次数超限ESP_OTA_IMG_ABORTED升级过程被中断流程是这样的新固件写入app1后otadata把app1标记为NEW。重启后 bootloader 看到NEW把它改成PENDING_VERIFY然后跳转过去启动。应用启动后如果一切正常需要在合适时机调用esp_ota_mark_app_valid_cancel_rollback()把状态改成VALID。如果应用启动后崩溃、看门狗复位、或者根本没来得及调用确认接口bootloader 在下次启动时发现它还是PENDING_VERIFY就会把启动次数加一。连续失败达到阈值默认是 3 次bootloader 就把这个分区标记为INVALID切回另一个分区。3.2 回滚阈值和确认时机怎么定默认的失败阈值是 3 次这个值在menuconfig里可以调对应CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和相关的重试次数配置。我的经验是3 次是个比较稳妥的默认值。设成 1 次太激进可能因为一次偶发的电源抖动就误回滚设成 5 次以上又太迟钝设备可能反复重启很久才恢复。确认时机的选择更讲究。你不能在app_main一进来就立刻调用确认接口因为那时候网络、外设、关键任务都还没初始化完万一后面初始化失败你已经把新固件标记成VALID了回滚就失效了。我的做法是在应用完成所有关键初始化、并且成功连上网络或完成一次自检之后再调用确认接口。比如一个联网的传感器节点我会等到 MQTT 连接成功、第一条数据上报成功之后才标记固件有效。这样能最大程度保证“标记为有效”的固件确实是能正常工作的。提示调用esp_ota_mark_app_valid_cancel_rollback()之后回滚窗口就关闭了。如果之后应用再崩溃系统不会自动回滚只会不断重启当前固件。所以确认时机宁晚勿早。3.3 回滚发生时设备经历了什么我实测过一次完整的回滚过程记录下来的串口日志很有代表性。新固件因为一个空指针解引用在初始化阶段就触发了 panic重启。bootloader 日志里能看到类似这样的信息I (30) boot: Loaded app from partition at offset 0x150000 I (30) boot: Set actual ota_seq2, ota_seq1 W (30) boot: ota_seq2 is pending verify ... I (120) boot: Loaded app from partition at offset 0x150000 W (120) boot: ota_seq2 is pending verify, rollback count 1 ... I (210) boot: Loaded app from partition at offset 0x150000 W (210) boot: ota_seq2 is pending verify, rollback count 2 ... I (300) boot: Rollback to ota_seq1 I (300) boot: Loaded app from partition at offset 0x10000三次启动失败后bootloader 自动切回了app0设备恢复正常。整个过程不需要人工干预也不需要串口连接。这就是自动回滚的价值——它让设备在无人值守的场景下也能自我修复。4. 分区表配置与工程落地实操4.1 在 Arduino IDE 里配置双分区很多人以为双分区是 ESP-IDF 的专属功能其实 Arduino IDE 里也能用只是配置方式不太一样。Arduino-ESP32 核心自带了几种分区方案在“工具”菜单的“Partition Scheme”里可以选。默认是Default 4MB with spiffs这个方案只有一个 app 分区不支持 OTA。你要选Minimal SPIFFS (1.9MB APP with OTA)或者Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)这类带 OTA 的方案。但内置方案的分区大小是固定的如果你的固件比较大或者需要更大的文件系统就得自定义分区表。做法是在项目目录下新建一个partitions.csv文件然后在 Arduino IDE 里没法直接指定需要改用 PlatformIO 或者在boards.txt里改配置。这也是为什么很多认真做 OTA 的项目最后都转到了 PlatformIO 或者纯 ESP-IDF。4.2 PlatformIO 下的分区表配置PlatformIO 的配置更灵活。在platformio.ini里可以指定分区表文件[env:esp32dev] platform espressif32 board esp32dev framework arduino board_build.partitions partitions.csv然后在项目根目录放一个partitions.csv内容按前面表格的格式写。这里有个容易踩的坑分区表的总大小不能超过 flash 容量。4MB flash 的话所有分区的偏移加大小不能超过 0x400000。我见过有人把两个 app 分区都设成 0x200000加上其他分区直接超了编译能过但烧录后启动异常。4.3 ESP-IDF 下的 menuconfig 关键项如果你用 ESP-IDF配置入口在idf.py menuconfig里。需要重点确认的几项Partition Table→Partition Table选择Custom partition table CSV并指定文件名Bootloader config→Enable app rollback support必须打开Bootloader config→Number of app rollback attempts默认 3可调Component config→ESP32-specific→Support for external, SPI-connected RAM按需打开回滚支持后bootloader 才会去读otadata里的PENDING_VERIFY状态并执行计数逻辑。如果这项没开即使你分区表配了双分区自动回滚也不会生效新固件启动失败后只会一直重启。注意修改分区表后第一次烧录建议用idf.py erase-flash先全擦再idf.py flash。因为旧的otadata内容可能和新分区布局不兼容残留数据会导致启动行为异常。5. OTA 升级代码的完整实现5.1 基于 ESP-IDF 的 OTA 核心流程ESP-IDF 提供了一套esp_ota_opsAPI核心步骤可以概括为获取当前分区、开始 OTA、分块写入、结束 OTA、设置启动分区、重启。下面是一段我实际项目里用的简化代码去掉了业务逻辑保留了骨架#include esp_ota_ops.h #include esp_http_client.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, }; 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); vTaskDelete(NULL); return; } int content_length esp_http_client_fetch_headers(client); const esp_partition_t *update_partition esp_ota_get_next_update_partition(NULL); ESP_LOGI(TAG, Writing to partition subtype %d at offset 0x%x, 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); vTaskDelete(NULL); return; } char buf[1024]; int total_read 0; while (1) { int data_read esp_http_client_read(client, buf, sizeof(buf)); if (data_read 0) break; err esp_ota_write(update_handle, buf, data_read); 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); vTaskDelete(NULL); return; } total_read data_read; } 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); vTaskDelete(NULL); return; } 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); vTaskDelete(NULL); return; } ESP_LOGI(TAG, OTA done, restarting...); esp_http_client_cleanup(client); esp_restart(); }这段代码里esp_ota_get_next_update_partition(NULL)会自动返回“非当前运行”的那个 app 分区这就是双分区切换的关键。esp_ota_set_boot_partition只改otadata标志不碰旧分区内容。5.2 应用有效性确认的代码位置新固件启动后必须在合适位置调用确认接口。我通常把它放在网络连接成功之后void app_main(void) { // 初始化 NVS、网络、外设... wifi_init_sta(); // 等待网络连接 if (wait_for_wifi_connected(30000)) { // 网络正常标记固件有效 esp_err_t err esp_ota_mark_app_valid_cancel_rollback(); if (err ESP_OK) { ESP_LOGI(TAG, Firmware marked valid, rollback cancelled); } } else { ESP_LOGW(TAG, Network not ready, rollback window still open); } // 继续业务逻辑... }如果网络一直连不上我就不确认让回滚窗口保持打开。这样如果新固件有网络相关的 bug设备重启几次后会自动回到旧固件。这个策略在实际部署中救过我好几次。5.3 Arduino 框架下的 OTA 实现差异Arduino-ESP32 核心也封装了Update库用法比 ESP-IDF 简单很多#include Update.h #include HTTPClient.h void performOTA(String url) { HTTPClient http; http.begin(url); int httpCode http.GET(); if (httpCode HTTP_CODE_OK) { int contentLength http.getSize(); bool canBegin Update.begin(contentLength); if (canBegin) { WiFiClient *client http.getStreamPtr(); size_t written Update.writeStream(*client); if (written contentLength) { if (Update.end()) { if (Update.isFinished()) { ESP.restart(); } } } } } http.end(); }但要注意Arduino 的Update库默认也会用双分区前提是你的分区方案支持 OTA。它内部同样会调用esp_ota_set_boot_partition。不过 Arduino 框架下对回滚确认的控制没有 ESP-IDF 那么细Update.end()之后通常需要手动处理确认逻辑或者依赖核心的默认行为。如果你对回滚时机有严格要求还是建议用 ESP-IDF。6. 常见问题与排查技巧实录6.1 升级后设备反复重启但不回滚这是最常见的问题。原因通常是回滚支持没打开或者应用在启动早期就调用了确认接口。排查步骤确认menuconfig里Enable app rollback support是打开的检查代码里esp_ota_mark_app_valid_cancel_rollback()的调用位置是否在关键初始化之前看串口日志里有没有pending verify字样如果没有说明 bootloader 根本没进入回滚计数流程还有一种可能是分区表里otadata分区缺失或大小不对。otadata必须是data类型、ota子类型大小 0x2000。少了它OTA 状态无处记录回滚自然无从谈起。6.2 OTA 写入到一半失败设备还能启动吗能。因为写入的是非当前分区旧分区完好无损。esp_ota_abort()会清理未完成的写入状态otadata里的启动标志没变下次启动还是从旧分区走。我实测过多次中途断电只要旧分区没被碰过设备都能正常启动。这也是双分区最让人安心的地方。但有一种情况例外如果你在esp_ota_set_boot_partition之后、重启之前断电而新分区其实写坏了那下次启动会尝试新分区失败后触发回滚。这个过程可能需要几次重启但最终会回到旧分区。6.3 分区表改了之后编译报错常见报错是Region overflow或者partition table offset exceeds flash size。这时候要算一下总账所有分区的偏移加大小必须小于等于 flash 容量。另外app分区的大小必须是 0x10000 的整数倍因为 ESP32 的 flash 映射要求。我一般会留至少 0x10000 的余量避免边界问题。6.4 回滚后旧固件的 NVS 数据还在吗在。NVS 分区是独立的OTA 切换 app 分区不会动 NVS。但要注意如果新旧固件对 NVS 里键值的结构定义不一致回滚后旧固件读到新固件写的数据可能会解析异常。我的做法是给 NVS 数据加版本号固件启动时检查版本不匹配就重置相关键值。这个细节在频繁 OTA 的项目里很重要。问题现象可能原因排查方法升级后一直重启回滚未启用或确认过早查 menuconfig 和确认接口调用位置找不到 OTA 分区分区表无 app1 或 otadata检查 partitions.csv写入失败flash 空间不足或网络中断看串口错误码确认分区大小回滚不触发otadata 损坏或大小错误擦除 flash 重新烧录回滚后数据异常NVS 结构不兼容加版本号校验6.5 几个我踩过的坑第一个坑是在中断里调用 OTA 相关函数。OTA 写入涉及 flash 操作flash 操作在某些情况下会关中断在中断上下文里调用会导致系统不稳定。所有 OTA 操作都应该放在独立任务里给足栈空间。第二个坑是HTTP 下载没有超时和重试。网络不稳的时候esp_http_client_read可能长时间阻塞看门狗直接复位。一定要设置timeout_ms并且对失败做重试或回退处理。第三个坑是固件版本号没管理好。OTA 服务器上放了新固件但设备不知道版本反复下载同一个文件。我在otadata之外还在 NVS 里存了一个版本字符串升级前先比对相同就跳过。7. 固件安全与升级策略的延伸思考7.1 固件校验不能只靠 CRCESP-IDF 的 OTA 流程会对写入的镜像做 SHA-256 校验esp_ota_end会验证镜像的完整性。但这只能保证“传输没出错”不能保证“固件来源可信”。如果你的 OTA 走公网强烈建议加上签名校验。ESP-IDF 支持 secure boot 和 flash encryption配合 OTA 可以做到固件只有被私钥签名的才能启动。这套配置稍微复杂但对于商用设备几乎是必选项。7.2 分批升级与灰度发布设备量大了之后一次性全量推送 OTA 风险很高。我的做法是分批先推 5% 的设备观察 24 小时确认没有异常回滚再扩大。ESP32 端可以在 OTA 请求里带上设备 ID 和当前版本服务器根据策略决定是否下发。这样即使新固件有问题影响面也可控。7.3 回滚不是万能药自动回滚能解决“新固件启动失败”的问题但解决不了“新固件运行一段时间后才出问题”的情况。如果新固件启动正常、确认了有效、跑了三天才崩溃回滚窗口早就关了。所以除了回滚还需要运行时看门狗、异常上报、远程日志这些手段配合。我一般会在固件里加一个心跳机制连续多次心跳失败就主动触发一次“标记无效并重启”强制回到旧分区。8. 我个人的实操体会这套双分区加自动回滚的机制我从三年前那次事故之后就开始用前后部署过几百台设备真正因为 OTA 导致设备完全失联的情况一次都没再出现过。最惊险的一次是新固件里有个内存泄漏设备跑十几个小时后重启但因为我在心跳逻辑里加了“连续三次异常重启就标记无效”设备自动回滚到了旧版本远程看日志才发现问题。如果你现在还在用单分区做 OTA我建议你花一个下午把分区表改了把回滚支持打开。改动量不大但带来的可靠性提升是数量级的。分区表配置和确认接口的调用位置是两个最容易出错的地方多打日志、多测几次断电场景心里就有底了。ESP32 这颗芯片在 OTA 方面的设计其实相当完善把它的能力用起来固件刷坏这件事真的可以不再是噩梦。
阅读完成 · 觉得有帮助?