☰
SystemVerilog事件驱动模拟仿真:让连续信号在数字调度器中自然演化
2026/10/27 1:02:24 网站建设 项目流程

做数字验证很多年的朋友,听到“用SystemVerilog跑真正的模拟事件驱动仿真”这种说法,第一反应多半是先愣一下,然后满脸问号:SystemVerilog不是数字世界的语言吗?写写约束、搭搭UVM环境、跑跑回归都行,它跟模拟仿真有什么关系?我最初看到这篇带MSDV标签的论文速读,反应也是同一个。但认真读下来才发现,这个思路恰好戳中了混合信号验证里一个特别痛的部位。

这篇工作是讲怎么把连续时间系统——RC充放电、运放、比较器、PLL这些模拟行为——用一种“事件驱动”的方式搬进SystemVerilog仿真器。它不接SPICE混仿,也不是那种固定步长的离散近似,而是让SV自己的调度器在“该醒来的时刻”醒来,做完状态更新就继续睡。听起来像魔法,其实原理并不复杂,难的是把脑子里那套“模拟仿真理所当然就是要小步长推进”的惯性扔掉。

如果你正在折腾数模混合验证、做行为级模型,或者为验证环境里那些“不跑完SPICE就推不下去”的模拟模块发愁,这篇速读值得看完。我会按自己的理解把这套方法的逻辑拆开,把实现细节、示例代码和路上踩过的坑一并铺开,尽量做到看完能自己动手试一版。

1. 想用SV挑战模拟仿真的人,先看看自己有多痛

先说清楚,这不是闲着没事找事。任何在真实项目中跑过数模混合验证的人,都应该体会过下面这种反复拉扯的痛。

1.1 传统混合信号验证卡在哪里

模拟电路仿真的传统路径是SPICE类工具的做法:把电路离散成极小的时间步,每步求解一组非线性方程组。精度是高,但速度是灾难。一个PLL锁定过程要跑到几百微秒,晶体管级SPICE仿真动辄跑几天几夜,这是常态。

到了数模混合芯片里,问题更复杂。数字模块和模拟模块必须同步仿真,于是有了几种主流路线,各有各的难处:

方案精度速度环境集成度现实问题
SPICE瞬态分析晶体管级,最高极慢几乎为零验证周期完全被模拟部分拖死
外部混合仿真中高慢一般两套仿真器之间时间同步、license、流程复杂度都是负担
SV实数建模(RNM)中快高精度和速度靠人去权衡,建模质量参差不齐

第三种方案大家应该很熟了,也就是用SV里的real类型把模拟信号数字化地搬进验证环境。RNM的优点是能放进UVM、能跑回归、能和数字逻辑无缝对接,但它的推进方式通常还是“每个时钟周期固定推进一次”或者“每隔固定间隔采样一次”。

问题就出在这:模拟行为本身是连续的,你强行给它套一个数字时钟的节拍,本质上还是在“按时钟离散化”,只是把步长从SPICE的皮秒级放宽到了时钟周期级。遇到快变信号,节拍不够密会丢细节;遇到慢变信号,节拍又太密白白浪费算力。这就是我理解中论文标题里“真正的”三个字的由来——它瞄准的正是这种“假连续”的妥协。

1.2 “真正的模拟事件驱动”到底在较什么真

RNM建模走到后面,很多人会有一个困惑:我已经把模拟模块的行为写成了SV的task或者function,信号也用real表示,但是模型的行为和真实电路对不上,尤其在瞬态和反馈场景里,总是差那么一口气。差在哪?就差在“更新时机是别人给的,不是模型自己算出来的”。

事件驱动仿真的核心思想完全不同:不固定推进节奏,由状态自身的变化来决定什么时候需要更新。一个电压信号正在按指数规律爬升,它离目标阈值还有多远是可以算出来的,那就可以把这个时间点直接算出来,然后让仿真器“睡到那一刻再醒”。

“真正的模拟事件驱动”要较真的,就是这件事:状态更新不能靠外部拍板,而要靠模型内部的演化规律自己预约下一次醒来的时间。模拟模块在自己的时间轴上做主人,数字模块在它们各自的时间轴上跑事件,两个世界在SV调度器里天然共用同一套时间推进机制。这一下就把RNM丢掉的连续感找回来了,同时保留了SV环境的全部便利。这才是论文思路真正值钱的地方。

2. 事件驱动和连续信号之间,到底缺了哪座桥

要理解这套方法,得先把“事件驱动为什么能处理连续信号”这个底层逻辑想透。

2.1 事件调度器的本职工作:跳过没意思的时间

数字仿真里的调度器,说白了就干一件事:维护一张事件表,时间线直接跳到下一个有事件要发生的时刻,中间没有事件的时间段一概不仿真。你在SV里写一句#10;,仿真器就把这个进程挂起,归入10个单位时间后的事件队列;队列中最早的事件是哪个,仿真时间就跳到哪。如果整个仿真环境里有100万个空闲周期,调度器不会傻乎乎地每周期都检查一次,而是直接跳到下一个真正有信号变化的时间点。

这套机制天然就擅长“跳时间”。模拟仿真慢,不是因为连续系统本身算起来复杂,而是我们把“每个细小的时间片都要算一遍”当成理所当然。如果能让模拟模块也学会“预约下一次关心的时间点”而不是每步都算,那它就也能享受事件驱动的跳时优势。

举个例子类比:小区门卫不会每秒钟盯着大门看有没有人进出,而是靠门禁触发电铃喊人。没人的时候,时间在门卫这里就是“被跳过”的。事件驱动模拟仿真要做的,就是给每个模拟节点装一个“门禁电铃”。

2.2 连续系统能不能事件化,取决于三件事

一个连续时间系统,能不能改造成事件驱动模型,我觉得取决于三个条件,这三个条件缺一不可:

第一,系统的状态必须用实数连续表示。这没啥悬念,SV的real类型天然胜任,它能表示电压、电流、温度、相位,精度是双精度浮点,足够覆盖绝大多数行为级建模需求。

第二,状态的演化规律要能写成可计算的函数。形式化地说,系统要能用微分方程dx/dt = f(x, t)或差分方程来描述。RC电路的指数响应、积分器的线性斜坡、比较器的阈值翻转,都属于这种有明确数学表达的情况。

第三,能预测“下一次关键事件发生在什么时刻”。这是最核心的一步。所谓“关键事件”,可以是电压越过比较器门限的那个瞬间,可以是状态变化量超过误差容限的那个瞬间,也可以是外部激励产生边沿的那个瞬间。能算出这个时间点,我们就能把它作为事件投递到SV调度器里。

只要三件事都成立,连续系统的仿真就可以彻底脱离固定步长,变成纯事件驱动。

2.3 SystemVerilog给的积木,比想象中多

很多人以为SV做不了这件事,其实是低估了它。在语言层面,SV为模拟事件驱动准备了足够多的原生积木:

  • real类型:从real变量、real端口到wire real网络,信号线可以直接跑模拟量,准确说近几年的仿真器对real net的支持已经很好。
  • #delay语句:这不是简单的延时,本质上是“把事件投递到未来某一时刻”。#(dt)里的dt是real类型,所以时间可以精确到仿真精度允许的范围。
  • $realtime系统函数:返回当前仿真时间,而且是实数量级的,配合timeunit/timeprecision里的fs精度,做纳秒级模拟事件绰绰有余。
  • fork/join_none与process类:可以在initial块里孵化后台的“模拟引擎”,需要kill的时候也能按进程句柄杀掉,不会污染数字事件的调度。
  • 数学系统函数:$ln、$exp、$sqrt这些,主流工具基本都提供,解决解析解计算的问题;个别老工具没有,也可以靠DPI-C补。

本质上看,SV仿真器的调度器本身就是事件驱动的,模拟事件驱动模型只是把“事件”的内容从数字信号翻转换成了模拟状态的阈值到达、误差越界。语言没缺任何东西,缺的只是建模思路。

3. 一种通用换算:把微分方程翻译成事件调度程序

理解了原理,接下来就动真格的。把连续时间系统塞进SV,实际落地时有两种典型套路,一种走解析解,一种走数值自适应步长。

3.1 有解析解就走捷径:一次睡到阈值时刻

最理想的情况是系统模型能直接解出状态随时间变化的解析式。以最常见的RC一阶电路为例:

$$ \frac{dv}{dt} = \frac{v_{in} - v}{\tau} $$

当输入从某个电平阶跃到VDD,电容电压的响应是:

$$ v(t) = V_{DD} - (V_{DD} - v_0) \cdot e^{-\frac{t - t_0}{\tau}} $$

如果关心的问题是“电容电压什么时候到达阈值VTH”,那把这个式子反解出来就行:

$$ t_{next} = t_0 - \tau \cdot \ln\frac{V_{DD} - V_{TH}}{V_{DD} - v_0} $$

这在SV里就是一行#(dt)的事。下面是一个最小实现骨架:

module rc_analytical; timeunit 1ns; timeprecision 1ps; real vin = 0.0; real vcap = 0.0; real tau = 10.0; // 时间常数,单位ns real VTH = 3.0; // 关心的阈值电压 parameter real VDD = 5.0; task automatic schedule_to_threshold(); real t_now, dt; t_now = $realtime; // 当前vin是阶跃后的高电平,vcap正从vcap_now向VDD爬升 dt = -tau * $ln((VDD - VTH) / (VDD - vcap)); // 睡到阈值到达的那一刻 #(dt); vcap = VTH; $display("%t reached threshold, vcap=%f", $realtime, vcap); endtask initial begin #10 vin = VDD; // t=10ns时输入阶跃 schedule_to_threshold(); $finish; end endmodule

注意一个重要细节:schedule_to_threshold()里先算dt再#(dt),这个dt是“从现在到目标事件的时间差”。计算本身使用的vcap是当前状态,#(dt)之后仿真时间直接跳到阈值时刻,醒来时状态还没更新,所以需要手动把vcap赋成VTH——这就是事件驱动模型的“事件处理动作”。

边界情况要提前防住:如果vcap已经等于VDD,或恰好等于VTH,对数里的分子会出现0或者除0,轻则算出无穷大,重则仿真器直接报错。工程上要么加一个极小的epsilon偏移,要么在调度前先判断当前状态和目标是否已经重合。

3.2 没有解析解就数值推进:自适应步长是底线

现实中的模型不会都像RC这么乖巧,很多非线性系统没有闭式解。这时候得回归数值积分,但不能用固定步长——否则事件驱动的意义就没了。我的做法是用自适应步长的梯形法,用显式欧拉和梯形法结果的差异来估算局部误差,再根据误差调整下一步的时长。

module rc_numeric; timeunit 1ns; timeprecision 1ps; real vin = 1.0; real vout = 0.0; real tau = 10.0; parameter real T_END = 100.0; // 仿真结束时间 parameter real TOL = 1e-3; // 相对误差容限 parameter real H_MAX = 5.0; // 最大步长 parameter real H_MIN = 1e-3; // 最小步长 task automatic run_rc(); real t, h, k1, k2, v_pred; real err, ratio, factor; t = $realtime; h = 0.1; while (t < T_END) begin k1 = (vin - vout) / tau; v_pred = vout + h * k1; k2 = (vin - v_pred) / tau; // 梯形法更新 vout = vout + 0.5 * h * (k1 + k2); // 用显式欧拉与梯形法的差估算局部误差 err = 0.5 * h * (k2 - k1); if (err < 0.0) err = -err; // 简易步长控制器:误差小则放大步长,误差大则缩小步长 ratio = TOL / (err + 1e-12); factor = 1.0; if (ratio > 2.0) factor = 2.0; else if (ratio < 0.5) factor = 0.5; h = h * factor; if (h > H_MAX) h = H_MAX; if (h < H_MIN) h = H_MIN; t = t + h; #(h); end endtask initial run_rc(); endmodule

这种做法的核心是:误差小就大胆跳大步,误差大就缩回小步,本质上是“用上一不的误差预约下一步的时长”。每一步的#(h)都是一个事件,信号平稳时事件稀疏,信号陡变时事件变密,天然贴合系统的动态行为。要注意H_MIN必须设一个下限,否则收敛困难时步长可能一路缩到0,退化成死循环。

工程上还有一个细节:局部误差估算用的是欧拉和梯形法的一步差,这个近似的可靠性对平滑系统足够,但遇到刚性系统(时间常数相差好几个数量级)还是不够稳。真遇到刚性系统,我的建议是别硬撑,把它拆成多个时间尺度分别建模,或者直接考虑DPI-C调用专门的求解器。

3.3 模拟节点的通用调度循环:预测-睡眠-更新

不管走解析解还是数值自适应,每个模拟节点的推进逻辑都可以抽象成同一个循环模板:

  1. 预测:基于当前状态计算下一次关键事件的时刻t_next(阈值到达时刻或误差控制的下一积分时刻)
  2. 睡眠:执行#(t_next - $realtime),把进程挂起到未来
  3. 更新:醒来后更新状态、刷新输出、必要时重新预约下一事件

SV里的通用骨架长这样:

task automatic analog_engine(); real t_next; forever begin t_next = compute_next_event_time(); // 解析解或数值步长控制器 if (t_next > $realtime) #(t_next - $realtime); update_state(); // 处理事件:更新状态/翻转输出 end endtask

“预测-睡眠-更新”这个循环,是整套模拟事件驱动方法的最小公倍数。理解了它,再看论文里那些花哨的应用场景,基本都能对号入座。

这里要特别提醒一个常见的坑:#(t_next - $realtime)里如果t_next比当前时间还小,也就是事件已经过期,直接写#(负数)会引发不可预期的行为。工程上要加保护:

real dt; dt = t_next - $realtime; if (dt < 1e-9) dt = 0.0; // 1e-9单位取决于timeunit #(dt);

4. 一个完整小案例:弛豫振荡器在SV里“自己动起来”

讲原理太抽象,我直接用一个自持振荡器案例把代码串起来。这个案例是我认为最能体现“真正的模拟事件驱动”价值的场景。

4.1 为什么选弛豫振荡器当样板

弛豫振荡器(Relaxation Oscillator)由一个滞回比较器加一个RC积分网络组成。比较器输出通过电阻给电容充放电,电容电压反过来控制比较器翻转,形成一个自持反馈环。

它的特点是:电容电压的演化方向完全由比较器的输出决定,而比较器的输出只在电容电压穿越两个门限的瞬间才翻转。这种“大部分时间在做正弦式爬升、少数关键时刻做翻转”的结构,跟事件驱动的契合度极其高,比固定步长仿真不知道省到哪里去了。

参数我按常见场景取:电源VDD=5V,充电门限VH=3V,放电门限VL=1V,RC时间常数tau=10ns。

4.2 代码逐段走读

下面是一段可直接丢进仿真器的完整SV模型:

module relax_osc; timeunit 1ns; timeprecision 1ps; parameter real VDD = 5.0; parameter real VH = 3.0; parameter real VL = 1.0; parameter real TAU = 10.0; real vcap = 0.0; // 电容电压 real vout = VDD; // 比较器输出,初始为高 task automatic schedule_next(); real t_now, dt; t_now = $realtime; if (vout > 4.0) begin // 充电段:vcap 从当前值向 VDD 指数爬升,目标 VH // vcap(t) = VDD - (VDD - vcap_now) * exp(-(t - t_now)/TAU) dt = -TAU * $ln((VDD - VH) / (VDD - vcap)); #(dt); // 醒来后,vcap 应该正好到 VH,比较器翻转为低 vcap = VH; vout = 0.0; end else begin // 放电段:vcap 向 0 指数下降,目标 VL // vcap(t) = vcap_now * exp(-(t - t_now)/TAU) dt = -TAU * $ln(vcap / VL); #(dt); // 醒来后,vcap 应该正好到 VL,比较器翻转为高 vcap = VL; vout = VDD; end $display("%t ns: vcap=%f vout=%f", $realtime, vcap, vout); endtask initial begin $display("%t ns: start", $realtime); forever schedule_next(); end endmodule

逐段走读一下。

参数定义用了parameter real而不是普通的parameter,保证浮点精度不丢失。vcap和vout都是real变量,vcap是整个模型的核心状态,vout既是比较器输出又是充放电方向的控制信号,初始为高电平,对应充电段起点。

schedule_next()里最关键的是那两条解析式。充电段中,vcap从当前值往VDD爬,爬行规律是标准的指数逼近,把VH代进去反解出到达时间,一觉睡过去。醒来的瞬间把vcap直接置为VH,因为事件模型里我们只关注“到达门限这个事实”,不用关心门限前后那一点点过渡。比较器翻转为0V,系统进入放电段。

放电段完全对称:vcap从当前值往0降,目标VL,同样反解出到达时间,睡醒后置vcap为VL,比较器翻回VDD。如此往复,振荡就自己跑起来了。运行这段代码的输出会是这样一条时间线:

0 ns: start 9.16 ns: vcap=3.000000 vout=0.000000 20.15 ns: vcap=1.000000 vout=5.000000 29.31 ns: vcap=3.000000 vout=0.000000 40.30 ns: vcap=1.000000 vout=5.000000

振荡周期大概20.15ns,跟理论值tau*(ln3+ln5)算出来的20.12ns基本吻合,误差来自timeprecision的时间量化。这个模型没有用任何固定步长,全程只有电容电压到达门限那一个时刻会唤醒进程,其余时间仿真器都在“睡觉”。

4.3 事件驱动省下的时间到底有多少

我直接说数量级。假设用固定1ps步长做这个振荡器1微秒的瞬态仿真,那就是100万步状态更新。哪怕用SPICE自带的自适应步长,遇到比较器翻转这种快速跳变事件,依然会被迫在翻转点附近加密计算。

换到上面的事件驱动模型,1微秒里大约只有50次左右的事件调度。每两个事件之间的时间不是白跳的,而是用解析解精确计算出来的。省下的计算量差了4到5个数量级。

有人可能会说,RC振荡器太简单了,SPICE也能优化。这话没错。但把同样的思路放到PLL锁定检测、DCDC电压环、ADC前端采样保持这些场景里,事件驱动模型的优势会被进一步放大。这类系统的“关键事件”其实很少,大部分时间系统都在平滑演化,而SPICE类工具会把大量算力耗在平淡无奇的区间上。

5. 落到真实工程前,这几个硬骨头必须先啃掉

原理和示例都跑通了,但如果你真的想把这套方法用到项目里,还有几个绕不开的工程问题值得提前知道。

5.1 real信号互联:端口直连还是wire real

模拟节点在模块之间怎么传递,是第一个会绊倒人的地方。SV里real类型往端口上接,有几种写法,选择会直接影响代码可维护性。

模块内部怎么都好说,private real变量随便用。模块之间要传模拟信号,主流做法有两种:

一是直接在端口声明上写real类型:

module analog_entity ( input real vin, output real vout ); // ... endmodule

这种方式把real端口当成变量端口使用,实例化时直接连线就行,语法干净,工具支持度也高。

二是用wire real声明中间网络,配合连续赋值:

wire real bus_v; analog_entity u1 (.vout(bus_v)); analog_entity u2 (.vin(bus_v));

wire real这种real net类型在近几年的仿真器里基本都支持,适合表达“一条模拟总线带多个负载”的结构。

需要特别注意的是,real信号不能直接塞进logic或者bit类型的变量里。有些同事图省事,把real强转成logic再传,精度一丢,整个模型就废了。模拟路径上全程保持real,直到你确实需要转换成数字电平信号(比如比较器输出接数字门),才在边界处专门做一次阈值判断。

5.2 非线性行为:分段线性、查表和DPI-C

真实电路很少像RC那么乖。二极管、饱和限幅、MOS开关、VCO调谐曲线,这些非线性行为怎么建模,是决定模型精度的关键。

我的分级策略是:能用分段线性就分段线性,不能用就用查表加插值,再不行就上DPI-C。

分段线性最简单,比如限幅器可以写成:

function automatic real clamp(real x, real lo, real hi); if (x > hi) clamp = hi; else if (x < lo) clamp = lo; else clamp = x; endfunction

查表插值适合描述那些没有解析表达式的曲线,比如压控振荡器的频率-电压曲线。SV里写一个一维插值函数很轻松,关键是表格密度要和输入信号的变化速率匹配,太稀会有台阶,太密则浪费存储。

真正难啃的是那些需要矩阵求解或者特殊数值算法的模块,SV本身没有这些数学库。这种时候DPI-C的价值就出来了,在C侧用成熟的数值库算出结果,通过DPI-C接口返回给SV。需要注意跨语言调用的数据同步,尽量把DPI-C封装成无状态的纯函数,避免在SV事件调度里维护C侧状态,否则调试会让你怀疑人生。

5.3 反馈环与代数环:调度顺序的隐性约束

模型一旦带反馈,就会遇到一个事件驱动特有的麻烦:如果反馈回路里没有存储状态(也就是没有电容、电感这种记忆元件),那输出会直接依赖输入,而且输入又反过来依赖输出,这就形成了代数环。

最典型的例子是理想运放接成跟随器。理论上vout = vp = vin,但如果模型内部先写out = gain * (vp - vn),再写vn = out,那赋值顺序就决定了结果。仿真器里的调度顺序稍有不同,结果就飘了。

我常用的工程解法有三种:

  1. 给回路注入一个极小的时间常数,把纯代数环变成一阶惯性环节,这在物理上也更接近真实电路。
  2. 在模型内部显式解出代数关系,比如跟随器直接写vout = vin,绕开环路。
  3. 引入单位延迟,用上一拍的计算结果代替环内瞬时状态。代价是引入半个周期的相位误差,但往往可以接受。

选择哪种,取决于你要模拟的对象本身。如果对象是真实的物理系统,注入小时间常数最“诚实”;如果只是验证数字侧的逻辑,引入延迟反而更省事。千万别让调度顺序的偶然性成为模型行为的决定因素,这是事件驱动建模里最容易出的隐性bug。

5.4 精度声明和工具差异,才是最容易被坑的地方

跑这套模型,最费神的往往不是算法本身,而是仿真器的精度设置。timeunit和timeprecision这两条声明,我建议从一开始就统一:

timeunit 1ns; timeprecision 1fs;

为什么?因为$realtime返回的时间精度受timeprecision限制,如果声明的是1ps精度,解析式算出的阈值时刻会按1ps量化,振荡器周期累积下来可能偏移好几个百分点。每颗门限到达时间的取整误差在20ns周期里可能不明显,但放到100us的PLL锁定仿真里,误差会一路滚雪球。

不同仿真器对real运算、数学函数、wire real的支持程度也有差异。同一份代码,在A工具上跑得好好的,换到B工具上可能$ln都不支持,或者#(dt)对real延迟的处理精度不同。我的经验是:开工前先花半天时间把模型里用到的语言特性在小例子里冒烟一遍,别等整个环境搭完才发现工具不支持。遇到数学函数缺失,DPI-C补一下是最快的绕路方式。

还有一个隐蔽的性能问题:timeprecision 1fs会把整个仿真环境的时间精度拉到fs级,数字事件多的环境里,调度器的维护成本会明显上升。一个折中方案是数字部分保持ps级精度,模拟模型单独用更高精度的本地声明;具体能不能生效要看工具对多精度混合的支持程度,项目里最好提前验证一下。

最后说几句个人的体会

我把这个思路复现过几版之后,最大的体会是:这套方法不是要拿SystemVerilog去替代SPICE,做晶体管级精度验证那是自己找罪受;它的价值在于把验证环境里“模拟模块和数字环境之间的那堵墙”拆掉,让模拟行为模型能堂堂正正地进入UVM、进入回归、进入CI流程。以前验证一个带模拟模块的芯片,要么等SPICE跑完才能往下走,要么拿一个精度堪忧的RNM模型顶着。事件驱动模型给了一个中间选项:精度可控、速度飞快、且完全活在SV生态里。

如果你也在搞混合信号验证,建议找一个简单的模拟模块,比如一个带比较器的LDO或者一个RC振荡器,按上面的“预测-睡眠-更新”循环自己搭一版。先跑通,看看事件次数和仿真时间的对应关系,再一点点把非线性、反馈、多节点互联加进去。等你能让一个自持振荡器在纯SV环境里“自己动起来”,这套方法你基本就掌握得差不多了。后面做PLL锁定检测、DCDC环路响应,都只是往这个骨架上填内容的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询