news 2026/9/24 12:55:39

从零设计AI加速器:FPGA实现矩阵运算与存储优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零设计AI加速器:FPGA实现矩阵运算与存储优化实战

1. 为什么我要自己造一块AI加速器

2023年的时候,我在一个边缘计算项目里被一块推理卡坑得很惨。模型本身只有几兆大小,算力需求也不算夸张,但市面上能买到的通用方案要么功耗高得离谱,要么延迟抖动大得没法做实时控制。那段时间我反复在想一个问题:为什么不能针对自己的模型结构,做一块专用的加速器?

这个念头一旦冒出来就压不住了。后来花了大概四个月,从指令集定义、矩阵运算单元设计、存储层次规划,到用FPGA做原型验证,再到跑通一个简化版的卷积网络,算是把整条链路走了一遍。这篇文章就是把这四个月里踩过的坑、做过的取舍、以及那些教科书上不会写的细节,完整地摊开来讲。

先说清楚定位:这不是一篇教你流片做芯片的文章,那需要千万级预算和完整的EDA工具链。这里讲的是一条从零开始、以FPGA为载体的AI加速器设计路径,核心是理解加速器的架构逻辑,用可综合的硬件描述语言把矩阵计算单元、数据搬运通路、控制状态机搭出来,最终能在真实硬件上跑通推理。

适合谁看?如果你写过Python训练模型,但对硬件设计只有模糊概念,这篇文章会帮你建立从算法到电路的映射思维。如果你已经做过FPGA开发,但没接触过AI加速器,这里面的数据复用策略和脉动阵列设计思路会很有参考价值。如果你只是好奇手机里的NPU到底在干什么,看完你会有个具象的认知。

整个设计围绕一个核心矛盾展开:矩阵乘法需要极高的数据吞吐,但存储器的带宽和容量永远是瓶颈。AI加速器的本质,就是在算力、带宽、面积、功耗这四个维度上找平衡点。后面所有的架构决策,都是这个矛盾的展开。

2. 先想清楚算什么东西:从矩阵乘法到硬件映射

2.1 神经网络推理的数学本质

不管网络多复杂,推理阶段的核心计算就是矩阵乘加。一个全连接层是Y = WX + b,一个卷积层展开后也是矩阵乘法。区别只在于数据怎么排布、有没有权重共享。

拿一个最简单的卷积层举例:输入特征图是H×W×C,卷积核是K×K×C×N,输出是H'×W'×N。如果把它看成矩阵运算,可以把每个输出像素位置对应的输入窗口拉成一个向量,卷积核拉成矩阵,就变成了输出矩阵 = 权重矩阵 × 输入矩阵

这个视角转换非常关键。因为一旦看成矩阵乘法,硬件设计就有了统一的优化目标:让乘加运算单元尽可能不停地工作,同时让数据搬运的代价尽可能小

我当时的做法是先统计目标模型的算子分布。跑了一个轻量级的图像分类网络,发现卷积层占了总计算量的92%以上,全连接层不到8%。这意味着加速器的设计重心必须放在卷积上,全连接层可以复用同一套矩阵运算单元,不需要单独优化。

2.2 为什么不用CPU或GPU而要自己设计

这个问题我被问过很多次。CPU的问题在于它的架构是为通用性设计的,大量的面积花在了分支预测、乱序执行、多级缓存一致性上,真正做乘加运算的ALU占比很小。跑一个矩阵乘法,CPU大部分时间在搬数据和控制流程,算力利用率可能只有个位数百分比。

GPU好很多,它有大量的流处理器可以做并行乘加。但GPU的功耗和面积对于边缘设备来说还是太大,而且它的编程模型是SIMT,需要把矩阵运算拆成成千上万个线程,调度开销和寄存器压力都不小。更关键的是,GPU的存储层次是固定的,你没法针对特定模型的数据复用模式做定制。

NPU的思路就是把这些通用性全部砍掉,只保留矩阵运算需要的最小控制逻辑,把面积和功耗全部堆到乘加阵列和片上存储上。我实测下来,同样一个卷积层,在FPGA上做的专用加速器比同期的嵌入式GPU方案,能效比大概高出三到五倍。当然这个数字跟具体实现和工艺有关,但方向是明确的。

2.3 确定加速器的能力边界

在动手写第一行代码之前,必须先把加速器的规格定死。我给自己列了一张表:

设计维度我的选择理由
支持算子卷积、全连接、池化、ReLU覆盖目标网络全部算子
数据精度INT8量化精度损失可接受,面积和功耗大幅降低
峰值算力约0.5 TOPSFPGA资源限制下的合理目标
片上存储输入缓存+权重缓存+输出缓存减少对片外存储的访问
接口AXI4方便与处理器和DMA对接
时钟频率100MHzFPGA时序收敛的稳妥选择

这张表看起来简单,但每一项背后都有取舍。比如为什么选INT8而不是FP16?因为FP16的乘法器面积大概是INT8的四倍,而目标模型的量化精度损失在1%以内,完全能接受。为什么峰值算力定0.5 TOPS?因为我用的FPGA有大约2000个DSP切片,每个切片一个时钟周期做一个乘加,100MHz下理论峰值就是0.4 TOPS左右,定0.5是留了点余量。

这里有个经验:规格不要定得太满。我第一次设计时想把所有算子都支持,结果控制逻辑复杂到状态机根本写不下去。后来砍掉了转置卷积和空洞卷积,只保留最核心的几种,反而顺利跑通了。

3. 矩阵运算单元:加速器的心脏怎么搭

3.1 从单个乘加器到脉动阵列

最朴素的矩阵乘法硬件实现,就是一堆乘加器排成一行,每个时钟周期取一行权重和一组输入,算完累加。但这样有个致命问题:权重和输入数据需要反复从存储器读取,带宽根本扛不住。

脉动阵列的思路是让数据在计算单元之间流动,而不是每次都从存储器取。想象一排工人站在传送带旁边,每个工人手里拿着一个权重,输入数据像流水一样从左边传进来,每经过一个工人就做一次乘加,结果往右边传。这样输入数据只需要从左边进一次,权重只需要预先加载一次,数据复用率极高。

我设计的阵列是8×8的,也就是64个乘加单元。每个单元包含一个INT8乘法器、一个32位累加器、以及若干寄存器用于暂存数据和权重。阵列的输入是8个激活值和8个权重,输出是8个部分和。

为什么选8×8?因为FPGA的DSP切片数量有限,64个乘加单元已经占用了大部分资源。而且8×8的阵列对应的数据位宽和缓存大小比较匹配,再大就会导致缓存溢出,反而降低效率。

3.2 数据流设计:权重固定还是输出固定

脉动阵列有三种经典数据流:权重固定、输出固定、行固定。我选的是权重固定,原因是卷积层的权重可以预先加载并保持不变,而输入数据不断变化。权重固定意味着权重只需要在换层时重新加载,推理过程中不需要反复搬运权重,节省了大量带宽。

具体实现上,每个乘加单元有一个权重寄存器,在层初始化阶段通过配置通路把权重写进去。推理阶段,输入数据从阵列左侧流入,每个周期向右移动一格。部分和从阵列底部流出,进入累加器。

这里有个细节:部分和的位宽。INT8乘INT8的结果是16位,但累加多个乘积后位宽会增长。我用了32位累加器,理论上可以累加65536个乘积而不溢出。对于目标网络的卷积核大小,这个位宽绰绰有余。

3.3 乘加单元的电路细节

单个乘加单元的结构其实不复杂,但有几个地方容易出错。

乘法器我用的是Booth编码的INT8乘法器,综合后大约占用一个DSP切片。累加器是一个32位加法器,带一个寄存器。关键路径在乘法器和加法器之间,需要插入流水线寄存器来保证时序收敛。

我一开始没加流水线,综合后时序报告显示最高频率只能跑到60MHz左右。后来在乘法器和累加器之间插了一级寄存器,频率直接上到120MHz。代价是延迟增加了一个周期,但对于推理任务来说,吞吐量比延迟重要得多。

还有一个坑是符号扩展。INT8是有符号数,乘法结果需要正确地进行符号扩展后再累加。我第一版忘了处理负数,导致推理结果完全错误。后来在乘法器输出后加了一个符号扩展逻辑,问题解决。

// 乘加单元的核心逻辑(简化版) module mac_unit( input clk, input rst_n, input signed [7:0] activation, input signed [7:0] weight, input signed [31:0] partial_sum_in, output signed [31:0] partial_sum_out ); reg signed [15:0] product; reg signed [31:0] accum; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin product <= 16'b0; accum <= 32'b0; end else begin product <= activation * weight; accum <= partial_sum_in + {{16{product[15]}}, product}; end end assign partial_sum_out = accum; endmodule

这段代码里{{16{product[15]}}, product}就是符号扩展,把16位乘积扩展到32位。少了这个,负数乘法就会出错。

4. 存储层次:比算力更关键的瓶颈

4.1 为什么存储设计决定加速器上限

有个数据很能说明问题:在典型的矩阵乘法中,每做一次乘加运算,需要读取两个操作数。如果操作数都从片外DRAM读取,那么算力再高也没用,因为DRAM带宽根本喂不饱计算单元。

我算过一笔账:8×8阵列在100MHz下,每秒需要8×8×100M = 6.4G次乘加。每次乘加需要两个INT8操作数,就是12.8GB/s的数据吞吐。而一块普通的DDR3带宽大概在8GB/s左右,根本不够。所以必须靠片上缓存来复用数据,把片外访问降到最低。

这就是存储层次设计的核心目标:让数据在片上多待一会儿,被尽可能多的计算单元复用

4.2 输入缓存、权重缓存与输出缓存的分工

我设计了三块片上缓存:

  • 输入缓存:存放当前层的输入特征图。大小是16×16×32字节,对应一个较大的输入窗口。输入数据从片外一次性搬进来,然后在阵列计算过程中反复读取。
  • 权重缓存:存放当前层的权重。大小是8×8×32字节,对应阵列一次能处理的权重块。权重在层初始化时加载,推理过程中只读。
  • 输出缓存:存放部分和和最终输出。大小是16×16×32字节。部分和在阵列计算过程中累加,最终结果写回片外。

三块缓存都用双端口BRAM实现,一个端口给计算阵列读,一个端口给DMA写。这样数据搬运和计算可以并行,不会互相阻塞。

缓存大小的选择有个经验公式:输入缓存至少要能放下阵列一次计算所需的全部输入数据,否则阵列会频繁等待数据。我选的16×16×32对应的是阵列处理一个16×16输出块所需的输入窗口,刚好匹配。

4.3 数据复用策略:卷积窗口的滑动技巧

卷积计算有个天然的数据复用机会:相邻的输出像素共享大部分输入数据。比如一个3×3卷积,输出像素(i,j)(i,j+1)的输入窗口有6个元素是重叠的。

我的做法是在输入缓存里维护一个滑动窗口。当阵列计算完一个输出块后,窗口向右滑动,只需要从片外加载新进入窗口的那一列数据,而不是重新加载整个窗口。这样片外访问量能降低到原来的三分之一左右。

权重那边的复用更简单:同一层的权重对所有输出位置都是一样的,所以权重缓存只需要在层切换时更新一次。这也是权重固定数据流的优势所在。

实测数据:用了滑动窗口复用后,片外带宽需求从12.8GB/s降到了4.2GB/s左右,普通的DDR3就能满足。这个优化对整体能效比的贡献,比单纯增加计算单元还要大。

5. 控制逻辑与指令调度:让数据流听话

5.1 状态机设计:从加载到写回的全流程

加速器的控制核心是一个有限状态机,管理整个推理流程。我把它分成五个状态:

  1. IDLE:等待启动信号。
  2. LOAD_WEIGHT:从片外加载当前层的权重到权重缓存。
  3. LOAD_INPUT:从片外加载输入数据到输入缓存。
  4. COMPUTE:启动脉动阵列,进行矩阵乘加,部分和写入输出缓存。
  5. WRITE_BACK:把输出缓存的结果写回片外,然后跳转到下一层或回到IDLE。

状态之间的跳转条件需要仔细设计。比如COMPUTE状态什么时候结束?我的做法是维护一个计数器,记录已经完成的输出块数量,当计数器达到当前层的总块数时,跳转到WRITE_BACK。

这里有个容易忽略的点:层与层之间的依赖。下一层的输入是上一层的输出,所以必须等WRITE_BACK完成后才能开始下一层的LOAD_INPUT。我在状态机里加了一个层计数器,确保顺序执行。

5.2 指令集设计:用微码描述每一层

为了让加速器能灵活支持不同的网络结构,我设计了一套简单的微指令。每条指令描述一个层的配置:

字段位宽含义
opcode4算子类型(卷积/全连接/池化)
input_addr16输入数据在片外的起始地址
weight_addr16权重在片外的起始地址
output_addr16输出数据在片外的起始地址
input_size16输入特征图尺寸
kernel_size8卷积核尺寸
stride4步长
channel_in8输入通道数
channel_out8输出通道数

这些微指令在推理开始前由主控处理器写入加速器的指令缓存。加速器逐条读取指令,配置状态机,执行对应的层。这样换一个网络只需要重新生成微指令序列,硬件不需要改动。

微码的好处是灵活,坏处是需要额外的指令缓存和译码逻辑。我权衡后觉得值得,因为如果每换一个网络都要重新综合硬件,那开发效率太低了。

5.3 流水线冲突与数据冒险的处理

脉动阵列是深度流水线结构,数据从输入到输出要经过多个周期。如果控制逻辑没有处理好,就会出现数据冒险。

我遇到的一个典型问题是:当阵列还在计算上一个输出块时,输入缓存已经被新数据覆盖了。原因是LOAD_INPUT和COMPUTE两个状态没有正确同步。后来我加了一个双缓冲机制:输入缓存分成两块,一块给当前计算用,一块给下一块数据加载用。当计算完成时,切换缓冲区的角色。

权重缓存也有类似问题,但因为权重只在层切换时加载,冲突概率低很多。我的做法是在LOAD_WEIGHT状态时暂停阵列,等权重加载完成后再启动COMPUTE。

这些同步逻辑写起来很琐碎,但少一个就会导致结果错误。我的建议是画一张完整的时序图,把每个状态下的数据流向都标清楚,然后再写代码。

6. 用FPGA把设计跑起来:从仿真到上板

6.1 仿真验证:先让行为模型跑通

在写可综合代码之前,我先用Python写了一个行为级的模拟器。这个模拟器不关心时序,只验证算法逻辑:给定输入和权重,计算输出,和PyTorch的结果对比。

这一步非常关键。因为硬件调试的成本远高于软件,如果算法逻辑本身有问题,上板后根本无从查起。我的模拟器大概200行Python,实现了卷积、池化、全连接的前向计算,以及INT8量化。

量化是个容易出问题的地方。我用的对称量化,把浮点权重和激活值映射到[-127, 127]的整数范围。缩放因子根据每层的最大值动态计算。模拟器里跑出来的精度损失在0.8%左右,可以接受。

模拟器跑通后,我用Verilog写了一个对应的行为级模型,用相同的测试向量验证。两个模型输出一致后,才开始写可综合的RTL代码。

6.2 综合与实现:时序收敛的实战技巧

综合的时候遇到了几个典型问题。

第一个是资源超限。8×8阵列加上三块缓存,BRAM和DSP的使用率都超过了80%。我不得不把输入缓存从16×16×32缩小到12×12×32,牺牲了一点数据复用率,换来了资源余量。

第二个是时序违例。关键路径在乘法器和累加器之间,组合逻辑延迟太大。我插了两级流水线寄存器,把路径切成三段,频率从60MHz提到了110MHz。代价是阵列的填充延迟增加了两个周期,但吞吐量提升明显。

第三个是布线拥塞。脉动阵列的互连非常密集,布局布线工具经常报拥塞。我的解决办法是给阵列加区域约束,让它集中在一个区域,减少长距离布线。另外把权重加载通路和激活值通路分开布局,避免互相干扰。

综合报告里我重点关注三个指标:LUT使用率、DSP使用率、BRAM使用率。目标是都控制在85%以内,留出余量给后续修改。最终实现下来,LUT用了72%,DSP用了81%,BRAM用了78%,算是比较健康的水平。

6.3 上板实测:性能与功耗的平衡

上板测试用的是Xilinx的Zynq开发板,PS端跑Linux,负责加载数据和启动加速器,PL端就是我的加速器设计。

实测性能:在100MHz时钟下,跑一个约50M MAC的卷积网络,耗时约12ms,等效算力约0.42 TOPS。功耗方面,PL端动态功耗约1.8W,加上PS端总共约3.5W。能效比大约是0.12 TOPS/W。

这个数字跟专用ASIC比差很远,但考虑到FPGA的工艺和架构限制,已经达到了我的预期。更重要的是,整个设计流程走通了,从算法到硬件到实测,每个环节都摸清楚了。

一个实测发现:输入缓存的命中率对性能影响极大。当输入特征图尺寸不是缓存大小的整数倍时,滑动窗口会频繁失效,导致片外访问量激增。后来我在数据排布时做了padding,让每层输入都对齐到缓存边界,性能提升了约20%。

7. 踩过的坑与设计迭代中的教训

7.1 量化精度损失比预想的大

第一版设计我用了INT4量化,想着面积能再小一半。结果跑出来的分类准确率掉了15个百分点,完全不可用。后来分析发现,INT4对权重分布的表示能力太弱,很多小权重直接被量化成零。

改回INT8后精度恢复。但我也学到了一件事:量化位宽不是越小越好,要看权重的实际分布。如果权重集中在零附近,低位宽会丢失大量信息。后来我加了一个逐通道的缩放因子,让每个输出通道有自己的量化参数,精度又提升了0.3个百分点。

7.2 状态机的死锁问题

调试阶段遇到过一次死锁:加速器卡在COMPUTE状态出不来。用逻辑分析仪抓信号发现,输出块计数器的值比预期少了一个,导致状态机永远等不到完成条件。

根因是边界处理。当输出特征图的尺寸不能被阵列大小整除时,最后一块的输出块尺寸会小于阵列大小。我的计数器逻辑没有处理这种情况,导致计数错误。修复方法是在微指令里增加一个"有效输出块数"字段,状态机根据这个字段判断是否完成,而不是根据理论计算值。

这个坑让我意识到:硬件设计里所有边界条件都必须显式处理,不能像软件那样靠运行时判断。因为硬件没有异常处理机制,一个边界错误就会导致整个系统挂死。

7.3 带宽估算与实际差距

设计初期我估算片外带宽需求是4GB/s,选了DDR3。但实测发现峰值带宽需求到了6GB/s,DDR3偶尔会成为瓶颈。

原因是我的估算没有考虑DMA的效率损失。实际DMA传输有握手开销、仲裁延迟、刷新周期,有效带宽大概只有理论值的70%。后来我把输入缓存加大,提高了数据复用率,才把峰值带宽压回4GB/s以内。

教训是:带宽估算要留至少50%的余量,因为实际系统中的开销远比理论模型复杂。

8. 如果重新来过:架构优化的几个方向

8.1 从8×8到16×16:阵列扩展的代价

如果FPGA资源允许,把阵列从8×8扩展到16×16,峰值算力能翻四倍。但代价是互连复杂度呈平方增长,布线拥塞会非常严重。而且缓存大小也要相应增加,否则数据供应跟不上。

我的建议是不要盲目扩大阵列。先算清楚缓存带宽能不能支撑,再决定阵列大小。一个经验法则是:阵列的输入带宽需求不能超过缓存带宽的80%,否则阵列会经常饿死。

8.2 加入稀疏化支持:跳过零权重

目标网络量化后有很多零权重,如果能跳过这些零,算力利用率能提升不少。实现方法是在权重缓存里加一个有效位掩码,阵列遇到零权重时跳过乘加。

但这样会增加控制逻辑的复杂度,而且稀疏模式不规律时,跳过逻辑本身的开销可能抵消收益。我试过一版,在稀疏度50%的情况下,性能只提升了15%左右,不太划算。如果稀疏度能到80%以上,可能值得做。

8.3 多核架构:用多个小阵列替代一个大阵列

另一个思路是用四个4×4的小阵列替代一个8×8的大阵列,每个小阵列独立处理不同的输出通道。这样互连更简单,布线更容易,而且可以灵活分配任务。

缺点是权重需要复制多份,缓存面积增加。而且多个阵列之间的同步需要额外逻辑。我目前还在评估这个方案,初步仿真显示在通道数较多的层上,多核架构的效率更高。

9. 一些实操层面的建议

如果你也想动手做一块自己的AI加速器,我有几个具体的建议。

先从仿真开始,不要急着上板。用Python或C++写一个行为模型,把算法逻辑验证清楚。硬件调试的时间成本是软件仿真的十倍以上,前期多花时间在仿真上,后期能省大量调试时间。

规格要砍到最小可用。不要想着支持所有算子,先支持一个卷积和一个全连接,跑通一个最简单的网络。我第一版想支持七种算子,结果三个月都没跑通。后来砍到两种,两周就上板了。

存储设计比计算设计更重要。花在缓存大小、数据复用、带宽估算上的时间,应该比花在乘加单元上的时间多。计算单元是规则的,存储访问模式是不规则的,后者才是难点。

时序收敛要提前考虑。写RTL的时候就要想好哪里插流水线,不要等综合报违例了再改。关键路径通常在乘法器、加法器、多路选择器这些组合逻辑上,提前规划好流水线级数。

留足资源余量。FPGA资源用到90%以上时,布局布线会变得极其困难,时序也很难收敛。控制在80%左右比较稳妥,给后续优化留空间。

最后说一个心态上的体会:设计加速器是个系统工程,涉及算法、架构、电路、工具链多个层面。不要指望一次成功,迭代是常态。我前后改了五版才达到可用的状态,每一版都解决了上一版暴露的问题。这个过程本身,比最终的性能数字更有价值。

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

PCA9546A:I2C总线复用器的原理、选型与实战避坑指南

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

作者头像 李华
网站建设 2026/9/24 12:54:32

eMMC调试利器mmc_utils:20+实用命令全面解析与实战

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

作者头像 李华
网站建设 2026/9/24 12:53:48

YT8521SH网络调试实战:RGMII时序与LED配置避坑指南

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

作者头像 李华
网站建设 2026/9/24 12:53:29

STM32上搭建Zephyr RTOS开发环境:从零开始点亮板载LED

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

作者头像 李华
网站建设 2026/9/24 12:52:29

AI编程工具选型指南:Cursor、Trae、OpenCode核心定位与实战边界

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

作者头像 李华
网站建设 2026/9/24 12:51:50

UPS配电三要素匹配:空开、线缆、蓄电池闭环校验

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

作者头像 李华