news 2026/10/17 1:29:47

SystemVerilog数组约束实战:foreach、sum与unique破解随机化难题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SystemVerilog数组约束实战:foreach、sum与unique破解随机化难题

随机化是 SystemVerilog 验证方法学里最能提效、也最容易翻车的部分,而数组的约束又是随机化里的“高危地带”。绿皮书第六章讲随机化,前面几篇都在聊标量变量的约束,到了数组这里,很多同学就从“能写”变成“能抄但不懂”,一遇到求解失败或者性能爆炸就完全懵了。这篇文章把数组约束的底层逻辑、三种常用工具、完整实操过程和我踩过的坑一次性讲透,看完你至少能回答三个问题:为什么数组约束容易不可解?foreach 里到底该不该写 size()?遇到大数组怎么把激励质量和仿真速度都保住。适合刚接触随机化验证的初级工程师,以及正在被约束求解器折磨的中级同学。

1. 数组约束的底层逻辑与应用场景

1.1 为什么数组约束是随机化中的硬骨头

数组约束和标量变量约束最大的区别在于:标量变量只需要求解一个确定域内的取值,而数组不仅要管每一个元素,还要管数组本身的长度,甚至要管元素和元素之间的相对关系。这就让约束求解器从“解一个方程”变成了“解一组联立方程”,复杂度呈指数上升。

更麻烦的是,SystemVerilog 的约束求解是双向的。你在一个约束里同时写data.size() == len和foreach (data[i]) data[i] < 10,求解器会认为这两个条件必须同时成立,也就是说它在解一个所有变量都联动的问题,不是一个变量一个变量按顺序填。很多人习惯用写 C 代码的思维去理解约束,觉得“先确定长度,再逐元素生成内容”,实际情况完全不是这样。求解器眼中,数组长度、元素值、元素个数之间没有先后顺序,全部是并行求解的关系。

把数组约束想成一个拼图游戏:每一条约束都是在限制拼图的某一部分形状,而求解器要找出一种整体拼法,让所有形状同时吻合。标量约束像拼图只要求中间那一片,数组约束则要求整幅拼图同时成立。理解了这一点,后面遇到求解失败、约束冗余、性能骤降,你就能快速定位问题出在“约束之间打架”还是“约束本身太强”。

1.2 典型应用场景

数组约束在真实项目中几乎是绕不开的,最常见的几类场景:

第一个是网络包、事务类对象的生成。验证一个路由器或交换芯片,报文长度、payload 内容、扩展字段都要有合理的随机分布,同时又必须满足协议规定的边界。比如以太网帧最短 64 字节、最长 1518 字节,payload 长度约束和内容约束就要一起写。

第二个是存储器、FIFO、Cache 的场景。初始化一块内存、生成一组写入地址,或者构造一个队列的填充状态,都需要对“一组数据”做约束。比如要求队列深度在一定范围内、队列中的地址互不重复、队列整体占用率不低于某个阈值。这些需求用数组约束表达最直接。

第三个是退避算法、仲裁策略、调度器的验证。要生成一组按优先级排列的请求,或者生成一组时间戳单调递增的事件序列,元素间的相对关系约束就派上了用场。绿皮书里那个经典的“数组元素递增长”例子,放到任何排序、仲裁场景里都能直接复用。

第四个是寄存器配置表、地址映射表的生成。比如一张页表项数组,每一项取值要在几个合法候选之间,且整张表的关键字段不能冲突。这类场景数组长度一般固定,但元素值域和互斥关系复杂,同样需要数组约束来保障激励质量。

2. 数组约束的三大核心工具

2.1 size():先给数组定个“骨架”

size()是数组约束的入场券。只要数组是动态数组或者队列,并且声明为rand,你就能通过约束它的size()来控制数组长度。这是所有数组约束里最基础、也最容易被忽略的一点:如果不对size()做任何约束,求解器会在合法范围内随机决定数组长度,同一个测试点跑两次结果完全不同。这在某些场景下是好事,但在需要稳定复现的回归测试里就是灾难。

class Packet; rand bit [7:0] payload[]; constraint c_size { payload.size() inside {[8:64]}; } endclass

建议在实际项目里对size()设显式边界,哪怕只是给一个宽松的范围,也比完全放任不管要可控得多。因为数组长度直接决定元素个数,而元素个数又和 sum、foreach 里引用的索引范围密切相关。如果你在一个约束块里既用size()又用foreach,一定要注意动态数组在用foreach时索引是0到size()-1,不要写了foreach (arr[i]) arr[i+1]这种越界访问,求解时它可能直接报错,也可能悄悄产生不可解集。

另一个重要细节:size()约束的变量本质上是动态数组的内建属性,不需要单独声明一个rand int len再和它绑定。但如果你需要对外记录长度,或者想在约束里多次引用一个统一长度值,显式声明一个rand int len,然后用data.size() == len和len inside {[8:64]}来约束,代码可读性会好很多,而且后续想调整长度范围时只改一处即可。

2.2 foreach:逐元素约束的万能钥匙

foreach是对数组元素做约束的主力工具。它的语法结构是foreach (数组名[i]) 约束体,其中i是系统自动给你生成的索引变量,不需要也不应该提前声明。这个索引变量的作用域只在foreach块内部,你可以直接在约束体里引用它,也可以用i来索引其他数组。

foreach最常见的三类用法:

rand bit [7:0] arr[]; constraint c_each_value { foreach (arr[i]) { arr[i] inside {[0:255]}; arr[i] != 32'hff; // 排除特殊值 } }

这一段是“逐元素值域约束”,限制每个元素的取值范围。

第二种是“元素间关系约束”。想在约束里表达“数组是升序排列的”,就在foreach里用if判断索引:

constraint c_increase { foreach (arr[i]) { if (i > 0) { arr[i] > arr[i-1]; } } }

注意这里的写法:i == 0时不存在arr[-1],所以必须用if (i > 0)包一层。这个“索引判空”的习惯非常非常重要,绿皮书里也反复强调,数组约束里所有引用i-1、i+1的地方都要先判断索引边界,否则轻则仿真警告、重则求解器直接崩溃。

第三种是“跨数组约束”。一个数组的元素和另一个数组的同索引元素相关,这也靠foreach实现。最典型的是生成一对“地址-数据”写序列:

rand bit [7:0] addr[]; rand bit [31:0] data[]; constraint c_pairs { addr.size() == data.size(); foreach (addr[i]) { addr[i] inside {[0:15]}; data[i] inside {[0:255]}; // 地址为特定值时,数据必须为偶数 if (addr[i] == 4'hf) data[i][0] == 1'b0; } }

foreach的强大之处在于它天然支持多维数组。定义一个二维动态数组md[ i ][ j ],你就可以嵌套两层foreach,里层对j索引,外层对i索引,每一维的大小可以各自约束。绿皮书里那套二维数组约束示例我建议你亲手敲一遍,你会发现二维数组约束本质上就是把一维数组约束的思路重复一层:先约束外层的size(),再对每个外层元素约束内层的size(),最后对每个内层元素约束值。

2.3 sum()、product() 与归约约束:让元素整体参与计算

如果数组约束只停留在“每个元素单独限制”的层面,你很快就会遇到一个问题:元素个体都满足条件,但整体的统计特征不对。比如你给一百个bit [3:0]元素各自加了inside {[0:15]}约束,生成了 100 个数,它们的总和平均值是多少?完全不可控。这时候就需要数组的归约约束:sum()、product()、and()、or()等。

sum()是最常用的,它的语义是把数组中所有元素加起来作为一个整体参与约束:

rand bit [7:0] arr[]; constraint c_sum { arr.sum() == 1024; arr.size() inside {[8:16]}; foreach (arr[i]) { arr[i] inside {[0:255]}; } }

如果你希望生成一组“总和固定、每个元素又有随机性”的数组,这种写法是最简洁的表达。但这里有一个必须说的陷阱:sum()的结果位宽。数组元素是bit [7:0],它们的和理论上最大是 255×数组长度,如果数组长度是 16,最大和是 4080,至少需要 12 位才放得下。可 SystemVerilog 在计算sum()时,结果类型取决于元素类型,如果元素是 8 位,求和时可能截断成 8 位,arr.sum() == 1024这种约束可能永远不可解,或者解出来完全不是你想要的。

解决方法是引入with子句做位宽扩展:

constraint c_sum_safe { arr.sum() with (int'(item)) == 1024; }

int'(item)把每个元素显式转成 32 位整型再做累加,避免溢出和截断问题。这是我强烈建议在项目里默认加上的写法,几乎是数组sum()约束的标准的防呆动作。类似地,product()结果位宽更要小心,因为乘法很容易溢出,用到时一律先做位宽扩展再加约束。

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

3.1 基础实操:固定长度、元素范围与边界值

先来一个完整的基础例子,模拟生成一个带长度控制的协议报文。这个类的核心需求有四个:长度在 8 到 32 之间;payload 每个字节不能全为 1;头两个字节是固定的魔数8'hAA、8'h55;整包数据的校验和(求和)为一个可配置目标值。

class ProtoPkt; rand int pkt_len; rand bit [7:0] payload[]; bit [15:0] crc_target; // 随机化前从外部配置 constraint c_pkt_len { pkt_len inside {[8:32]}; payload.size() == pkt_len; } constraint c_payload { // 前两个字节为固定魔数 payload[0] == 8'hAA; payload[1] == 8'h55; // 其余字节范围避开全 1 foreach (payload[i]) { if (i > 1) payload[i] inside {[0:254]}; } // 整包累加和等于外部配置的目标值 payload.sum() with (int'(item)) == int'(crc_target); } function void pre_randomize(); // 预先把外部配置写到随机化过程中 endfunction endclass

这里面有三个小细节值得展开说。第一个是payload[0]、payload[1]这类“定值”其实也可以用标量约束表达,但在数组里直接写常量索引是最直白的。第二个是在foreach里用if (i > 1)把固定魔数字节和普通数据区分开,这样后续如果想改成“前 N 字节为自定义头”,只需要把i > 1改成i >= N,维护成本极低。第三个是sum() with (int'(item)) == int'(crc_target),前后都做了扩展。不要小看这个等式的左右两侧,某次项目里就是只转了左侧没转右侧,导致约束不可解,查了整整一天。

验证激励的另一个重要目标是“边界值覆盖”。比如要确保测试能覆盖报文最大长度 32 字节和最小长度 8 字节,除了靠随机命中,还要显式写约束固化几个边界角度:

task run_boundary(); ProtoPkt p = new; // 最小长度角度 assert(p.randomize() with { pkt_len == 8; }); // 最大长度角度 assert(p.randomize() with { pkt_len == 32; }); endtask

这里用randomize() with {}做内联约束,是一种非常实用的定向随机手段,它不会污染原类里的约束,只在本次求解时叠加条件。我在所有验证项目里都会给每个核心类配一个run_boundary()任务,用来在冒烟测试阶段快速验证边界路径,效果比让纯随机去碰边界高出一个数量级。

3.2 进阶实操:元素间关系、去重与整体统计

基础约束能保证每个元素合法,但真实协议往往会要求元素之间有更强的整体关系。我拿一个仲裁器测试场景来演示:生成一组请求队列,要求队列长度 4 到 16;每个请求的目标端口号互不重复;队列里至少要有一个广播请求;队列整体优先级之和不能超过设定阈值。

class ArbReqQueue; rand bit [3:0] port_id[]; rand bit [2:0] prio[]; constraint c_len { port_id.size() inside {[4:16]}; prio.size() == port_id.size(); } constraint c_unique_port { unique { port_id }; } constraint c_broadcast { port_id.sum() with ( int'(item == 4'hf) ? 1 : 0 ) >= 1; } constraint c_prio_limit { prio.sum() with ( int'(item) ) <= 80; } endclass

unique { port_id }是 SystemVerilog 2009 之后引入的约束,作用是让数组中所有元素互不重复。这里有个非常实际的工程判断:unique的求解代价和数组长度密切相关,长度小于 16 时几乎无感,长度超过 64 时求解时间可能会明显上涨。如果只是希望“大多数情况下不重复”,可以不用unique,而是在post_randomize里做一次检查、必要时手动洗牌重排,代价更小。

c_broadcast里的sum() with (条件 ? 1 : 0)是一个很巧妙的统计技巧,它把“至少一个元素等于某值”这种存在性描述,转化成“满足条件的元素个数 >= 1”这种求和约束。类似的,你要写“最多两个元素等于某值”,就把>= 1改成<= 2。这种写法比在foreach里写一堆if累加状态要简洁得多,可读性和可维护性都更好。

元素之间的顺序关系是另外一个常见需求。比如要生成一组单调递增的时间戳:

rand bit [31:0] timestamp[]; constraint c_ts_increase { timestamp.size() inside {[4:8]}; foreach (timestamp[i]) { if (i > 0) { timestamp[i] > timestamp[i-1]; } } }

这种递增约束在小数组上解得很快,但一旦数组长度超过几十个,求解器要搜索的空间就成倍扩大。经验法则:如果数组长度小于 10,放心用foreach递增长约束;如果长度大于 20,建议改成二段式生成——先随机化并排序一组中间值,再填进数组。我后面会细说。

3.3 多维数组约束与更多细节

很多 RTL 验证场景需要生成二维数据结构,比如多端口交换机的每端口队列深度表、图像处理里的块数据。绿皮书第六章对二维数组约束的讲述篇幅不算多,但实际工程里几乎避不开。多维数组约束的要点是“逐层解锁”:

class MatrixGen; rand bit [7:0] matrix[][]; constraint c_matrix_shape { matrix.size() inside {[2:4]}; foreach (matrix[i]) { matrix[i].size() inside {[2:6]}; foreach (matrix[i][j]) { matrix[i][j] inside {[0:255]}; } } } endclass

这里matrix.size()约束的是第一维长度,matrix[i].size()约束的是第二维每一行的长度,matrix[i][j]约束的是元素本身。求解器会把这些全部当作联合条件一起求解,也就是说第一维长度不同,每个子数组的长度上限也不同,求解器需要找到一个组合让所有size()约束都成立。这张“所有维度联动”的性质既是多维数组约束的灵活之处,也是性能瓶颈的来源。

多维数组加关系约束时要格外小心。你想表达“同一行元素互不重复、但不同行可以重复”,直接对二维数组写unique { matrix };是不行的,因为那要求整个多维结构整体唯一。正确做法是用辅助向量做映射,或者把二维数组展平成一维再约束。我在做图像处理模块验证时常用一个技巧:把二维数组展平成foreach里用两个索引合成一个唯一键,比如matrix[i][j]在处理唯一性时看作i * row_width + j的一维元素集合,配合unique的with子句实现。

此外,数组约束里还有一个容易被忽视的约束类型:soft约束和if-else的配合。当你希望“默认约束生效,但极端情况下可以被覆盖”时,可以用soft修饰数组相关约束:

constraint c_soft_size { soft payload.size() inside {[16:32]}; }

soft约束在与其他约束冲突时优先级最低,不会导致整体不可解。但我要提醒一句:soft修作用于数组元素或者数组size()时,不同仿真器对“冲突后如何降级”的处理方式有细微差异。某次我在两个主流仿真器上跑同一段soft数组约束,一个把size()降到了边界值,另一个直接放弃了整个数组约束块,导致后续引用payload[0]时越界。所以soft不要滥用,尤其是涉及size()时。

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

4.1 求解失败:约束矛盾怎么定位

数组约束最常见的失败原因是“约束之间隐含矛盾”。我见过很多同学写代码时每个约束单看都对,组合起来就是无解。“数组长度最大 6、每个元素最大 40、总和必须为 300”——最大总和才 240,哪个仿真器来都是报 randomization failed。这类问题的定位手段,总结成一套流程:

第一步,缩小范围法。把最可疑的一条约束去掉,重新随机化。如果通过,说明去掉的约束就是矛盾制造者。第二步,打印求解器给的诊断信息。大多数仿真器在 randomize 失败时会输出No valid solution found,但不会告诉你是哪条约束出的问题,这时候要靠if (!randomize())手动打印。

if (!p.randomize()) begin $display("Randomization failed at %t", $time); $display("len = %0d", p.pkt_len); // 手动检查关键中间变量的候选值范围 end

这个if (!randomize())的检查习惯一定要养成,不要在验证环境里裸奔。

第三步,善用constraint_mode(0)做二分排查。把约束分成两组,一组关掉、一组跑,逐步二分,和 Debug 二分法一个道理。我在项目里写过一段通用的排查脚本模板,把所有约束名放进一个数组,每次关一半跑一轮,两三轮内就能锁定矛盾所在。

还有一种非常隐蔽的失败原因:foreach里引用了一个未定长度的其他数组。比如:

rand int len; rand int arr1[]; rand int arr2[]; constraint c_bad { len inside {[2:4]}; arr1.size() == len; arr2.size() == len; foreach (arr1[i]) { arr2[i] == arr1[i]; // 看似没问题 } }

求解器要为len找到一个值,让两个数组长度相同且逐元素相等,这通常能解,但如果len又受其他约束影响,比如len != 4,而arr1和arr2的其他约束强制它们长度必须为 4,那矛盾就产生了。排查这类问题时,重点不是看foreach,而是看长度约束和元素约束之间是否存在“被忽略的耦合”。

4.2 性能问题:大数组与复杂约束的取舍

数组约束的性能瓶颈一般出现在三个地方:数组规模过大、元素间关系复杂、使用了unique或嵌套foreach。先说数组规模:求解器对动态数组的求解复杂度与数组长度的关系是超线性的,长度从 16 涨到 32,求解时间可能翻好几倍。所以验证平台里如果只是需要“一大段随机数据”,没必要全走约束求解器,可以考虑用$urandom或者std::randomize生成原始数据,再通过post_randomize修正格式。这是约束求解和直方图生成互相配合的常用手段。

再说unique。unique的本质是给求解器加了一组全对全的不等约束,数组长度 N 意味着约 N^2/2 个不等式约束。N 到 50 左右还撑得住,N 到 150 以上,很多仿真器直接进入“慢性死亡”状态,跑一分钟都不出结果。我处理大数组去重需求时,常用的降级方案是“查重表法”:

class BigUnique; rand bit [7:0] large_arr[]; rand bit [255:0] used; // 位图 constraint c_use_map { foreach (large_arr[i]) { // 保证该值没被用过,同时置位位图 used[large_arr[i]] == 0; } end endclass

位图法把“全对全”变成“一对全”,约束数量线性下降,求解速度大幅提升,代价是元素取值范围要能装进一个足够宽的位图变量里。这个思路我在多个项目里用了很多次,效果稳定。

最后说嵌套foreach和复杂数值关系。如果某条约束关系必须要精确保值,但它又依赖多个数组元素,试着把关系拆到post_randomize里实现。约束求解器不是万能的,后处理脚本能做“修正”和“再随机化”的工作。原则是:能用约束表达核心覆盖目标的地方,用约束;只是为了让数据“好看”或“合法”的地方,用后处理修正,把求解器的算力留给真正有价值的约束。

4.3 调试与经验盘点

数组约束调试,我最常用的三件套是%p格式化打印、post_randomize里的断言检查、以及“种子回放”法。%p可以直接把整个数组打印出来,比循环写$display效率高得多;post_randomize里加assert检查约束后置条件,能帮你确认求解结果真的满足意图,防止约束写错但求解器恰好给出了“看起来对”的结果;种子回放则是用固定的seed复现同一个失败场景,方便反复调试。这三个习惯我在每个项目都遵守,省下了无数查 bug 的时间。

再盘几个容易忽略的细节。第一,动态数组在声明为rand后,如果约束没有给size()指定范围,不同仿真器对“未约束动态数组”的默认处理可能不同,有的直接报错,有的默认生成长度为 0 的数组。为了可移植性,一定要显式约束size()。第二,randc是对变量而言的,不能对数组整体做“每个元素遍历一次”的随机化。如果你需要的是“这组值不重复且尽量全覆盖”,用unique或者位图法,不要指望randc能对数组生效。第三,不要在约束里调用非确定性函数,比如$urandom、$random,这会破坏约束求解器的确定性,导致同一个种子跑出不同结果,回归复现直接失效。

关于二维数组和多维数组的约束,还有一条经验:约束块里写foreach (matrix[i])会自动为i生成一个从0到matrix.size()-1的隐式变量,它和你在约束体里声明的任何同名变量都不同。不要在foreach外试图引用这个隐式索引,也不要在同一个约束块里声明同名变量,否则会出现难以理解的编译或求解行为。

最后分享一个我自己常用的“两段式数组随机化”模板,解决“需要先确定长度、再填充内容”的场景:

class TwoStageArr; rand int len; rand bit [7:0] data[]; constraint c_len { len inside {[8:32]}; } function void post_randomize(); data = new[$clog2(len)]; // 此时 data.size() 已定,手动填充更多样的数据 foreach (data[i]) data[i] = $urandom_range(0, 255); endfunction // 需要约束求解器参与内容随机时,走这个方法 task randomize_content(); assert(std::randomize(data) with { data.sum() with (int'(item)) == 100; }); endtask endclass

这个模板的精髓在于:长度用约束求解器随机,内容用后处理或二次随机化,两道工序解耦,既保留了可复现性,又避免了“长度与内容双向耦合”导致的性能滑坡。需要精确约束内容的时候,再走randomize_content()单独求解数组内容。

数组约束在绿皮书第六章里看似只是几个 API 的组合,但真正把它用起来、用稳,靠的是对求解器工作原理的理解和大量的实战打磨。我个人在实际项目里最深的一点体会是:数组约束不要“一步到位”,能用后处理分离的不要强行塞进一个约束块,能用简单约束表达的不要上unique和嵌套foreach。把复杂度和求解空间控制住,随机化激励才能真正帮到验证收敛,而不是成为新的调试黑洞。希望这篇关于数组约束的梳理,能让你在下次面对“无解”和“慢解”时,少走几趟弯路。

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

FFT里常被误读的“基”:基2 DIT算法原理与蝶形运算全解析

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

作者头像 李华
网站建设 2026/10/17 1:28:42

无电感电荷泵D类功放CS5250:原理、选型、PCB布局与避坑指南

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

作者头像 李华
网站建设 2026/10/17 1:28:42

SpringBoot面试必问:自动配置原理与启动流程深度解析

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

作者头像 李华
网站建设 2026/10/17 1:28:35

非对称加密公式详解:RSA、ECC、ElGamal密钥生成与签名验签

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

作者头像 李华
网站建设 2026/10/17 1:28:35

双节锂电池主动均衡设计:飞渡式架构与MCU协同控制

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

作者头像 李华
网站建设 2026/10/17 1:28:33

结构体字节对齐与#pragma pack(1):通信乱码排查实战

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

作者头像 李华