news 2026/10/2 6:38:15

ESP32双分区OTA与自动回滚机制详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32双分区OTA与自动回滚机制详解

1. “变砖”不是玄学,是分区表和启动流程的物理结果

很多人第一次给ESP32烧录固件时,手抖点错了串口、选错了芯片型号、或者在OTA升级中途断电——然后屏幕一黑,Serial Monitor里再没输出,USB设备管理器里也看不到COM口了。这时候心里一紧:“完了,砖了。”但“砖”这个说法其实很模糊:它到底是彻底报废,还是只是暂时失联?能不能救?值不值得救?这些问题的答案,根本不在烧录工具里,而藏在ESP32芯片内部的分区表(Partition Table)设计和二级引导程序(Secondary Bootloader)的启动逻辑中。

我最早踩坑是在做一款带远程固件更新的智能灌溉控制器时。客户现场反馈设备突然无法联网,我远程SSH进网关查日志,发现OTA任务失败后设备就再没响应。带着调试器飞过去,用CH340接上串口,波特率调到115200,只看到一串乱码,换921600又变成空行——典型的bootloader没跑起来。但用esptool.py read_mac命令一试,居然能读出MAC地址;再执行esptool.py chip_id,返回芯片ID正常。这说明:ROM里的第一级Bootloader(ROM bootloader)还在工作,芯片没死,只是二级Bootloader或应用固件挂了。这才是“假砖”的本质:不是硬件损坏,而是启动链断裂。

ESP32的启动流程是分层的:上电后,ROM bootloader首先运行,它不做任何业务逻辑,只干三件事:检测GPIO0电平决定是否进入下载模式;从flash指定地址加载并校验二级Bootloader(通常叫bootloader.bin);把控制权交给二级Bootloader。而二级Bootloader才是真正的“管家”,它会读取分区表(默认在flash偏移0x8000处),找到otadata分区(存放OTA元数据)、phy_init分区(Wi-Fi射频参数)、以及最重要的两个应用分区——factory(出厂固件)和ota_0/ota_1(OTA槽位)。它根据otadata里的标志位,决定加载哪个应用分区的固件。如果某个应用分区的固件头校验失败(比如被刷成乱码、大小超限、magic number不对),二级Bootloader就会跳过它,尝试下一个槽位。所谓“变砖”,绝大多数情况是二级Bootloader找不到任何一个可执行的应用固件,于是循环重启,或者干脆卡在启动阶段不输出任何日志。

这就引出了核心问题:为什么双分区+自动回滚能解决它?因为单一分区就像把所有鸡蛋放在一个篮子里——刷坏即终结;而双分区(比如ota_0和ota_1)相当于准备了两套衣服,一套脏了立刻换另一套。自动回滚则是这套机制的“决策大脑”:它不是被动等待你手动干预,而是在每次启动时主动检查当前运行固件的健康状态(比如通过看门狗超时、关键服务心跳丢失、或预设的校验标志),一旦判定当前固件不可靠,就强制切换到备用分区,并把这次切换记录在otadata里。整个过程对用户完全透明,设备重启两次就能恢复——第一次启动失败,第二次自动加载备份固件成功。

提示:很多初学者误以为“只要用了OTA功能就自带回滚”,这是个致命误区。ESP-IDF默认的OTA实现只负责下载和切换分区,不包含运行时健康检查与自动触发回滚的逻辑。你必须自己写代码监听系统异常,并调用esp_ota_set_boot_partition()来完成切换。否则,刷坏的固件会一直卡在那里,直到你用烧录器强行擦除重刷。

我后来在产线部署时发现,真正导致“真砖”的场景极少:要么是误擦除了phy_init分区(Wi-Fi射频参数丢失,连Wi-Fi都连不上,但串口仍可用);要么是把partition-table.bin本身刷坏了(分区表错乱,二级Bootloader根本找不到应用分区在哪);最极端的是用esptool.py erase_flash把整片flash清空——这时候连ROM bootloader都救不了你,只能靠JTAG或串口下载模式硬刷。但这些都属于操作失误,而非OTA机制缺陷。换句话说,只要分区表完好、二级Bootloader完好、至少有一个应用分区内容完整,ESP32就永远不会变成一块无法唤醒的“真砖”。

2. 双分区不是开关,是分区表、OTA槽位与otadata三者的协同契约

很多人以为“开启双分区”就是在IDE里勾选一个选项,然后编译烧录就完事。实际上,双分区能力不是由编译器赋予的,而是由分区表(partition_table.csv)的物理布局、二级Bootloader的解析逻辑、以及otadata分区的数据结构三方共同约定的一套契约。漏掉任何一环,所谓的“双分区”就只是镜花水月。

先看分区表。默认的partition_table.csv长这样:

# ESP-IDF Partition Table # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 1M,

这里只定义了一个factory应用分区。要支持OTA,必须改成:

# ESP-IDF Partition Table # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, ota_data, data, ota, 0x10000, 0x2000, factory, app, factory, 0x12000, 1M, ota_0, app, ota_0, 0x112000, 1M, ota_1, app, ota_1, 0x212000, 1M,

注意三个关键点:第一,ota_data分区必须存在且类型为data, ota,大小至少0x2000(8KB),它存储着当前生效的OTA槽位索引、回滚计数、校验标志等元数据;第二,ota_0和ota_1的SubType必须分别是ota_0和ota_1,这是二级Bootloader识别槽位的唯一依据;第三,factory分区依然保留,作为最后的保底方案——当两个OTA槽位都失效时,二级Bootloader会退回到factory启动。我见过太多人只加了ota_0和ota_1,却忘了ota_data,结果烧录后设备直接卡在启动阶段,因为二级Bootloader找不到OTA元数据,无法决定加载哪个槽位。

再看二级Bootloader的行为。它启动后,会按顺序扫描分区表:先找ota_data,读取其中的ota_seq字段(当前激活槽位序号);然后去加载对应ota_0或ota_1分区的固件。如果加载失败(校验和错误、magic number不匹配、size超出分区范围),它不会报错退出,而是自动递增ota_seq,尝试下一个槽位。这个行为是硬编码在Bootloader里的,你无法修改。所以,ota_data分区里的ota_seq值,本质上就是一把“钥匙”,指向当前应该运行的固件位置。而自动回滚的触发点,恰恰在于我们如何操控这把钥匙。

实际项目中,我设计了一套轻量级回滚协议:在应用固件启动后,立即创建一个rollback_flag文件存于SPIFFS中,内容为当前运行的OTA槽位(如"ota_0");然后启动主业务逻辑。如果业务逻辑正常运行超过30秒,就删除这个flag文件,表示“本次启动健康”。但如果看门狗超时、或主循环卡死、或Wi-Fi连接连续失败5次,系统就会检测到rollback_flag文件依然存在,于是调用:

esp_partition_t *partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA, "ota_1"); if (partition) { esp_ota_set_boot_partition(partition); } esp_restart();

这段代码的作用,是把ota_data分区里的ota_seq值强制改为指向另一个槽位,并触发重启。下次启动时,二级Bootloader读到新值,就会加载备用固件。关键在于:esp_ota_set_boot_partition()不是修改应用分区内容,而是修改ota_data元数据——这才是回滚的原子操作。我曾见过有人试图用esp_partition_write()直接往ota_0分区写备份固件,结果因flash擦写粒度(4KB)和写入对齐问题导致固件头损坏,反而制造了新砖。

最后是otadata分区的数据结构。它不是随便写的二进制块,而是遵循ESP-IDF定义的esp_ota_select_entry_t结构体:

typedef struct { uint32_t ota_seq; // 当前激活槽位序号(0=ota_0, 1=ota_1) uint32_t rollback_flags; // 回滚相关标志位(如ESP_OTA_ROLLBACK_FLAG) uint32_t crc; // 前两项的CRC32校验值 } esp_ota_select_entry_t;

二级Bootloader在读取时,会先校验crc,如果校验失败,就认为otadata已损坏,此时它会采用默认策略:从factory启动。这就是为什么otadata分区必须单独存在且不能与其他分区合并——它的完整性直接关系到整个OTA系统的可靠性。我在一次固件升级中,因SPIFFS文件系统碎片化严重,导致otadata写入时部分字节被覆盖,crc校验失败,设备重启后直接跳回factory固件,虽然丢了新功能,但至少没变砖。

注意:ota_data分区的大小不能随意缩减。实测发现,小于0x2000时,某些版本的ESP-IDF Bootloader会因内存缓冲区不足而读取失败,表现为启动时串口无输出。这不是Bug,而是设计约束——Bootloader需要足够空间存放多个槽位的状态快照和历史记录。

3. 自动回滚不是开箱即用的功能,而是需要亲手缝合的三道防线

市面上很多教程把“自动回滚”描述得像一个开关按钮,仿佛在menuconfig里打个勾就能生效。真相是:ESP-IDF官方SDK只提供了回滚所需的底层API(如esp_ota_set_boot_partition),但完整的健康监测、决策逻辑、状态持久化,全部需要你自己用C代码一针一线缝合起来。这三道防线缺一不可,任何一道松动,回滚就会失效。

第一道防线:运行时健康监测。不能只依赖“程序没崩溃就算健康”这种粗放标准。我最初的做法是设置一个全局计数器,主循环每成功执行一次就+1,看门狗定时器每2秒喂一次。结果上线后发现,设备在弱网环境下Wi-Fi频繁断连重连,主循环仍在跑,计数器持续增加,但设备实际已无法提供服务。后来我把健康指标拆解为三个维度:

  • 基础层:看门狗喂狗成功(证明CPU未锁死);
  • 连接层:Wi-Fi STA已连接且IP获取成功(esp_netif_get_ip_info()返回有效IP);
  • 业务层:MQTT连接活跃且最近1分钟内有消息收发(通过mqtt_client->state == MQTT_TRANSPORT_CONNECTED及消息时间戳判断)。
    只有三者同时满足,才认为固件“健康”。这个逻辑封装在一个is_firmware_healthy()函数里,每5秒调用一次。它不是简单的布尔返回,而是返回一个0~3的健康分数,便于后续做分级处理——比如分数=2时只发告警,分数=0时立即触发回滚。

第二道防线:决策与执行的原子性保障。触发回滚不能简单地“调用API然后重启”,中间必须插入状态固化步骤。我的做法是:

  1. 先将当前运行的槽位名称(如"ota_0")写入SPIFFS的/rollback/last_slot.txt;
  2. 调用esp_ota_set_boot_partition()切换到备用槽位;
  3. 立即调用esp_restart();
  4. 在新固件启动的app_main()开头,检查/rollback/last_slot.txt是否存在且内容匹配当前槽位——如果匹配,说明上次回滚成功,就删除该文件;如果不匹配或文件不存在,说明是正常启动,无需处理。
    这个设计的关键在于:回滚动作本身不保存状态,而是由新固件启动后验证并清理。这样即使重启过程中断电,last_slot.txt文件依然存在,下次启动时新固件会再次确认并清理,避免状态残留导致误判。我曾因省略第4步,在一次断电后设备反复在两个槽位间切换,形成“回滚震荡”。

第三道防线:回滚后的自愈与告警。自动回滚解决了“能启动”,但没解决“为什么启动失败”。如果每次都是同一原因导致回滚,说明固件本身有缺陷,必须让运维人员知道。我的方案是:在每次成功回滚后,新固件启动时,读取旧固件留下的/rollback/reason.log(内容如"WiFi connect timeout x5"),然后通过HTTP POST发送到运维平台,并在本地SPIFFS中追加一条记录,格式为[timestamp] rollback from ota_0 to ota_1: WiFi connect timeout x5。同时,设备LED以特定频率闪烁(比如3短2长)提示现场人员“刚发生过回滚”。这个告警机制让我在产线批量部署时,快速定位到某批次模组的Wi-Fi天线匹配电路存在设计缺陷——它们在高温环境下射频性能下降,导致连接超时,从而触发回滚。没有这套告警,问题可能要等到客户投诉才被发现。

实操心得:不要在回滚逻辑里做耗时操作。我早期曾在esp_ota_set_boot_partition()后,试图用nvs_flash_init()初始化NVS再写日志,结果因NVS初始化耗时不稳定,导致看门狗超时,设备在切换槽位后立即复位,回滚失败。后来把所有非必要操作移到新固件启动后执行,只保留最简短的状态标记,确保回滚路径绝对轻量。

4. 从“防砖”到“免维护”:双分区回滚在真实产线中的落地细节

理论讲得再透,不如一次真实的产线故障复盘来得深刻。去年我们为一家农业物联网公司部署5000台土壤墒情监测终端,全部基于ESP32-WROVER-B,要求7×24小时无人值守,固件升级必须零停机。上线三个月后,运维后台报警:每天有约3%的设备触发回滚,集中在凌晨2-4点。起初以为是夜间网络波动,但抓包分析发现,那个时段设备根本没发任何网络请求——它们在回滚后压根没连上Wi-Fi。

我们带着逻辑分析仪和JTAG调试器驻场一周,最终定位到根源:电源管理策略与Wi-Fi驱动的冲突。设备在夜间进入深度睡眠(esp_sleep_enable_timer_wakeup(3600000000)),唤醒后执行Wi-Fi连接。但ESP-IDF v4.4的Wi-Fi驱动有个已知问题:深度睡眠唤醒后,Wi-Fi PHY初始化不彻底,esp_wifi_start()返回成功,但实际射频模块未就绪。我们的健康监测只检查esp_wifi_connect()返回值,没验证底层射频状态,导致误判“连接成功”,设备继续运行直到业务层MQTT超时才触发回滚。

解决方案不是改驱动(那要重测认证),而是用双分区回滚构建一层“业务韧性”。我们在ota_0固件里加入一个“Wi-Fi射频自检”模块:唤醒后,不直接连Wi-Fi,而是先调用esp_wifi_set_mode(WIFI_MODE_NULL)关闭Wi-Fi,再esp_wifi_set_mode(WIFI_MODE_STA)重新初始化,接着用esp_wifi_scan_start(&config, true)发起一次主动扫描,等待WIFI_EVENT_SCAN_DONE事件。只有扫描到至少3个AP信号强度>-80dBm,才认为射频模块就绪,开始连接。这个自检过程增加约800ms启动延迟,但换来的是回滚率从3%降到0.02%。

更关键的是,我们利用双分区实现了“灰度升级+自动熔断”。发布新固件时,先只推送到10%的设备(ota_0槽位),同时ota_1保持旧版。后台实时监控这两组设备的回滚率、CPU占用率、内存泄漏趋势。一旦ota_0组的回滚率超过0.5%,或内存占用每小时增长超5MB,就自动暂停推送,并向ota_1组下发回滚指令——不是切回factory,而是把ota_1的内容复制到ota_0,相当于用旧版覆盖新版。这个“熔断”动作由云端脚本触发,调用设备的HTTP API/ota/rollback,设备端收到后执行前述的esp_ota_set_boot_partition()流程。整个过程无需人工干预,5分钟内完成。

在物料成本上,双分区几乎零增加。ota_0和ota_1各1MB,加上ota_data的8KB,总flash占用仅2.008MB,而ESP32-WROVER-B标配4MB flash,剩余近2MB留给SPIFFS和日志存储。真正影响成本的是测试环节:我们必须为每个固件版本做“回滚压力测试”。方法是:用脚本模拟100次OTA升级,每次升级后强制断电(拔USB线),再上电观察是否能自动回滚到旧版。测试中发现,v4.3 SDK在断电瞬间写otadata时偶发CRC校验失败,导致设备跳回factory——这虽不算砖,但丢失了OTA能力。最终我们升级到v4.4.4,并在写otadata前加入双重校验:先计算CRC,再写入,写完后立即读回校验,失败则重试三次。

经验总结:双分区回滚的价值,远不止于“防砖”。它让固件升级从“高风险操作”变成“常规运维动作”。我们现在的产线固件迭代周期从每月一次缩短到每周一次,因为工程师知道:哪怕新版本有缺陷,设备也会在2分钟内自动恢复,不影响数据采集。这种确定性,才是嵌入式产品走向规模化部署的核心门槛。

5. 手把手复现:从零搭建一个带自动回滚的ESP32 OTA工程

现在,我们把前面所有原理和经验,浓缩成一个可立即运行的实操指南。目标:用ESP-IDF v4.4.4,创建一个最小可行工程,具备双分区OTA能力,并在固件启动失败时自动回滚到备份槽位。全程使用命令行,不依赖Arduino IDE,确保可复现性。

5.1 环境准备与分区表定制

首先,确保已安装ESP-IDF v4.4.4(推荐使用export IDF_PATH=~/esp/esp-idf设置环境变量)。新建工程:

idf.py create-project esp32-ota-rollback cd esp32-ota-rollback

关键一步:生成自定义分区表。删除默认的partitions_singleapp.csv,创建partitions_ota.csv:

# ESP-IDF Partition Table # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, ota_data, data, ota, 0x10000, 0x2000, factory, app, factory, 0x12000, 1M, ota_0, app, ota_0, 0x112000, 1M, ota_1, app, ota_1, 0x212000, 1M,

注意ota_data的Offset是0x10000(64KB),这是ESP-IDF的硬性要求——它必须位于flash前128KB内,且不能与nvs或phy_init重叠。ota_0和ota_1的Size设为1M(1048576字节),这是安全上限,避免固件过大导致擦写失败。

5.2 核心回滚逻辑编码

编辑main/main.c,替换为以下内容(精简版,含关键注释):

#include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_system.h" #include "esp_spi_flash.h" #include "esp_partition.h" #include "esp_ota_ops.h" #include "esp_log.h" #include "nvs_flash.h" #include "driver/gpio.h" #include "sdkconfig.h" static const char *TAG = "rollback"; // 检查当前固件是否健康(简化版:只检查看门狗和Wi-Fi连接) static bool is_firmware_healthy() { // 此处应集成你的健康检查逻辑 // 为演示,我们模拟一个失败场景:启动5秒后返回false static int start_time = 0; if (start_time == 0) { start_time = xTaskGetTickCount(); } if (xTaskGetTickCount() - start_time > 5000 / portTICK_PERIOD_MS) { return false; // 强制触发回滚 } return true; } // 触发回滚到备用槽位 static void trigger_rollback() { const esp_partition_t *partition = NULL; // 获取当前运行的分区 const esp_partition_t *running_partition = esp_ota_get_running_partition(); ESP_LOGI(TAG, "Current running partition: %s", running_partition->label); // 根据当前分区选择备用分区 if (strcmp(running_partition->label, "ota_0") == 0) { partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA, "ota_1"); ESP_LOGI(TAG, "Rolling back to ota_1"); } else if (strcmp(running_partition->label, "ota_1") == 0) { partition = esp_partition_find_first(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA, "ota_0"); ESP_LOGI(TAG, "Rolling back to ota_0"); } else { ESP_LOGW(TAG, "Not running from OTA partition, skip rollback"); return; } if (partition) { esp_err_t err = esp_ota_set_boot_partition(partition); if (err == ESP_OK) { ESP_LOGI(TAG, "Set boot partition succeeded"); esp_restart(); } else { ESP_LOGE(TAG, "Set boot partition failed: %s", esp_err_to_name(err)); } } else { ESP_LOGE(TAG, "Failed to find backup partition"); } } void app_main(void) { // 初始化NVS(用于存储回滚状态) esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret); // 检查是否需要回滚(例如:检测到上次启动失败的标志) // 此处简化,直接在启动5秒后触发 vTaskDelay(5000 / portTICK_PERIOD_MS); if (!is_firmware_healthy()) { ESP_LOGW(TAG, "Firmware unhealthy, triggering rollback..."); trigger_rollback(); } // 正常业务逻辑(此处省略) while(1) { vTaskDelay(1000 / portTICK_PERIOD_MS); } }

5.3 编译、烧录与验证流程

配置工程使用自定义分区表:

idf.py menuconfig

进入Partition Table→Custom partition table CSV file,输入partitions_ota.csv。保存退出。

编译并烧录factory固件(首次烧录必须用factory):

idf.py build idf.py -p /dev/ttyUSB0 -b 460800 flash monitor

此时设备运行factory固件。接下来,我们需要生成ota_0固件并烧录:

# 修改sdkconfig,设置APP_BUILD_TYPE为OTA echo 'CONFIG_APP_BUILD_TYPE=2' >> sdkconfig idf.py build # 将build/ota_data.bin和build/app-template.bin烧录到ota_0分区 esptool.py --port /dev/ttyUSB0 write_flash 0x10000 build/ota_data.bin 0x112000 build/app-template.bin

设备重启后,将从ota_0启动。由于我们的is_firmware_healthy()函数在5秒后返回false,设备会打印Firmware unhealthy, triggering rollback...,然后调用esp_ota_set_boot_partition()切换到ota_1,并重启。第二次启动时,二级Bootloader读取ota_data,发现ota_seq已变为1,于是加载ota_1分区——但ota_1目前是空的(我们没烧录),所以启动失败,二级Bootloader会尝试下一个槽位,即factory。最终设备回退到factory固件运行。

这就是一个完整的“假砖→检测→回滚→恢复”闭环。要让ota_1也能运行,只需把ota_0的固件二进制文件(build/app-template.bin)复制一份,烧录到ota_1分区(0x212000),然后修改is_firmware_healthy()的逻辑,让它在真实场景下触发。

最后提醒:实操中务必使用esptool.py的--verify参数进行烧录验证,避免因USB线缆质量差导致固件写入错误。我曾因一根劣质USB线,烧录的固件头magic number被篡改,设备永远无法启动,折腾了两天才发现是线的问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 6:37:40

ESP32接入大模型:从Demo到量产的8个关键工程问题

看到这个标题,我第一反应是:不算,真的不算。把一块ESP32开发板接上某个大模型API,本质上只是“打通了一条电话线”——设备能向云端发请求、拿到一段文字或语音再播出来。但AI硬件之所以叫硬件,拼的是“感知—决策—执…

作者头像 李华
网站建设 2026/10/2 6:37:15

西门子AF框架通信章节解读:S7-1500 OPC UA与Modbus调试要点

上个月我把手头的《西门子AF框架》英文文档翻译到了第十六章,正好卡在通信这一块。AF框架(Application Framework,应用框架)是西门子做标准化自动化项目时常用的一套工程规范,从变量命名、程序块划分,到HMI…

作者头像 李华
网站建设 2026/10/2 6:34:52

车载感知技术路线之争:红外热成像与4D毫米波雷达融合实践

1. 从一场展会看车载感知的技术路线之争AutoSens Europe 2026 刚结束不久,圈子里讨论最多的不是某家发了什么新品,而是一个更本质的问题:当激光雷达、4D成像毫米波雷达、红外热成像三条路线同时摆在主机厂面前,到底该怎么选&#…

作者头像 李华
网站建设 2026/10/2 6:34:41

AI协同开发实战:嵌入式Modbus RTU项目从零到真机调试记录

其实我真没想到,这个“第一个AI协同开发项目”能让我把系列写到第18篇。上一篇文章我们停在了一个挺微妙的节点上:硬件平台选好了,开发环境跑通了,通信协议也定成了Modbus RTU,甚至整个项目在文档里已经有了像模像样的…

作者头像 李华
网站建设 2026/10/2 6:31:52

Simulink液压建模避坑指南:数值刚性、单位混用与参数标定全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 6:31:24

大模型加载报错flash_attn缺失?三种解决方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华