1. 从一次“刷废了”的深夜抢救说起
凌晨两点,我盯着串口监视器里不断滚动的乱码,手里的开发板已经第三次重启失败了。那是我第一次给 ESP32 刷自定义固件,手一抖把分区表写错了,设备直接卡在 bootloader 阶段,连串口都只剩下一堆看不懂的十六进制输出。当时脑子里只有一个念头:这板子是不是废了?
后来我才明白,ESP32 的“变砖”和传统单片机那种“一失足成千古恨”的变砖,完全不是一回事。乐鑫在设计这颗芯片的时候,就在 ROM 里固化了一段不可修改的引导程序,只要这段 ROM 引导程序还在,你就永远有机会把设备救回来。换句话说,ESP32 几乎不可能真正“死透”,除非你把芯片物理烧毁或者把 eFuse 里的关键位写死。
但“能救回来”和“不用救”是两码事。每次刷坏都要拆机、接线、按住 BOOT 键手动进下载模式,这种体验谁试谁知道。所以真正值得聊的,不是“变砖了怎么救”,而是“怎么设计一套机制,让固件刷坏了也能自己滚回来”。这就是我今天要展开的内容:用双分区加自动回滚,把 ESP32 的 OTA 升级做成一个“刷不死”的系统。
这套方案适合所有用 ESP32 做量产设备、远程部署或者经常需要迭代固件的开发者。不管你是用 Arduino IDE 还是 ESP-IDF,不管你走的是 Wi-Fi OTA 还是蓝牙推送,双分区加回滚的逻辑都是通用的。读完你至少能搞清楚三件事:分区表到底该怎么划、回滚标志位在什么时机打、以及那些官方文档里不会写的坑到底长什么样。
2. 双分区方案的整体设计与选型考量
2.1 为什么单分区 OTA 是个危险游戏
先说说最朴素的 OTA 做法:设备只有一个 app 分区,新固件下载下来之后直接覆盖旧固件。这种做法在网速稳定、固件经过充分测试的前提下勉强能用,但它有一个致命缺陷——写入过程中一旦断电、断网或者固件本身有 bug,设备就彻底失去了可运行的代码。你可能会说“那我写完再校验不就行了”,问题是校验通过只代表数据完整,不代表固件能正常启动。一个死循环的setup()或者一个空指针解引用,照样能让设备在重启后卡死。
我见过太多团队在早期为了省那几百 KB 的 Flash 空间,选择单分区方案,结果每次发版都提心吊胆。更麻烦的是,设备部署在现场之后,你根本没有物理接触的机会,一旦刷坏就只能派人上门或者整机更换。这个成本远比多占一个分区的 Flash 要高得多。
2.2 双分区的基本布局与空间计算
双分区方案的核心思路很简单:Flash 里同时存在两个 app 分区,一个在运行,另一个用来接收新固件。升级时把新固件写到“备用”分区,写完之后修改启动标志,下次重启就从新分区启动。如果新分区启动失败,bootloader 会自动切回旧分区。
以一块常见的 4MB Flash 的 ESP32 为例,我通常这样划分:
| 分区名称 | 类型 | 偏移地址 | 大小 | 用途说明 |
|---|---|---|---|---|
| nvs | data | 0x9000 | 0x5000 | 存储 Wi-Fi 配置和回滚标志 |
| otadata | data | 0xE000 | 0x2000 | 记录当前启动分区和状态 |
| phy_init | data | 0x10000 | 0x1000 | 射频校准数据 |
| ota_0 | app | 0x11000 | 0x180000 | 主固件分区 A |
| ota_1 | app | 0x191000 | 0x180000 | 主固件分区 B |
| storage | data | 0x311000 | 0xE0000 | 文件系统或用户数据 |
这里有几个数字需要解释一下。ota_0 和 ota_1 各占 1.5MB,加起来 3MB,加上前面的引导区和后面的存储区,刚好塞进 4MB Flash。如果你的固件比较大,比如带了摄像头驱动或者语音模型,那就得换 8MB 甚至 16MB 的模组。我一般建议固件体积不要超过分区大小的 70%,留出足够的余量给未来的功能扩展。
otadata 分区只有 8KB,但它非常关键。它里面存了两个ota_select结构体,每个结构体记录了对应 app 分区的序列号和状态。bootloader 每次启动时都会读这个分区,决定从哪个 app 分区加载固件。这个设计的好处是,切换分区的操作只是写几个字节的 Flash,速度极快,几乎不可能在写入过程中出错。
2.3 自动回滚的判定逻辑
自动回滚的触发条件,不同版本的 ESP-IDF 有细微差别,但核心逻辑是一致的。我以目前最常用的 ESP-IDF v4.x 和 v5.x 为例来说明。
当设备从 ota_1 分区启动后,bootloader 会把 otadata 里的状态标记为ESP_OTA_IMG_NEW。此时固件开始运行,你的应用程序需要在初始化完成后,主动调用esp_ota_mark_app_valid_cancel_rollback()来告诉系统“这个固件没问题”。如果你没有调用这个函数,设备又重启了一次,bootloader 就会把状态改成ESP_OTA_IMG_PENDING_VERIFY。再重启一次,如果还是没有确认,bootloader 就会判定新固件启动失败,自动把启动分区切回 ota_0。
这个“两次重启”的机制是一个安全缓冲。有些固件启动后需要连接网络、同步时间或者等待外设就绪,这些操作可能需要几十秒甚至几分钟。在这段时间内如果发生意外重启,系统不会立刻回滚,而是给你第二次机会。只有连续两次都没有确认,才会真正回滚。
在 Arduino 环境下,这个逻辑被封装得更简单。你只需要在setup()里调用esp_ota_mark_app_valid_cancel_rollback(),剩下的交给底层。但要注意,Arduino 的 OTA 库默认不会自动回滚,你需要手动配置分区表并启用相关选项。
3. 分区表配置与固件端代码实现细节
3.1 手把手写一份可用的分区表
分区表是一个 CSV 文件,通常命名为partitions.csv,放在项目根目录下。ESP-IDF 在编译时会自动读取这个文件,Arduino IDE 则需要你在工具菜单里选择自定义分区表。
下面是我在实际项目中反复使用的一份分区表,针对 4MB Flash 优化过:
# 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, 0x11000, 0x180000, ota_1, app, ota_1, 0x191000,0x180000, storage, data, spiffs, 0x311000,0xE0000,这里有几个容易踩坑的地方。第一,偏移地址必须严格按照 Flash 的扇区对齐,ESP32 的 Flash 扇区是 4KB,所以所有偏移都必须是 0x1000 的整数倍。第二,otadata 分区的大小必须是 0x2000,不能多也不能少,这是 bootloader 硬编码的要求。第三,如果你用的是 ESP32-C3 或 S3,Flash 布局可能略有不同,建议先用esptool.py flash_id确认一下实际容量。
写完之后,在 ESP-IDF 里执行idf.py partition-table可以查看编译后的分区表是否正确。在 Arduino IDE 里,你需要把这份 CSV 放到 sketch 目录下,然后在Tools -> Partition Scheme里选择Custom,并在boards.txt或平台配置里指定文件路径。
3.2 固件端确认逻辑的代码实现
分区表配好之后,接下来就是在固件里加入确认逻辑。我用 ESP-IDF 和 Arduino 两种环境分别说明,你可以根据自己的技术栈选择。
在 ESP-IDF 中,典型的 OTA 升级流程是这样的:
#include "esp_ota_ops.h" #include "esp_http_client.h" void ota_task(void *pvParameter) { const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); const esp_partition_t *running_partition = esp_ota_get_running_partition(); ESP_LOGI(TAG, "当前运行分区: %s", running_partition->label); ESP_LOGI(TAG, "目标写入分区: %s", update_partition->label); esp_ota_handle_t update_handle = 0; esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &update_handle); if (err != ESP_OK) { ESP_LOGE(TAG, "OTA 初始化失败: %s", esp_err_to_name(err)); vTaskDelete(NULL); return; } // 这里省略 HTTP 下载和写入循环 // 实际项目中需要分块读取并调用 esp_ota_write() err = esp_ota_end(update_handle); if (err != ESP_OK) { ESP_LOGE(TAG, "OTA 结束失败: %s", esp_err_to_name(err)); vTaskDelete(NULL); return; } err = esp_ota_set_boot_partition(update_partition); if (err != ESP_OK) { ESP_LOGE(TAG, "设置启动分区失败: %s", esp_err_to_name(err)); vTaskDelete(NULL); return; } ESP_LOGI(TAG, "OTA 成功,准备重启"); esp_restart(); }这段代码的关键在于esp_ota_set_boot_partition(),它会把 otadata 里的启动标志指向新分区。重启之后,bootloader 就会从新分区加载固件。
然后在新固件的app_main()里,你需要加入确认逻辑:
void app_main(void) { // 初始化外设、连接网络、启动服务 // ... // 确认固件有效,取消回滚 esp_err_t err = esp_ota_mark_app_valid_cancel_rollback(); if (err == ESP_OK) { ESP_LOGI(TAG, "固件已确认,回滚已取消"); } else { ESP_LOGE(TAG, "确认固件失败: %s", esp_err_to_name(err)); } }注意,这个确认操作最好放在所有关键初始化都完成之后。比如你的设备需要连接 Wi-Fi 才能正常工作,那就等 Wi-Fi 连接成功、MQTT 订阅完成之后再调用。否则固件虽然启动了,但网络功能是坏的,用户照样用不了。
在 Arduino 环境下,代码更简洁:
#include <WiFi.h> #include <HTTPClient.h> #include <Update.h> void setup() { Serial.begin(115200); // 连接 Wi-Fi WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWi-Fi 已连接"); // 确认固件有效 esp_ota_mark_app_valid_cancel_rollback(); Serial.println("固件已确认"); // 其他初始化 }Arduino 的Update库会自动处理分区切换,你只需要在合适的位置调用确认函数即可。但要注意,Arduino 默认的分区方案可能不包含双 OTA 分区,你需要在Tools -> Partition Scheme里选择Minimal SPIFFS (1.9MB APP with OTA)或自定义分区表。
3.3 回滚标志位的存储与读取
otadata 分区的读写是由 bootloader 和esp_ota_ops库自动管理的,你不需要手动去操作 Flash 地址。但理解它的存储结构对排查问题很有帮助。
otadata 里有两个ota_select结构体,每个结构体包含四个字段:ota_seq(序列号)、ota_state(状态)、crc(校验值)和padding。序列号是一个递增的整数,bootloader 会选择序列号更大的那个分区启动。状态字段则记录了当前分区的健康状态,可能的值包括ESP_OTA_IMG_NEW、ESP_OTA_IMG_PENDING_VERIFY、ESP_OTA_IMG_VALID、ESP_OTA_IMG_INVALID等。
当你调用esp_ota_mark_app_valid_cancel_rollback()时,系统会把当前分区的状态改为ESP_OTA_IMG_VALID,同时把另一个分区的状态标记为ESP_OTA_IMG_ABORTED。这样下次 OTA 时,系统就知道哪个分区是安全的、哪个是可以覆盖的。
如果你在调试过程中想手动查看 otadata 的内容,可以用esptool.py read_flash把 otadata 分区读出来,然后用十六进制工具分析。不过大多数情况下,直接用esp_ota_get_state_partition()函数查询状态就够了。
4. 完整 OTA 升级流程与回滚触发实测
4.1 从服务器推送固件到设备重启的完整链路
我把整个 OTA 流程拆成六个阶段,每个阶段都有明确的输入和输出,方便你在自己的项目里对照实现。
第一阶段是固件打包。在 ESP-IDF 里,idf.py build会生成一个.bin文件,这个文件包含了引导头、分区表和应用程序。你需要把这个文件放到 HTTP 服务器或者对象存储上,并记录它的版本号和 MD5 校验值。我通常会在服务器上维护一个 JSON 文件,里面列出最新版本号、下载地址和校验值,设备启动后先请求这个 JSON,对比版本号决定是否升级。
第二阶段是设备端检查更新。设备连接网络后,向服务器发送当前固件版本号,服务器返回最新版本信息。如果版本号不同,设备就进入升级流程。这一步要注意加超时和重试机制,网络不稳定的时候不能一直卡在这里。
第三阶段是下载固件。设备用 HTTP 或 HTTPS 分块下载固件,每下载一块就调用esp_ota_write()写入 Flash。这里的关键是不要一次性把整个固件读到内存里,ESP32 的 RAM 只有几百 KB,大固件根本放不下。我一般用 4KB 的缓冲区,边下边写,内存占用很小。
第四阶段是校验和切换分区。下载完成后,调用esp_ota_end()校验固件完整性,然后调用esp_ota_set_boot_partition()切换启动分区。这两个操作都很快,通常几十毫秒就能完成。
第五阶段是重启并验证。设备重启后从新分区启动,应用程序执行初始化,确认关键功能正常后调用esp_ota_mark_app_valid_cancel_rollback()。如果一切顺利,新固件就正式生效了。
第六阶段是回滚处理。如果新固件启动失败,bootloader 会自动切回旧分区。旧分区的固件启动后,会发现自己被“复活”了,此时应该上报一条回滚事件到服务器,方便你排查问题。
4.2 模拟固件崩溃并观察自动回滚
光看代码不过瘾,我实际做了一次破坏性测试,把整个过程记录下来。
我准备了两块相同的 ESP32 开发板,一块烧录正常固件作为对照,另一块用来测试回滚。测试固件里我故意在setup()里加了一个死循环:
void setup() { Serial.begin(115200); Serial.println("新固件启动"); // 故意制造崩溃:不调用 esp_ota_mark_app_valid_cancel_rollback() while (true) { delay(1000); Serial.println("卡在新固件里..."); } }然后通过 OTA 把这份固件推送到设备。设备下载完成后重启,串口输出显示它确实从 ota_1 分区启动了,但因为没有调用确认函数,otadata 里的状态一直是ESP_OTA_IMG_NEW。
我手动按了一下复位键,设备再次重启。这次 bootloader 把状态改成了ESP_OTA_IMG_PENDING_VERIFY,但仍然从 ota_1 启动。串口里还是那行“卡在新固件里...”。
我又按了一次复位键。第三次重启时,bootloader 判定新固件连续两次未确认,自动把启动分区切回了 ota_0。串口输出变成了旧固件的启动日志,设备恢复了正常功能。
整个过程用了不到十秒,三次重启,自动完成回滚。我完全没有碰任何接线,也没有手动进下载模式。这就是双分区加自动回滚的威力。
4.3 回滚过程中的串口日志分析
为了让你更直观地理解回滚过程,我把关键日志片段整理出来:
# 第一次从新分区启动 I (30) boot: Loaded app from partition at offset 0x191000 I (33) boot: Set actual ota_seq=2 in otadata[0] I (45) app_main: 新固件启动 I (1045) app_main: 卡在新固件里... # 第二次重启,状态变为 PENDING_VERIFY I (30) boot: Loaded app from partition at offset 0x191000 I (33) boot: Set actual ota_seq=2 in otadata[0] I (45) app_main: 新固件启动 I (1045) app_main: 卡在新固件里... # 第三次重启,触发回滚 I (30) boot: Loaded app from partition at offset 0x11000 I (33) boot: Set actual ota_seq=1 in otadata[0] I (45) app_main: 旧固件启动 I (1045) app_main: 系统正常运行从日志里可以清楚地看到,bootloader 在第三次启动时把加载地址从0x191000换成了0x11000,也就是从 ota_1 切回了 ota_0。同时 otadata 里的序列号也从 2 变回了 1。
这里有一个细节值得注意:回滚之后,ota_1 分区里的固件并没有被删除,它只是被标记为“不可启动”。下次 OTA 时,系统会优先选择 ota_1 作为写入目标,因为它的序列号更低。这个设计很巧妙,既保留了回滚能力,又不会浪费 Flash 空间。
5. 常见问题排查与避坑经验实录
5.1 OTA 升级失败的高频原因速查表
在实际项目中,OTA 失败的原因五花八门,我整理了一份速查表,覆盖了八成以上的常见问题:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 下载到一半卡死 | 网络不稳定或服务器限速 | 查看串口日志中的 HTTP 状态码 | 增加超时重试,分块下载 |
| 写入 Flash 报错 | 分区大小不够 | 检查固件体积和分区表 | 扩大 ota 分区或裁剪固件 |
| 重启后仍从旧分区启动 | otadata 未正确写入 | 读取 otadata 分区内容 | 检查esp_ota_set_boot_partition返回值 |
| 新固件启动后立即回滚 | 未调用确认函数 | 查看串口是否有确认日志 | 在初始化完成后调用确认函数 |
| 回滚后旧固件也起不来 | 旧分区被意外覆盖 | 检查分区表和写入地址 | 确保 OTA 只写入备用分区 |
| 固件校验失败 | 下载数据损坏 | 对比服务器和设备的 MD5 | 增加 HTTPS 和校验机制 |
这张表里的每一行都是我实际踩过的坑。比如“回滚后旧固件也起不来”这一条,我曾经因为分区表偏移写错,导致 OTA 写入时覆盖了 ota_0 分区,结果两个分区都废了。后来我养成了一个习惯:每次修改分区表之后,先用esptool.py read_flash把整个 Flash 读出来备份,确认分区布局无误再烧录。
5.2 那些官方文档不会告诉你的实操心得
第一个心得是关于确认函数的调用时机。官方示例通常把esp_ota_mark_app_valid_cancel_rollback()放在app_main()的开头,但这其实有风险。如果你的固件启动后需要连接 Wi-Fi、同步时间、初始化传感器,这些操作可能需要几秒钟甚至更久。如果在这段时间内设备因为电源波动重启了,而确认函数已经调用过了,系统就会认为新固件是好的,不会回滚。但实际上固件可能根本没完成初始化。
我的做法是把确认函数放在所有关键初始化之后,并且加一个“健康检查”逻辑。比如设备需要连接 MQTT 服务器,那就等 MQTT 连接成功、订阅完成、收到第一条消息之后再确认。这样虽然回滚的判定时间变长了,但准确性高得多。
第二个心得是关于电源的。OTA 写入 Flash 的时候电流波动比较大,如果电源质量不好,很容易导致写入失败甚至设备重启。我在一个项目里遇到过 OTA 成功率只有 70% 的情况,后来换了一个输出电流更大的电源适配器,成功率直接拉到 99% 以上。所以如果你在做量产设备,电源设计一定要留足余量,最好在 OTA 期间关闭其他高功耗外设。
第三个心得是关于版本号的。很多团队用简单的递增整数作为版本号,比如 1、2、3。这种做法在单设备上没问题,但如果你有多个设备、多个固件分支,很快就会乱套。我建议用语义化版本号,比如1.2.3,并且在固件里嵌入编译时间戳。这样即使版本号相同,也能通过时间戳判断哪个更新。
5.3 如何验证回滚机制真的生效了
写完代码不代表万事大吉,你必须实际测试回滚机制。我通常用三种方法验证:
第一种是“死循环测试”,就是前面演示的那样,在新固件里故意不调用确认函数,观察设备是否会自动回滚。这种方法最直接,但需要物理接触设备来按复位键。
第二种是“看门狗测试”,在新固件里故意触发看门狗复位,模拟固件崩溃。ESP32 的看门狗默认在 5 秒内没有喂狗就会复位,你可以用esp_task_wdt_init()配置更短的超时时间。这种方法更接近真实场景,因为现场固件崩溃往往就是看门狗触发的。
第三种是“断电测试”,在 OTA 写入过程中直接拔掉电源,然后重新上电,观察设备是否能从旧分区启动。这种测试最残酷,但也最能暴露问题。我建议在量产前至少做十次断电测试,确保任何时间点断电都不会导致设备变砖。
5.4 关于 Flash 寿命的一点提醒
双分区方案会频繁擦写 Flash,而 Flash 的擦写次数是有限的。普通的 NOR Flash 大概能擦写 10 万次左右,听起来很多,但如果你每天 OTA 一次,十年也就三千多次,完全在安全范围内。真正需要注意的是 otadata 分区,它每次启动都会写入状态,频率比 app 分区高得多。
不过乐鑫在设计的时候已经考虑到了这一点。otadata 的写入是均衡的,两个ota_select结构体交替使用,而且只有在状态变化时才会真正写入。正常使用情况下,otadata 的寿命足够支撑设备运行十年以上。如果你实在担心,可以在 NVS 里记录 OTA 次数,超过一定阈值就提醒用户更换设备。
6. 从双分区延伸到更可靠的固件更新策略
双分区加自动回滚解决的是“刷坏了能回来”的问题,但它没有解决“怎么知道刷坏了”的问题。在实际项目中,我还会加一层应用层的健康检查。比如设备启动后主动向服务器发送心跳,如果服务器在预期时间内没有收到心跳,就认为设备异常,下次设备上线时强制推送旧版本固件。
另外,对于部署在偏远地区或者网络不稳定的设备,我会建议加入“差分升级”的支持。差分升级只传输新旧固件之间的差异部分,体积可能只有完整固件的十分之一,大大降低了下载失败的概率。ESP-IDF 从 v4.3 开始支持差分 OTA,配合esp_delta_ota组件使用,效果很好。
还有一个容易被忽视的点是固件加密。如果你的设备涉及商业机密或者用户隐私,固件在 Flash 里应该是加密存储的。ESP32 支持 Flash 加密和安全启动,可以在 eFuse 里烧录密钥,防止固件被读取或篡改。但要注意,一旦启用了 Flash 加密,OTA 升级的固件也必须是加密的,否则 bootloader 会拒绝加载。
我在实际使用中发现,双分区加自动回滚这套机制最大的价值不是技术本身,而是它改变了团队的开发节奏。以前发版要挑时间、要有人值守、要准备回滚方案,现在可以随时发、随时回,心理负担小了很多。当然,这不意味着你可以随便发未经测试的固件,该做的单元测试、集成测试一个都不能少。回滚只是最后一道防线,不是第一道。
最后再分享一个小技巧:在 otadata 里除了系统自动管理的字段,你还可以在 NVS 里存一个自定义的“升级日志”,记录每次 OTA 的时间、版本号、结果。这样设备出问题的时候,你只要读一下 NVS 就能知道它经历过什么,比翻串口日志方便得多。