news 2026/10/24 3:17:50

Linux CMA连续内存分配器:原理、配置与驱动实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux CMA连续内存分配器:原理、配置与驱动实践

做多媒体平台或者外设驱动的人,迟早会撞上“大块连续物理内存”这道坎。我第一次被它卡住是在一个视频采集模块上:DMA 引擎要求源地址和目标地址都是物理连续的一段内存,而kmalloc那个量级根本不够看,vmalloc又只保证虚拟连续、落到物理上完全是散的,用alloc_pages倒是能碰运气分到几兆,可系统跑一跑就碎成渣了。后来我把目光放到 CMA(Contiguous Memory Allocator,连续内存分配器)上——它是从内核 3.19 开始进主线、在 4.x/5.x/6.x 上已经相当成熟的机制,核心思想是“平时把内存借给伙伴系统用,关键时刻再把页面赶走凑出连续大块”。这篇文章我按自己从踩坑到落地的一条完整路径来写:先说清楚它解决了什么问题,再讲怎么配置、接口怎么选,最后给一个能在真实内核里跑起来的模块示例和调试套路。内容对准 5.4 以上的内核,适合正在写 DMA、ISP、GPU、编解码器驱动的同学,也适合只是想把 CMA 原理彻底搞明白的读者。

1. CMA到底解决了什么问题,又凭什么“共享”

1.1 驱动为什么会需要大块连续物理内存

很多外设的数据搬运并不经过 IOMMU,或者 DMA 控制器本身不支持 scatter-gather,这种情况下驱动只能给出一段“物理地址连续”的缓冲区。典型例子是嵌入式平台上的视频编解码器:一帧 1080p 数据可能就要 8~16 MiB,ISP 的统计缓冲、GPU 的 framebuffer、网卡描述符环、音频 DSP 的共享内存,都会提出类似需求。而且这类需求往往不是几页,而是几十甚至上百个连续物理页。

如果你试图用常规内核接口去满足它,会发现处处受制:kmalloc基于 slab,单个分配大小被限制在很小量级,远不够用;vmalloc能分到很大的虚拟连续区间,但物理页是分散的,DMA 根本没法用;alloc_pages可以指定 order 申请连续页,但系统内存碎片化之后,order-10 以上的大块几乎是抽奖。更麻烦的是,嵌入式设备没有 swap,内存长时间跑业务后,大块连续空闲页会越来越难找。速度和连续性这两个要求叠在一起,就成了驱动开发里最经典的痛点之一。

1.2 CMA的共享式设计:连续内存也有“借”的时候

CMA 的聪明之处在于它不打算“独占”内存。它会在系统内存中划出一块预留区域,但不是像memblock_reserve那样直接把这块区域占死,而是把这区域里的页面全部标记为MIGRATE_CMA类型,放回伙伴系统。伙伴系统分配页面时,凡是标注为可迁移(movable)的分配请求,都可以先使用这些页面。也就是说,操作系统平常可以拿这块“备用连续内存”去放页缓存、匿名页等临时数据。

真正需要连续大块时,CMA 再从这堆页面里挑出目标区间,把占用着这些页面的常规数据“请走”——能迁移的迁移,能回收的回收,全部腾干净之后,把这一整段连续页面交付给调用者。整个过程很像小区物业的灵活停车位:平时业主们随便停,突然要组织车队出门,物业再逐个电话挪车,把需要的车位连成一排。代价是“挪车”这件事本身有开销,也有失败的可能,所以 CMA 并不适合高频小内存分配,而非常适合“一次性申请、长期持有”的大块连续缓冲区。

1.3 和传统方案摆在一起对比

把几种常见思路放在一张表里,区别就非常直观了:

方案物理连续性内存利用率适用场景主要缺陷
静态预留区(memblock_reserve)高低,区域长期闲置对连续性要求极端苛刻且量不大浪费内存,系统可用内存变小
GFP_DMA / GFP_DMA32取决于当前分配,不保证大块中仅限低端内存的旧设备低端内存本来就紧张,仍会碎片化
vmalloc仅虚拟连续高软件处理、不需要 DMA物理散页,外设不可用
CMA高,按需腾挪较高,平时可被复用大块 DMA 缓冲、多媒体硬件分配时有迁移开销,可能迁移失败

从这个表可以看出一条关键结论:如果你只要虚拟连续、不需要外设访问,老老实实用vmalloc就好;一旦涉及到 DMA、要求物理页连续,CMA 才是既不浪费内存、又能稳定交付大块连续内存的正解。

2. 让CMA先跑起来:内核配置和启动参数

2.1 内核选项怎么开

CMA 不是默认全开的,你得先确认自己的内核打开了相关开关。我平时排查的第一步就是直接看.config里的几个选项:

CONFIG_CMA=y CONFIG_DMA_CMA=y CONFIG_CMA_AREAS=8 CONFIG_CMA_SIZE_MBYTES=256

CONFIG_CMA是总开关,不开它后面全是空谈。CONFIG_DMA_CMA决定是否把 CMA 接入 DMA 框架,绝大多数设备驱动场景都要开。CONFIG_CMA_AREAS是系统允许注册的 CMA 区域数量上限,默认值我记得不大,如果你既要全局 CMA、又要给多个外设各自预留区域,这个值必须调大,否则启动时会出现 “CMA areas list is full” 之类的报错,后面的区域直接注册失败。CONFIG_CMA_SIZE_MBYTES是全局 CMA 的默认大小,注意它只在没有显式传启动参数时生效,实际部署我更推荐用启动参数来指定。

提示:不同内核版本的 menuconfig 菜单路径不太一样,不必死记路径。直接在.config里搜CMA,把上述选项开好,比在菜单里翻来翻去省事得多。

2.2 启动参数和设备树方式

CMA 区域有两个来源:一是内核启动参数,二是设备树的 reserved-memory 节点。

启动参数写法很简单,在 bootloader 的 bootargs 里追加即可:

cma=256M

如果希望限制物理地址范围,还可以带基址和区间:

cma=128M@0x40000000-0x50000000

这种写法适合某些 DMA 控制器只能访问特定地址范围、或者想把 CMA 区域固定放置在低端内存里的平台。不写地址范围时,内核会从 memblock 里挑合适的区域,地址相对随机。

设备树方式是嵌入式平台上更正式的做法,直接在 reserved-memory 里声明节点:

reserved-memory { #address-cells = <1>; #size-cells = <1>; ranges; vcodec_cma: vcodec_cma@a0000000 { compatible = "shared-dma-pool"; reusable; reg = <0xa0000000 0x10000000>; /* 256 MiB */ linux,cma-default; }; };

这里reusable是关键属性,它告诉内核:这块预留内存平时可以被系统复用,这才是 CMA 的精髓。加了linux,cma-default后,这块区域会成为系统默认 CMA,很多基于设备 DMA 框架的驱动会自动使用它。需要注意,千万不要给这类节点加no-map,那会把区域从内核页表里摘掉,CMA 就没法正常工作了。

2.3 怎么确认CMA真正生效

配置改完、内核启动起来,第一步是确认区域确实注册了。看启动日志和 meminfo 是最快的办法:

dmesg | grep -i cma cat /proc/meminfo | grep -i cma

正常情况下应该能看到类似下面的输出:

[ 0.000000] cma: Reserved 256 MiB at 0x00000000a0000000 CmaTotal: 262144 kB CmaFree: 262144 kB

CmaTotal就是 CMA 区域总量,CmaFree是当前还没被 CMA 分配出去、仍然可以被系统借用的量。刚启动时两者相等很正常。另外别忘了,通过启动参数或CONFIG_CMA_SIZE_MBYTES创建的全局 CMA 区域,在内核里的注册名字固定是reserved,后面写模块时cma_get("reserved")就直接命中它。设备树方式创建的区域,注册名一般会跟随节点名,有些平台还会带上 unit address,建议实际到 debugfs 里确认一下具体名字。

3. 模块里到底该调哪个接口

3.1 三条路:cma_alloc、dma_alloc_from_contiguous、dma_alloc_attrs

真正写代码时你会发现,和 CMA 相关的接口不止一个,选错接口会走不少弯路。我一般按下面的维度来选:

API头文件前置条件适用场景
cma_alloc / cma_releaselinux/cma.h拿到 struct cma *模块自己管理 CMA 区域,最直接可控
dma_alloc_from_contiguous / dma_release_from_contiguouslinux/dma-contiguous.h有 struct device * 且绑定好 CMA 区域想走设备 DMA 框架,同时处理 DMA mask
dma_alloc_coherent / dma_alloc_attrslinux/dma-mapping.h有正常 DMA 设备常规驱动首选,缓存一致性也一并处理

如果你写的是平台驱动,手头有struct device,那dma_alloc_coherent或dma_alloc_attrs是最省事的入口,缓存一致性、DMA 地址转换都会自动处理好。但它的问题是路径比较黑盒,你不好确定这次分配到底有没有走 CMA、走的是哪块区域。我自己调试内核模块里的 CMA 分配时,反而更喜欢直接用cma_alloc,因为它把“从哪个区域分配多少页、对齐到什么程度”全部摆在明面上,出了问题也好跟踪。

3.2 怎么拿到 struct cma *:cma_get与设备树绑定

直接走cma_alloc的前置条件是拿到一个struct cma *指针。最简单的方法是按名字找:

#include <linux/cma.h> struct cma *area; area = cma_get("reserved"); if (IS_ERR(area)) { return PTR_ERR(area); }

cma_get会遍历内核维护的 CMA 区域列表,按注册名匹配。用完要对称地调用cma_put(area)释放引用,否则模块反复加载卸载会泄漏引用计数。所有 CMA 区域的注册名可以在/sys/kernel/debug/cma/目录下看到,不同内核版本布局略有差异,有按区域建子目录的,也有直接在根目录放count、used、bitmap文件的,不确定时先ls一下。

如果是设备树声明了自己的 CMA 节点,更规范的做法是在驱动里通过of_reserved_mem_device_init_by_name把区域绑定到设备上,之后用dma_alloc_from_contiguous分配。但这个方法要求设备树、driver 的 compatible 匹配都正确,节点名也要完全对上,配置链更长。模块调试阶段,我建议先用cma_get走通流程,再考虑是否要做正式设备绑定。

3.3 参数怎么传:count、align、gfp_mask的讲究

cma_alloc的完整签名是:

struct page *cma_alloc(struct cma *cma, unsigned long count, unsigned int align, gfp_t gfp_mask);

count是要分配的页数,比如 4096 页在 4 KiB 页大小下就是 16 MiB。align的单位也是页,表示分配结果按多少页对齐,而且必须是 2 的幂。举个例子:4 KiB 页大小下,想让物理地址按 1 MiB 对齐,那就传 256,因为 256 × 4 KiB = 1 MiB。如果只是普通页对齐,传 1 就行。

gfp_mask这个参数最容易忽略,它必须允许内核做直接回收和页面迁移,常规场景直接传GFP_KERNEL。因为cma_alloc内部需要把占用目标区间的可迁移页面迁走,这个过程可能睡眠、可能唤醒 kswapd,所以它不能在中断上下文、不能持锁调用。如果你发现自己代码运行在原子上下文,但又有 CMA 需求,说明设计就出问题了——这种大块连续内存应该在驱动的 probe 阶段申请好,而不是在热路径里临时抱佛脚。

4. 一个能跑的CMA内核模块示例

4.1 示例要覆盖什么

下面这个模块我尽量保持最小,但把完整生命周期都走一遍:用cma_get拿到全局 CMA 区域,用cma_alloc分配 16 MiB 物理连续内存,并且要求 1 MiB 物理对齐,模块卸载时用cma_release释放、cma_put归还引用。如果你需要把它扩展成字符设备,只需要加file_operations,把这里的struct page *通过remap_pfn_range映射给用户态即可,CMA 的分配逻辑可以原封不动搬过去。

4.2 核心代码实现

#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/cma.h> #include <linux/mm.h> #include <linux/gfp.h> #define CMA_AREA_NAME "reserved" #define ALLOC_PAGES 4096UL /* 4096 * 4KiB = 16 MiB */ #define ALIGN_PAGES 256U /* 256 * 4KiB = 1 MiB */ static struct cma *demo_cma; static struct page *demo_pages; static int __init cma_demo_init(void) { demo_cma = cma_get(CMA_AREA_NAME); if (IS_ERR(demo_cma)) { pr_err("cma_get('%s') failed: %ld\n", CMA_AREA_NAME, PTR_ERR(demo_cma)); return PTR_ERR(demo_cma); } demo_pages = cma_alloc(demo_cma, ALLOC_PAGES, ALIGN_PAGES, GFP_KERNEL); if (!demo_pages) { pr_err("cma_alloc %lu pages failed\n", ALLOC_PAGES); cma_put(demo_cma); demo_cma = NULL; return -ENOMEM; } pr_info("cma_demo: alloc %lu pages (%lu KiB), phys=%pa, align=%u pg\n", ALLOC_PAGES, ALLOC_PAGES * 4UL, &page_to_phys(demo_pages), ALIGN_PAGES); return 0; } static void __exit cma_demo_exit(void) { if (demo_pages) { cma_release(demo_cma, demo_pages, ALLOC_PAGES); demo_pages = NULL; } if (demo_cma) { cma_put(demo_cma); demo_cma = NULL; } pr_info("cma_demo: released\n"); } module_init(cma_demo_init); module_exit(cma_demo_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("CMA alloc/release demo");

配套 Makefile 没有特殊之处:

obj-m := cma_demo.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

4.3 编译、加载和结果验证

在模块目录下执行make,然后按顺序做下面几步:

sudo insmod cma_demo.ko dmesg | tail -5 cat /proc/meminfo | grep -i cma sudo rmmod cma_demo dmesg | tail -3

正常的话dmesg里会出现类似这样的日志:

cma_demo: alloc 4096 pages (16384 KiB), phys=0x00000000a1000000, align=256 pg

同时可以看到CmaFree比CmaTotal少了 16384 KiB,说明这 16 MiB 已经被模块占住了。卸载后CmaFree应该回到和CmaTotal相等,证明cma_release把页面还了回去。我调试时如果发现CmaFree没恢复,第一反应就是代码里还有别的路径占着页面没释放,而不是怀疑内核出 bug。

4.4 释放路径:cma_release要小心的事

释放路径比分配更容易出问题。cma_release必须传对当初分配的起始struct page *和页数,任何一个是错的,轻则内存泄漏,重则内核直接 BUG。我的习惯是把这两个值保存在模块私有结构体里,而不是每次重新计算。

另外,释放前必须确保所有 DMA 映射已经解除。如果这块内存曾经通过dma_map_single映射给外设,要先调用dma_unmap_single,等外设彻底停下来了,才能cma_release。否则外设还在写这块内存,你把页还回 CMA 后,它可能又被系统当作普通页分配给别人,数据就被写花了——这种问题极其难查,日志里什么都看不出来。

5. 失败模式与排查实录

5.1 最常见:分配失败但CmaFree还有富余

我遇到得最多的一类问题是:CmaFree明明还剩不少,但cma_alloc就是返回 NULL。如果你真以为“空闲够多就该成功”,那就把 CMA 想简单了。

CmaFree统计的是 CMA 区域里当前没有被cma_alloc分配走、可以由伙伴系统借用的页面。这些页面上很可能正住着页缓存、匿名页、用户进程的堆。cma_alloc想在某个对齐位置凑出 16 MiB 连续块,必须保证目标区间内的所有页面都能被迁移或回收。只要区间里有一页被 pin 住(比如被长期固定给 RDMA、被某些文件系统引用无法回收、被 mlock 锁住),整个分配就会失败,哪怕区间里绝大多数页面都是空闲的。这就像物业要清空一整排车位,结果其中一个车位被焊了地锁,整排车队就进不来。

这类问题可以从几方面排查。先看目标大小是否超过区域总量;再看对齐是否过大——比如区域本身只有 8 MiB,你非要 1 MiB 对齐的 16 MiB,那不可能成功;最后重点检查系统 IO 负载和内存 pin 情况,降低负载、减少不可迁移内存后,再试一次往往就能分出来。

5.2 模块加载阶段的失败

还有一批问题根本走不到cma_alloc那一步:

现象原因处理
insmod 报 Unknown symbol cma_alloc内核版本太老,或模块没有声明 GPL确认内核版本;MODULE_LICENSE("GPL")必须写
cma_get 返回 -ENOENT区域名不对,或 CMA 压根没注册ls /sys/kernel/debug/cma/看真实名字
启动日志很多区域注册失败CONFIG_CMA_AREAS太小调大后重新编译内核
编译时找不到 linux/cma.h内核 headers 不完整安装对应版本 headers

这里多说一句,cma_alloc、cma_get这些符号都是用EXPORT_SYMBOL_GPL导出的,非 GPL 模块在 insmod 时就会因为“Unknown symbol”被拒。以前有人为了绕开这个检查把MODULE_LICENSE随便填,我劝你省省,内核模块合规性不是靠改字符串能蒙混过去的,老老实实写 “GPL” 并对自己的修改负责。

5.3 从trace和debugfs把问题钉死

遇到抹不开头绪的 CMA 分配失败,别靠猜,直接开 trace。内核的 tracefs 里有一组 CMA 事件:cma_alloc_start、cma_alloc_success、cma_alloc_unsuccess。用 trace-cmd 一键抓最省事:

sudo trace-cmd record -e cma sudo trace-cmd report | grep cma

没有 trace-cmd 的环境可以手动开关事件节点:

echo 1 > /sys/kernel/tracing/events/cma/cma_alloc_start/enable echo 1 > /sys/kernel/tracing/events/cma/cma_alloc_unsuccess/enable

老内核如果事件不全,用 kprobe 挂钩cma_alloc和cma_release也能拿到调用参数和返回结果。除了 trace,/proc/pagetypeinfo也值得看,它会把每个 pageblock 的类型和空闲情况列出来,你能直接看到MIGRATE_CMA区域是不是被非 movable 页面占用了。配合/sys/kernel/debug/cma/下的 used 计数,基本能把“内存够但分不出来”这类问题定位到具体区间。

5.4 分配成功之后的“隐形地雷”

内存分出来,不代表就万事大吉了,下面这几个坑我基本都踩过:

  • cma_alloc返回的页面不会清零,里面装的可能是别人用过的数据。如果这块内存要暴露给用户态,或者里面会放密钥类敏感数据,一定要自己先清零;不清零轻则 bug,重则属于严重的安全疏漏。
  • 分出来的物理地址不一定在设备的 DMA 能力范围内。比如设备只能访问 32 位地址,而 CMA 区域落在了 4G 以上,那分配再成功,外设也摸不到。这时候要么在设备树里把区域放在限定地址区间内,要么用dma_set_mask配合dma_alloc_from_contiguous来强制约束。
  • page_address()不一定返回有效虚拟地址。CMA 区域如果落在高端内存(highmem)之上,直接page_address会拿到 NULL。访问页面内容前先用kmap/kmap_local_page,或者干脆只使用物理地址走 DMA,别指望去虚拟地址读写。
  • CMA 分配不能放在高频路径里。每次分配都可能触发大范围页面迁移,延迟能到几十上百毫秒,生产系统根本扛不住。正确姿势是驱动初始化时分配一次,整个生命周期持有使用,退出时再释放。

6. 用CMA的几条实战准则(个人体会)

6.1 什么时候别用CMA

看到这里你大概已经明白,CMA 不是什么万能内存池。如果你的需求只是虚拟连续、不需要外设访问,那vmalloc更合适;如果每次只需要几页,连续不连续无所谓,那普通kmalloc或alloc_pages就行;如果分配释放极其频繁、又对延迟敏感,CMA 的迁移开销会让你欲哭无泪。我见过有人在网卡驱动里每个数据包都走一次 CMA,结果性能直接崩到不可用,最后改写固定缓存池才解决。

6.2 我固定的“上板前检查清单”

踩过几次坑之后,我给自己定了一套固定流程,每次在板子上调 CMA 之前都会过一遍:

  1. 确认内核CONFIG_CMA、CONFIG_DMA_CMA已开启,CONFIG_CMA_AREAS足够所有区域注册。
  2. 设备树或启动参数指定明确的区域大小和地址范围,不依赖默认值。
  3. 启动后检查dmesg的 cma 注册行、/proc/meminfo的 CmaTotal/CmaFree、debugfs 下的区域名字。
  4. 在驱动 probe 阶段申请,remove 阶段释放,申请结果存私有数据,release 参数和申请参数完全一致。
  5. 申请后如果走 DMA,先确认物理地址在设备 mask 范围内,再 dma_map。
  6. 释放前 dma_unmap,确认外设已停止访问,再cma_release。
  7. 分配失败时第一时间开 trace 看cma_alloc_unsuccess,而不是反复重试猜原因。

6.3 后续还能怎么扩展

CMA 用顺了之后,你会发现它其实可以和很多机制组合。比如配合dma_buf把 CMA 内存导出给用户态,实现零拷贝视音频处理;也可以在多块 CMA 区域之间做分配策略,把高带宽外设和普通外设的缓冲隔离开;还可以结合设备树 reserved-memory 给每个外设单独建区域,互不干扰。我自己后来的习惯是优先走设备树声明区域,把区域名固定成节点名,驱动里用cma_get拿引用,这样既不依赖全局默认区域,又能通过 debugfs 一眼看清每块区域被谁用了。这个方案我在好几个多媒体项目里验证过,稳定性和可维护性都比直接堆全局 CMA 好不少。如果你也准备在自己模块里引入 CMA,建议从本文这个最小模块起步,跑通之后再往正式驱动上迁移。

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

IM即时通讯核心结构体设计:消息、会话与增量同步的边界法则

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

作者头像 李华
网站建设 2026/10/24 3:15:16

V4L2应用层编程实战:从open到STREAMON的完整流程与踩坑汇总

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

作者头像 李华
网站建设 2026/10/24 3:14:40

流媒体技术优化如何真正转化为商业价值:成本、体验与变现策略

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

作者头像 李华
网站建设 2026/10/24 3:14:10

组态王6.55安装激活与License无效排查全指南

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

作者头像 李华
网站建设 2026/10/24 3:13:24

STM32L162ZE与PCA9422协同实现微安级电源管理

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

作者头像 李华
网站建设 2026/10/24 3:12:17

PAM4光链路多段SOA预放大设计与实操指南

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

作者头像 李华