news 2026/10/6 7:21:45

FPGA直连NVMe SSD:硬件协议栈实现与性能调优实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA直连NVMe SSD:硬件协议栈实现与性能调优实战

1. 项目缘起与整体方案设计

1.1 为什么要在FPGA上直接挂NVMe SSD

我第一次接触这个需求,是帮一个做高速数据采集的朋友解决存储瓶颈。他的系统前端是8通道ADC,采样率拉到1GSPS,原始数据率算下来接近16GB/s,后端用了几块企业级SATA SSD做RAID,实测写入连2GB/s都跑不满,CPU软中断直接被打满,丢包丢到怀疑人生。后来我们把目光转向NVMe SSD——单块PCIe 3.0 x4的盘,顺序写入轻松跑到3GB/s以上,IOPS更是SATA盘的十几倍。问题在于,谁来当这个Host?用CPU跑NVMe驱动,协议栈开销和中断延迟在超高吞吐场景下依然是瓶颈;用专用存储控制器芯片,灵活性又太差,没法做自定义的数据预处理。

Xilinx FPGA方案就是在这个夹缝里杀出来的。FPGA本身有PCIe硬核,可以配置成Root Complex模式,直接枚举并管理NVMe SSD,把整条存储路径从“CPU软件栈”下沉到“硬件逻辑”。数据从ADC进来,在FPGA内部做完打包、加校验、甚至简单的压缩,直接通过NVMe Host Controller IP写进SSD,全程不经过CPU和DDR内存,延迟和吞吐都做到了极致。

这个方案适合谁?如果你在做高速数据采集、实时图像处理、雷达信号记录、或者任何需要把海量数据以极低延迟落盘的场景,并且对成本不像互联网大厂那么敏感,那这套路子值得你花时间啃下来。当然,前提是你得有一定的FPGA开发基础,至少用过Vivado,写过AXI-Stream接口的逻辑,不然光是调PCIe枚举就能让你怀疑人生。

1.2 整体架构与数据通路拆解

整个系统的核心思路可以用一句话概括:让FPGA扮演CPU的角色,用硬件逻辑实现NVMe协议栈,把数据从用户逻辑直接灌进SSD。

数据通路大致是这样的:用户逻辑(比如ADC采集模块)产生数据流,通过AXI-Stream接口送入一个数据搬运模块(通常叫Data Mover或DMA Engine),这个模块负责把数据流切成NVMe命令要求的块大小,然后通过AXI-Stream接口送给NVMe Host Controller IP。NVMe Host Controller IP内部实现了NVMe协议的队列管理、命令生成、Completion处理等逻辑,它通过PCIe硬核与SSD通信。SSD返回的Completion信息再通过IP回传给控制逻辑,完成一次写入的闭环。

控制通路则是另一条线:MicroBlaze软核或者状态机负责初始化PCIe、枚举NVMe设备、创建IO Submission Queue和Completion Queue、提交Identify命令获取SSD的命名空间信息、然后开始下发读写命令。这条通路的数据量不大,但对时序和协议正确性要求极高,一个字段填错,SSD就直接不响应了。

注意:NVMe Host Controller IP并不是Xilinx官方免费提供的IP,通常需要从第三方IP供应商购买授权,或者自己基于PCIe硬核手写NVMe协议栈。市面上有一些开源实现,但稳定性和性能参差不齐,商用项目建议走正规授权渠道。

1.3 关键器件选型与参数计算

选型这块我踩过不少坑,这里直接给结论。FPGA芯片方面,如果你要做PCIe 3.0 x4以上的NVMe,必须选带PCIe硬核的器件。Xilinx 7系列里,Artix-7的PCIe硬核只支持Gen2 x4,带宽上限2GB/s,跑NVMe有点勉强;Kintex-7和Virtex-7支持Gen3 x8,才是正道。UltraScale和UltraScale+系列就更不用说了,Gen3 x16甚至Gen4都有,但价格也上去了。

我实际用的是Kintex-7 XC7K325T,PCIe Gen3 x8,理论带宽8GB/s,实际跑NVMe SSD顺序读写能到3.2GB/s左右,瓶颈在SSD本身而不是FPGA。DDR内存方面,如果你需要做数据缓存或者命令队列管理,至少挂一颗DDR3,容量看你的数据缓冲需求,一般512MB到1GB够用。

SSD选型有个坑:不是所有NVMe盘都能在FPGA上跑起来。消费级盘往往对PCIe枚举和NVMe协议实现有各种“非标”行为,比如对Admin命令响应超时、对队列深度有限制、甚至有些盘在非x86平台上直接不工作。建议优先选企业级盘,比如Intel DC系列、Samsung PM系列,这些盘对标准协议支持好,功耗和散热也稳定。我实测过Samsung 970 EVO Plus,能跑但偶尔会掉盘;换成Intel P4510之后,连续跑72小时没出过问题。

带宽计算很简单:PCIe Gen3每lane每方向8GT/s,编码开销后有效带宽约985MB/s,x4就是3.94GB/s,x8就是7.88GB/s。NVMe SSD的顺序读写性能通常在2GB/s到7GB/s之间,取决于盘本身。所以如果你用Gen3 x4,SSD性能在3GB/s左右,基本能跑满;如果用Gen3 x8,SSD得选高端企业级盘才能发挥全部带宽。

2. NVMe Host Controller IP核心细节解析

2.1 NVMe协议栈的硬件化实现要点

NVMe协议看起来复杂,但核心逻辑其实不绕。它本质上是一个基于队列的命令-完成模型:Host把命令写入Submission Queue,然后更新Doorbell寄存器通知SSD;SSD处理完后把完成信息写入Completion Queue,并触发中断(或者让Host轮询)。硬件实现的关键在于把这套流程用状态机固化下来,做到每个时钟周期都能处理一个队列条目。

队列管理是第一个难点。NVMe支持最多64K个队列,每个队列深度最多64K。实际硬件实现时,通常只开几个队列:一个Admin Queue用于初始化,一个或几个IO Queue用于数据传输。队列在内存中的布局是环形缓冲区,Host维护Head和Tail指针,SSD也维护一套。硬件逻辑需要精确地更新这些指针,任何一次指针错位都会导致队列卡死。

命令生成是第二个难点。NVMe命令是64字节的固定格式,里面包含操作码、命名空间ID、LBA起始地址、数据传输长度、PRP(Physical Region Page)列表等字段。PRP列表尤其麻烦,它描述了数据在Host内存中的物理页分布,硬件需要根据数据缓冲区地址自动生成PRP条目。如果数据跨页,还得生成PRP List,这涉及到额外的内存访问和链表管理。

实操心得:很多NVMe Host Controller IP在PRP处理上偷懒,只支持单页传输,导致大块数据写入时性能暴跌。选IP的时候一定要确认它支持PRP List,并且PRP List的生成是硬件自动完成的,不需要软件干预。

2.2 队列深度与并发性能的权衡

队列深度直接决定了SSD能同时处理多少个命令。消费级盘通常支持队列深度64,企业级盘能到256甚至1024。理论上队列越深,SSD内部的并发度越高,IOPS越大。但在FPGA实现里,队列深度不是越大越好。

原因在于资源消耗。每个队列条目需要存储命令本身(64字节)、PRP列表、以及状态信息。如果队列深度开到1024,光命令存储就要64KB的BRAM,再加上PRP和状态,资源很快就吃紧了。而且深度越大,指针管理的逻辑越复杂,时序收敛越困难。

我的经验是:对于顺序大块写入场景,队列深度32到64足够。因为顺序写入时,SSD内部本来就能做很好的合并和调度,队列再深也提升有限。真正需要深队列的是随机小IO场景,比如数据库负载,但那种场景FPGA方案的优势反而不明显,因为随机IO的瓶颈在SSD内部的FTL层,不在Host端。

实测数据:我用Intel P4510 2TB,队列深度从16增加到64,顺序写入带宽从2.8GB/s提升到3.1GB/s,提升约10%;继续增加到256,带宽只多了0.05GB/s,基本可以忽略。但资源消耗翻了好几倍。所以别盲目追求深队列,够用就行。

2.3 Doorbell寄存器与中断处理的硬件逻辑

Doorbell是Host通知SSD“有新命令了”的机制。每个队列有一对Doorbell寄存器:Submission Queue Tail Doorbell和Completion Queue Head Doorbell。Host写完命令后,把SQ Tail Doorbell更新为新的Tail值,SSD就知道要处理新命令了。SSD写完完成信息后,Host需要更新CQ Head Doorbell,告诉SSD“这个完成项我处理完了”。

硬件实现时,Doorbell的写入必须严格按顺序,不能乱序。因为SSD可能同时监控多个队列的Doorbell,如果两个队列的Doorbell更新顺序错了,可能导致命令处理顺序错乱。我的做法是用一个专门的Doorbell管理模块,把所有队列的Doorbell更新请求串行化,确保每次只有一个Doorbell写入在途。

中断处理方面,NVMe支持MSI-X中断,每个队列可以独立中断。但在FPGA里,中断处理反而比轮询麻烦。因为中断需要CPU参与(即使是MicroBlaze),会引入上下文切换开销。对于高性能场景,我建议直接用轮询模式:硬件逻辑不断检查Completion Queue的Phase Tag位,一旦发现新完成项就立即处理。这样延迟更低,逻辑也更简单。

注意:轮询模式会持续占用总线带宽,如果系统里还有其他主设备(比如DMA),需要做好仲裁,避免饿死其他设备。

3. 实操过程与核心环节实现

3.1 Vivado工程搭建与PCIe硬核配置

第一步是建Vivado工程,选对器件型号。我以Kintex-7 XC7K325T为例,工程建好后,在IP Integrator里添加PCIe硬核。Xilinx 7系列的PCIe硬核叫“7 Series Integrated Block for PCIe”,配置界面里几个关键参数:

  • Lane Width:选x4或x8,取决于你的板卡和SSD。我用的板子是x8的,所以选x8。
  • Max Link Speed:选Gen3,如果板卡布线质量一般,可以先选Gen2调试,稳定后再升Gen3。
  • Reference Clock Frequency:通常是100MHz,也有125MHz的,看板卡晶振。
  • BAR配置:NVMe需要至少两个BAR:一个用于寄存器访问(通常64KB),一个用于MSI-X表(通常16KB)。BAR的大小和类型要跟SSD的预期匹配,不然枚举会失败。

配置完PCIe硬核后,需要添加NVMe Host Controller IP。这个IP通常以AXI-Stream接口暴露数据通路,以AXI-Lite接口暴露控制寄存器。把它和PCIe硬核的AXI-Stream接口对接,注意位宽匹配:PCIe硬核通常是64位或128位,NVMe IP可能是256位或512位,中间需要加位宽转换模块。

时钟域也是个大坑。PCIe硬核的时钟来自外部参考时钟,通常是100MHz或125MHz;NVMe IP可能跑在250MHz或300MHz;用户逻辑又是另一个时钟域。跨时钟域处理必须做好,否则数据错乱、队列指针不同步,各种诡异问题都会出来。我的做法是用异步FIFO做数据缓冲,用双触发器同步器做控制信号同步,关键指针用格雷码编码。

3.2 MicroBlaze软核初始化流程与代码实现

MicroBlaze负责NVMe设备的初始化和命令下发。初始化流程大致如下:

  1. PCIe枚举:扫描PCIe总线,找到NVMe设备,读取Vendor ID和Device ID,确认是NVMe盘。然后配置BAR地址,使能Bus Master。
  2. Admin Queue创建:在DDR里分配Admin Submission Queue和Completion Queue的内存空间,把基地址写入NVMe控制器的Admin Queue Attributes寄存器。
  3. Identify命令:下发Identify Controller命令,获取SSD的基本信息(型号、固件版本、支持的队列数等);再下发Identify Namespace命令,获取命名空间信息(容量、LBA大小、支持的读写命令等)。
  4. IO Queue创建:根据Identify结果,创建IO Submission Queue和Completion Queue,配置队列深度和优先级。
  5. 开始读写:构造NVMe读写命令,填入PRP列表,更新SQ Tail Doorbell,等待Completion。

代码方面,我用的是Xilinx SDK(现在叫Vitis)写裸机程序。关键函数是nvme_admin_cmd()和nvme_io_cmd(),前者用于Admin命令,后者用于IO命令。每个命令的构造需要严格按照NVMe规范填写64字节的命令结构体,一个字段都不能错。

// NVMe命令结构体(简化版) typedef struct { uint8_t opcode; uint8_t flags; uint16_t command_id; uint32_t nsid; uint64_t reserved; uint64_t metadata; uint64_t prp1; uint64_t prp2; uint32_t cdw10; uint32_t cdw11; uint32_t cdw12; uint32_t cdw13; uint32_t cdw14; uint32_t cdw15; } nvme_command_t; // 构造读命令 void build_read_cmd(nvme_command_t *cmd, uint32_t nsid, uint64_t lba, uint32_t nlb, uint64_t prp1, uint64_t prp2) { cmd->opcode = 0x02; // Read cmd->nsid = nsid; cmd->prp1 = prp1; cmd->prp2 = prp2; cmd->cdw10 = (uint32_t)(lba & 0xFFFFFFFF); cmd->cdw11 = (uint32_t)(lba >> 32); cmd->cdw12 = (nlb - 1) & 0xFFFF; // Number of Logical Blocks }

实操心得:命令ID(command_id)必须唯一,并且跟Completion Queue里的完成项对应。我一开始用固定值,结果多个命令并发时Completion对不上号,数据写到了错误的位置。后来改成循环递增的ID,问题解决。

3.3 数据搬运模块与AXI-Stream接口对接

数据搬运模块是整个系统的“搬运工”,它从用户逻辑接收数据流,切成NVMe命令要求的块大小,然后通过AXI-Stream送给NVMe IP。这个模块的设计要点:

  • 数据位宽转换:用户逻辑可能是32位或64位,NVMe IP可能是256位或512位,需要做位宽转换。用Xilinx的AXI-Stream Data Width Converter IP可以搞定,但要注意FIFO深度,太浅会导致反压频繁,太深会消耗BRAM。
  • 数据缓冲:NVMe命令要求数据块大小通常是4KB的整数倍。如果用户数据流不是4KB对齐的,需要先缓冲再打包。我用了一个8KB的BRAM做缓冲,攒够4KB就发一个命令。
  • PRP生成:每个命令需要两个PRP条目(prp1和prp2)。prp1指向数据缓冲区的第一个物理页,prp2指向第二个页或者PRP List。如果数据超过两个页,prp2指向一个PRP List,List里再指向后续页。硬件需要根据缓冲区地址自动计算这些值。

AXI-Stream接口对接时,注意TREADY和TVALID的握手。NVMe IP的TREADY可能会因为SSD内部队列满而拉低,这时候数据搬运模块必须暂停发送,否则数据就丢了。我的做法是在数据搬运模块里加一个深度足够的FIFO,当FIFO快满时主动暂停用户逻辑的数据产生,形成反压。

3.4 性能测试方案与实测数据记录

性能测试我分了三组:顺序写入、顺序读取、随机4K写入。测试工具是自己写的FPGA逻辑,用一个计数器生成递增数据,写入SSD后再读回来比对,确保数据正确性。测试数据量是100GB,跑10次取平均值。

测试环境:

  • FPGA:Kintex-7 XC7K325T,PCIe Gen3 x8
  • SSD:Intel P4510 2TB,NVMe 1.3
  • 队列深度:64
  • 数据块大小:128KB

实测结果:

测试项带宽IOPS延迟
顺序写入3.12 GB/s24.4K42 us
顺序读取3.28 GB/s25.6K38 us
随机4K写入1.85 GB/s462K138 us
随机4K读取2.10 GB/s525K122 us

对比CPU方案(同款SSD,x86服务器,Linux内核NVMe驱动):

  • 顺序写入:3.15 GB/s(几乎一样,说明瓶颈在SSD)
  • 顺序读取:3.30 GB/s(几乎一样)
  • 随机4K写入:2.05 GB/s,512K IOPS(CPU略高,因为SSD内部FTL优化)
  • 随机4K读取:2.35 GB/s,588K IOPS(CPU略高)

结论:顺序读写场景,FPGA方案跟CPU方案打平,但FPGA方案的CPU占用率为零,延迟更稳定(没有上下文切换抖动)。随机小IO场景,CPU方案略优,因为SSD内部的FTL调度更适应CPU的访问模式。但FPGA方案的优势在于确定性延迟和零CPU开销,对于实时性要求高的场景,这点差距可以接受。

注意:测试时一定要监控SSD温度。NVMe盘在高负载下温度能到70度以上,过热会触发降速。我一开始没加散热片,跑了几分钟带宽就从3.1GB/s掉到1.5GB/s,加了散热片后才稳定。

4. 常见问题与排查技巧实录

4.1 PCIe链路训练失败与枚举异常

这是最常见的问题,现象是Vivado里PCIe硬核的LTSSM状态机卡在Polling或Configuration阶段,或者MicroBlaze扫描不到NVMe设备。

排查思路:

  1. 检查参考时钟:用示波器量PCIe参考时钟的频率和抖动,必须在规范范围内(100MHz ±300ppm)。我遇到过板卡晶振焊接不良,频率偏了500ppm,链路死活训练不起来。
  2. 检查复位信号:PCIe硬核的复位必须满足时序要求,PERST#信号要干净,不能有毛刺。我试过用普通GPIO做复位,结果因为毛刺导致链路训练随机失败,后来换成专用复位芯片才稳定。
  3. 检查Lane映射:x8的链路,如果Lane顺序接反了,链路会降级到x4甚至x1。用Vivado的IBERT工具可以扫描Lane极性,确认映射正确。
  4. 检查BAR配置:NVMe设备要求BAR0是64位可预取内存,BAR1是64位不可预取内存。如果BAR类型配错,枚举会失败。用lspci命令(在MicroBlaze Linux下)或者自己写扫描代码,确认BAR被正确分配。

实操心得:如果链路训练不稳定,可以先降速到Gen2调试,稳定后再升Gen3。Gen3对信号完整性要求高很多,PCB走线阻抗不匹配、过孔太多、连接器质量差都会导致训练失败。

4.2 队列卡死与Completion超时处理

队列卡死的现象是:命令下发后,Completion Queue里迟迟没有完成项,Doorbell更新了但SSD不响应。

排查思路:

  1. 检查Doorbell地址:NVMe的Doorbell寄存器在BAR0的特定偏移,每个队列的Doorbell地址是0x1000 + (qid * 8)(SQ Tail)和0x1000 + (qid * 8) + 4(CQ Head)。如果地址算错,Doorbell写到了错误的位置,SSD自然不响应。
  2. 检查命令格式:用ChipScope或ILA抓取AXI-Stream上的命令数据,逐字段比对NVMe规范。我遇到过cdw10的LBA字段填反了高低32位,导致SSD认为访问越界,直接丢弃命令。
  3. 检查PRP列表:如果数据跨页,prp2指向PRP List,List里的每个条目指向一个物理页。如果List地址不对,或者List里的页地址不对,SSD会返回PRP错误。用ILA抓取PRP List的内容,确认跟DDR里的实际物理地址一致。
  4. 检查中断/轮询逻辑:如果用轮询,确认Phase Tag位的翻转逻辑正确。NVMe的Completion Queue条目有一个Phase Tag位,初始为0,队列绕回一圈后翻转为1。如果Phase Tag判断错误,会漏掉完成项或者重复处理。

注意:队列卡死后不要直接复位SSD,先尝试更新CQ Head Doorbell,看能不能恢复。如果不行,再走Admin Queue的Controller Reset流程。直接断电复位SSD可能导致数据丢失。

4.3 带宽不达标的性能调优

带宽跑不满的原因很多,按优先级排查:

问题现象可能原因排查方法解决方案
带宽只有理论值一半PCIe链路降级读LTSSM状态和Link Status寄存器检查Lane映射和信号完整性
带宽波动大队列深度不足增加队列深度测试调到64或128
带宽随数据量增加而下降散热问题监控SSD温度加散热片或降低负载
带宽远低于SSD标称值数据块太小增大传输块到128KB或256KB调整数据搬运模块的打包大小
带宽不稳定,偶尔归零跨时钟域问题用ILA抓跨时钟域信号加异步FIFO和同步器

我踩过最坑的一个问题是:数据搬运模块的FIFO深度只有512字节,结果每次传输都要等FIFO填满,反压频繁,带宽只有1.2GB/s。后来把FIFO深度加到8KB,带宽直接跳到3.1GB/s。所以FIFO深度一定要够,别省那点BRAM。

4.4 常见问题速查表

问题现象快速排查解决
链路训练失败LTSSM卡在Polling量参考时钟、查复位换时钟源、加复位芯片
枚举不到设备MicroBlaze扫描无响应查BAR配置、查Lane映射修正BAR类型、调整Lane顺序
队列卡死Completion超时查Doorbell地址、查命令格式修正地址、逐字段比对规范
带宽不达标低于SSD标称值查链路速率、查队列深度、查FIFO深度升Gen3、加队列深度、加FIFO
数据错误读回数据跟写入不一致查PRP列表、查跨时钟域修正PRP、加同步器
SSD掉盘运行一段时间后设备消失查温度、查电源加散热、换电源

最后分享一个小技巧:调试NVMe的时候,一定要用ILA抓取PCIe TLP包。Vivado的PCIe硬核有内置的TLP监控接口,可以抓取所有进出SSD的TLP包。通过分析TLP包,你能看到NVMe命令是否真的发到了SSD,SSD是否返回了Completion,Completion的状态码是什么。这个手段比盲猜高效一百倍。我一开始不知道这个功能,调了一周都没进展,后来用了TLP监控,半小时就定位到是PRP地址算错了。

这个方案后续还可以这样扩展:把NVMe Host Controller IP和RDMA over Converged Ethernet(RoCE)结合起来,做成一个网络存储节点,前端通过以太网接收数据,后端直接写入NVMe SSD,全程零CPU拷贝。或者把多个NVMe SSD做成RAID 0,用FPGA逻辑做条带化,带宽能叠加到10GB/s以上。这些方向我都试过,有机会再单独写一篇分享。

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

macOS 下用 Luatools 烧录 LuatOS 固件:从驱动配置到串口调试全攻略

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

作者头像 李华
网站建设 2026/10/6 7:21:44

远程IO选型实战指南:EtherCAT/PROFINET/EtherNet/IP深度对比

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

作者头像 李华
网站建设 2026/10/6 7:21:20

从原理图到实测:手把手设计一块USB3.0四口集线器

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

作者头像 李华
网站建设 2026/10/6 7:21:19

基于STM32的仓库环境控制系统:温湿度粉尘监测与ESP8266上云实战

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

作者头像 李华
网站建设 2026/10/6 7:20:49

FPGA与PHY之间SGMII接口设计调试指南:从原理到实战

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

作者头像 李华
网站建设 2026/10/6 7:20:27

Altium Designer PCB安装孔设计全攻略:Pad、Via与Board Cutout详解

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

作者头像 李华