UVM编码规范实战:Easier UVM模板与代码生成器避坑指南
简介这份资源是Doulos出品的《Easier UVM Coding Guidelines》编码指南面向已掌握SystemVerilog与UVM基础、希望提升验证代码一致性与复用性的硬件验证工程师及项目管理者。它针对团队中编码风格各异、维护成本高、验证环境难以从块级复用到系统级等痛点给出可落地的规范建议。压缩包内仅1个PDF文件约1.12MB内容涵盖命名约定、通用编码指南、时钟与同步、事务与序列、组件结构、配置管理、工厂机制、测试编写、消息报告、寄存器层及功能覆盖率等章节并附有仿真加速器与仿真器运行建议。读者可据此快速建立符合行业最佳实践的UVM验证环境减少风格分歧带来的维护负担并借助配套代码生成器与视频教程边学边练。目前已有2169人学习下载适合需要系统规范验证代码的中高级工程师参考。1. 从一份 2016 年的 PDF 说起为什么 UVM 代码总在重复造轮子如果你写过半年以上的 SystemVerilog 验证环境大概率经历过这种场面项目里三个人写了三套 driver命名风格各不相同有人用cfg有人用config有人把build_phase写成build有人把 sequence 直接塞进 test 里。等到要复用某个 VIP 的时候发现根本拆不出来只能复制粘贴再改一遍。这不是能力问题是缺少一套统一的编码约定。Easier UVM Coding Guidelines 就是冲着这个问题来的。它是一份厂商中立的 UVM 编码指南配套一个代码生成器核心目标只有一个把 UVM 里“同一件事有五种写法”收敛成“推荐一种写法”。它不替代 UVM 官方类库也不替代 SystemVerilog 语言标准而是在两者之上给出一层工程化的编码约束。适合谁适合刚学完 UVM 但一上手就不知道从哪写起的工程师也适合团队里想把代码风格统一起来的技术负责人。这份文档的版本是 2016-06-24虽然年头不短但它对 factory、config、sequence 这些核心机制的编码建议放到今天的 UVM 项目里依然成立。2. 三类核心对象的编码模板component、sequence item、sequenceEasier UVM 把用户自定义的 UVM 对象收敛成三类component、sequence item、sequence。这三类对象各有固定的编码模板模板之间保持一致的行序和命名习惯。理解这三类模板是读懂整份指南的入口。2.1 为什么是这三类而不是更多UVM 本身提供了大量的基类uvm_component、uvm_object、uvm_transaction、uvm_sequence_item、uvm_sequence、uvm_driver、uvm_monitor、uvm_scoreboard……如果每个基类都搞一套模板学习成本反而更高。Easier UVM 的做法是抓大放小component 代表结构在 build 阶段实例化生命周期贯穿整个仿真sequence item 和 sequence 代表激励在 run 阶段动态创建用完就丢。其他类型比如 config 对象和 callback 对象归为次要类型不单独定义模板而是复用 sequence item 的写法。这个分类背后的逻辑是结构性的东西和动态性的东西编码方式本来就该不一样。component 需要处理 hierarchy、phase、configsequence item 需要处理 randomization、constraint、field automation。把这两类分开定模板比混在一起讲要清晰得多。2.2 component 模板的固定行序一个符合 Easier UVM 的 component类内部的声明顺序是固定的。常见做法是先写 factory 注册宏再写 config 对象句柄然后写 TLM 端口接着写内部变量最后写 phase 方法。这个顺序不是随便定的它对应的是读者阅读代码时的关注优先级——先知道这个组件是什么类型再知道它需要什么配置再知道它对外暴露什么接口最后才看它具体怎么干活。// 一个符合 Easier UVM 风格的 driver 骨架 class my_driver extends uvm_driver #(my_seq_item); uvm_component_utils(my_driver) // 1. factory 注册 my_config cfg; // 2. config 对象句柄 virtual my_if vif; // 3. 虚拟接口 uvm_analysis_port #(my_seq_item) ap; // 4. TLM 端口 function new(string name, uvm_component parent); super.new(name, parent); endfunction function void build_phase(uvm_phase phase); // 5. phase 方法按固定顺序 super.build_phase(phase); if (!uvm_config_db#(my_config)::get(this, , cfg, cfg)) uvm_fatal(NOCFG, cfg not set) ap new(ap, this); endfunction task run_phase(uvm_phase phase); forever begin seq_item_port.get_next_item(req); drive_one(req); seq_item_port.item_done(); end endtask task drive_one(my_seq_item item); // 具体的驱动逻辑 endtask endclass这段代码里几个关键点uvm_component_utils宏必须放在类的最前面它负责把类注册到 factoryconfig 对象通过uvm_config_db::get获取获取失败直接uvm_fatal而不是默默继续TLM 端口在 build_phase 里new出来不在new()函数里做。这些细节单独看都不复杂但如果不统一十个人能写出十种变体。2.3 sequence item 和 sequence 的模板差异sequence item 的模板和 component 有明显区别。它继承自uvm_sequence_item用uvm_object_utils注册字段用rand修饰并配合uvm_field_*宏做自动化。sequence 则继承自uvm_sequence#(item_type)核心方法是body()在body()里通过start_item/finish_item或者uvm_do宏来产生激励。// sequence item 模板 class my_seq_item extends uvm_sequence_item; uvm_object_utils(my_seq_item) rand bit [31:0] addr; rand bit [31:0] data; rand bit we; function new(string name my_seq_item); super.new(name); endfunction constraint c_addr { addr inside {[32h0000_0000 : 32h0000_FFFF]}; } endclass // sequence 模板 class my_seq extends uvm_sequence #(my_seq_item); uvm_object_utils(my_seq) function new(string name my_seq); super.new(name); endfunction task body(); uvm_do_with(req, { addr 32h1000; }) endtask endclass注意 sequence item 的new()函数参数只有一个 string name默认值是类名的小写形式而 component 的new()有两个参数name 和 parent。这个差异来自 UVM 本身的类层次结构Easier UVM 只是把它固定下来让每个人写的时候不用再纠结。2.4 命名约定的硬性要求Easier UVM 对命名有明确要求类名用全小写加下划线比如my_driver、my_seq_item实例名也用全小写加下划线宏用全大写config 对象统一以_config结尾sequence 统一以_seq结尾。这些约定看起来琐碎但它们是代码可检索性的基础。当你在一个几十万行的验证环境里找某个 driver 的配置时能靠命名规律快速定位比靠记忆靠谱得多。3. 把指南落到工程factory、config、phase 的实操配置理解了模板之后下一步是把这些模板串成一个能跑的验证环境。Easier UVM 在 factory 覆盖、config 传递、phase 顺序这三个方面给出了具体的操作建议这些建议直接影响到环境能不能复用、好不好调试。3.1 factory 覆盖的正确打开方式UVM factory 的核心价值是你可以在不修改原始类代码的前提下用子类替换父类。Easier UVM 推荐的做法是在 test 的build_phase里做类型覆盖而不是在 env 或更低的层次里做。原因是 test 是验证环境的最高层覆盖决策应该集中在顶层而不是散落在各个组件里。// 在 test 中做 factory 覆盖 class my_test extends uvm_test; uvm_component_utils(my_test) function void build_phase(uvm_phase phase); super.build_phase(phase); // 用 my_driver_ext 替换 my_driver my_driver::type_id::set_type_override(my_driver_ext::get_type()); endfunction endclass这里用的是set_type_override它是全局覆盖所有my_driver的实例都会被替换。如果只想替换某个特定实例用set_inst_override传入实例的路径。Easier UVM 的建议是优先用 type override因为它的影响范围明确调试时容易追踪inst override 只在确实需要差异化替换时使用。一个常见的翻车场景是在 env 里做了覆盖但 test 里又做了一次两次覆盖冲突最终生效的是最后注册的那个。这种问题在大型环境里非常难查因为覆盖是隐式的代码里看不到直接调用。Easier UVM 的对策是把覆盖集中到 test并且建议在覆盖前后加uvm_info打印方便确认覆盖是否生效。3.2 config 数据库的层次化传递config 数据库是 UVM 里传递配置信息的主要机制。Easier UVM 推荐的做法是config 对象在 test 的build_phase里创建并随机化然后通过uvm_config_db::set往下传。组件在build_phase里用get获取获取失败就报 fatal。// test 中设置 config class my_test extends uvm_test; uvm_component_utils(my_test) my_config cfg; function void build_phase(uvm_phase phase); super.build_phase(phase); cfg my_config::type_id::create(cfg); if (!cfg.randomize()) uvm_fatal(RANDFAIL, cfg randomization failed) uvm_config_db#(my_config)::set(this, env.agent.driver, cfg, cfg); endfunction endclassset的第二个参数是路径支持通配符。env.agent.driver表示只传给这个路径下的 driver如果用*则传给所有组件。Easier UVM 建议尽量用精确路径避免用通配符因为通配符会让配置的来源和去向变得模糊。另外config 对象在set之前先randomize这样约束集中在一处组件拿到的时候已经是最终值不需要在组件内部再做随机化。3.3 phase 顺序与 objection 机制UVM 的 phase 机制是自动执行的但 phase 之间的顺序、哪些 phase 是 task 哪些是 function这些细节如果不注意很容易写出竞争条件。Easier UVM 的建议是build_phase只做结构创建和配置获取不做任何耗时操作connect_phase只做 TLM 连接run_phase里用forever循环处理事务配合 objection 机制控制仿真结束。// 在 sequence 中使用 objection class my_seq extends uvm_sequence #(my_seq_item); uvm_object_utils(my_seq) task body(); uvm_phase phase get_starting_phase(); if (phase ! null) phase.raise_objection(this); repeat (10) begin uvm_do(req) end if (phase ! null) phase.drop_objection(this); endtask endclassobjection 的作用是告诉 UVM“我还有事没做完先别结束仿真。”如果没有 raise objection仿真可能在 sequence 还没发完激励的时候就结束了。Easier UVM 推荐在 sequence 的body()里 raise/drop而不是在 test 的run_phase里因为 sequence 才是真正产生激励的地方谁产生激励谁负责 objection责任边界清晰。注意raise 和 drop 必须成对出现且 drop 的次数不能多于 raise。如果 sequence 在中途被 killobjection 可能没有正常 drop导致仿真挂死。常见做法是在body()里用fork...join包一层确保异常退出时也能 drop。4. 避坑与排查五个真实项目里踩过的坑指南读起来是一回事落到项目里是另一回事。下面这五个坑是我在多个 UVM 项目里反复见到的每一个都对应 Easier UVM 里某条看起来不起眼但实际很关键的建议。4.1 坑一factory 注册宏写错create 返回 null现象my_driver::type_id::create(driver, this)返回 null仿真在 build_phase 就挂掉。原因类里忘了写uvm_component_utils(my_driver)或者宏里的类名和实际类名不一致。factory 里没有注册这个类create 自然找不到。解决检查每个 component 和 sequence item 类是否有对应的注册宏宏参数必须和类名完全一致。如果用了参数化类宏要写在参数化类内部且参数要匹配。4.2 坑二config 数据库 set 和 get 的类型不匹配现象uvm_config_db#(my_config)::get返回 0组件报 fatal。原因set 的时候用的是uvm_config_db#(my_config)::set但 get 的时候写成了uvm_config_db#(uvm_object)::get类型不匹配get 不到。解决set 和 get 的类型参数必须完全一致。Easier UVM 建议 config 对象统一用具体的类型不要用uvm_object这种基类类型。另外set 的路径和 get 的路径要能对上get 的时候用this作为上下文set 的时候路径要包含 get 所在的层次。4.3 坑三sequence 里用了uvm_do但没 raise objection现象仿真跑了几纳秒就结束了sequence 里的激励只发了一两个。原因run_phase是 task phase如果没有 objectionUVM 认为没有事情要做直接跳到check_phase和report_phase。解决在 sequence 的body()里 raise objection发完激励再 drop。如果 sequence 是在 test 的run_phase里启动的也可以在 test 里 raise但 Easier UVM 更推荐在 sequence 内部处理因为这样 sequence 可以独立控制自己的生命周期。4.4 坑四TLM 端口在new()里创建导致空指针现象connect_phase里访问ap端口时报 null pointer。原因TLM 端口在new()函数里new了但new()的执行时机早于build_phase此时组件的层次结构还没建立端口无法正确绑定。解决TLM 端口一律在build_phase里创建。Easier UVM 的 component 模板明确要求new()只做super.new()所有需要层次结构的操作都放到build_phase。4.5 坑五phase 里用了#10这种绝对延时现象仿真结果和预期不符换一个仿真器结果又变了。原因在build_phase或connect_phase里用了#10这种延时但这两个 phase 是 function phase不允许消耗仿真时间。即使在某些仿真器里不报错行为也是未定义的。解决function phase 里不做任何延时所有延时操作放到run_phase或main_phase里。Easier UVM 的建议是把 phase 分成两类结构类 phase 只做零时间操作运行类 phase 才处理时间相关逻辑。5. 从指南到生成器用代码模板批量产出合规代码Easier UVM 除了 PDF 指南还配了一个 Apache 2.0 许可的代码生成器。这个生成器的价值在于它把指南里的模板变成了可执行的代码产出工具你输入组件名和类型它直接吐出符合规范的骨架代码。对于团队来说这意味着新人在写第一个 driver 的时候不需要从头翻指南直接生成再填逻辑就行。5.1 生成器的输入与输出生成器的典型用法是命令行调用传入组件类型和名称。常见做法是# 生成一个 driver 骨架 easier_uvm_gen -type driver -name my_driver -output ./src/ # 生成一个 sequence item easier_uvm_gen -type seq_item -name my_item -output ./src/ # 生成一个完整的 agent包含 driver、monitor、sequencer easier_uvm_gen -type agent -name my_agent -output ./src/生成出来的文件已经包含了 factory 注册、config 句柄、TLM 端口声明、phase 方法骨架你只需要在drive_one或body里填具体逻辑。参数说明-type指定组件类型支持 driver、monitor、agent、env、test、seq_item、sequence-name指定类名生成器会自动推导文件名和实例名-output指定输出目录。5.2 生成器与手工代码的边界生成器只负责骨架不负责逻辑。scoreboard 的比较逻辑、coverage 的采样逻辑、sequence 的激励构造这些都需要手工写。Easier UVM 在指南里明确说了生成器产出的是 boilerplate项目特定的逻辑才是工作量的大头。所以不要指望生成器能解决所有问题它的定位是消除重复的模板代码让你把精力集中在真正需要思考的地方。5.3 把生成器接入 CI 流程如果团队已经有一套 CI 流程可以把生成器接入进去。常见做法是在代码提交前跑一遍生成器检查新增的组件是否符合模板规范或者用生成器产出的代码作为基线手工修改的部分用 diff 对比确保没有偏离模板太远。这个做法在多个项目里验证过能有效减少代码评审时因为风格问题产生的来回拉扯。提示生成器的输出目录建议和手工代码目录分开比如gen/放生成代码src/放手工代码。这样升级生成器版本时不会覆盖手工修改的内容。5.4 验证生成代码是否合规生成器产出的代码默认符合 Easier UVM 指南但如果你手工改了之后怎么确认没有破坏规范一个实用的方法是写一个简单的 lint 脚本检查几个关键点每个 component 类是否有uvm_component_utils宏每个 sequence item 是否有uvm_object_utils宏build_phase里是否有super.build_phase(phase)调用TLM 端口是否在build_phase里创建。这些检查用正则表达式就能做不需要复杂的静态分析工具。# 简单的合规检查脚本片段 import re def check_component(filepath): with open(filepath) as f: content f.read() issues [] if not re.search(ruvm_component_utils\s*\(, content): issues.append(missing uvm_component_utils macro) if build_phase in content and super.build_phase not in content: issues.append(build_phase missing super call) return issues这个脚本的逻辑很直接读文件内容用正则匹配关键模式返回问题列表。参数方面filepath是待检查的 SystemVerilog 文件路径。实际使用时可以遍历整个src/目录对每个.sv文件跑一遍把问题汇总输出。这个脚本不能替代完整的 lint 工具但作为提交前的快速检查足够拦住大部分低级错误。从那以后我每次新建 UVM 组件都强制先跑一遍生成器再在生成代码的基础上改。手工从零写不是不行但没必要——模板代码写多了容易漏掉super.build_phase或者忘记注册宏这些坑踩一次就够了。希望这份拆解能帮到你少走几个弯路。本文还有配套的精品资源点击获取

相关新闻

AI 把 C 改写成 Python,功能就迁移了吗?跨语言改写如何验证行为一致

AI 把 C 改写成 Python,功能就迁移了吗?跨语言改写如何验证行为一致

从“代码改写出来了”到“功能迁移完成”,中间还差一份行为证据。 用 AI 把 C 代码改写成 Python,最直观的变化是文件换了后缀,函数也能运行了。但调用方真正依赖的是:同样的输入得到什么结果,错误如何报告&#xff0c…

2026/10/14 10:06:21 阅读更多 →
绯色回响拉斐拉角色介绍 绯色回响拉斐拉技能详解

绯色回响拉斐拉角色介绍 绯色回响拉斐拉技能详解

绯色回响拉斐拉凭借独特的一体双魂形态机制,兼顾护盾保护与输出能力,在不少攻坚阵容里站稳了核心位置。绯色回响拉斐拉拥有白、红两套形态,技能体系围绕“死亡感知”层层叠加,双形态在回合结束自动切换,想看清双形态切…

2026/10/14 10:06:21 阅读更多 →
线上出错,先让 AI 修代码还是回滚?故障排查与恢复决策怎么分工

线上出错,先让 AI 修代码还是回滚?故障排查与恢复决策怎么分工

先判断恢复路径,再决定修复代码的节奏 线上故障发生后,AI 很快给出一个补丁,团队就会遇到一个容易被忽略的问题:有了修复建议,是否就应该立即发布? 这个问题需要同时考虑两件事:补丁能否解决技…

2026/10/14 10:06:21 阅读更多 →

最新新闻

Python实现重复文档查找与清理工具

Python实现重复文档查找与清理工具

1. 重复文档查找工具的设计与实现在日常工作中,我们经常会遇到文档重复的问题,特别是从QQ、微信等平台接收的文件,经常会在不同文件夹中保存多份相同内容但不同名称的文件。这些重复文件不仅占用宝贵的存储空间,还会导致文件管理混…

2026/10/14 11:02:10 阅读更多 →
SpringBoot文旅系统开发:微服务架构与智能推荐实践

SpringBoot文旅系统开发:微服务架构与智能推荐实践

1. 项目背景与核心价值 绍兴作为拥有2500年建城史的文化名城,其"水乡、桥乡、酒乡、书法之乡"的文旅资源数字化呈现具有典型示范意义。这个基于SpringBoot的旅游系统毕业设计,实际上构建了一个可复用的区域文旅数字化解决方案原型。我在参与某…

2026/10/14 11:02:10 阅读更多 →
自考备考:8款工具有效降低AI误判率

自考备考:8款工具有效降低AI误判率

1. 项目背景与核心价值作为一名连续5年参与自学考试辅导的从业者,我深刻理解考生在备考过程中面临的核心痛点。2023年某省自考办统计数据显示,78%的考生在主观题作答时因表述不规范被系统误判为AI生成内容,导致平均每科损失12-15分。这种现象…

2026/10/14 11:02:10 阅读更多 →
AI辅助自考工具测评:如何科学选择与使用

AI辅助自考工具测评:如何科学选择与使用

1. 自考备考新趋势:AI辅助工具的崛起与选择困境作为一名连续三年跟踪自考工具演变的备考顾问,我亲眼见证了AI技术如何重塑自学考试的准备方式。2023年以前,考生们还在用纸质笔记和传统题库;而到了2025年,已有78%的自考…

2026/10/14 11:02:10 阅读更多 →
给AI Agent配一套Skills工程工作流:从玩具到工具的落地指南

给AI Agent配一套Skills工程工作流:从玩具到工具的落地指南

最近一段时间,AI Agent 的热度又起来了,但真正能把 Agent 用进生产环境的人其实不多。我见过太多团队把 Agent 当成一个“聊天机器人”来调,结果 Chat 的时候什么都好,一放到工作流里就怎么都不对。今天想聊的这套东西&#xff0c…

2026/10/14 11:02:10 阅读更多 →
C++字符类型char与wchar_t深度解析

C++字符类型char与wchar_t深度解析

1. 理解C中的字符类型本质在C开发中,字符处理是每个程序员都会遇到的基础问题。char和wchar_t看似简单,但背后涉及字符编码、国际化支持等复杂概念。我们先从内存层面理解它们的物理差异:char类型保证至少8位宽度,这源于C语言的遗…

2026/10/14 11:01:09 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/14 7:53:34 阅读更多 →