☰
SystemVerilog模拟事件驱动仿真:DFE与拉普拉斯域解析波形实践
2026/10/27 9:17:08 网站建设 项目流程

1. 从一次仿真卡死说起:为什么需要模拟事件驱动

去年做混合信号验证的时候,我遇到一个很典型的问题:一个带开关电容滤波器的电路,数字控制部分用SystemVerilog写好了,模拟部分用传统SPICE仿真器跑,两者之间的同步靠固定时间步长硬凑。结果就是,每次开关动作发生的时候,仿真器要么错过精确的切换时刻,要么把时间步长缩到极小,一个简单的滤波周期跑了我整整一个下午。后来我换了个思路,把模拟部分的微分方程用拉普拉斯域解析解来表示,让模拟信号的变化自己决定什么时候需要被“关注”,而不是每隔固定时间就去采样一次。这就是模拟事件驱动仿真的核心思路,也是DFE(Discrete Function Evaluation)方法在SystemVerilog里落地的基本逻辑。

这篇内容适合谁看?如果你正在做数模混合验证,手头有SystemVerilog的验证环境,但被模拟部分的仿真速度或者精度问题卡住了,那这篇东西应该能帮到你。如果你只是听说过DFE或者拉普拉斯域解析波形,想看看这东西到底怎么在SystemVerilog里写出来,那也可以顺着往下看。我会从整体设计思路开始拆,然后讲核心细节,再给完整的实操过程,最后把踩过的坑整理成排查表。代码部分我会给关键片段,参数选择我会把计算过程写清楚,保证你能照着复现。

2. 整体设计思路:为什么是DFE加解析波形

2.1 传统模拟仿真在数字验证环境里的尴尬

大部分数字验证工程师对模拟仿真的态度是“能躲就躲”。原因很简单:SPICE类仿真器精度高,但速度慢得让人抓狂;行为级模型速度快,但精度又不够,尤其是涉及开关电容、非线性或者高频响应的时候,行为级模型经常和实际电路对不上。更麻烦的是,数字仿真器的时间推进机制和模拟仿真器完全不一样。数字侧是事件驱动的,信号变化才触发计算;模拟侧是时间步进驱动的,不管有没有变化,时间步长到了就得算一次。这两者放在一起,要么数字侧等模拟侧,要么模拟侧被数字侧的事件切得七零八落。

我试过用固定小步长来同步,比如把模拟步长设成数字时钟周期的百分之一。结果就是仿真时间爆炸,一个几毫秒的测试用例跑几个小时。后来我意识到,问题的根源在于:模拟信号的变化其实不需要在每个时间点都被计算,只需要在它“可能影响数字侧判断”或者“数字侧发生变化”的时候才需要被关注。这就是事件驱动的思路。

2.2 DFE方法的核心逻辑

DFE的全称是Discrete Function Evaluation,直译过来就是离散函数求值。它的核心思想是:对于线性时不变系统,模拟部分的响应可以用拉普拉斯域的传递函数来表示,然后在时域里用解析解的形式给出。也就是说,我不需要一步一步地数值积分,而是直接算出在任意时刻t的精确值。这样一来,模拟信号就变成了一个“可查询”的函数,而不是一个“必须推进”的状态。

具体来说,假设模拟部分的传递函数是H(s),输入是数字侧给过来的分段常数信号或者脉冲信号,那么输出在时域里的表达式可以通过拉普拉斯逆变换得到。对于常见的一阶和二阶系统,这个逆变换是有闭式解的。比如一阶低通滤波器的阶跃响应就是指数上升曲线,二阶系统的阶跃响应就是带阻尼的正弦衰减。这些闭式解在SystemVerilog里用real类型和数学函数就能算出来,不需要任何数值积分。

注意:DFE方法只适用于线性时不变系统。如果你的模拟部分包含强非线性或者时变元件,那这个方法不适用,还是得回到传统数值仿真。

2.3 为什么选SystemVerilog而不是其他语言

有人可能会问,为什么非要在SystemVerilog里做这件事?用Python或者C++写个模型,然后通过DPI-C接进来不行吗?当然可以,但那样做的代价是你要维护两套代码,而且每次模拟参数变化都要重新编译C++侧。SystemVerilog本身有real类型,有数学函数,有类和多态,完全可以自己实现一套轻量级的解析波形计算引擎。更重要的是,SystemVerilog的仿真调度机制天然支持事件驱动,你可以用event或者semaphore来控制模拟信号的更新时机,和数字侧的always块无缝配合。

我实际对比过两种方案:用DPI-C接C++模型,仿真速度确实快一点,但调试起来很痛苦,尤其是当模拟参数需要扫描的时候,每次都要重新编译动态库。纯SystemVerilog实现虽然单次计算慢一些,但整个流程都在一个语言里,参数化方便,调试也直观。对于中等规模的混合信号验证,纯SystemVerilog方案的综合效率更高。

2.4 整体架构拆解

整个模拟事件驱动仿真引擎可以分成四个部分。第一部分是波形描述层,用拉普拉斯域的参数来定义一个模拟信号,比如直流增益、极点、零点、初始条件。第二部分是事件调度层,负责记录当前仿真时间,判断什么时候需要重新计算模拟输出。第三部分是解析计算层,根据当前时间和输入历史,用闭式解算出模拟输出的精确值。第四部分是接口层,把模拟输出转换成数字侧可以读取的real或者wreal类型,同时把数字侧的变化反馈给模拟引擎。

这四个部分在SystemVerilog里可以用class来组织。波形描述层是一个基类,不同的模拟行为继承它并实现自己的解析计算函数。事件调度层可以用一个全局的time变量加上一个事件队列来实现。接口层用wreal或者real类型的net来连接数字和模拟域。下面我会逐个展开。

3. 核心细节解析:拉普拉斯域解析波形怎么算

3.1 拉普拉斯域到时间域的转换基础

拉普拉斯变换是把时间域的微分方程变成复频域的代数方程。对于一个线性系统,传递函数H(s)等于输出的拉普拉斯变换除以输入的拉普拉斯变换。常见的一阶系统H(s) = A / (1 + s/ωp),其中A是直流增益,ωp是极点角频率。二阶系统H(s) = A ωn² / (s² + 2ζωn s + ωn²),其中ωn是自然频率,ζ是阻尼比。

要把这些转换回时间域,需要做拉普拉斯逆变换。对于一阶系统,单位阶跃响应的时域表达式是y(t) = A (1 - exp(-ωp t))。对于二阶欠阻尼系统,单位阶跃响应是y(t) = A [1 - exp(-ζωn t) (cos(ωd t) + ζ/√(1-ζ²) sin(ωd t))],其中ωd = ωn √(1-ζ²)是阻尼振荡频率。这些公式在SystemVerilog里用$exp、$sin、$cos就能直接算。

提示:实际电路里经常遇到的是带初始条件的响应,比如电容上已经有电压了。这时候需要把初始条件等效成输入信号的拉普拉斯变换的一部分,或者直接用叠加原理,把零输入响应和零状态响应分开算再相加。

3.2 分段常数输入的卷积处理

数字侧给模拟侧的信号通常是分段常数,比如一个脉冲或者一个阶跃。对于分段常数输入,输出的计算可以用卷积积分来做。假设输入在时间段[tk, tk+1]内是常数Uk,那么输出在这个时间段内的表达式可以写成零状态响应加上之前所有时间段留下的“残余响应”。

具体来说,对于一阶系统,如果输入在t=0时刻从0跳到U,那么输出就是U A (1 - exp(-ωp t))。如果输入在t=t1时刻又跳回0,那么输出就是之前响应的衰减加上新的阶跃响应。用叠加原理,可以把每个跳变时刻的阶跃响应叠加起来。在SystemVerilog里,我通常维护一个跳变时刻的队列,每次计算输出的时候,遍历这个队列,把每个跳变产生的阶跃响应在当前时刻的值算出来,然后累加。

这个方法的计算量随着跳变次数线性增长。对于典型的混合信号验证,跳变次数不会太多,所以完全可接受。如果跳变次数真的很多,可以用递归更新公式来减少计算量,但那是另一个话题了。

3.3 事件调度:什么时候需要重新计算

事件驱动的核心是“按需计算”。模拟输出只在两种情况下需要更新:第一种是数字侧发生了变化,比如时钟翻转或者控制信号跳变,这会导致模拟输入变化,从而需要重新计算模拟输出;第二种是模拟输出本身发生了足够大的变化,可能影响数字侧的判断,比如比较器阈值被跨过了。

第一种情况好处理,数字侧的变化本身就是事件,直接在always块里触发模拟引擎的更新函数就行。第二种情况稍微麻烦一点,因为模拟输出是连续变化的,你不可能在每个无穷小的时间点都去检查。我的做法是:在每次更新模拟输出之后,预测下一次输出可能跨过阈值的时间,然后把这个时间作为一个事件插入到调度队列里。如果在这个时间之前数字侧又发生了变化,那就取消旧的事件,重新预测。

预测跨阈值时间的方法取决于波形类型。对于指数上升曲线,可以解析求解;对于阻尼振荡,可能需要数值求根。我一般用简单的二分法或者牛顿法,在SystemVerilog里写个循环就能搞定。精度要求不高的时候,直接用线性插值也够用。

3.4 接口层的wreal和real选择

SystemVerilog里连接数字和模拟域的常用类型是wreal和real。wreal是Verilog-AMS引入的,在纯数字仿真器里支持有限。real是SystemVerilog原生类型,但real类型的net不能直接参与数字逻辑运算。我的经验是:如果仿真器支持wreal,优先用wreal,因为它有解析函数和阈值检测的语义;如果不支持,就用real加上自定义的阈值检测逻辑。

在接口层,我通常会做一个“数字化”的转换,把模拟输出和某个阈值比较,产生一个数字信号给数字侧用。同时,数字侧的控制信号通过一个“模拟化”的转换,变成模拟引擎的输入。这两个转换函数是接口层的核心,需要仔细处理时序,避免竞争。

4. 实操过程:从零搭建一个一阶RC滤波器的DFE模型

4.1 定义波形描述类

先定义一个基类,叫analog_waveform_base,里面包含纯虚函数eval和update_input。eval函数接收当前时间,返回模拟输出的值。update_input函数接收新的输入值和变化时刻,更新内部状态。

virtual class analog_waveform_base; pure virtual function real eval(time t); pure virtual function void update_input(real val, time t); endclass

然后定义一阶RC滤波器的类,继承这个基类。内部状态包括直流增益A、极点角频率wp、当前输入值、上一次输入变化时刻、以及一个跳变历史队列。

class rc_filter extends analog_waveform_base; real A; real wp; real input_val; time last_change; real step_history[$]; // 存储每次跳变的幅度和时间 time time_history[$]; function new(real a, real w); A = a; wp = w; input_val = 0; last_change = 0; endfunction virtual function void update_input(real val, time t); real delta; delta = val - input_val; if (delta != 0) begin step_history.push_back(delta); time_history.push_back(t); input_val = val; last_change = t; end endfunction virtual function real eval(time t); real y; y = 0; for (int i = 0; i < step_history.size(); i++) begin real dt; dt = (t - time_history[i]) * 1e-9; // 假设时间单位是ns if (dt >= 0) begin y += step_history[i] * A * (1 - $exp(-wp * dt)); end end return y; endfunction endclass

这段代码里,step_history存储的是每次输入跳变的幅度,time_history存储跳变时刻。eval函数遍历所有历史跳变,把每个跳变产生的阶跃响应在当前时刻的值累加。注意时间单位转换,SystemVerilog的time默认单位是仿真器的时间精度,我通常用1ns作为基准,所以乘以1e-9转换成秒。

注意:这个实现假设输入跳变是瞬时的。如果输入本身有上升时间,需要把输入建模成斜坡或者更复杂的波形,那解析解会不一样。对于大多数数字控制信号,瞬时跳变假设是合理的。

4.2 事件调度器的实现

事件调度器负责维护当前仿真时间,并在需要的时候调用eval函数。在SystemVerilog里,可以用一个always块加上一个event来控制。

class event_scheduler; analog_waveform_base waveform; real threshold; event threshold_crossed; time next_check; function new(analog_waveform_base w, real th); waveform = w; threshold = th; endfunction task run; real current_val; real next_val; time dt; forever begin @(waveform.input_changed); // 假设waveform有一个input_changed事件 current_val = waveform.eval($time); // 预测下一次跨阈值的时间 dt = predict_crossing(current_val, threshold); if (dt > 0) begin next_check = $time + dt; fork begin #(dt); -> threshold_crossed; end join_none end end endtask function time predict_crossing(real current, real th); // 简单线性预测,实际可以用解析法 real slope; slope = (waveform.eval($time + 1) - current) / 1.0; if (slope == 0) return 0; return (th - current) / slope; endfunction endclass

这个调度器在每次输入变化时重新计算输出,并预测下一次跨阈值的时间。如果预测时间有效,就启动一个延迟任务,在延迟结束后触发threshold_crossed事件。数字侧可以监听这个事件来更新比较器输出。

4.3 参数计算:从电路参数到拉普拉斯域参数

假设你有一个实际的RC低通滤波器,R = 1kΩ,C = 1nF。截止角频率ωp = 1/(RC) = 1/(1000 × 1e-9) = 1e6 rad/s。直流增益A = 1。在SystemVerilog里,这个滤波器的实例化就是:

rc_filter filt = new(1.0, 1e6);

如果是一个二阶系统,比如RLC电路,R = 1kΩ,L = 1mH,C = 1nF。自然频率ωn = 1/√(LC) = 1/√(1e-3 × 1e-9) = 1e6 rad/s。阻尼比ζ = (R/2) √(C/L) = (1000/2) √(1e-9/1e-3) = 500 × 1e-3 = 0.5。这些参数直接填进二阶系统的类里就行。

提示:实际电路里经常有多个极点和零点,传递函数可能很复杂。我的做法是先用电路仿真软件(比如LTspice)扫频得到幅频和相频特性,然后用矢量拟合或者手动拟合得到近似的极点和零点,再填进DFE模型。对于大多数验证场景,一两个主导极点就够了。

4.4 和数字侧的联调

数字侧通常是一个always块,里面用@(posedge clk)来触发。模拟侧的更新可以通过一个interface来传递。我一般定义一个analog_if接口,里面包含real类型的信号和event。

interface analog_if; real analog_out; event analog_changed; modport digital_side(output analog_out, input analog_changed); modport analog_side(input analog_out, output analog_changed); endinterface

数字侧读取analog_out的时候,需要确保模拟侧已经更新了。我通常用event来同步:模拟侧更新完analog_out之后触发analog_changed,数字侧用@(analog_changed)来等待。如果数字侧是时钟驱动的,那就把analog_changed和时钟事件用or连起来。

always @(posedge clk or analog_if.analog_changed) begin if (analog_if.analog_out > 0.5) begin comp_out <= 1; end else begin comp_out <= 0; end end

这个always块在时钟上升沿或者模拟输出变化时都会执行,保证比较器输出及时更新。

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

5.1 仿真结果和SPICE对不上怎么办

这是最常见的问题。我遇到过的原因有几种:第一种是时间单位搞错了。SystemVerilog的$time返回的是仿真器的时间单位,可能是1ns、1ps或者1fs。如果你的公式里用的是秒,那必须乘以正确的时间因子。我一般会在代码开头定义一个localparam TIME_SCALE = 1e-9,然后所有时间转换都用这个常量。

第二种是初始条件没处理。SPICE仿真器会自动计算直流工作点,但DFE模型需要你手动指定初始输出值。如果初始值不对,前几个周期的波形就会偏。我的做法是在eval函数里加一个初始偏移项,或者在第一次更新输入之前先调用一次eval来初始化。

第三种是极点位置算错了。尤其是二阶系统,阻尼比ζ如果小于1,是欠阻尼,有振荡;如果大于1,是过阻尼,没有振荡。公式不一样。我见过有人把过阻尼系统套欠阻尼公式,结果波形完全不对。排查的时候先用一个简单的阶跃输入,看输出的上升时间和超调量是不是符合理论值。

5.2 事件调度导致仿真卡死

事件调度器如果写不好,很容易陷入死循环。比如预测跨阈值时间的时候,如果斜率算出来是0,那预测时间就是无穷大,延迟任务永远不会触发,仿真就卡住了。我的做法是在predict_crossing函数里加一个保护:如果斜率绝对值小于某个阈值(比如1e-12),就返回一个默认的检查间隔,比如1ns。

还有一种情况是阈值反复穿越。模拟输出在阈值附近有噪声或者振荡,导致threshold_crossed事件频繁触发,数字侧不停翻转。这时候需要加迟滞,比如上阈值0.6,下阈值0.4,只有超过上阈值才置1,低于下阈值才置0。

注意:迟滞窗口的大小需要根据模拟输出的噪声幅度来定。如果噪声幅度是10mV,迟滞窗口至少要是20mV,否则还是会反复穿越。

5.3 性能优化:什么时候该用递归更新

如果跳变历史队列很长,每次eval都遍历整个队列,计算量会很大。我实测过,跳变次数超过1000次之后,eval的耗时开始明显影响仿真速度。这时候可以用递归更新:只保留最近一次跳变的状态,每次更新时基于上一次的输出值做衰减和新增。

对于一阶系统,递归更新公式是:y(t) = y(t_prev) exp(-ωp Δt) + U A (1 - exp(-ωp Δt)),其中Δt是距离上一次更新的时间。这个公式只需要常数时间,和跳变次数无关。二阶系统的递归更新稍微复杂一点,但也可以用状态空间的形式来做。

我的建议是:跳变次数少于100的时候,用遍历法,代码简单不容易出错;超过100的时候,换成递归法。两种方法的结果在数值上应该一致,可以写个测试用例对比一下。

5.4 常见问题速查表

问题现象可能原因排查方法解决方法
波形幅度不对直流增益A算错检查电路参数和公式重新计算A
上升时间不对极点频率wp算错对比理论上升时间重新计算wp
波形有振荡但理论没有阻尼比ζ小于1检查ζ计算公式调整ζ或改用过阻尼公式
仿真卡死预测跨阈值时间为无穷大打印斜率值加斜率保护
数字侧翻转频繁阈值附近噪声观察模拟输出波形加迟滞窗口
仿真速度慢跳变历史队列过长统计跳变次数改用递归更新
和SPICE对不上时间单位不一致检查TIME_SCALE统一时间单位
初始几个周期偏差初始条件未设置检查初始输出值手动设置初始条件

5.5 几个我踩过的坑

第一个坑是real类型的精度。SystemVerilog的real是双精度浮点,一般够用,但在累加很多次之后会有舍入误差。我遇到过跑了几百万个时间点之后,输出值漂移了1%的情况。解决办法是定期用解析解重新校准,比如每1000次更新就用遍历法算一次精确值,把递归更新的状态重置。

第二个坑是事件竞争。数字侧和模拟侧如果同时更新,谁先谁后会影响结果。我一般用非阻塞赋值来避免竞争,或者在接口层加一个握手信号,确保模拟侧更新完成之后数字侧才读取。

第三个坑是参数扫描。做蒙特卡洛分析的时候,每次参数变化都要重新创建波形对象。如果对象创建太频繁,垃圾回收会成为瓶颈。我的做法是复用对象,只更新参数,不重新new。

6. 后续扩展方向

这套DFE引擎目前只支持线性时不变系统,但实际验证中经常遇到非线性,比如二极管或者饱和放大器。一个可行的扩展是分段线性化:把非线性曲线分成若干段,每段用不同的线性参数,当输入进入新的一段时切换参数。这样既能保持解析解的速度,又能近似非线性行为。

另一个方向是支持更多波形类型。目前只处理了阶跃和分段常数,实际中还有斜坡、正弦、脉冲串等。对于正弦输入,线性系统的稳态响应也是正弦,只是幅度和相位变了,可以直接用频率响应算。对于脉冲串,可以分解成多个阶跃的叠加。

还有一个方向是和UVM集成。把DFE引擎封装成一个UVM component,通过TLM接口和scoreboard、coverage collector连接。这样就能在现有的UVM验证环境里直接使用,不需要改数字侧的代码结构。

我在实际项目里用这套方法跑过几个开关电源和锁相环的验证用例,仿真速度比传统SPICE快了两个数量级,精度在1%以内。对于验证阶段来说,这个精度足够了。如果你也在做混合信号验证,不妨试试这个思路,先从一阶系统开始,跑通了再扩展到二阶和更复杂的系统。

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

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

立即咨询