news 2026/10/3 1:14:02

Quartus II + Modelsim 下用 Verilog 设计 PC 驱动 ROM 地址的完整实验与仿真

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Quartus II + Modelsim 下用 Verilog 设计 PC 驱动 ROM 地址的完整实验与仿真

这实验我当年第一次做的时候其实挺懵的:Quartus II 里画个方框图,写几行代码,Modelsim 里拉出来几根波形,PC 一位位往上加,ROM 的 data 就跟着变了,看起来好像没啥了不起。但后来回头再看,这恰恰是打通"时序逻辑"和"存储器读操作"最关键的一步。程序计数器 PC 怎么产生地址、ROM 怎么根据地址把数据送出来、仿真波形上到底哪个信号先变哪个信号后变——把这条链路看明白了,后面理解 CPU 取指、理解总线结构,都会顺很多。

这篇就以"Quartus II 设计程序计数器 PC、由 PC 产生 ROM 的地址(含仿真)"这个实验为主线,从原理拆解到代码实现,再到 Modelsim 仿真和排查心得,一步步过一遍。适合正在学计算机组成原理、数字逻辑,或者刚接触 FPGA 的读者。如果你已经会点 Verilog 但没想清楚"PC 和 ROM 到底怎么配合",这篇也能帮你把这块拼图补上。

1. 实验思路拆解:为什么要用 PC 去驱动 ROM 地址

1.1 从 CPU 取指流程看 PC 的角色

先想一个最朴素的问题:CPU 凭什么知道下一步要执行哪条指令?答案是靠地址。CPU 内部有一个专门用来"记住当前指令地址"的寄存器,这就是程序计数器 PC(Program Counter)。它本质上就是一个带有自增功能的寄存器:每个时钟周期,PC 输出当前地址,然后把地址交给存储器,存储器把该地址上的指令数据返回给 CPU,同时 PC 自己加上一个固定的步长,指向下一条指令。这个过程在不断循环,就构成了取指(Fetch)的基本节拍。

所以在这个实验里,PC 是"源头",ROM 是"终点"。PC 的输出接 ROM 的地址输入,ROM 的输出就是该地址里预先存好的数据。如果你想得更远一点,这个"PC 产生地址"的动作,已经是 CPU 所有后续操作的地基了。把这一步仿真透彻,比你背十遍"程序计数器是CPU的重要组成部分"都管用。

1.2 地址驱动方式的选择:为什么用 PC 的信号直接连 ROM 地址

实现"PC 产生 ROM 地址"其实有几种做法。直接的方式就是像这个实验里展示的:PC 模块的输出信号 pc_addr 直接接到 ROM 模块的 addr 输入端口,中间不加任何缓存和转换。这么做的好处是让初学者一眼看清数据流——PC 的值就是 ROM 的地址,没有弯弯绕绕。

另一种做法是在 PC 和 ROM 之间加一个地址锁存器或者总线缓冲器,这在真实 CPU 里往往是必要的,因为地址总线和数据总线可能会被多个设备分时复用。但对这个实验来说,加锁存器只会把问题复杂化,不利于建立最核心的信号通路概念。所以我在写代码时选择了最直白的连接方式:顶层模块里用一条 wire 把 PC 的 pc_addr 和 ROM 的 addr 直接连起来。

1.3 实验设计的逻辑闭环与目标拆解

整个实验其实要验证一个完整的数据通路:复位后 PC 清零 -> 使能后每个时钟上升沿 PC 加 1 -> PC 输出的地址传给 ROM -> ROM 输出对应地址里的数据。这四条链路环环相扣,任何一条断了都不行。

如果严格拆解,这个实验其实包含两个独立的小模块。第一块是 PC,它是时序逻辑,必须有时钟和复位,靠 always 块实现每个时钟周期加一。第二块是 ROM,它是纯组合逻辑,输入地址,输出数据,内部只是一个预先填好的查找表或存储器。把这两个模块结合起来,就完成了"由 PC 产生 ROM 地址"的核心设计。

2. 核心模块设计与实现要点

2.1 PC 模块的 Verilog 实现与分析

PC 模块的代码非常简单,但越是简单的东西越能看出基本功。核心代码如下:

module pc ( input wire clk, input wire rst_n, input wire pc_en, output reg [7:0] pc_addr ); always @(posedge clk or negedge rst_n) begin if (!rst_n) pc_addr <= 8'h00; else if (pc_en) pc_addr <= pc_addr + 1'b1; else pc_addr <= pc_addr; end endmodule

这里有三个关键点要细说。

第一个是复位方式。我用了异步复位(negedge rst_n),也就是复位信号一拉低,PC 立刻清零,不等待时钟沿。在实际工程里,异步复位很常用,因为复位信号通常来自专门的上电复位电路,它的作用是"紧急情况立即生效",不需要等时钟。而同步复位则需要复位信号持续到下一个时钟上升沿才能生效。这个实验里复位信号很重要,因为 ROM 地址从 0 开始才有意义,不复位的话 PC 初始值是未知的 X 态,ROM 根本没法读。

第二个是使能信号 pc_en。PC 不是每个时钟都一定要加一。在真实 CPU 里,遇到暂停、等待、分支跳转等场景,PC 的递增是需要被"冻结"的。加一个使能信号,就相当于给 PC 装了一个开关。当 pc_en 为高时,时钟上升沿触发加一;为低时,保持当前值不变。这就是教科书上常说"PC 具有保持功能"的硬件来源。

第三个是位宽问题。我用的是 8 位 PC,理论上可以寻址 256 个存储单元。这个实验 ROM 里只放十几条数据,8 位完全够用,而且波形里看起来比较直观。如果你后面要把 ROM 做深,比如 1024 深,PC 至少要 10 位才行。位宽不足会导致高位地址丢失,这是初学者很容易踩的坑。

2.2 PC 自增逻辑的两种写法对比

PC 的"加一"操作,在 Verilog 里有两种常见写法。刚才上面用的是直接在时序逻辑里pc_addr <= pc_addr + 1'b1;,RTL 综合时会自动推断出一个加法器,把 PC 的输出反馈到加法器输入,再回灌到寄存器。这种写法代码简洁,适合教学,也适合快速验证功能。

另一种写法是使用组合逻辑单独计算"下一个 PC 值":

// 组合逻辑块 wire [7:0] next_pc = pc_addr + 1'b1; // 时序逻辑块 always @(posedge clk or negedge rst_n) begin if (!rst_n) pc_addr <= 8'h00; else if (pc_en) pc_addr <= next_pc; end

两种写法在仿真和综合结果上几乎没有区别,因为综合器都会自动优化。但第二种写法更贴近"数据通路"的思维模式——把"下一个状态的计算"和"状态的存储"分开。我个人的建议是:初学阶段先用第一种,先把功能跑通;等你开始设计复杂一点的 CPU 时,再用第二种,因为它方便你在 next_pc 上组合跳转逻辑,比如next_pc = pc_addr + offset。所以两种都要会,但不要一开始就纠结用哪种,先用顺手的。

对比一下这两种写法:

对比项直接时序自增组合逻辑计算 next_pc
代码复杂度低,适合入门略高一点
可读性直观层次更清晰
灵活性后续加跳转逻辑需改动 always 块可以自由组合各种跳转条件
综合结果自动推断加法器与左边基本一致

2.3 ROM 模块的实现:从查找表演化到 MIF 文件

ROM 模块实现方式有三种:最简单是直接用 case 写查找表,进阶是用寄存器数组加 initial 赋值,再进阶是真用 Altera ROM IP 核加载 MIF 文件。

我建议初学者先写 case 查找表,因为它可以让你完全掌控地址和数据的对应关系。代码如下:

module rom #( parameter DATA_WIDTH = 8, parameter ADDR_WIDTH = 8 ) ( input wire [ADDR_WIDTH-1:0] addr, output reg [DATA_WIDTH-1:0] data ); always @(*) begin case (addr) 8'h00: data = 8'h01; 8'h01: data = 8'h03; 8'h02: data = 8'h07; 8'h03: data = 8'h0F; 8'h04: data = 8'h1F; 8'h05: data = 8'h3F; 8'h06: data = 8'h7F; 8'h07: data = 8'hFF; default: data = 8'h00; endcase end endmodule

这里我故意把存储的数据设计成了有规律的序列:01、03、07、0F、1F、3F、7F、FF。每个数据比上一个高一位变 1,这样在仿真波形里你一眼就能确认 ROM 输出是否和地址一一对应。如果数据乱写,地址对不上数据,排查起来很费劲。

case 查找表适合验证阶段,但它的缺点是数据量一大就写不动了,而且跟真实的 ROM 行为有差距。真实 ROM 应该是一个初始化为特定内容的存储阵列。更接地气的做法是使用 Quartus II 的 ROM IP 核,加载 MIF 文件,这样存储内容在工程文件里独立管理,改数据不用改代码。

2.4 用 MIF 文件初始化 ROM 的方法

如果你决定用 ROM IP 核,需要先准备一个 MIF(Memory Initialization File)文件。这个文件的作用就是告诉 Quartus"ROM 里出厂时存的什么内容"。MIF 的格式非常严格,标点符号和关键字都不能错,否则编译直接报错。一个标准 MIF 文件长这样:

DEPTH = 16; WIDTH = 8; ADDRESS_RADIX = HEX; DATA_RADIX = HEX; CONTENT BEGIN 0 : 01; 1 : 03; 2 : 07; 3 : 0F; 4 : 1F; 5 : 3F; 6 : 7F; 7 : FF; [8..15] : 00; END;

几个格式细节要特别注意。DEPTH 必须大于你实际使用的最大地址,否则访问越界。ADDRESS_RADIX 和 DATA_RADIX 分别设置地址和数据的进制,HEX 和 BIN 都行,但要保持统一,别写着写着混了。CONTENT BEGIN 和 END 之间每行是"地址 : 数据",冒号两边的空格有没有其实都行,但分号不能丢。最后那个[8..15] : 00;是批量填零的写法,地址连续区间的数据相同就用它,能省不少事。

在 Quartus 里用 ROM IP 核时,一般选择单端口 ROM,位宽 8、深度 16,然后在初始化文件那里指定 MIF 文件路径。需要注意 MIF 文件最好跟工程文件放在同一个目录下,或者用相对路径,不然换电脑打开工程时会报找不到文件。

3. 完整实操过程:建工程、写代码、跑仿真

3.1 Quartus II 工程创建与文件组织

先说环境。我这边用的是 Quartus II 13.0 搭配 Modelsim-Altera 10.1e,这套组合在实验场景里最经典。Quartus 版本差异主要影响器件库和 IP 核的界面,但写 Verilog 代码和基本仿真流程是通用的。安装步骤网上资料很多,这里默认你已经装好了环境,能正常打开 Quartus 并调起 Modelsim。如果安装时有版本冲突或者 license 问题,建议优先卸载干净后重装,这是最省时间的。

新建工程的流程其实非常机械:File -> New Project Wizard,工程名随便起,比如 pc_rom_test。第一页记得选好工程目录,不要放在系统盘带中文路径的目录下,Modelsim 对中文路径支持很差,编译阶段容易出幺蛾子。器件型号选你手头板子的型号,如果只是仿真不打算上板,就可以选 Cyclone IV 系列里任意一个型号,比如 EP4CE10F17C8,后面仿真不受影响。

工程建好后,把文件组织清楚。我习惯把源码和仿真文件分开目录放。顶层文件叫 pc_rom_top.v,PC 模块单独一个文件 pc.v,ROM 模块一个文件 rom.v,testbench 文件叫 tb_pc_rom.v。虽然也可以全写在一个文件里,但分文件的好处是后面你复用 PC 模块或换 ROM 存储内容时不用动其他代码。这个习惯越早养成越好。

3.2 顶层模块的连接:把 PC 的输出喂给 ROM 地址

顶层模块的作用就是把 PC 和 ROM 串起来。它的代码很直观:

module pc_rom_top ( input wire clk, input wire rst_n, input wire pc_en, output wire [7:0] rom_addr, output wire [7:0] rom_data ); wire [7:0] pc_addr; pc u_pc ( .clk (clk), .rst_n (rst_n), .pc_en (pc_en), .pc_addr(pc_addr) ); rom u_rom ( .addr(pc_addr), .data(rom_data) ); assign rom_addr = pc_addr; endmodule

注意看,rom_addr 是从 PC 引出来的观察信号,rom_data 是 ROM 输出的数据。之所以把 rom_addr 单独引出来,是为了仿真波形里观察方便,因为实际上 ROM addr 的输入就是 pc_addr,直接对着 pc_addr 看也能明白。

连接这类信号,有个地方值得提醒:所有 wire 型变量必须保证单一驱动源。顶层模块里千万别对 pc_addr 又赋值、又连接模块端口,那样会多驱动报错。初学者写顶层时经常手滑,多写了一条 assign,编译报出"Multiple drivers"错误,半天找不到原因。

3.3 Modelsim 仿真的 Testbench 写法与操作流程

仿真环节是验证设计正确性的核心。testbench 文件就是一个专门用来产生激励的 Verilog 模块,它不需要被综合成电路,只用来在仿真器里跑波形。我这个实验的 testbench 如下:

`timescale 1ns / 1ps module tb_pc_rom; reg clk; reg rst_n; reg pc_en; wire [7:0] rom_addr; wire [7:0] rom_data; pc_rom_top u_top ( .clk (clk), .rst_n (rst_n), .pc_en (pc_en), .rom_addr(rom_addr), .rom_data(rom_data) ); initial begin clk = 1'b0; rst_n = 1'b0; pc_en = 1'b0; #100; rst_n = 1'b1; pc_en = 1'b1; #500; $stop; end always #10 clk = ~clk; endmodule

testbench 的设计有几个关键细节。timescale声明了仿真时间单位和精度,这里是 1ns/1ps,常用配置。时钟用always #10 clk = ~clk;生成周期 20ns 的时钟,对应 50MHz,对我们这个仿真足够。复位信号先拉低 100ns,让 PC 在这个时间里彻底清零,然后拉高并打开 pc_en,开始真正运行。

Modelsim 的操作流程,细说起来是这样的。先打开 Modelsim-Altera,File -> New -> Library 新建一个工作库,库名通常叫 work。然后 Compile -> Compile,把 pc.v、rom.v、pc_rom_top.v、tb_pc_rom.v 四个文件依次编译进来,编译顺序没严格要求,但建议先底层后顶层。编译如果报错,回到代码改完重新编译,直到全部通过。然后 Simulate -> Start Simulation,在 Design 标签页里找到 work 库,选中 tb_pc_rom,点 OK。这会弹出一个 Simulate 窗口,里面是仿真对象列表。

接着把需要观察的信号加到波形窗口:选中 tb_pc_rom 里的 clk、rst_n、pc_en、rom_addr、rom_data,右键 Add Wave。设置好运行时间,比如 1000ns,点 Run。波形窗口就会实时画出仿真的结果。如果你用的是 Quartus 集成的 Modelsim,也可以在 Quartus 里直接设置 EDA Tool 为 Modelsim-Altera,在 Analysis & Synthesis 通过后,用 Tools -> Run Simulation Tool -> RTL Simulation 一键拉起 Modelsim,它会自动编译工程里的代码,前提是你已经把 testbench 文件也添加到了工程里。

3.4 仿真波形分析与时序关系检查

仿真跑完,重点看波形。正确的结果应该是:clk 正常翻转,rst_n 拉低时 pc_addr 保持为 0,rst_n 拉高且 pc_en 为 1 后,每个 clk 上升沿 pc_addr 加 1,从 00 到 01、02、03......一直加下去。对应的 rom_data 则随着地址变化依次输出 01、03、07、0F、1F......

这里有一个非常值得观察的时序细节:rom_data 的变化时刻比 pc_addr 的变化时刻略微滞后。因为 PC 是时序逻辑,地址在时钟上升沿更新;而 ROM 是组合逻辑,地址变化后需要经过一小段组合延迟,数据才会稳定输出。在真实硬件里这个延迟是 ns 级的,仿真里由于没有加 gate delay,所以几乎看不到延迟,但你要理解这个"先有地址、再有数据"的先后关系。从波形上你还会看到,地址和数据的变化都在时钟的同一个周期内完成,这就是同步设计中"一个时钟周期完成一次取指"的基本保障。

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

4.1 波形输出全是红线 X 态,怎么处理

红线是目前仿真里出现概率最高的问题。所谓红线,就是信号值是 X(未知)状态。产生 X 态的常见原因有三个:一是没有复位,PC 寄存器初始值是未知的。二是某个信号悬空,没有驱动源。三是模块端口连接错误,导致某个输入引脚没有接到任何东西。

排查思路要按顺序来。先看 rst_n 是否能正常拉低再拉高,如果 testbench 里复位信号从开始就是 Z 状态,那 PC 永远无法复位。再看时钟,如果 clk 一直是 X 或者 Z,整个设计都不知道什么时候翻转。接着看各模块的例化端口,有没有漏连、错连。我见过不少初学者把 pc_en 写成了pc_en,然后顶层模块里根本没有这个输入引脚,仿真器直接就把它当成悬空处理。

4.2 仿真波形 PC 正常递增但 ROM 输出全为 0

这个问题的症状很典型:PC 从 0 到 1 到 2 一路涨,看起来完全正常,但 ROM 的数据一直是 0。首先要确认你观察的 ROM 数据端口是 data,而不是地址端口。很多人会把 ROM 的地址信号误当成数据线。第二要看 ROM 模块内部的设计:如果你用的是 case 查找表,检查一下 case 的地址宽度和 pc_addr 是否匹配。比如 ROM 模块里只写了 4 位地址的 case,而 PC 输出 8 位地址,仿真器会对未列出的高位地址执行 default 语句,如果 default 返回 0,高地址自然全是数据 0。

如果你用的是 MIF 初始化的 ROM IP 核,还要检查 MIF 文件是否真的被加载。在 Modelsim 里可以通过右键查看 ROM 实例的属性,确认 init file 路径是否正确。我遇到过一种情况:MIF 文件放在中文路径下,Quartus 在综合时能读到,但 Modelsim 在初始化仿真时读不到,结果 ROM 内全是 0。解决办法很粗暴:把 MIF 文件移到工程根目录,或者直接用英文路径,再重新编译仿真。

4.3 PC 不递增,一直卡在某个地址不动

PC 卡住不动,常见原因是 pc_en 一直为低。比如 testbench 里初始化了 pc_en = 1'b0,后面运行了一个时钟周期就把 pc_en 拉低,但你拉低的时机刚好在上升沿之后,导致功能上看起来"PC 根本没动过"。更隐蔽的原因是你把复位信号反复拉低,每次拉低 PC 都清零,你看到的地方当然不会递增。

解决思路是先看波形上 pc_en 和 rst_n 的电平状态。确保复位拉高后保持高电平,pc_en 拉高后不要再动。如果都正常,再检查 PC 模块的 always 块敏感列表。always @(posedge clk or negedge rst_n)里如果漏了negedge rst_n,在综合时也许能通过,但仿真时复位行为就不对,甚至会导致 PC 在复位时和时钟沿同时触发,出现双事件竞争,结果不确定。

4.4 Quartus 编译报错与 Modelsim 版本不匹配问题汇总

很多时候代码逻辑没问题,但 Quartus 或 Modelsim 的版本问题会让人抓狂。常见情况是 Modelsim 编译时提示找不到某个库文件,比如altera_mf或lpm。这是因为 Modelsim 没有正确关联 Quartus 的库。解决办法是在 Modelsim 里手动添加 Quartus 的仿真库路径。具体操作:Tools -> Edit Preferences -> Library Preferences,把 Quartus 安装目录下eda/sim_lib的路径添加进去。在 Quartus 13.0 里,这个路径一般是C:\altera\13.0sp1\quartus\eda\sim_lib。

另一个常见问题是编译通过但仿真器里看不到波形,Run 之后没有任何反应。先检查是否 Stop 了仿真,再检查是否把波形窗口和 Simulation 窗口搞混。还有可能是你运行的时间太短,PC 刚开始变化,波形窗口还没来得及刷新。把这些可能排除掉,问题基本就解决了。

为了让你排查时更省事,我把常见问题整理成一个速查表:

现象可能原因检查方向
波形全部是 X 态没有复位/时钟没驱动testbench 激励产生了吗
PC 一直为 0pc_en 没拉高/复位没有释放波形里看 pc_en 和 rst_n
PC 递增但 ROM 输出 0ROM 未初始化/地址不匹配检查 MIF 或 case 的地址覆盖
ROM 输出变化但数据混乱MIF 中地址与数据对不上对照存储表逐项检查
Modelsim 编译报缺库仿真库路径没有配好手动添加 sim_lib 目录
仿真无波形运行时间太短/未添加信号检查 Wave 窗口和 Run 时间

4.5 几个有价值的仿真调试技巧

最后分享三个仿真排查的小技巧,这是我做了多个实验后总结出来的,对效率和调试质量都有很大提升。

第一个技巧:仿真时把变量信号全部加进波形,别只盯着顶层端口。在 Modelsim 的对象窗口里,右键模块,选择 Add Wave,可以把模块内部的中间变量一起加进去。比如 PC 模块内部的pc_addr虽然是输出端口,但你加进去后可以看到它和顶层 rom_addr 的关系。内部变量加得越多,定位问题越快。

第二个技巧:善用$monitor或$display在仿真控制台打印关键信息。比如在 testbench 里加一句$display("time=%t pc_addr=%h rom_data=%h", $time, rom_addr, rom_data);,每次 PC 变化都会打印一行。当你觉得波形密密麻麻看不清的时候,直接看打印信息更直觉。

第三个技巧是,单独验证 PC 和单独验证 ROM。把复杂问题拆开,先单独仿真 PC,确认它每个周期加一;再单独仿真 ROM,给一个固定地址,确认数据对应关系;最后再把两个模块合并。很多人一上来就仿真整个顶层,出了错还要猜是哪个模块的锅,白白浪费时间。模块化验证虽然多花几步,反而是最快的过程。

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

本地跑通开源大模型:LLM、Llama与Ollama入门指南

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

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

Android Studio实战:打造轻量级对话机器人App全攻略

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

作者头像 李华
网站建设 2026/10/3 1:12:22

编译原理实战:从词法分析到中间代码生成,手写一个最小编译器

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

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

STM32F103自定义HID开发全指南:从USB枚举到WinUSB免驱通信

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

作者头像 李华
网站建设 2026/10/3 1:10:39

Linux常用指令实战指南:从场景出发,避开常见坑

说实话&#xff0c;很多朋友让我推荐Linux学习资料时&#xff0c;上来就问“Linux常用指令有哪些”&#xff0c;然后甩给我一张密密麻麻的命令大全截图。但我做了这么多年运维和开发&#xff0c;最深的体会是&#xff1a;只背命令清单是没用的&#xff0c;真正值钱的是理解每条…

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

ROS2+YOLOv5s桌面级立体仓储系统工程实践

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

作者头像 李华