news 2026/9/11 10:09:02

MCU上的关键词检测:ML-KWS源码静态评测与工程架构解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MCU上的关键词检测:ML-KWS源码静态评测与工程架构解析

做源码静态评测这件事,我一般不太喜欢围着宣传文案转,更愿意直接把仓库拉下来,一行一行把代码当审计对象来读。ML-KWS-for-MCU就是这么被我反复拆过好几次的项目。它是 Arm 官方早期放出来的关键词检测示例,目标非常干练:在 Cortex-M 级别的 MCU 上,把实时音频流做成唤醒词识别,内存占用压在几十 KB 量级,推理端完全离线。很多人把它当成一个“能编译能跑的 demo”,跑完 LED 亮了、串口打印对了,就觉得已经吃透。但如果你把整个仓库从头到尾做一次工程架构层面的源码静态评测,会发现这个体积很小的工程里,其实藏着一条完整的、可产品化的边缘 AI 数据流:音频采集、DSP 特征提取、神经网络推理、后处理、低层硬件抽象,全都按可替换的模块边界切开。

这篇不会只讲“怎么编译”,而是把代码拆开给你看:每一层负责什么、为什么这么切、哪些优化是真省内存、哪些地方换平台就要动刀。适合嵌入式工程师、做离线语音产品的同学,以及所有想搞明白“MCU 上跑 AI 到底怎么省资源”的人。

1. 为什么选这个项目做静态评测:边缘AI里的“麻雀解剖学”

1.1 项目定位:这不是训练框架,而是MCU推理的完整示例

ML-KWS-for-MCU的定位和 TensorFlow Lite Micro 自带的示例不一样,它不是一个纯粹的“模型演示”,而是一个从数据集准备、模型训练,到定点量化、C 代码部署、板级适配全部覆盖的端到端工程。仓库里的 Python 侧代码负责把语音关键词数据训练成神经网络模型,然后导出成 TensorFlow Lite 格式;C 侧代码负责在单片机上把模型跑起来。这个“双轨制”结构看起来简单,但非常符合真实产品的组织方式:训练和部署本来就是两个团队、两套工具链的事,耦合死了反而没法迭代。

我第一次看这个工程的时候,最大的感受是它把“嵌入式机器学习”做成了类似“流水线工厂”的东西。音频进来不是直接喂给神经网络,而是先经过一串很讲究的 DSP 操作,把波形变成特征图,再进行推理。为什么这么做?因为直接在时域波形上跑神经网络,模型体积会非常臃肿,而且对噪声和说话人变化的鲁棒性很差。MFCC(梅尔频率倒谱系数)这一类特征本身就是音频领域几十年验证过的压缩表示,能大幅降低模型输入维度,这对于 Flash 和 RAM 都以 KB 计算的 MCU 来说,不是“可选项”,而是“必选项”。

从静态评测的角度讲,这个项目的学习价值在于它演示了一条可复用的移植路径。你不需要照搬它的模型,甚至不需要照搬它的算法,你只需要理解它的分层方式,就能把任何 TensorFlow Lite 模型按同样的思路塞进自己的 MCU 工程里。

1.2 静态评测看什么:四个维度的审计框架

源码静态评估和普通的“代码 review”稍微有点区别。普通 review 关心代码有没有 bug、命名规不规范,而静态评测更关心工程体质:这套代码拿到三个月后、换一个编译器、换一颗芯片,还能不能稳得住。我自己的评测框架一般分成四个维度:

  • 架构清晰度:模块边界是否明确,数据流是否单向,新来的人看代码能不能半小时内画出调用图。
  • 可移植性:硬件相关的代码是否被隔离,CMSIS 一类的工具库抽象是否到位,编译宏是否把平台差异管理住了。
  • 资源效率:全局变量、静态缓冲区的使用是否克制,关键路径上有没有不必要的内存拷贝,算法实现是否为 MCU 做了定点化优化。
  • 工程完整度:有没有配套的脚本、测试用例、文档,模型和部署代码是不是对得上号。

这四个维度正好对应了边缘 AI 落地时最容易翻车的四件事:架构乱改不动、换平台重写一遍、内存爆掉、模型与代码版本错位。后面我会用 ML-KWS-for-MCU 的实际代码逐条对照。

2. 工程架构全景:从仓库结构到Cortex-M上的音频数据流

2.1 仓库目录与模块边界:训练层与部署层如何解耦

把仓库拉下来后,顶层目录的安排就很有代表性。训练相关的脚本、模型定义、数据工具放在一个区域,部署相关的 C 代码放在另一个区域,两者靠“导出模型文件”这条唯一的接口衔接。这个设计非常关键:模型和推理引擎是松耦合的,你换一个训练出来的模型,只要张量尺寸和量化参数一致,C 侧代码基本不用动。

部署侧的 C 代码也不是一个大杂烩,而是按职责拆成几个清晰的文件:特征提取(DSP/MFCC)、神经网络推理(NN)、主流程控制(KWS)、以及 TensorFlow Lite Micro 运行时集成。这种拆分方式让我想起很多成熟的商业音频中间件:算法库、应用层、平台适配层各管各的。ML-KWS-for-MCU 的体量不大,但分层思维已经到位了。

在真正动手改代码前,先把目录结构和模块职责在脑子里过一遍,能省掉后面大量“这函数在哪儿定义”的查找时间。它不是一个所有东西堆一起的单文件工程,这是它能被复用到真实产品的根本原因。

2.2 运行时数据流:从音频帧到唤醒结果的九段管线

我习惯把一条数据流管线拆成多段来看,每一段都有明确的输入输出,这样定位问题时思路特别清晰。这个项目里的音频数据流大致是这样走的:

  1. 音频采集:从麦克风或开发板音频接口拿到 PCM 原始数据。
  2. 分帧加窗:把连续音频切成固定长度的帧,通常会加汉明窗来抑制频谱泄漏。
  3. FFT 变换:把时域信号变换到频域,得到幅度谱。
  4. 梅尔滤波器组:按人耳感知特性把频带划分成梅尔刻度上的若干子带。
  5. DCT 变换:对滤波结果做离散余弦变换,得到 MFCC 特征向量。
  6. 特征拼接:把连续几帧的特征拼成一个带时间上下文的特征图。
  7. 模型推理:特征图送进量化后的神经网络,得到分类概率。
  8. 后处理:对连续多次推理结果做平滑,比如滑动平均,避免单个异常帧导致误唤醒。
  9. 结果输出:判定是否超过阈值,触发唤醒回调或点亮 LED。

第 1 到第 5 段属于 DSP 范畴,数据量从几千字节的音频帧压到几十字节的特征向量;第 6 到第 9 段是模型和应用逻辑。这个划分的直接好处是:如果你想换一套特征提取方法,只动前五段;如果你想换一个模型,只动后四段。它们之间的数据结构是固定的,这种“接口固定、内部自由替换”的设计是工程上特别舒服的形态。

2.3 硬件抽象与CMSIS依赖:为什么说“只要换推理后端就能上新品”

静态评测中最能看出工程功底的地方,在于它如何处理硬件依赖。ARM 生态里,CMSIS 是一套标准的硬件抽象层,它屏蔽了不同 Cortex-M 型号之间的差异。ML-KWS-for-MCU 在 DSP 计算和神经网络卷积计算上都大量依赖 CMSIS-DSP 和 CMSIS-NN 提供的优化算子,而不是自己拿 C 语言强行实现一遍。

这样做有两个很现实的原因。第一,ARM 官方提供的 DSP 库针对各代内核做了深入优化,比如使用 SIMD 指令、饱和运算、查表法加速,这比你手写的循环快得多;第二,CMSIS 本身就是跨厂商标准的,你在 STM32 上写的代码,迁移到 NXP、GD32 或者其他 Cortex-M 平台时,底层算子接口基本一致,工作量集中在板级初始化和外设驱动上。

但依赖 CMSIS 也不是没有代价。它的版本差异有时候大得让你怀疑人生,旧版本工程碰到新版本编译器,库函数的头文件路径和指令集兼容性都会出问题。这个后面在“踩坑实录”里我再细说。

2.4 用一张表拆解核心依赖关系与调用链

读源码时我习惯先画一张依赖关系表,理清楚“谁调用了谁”,再深入细节。这里整理一个精简版:

模块文件主要职责依赖外部组件调用方向
kws.c主流程、状态机、后处理dsp、nn 模块顶层调度
dsp.cMFCC 特征提取流水线CMSIS-DSP、音频缓冲区被 kws 调用
nn.c神经网络推理封装CMSIS-NN、TensorFlow Lite Micro被 kws 调用
mfcc.c梅尔滤波器、DCT 矩阵计算数学查表被 dsp 调用
tflu 集成层加载模型、张量内存管理TensorFlow Lite Micro 运行时被 nn 调用
平台板级包时钟、音频接口、串口打印CMSIS-Core、厂商 SDK初始化阶段

这张表看着简单,但它是整个工程的骨架。后续不管你是做二次开发还是移植,只要把这张表里每一个模块的替代方案想清楚,整个工程就不会翻车。我经常跟同事说,看懂这张表,比背住每一行代码有用得多。

3. 源码静态评测:核心文件逐个过,代码质量与优化点全记录

3.1 核心源文件职责划分与调用关系

我读代码的习惯是先看头文件,再看实现,因为头文件里写清楚了每个结构体和接口的边界。ML-KWS-for-MCU 的头文件有一个很好的特点:公共接口非常克制,暴露给外部的函数少,大量辅助函数用 static 限定在文件内部。这是一种很好的信息隐藏设计,外部模块只能通过几个明确的入口调用,内部实现的改动不影响外部。

以特征提取这一侧来说,对外暴露的基本上就是“填一段音频数据,拿一帧特征向量”的接口。至于内部是先用 FFT 还是先加窗、梅尔滤波器有多少组,都是实现细节。这样设计的好处很明显,当你换一个更快的 FFT 库时,只有内部需要改,调用方完全不用动。

神经网络推理一侧也是如此。主流程不需要知道卷积层怎么排布、激活函数是什么,它只需要把特征图的指针和张量的尺寸交给推理接口,拿回一个分类结果数组就行。这种“接口即文档”的风格,在嵌入式代码里非常推荐,因为嵌入式代码往往没有完善的文档体系,唯一可信的注释就是接口本身。

3.2 MFCC模块的实现细节:定点化、查表与内存复用

MFCC 的完整计算过程在 PC 上有大量浮点库可以用,但在 MCU 上,浮点运算既慢又费电,所以这里有一个很关键的动作:把浮点运算转成定点运算,或者用查表法替代实时计算。

我仔细看了 MFCC 相关实现,有几个地方做得特别节省资源。第一,梅尔滤波器组的系数不是实时算的,而是预先算好之后直接查表;第二,DCT 变换矩阵也是固定常数,直接存成数组;第三,内部缓冲区尽量复用同一段内存,上一阶段算完的数据,下一阶段直接原地覆盖。内存复用这条对 MCU 来说尤其重要,因为嵌入式系统的 RAM 是按 KB 计算的,任何一个模块多开一个几百字节的全局数组,整个工程的余量就会变小。

但这种“复用内存”的做法也有风险,它要求开发者对数据生命周期有非常清晰的理解。如果你在一个异步回调里访问了正在被另一个模块写入的缓冲区,数据就会被踩掉。这也侧面说明,ML-KWS-for-MCU 的整体调度是设计成单线程、顺序执行的,它靠这种确定性调度来保证复用内存的安全性。

3.3 神经网络推理模块:DS-CNN算子、量化与CMSIS-NN加速

模型侧,默认方案用的是深度可分离卷积网络(DS-CNN),这种结构把标准卷积拆成逐通道的空间卷积和逐点的 1x1 卷积,参数量和计算量大幅下降,非常适合 MCU。这就是为什么同体量的模型在 PC 上可能跑得动,但到了 MCU 上就必须换这种轻量化结构。

推理模块的另一大重点是量化。模型权重和激活值全部转成 int8 定点数,而不是浮点数。量化后模型体积直接缩小到原来的四分之一左右,计算速度也快很多,因为 int8 乘法在 Cortex-M 上有对应的硬件加速指令和 CMSIS-NN 优化算子。这个项目的默认推理路径就是走 CMSIS-NN 的卷积函数,把 int8 张量送进去,高效跑完整个网络。

静态分析中还应该注意一个点:TensorFlow Lite Micro 的生产环境集成。它不是一个特别轻量的运行时,但能够提供统一的张量操作和内存分配策略,帮你在“每个项目手写推理代码”和“上完整 Linux 框架”中间找到一个折中点。ML-KWS-for-MCU 选它,是从工程可维护性出发的决定:换模型时不用重新造轮子。

3.4 代码可移植性体检:宏开关、编译器兼容与潜在风险点

代码整体的可移植性属于中上水平。它用了一系列编译宏来控制功能开关,比如是否使用 CMSIS-DSP、是否开启调试打印、输入特征长度是多少等。这样在移植到不同平台时,你可以通过调整宏定义来适配硬件约束,而不是大段删改代码。

不过静态评测里也能看到几个潜在风险点。一个是全局缓冲区的大小直接和模型输入维度绑定,如果你换了更大的模型,得同步检查所有相关数组定义,漏一个就容易踩内存越界。另一个是对编译器的优化选项比较敏感,尤其是涉及 DSP 库的链接时,如果优化等级和指令集没配对,可能出现“编译通过但运行结果不对”的诡异问题。这些问题不是代码本身有 bug,而是嵌入式工程里典型的“配置耦合”问题。

4. 实操复现:在Cortex-M7开发板上完整跑通并测出资源账

4.1 环境准备与版本陷阱:CMSIS版本、编译器选项、链接脚本

我实际操作时用的是一块 Cortex-M7 内核的开发板,这不算什么特殊选型,STM32F746 或者同类板卡都可以。先说环境陷阱,这也是最容易劝退新手的地方。仓库默认的工程文件可能对编译器版本、CMSIS 版本有隐性要求。如果你用的是 GCC 工具链,要格外留意 CMSIS-DSP 库的版本,不同版本之间的头文件组织方式和指令集支持差异很大。

我建议在开始动手之前,先把编译环境的版本固定下来,记到工程的 README 里,否则两三个月后回头再看,编译器升级一次,整个工程可能就编不过了。这是我在实际工程里反复踩过的问题,写下来给各位做个参考。

链接脚本也是值得注意的地方。MCU 的 Flash 和 RAM 都很小,链接脚本必须明确所有段的放置位置。ML-KWS-for-MCU 本身对链接脚本没有特殊要求,但你需要确保模型权重、张量 Arena 内存段有足够的空间。如果模型偏大或者张量 Arena 设小了,链接能通过,运行时会直接崩溃或者推理结果全错。

4.2 关键配置项逐个讲解:音频参数、模型尺寸、推理周期

拿到一个边缘 AI 工程,第一件要做的事不是编译,而是把配置文件里每一个关键参数都搞清楚。在这个项目里,核心参数就那么几个,但每个都直接影响系统表现。

  • 采样率:一般是 16k 或 8k,高采样率保留更多高频信息,但数据量和计算量也更大。
  • 帧长与帧移:决定了每次特征提取能覆盖多长的时间窗口。帧太短,频率分辨率不够;帧太长,实时性变差。
  • MFCC 维度:比如用 40 维还是 13 维,这是精度和计算量的直接折中。
  • 模型输入张量尺寸:由“特征维度 × 时间帧数”决定,直接决定了模型第一层的计算量。
  • 推理周期:每隔多少帧做一次推理,决定了系统响应速度和平均功耗。

这些参数之间是互相纠缠的。比如你把 MFCC 维度从 13 提到 40,输入张量变大,模型参数量可能也要跟着调,最后 RAM 占用和推理耗时就都变了。理解了这一层,你才算真正读懂这个工程的资源预算逻辑。

4.3 实测资源与性能数据:Flash、RAM、推理耗时对照表

在实际板子上跑通之后,我把编译产物的资源占用和推理耗时记录了一下,给大家一个量级参考。这个数据不是所有人都会完全一致,因为模型版本、编译器优化等级、CPU 主频都会影响结果,但数量级是可靠的。

指标项参考数据(Cortex-M7,中等优化等级)
Flash 占用(含模型)约 150~200 KB
RAM 占用(含张量 Arena)约 60~100 KB
单帧特征提取耗时几毫秒到十几毫秒
单次模型推理耗时几十毫秒量级
从输入音频到输出结果的端到端延迟通常 < 200ms

这个资源账说明了一个关键结论:MCU 上跑关键词识别是完全可行的,而且不需要旗舰级芯片。一颗带 DSP 指令的 Cortex-M4/M7 或带 Helium 技术的 Cortex-M55/M85 就能很舒服地跑起来。

4.4 移植到自研板卡的具体操作路径

拿到这个工程之后,最常见的诉求是“把音频输入从开发板换成自己的麦克风电路”。移植步骤其实不复杂,关键是把路径走对:

  1. 先把平台相关代码隔离出来,确认音频数据采集接口在哪。
  2. 核对采样率、位深、声道数,确保输入给 DSP 模块的数据格式完全匹配。
  3. 配置 DMA 或中断,按帧长度定时把数据送入推理流水线。
  4. 编译跑通后,先用开发板自带的音频文件或信号源验证,再用真实麦克风测。
  5. 最后针对自己的麦克风灵敏度和系统噪声,调整唤醒阈值和滤波参数。

实际移植中,绝大部分时间不是花在推理代码上,而是花在“把音频数据按时按量送到该去的地方”。这是所有音频 AI 产品共通的痛点,ML-KWS-for-MCU 也不能替你解决,但只要管线清晰,定位问题就快。

5. 审计中的常见问题与踩坑实录

5.1 构建期、运行期、结果验证期的典型问题速查表

静态评测和动态验证往往是配合进行的,很多代码上的疑点都要靠实际运行来验证。我整理了几类高频问题,你可以当排查手册用。

现象可能原因排查方向
编译报错,找不到 CMSIS-DSP 头文件库路径未添加或版本不匹配检查 include path 和 CMSIS 版本
编译通过,烧录后程序死循环/进 HardFault张量 Arena 空间不足或未对齐增大 Arena,检查内存对齐
推理结果几乎恒定,不随输入变化输入张量数据未正确写入打印输入张量前几个值核对
麦克风输入下识别率极低音频增益不足、噪声太大调整音频增益,增加滤波
更换编译器后推理结果异常涉及 DSP 库的指令集配置不一致统一编译器版本和指令集选项
唤醒延迟明显偏长推理周期过长或 FFT 计算量大减小特征维度或降低推理频率

这些问题的共性在于,它们很少是“某一行代码写错”造成的,而是工程参数和环境配置没有对齐导致的。所以我一直建议,做边缘 AI 项目时要维护一个“配置台账”,把你最终确认可用的所有版本号和关键宏定义记下来。

5.2 我踩过的三个坑:从一次推理噪声聊到编译优化

第一个坑是输入数据类型搞错。我记得很清楚,第一次跑通后,唤醒率始终上不去,串口打印的预测结果一直在几个类别间乱跳。后来我把送入 DSP 模块的音量数据打出来看,发现格式是 16 位有符号 PCM,但算法内部某个环节默认当成了 8 位无符号处理,整个波形直接被削得没法看。这种问题不会报错,但会让所有下游结果失效。排查方法是打印原始数据和特征值,做一次“数据可视化式”的验证。

第二个坑是编译器优化级别。调试阶段我习惯用 O0,结果发现推理速度慢到没法实时处理;切到 O2 之后速度上去了,但某一个中间计算结果的精度又不对了。最后定位到是 DSP 库函数的编译选项和主工程不一致,导致部分代码用了浮点库、部分代码用了查表指令,数值表现完全乱套。嵌入式项目里,“统一优化选项”要像“统一换行符”一样对待。

第三个坑是张量 Arena 对齐。MCU 上有些硬件指令要求内存地址按 4 字节或者 8 字节对齐,如果你在链接脚本里把 Arena 放到了没对齐的地址,程序偶尔会跑飞,偶尔不出问题,非常讨厌。这类内存对齐问题的排查思路很简单:查链接脚本里变量段的对齐属性,在初始化时输出 Arena 的实际地址,看低位是否为 0。

这三个坑有个共同特点:都不是算法原理多深奥导致的,而是工程细节没对齐。这也正是做源码静态评测的价值所在,你不能只看算法核心,还得看整个工程环境是否统一。

6. 评测之外的延伸思考:从KWS到更通用的MCU端音频AI

6.1 代码库作为产品模板的价值

很多人会觉得,ML-KWS-for-MCU 只是一段“唤醒词示例”,离真正产品太远。但我的看法不太一样。工程架构这件事,最难的不是从零写一个酷炫的算法,而是把数据流组织得清晰、把硬件依赖隔离得干净、把资源消耗控制得明白。这个仓库虽然小,但它把上面三件事全做到了。

如果你要做一个“低功耗关键词唤醒 + 音频事件检测”的产品,完全可以照着这个架构搭架子。把 MFCC 换成自己的特征方案,把 KWS 模型换成自定义事件分类模型,甚至可以把 DSP 层换成环境噪声检测模块,整体骨架都不用大动。这就是模板的价值:它不是让你照抄,而是让你少踩一遍从零到一的坑。

6.2 给后续尝试者的建议

我自己拆完这个工程后,最大的体会是:在 MCU 上做 AI,真正稀缺的不是模型有多好,而是把算力、内存、功耗和延迟同时塞进一个小盒子的系统设计能力。ML-KWS-for-MCU 的模型精度放到今天不算顶尖,部署方式也不算花哨,但它把“工程落地”这件事演示得很扎实。

如果你准备拿它做二次开发,我建议不要急着换模型、调参数,先原封不动跑通,再逐步替换模块。每替换一个模块,都要做一次完整的资源测试和回归验证。这种“小步快跑”的方式,能让你在每次改动后都清楚知道卡在哪个环节。最后再分享一个小技巧,把串口调试信息做成可开关的宏,工程调试时全开,正式烧录时全关。这个习惯帮我省掉了大量“打印代码影响实时性”的破事,希望对你也管用。

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

QGroundControl与PX4地面站配置完全指南:从安装到首飞

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

作者头像 李华
网站建设 2026/9/11 10:04:07

STM32F103 AB分区OTA实战:从零实现工业级固件升级

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

作者头像 李华
网站建设 2026/9/11 10:03:47

WinApps 安装教程:4 步让 Linux 跑起 Windows 应用

WinApps 安装教程&#xff1a;4 步让 Linux 跑起 Windows 应用 【免费下载链接】winapps Run Windows apps such as Microsoft Office/Adobe in Linux (Ubuntu/Fedora) and GNOME/KDE as if they were a part of the native OS, including Nautilus integration. Hard fork of…

作者头像 李华
网站建设 2026/9/11 10:03:44

迁移不是搬家,而是重画边界,SAP HANA 迁往 HANA Cloud 最容易返工的设计区域

很多 SAP HANA 迁移项目真正进入实施阶段后,团队很快会碰到一个和最初预期完全不同的问题。数据库能够连接,表也能够导出,数据量看起来并没有大到无法处理,可项目依然很难按照传统数据库迁移的方式一路推进。原因并不神秘,真正消耗时间的部分往往不是把若干 TB 的数据从源…

作者头像 李华
网站建设 2026/9/11 10:03:22

有机化学同系物:概念、判断与应用解析

1. 同系物的基础概念解析有机化学中&#xff0c;同系物&#xff08;Homologous series&#xff09;是指具有相同官能团和相似结构特征&#xff0c;但在分子组成上相差一个或多个CH₂基团的一系列化合物。这个概念最早由德国化学家赫尔曼科尔贝在19世纪提出&#xff0c;现已成为…

作者头像 李华
网站建设 2026/9/11 10:02:59

MCU关键词唤醒模型静态审计与工程落地指南

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

作者头像 李华