
写这篇速读之前我想先聊聊我自己的反应。刚看到这个题目的时候我愣了一下因为绝大多数SystemVerilog使用者包括干了多年的验证工程师都是在“写测试平台”而不是“写仿真器”。用SystemVerilog去实现一个真正的模拟事件驱动仿真引擎相当于用锤子造锤子这件事本身就很有意思。它不光是秀技巧更是把IEEE 1800-2017标准里那套调度语义用一种极其朴素的方式重新实现了一遍。读完这套思路之后我最大的感受是以前调试竞争冒险、理解delta cycle、排查UVM的report顺序问题时都是靠经验和直觉但现在我真正看得见它们是怎么发生的了。这篇内容适合两类人一类是天天被仿真器行为折磨的验证工程师另一类是对硬件仿真器内部机制感兴趣的开发爱好者。不需要你会写编译器也不需要你精通UVM源码只要你写过always (posedge clk)并且被#0坑过这篇文章里的每一段你都能看懂而且看完之后你会对事件驱动这件事有完全不同的理解。1. 整体设计与思路拆解1.1 这到底是在“模拟”什么题目里的关键词是“模拟事件驱动仿真”。注意这里的“模拟”不是指仿真工具在做模拟而是说我们自己写一个调度引擎用软件的方式去复刻标准仿真器的行为。也就是说我们现在是“仿真器的仿真器”。具体来说我们要在SystemVerilog内部维护一套抽象的逻辑时间、事件队列和delta cycle调度机制。被测对象不再是DUT而是一些被我们挂到引擎上的“仿真单元”比如一个组合加法器模型、一个触发器模型。这些单元的延迟行为不通过SV自带的#延时实现而是通过向引擎提交“未来事件”来体现。这里最重要的是时间解耦。写这段代码的时候脑子里必须时刻记住两套时间宿主仿真器的时间也就是跑我们代码的那个仿真器的物理时间和引擎内部维护的逻辑时间。两者完全独立。引擎里的时间是一个longint unsigned变量推进它不需要经过宿主仿真器的调度器而是完全由我们自己控制的while循环来驱动。1.2 为什么选择SystemVerilog当宿主语言想做一个事件驱动的模拟器最自然的想法是用Python、C或者Rust来写。这些语言做数据结构和事件循环都很顺手。但用SystemVerilog做有一个不可替代的优势需要模拟的模型RTL行为本身就是用SystemVerilog描述的。这意味着我们可以把被仿真对象的代码和仿真引擎放进同一套类型系统里。你不用做跨语言封装不用通过DPI把C对象映射到SV对象也不用处理Python和验证环境之间的类型转换。模型里的信号、变量、事件跟引擎里的队列、时间戳天然就是同一个世界的东西。SystemVerilog语言本身也提供了足够的“原料”class支持继承和多态正好用来实现事件回调queue和associative array可以支撑事件队列和时间索引fork...join_none能模拟并发进程的部分行为。当年我读完论文里的思路第一反应就是“哦原来SV这些特性凑在一起真能拼出一个调度器来”。当然缺点也很明显SV没有泛型没有标准库容器动态数组和关联数组在事件规模变大之后性能会比较感人。但作为教学实验、调度语义研究和验证方法学探索这种代价完全可以接受。这个项目本来就不是为了跟商业仿真器比性能而是为了把“事件驱动”这件事彻底看明白。1.3 引擎的整体架构整个引擎的架构可以拆成三层看待。最底层是事件对象。一个事件本质上是一个带时间戳、优先级和回调行为的记录。在SV里我用一个sim_event基类里面存time_stamp、region_id和order然后通过虚方法do_execute()让子类实现具体行为。中间层是调度队列。我用了一个以时间为键的关联数组每个键下面挂一个事件队列。这样按时间推进时可以快速找到当前时间点的事件集。最上层是调度主循环。它不断从队列中取最小时间戳的事件推进当前时间然后执行该时间点上所有delta cycle的事件直到这个时间点的活跃事件全部清空再跳到下一个时间点。这三层很难说哪一层更重要。但从我自己的实现经验来看最容易被忽略、也最容易出bug的恰恰是最底层的事件对象——特别是回调的生命周期管理。这个问题后面我会专门讲。2. 核心细节解析与实操要点2.1 IEEE调度语义如何映射到代码标准SystemVerilog仿真器的调度机制是理解所有时序问题的基石。IEEE 1800里把仿真的推进分成若干个region每个时间槽内按固定顺序执行Active区域先执行然后是Inactive、NBA、Observed、Reactive等。这保证了像非阻塞赋值这样的操作会在当前时间槽稍后的NBA阶段才更新目标变量。在自研引擎里我没法真的去调用宿主仿真器的这些region那需要VPI或者PLI而且这就失去意义了。所以我用的是另一种建模方式给每个事件打一个region_id然后在同一个时间点内按region_id从小到大批量执行。我的简化设计只用了五个region但已经足够复现绝大多数行为region_id名字对应语义0Active阻塞赋值、always_comb的立即更新1Inactive#0延时语句的更新操作2NBA非阻塞赋值右值采样后的更新3Observed断言采样点SV标准里的监控区域4ReactiveUVM的report、objection等后续反应这样做的效果是同一个时间点上如果Active阶段产生了一个新事件而这个新事件仍然属于Active阶段它并不会在当前这批处理中立刻执行而是等当前Active队列清空后作为新一轮delta cycle再执行。这跟标准仿真器的“当前时槽内反复调度直到稳定”是完全一致的。2.2 事件对象的字段设计与优先级规则事件对象是引擎的最小数据单元。我在实现里给它的字段设计如下class sim_event; longint unsigned time_stamp; int region_id; longint unsigned order; string event_name; protected bit executed; function new(string name ); event_name name; executed 0; endfunction virtual task execute(); // 子类必须重写执行回调行为 endtask endclass这里容易被忽略的是order字段。它代表事件入队的全局序号用来处理“相同时间戳、相同region”的事件顺序。标准仿真器对同一region内的执行顺序是未定义的但工程上总要有一个确定性尤其是做回归对比的时候。我用一个引擎级的单调递增计数器每次插入事件时分配order排序时先按时间戳、再按region_id、最后按order。这样引擎的调度行为每次跑都是可复现的。还有一个细节executed位。按理说事件出队就一定会被执行但我在调试时发现有些事件在处理过程中会被其他逻辑取消或者覆盖。保留这个标志是为了在出队后、执行前做一次检查避免重复执行已经失效的回调。2.3 回调对象的生命周期最容易被坑的地方事件执行本质上就是调用一个回调。SV里没有真正的函数指针我用的方案是继承多态每种需要被引擎调度的行为都写一个sim_event的子类在execute()里调用目标模型的更新方法。这听上去简单实际写的时候却容易踩两个坑。第一个坑是悬挂引用。如果事件的execute()里持有某个模型对象的句柄而这个模型对象在事件执行前被销毁了SV里不会像C那样直接崩溃而是在运行时报null handle访问错误。更麻烦的是SV没有RAII所有动态对象都需要靠你自觉管理生命周期。我的建议是所有注册到引擎的事件要么持有目标对象的句柄并保证目标对象存活到引擎run()结束要么干脆用浅拷贝把需要的状态复制进事件对象里。第二个坑是回调里又插入事件。这在逻辑上完全合法比如更新输出信号后又触发下游组合逻辑。但如果实现不小心很容易造成当前正在处理的队列被修改导致遍历时跳过或者重复执行事件。我后文会讲到具体的处理办法。提示如果你只是想复现一个简单模型建议先把回调设计成“只读取其他信号当前值然后更新自己的输出”这种纯函数式风格。等这个跑通了再逐步引入状态和跨事件互动。2.4 时间索引结构关联数组还是排序队列调度引擎的核心数据结构我纠结了一阵子。一开始我用的是一个sim_event队列每次插入后按时间排序取的时候直接从头部pop。逻辑最简单但性能很差每插入一个事件都伴随着O(n)的排序或者插入操作。后来改成关联数组protected sim_event time_slots[longint unsigned][$];用时间戳当键每个键下面挂一个队列。新事件直接append到对应时间点的队列尾部。主循环取最小时间戳时只需要遍历关联数组的键找到最小值键数量远小于事件总数然后处理该键下的整个队列。事件数量大时这种结构的效率比排序队列高很多。遍历关联数组找最小键在SV里有一个小技巧longint unsigned keys[$]; longint unsigned min_key; if (time_slots.num() 0) begin keys time_slots.keys(); min_key keys.min(); endkeys()返回所有键组成的队列min()直接取最小值。这个操作虽然是O(n)但n是时间点数量而不是事件总数。在很多模型里时间点是稀疏的这个优化非常值得。2.5 为什么不用SV自带的#延时和事件你可能想问既然宿主环境就是SystemVerilog仿真器为什么不直接利用它自带的#延时和事件来模拟事件驱动这样不是更省事吗这个问题的答案恰恰是这套实现的核心价值所在。如果用了宿主环境的#和那写出来的东西就不是一个独立的事件驱动模拟器了它只是在织一张更复杂的测试平台网。宿主仿真器怎么调度delta cycle、怎么处理region之间的顺序全部隐藏在工具内部你根本看不见。而我们想要的是“真正的模拟”。这意味着引擎内部的逻辑时间推进、delta cycle翻转、region执行顺序都必须由我们自己代码里的数据结构来控制。宿主SV只负责提供容器和基本语法能力不参与调度决策。这就像你想研究CPU的流水线行为你不能直接拿一块真实CPU来测而是要自己写一个流水线模拟器让每个阶段的信号变化都暴露出来。3. 实操过程与核心环节实现3.1 最小引擎骨架下面这个代码块是一个可运行的最小引擎骨架不包含具体模型。我把它拆成三段事件基类、引擎类、主循环。为了不跑题我砍掉了大量防御性检查只保留核心逻辑。class sim_event; longint unsigned time_stamp; int region_id; longint unsigned order; string event_name; protected bit executed; function new(string name ); event_name name; executed 0; endfunction virtual task execute(); // subclass override endtask function bit is_done(); return executed; endfunction endclass class sim_engine; protected sim_event time_slots[longint unsigned][$]; protected longint unsigned current_time; protected longint unsigned global_order; protected int current_delta; protected int max_iterations 1000; function void insert_event(sim_event e); longint unsigned t; t e.time_stamp; e.order global_order; time_slots[t].push_back(e); endfunction protected function longint unsigned get_min_time(); longint unsigned keys[$]; if (time_slots.num() 0) return 0; keys time_slots.keys(); return keys.min(); endfunction protected task process_time_slot(longint unsigned t); sim_event e; int spin_count; current_time t; current_delta 0; // 一个时间点上可能有多轮delta cycle while (time_slots.exists(t) time_slots[t].size() 0) begin spin_count; if (spin_count max_iterations) begin $display([Engine] FATAL: zero-delay loop detected at time %0t, t); break; end // 处理当前region队列中的所有事件 while (time_slots.exists(t) time_slots[t].size() 0) begin e time_slots[t].pop_front(); if (!e.is_done()) begin e.execute(); end end current_delta; end // 清空后删除该时间键以免影响后续最小时间计算 time_slots.delete(t); endtask task run(); longint unsigned next_t; while (time_slots.num() 0) begin next_t get_min_time(); process_time_slot(next_t); end endtask endclass这段代码看起来很短但里面藏了好几个关键细节。第一个关键点是process_time_slot里的外while循环。它保证了一个时间点上的delta cycle可以反复执行直到没有新事件生成。这是事件驱动仿真“迭代到稳定”的核心。第二个关键点是time_slots[t].size() 0的判断。每次内层while执行完一批事件外层while会再检查该时间点是否又有新事件进来。如果有说明刚执行的这批事件触发了新的零延迟更新比如组合逻辑级联那么再来一轮。第三个关键点是max_iterations。它是我用来防止组合逻辑振荡导致死循环的保险丝。真实RTL里如果出现assign a ~a这种组合环标准仿真器会报错或者卡死。我的引擎作为一个教学工具用了最简单的策略同一时间点delta迭代次数超过上限就直接报错退出。3.2 挂一个组合逻辑模型上去光有引擎还不够得让它能跑实际的逻辑模型。这里我写一个最简单的组合加法器模型用来演示事件插入和回调执行。class adder_model extends sim_event; logic [7:0] a; logic [7:0] b; logic [8:0] sum; sim_engine engine; function new(sim_engine eng, string name adder); super.new(name); engine eng; event_name name; endfunction task set_inputs(logic [7:0] new_a, logic [7:0] new_b); a new_a; b new_b; // 输入变化后向引擎提交一个Active region事件 schedule_update(); endtask protected function void schedule_update(); this.time_stamp engine.get_current_time(); this.region_id 0; // Active engine.insert_event(this); endfunction virtual task execute(); sum a b; this.executed 1; $display([Adder] time%0t delta%0d sum%0d, engine.get_current_time(), engine.get_delta(), sum); endtask endclass注意这里的一个设计选择adder_model本身既是模型又继承了sim_event。这意味着同一个对象既是“被仿真对象”也是“事件”。它的execute()就是模型的更新函数。这种写法比较紧凑但不适合复杂模型。复杂模型里我建议把事件和模型拆成两个类事件里持有模型句柄在execute()里调用模型的方法。这样模型可以同时被多个事件触发而不会出现一个模型对象只能挂在一种事件下的限制。使用这个模型的测试流程大概是module top; sim_engine engine; adder_model adder; initial begin engine new(); adder new(engine, adder_inst); // 逻辑时间0时刻注入一次输入变化 adder.time_stamp 0; adder.region_id 0; engine.insert_event(adder); engine.run(); end endmodule3.3 时序逻辑模型非阻塞赋值与NBA区域组合逻辑只需要Active区域就够了但时序逻辑必须严格处理非阻塞赋值的两阶段行为先采样、后更新。我的引擎里用NBA区域模拟这个两阶段过程。以一个D触发器为例class dff_model extends sim_event; logic clk; logic d; logic q; logic sampled_d; sim_engine engine; function new(sim_engine eng, string name dff); super.new(name); engine eng; endfunction task posedge_clk(); // 时钟上升沿到来时先采样d sampled_d d; // 然后提交一个NBA事件延迟更新q schedule_nba_update(); endtask protected function void schedule_nba_update(); this.time_stamp engine.get_current_time(); this.region_id 2; // NBA engine.insert_event(this); endfunction virtual task execute(); if (this.region_id 2) begin q sampled_d; $display([DFF] time%0t q%0d, engine.get_current_time(), sampled_d); end this.executed 1; endtask endclass这里我用逻辑变量sampled_d模拟了标准仿真器里NBA右值采样的动作。在时钟沿事件里先把输入采进临时变量等到NBA区域执行的时候再把值赋给q。为什么不能直接在时钟沿事件里q d因为在标准仿真器里如果多个触发器在同一个时钟沿触发而它们之间存在组合逻辑路径另一个触发器从q采样到的值必须是更新前的旧值。如果没有两阶段机制就会出现采样到新值的情况这跟真实硬件的行为是相悖的。这个例子非常直观地说明了为什么IEEE标准要把NBA单独设成一个region。不是出于学术洁癖而是因为如果不这么做经典的主从触发器和流水线行为根本无法正确建模。4. 验证与对比自研引擎 vs 标准仿真器4.1 验证思路与测试用例设计写引擎不是目的让引擎表现得像个真正的仿真器才是目的。所以我做了对比验证同一组RTL模型分别用我的引擎驱动和直接用标准SystemVerilog仿真器跑一遍原生描述然后对比每个时间点上的信号值。我设计了三个测试用例来覆盖不同层次的问题组合逻辑级联一个加法器输出接另一个加法器输入导致同一个时间点需要多轮delta cycle才能稳定。D触发器加反相器反馈经典的分频器结构检验NBA语义是否正确。纯组合反馈环assign a ~a;用来测试死循环检测机制是否可靠。这三个用例覆盖了事件驱动仿真的三个核心挑战delta cycle收敛、时间点内状态更新顺序、异常终止保护。4.2 对照结果与行为差异分析测试1的结果让我挺满意的。引擎连跑三轮delta cycle后得到稳定值跟标准仿真器的波形在数值和时间点上完全一致。唯一的区别是时间戳的显示粒度标准仿真器把delta cycle隐式处理了引擎把每一轮都打印出来了。这就好比一个人告诉你“3点整结果变成42”另一个告诉你“3点整第一轮算出来38第二轮算出来41第三轮才算出来42”。后者信息量大很多。测试2出现了值得思考的现象。模拟分频器的时候如果我在时钟沿事件里直接执行q sampled_d分频器输出的翻转频率是对的但下一个触发器采样到的q值偶尔会不对。就是因为更新提前发生了。改成NBA事件之后行为恢复正确。这个例子特别适合给刚入门的同事演示为什么非阻塞赋值不能随便改成阻塞赋值。测试3的反馈环引擎在1000轮迭代后打出了FATAL日志然后停下来。标准仿真器对这类写法会给出“零延迟循环”的编译警告或者直接跑到超时。本质上我们都在做同一件事识别出这个模型没有稳定点然后拒绝继续白费力气。4.3 行为差异速查表测试项我的引擎表现标准仿真器表现组合级联时间点内多轮delta最终值一致值一致内部分辨率更高DFF反相器NBA两阶段采样更新严格分离行为一致组合反馈环1000轮后报错终止编译警告或运行超时同一region内相对顺序按全局order确定可复现标准里未定义工具实现不定表格最后一行值得多说一句。我特意让引擎的同一region事件按order排序执行是因为在做回归对比时不确定性是最大的敌人。商业仿真器未必保证同一个region内两个事件的顺序但很多验证环境隐式依赖了顺序。如果哪天换个仿真器版本导致顺序变了测试可能莫名其妙失败。我的引擎在这个维度上是确定性的这在调试和对比中反而很有优势。5. 常见问题与排查技巧实录5.1 活锁与自旋迭代上限不只是保险丝我最早跑反馈环测试的时候没有设max_iterations结果就是宿主仿真器直接卡死。不是死循环那种卡死而是我的引擎在同一个时间点疯狂执行空事件日志刷屏。后来加上上限又发现一个问题上限设得太小会误报。正常的组合级联在信号链路过长时delta迭代次数也会比较多。我当时用默认1000迭代测试一个16位行波进位加法器模型结果还没算到最终进位就触发了FATAL。最后我把上限改成可配置参数并让引擎在触发前打印当前已执行的事件数量和信号状态这样能区分是真正的振荡还是模型深度过大。排查技巧如果FATAL触发不要急着调大上限先看日志里反复执行的事件名。如果事件名循环固定基本就是反馈环如果事件名一直在往前延伸那更像深度链路的正常迭代。5.2 队列遍历时插入导致的事件丢失这是一个非常隐蔽的bug。我一开始在process_time_slot里用的是foreach遍历time_slots[t]然后在回调里往同一个时间点插入新事件。SV的foreach在遍历动态队列时如果队列长度变了行为是不保证的。最典型的表现是刚插入的新事件被跳过或者旧事件被重复执行。改成pop_front的while循环后问题彻底消失。因为pop_front每次取队首元素执行完它新push_back的元素天然排到队尾下一轮循环自然就会被取到。这个改动虽然只差几行代码但直接决定了引擎的正确性。注意如果你在某个时间点处理过程中又插入了同一个时间点但region_id更小的事件那就意味着出现了“时间倒流”。这在标准仿真器里不会发生我的引擎里同样需要禁止。我在insert_event里加了一条断言插入事件的time_stamp必须大于等于当前正在处理的time_slot时间戳。否则直接报错。5.3 fork...join_none线程泄漏与并发状态冲突有些事件回调里我一开始图省事用了fork...join_none想着让回调异步执行。结果就是多个事件的执行线程交错共享变量被同时修改导致状态完全错乱。比如说DFF的q可能同时被两个事件的线程改写。排查这个问题的过程很痛苦。表面上看的事件顺序是对的但数值偶尔错。后来我把所有回调改成完全串行执行在事件循环里不创建任何fork问题就消失了。这里要分清一个层次引擎内部所有事件的执行必须串行。如果你想模拟多个并行进程必须在引擎的事件执行逻辑之上另建并发模型而不是直接在事件回调里开fork。事件循环本身要像单线程CPU一样一次只执行一条指令。5.4 时间戳边界与浮点陷阱我的引擎逻辑时间用longint unsigned没有用实数所以整数时间点的边界处理比较简单。但如果你去复现一个带小数延时的模型就要小心浮点误差。两个事件的时间戳分别是4.999999和5.000000在浮点比较下可能被认为是两个时间点导致执行顺序错误。我的建议是逻辑时间全部用整数内部统一乘一个精度系数。比如最小时间单位为1ps那么1ns的时间戳就是1000。这样既避免了浮点比较的麻烦也让日志和时间戳的含义更清楚。5.5 调试日志的最佳实践最后分享一个我自己的调试习惯。引擎跑起来之后我开启两种日志级别。第一种是“事件级”日志打印每个事件出队、执行、入队的时间、region、名称。第二种是“信号级”日志打印每个模型的输入输出变量变化。两种日志交替看基本能在几分钟内定位到问题。关键是时间戳和delta编号要对齐。我在每个事件打印时都输出time%0t delta%0d。如果两个日志里delta编号对不上说明事件被错误地跨delta执行了。这种定位方式比盯着波形猜要快得多。我做了个小工具函数统一打包这些信息function void debug_event(sim_event e); $display([Event] %0t / delta%0d / region%0d / %s / order%0d, e.time_stamp, engine.get_delta(), e.region_id, e.event_name, e.order); endfunction个人心得与进一步扩展这套引擎我前后改了三个版本从最初的排序队列到现在的关联数组时间槽从事件里直接开fork到全串行执行每次重构都让我对仿真器的理解加深一层。以前我调试UVM环境里莫名其妙的report顺序问题时只会加uvm_info去猜现在我脑子里会有一个清晰的画面某个时间点的Observed region里谁先谁后是完全可以推算出来的。最后再说一个我觉得很实用的扩展方向给引擎加上随机扰动注入功能。既然引擎的所有调度都是显式的我完全可以在事件出队时以一定概率调整order模拟不同仿真器对同一region内事件顺序的差异用来测试验证环境对调度顺序的敏感性。这个功能在真实仿真器上几乎没法做但在自己的引擎里只需要改几行代码。这套思路比我最初预期的还要有延展性。