news 2026/9/14 3:18:00

深入理解设备树dma-coherent:缓存一致性、DMA API与实战排障

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解设备树dma-coherent:缓存一致性、DMA API与实战排障

写驱动的人第一次在设备树里看到dma-coherent;这个属性时,多半是照着厂商BSP的样板抄过来的。但这个属性背后,其实是你和设备之间关于 CPU cache 怎么配合的一纸契约。dma-coherent决定了 DMA 驱动在数据通路里要不要手动刷 cache、怎么刷、刷到什么程度;写对了,驱动干净利落,写错了,轻则性能砍半,重则内存数据悄悄被改坏,系统随机死机。这篇文章我会从缓存一致性原理、设备树语义、内核 DMA API 的差异响应,再到硬件总线层的 snoop 机制和实际排障手段,把dma-coherent一次讲透。

1. 先搞清楚:dma-coherent 到底在解决什么问题

想理解dma-coherent,不能只把它当成一个 DTS 属性来背。它背后是 Linux 设备驱动开发里最基础也最容易踩坑的一个问题:CPU 和外设看到的同一块物理内存,内容可能不是同一个版本。

1.1 CPU 与外设眼中的“同一块内存”并不一致

现代 CPU 不会每次读写都直接去访问内存。CPU 读数据时,会先把内存里的数据复制到 cache(一级、二级,甚至到三级),后续的读写都在 cache 里完成,命中 cache 的地带根本不会到达内存。CPU 写数据时,也往往是先改 cache,然后根据 cache 策略在某个时机把脏数据写回内存,或者直接直写内存。

问题在于,DMA 控制器访问内存时,走的是总线上的地址,它通常不会经过 CPU cache。这就造成了两个并排存在的“视图”:

  • CPU 视图:看到 cache 里最新的数据,还没同步到内存。
  • 设备视图:直接读内存,如果 CPU 的新数据还在 cache 里没落盘,设备读到的是旧数据。

反过来也一样。DMA 控制器向内存写入一包数据后,如果 CPU cache 里还保留着这块地址区域的旧内容,CPU 再读这个地址时命中的是缓存里的老数据,等于设备写进来的新数据被“屏蔽”了。

我打个比方:你和同事共用一个纸质登记本,每次你看到新内容后会抄一份放在自己桌上(cache),同事过来只翻原登记本。你桌前那份和原登记本的内容一旦不同,两边对不上就是必然的。

在 DMA 场景中,这种不一致会引发很严重的后果。一块网卡要把 CPU 组装好的数据包发出去,数据包的内容还在 cache 里,DMA 直接读内存,读出去的全是垃圾;一块存储控制器收了一包数据落进内存,CPU 因为 cache 里有旧数据而拿到错误内容,最终体现为文件校验失败、网络校验和错误、系统日志里随机刷出异常。

1.2 一致性维护的两种策略:软件刷 cache,还是硬件 snoop

既然缓存不一致是必然存在的,那就得有机制来保证 DMA 传输前后两边看到的数据一致。整个业界只有两条路:

第一条路是让软件来管。驱动在启动一次 DMA 前,显式调用 cache 维护指令,把 CPU cache 里对应地址的数据“写回”内存,确保设备能读到最新内容;DMA 结束后,再把 CPU cache 里对应地址的缓存“失效”,迫使 CPU 下次重新从内存读。这样 CPU 读到的就是设备写进内存的最新数据。这套流程对应的是dma_map_singledma_sync_single_for_cpudma_sync_single_for_device这一组 API,需要驱动开发者在正确的位置调用,漏一次就出一次事。

第二条路是让硬件来兜底。CPU 和 DMA 控制器之间有一条能够感知 cache 的总线,DMA 发起读写请求时,总线会主动去 CPU cache 里“查一下”,看看最新数据是不是还在 cache 里;如果命中,就把 cache 里的内容返回给设备;设备写内存时,也会顺带让 CPU cache 里对应的旧行失效。这个过程叫做 snoop(窥探),整个过程不需要软件介入,CPU 和 DMA 看到的内存天然一致。

设备树里的dma-coherent;就是在告诉内核:我这个设备走的是第二条路,硬件能维护一致性,内核和驱动不需要为了 cache 同步付出额外代价。

2. 设备树里的 dma-coherent 与 dma-noncoherent

搞清楚了原理,再看设备树属性就顺了。dma-coherent;不是给 DMA 控制器本身用的,而是给“使用 DMA 的外设节点”用的,比如网卡、存储控制器、音视频加速器。它表示该设备发起的 DMA 操作具备硬件缓存一致性。

2.1 DTS 解析过程:从设备树到 dev->dma_coherent

在设备树中,这个属性通常写成这样:

&usbotg1 { status = "okay"; dma-coherent; };

注意,它是一个 bool 类型的属性,没有值,写不写值不重要。对应地,Linux 里还有一个dma-noncoherent;,表示显式声明设备不具备一致性。

当内核为这个节点创建 platform device 时,会执行of_dma_configure()。在drivers/of/address.c里,它调用of_dma_is_coherent(np)来查询节点的 coherent 状态,并把结果写到struct devicedma_coherent字段:

dev->dma_coherent = of_dma_is_coherent(np);

of_dma_is_coherent()的语义是:从当前节点一直往父节点找,如果先碰到dma-coherent;,返回 true;如果先碰到dma-noncoherent;,返回 false;找不到任何属性就默认返回 false。这说明两个重要点:

  • 属性是可以继承的。父节点写了dma-coherent;,子节点默认也一致,除非子节点显式写dma-noncoherent;覆盖。
  • 默认行为是 non-coherent。也就是说,只有你明确告诉内核“这个 DMA 是硬件一致的”,内核才相信你;不写就按非一致处理。

在 ACPI 平台上,对应的概念是 CCA(Cache Coherency Attribute)。x86 平台几乎都把设备描述成 coherent,所以你在 x86 上写驱动很少感知到 cache 同步问题。而 ARM 平台不一样,设备树默认 not coherent,所以驱动里到处是 sync 操作。

2.2 什么时候必须写,什么时候千万不能写

这是我在实际项目中觉得最需要判断力的一步。不同 SoC,甚至同一颗 SoC 里不同的外设,DMA 路径的硬件设计都可能完全不同。

判断标准只有一个:硬件链路里有没有“一致性端口”。比如 ARM 的 ACE/ACP 总线端口、CCI/CMN 总线上的 snoop 节点。如果 DMA 发出的访问请求最终能通过这些端口和 CPU cache 交互,那么这个设备就是 coherent 的,应该写dma-coherent;

常见的情况我给你列在里面:

场景建议原因
SoC 内部 DMA,挂在 ACE/ACP 一致性端口下dma-coherent;硬件 snoop 保证一致
普通 AXI DMA,DMA 通路只是直连内存控制器不要写没有 snoop 路径,需要软件 sync
x86 PCIe 设备通常默认 coherentACPI CCA 已声明
ARM 平台下的 PCIe 设备需要具体分析很多 PCIe 根节点是 non-coherent,需 SMMU 或软件维护

最容易出问题的是“照抄”。我见过不少板卡,设备树是从别的项目拷贝过来的,上面带着dma-coherent;,但实际上这颗 SoC 的外设 DMA 通路根本没有 ACE 端口,硬件也不做 snoop。加上这个属性后,驱动里的 cache sync 全被内核跳过了,DMA 发出去的是旧 cache 数据,设备收到的包体全是错的,现象奇奇怪怪。反过来,硬件明明支持一致性,设备树没写,驱动每次收发包都在空转刷 cache,性能低,并且某些条件下的 invalidate 还会把 CPU 刚更新的数据冲掉,出现随机性的数据损坏。

3. 内核 DMA API 是怎么响应 coherent 标志的

dev->dma_coherent这个标记一旦被设置,它会影响从内存分配到底层 map 的一整套 DMA API 行为。这里面的差异,是驱动开发者能够“感知”到dma-coherent的最直接入口。

3.1 dma_alloc_coherent:一致内存分配

dma_alloc_coherent()是驱动里最常用的一致性内存分配接口,通常用来分配 DMA 描述符环、接收/发送缓冲区。它的保证是:返回的 CPU 虚拟地址和 DMA 地址指向同一块物理内存,在 DMA 传输期间,CPU 和设备都无需额外维护 cache。

在 non-coherent 设备上,为了让这个保证成立,内核要付出额外成本。比如在部分 ARM64 实现中,底层会把分配的页映射成 Normal Non-Cacheable 属性,或者 DMA API 内部会在分配后对整块区域做 cache 清理,确保首次访问一致。在 coherent 设备上就简单了,分配出来的页就是普通的 cacheable 页,CPU 读写效率高,设备通过总线 snoop 也能看到最新数据。

因此,同样一个dma_alloc_coherent()调用,coherent 和 non-coherent 设备的性能差距很大。这也是为什么有些驱动在换成支持硬件一致性的新平台后,吞吐量能明显提升——一半的 cache flush 开销省掉了。

另外注意,dma_alloc_coherent()并不保证 DMA 起始地址是物理页对齐的,对 DMA 地址的对齐要求,还是要用dma_alloc_attrs()配合DMA_ATTR_ALLOC_SINGLE_PAGES或直接检查dma_get_required_mask()这类手段去确认。

3.2 streaming API 的 sync 操作在两种模式下各自的命运

dma_alloc_coherent()适合大块、生命周期长的缓冲区。对于网络包这种复用频繁、生命周期短的数据,驱动通常用流式映射(streaming mapping):

dma_addr_t dma_map_single(struct device *dev, void *cpu_addr, size_t size, enum dma_data_direction dir);

映射之后,数据在 DMA 传输过程中由 DMA API 保证一致性。具体到实现,在 non-coherent 设备上,dma_map_single()内部会调用一次dma_direct_sync_single_for_device(),根据方向做 cache clean:

  • DMA_TO_DEVICE:把 cache 里的脏数据写回内存,否则设备读不到最新内容。
  • DMA_FROM_DEVICE:把 cache 对应行失效,防止 CPU 后续读到旧数据。
  • DMA_BIDIRECTIONAL:先 clean 后 invalidate,两头都顾。

等到 DMA 完成,驱动调用dma_unmap_single()dma_sync_single_for_cpu()时,non-coherent 设备会再做一次方向对应的 cache 维护。

而在 coherent 设备上,所有这些 sync 操作都被跳过。dma_direct_sync_single_for_device()dma_direct_sync_single_for_cpu()里都有一层判断:

if (dev_is_dma_coherent(dev)) return;

也就是说,从 API 语义上说,你在 coherent 设备上调用 sync 系列函数不会出问题,它什么也不做;但如果你自己绕过标准 API,直接对 dirty cache 行做 invalidate,那就是在制造数据丢失事故。

3.3 描述符环(descriptor ring)是重灾区

网络驱动、存储驱动里最常见的一致性 bug 不在 payload 缓冲区,而在描述符环。描述符本身是一块内存,CPU 往里面填地址、长度、标志位,然后写 doorbell 通知 DMA 开始工作;DMA 完成后再写回一个状态标志,CPU 轮询读取。

这块内存如果用普通的kmalloc()分配,然后用dma_map_single()映射到设备,那么在 non-coherent 场景下,每填一个描述符之后必须做dma_sync_single_range_for_device(),每读到一个 DMA 完成标志前必须做dma_sync_single_range_for_cpu()。漏一次,轻则描述符内容不被 DMA 识别,重则读到半新的标志导致超时或数据错位。

正确又省心的做法是:描述符环这种控制结构统一用dma_alloc_coherent()分配。不管设备是 coherent 还是 non-coherent,DMA API 都能保证 CPU 视角和设备视角的一致性,驱动代码不用在每个更新点手动 sync,可维护性高一个档次。

当然,即便设备是 coherent,CPU 与 DMA 之间的“顺序”仍然需要保障。你写完描述符后,还是要用dma_wmb()这类内存屏障确保写操作按顺序被设备观察到,别把dma-coherent当成万能屏障。

4. 硬件层面:coherent 到底靠什么实现

理解了软件侧的行为,就可以回答一个更深的问题:硬件侧到底是怎么做到 cache 一致性的?这里以 ARM SoC 为例,因为绝大多数dma-coherent的坑都出现在 ARM 平台。

4.1 硬件 snoop 是怎么工作的

首先要有一个概念:并不是所有挂在总线上的 DMA 都天然具备一致性。传统 AXI 总线上,DMA 访问内存就是一个普通读写请求,内存控制器只负责把请求落到 DRAM,根本不会去问 CPU cache 有没有最新副本。

实现硬件一致性的关键,是总线协议对“可共享”和“snoop”的支持。ARM 从 AMBA3 AXI 之后提出的 ACE 协议,允许外设发起带 snoop 属性的读写请求。DMA 控制器在总线上发出一个读请求时,如果请求被标记为 shareable,连接 CPU 和一致性总线的组件(比如 CCI、CMN、SCU)会去检查 CPU 的 cache,如果命中了脏 cache 行,就把 cache 里的最新数据作为读请求的返回;如果是写请求,会同步更新或失效相关 cache 行,让 CPU 后续访问拿到的也是新值。

你可以把一致性总线理解成一个“多边中间人”:DMA 想在内存里拿数据,中间人先去 CPU 的各个 cache 里问一圈“你们有没有这个地址的新内容”,有就拿回来告诉 DMA,没有就去内存拿。整个过程透明,软件不需要参与。

另一种常见实现是给 DMA 控制器开一个“一致端口”。有些 SoC 为特定外设(比如大带宽的视频编解码器 DMA)单独引出一条 ACP(Accelerator Coherency Port)接口,接到 CPU cluster 的 SCU 上。走这个端口访问内存的 DMA,天然具备 snoop 能力。设备树里对这类外设写dma-coherent;是完全正确的。

4.2 为什么有些 SoC 的 DMA 必须开 cacheable 位

这里要说一个很隐蔽的坑:硬件支持一致性和 DMA 控制器实际使用了一致性通路,是两回事。

很多 DMA 控制器内部有一个控制字段,比如cacheable位、shareable位,决定 DMA 发出的总线请求带不带可共享/可缓存属性。如果这个位没有被正确设置,即使 DMA 控制器的硬件物理上连到了 ACE 总线,它发出的请求也只是普通访问,总线不会对它做 snoop,硬件一致性等于没有。

这种位一般在驱动初始化的寄存器配置里设置,由固件或裸机 BSP 完成。如果你看了 datasheet 认为某设备支持 coherent,就只改了设备树,却漏了 DMA 控制器本身的 cacheable 位配置,那问题会以一种非常隐蔽的方式出现:偶尔数据错、偶尔正常,而且难以稳定复现。

处理这种问题我的经验是,先确认三件事:

  1. 设备树里该设备的dma-coherent;是否与 datasheet 一致。
  2. SoC 参考代码里 DMA 控制器的 cacheable/shareable 寄存器配置是否被完整带入。
  3. 这个设备的 DMA 通路是否真的经过一致性端口,而不是仅仅在框图上看似相连。

还有一个容易忽略的点:IOMMU(SMMU)存在时,coherent 的判断会受页表属性影响。DMA 经过 SMMU 翻译时,访问属性由 SMMU 页表项的 Attr 字段决定,即使设备树写了dma-coherent;,如果 SMMU 页表配置把访问标记成 non-cacheable,硬件一致路径仍然不会生效。此时要检查 IOMMU 驱动和 dma-direct/iommu-DMA 层的配置,不能只看设备树。

5. 实战:确认 dma-coherent 状态与排查典型问题

光说不练没有意义。这一节我分享几个实际干活时拿来即用的确认方法和排障记录。

5.1 驱动里怎么确认自己设备是否 coherent

最直接的办法是在驱动 probe 的时候把状态打出来:

static int my_driver_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; dev_info(dev, "DMA coherent: %s\n", dev->dma_coherent ? "yes" : "no"); if (!dev->dma_coherent) { dev_warn(dev, "Non-coherent DMA path, need explicit sync\n"); } ... }

dev->dma_coherent就是设备树解析后的最终结果,能反映属性继承和覆盖之后的真实状态。如果你想在驱动里再交叉验证一遍设备树原始内容,可以:

if (of_property_read_bool(np, "dma-coherent")) { ... }

一般不需要,dev->dma_coherent已经包含了这个信息。

另外,/sys/devices/platform/xxx/of_node下面可以看到设备树节点的原始二进制属性,用dtc工具把 DTB 反编译出来也能直接确认属性是否真的写对了。

5.2 开启 CONFIG_DMA_API_DEBUG 与其它调试手段

排查 DMA 问题,第一个该开的就是内核配置里的 DMA API 调试开关:

CONFIG_DMA_API_DEBUG=y CONFIG_DMA_API_DEBUG_SG=y

开启后,内核会记录每一次 map/unmap 的地址、长度、方向,并在 unmap 时校验是否匹配。它能捕获不少经典错误:

  • 同一个 DMA 地址被 unmap 两次。
  • unmap 时传了和 map 时不同的长度或方向。
  • map 之后再访问没有被 sync 的内存。
  • DMA 地址越界或没有按 cache line 对齐。

如果系统起来后发现没有抓到的上层错误,可以进一步使用动态调试查看 DMA API 内部路径:

echo 'file drivers/iommu/dma-iommu.c +p' > /sys/kernel/debug/dynamic_debug/control echo 'file kernel/dma/direct.c +p' > /sys/kernel/debug/dynamic_debug/control

打开后,驱动传输时可以看到实际走了 direct mapping 还是 iommu 路径,以及每次 sync 是否被跳过。这个信息在判断 coherent 配置是否生效时非常直观。

还有一个我常用的笨办法:在驱动收发路径里专门加计数器,统计dma_sync_single_*这一组函数的调用次数。如果设备树声明了dma-coherent;,这些调用应该全部被内核跳过;如果还有大量调用,说明配置和实际行为对不上。

5.3 三个高频故障场景与排查实录

场景一:硬件支持 coherent,设备树漏写dma-coherent;

现象是驱动功能基本正常,但吞吐量明显低于预期,CPU 占用率还高。用 perf 看热点,大量时间都耗在 cache maintenance 指令上。进一步检查发现驱动的 RX 路径里dma_sync_single_for_cpu()每次都在 invalidate cache,把 DMA 刚写入的数据从 cache 中作废掉,CPU 去读的时候又得从内存重新加载,白白损失一次内存访问延迟。

排查思路:先确认硬件文档确实支持一致性端口,再对比参考 BSP 确认 DTS 中少了这个属性。加上dma-coherent;后,DMA API 的 sync 路径全部变成 no-op,性能恢复到预期水平。

场景二:硬件不支持 coherent,设备树却照抄了dma-coherent;

现象非常诡异:网卡发出去的包偶发内容错误,存储写入的数据偶尔校验失败,但又不是每次都失败。因为内核认为设备是 coherent,所有 sync 都被跳过了,而 DMA 实际上读的是内存里的旧数据。

排查的时候,我先把设备树里的dma-coherent;删掉,驱动保持不动,再跑压力测试。如果问题消失,基本就能确定是缓存一致性问题。再结合 DMA 控制器的 cacheable 位确认硬件不支持一致性,最后保留 non-coherent 配置,驱动在关键路径上补上 sync 调用,问题彻底解决。

场景三:描述符环用普通 kmalloc 分配,non-coherent 下漏了 sync

年轻工程师容易犯的一个错误是:描述符环用kzalloc()分配,然后dma_map_single()映射给设备,但每次更新描述符后不调用 sync,认为 map 一次就万事大吉。这个误区在 x86 上往往不暴露,因为 x86 默认把设备看成 coherent;一搬到 ARM 平台上就翻车。

现象是 DMA 停在半路,doorbell 写了但控制器不执行,或者执行后用旧描述符反复搬运同一块数据。定位方式其实不难:在填写描述符到写 doorbell 之间,用芯片调试器去看 DMA 控制器的描述符缓存,通常会发现控制器持有的还是旧地址。补上dma_sync_single_range_for_device()后恢复正常。

这个案例给我们的教训是:描述符环这类控制结构,不管平台是不是 coherent,统一用dma_alloc_coherent()分配最稳妥,省得在每次更新点纠结 sync 的粒度。

6. 给后来人的几点经验

讲了这么多原理和案例,最后聊几句我在实际项目里的惯用做法,不算什么高深技巧,但能帮你少走弯路。

第一,新板子 bring-up 阶段,从第一天就把CONFIG_DMA_API_DEBUG开启,不要等到出了诡异问题再开。很多时候问题早就在 dmesg 里打印过警告,只是当时没人注意。跑一轮长时间压力测试后,再看/sys/kernel/debug/dma-api/summary,如果计数异常,说明你的 map/unmap 路径有泄漏,越早发现越好修。

第二,设备树里dma-coherent;宁可先不写,也不要拍脑袋写。默认 non-coherent 是最保守的路径,驱动里只要按标准 API 写了 sync,功能上不会出错,顶多性能差一点。等确认硬件确实支持一致性,再在测试环境里加上这个属性跑一轮压力,确认性能收益和稳定性都达标后,再放进正式的 DTS 里。

第三,排查一致性问题时,不要只盯着驱动源码,先把“设备树属性 + DMA 控制器寄存器配置 + 总线拓扑(是否有 ACE/ACP/CCI/CMN) + IOMMU 配置”四件事全部过一遍。设备树只是最后一道开关,前面任何一环断了,dma-coherent都只是张空头支票。

第四,如果手头平台同时有 SMMU 和 DMA,建议把 DMA 是否走 IOMMU 路径也纳入检查。设备树只是把 coherent 信息传给了软件,真正有没有生效,要看 DMA 实际发出的访问请求带没带可共享属性。这个用逻辑分析仪可能不好抓,但打开CONFIG_IOMMU_DEBUG或 SMMU 的调试工具,一般能看到页表属性的配置情况。

最后再分享一个我常用的快速验证技巧:当你拿不准某个 DMA 设备到底该不该加dma-coherent;时,用同一个驱动分别编两个内核,一个带属性、一个不带,分别在 40 度到 85 度的高低温箱里跑网卡吞吐测试和磁盘读写校验。不一致路径下的内存损坏问题有几个特点:随机、偶发、越跑越频繁,高温下更容易暴露。能扛过一整夜压力测试,基本才能说你在这个设备上的 coherent 配置是稳妥的。

做 DMA 驱动,本质上是在跟“一致性”三个字打交道。dma-coherent只是这组问题的入口,真正值钱的,是你愿意花时间去搞清楚 CPU cache 和 DMA 硬件之间那层看不见的握手协议。把这层机制吃透,很多玄学问题都会变成可以稳定复现、可以推理、可以解决的工程问题。

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

2025年大语言模型技术演进与实战部署指南

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

作者头像 李华
网站建设 2026/9/14 3:16:08

DDR5内存选购指南:带宽、时序、温控与兼容性四维解析

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

作者头像 李华
网站建设 2026/9/14 3:15:08

从单体智能体到超级团队:企业级Agent平台实践指南

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

作者头像 李华
网站建设 2026/9/14 3:14:42

NumPy数组基础与高效计算实战指南

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

作者头像 李华
网站建设 2026/9/14 3:14:30

Lithe-IDEA:轻量开源IDE的架构革命与Spring Boot开发新范式

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

作者头像 李华