news 2026/9/25 5:01:08

ESP32双OTA分区实现应用平台:固件安装、启动切换与回退实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ESP32双OTA分区实现应用平台:固件安装、启动切换与回退实战

ESP32 上做“应用平台”,本质上是用双 OTA 分区实现固件级的安装、启动、切换、回退。这篇文章把分区表设计、安装接口、启动引导切换、Web 管理端、实际烧录流程和常见坑全部分享给你,整个过程可以完整复现。

1. 项目设计拆解:ESP32 “安装应用”的三个关键问题

1.1 为什么不能“拷个文件”就算安装?

很多朋友刚开始做这个项目时会有一个直觉:手机的 App 是安装包,解压后放到某个目录就行,那 ESP32 是不是也能把某个“应用文件”放到 Flash 里,下次开机直接用?

这个直觉在 ESP32 上会撞上一堵硬墙:CPU 不能直接从文件系统里执行代码。ESP32 的代码区访问依赖 Flash Cache 和 MMU,也就是说,只有位于特定地址段、被引导程序映射过的固件镜像,才能被 CPU 执行。像 SPIFFS、LittleFS 这些文件系统,更多是用来存配置、网页、字库和采样数据,里面的“文件”没有办法被当作可运行代码加载。

所以“安装应用”在 ESP32 上,实际操作并不是“把文件拷进去”,而是要把一整份新固件写到专门的 app 分区,再想办法告诉 bootloader:下次启动,从这一份固件开始跑。

这个思路一旦想明白,整个项目就好办了。最直接的做法,就是用 ESP32 自带的 OTA 双分区机制,让 app0 永远是“系统/启动器”,app1 是“应用槽位”。平台本身跑在 app0,用户想安装新应用的时候,平台把新固件下载到 app1,然后修改启动目标,重启后进 app1。要“卸载”或者退回平台,也简单,把启动目标指回 app0 再重启。

1.2 双分区到底怎么“装”应用?

我做的这个小型应用平台,为了避免“把平台自己也覆盖掉”这种尴尬局面,用了最稳妥的双 OTA 分区方案:

  • app0:平台固件,包含 WiFi 连接、HTTP 服务、应用市场页面、OTA 安装逻辑,相当于“系统桌面”和“应用商店”的合体。
  • app1:空出来的应用槽位,任何要“安装”的应用都被写入这个分区。

安装应用时,平台固件里跑一段 HTTP 下载逻辑,把应用市场的.bin固件流式写入 app1。写入过程中同时计算 SHA256 校验值,下载完成后比对哈希值。校验没问题,就用esp_ota_set_boot_partition把 boot 分区指向 app0 的 OTA_1,然后esp_restart()重启。bootloader 看到 otadata 里的指向变化,就会启动 app1 里的新应用。

这里值得说一下,为什么我用“双分区 + boot 切换”,而不是直接覆盖 app0。

一个关键原因是安全回退。手机上的安装,App Store 会做签名校验;ESP32 如果不做冗余设计,一旦下载的固件本身损坏,或者中途 WiFi 断了,那当前固件也被破坏,直接变砖。双分区方案里安装失败顶多无法启动新应用,平台还留在 app0,长按按键就能回得来,安全性好很多。

两个分区的分工也很清晰:

  • app0:发生用户操作的地方,安装、卸载、版本查看都在这里做;
  • app1:跑实际应用,比如温度计、点灯器、小游戏,里面有“返回平台”的按钮或命令。
方案是否保留平台安装失败风险实现复杂度适合场景
单分区覆盖不保留高,容易变砖低一次性烧录,不适合做应用平台
双分区 + boot 切换保留低,失败还能回平台中做一个能装卸应用的“系统”,本项目的选择
三/四分区 + 多应用槽保留更低,可多应用共存高大 Flash(8MB/16MB),想同时装多个应用

双分区方案在 ESP32 上还有一个隐性的好处:esp-idf 的 bootloader 原生支持otadata分区,你不需要自己设计启动菜单,官方引导程序已经把我们需要的“选择启动哪个 OTA 分区”这种底层功能实现好了,只用理解为配置操作即可。

1.3 应用包格式与市场端设计

有了“安装”机制,还要有“应用市场”。我这里做的市场端非常简单,因为目标不是造一个真正的商业分发平台,而是把一个“可用的最小闭环”跑通。

应用的“包”由两部分组成:

  • 编译产物:PlatformIO 或 Arduino IDE 生成的firmware.bin(注意不是merged.bin,细节后面讲);
  • 描述信息:一段 JSON,包含应用 id、名称、版本、简介、下载 URL、SHA256 值。

举个最简单的市场清单manifest.json:

[ { "id": "led-blinker", "name": "LED 闪烁器", "version": "1.0.0", "author": "MakerHub", "desc": "点灯应用,支持三种模式切换", "size": "320KB", "url": "http://192.168.1.100/apps/led-blinker.bin", "sha256": "a1b2c3d4e5f6..." }, { "id": "thermometer", "name": "温湿度计", "version": "1.2.0", "author": "MakerHub", "desc": "读取 DHT22 传感器并显示到 OLED", "size": "480KB", "url": "http://192.168.1.100/apps/thermometer.bin", "sha256": "f7e8d9c0b1a2..." } ]

市场端用一个普通 HTTP 静态服务器就够,我测试时直接在电脑上开了一个python3 -m http.server 8080,把 bin 文件和 json 放在同一目录。ESP32 接入同一个局域网,Web 管理页里就能拉取到这个列表。

值得说明的是,这里sha256字段不是可有可无的装饰,而是为了防止下载过程中出现坏块、断点续传出错导致写入损坏。哪怕你只是自己在局域网玩,我也强烈建议加上校验,否则你后面排查“为什么启动后黑屏”时,会浪费大量时间。

2. 分区表、OTA 安装与启动切换的实现

2.1 分区表:给“系统”和“应用”各画一块地

设计 ESP32 应用平台,第一步不是写代码,而是画分区表。这是一个 4MB Flash 的 ESP32 开发板常用的分区方案:

# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1C0000, app1, app, ota_1, 0x1D0000, 0x1C0000, spiffs, data, spiffs, 0x390000, 0x70000,

逐行解释一下:

  • nvs:非易失存储,用来保存 WiFi 配置等键值数据,几乎所有工程都要留。
  • otadata:这是双 OTA 的关键,bootloader 读取这个分区判断启动哪一个 app 分区。
  • app0和app1:两个大小相等的 App 分区,offset 分别是0x10000和0x1D0000,每个分区容量0x1C0000,约 1.75MB,对于绝大多数 Arduino 框架的 ESP32 应用都够用。两者之间要保证互不覆盖。
  • spiffs:文件系统分区,用来存网页资源、字库和配置备份。

如果改成分区表,注意Size和Offset要满足 Flash 擦除块对齐要求,一般偏移按 0x10000 对齐,相对保险。ESP32 上最常见的错误之一,就是自己手动计算地址算偏了,结果应用写到一半把 otadata 覆盖了,启动时 bootloader 一脸茫然,无限重启。所以没把握就直接用上面的表格,别乱改。

在 PlatformIO 里,用board_build.partitions = partitions_4mb.csv指向这个文件。Arduino IDE 的用户则要通过 Tools 菜单里Partition Scheme选择Huge APP或者自定义分区 CSV,效果相同。

2.2 OTA 安装核心代码

下面是整个项目里最核心的一段代码:从 URL 下载固件,写入 app1 分区,并计算 SHA256。基于 Arduino 框架,编译环境是 PlatformIO。

#include <WiFi.h> #include <HTTPClient.h> #include <Update.h> #include <mbedtls/md.h> bool downloadAndFlash(const String& url, const String& expectSha256) { HTTPClient http; http.setConnectTimeout(3000); http.setTimeout(15000); http.begin(url); int code = http.GET(); if (code != 200) { Serial.printf("[OTA] HTTP 下载失败: %d\n", code); http.end(); return false; } int total = http.getSize(); if (total <= 0) { Serial.printf("[OTA] 未知文件大小,中止\n"); http.end(); return false; } Serial.printf("[OTA] 开始写入 app 分区, 大小=%d bytes\n", total); Update.begin(total); WiFiClient* stream = http.getStreamPtr(); uint8_t buf[4096]; size_t written = 0; size_t readed = 0; mbedtls_md_context_t ctx; unsigned char digest[32]; mbedtls_md_init(&ctx); mbedtls_md_setup(&ctx, MBEDTLS_MD_SHA256, 0); mbedtls_md_starts(&ctx); while (stream->connected() && written < total) { readed = stream->readBytes(buf, sizeof(buf)); if (readed == 0) break; mbedtls_md_update(&ctx, buf, readed); size_t n = Update.write(buf, readed); written += n; if ((written * 100 / total) % 25 == 0) { Serial.printf("[OTA] 进度: %d%% (%d/%d)\n", (int)(written * 100 / total), (int)written, total); } yield(); } mbedtls_md_finish(&ctx, digest); mbedtls_md_free(&ctx); bool verified = true; if (expectSha256.length() == 64) { char hex[16]; String actual = ""; for (int i = 0; i < 32; i++) { snprintf(hex, sizeof(hex), "%02x", digest[i]); actual += hex; } Serial.printf("[OTA] SHA256: %s\n", actual.c_str()); verified = actual.equalsIgnoreCase(expectSha256); } if (!Update.end()) { Serial.printf("[OTA] 写入结束失败: %s\n", Update.errorString()); http.end(); return false; } if (!verified) { Serial.println("[OTA] SHA256 校验失败,不切换启动分区"); http.end(); return false; } Serial.println("[OTA] 安装成功,等待重启"); http.end(); return true; }

这段代码里有两个容易忽视的细节:

第一,Update.begin(total)一定要传入从 HTTP 响应里拿到的 Content-Length,而不是0。因为传0时,Update 库无法判断目标分区大小是否足够,会走不同的逻辑,在部分固件版本上会返回NOT_ENOUGH_SPACE错误。

第二,写更新循环里要放yield()或者delay(0),让底层 WiFi 协议栈的 watchdog 任务有机会运行。不写的话,下载到一半 WiFi 断开,readBytes持续拿到 0,整个流程就会卡死。这个坑非常隐蔽,我第一次跑就栽在这里。

2.3 启动切换与“返回平台”

下载完成,SHA256 校验通过,接下来要做的是切换启动目标。看这段:

#include <esp_ota_ops.h> void switchBootTo(bool toApp1) { esp_partition_subtype_t subtype = toApp1 ? ESP_PARTITION_SUBTYPE_APP_OTA_1 : ESP_PARTITION_SUBTYPE_APP_OTA_0; const esp_partition_t* part = esp_partition_find_first(ESP_PARTITION_TYPE_APP, subtype, NULL); if (part == NULL) { Serial.println("[BOOT] 找不到目标分区"); return; } esp_err_t err = esp_ota_set_boot_partition(part); if (err != ESP_OK) { Serial.printf("[BOOT] 设置启动分区失败: %s\n", esp_err_to_name(err)); return; } esp_restart(); }

esp_ota_set_boot_partition不是直接修改 app 分区内容,而是改写otadata分区里的启动标记。bootloader 启动时会去读这个标记,决定跑 app0 还是 app1。这正是双分区方案的核心,用“指针指向”而不是“覆盖内容”来实现应用切换。

我还做了一个“Home 键”逻辑,效果类似手机按 Home 键退回桌面。用户应用里监听 GPIO0 长按事件:

const int HOME_BTN_PIN = 0; void setup() { pinMode(HOME_BTN_PIN, INPUT_PULLUP); Serial.begin(115200); } void loop() { static uint32_t pressStart = 0; if (digitalRead(HOME_BTN_PIN) == LOW) { if (pressStart == 0) pressStart = millis(); if (millis() - pressStart > 3000) { Serial.println("长按触发,返回平台..."); switchBootTo(false); } } else { pressStart = 0; } delay(50); }

这里说的“返回平台”,其实就是把启动分区切回 app0 然后软重启,不用重新刷任何固件,所以速度快、失败率低。实现上比手机上的任务切换还干净。

2.4 Web 管理 API 设计

平台固件里放一个异步 Web 服务器,页面负责展示可用应用、已安装状态和安装按钮。接口设计得尽量简单:

接口方法说明
/GET应用中心管理页面 HTML
/api/appsGET从市场服务器拉取可用应用列表并返回给页面
/api/install?id=xxxPOST触发下载安装指定 id 的应用
/api/statusGET返回当前运行分区、安装进度、版本号

页面端的大体交互流程是:

  1. 打开应用中心页面,页面先请求/api/apps,拿到 JSON 后用 JS 渲染卡片;
  2. 点击某个应用卡片上的“安装”,页面发 POST 到/api/install;
  3. 平台后端调用downloadAndFlash()开始下载,同时用/api/status轮询进度;
  4. 下载完成、校验通过后,页面弹窗“安装成功,正在重启进入应用”;
  5. 几秒后浏览器自动刷新,如果加载出来的是应用自己的页面,说明切换完成。

Web 管理页给平台用,用户应用可以完全不做 Web UI,也可以自己起一个服务端口,看应用具体是什么。比如我的温湿度计应用,安装完重启后就自动连 WiFi,启动一个本地 HTTP 服务显示温度和湿度数据,超级直观。

3. 实操过程:从编译到“安装”一个应用

3.1 工程结构与环境

整个项目建议用 PlatformIO,因为要同时管理平台固件和多个示例应用,PlatformIO 的工程隔离做得比 Arduino IDE 好太多。工程结构如下:

esp-app-store/ ├── platformio.ini ├── partitions_4mb.csv ├── src/ │ ├── main.cpp │ ├── web.cpp │ ├── ota.cpp │ ├── app_api.cpp │ └── config.h └── data/ └── index.html

platformio.ini配置示例:

[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino monitor_speed = 115200 board_build.partitions = partitions_4mb.csv board_build.filesystem = littlefs lib_deps = bblanchon/ArduinoJson@^7.0.4 me-no-dev/ESPAsyncWebServer@^2.0.0 me-no-dev/AsyncTCP@^1.1.0

这里用 ESPAsyncWebServer 是因为它是异步的,在下载 OTA 的同时还能响应/api/status的轮询请求。如果用同步的WebServer库,下载时主线程阻塞,页面进度条就会卡住不动。

3.2 编译“平台固件”与“示例应用”

平台固件编译命令:

pio run -e esp32dev

编译完成后,生成的核心文件在.pio/build/esp32dev/firmware.bin。这个 bin 是整个 app 分区的镜像,大小一般几百 KB 到 1MB 左右。

示例应用(比如 LED 闪烁器)单独建一个工程,同样编译生成firmware.bin。注意这里有一个关键点:不要把 PlatformIO 输出的firmware.merged.bin当作安装包。merged.bin是用来整片烧录的,里面包含了 bootloader、分区表、整个 app 镜像,直接写入单个 app 分区会把分区结构搞乱,导致启动时 bootloader 都找不到正确分区。安装包只认firmware.bin,因为Update.write()写入的就是这一份完整的 app 镜像。

把两个示例应用编译好的firmware.bin放到电脑上的apps/目录:

apps/ ├── manifest.json ├── led-blinker.bin └── thermometer.bin

3.3 在本地搭一个“应用市场”服务器

测试时不需要买服务器,电脑上一条命令就能搞定:

cd apps python3 -m http.server 8080

然后确认 ESP32 和电脑在同一局域网段。假设电脑 IP 是192.168.1.100,那么应用市场地址就是:

http://192.168.1.100:8080/manifest.json

在配置文件config.h里写上这个地址:

#define MARKET_URL "http://192.168.1.100:8080"

烧录平台固件:

pio run -e esp32dev -t upload pio run -e esp32dev -t uploadfs

uploadfs这步是烧录文件系统,把data/index.html页面烧进去。

3.4 现场实测:一个应用从下载到运行的完整流程

我实测时用的是 ESP32 DevKitC V4(4MB Flash),手机开热点给 ESP32 和电脑同时连接。

打开浏览器访问平台 IP,应用中心页面上列出了两个可用应用。点击“LED 闪烁器”的安装按钮,后端日志是这样:

[OTA] 开始写入 app 分区, 大小=348160 bytes [OTA] 进度: 25% (87040/348160) [OTA] 进度: 50% (174080/348160) [OTA] 进度: 75% (261120/348160) [OTA] 进度: 100% (348160/348160) [OTA] SHA256: 3f7d2...c1a9 [OTA] 安装成功,等待重启 [BOOT] 设置启动分区指向 APP1

紧接着 ESP32 软重启,串口输出:

[Bootloader] ... [APP1] LED Blinker 1.0.0 开始运行

GPIO2 上的 LED 按 500ms 周期闪烁起来。这时候如果你访问平台 IP,会提示连接失败,因为平台已经不在线。长按 GPIO0 三秒,打印:

长按触发,返回平台... [BOOT] 设置启动分区指向 APP0

重启后又是应用中心的页面,说明“应用卸载/回退”成功。接着我又装了一次“温湿度计”,流程完全一样,只是这次应用跑起来后连的是 DHT22,并在 OLED 上显示数据。整个“安装—运行—返回—再安装”的循环,实测下来没有一次卡死或者变砖。

4. 避坑指南:常见问题与排查

4.1 常见问题速查表

自己做这个项目会遇到不少坑,我把高频问题整理成了速查表:

现象可能原因排查与解决
安装时 HTTP GET 返回 404manifest 里的 url 写错,或服务器路径不对先在电脑浏览器里打开 url,确认能直接下载
Update.begin报NOT_ENOUGH_SPACEbin 文件比 app 分区还大查看分区表 app0/app1 的 Size,确认编译产物小于分区;必要时换成 8MB Flash 开发板
OTA 写到 100% 后无法启动,循环重启用的是merged.bin而不是firmware.bin,导致分区结构损坏确认下载路径指向firmware.bin
重启后还在跑旧应用esp_ota_set_boot_partition没有生效,或 otadata 被破坏用esptool.py --chip esp32 --port COMxx erase_flash整片擦除后重新烧录
下载过程中 WiFi 断开天线信号弱,或代码里没有做超时重传设置http.setTimeout(15000),加失败后自动重试 2 次;也可以先缓存到 SPIFFS 再写 OTA(牺牲速度换稳定)
下载完成后 SHA256 校验总是不通过HTTP 服务器做了 gzip 压缩或 Range 响应关闭 gzip,确保 Content-Length 是原始 bin 大小
长按 GPIO0 返回平台没反应GPIO0 是 BOOT 引脚,和某些板载按键冲突换用 GPIO2/GPIO4 等常规 IO,或在用户应用代码里加消抖和去重逻辑

4.2 我踩过的两个典型坑

第一个坑是分区表对齐问题。我一开始为了“节省空间”,把 app0 的 Size 设成0x1BFFFF,听着挺精打细算,但分区表擦除单位不匹配,导致 bootloader 读取 partition table 时直接报错,整块板子怎么按复位都起不来。后来被迫按住 BOOT 键重新擦除整个 Flash 才救回来。所以分区表设计有一条铁律:偏移和大小都用 0x10000 的整数倍,不要用看起来很“优化”的怪数值。

第二个坑是 OTA 过程中断网。我把 ESP32 放在客厅,电脑放在卧室,隔了两堵墙。下载 300KB 的固件本来十几秒就完,结果中途 WiFi 断了,下载线程卡住,进度条也不动。后来加了重试逻辑,并且把 HTTP 超时时间从默认的 3 秒调到 15 秒,稳定性明显好很多。如果以后要在生产环境用,建议把固件先完整下载到 SPIFFS 临时文件,校验完再写 OTA 分区,这样即使写 OTA 时断电,旧应用也不会被破坏。

4.3 回滚与安全策略

双分区方案已经天然给了我们一重安全保险:平台固件在 app0,应用在 app1,无论 app1 怎么折腾,平台都能通过 boot 切换回来。

但如果你还想更稳一点,可以在用户应用里加“启动健康检测”。比如新应用启动后 10 秒内如果检测不到自身的心跳状态,就自动调用switchBootTo(false)回退平台。这么做的逻辑和手机里“系统更新失败自动回滚到上一个版本”是一个道理。ESP32 官方也提供了esp_ota_mark_app_valid()这类接口,可以在应用运行正常后声明“当前固件有效”。把这些机制加进去,整个平台才真正具备“量产级”的可靠度。

5. 还能怎么玩:从“安装器”变成“应用商店”

5.1 引入签名,做受信任的安装源

我现在的 SHA256 只能防止传输损坏,不能防止恶意固件。如果有精力,可以给每个 bin 加一个 ed25519 数字签名,ESP32 侧用公钥验签通过后才允许写入和切换。这比用过时的 Md5 要可靠得多。单片机做非对称验签也不是难事,mbedtls库自带 ed25519 支持,算力上完全没问题。

5.2 应用之间共享数据

应用和平台之间不通信是很浪费的。ESP32 的 NVS 分区天然就是一个“跨应用存储区”,我建议把 WiFi 配置、用户偏好、运行日志都放在 NVS 里。在一个应用里写入player_name,切回平台再切换到另一个应用,新应用拿去直接用。这种共享机制会让整个平台的体验更像一个真正的操作系统。

5.3 接上屏幕和语音模块

应用平台做成 Web 页面确实更通用,但如果你手上有 TFT 屏或者 OLED,用 LVGL 写一个桌面风格 UI 会更酷。图标点一下启动应用,长按回退桌面,完全复刻手机交互。语音入口也很有意思,DY-SV17F 这种离线语音模块下面带一个应用列表,说“打开温度计”就切到对应应用,把“App Store”升级成语音助手商店,玩起来挺加分。

5.4 当前方案的边界

双分区方案本质上只能同时装一个“应用”,因为 app1 只有一个槽位。如果你希望像手机一样同时安装 5 个应用,那就要扩大到 4 个或更多 OTA 分区,并在平台里维护一份“已安装列表”。这需要更大的 Flash,推荐直接上 8MB 甚至 16MB 的 ESP32 开发板。要注意的是,ESP32 标准版不支持从外部 SPI Flash 执行代码,扩展 Flash 只能用来存文件系统数据,不能用来扩展可执行分区。如果对“多应用并行”有执念,建议看 ESP32-S3 配合多分区方案。

我自己在把这个项目跑通之后,最大的收获不是这个“应用平台”本身,而是彻底理解了 OTA 的底层逻辑。它不仅仅是“远程升级固件”那么单一的动作,而是一个完整的安全启动链设计:分区表布局、otadata 切换、固件校验、失败回滚。这套思路放到任何需要远程更新设备的量产项目里都适用。

如果你也想复刻这个项目,我的建议是先把最小闭环做扎实:app0 平台、app1 应用、Web 页面、安装—切换—返回。不要一上来就加屏幕、加语音、加多分区,那些都是锦上添花的东西。让“安装”这个动作稳定得像手机一样,项目就已经成了一半。后面想加什么功能,都是在这个稳定基底上叠积木。

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

安路TD与Modelsim联合仿真:IP核编译、库映射与波形调试避坑指南

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

作者头像 李华
网站建设 2026/9/25 5:01:01

diagrams.net在线画图实操技:从架构图到流程图的高效工作流

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

作者头像 李华
网站建设 2026/9/25 5:00:40

PyBLE:基于BLE的ESP32无线调试协议栈

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

作者头像 李华
网站建设 2026/9/25 4:59:27

头歌平台损失函数手写实践:从公式到可调试代码

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

作者头像 李华
网站建设 2026/9/25 4:58:21

九联UNT403HS刷机全攻略:U盘强刷与救砖实战

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

作者头像 李华