尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ESP32刷机变砖?双分区OTA与自动回滚机制全解析

ESP32刷机变砖?双分区OTA与自动回滚机制全解析 刷固件这件事很多人一上来就问“ESP32会不会变砖”。我直接说结论绝大多数情况下你不会得到一块真正意义上的砖。ESP32芯片里固化了一级ROM引导程序只要芯片本身没烧坏、eFuse没被锁Flash里哪怕全被擦成空片也能通过下载模式重灌固件。真正需要担心的是固件在升级过程中写到一半断电、新固件本身有严重Bug、或者启动链被搞乱导致设备反复重启看起来像“变砖”却没法远程救。这篇文章就用双分区加自动回滚这套机制回答你“刷坏了到底会不会变砖”并且给你一套你自己也能复现的工程方案。这套方案适合三类人正在做ESP32量产固件、担心远程升级把设备升挂的开发者刚入门想搞懂OTA原理、不想整天拿下载线救板的玩家以及准备搭建私有固件升级体系、想给设备上“保险”的嵌入式工程师。1. ESP32“变砖”到底是怎么一回事1.1 芯片自带“急救员”一级ROM引导聊回滚之前得先把“变砖”这两个字拆开。ESP32的上电启动流程和电脑很像芯片内部有一块出厂固化的ROM引导程序这是第一级引导复位之后它先检查Flash把第二级引导程序也就是Bootloader加载到RAM里再由Bootloader去加载用户App固件。关键点在于ROM引导程序是烧不掉的它存在于芯片内部不在Flash上。所以Flash里的Bootloader、分区表、App就算全被刷成乱码芯片也不会“死透”它每次复位都会尝试从ROM启动顶多不断打印启动失败日志。只要你能让芯片进入下载模式esptool这类工具依然能识别芯片然后把Flash整个擦掉重新写一遍。这就解释了为什么ESP32很少出现“物理不可逆变砖”。如果你把Flash里的内容全清空上电后看到的只是不断重启的串口日志芯片本身还活着。真正得了绝症的是芯片供电烧了、引脚击穿了、或者有人把eFuse里禁用下载模式的位给写进去了那些情况才属于硬件层面的“救不活”。1.2 真砖、软砖还是假砖实践里我把“变砖”分成三类。第一类是真砖芯片硬件损坏或者eFuse安全策略锁死哪条路都进不去只能换芯片。第二类是软砖Flash里的App崩溃、Bootloader和App不匹配、分区表偏移搞错导致系统无法启动但通过串口下载模式擦除重灌就能恢复。第三类是假砖板子本身没坏只是供电不足、串口芯片驱动异常、GPIO0被外部拉高而进不了下载模式换个USB口、按住BOOT键重新上电就活过来了。日常大家刷固件怕的几乎全是软砖和假砖。而双分区加自动回滚这套机制防的正是“软砖”里最尴尬的一种情况设备在野外、在用户手里、在没法拆壳的开发板上新固件刷进去跑不起来设备反复重启又没有人拿着下载线守在旁边。它解决的不是“能不能修”而是“能不能自己修”让设备在新固件出现异常时自动退回上一个正常版本。2. 双分区为什么能避免“刷坏”2.1 单分区OTA一场只能成功的赌博很多ESP32初学者的第一个工程用的是单分区方案Flash里只有一个App运行区OTA升级时直接把新固件覆盖到正在运行的分区上。这个方案简单、空间利用率高但风险也明显。一旦升级过程中网络中断、断电、固件下载不完整正在运行的分区可能已经被部分覆盖下次启动时Bootloader在Flash里翻到一半发现校验不过整个设备就“软砖”了。更麻烦的是原来的可用版本已经被覆盖想回退只能靠下载线重新烧录。换句话说单分区OTA是把“当前能用的系统”和“新下载的系统”放在同一个地方升级失败时旧版本也没保住。有人可能会说那我先下载到内存再一次性写入Flash不行ESP32的应用分区通常有几百KB到几MBRAM根本装不下完整固件。也有人说那我写之前先备份旧固件也不现实Flash里没有第二个同尺寸空间给你备份。所以问题的根源是系统里缺少一个“始终可用的兜底版本”。2.2 A/B双分区把“当前版本”留在原地双分区方案其实就是A/B分区手机系统里早就这么干了。Flash里规划两个App分区比如叫ota_0和ota_1当前运行的是其中一个升级时把新固件写入另一个空闲分区。写入过程完全不碰正在运行的分区所以哪怕写了一半断电、固件损坏、版本不兼容老固件还在原地Bootloader依然能正常启动它。这个方案看起来只是多留了一块空间但它把“写入风险”和“运行风险”隔离开来。写入风险发生在空闲分区上不会影响当前系统运行风险则交给了Bootloader和otadata分区去判断。双分区不保证新固件一定没问题但保证“新固件出问题时旧的还能拉出来继续跑”。代价也很直接需要多占用一个App分区的Flash空间。正常OTA固件如果1.5MB双分区就要3MB如果还挂了一堆组件导致固件膨胀到2MB以上4MB Flash的ESP32会非常紧张。这也是为什么量产项目选型时4MB Flash只适合轻量固件8MB甚至16MB Flash逐渐成了主流。3. 自动回滚的核心机制新固件的“试用期”3.1 otadata与三个状态双分区解决了“旧版本是否还在”但还缺一个关键问题Bootloader怎么知道该启动哪个分区这就需要otadata分区。每次Bootloader启动时都会读otadata里的记录里面保存了当前选择的分区信息、状态标志和CRC校验值。当一个新固件被写入空闲分区并切换为启动目标时otadata里这个新分区并不会直接标记为“可用”而是进入一个“待验证”状态。翻译成人话就是新固件处于试用期Bootloader允许它跑一次或一段时间但心里一直记着“这个版本还没被确认”。三种关键状态大概是这样otadata状态含义触发条件待验证新固件刚被切换到启动区还没确认自身可用OTA完成后重启已验证新固件运行正常主动告知Bootloader“可以留下我”App调用确认API回滚当前分区被判定不可信Bootloader切换到另一个分区待验证状态下发生复位/死机在这套机制下升级后的第一次启动并没有“尘埃落定”新固件必须在一个窗口期内证明自己。而这个证明动作由固件自己的业务逻辑决定。3.2 确认时机与回滚触发条件为什么不能一启动就自动确认新固件可用因为“能启动”和“真能用”是两码事。有些固件启动后看着一切正常但一加载NVS配置就崩溃有些固件连上Wi-Fi才触发崩溃还有的固件需要外接传感器初始化时序不对就直接死循环。如果Bootloader在进入App的瞬间就把新版本标记为永久有效那回滚机制就白做了。正确的做法是在新固件里把所有关键初始化做完之后再调用确认函数。比如Wi-Fi连接成功、配置文件加载成功、外部设备握手成功、再或者业务数据同步完成之后才告诉Bootloader“这个版本已经是可信版本”。ESP-IDF里对应的API是esp_ota_mark_app_valid_cancel_rollback()。反过来如果新固件进入待验证状态后发生复位、崩溃、掉电重启Bootloader在下一次上电时会发现这个分区没有被确认它会自动放弃当前分区把启动目标切回另一个分区。用户看到的可能就是“设备重启了两下又恢复到旧固件了”整个过程不需要下载线也不需要人工干预。这就是自动回滚的实用价值它给固件升级加了一个“反悔期”。4. 实操搭建双分区自动回滚工程4.1 分区表4MB Flash怎么分先说分区表。ESP32的Flash布局由CSV文件定义里面有Bootloader区、分区表、NVS、otadata、App分区和数据分区。用ESP-IDF时你可以自己写一个partitions.csv替换默认分区表。以4MB Flash的ESP32模组为例我建议这样分区# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, ota_0, app, ota_0, 0x10000, 0x1C0000, ota_1, app, ota_1, 0x1D0000, 0x1C0000, spiffs, data, spiffs, 0x390000, 0x70000,这份分区表里每个App分区是0x1C0000字节约1.75MB。0x10000到0x390000为两个App分区预留最后一个spiffs分区约448KB给图片、网页、配置文件这类资源使用。如果你不需要SPIFFS也可以把最后这段留成未分配区域或者改成coredump分区用来存崩溃转储信息。注意几个细节。nvs分区偏移必须在0x9000otadata偏移固定在0xe000这是因为Bootloader对这两个位置有默认约定乱改动容易出兼容问题。App分区的大小必须一致如果ota_0和ota_1大小不同OTA工具会报错而且回滚逻辑也会变得很别扭。如果你的固件比较大建议直接上8MB Flash模组把每个App分区放大到2MB甚至3MB。4.2 开启Bootloader回滚支持分区表只是基础要真正启用回滚机制还得让Bootloader支持这个功能。在ESP-IDF工程里执行idf.py menuconfig搜索“rollback”或者“APP_ROLLBACK”找到Bootloader的App rollback support选项并打开。这个配置会改变Bootloader的行为让它在otadata处于待验证状态时有资格做回滚判断。只改了menuconfig还不够App代码里也要配合。要理解ESP-IDF的逻辑新固件被写入并切换启动分区后Bootloader会把它标记为待验证如果新固件一直不调用确认函数那么在下一次复位时Bootloader就会回滚到旧分区。所以菜单里打开回滚支持相当于给了Bootloader一把武器而App调用确认函数才是把武器收回鞘里的动作。如果你是Arduino用户过程会不太一样。Arduino IDE里可以在Tools Partition Scheme选择类似“Dual App 4MB with SPIFFS”的预设分区表也能用ArduinoOTA写入另一个App分区。但Arduino默认的OTA流程没有显式调用确认函数你需要在setup()里尽早调用ESP系列的OTA确认API否则会出现“固件升级成功但重启后又被回滚成旧版”的怪现象实际上这正是回滚机制生效的表现。4.3 OTA升级端与确认端代码先把重点放在老固件升级新固件这一侧。我习惯用HTTP本地服务器做演示设备从局域网拉取新固件然后写入空闲分区。核心逻辑用ESP-IDF的esp_ota_ops接口写大致是这样的#include esp_ota_ops.h #include esp_http_client.h static void ota_task(void *arg) { esp_http_client_config_t cfg { .url http://192.168.1.50:8000/firmware.bin, .timeout_ms 10000, .buffer_size 1024, }; esp_http_client_handle_t client esp_http_client_init(cfg); esp_http_client_open(client, 0); esp_http_client_fetch_headers(client); const esp_partition_t *next esp_ota_get_next_update_partition(NULL); esp_ota_handle_t ota_handle 0; esp_ota_begin(next, OTA_SIZE_UNKNOWN, ota_handle); char buf[1024]; int len 0; int total 0; while ((len esp_http_client_read(client, buf, sizeof(buf))) 0) { esp_ota_write(ota_handle, buf, len); total len; } esp_http_client_close(client); esp_http_client_cleanup(client); esp_ota_end(ota_handle); esp_ota_set_boot_partition(next); esp_restart(); }这段代码的每一行都有具体用途。esp_ota_get_next_update_partition是获取“当前没有运行的那个App分区”这是双分区方案的正确入口。esp_ota_begin开始一次OTA写入esp_ota_write把HTTP读取到的固件数据逐块写入空闲分区esp_ota_end校验镜像完整性最后esp_ota_set_boot_partition把下次启动目标切到新分区再重启进入新固件。然后是关键的新固件确认端。新固件不能在main函数第一行就立刻确认而是应该在业务初始化完成后调用确认函数#include esp_ota_ops.h void app_main(void) { // 初始化外设、Wi-Fi、配置加载、业务自检 bool ok self_check(); if (ok) { esp_ota_mark_app_valid_cancel_rollback(); } }这里的关键是self_check里做什么。简单场景下可以检查NVS是否可读、Wi-Fi是否连上、关键外设是否握手成功。复杂场景下甚至可以做成“与云平台完成三次心跳之后再确认”这种业务级判断。确认函数越晚调用回滚保护窗口越久但也不能无限晚否则用户都已经开始使用了系统却还处于“随时可能回滚”的未确认状态。5. 实测记录故意刷坏看自动回滚自己救场5.1 测试环境与准备我用一块ESP32-DevKitC模组是ESP32-WROOM-32Flash大小4MB开发环境是VS Code加ESP-IDF v5.x。串口通过USB转TTL连接波特率115200。先在电脑上启动一个简单的HTTP文件服务用Python的http.server一行命令就能办到把编译好的坏固件放在那个目录里ESP32就能从局域网直接拉取。测试分两个版本。v1.0.0是稳定固件打印版本号、连接Wi-Fi、初始化完成后调用确认函数v2.0.0是故意做坏的固件打印版本号后不调用确认函数延时两秒直接esp_restart()模拟“新固件启动后立刻崩溃重启”的典型故障场景。5.2 三个坏固件场景的实测流程场景一是“新固件反复重启但不确认”。先用idf.py flash烧录v1.0.0上电后稳定运行。然后通过设备里的OTA代码从HTTP服务器拉取v2.0.0设备自动重启串口打印里出现了新固件版本号两秒后设备再次重启再打印的却是v1.0.0。这说明Bootloader已经自动回滚到旧分区整个过程我没有碰任何操作按钮。场景二是“新固件进入死循环但不重启”。把v2.0.0改成一个死循环比如while(1)空转。当新固件卡死时任务看门狗可能触发panic也可能不触发。但只要新固件没有调用确认函数复位后Bootloader依然会选择回滚。如果死循环导致看门狗复位效果和场景一完全一样如果看门狗没触发设备会一直卡在新固件里这种情况已经不是回滚能解决的需要单独设计应用层看门狗来兜底。场景三是“升级后立刻掉电重启”。OTA完成后没有立即重启而是先断电再上电。因为新固件处于待验证状态上电后Bootloader会判断当前分区不可信自动回到旧分区。做过量产远程升级的朋友应该能看出这个场景的价值很多设备升级完成后用户会直接拔电源如果没有回滚机制新固件一旦有问题设备就真的需要返厂了。5.3 串口日志怎么判断回滚生效回滚是否生效最直接的证据就是串口日志。启动阶段Bootloader会打印它从哪个偏移加载App进入App后固件会打印自己的版本号。如果你看到启动偏移从0x1D0000回到0x10000同时打印的版本号从v2.0.0回到v1.0.0那就是回滚成功。不同版本的ESP-IDF、不同日志等级下Bootloader打印内容会有差异但判断逻辑万变不离其宗看Bootloader选择的App偏移看App自报的版本号。如果你的新固件里还加了启动次数、错误原因的NVS记录排查起来会更舒服比如可以在NVS里记录“本版本启动失败回滚前状态是xxx”方便事后分析。6. 常见问题排查与避坑经验6.1 常用问题速查表现象可能原因解决办法OTA后设备反复重启最终回到旧固件新固件未调用确认函数或确认函数执行失败检查新固件初始化流程确认业务跑通后再调用确认OTA成功但新旧固件都在始终无法保留新版本确认函数在启动早期被旧逻辑覆盖或者没有执行查看串口日志确认是否到达确认函数位置双分区下App烧录总提示空间不足分区表没有生效App写在旧分区偏移重新配置分区表用idf.py flash烧录时确认使用正确CSV下载时串口毫无反应无法进入下载模式电源不足、串口驱动异常、GPIO0被外部拉高重新上电、按住BOOT键再试检查USB线和供电回滚后旧固件也起不来分区表偏移重叠、Bootloader损坏、Flash物理故障用esptool erase_flash全片擦除后重灌使用flash加密和安全启动后旧固件拒绝启动防回滚安全策略阻止旧版本固件回退这种场景不要把普通回滚和anti-rollback混用需额外设计版本升级链6.2 关于量产的一点额外提醒如果你只是自己玩双分区加自动回滚做到“能跑”就够了。但如果是量产考虑有几件事我建议提前想清楚。第一确认函数不是越晚越好。确认太早会削弱回滚保护确认太晚会导致正常设备一直处于“试运行”状态万一哪次意外断电明明没问题的固件也被回滚用户会以为升级失败。最好是定义明确的“业务成功标志”比如“连上服务器并完成一次有效通信”再确认。第二注意升级包大小和App分区尺寸的匹配。ESP-IDF的镜像是有头部的各个段还要做对齐如果升级包比分区略小通常没问题如果比分区大OTA写入会失败但当前分区不受影响。所以在编译之后先看一眼固件体积别等到推送时才发现塞不下。第三灰度升级永远值得做。哪怕有了双分区和自动回滚也不代表所有问题都能自动恢复。回滚能救“新固件起不来”这种故障但救不了“新固件能起来业务数据算错”这类隐性故障。先放给一小批设备观察确认率和业务日志再逐步扩大范围。最后说一个我踩过的坑在用Arduino IDE做双分区OTA验证时我一开始没有在setup()里调用确认API结果每次ArduinoOTA烧写完新固件设备重启两秒钟后又弹回旧固件我还以为是分区表写错了。后来翻官方文档才反应过来ArduinoOTA本身并没有帮新固件做“确认”回滚机制一直在后台等着我下结论。搞清楚这个逻辑之后我对“自动回滚”的理解就变成了三个字试用期。新固件每次升级都先试用试用通过才转正没通过就退回原版本。这套机制放到产品上就是给远程升级兜底的那道保险。只要你把确认条件设计得足够贴合实际业务ESP32固件升级就真的可以做到“放手不管”了。
返回列表