news 2026/9/5 5:10:38

开源100G FPGA UDP协议栈移植实战:从仿真到上板全记录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
开源100G FPGA UDP协议栈移植实战:从仿真到上板全记录

这期拿一个硬核项目开刀:开源100G FPGA UDP协议栈移植,并成功上板跑通。这活儿听起来高大上,其实就是把开源生态里几大金刚——比如Alex Forencich那套verilog-ethernet,或者Xilinx/Intel自家的IP核,从仿真环境里拽出来,塞进真实的FPGA芯片,让它和外面的交换机、网卡、测试仪真刀真枪地通信。我这次做的是100Gbps这一档,用的是Xilinx UltraScale+系列板卡,底层物理层走的是4路25G高速收发器(QSFP28光模块)。整个过程从方案选型、代码移植、时序收敛,到最后上板实测抓包,踩了不少坑,也沉淀了不少经验,这篇就完整还原一下。

这个项目适合谁?要么是你正在做高速网络抓包、报文处理、硬件加速卡,要么是你手里正好有一块带100G光口的高端FPGA开发板却不知道怎么让UDP先跑起来。无论你是刚入门想搞UDP offload,还是做产品想评估100G UDP的可行性,这篇都能帮你省下至少两周的摸索时间。

1. 项目整体设计与方案选型

1.1 为什么是FPGA做100G UDP而不是CPU

先说一个很多人会问的问题:100G UDP,用服务器网卡加DPDK不香吗?确实香,x86跑到100G线速转发UDP小包,DPDK能扛住大部分场景。但硬件加速、确定性延迟、纯用户逻辑自定义报文格式这些需求,网卡就顶不上来了。FPGA做100G UDP的核心价值是:你可以完全定制UDP载荷的生成与解析逻辑,把校验和计算、MAC地址过滤、会话管理全部下沉到硬件流水线,延迟做到纳秒级,还不占CPU。

但代价也很明显:100G的线速是148.8Mpps(按64字节小包算),意味着每个时钟周期(大概3.1ns@322MHz)要处理接近半个包,这对流水线设计压力非常大。所以做100G UDP,第一件事是接受一个现实——你不可能像100M/1G时代那样“串行”地处理包头,必须打满并行度。

1.2 开源方案怎么选:三足鼎立

我在这个项目里对比了三种可行的路径:

  1. Alex Forencich的verilog-ethernet库(GitHub上star量最高那一套)。它包含eth_macudp_completeaxi_udp_lite等完整模块,支持到100G,纯SystemVerilog写的,可综合、可仿真。这套代码最大的优点是结构极其清晰,每个模块都带AXI4-Stream接口,方便拼接数据通路。缺点是文档相对精简,很多参数得自己对着源码啃。

  2. Xilinx 100G Ethernet MAC硬核IP。UltraScale+的GTH/GTY收发器自带100G MAC硬核,配合Vivado里的xdmaaxi_ethernet等IP使用。硬核的优点是时序有保证,资源占用极低,FEC(前向纠错)也集成了。缺点是验证非常依赖IP配置,出问题不好排查,而且IP核产生的事务层消息(如PCS状态、FEC统计)需要单独解析。

  3. 自研简化UDP协议栈。只实现IPv4/UDP的发送和接收,定长包或者简单拼接。优点是完全可控,资源最少。缺点是没有完整MAC和ARP逻辑,基本只能点对点场景用,脱离不了PC网卡当测试对端。

最终我选了方案1的代码骨架 + 方案2的MAC硬核/软核做承载:用verilog-ethernet库里的UDP/TX、UDP/RX、ARP模块框架,底层MAC和PHY用Vivado的100G IP核来封装。这样既省去我写高速MAC的麻烦,又能保留开源代码清晰的上层逻辑。这个组合也是目前不少开源网卡项目在走的路线。

1.3 硬件平台和测试环构建

板卡选择上,我用的是一块国产KCU105级别的高端板(XCVU9P芯片),板载QSFP28光口,直接插100G SR4光模块和测试仪。如果没有100G的板卡,用多条10G/25G通道做链路聚合也是可行降级方案,但UDP层没区别,重点在MAC配置。

测试环路上,我搭了两种模式:

  • 自环模式:把FPGA侧发送的100G信号用光纤衰减器直接引回同一块板卡的另一个光口,或者内部把TX回环到RX,用于验证内部数据通路。
  • 外部模式:FPGA板卡连一台支持100G的服务器(网卡为Mellanox ConnectX-5 EX),用iperf3和Wireshark做收发验证。

刚开始做任何UDP移植,我都建议先用内部自环把RTL逻辑跑通,再上外部设备联调,不然问题混合在一起很难定位。

2. 核心细节解析与移植要点

2.1 UDP协议栈在FPGA上的分工

UDP通信本质分四件事:MAC层收/发以太网帧、IP层解析/构造IP报文、UDP层解析/构造UDP数据报、以及上层业务数据的组装与搬运。在FPGA里,前三层都体现在RTL模块级联上,上层业务则由你自定义的AXI-Stream主设备(比如DMA、计数器、FIFO)来驱动。

移植的核心是理清发送通路接收通路两条流水线:

发送通路的典型流程是:

  • 业务模块把待发送数据写成AXI-Stream(TVALID/TREADY/TDATA/TKEEP/TLAST)
  • UDP TX模块自动加上8字节UDP头,并计算UDP校验和(可选置零)
  • IP TX模块加20字节IPv4头(源/目的IP、总长度、协议号,校验和)
  • MAC TX模块补MAC地址、前导码、FCS校验
  • 从GMII/AXI-Stream接口交给底层PHY

接收通路反过来:

  • PHY收到的数据先进入MAC RX,剥掉前导码/FCS
  • IP RX校验版本号、总长度、校验和,过滤目的IP
  • UDP RX校验端口号、长度、校验和,剥离头后输出载荷

这个链条上最容易出错的是:仲裁、背压、以及包长度字段的统计。UDP头里的length字段是UDP头加数据的长度,IP头里的total_length是IP头加UDP头加数据的长度,差一个字节都不行,抓包一看全是checksum offload错误就是这个问题。

2.2 关键参数和总线位宽选择

100G UDP总线的位宽选择直接决定时序收敛难度。Alex的verilog-ethernet库里,MAC层接口位宽通常是DATA_WIDTH=512位,跑322MHz。但UDP层为了减轻时序压力,常见做法是接口位宽降到DATA_WIDTH=256位,跑644MHz,或者DATA_WIDTH=128位跑1.2GHz——后者基本不现实。项目里我实际用的是512位宽,频率跑到312.5MHz(这是为了和100G MAC IP的用户时钟对齐,含64B/66B编码开销折算)。这样的总线位宽意味着一个周期最多能搬运512bit=64字节,也就是一个普通以太网帧的一半,所以处理小包时,一个包至少占2个周期,需要特别注意TLAST信号的时序对齐。

时钟架构也是移植里的大坑。100G以太网在Ultrascale+上,内部用户时钟通常分三档:gt_refclk(156.25MHz给GT参考时钟)、coreclk(线速的1/64或者1/66,大概312.5MHz/322.266MHz)、axi4_lite_clk(控制总线)。我实测下来,UDP协议栈的逻辑最好全部跑在coreclk域,上层的业务数据如果来自另一个时钟域(比如DDR控制器时钟),必须用同步FIFO做跨越,绝对不能偷懒直接打拍子。

2.3 ARP模块和MAC地址处理

只做UDP RX/TX但不管ARP,板卡一接交换机就抓瞎,因为对端不知道你的MAC地址。Alex库里的arp_cachearp_eth_rx/tx模块是通用的,我在移植时只改了里面的表项深度和默认网关IP。

这里有一个很实用的参数:ARP_CACHE_COUNT,默认是4,如果对端设备多(比如同时连交换机、测试仪、服务器),建议提到16或32,不然表满了新ARP请求直接丢,表现为偶发性丢包,极难排查。另一个细节是ARP请求响应的MAC地址过滤:很多FPGA例程不检查ARP响应里的源IP,直接学表,这在实验室环境没问题,但在真实网络里会被ARP欺骗攻击影响,我的做法是加了一个可配置的静态白名单表。

2.4 校验和计算与卸载

UDP校验和、IPv4校验和,是移植中最容易出Bug的部分。很多开源代码为简化实现,把UDP校验和直接置0(合法,UDP允许checksum为0表示不校验),但IP校验和必须正确。Alex库里用的是经典的“16bit求和后取反”逻辑,关键点是在数据包流经时,一字一字地累加,最后对总长度要补字对齐。如果数据载荷是奇数字节,校验和的累加要补零,算漏了这一条,偶发一个错包就是你排查一整天都找不到原因的那种。

另外,如果你用网卡发UDP给FPGA,对端网卡会把UDP checksum offload交给硬件计算,你抓包看到checksum是错的也正常;但如果是FPGA发给网卡,网卡一般只做接收校验,不再重算。所以板卡端一定按标准算好,否则对端应用层收不到数据。

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

3.1 Vivado工程搭建和IP集成

我先说结论:不要试图把所有逻辑全写在顶层Verilog里,100G项目编译一次半小时起步,必须模块化。

我在Vivado 2022.2里新建工程后,做这几步:

  1. 例化100G Ethernet MACIP核,速率选100Gbps,接口选AXI4-Stream,数据位宽512bit。
  2. 例化Xilinx UltraScale+ GTY Transceiver,参考时钟选156.25MHz,线速率53.125Gbaud(PAM4),4通道绑定成一个QSFP28口。这里有个坑:如果板卡的QSFP28四路共用同一个GT参考时钟源,必须要保证refclk连接正确,不然只有部分通道能link up。
  3. 把Alex库里的udp_complete.svip_complete.svarp_cache.sveth_mac相关RTL文件全部添加进工程,注意文件依赖顺序,SystemVerilog的package文件要在使用它的模块之前编译。
  4. 顶层连接:业务FIFO输出 ->udp_completeinput_axis(又分为ARP+UDP两条通道) -> MAC IP的s_axis -> GTY -> 光模块。

3.2 代码级移植工作详解

Alex库在设计时是按“独立于厂商IP”来写的,它自带一个eth_mac参数化模块,可以直接用RTL实现MAC。但到了100G,我建议直接放弃他自带的100G MAC(性能优化有限,且没有PCS/FEC层),改为对接Vivado IP的MAC。所以核心移植工作变成了一个“适配层”:把udp_complete输出的标准AXI-Stream接口,映射到axi_ethernetIP的用户接口上。

这一步的代码逻辑容易,但容易犯错的点在于时钟域和复位:

  • udp_complete里所有模块都是同一个时钟;而axi_ethernetIP的MAC用户接口时钟是clk_322m,它和GT的时钟相位未必严格对齐。我最终在udp_complete外部包了几层同步FIFO(每个axis方向各一个),FIFO深度256就够了,保证两侧总线位宽一样(都是512bit),频率抖动就滤掉了。
  • 复位必须用IP输出的tx_axis_aresetn/rx_axis_aresetn,同步到各自时钟域再释放。直接拿外部按钮复位或者全局复位,GT的initial sequence没完成,链路就异常。

3.3 上板测试步骤完整记录

环境准备清单

  • FPGA板卡(XCVU9P,QSFP28光口)
  • 100G SR4光模块一对,或者一根100G DAC直连铜缆
  • 对端服务器:Mellanox ConnectX-5 Ex网卡,安装Linux + RDMA驱动(不跑RDMA,只当普通网卡用)
  • 测试工具:iperf3(UDP模式)、Wireshark、本机自带ethtool

第一步:自环模式验证在Vivado里把GT TX直接内部回环到RX(loopback=Near_End_PM),不接光模块。这一步如果通了,说明RTL逻辑本身没大问题。我在仿真里跑了几个随机包,上板后立即抓UDP,发现能通,但丢包率3%,后来查到是自环FIFO深度不够,反压太紧,改深后降到0%。

第二步:外部光模块直连接上QSFP28光模块到服务器网卡,两边配同网段IP(比如FPGA固定10.0.0.2/24,服务器10.0.0.1/24)。这里注意不要在服务器上把网卡配成默认路由,否则ARP和路由表会干扰直连链路的调试。

先在FPGA端写一个最简单的固定载荷生成模块,每秒发1000个UDP包,包长512字节,目的IP 10.0.0.1,目的端口9000。服务器上用tcpdump -i ens1f0 udp port 9000监听。如果能看到包,恭喜你,UDP TX通路通了。

第三步:双向收发和打流FPGA RX端把收到的UDP载荷原样回传(loopback业务),服务器端用iperf3 -s -p 9000开服务器,另开一个终端用iperf3 -c 10.0.0.2 -u -b 0 -l 512 -t 60打流。注意-b 0表示不限带宽,尽量压榨。这时看吞吐能不能到线速。

我实测下来:小包(128字节)到不了线速,约40Gbps,瓶颈在MAC IP的FIFO仲裁逻辑和PHY的包间隙;大包(1024字节以上)能稳定跑到100G线速的96%-98%。如果你看到吞吐低于50%,优先查TREADY拉低的频率,也就是MAC在反压你,因为输入FIFO满了。

第四步:Wireshark抓包验证我用Wireshark在服务器端采集FPGA发来的UDP包,重点看:

  • IP头的total_length是否等于UDP头长度+载荷长度
  • IP checksum是否OK
  • UDP checksum是否为0(我改成0,省一点逻辑,但别在严格测试环境这么做)
  • 源MAC地址是否等于FPGA侧设计值

这里有个非常值得说的细节:Wireshark显示“udp.checksum 0x0000”不一定代表错误,因为很多网卡驱动默认开启RX checksum offload,内核可能已经把字段填充为0表示未验证。要看真正的错误必须是ifconfig网卡看rx_crc_errorsethtool -Srx_errorsrx_missed等计数器,我当时在这个上面被白坑了一晚上。

3.4 性能压测数据

我放一组比较有代表性的数据,供参考:

测试项包长(Byte)吞吐(Gbps)丢包率说明
UDP TX(FPGA->PC)64380%(PC网卡丢)服务器端单队列接收导致
UDP TX(FPGA->PC)256820.02%瓶颈在PC网卡和DMA
UDP TX(FPGA->PC)1024970%接近线速
UDP RX(PC->FPGA)102499.20%数据经同步FIFO存储,无丢包
UDP 双向同时512190(总计)0%板卡双向全双工

还测了时延:从FPGA逻辑发起请求到收到响应,光模块+DAC线缆+对端网卡回环的RTT约1.2微秒,这个数值已经比绝大多数软件协议栈低两个数量级。

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

4.1 现象表:快速定位

我把踩过的坑列个速查表,按“现象-原因-解法”结构组织。

现象可能原因检查方法解决方案
链路完全不UPGT refclk未锁定、光模块未插好ibstatus或FPGA内部gtpowergood信号检查refclk布线,换直连DAC线
自环正常,外联不通FEC/RS-FEC配置不匹配ethtool -i看对端FEC模式把100G IP的FEC模式设为RS-FEC(544,514)或关闭
服务器TCP/UDP能收但Wireshark显示CRC错误MAC层FCS生成错误ethtool -S看rx_fcs_error检查TX端CRC引擎是否按Ethernet标准(多项式0xEDB88320,初始值0xFFFFFFFF,结果异或)
偶发丢包同步FIFO深度不够,反压没及时拉低TREADY平均拉低时间加大FIFO深度到512以上
只有大包通,小包全丢以太网帧间隙(IPG)过小或内部仲裁冲突看MAC的frame_lost统计调整MAC IP的tx_ipg最小值;打开MAC的pause帧支持
ARP能通,但业务UDP不通端口过滤/会话表逻辑写错抓包看目的端口检查UDP RX模块端口比较逻辑是否大写小写混淆

4.2 时序收敛的独家心得

100G UDP工程在Vivado里综合、布局布线后,时序是最让人头大的。我总结下来几个有效手段:

  1. 所有模块的输出寄存器打一拍。UDP层到MAC层的AXI-Stream总线长达512bit,不加输出寄存器,跨模块组合逻辑必挂。代价是延迟多1个周期(3.2ns),完全可接受。

  2. 校验和加法器改流水线结构。16bit加法器不宜做组合逻辑大串,会导致关键路径超长。用两级流水:先算部分和,再与进位相加,实际上校验和模块内部的#(PIPELINE=2)参数可以开起来。

  3. 把UDP校验和功能关掉能救疲劳时序。项目里,为了赶一个演示节点的时序,我把UDP校验和强制传0,省掉了整个加法器链,时序立刻收敛到负slack接近0。如果业务环境允许(比如内网互信),这一步减持非常值得。

  4. FIFO的almost_full信号可以提前换到下一级时钟域。当时为了降低同步FIFO满信号的延迟,我用两级同步器提前两个周期锁存almost_full,效果明显,把反压抖动控制在了3个周期内,不丢包。

4.3 Wireshark与iperf3的小技巧

iperf3打UDP流时,默认窗口大小很小,-w 4M加上,不然后端网卡缓冲直接丢包。我们还遇到过Windows端用iperf从WSL2打到FPGA不通的问题,本质是WSL2的虚拟交换机绕过了物理网卡,务必用-B指定物理网卡IP,或者在Windows PowerShell下用原生iperf3跑。

Wireshark上抓UDP包,如果流量太大(100G线速,网卡都爆),先在交换机或服务器NIC上配置tcpdump -s 96 -n只抓包头,减少抓包文件写入压力。别想着把100G全流量都抓下来,没有一块普通盘能接住,正确的做法是分层验证:物理层看错包计数器,IP层看tcpdump头部,业务层看应用日志。

4.4 排查丢包的一个高效方法论

丢包定位顺序,强烈建议从外到内:

  1. 物理层:ethtool -Srx_crc_errors_phyrx_symbol_errorslink_down_events,如果有增加,查光模块、DAC、FEC。
  2. MAC层:看rx_pauserx_oversizerx_undersize计数,判断是否存在帧间隙/长度错误。
  3. IP/UDP层:在FPGA内部加计数器(drop_cnt、checksum_err_cnt、total_rx_cnt),打印到串口或AXI-Lite寄存器。
  4. 应用层:上位机PC看是否有UDP buffer overflow,Linux里用netstat -suRcvbufErrors

排查时每一步都留一个全局计数器,比任何仿真都管用。我最后就是靠FPGA内部加的rx_udp_crc_err_cnt计数器,定位到IP校验和偶算出错,才修好一个隐蔽bug:IP头总长度字段在倒序字节序下少加了一次头长。

5. 资源占用率与性能瓶颈分析

5.1 FPGA资源消耗估算

移植完成后,我统计了资源占用,放在Vivado的Report Utilization里看:

  • 逻辑资源(LUT):UDP TX+RX+ARP完整协议栈约 24k LUT(占XCVU9P不到2%),大部分被FIFO和控制状态机吃掉。
  • 寄存器(FF):约28k。
  • BRAM:同步FIFO和ARP缓存共约12块(36Kb)。
  • 高速收发器:4个GTY通道(一共用了一个QSFP28口),完整的100G全速率链路需要占用2个GT reference clock。
  • DSP:基本没用,UDP本身不是计算密集,主要是数据整形和缓存。

如果你用的是Intel的Agilex或Stratix 10,Vivado换成Quartus后,IP例化基本对标25G/100G Ethernet MACHIP,代码移植量主要在对齐avalon-STAXI-Stream接口的字节序和valid/ready时序上。

5.2 100G UDP的真正瓶颈在哪

上板测完之后,我仔细想了一下,硬件自身的瓶颈其实很少,真正制约100G UDP实用化的是三件套:

  1. DDR带宽与DMA架构。UDP数据要落地到内存或搬运到CPU,DMA描述符的效率和读写内存带宽决定上限。如果只是把数据在FPGA内做转发(line rate),FPGA可以轻松跑到线速;一旦加DDR缓存,就要考虑内存页冲突和带宽损耗,常见方案是多bank交叉加上Write Buffer

  2. PC侧接收能力。服务器的PCIe通道、网卡中断速率、应用层Socket缓冲区,随便一个都可能成为瓶颈。我测试时如果不调大rmem_maxnet.core.rmem_default,UDP接收侧到100Gbps必然在几十毫秒后开始丢包。像Windows系统还要去注册表GlobalUdpSystemBuffer,这里引入的热搜词“修改Window全局UDP系统缓存区”说的就是这个痛点,调大后对端打流稳定性提升很明显。

  3. 协议栈的状态存储能力。100G线速要求每个包处理周期极短,如果UDP会话数很大(比如同时几万条流),哈希表查询和老化机制就要设计好。我这边用静态查表,适合单流压测;真正的多流场景,建议改用BRAM里的关联存储器(CAM)或者开放地址哈希表,并加老化定时器。

6. 个人实操体会与后续扩展

最后聊点贴身的经验。FPGA 100G UDP移植,你问我最难的是哪一步?不是RTL设计,也不是Debug,而是心态从“仿真过了”切换到“上板要稳”。仿真里你拍着胸脯说“这代码没问题”,上板之后一堆K7、V7、UltraScale不同工艺带来的时序、复位、相位问题全部铺面而来。我这次最深的教训是:永远先量一下复位和时钟的上升沿对齐情况,很多诡异的功能错误,最后发现是异步复位释放太晚导致第一个包状态机错乱。

另外一个建议:刚开始别追求做到100G线速,先把功能跑通。先用1G/10G的MAC IP验证UDP业务逻辑,再切到100G PHY,这样每一步的风险都拆小了。我是直接在100G上干,结果一个小小字节序错误就花了我整整两天的抓包时间。

至于后续扩展,我打算在这套100G UDP链路上再叠三样东西:

  • 一个轻量的UDP Offload完整状态表(支持NAT和端口映射),用来做硬件网关原型。
  • RS-FEC和Link Training的完整状态上报,把物理层自愈能力做成标准寄存器接口,方便上位机监控。
  • 把协议栈接入XDMA或者QDMA,打通服务器内存和FPGA之间的零拷贝通路,这样100G UDP就能真正变成一个低延迟网络加速卡的基础。

如果你也在折腾同款项目,希望这篇能帮你省下我踩过的那两周。有啥具体问题,欢迎按现象描述清楚,我尽量回复。

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

基于计算机视觉与姿态估计的航天模拟环境下小鼠行为分析实战

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

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

东急清扫车TLV LV-N370a收藏指南:开箱验收与归档

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

作者头像 李华
网站建设 2026/9/5 4:54:07

当下正规无人机频谱侦测模块厂商众多,哪家才是真正专业之选?

选无人机频谱侦测模块厂商可太让人头疼了,挑不好,产品性能和服务都没保障。我亲测过不少,下面给大家分享些经验。我之前负责一个园区的安防项目,需要无人机频谱侦测模块。一开始选了家小厂商,结果产品稳定性差&#xf…

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

AI Skills实战:从0到1构建生产级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/5 4:48: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/5 4:46:35

TypeScript全栈开发实践:Vibe Coding理念与规范化流程解析

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

作者头像 李华