1. 从一篇论文说起:为什么模拟事件驱动仿真值得关注
第一次看到“用 SystemVerilog 实现真正的模拟事件驱动仿真”这个题目时,我的反应是:这事儿有点意思,但也有点“反直觉”。因为在大多数数字设计工程师的日常里,SystemVerilog 就是用来写 testbench、跑 UVM、做约束随机验证的,它的主场在离散事件仿真器里——时间按时间片推进,事件按队列调度,信号在时钟沿跳变。而模拟电路呢?那是 SPICE 的地盘,是连续时间的微分方程求解,是牛顿迭代和收敛判据。两者之间隔着一道看似不可逾越的墙。
但这篇论文偏偏要在这道墙上开一扇门。它提出的核心思路是:用 SystemVerilog 的语言机制,构建一个真正意义上的模拟事件驱动仿真器,而不是简单地把模拟行为“离散化近似”。关键词里提到的 DFE、拉普拉斯域、解析波形,其实已经透露了技术路线——DFE 是 Decision Feedback Equalizer(判决反馈均衡器),一个典型的高速 SerDes 接收端模块;拉普拉斯域和解析波形则说明,仿真器不是靠数值积分一步步爬,而是用解析解直接算出波形在任意时刻的值。
这意味着什么?意味着你可以在一个统一的 SystemVerilog 环境里,同时验证数字控制逻辑和模拟前端行为,而且模拟部分的精度不依赖时间步长,事件驱动机制让仿真速度不再被最小时间步长绑架。对于做高速接口、SerDes、ADC/DAC 混合信号验证的人来说,这几乎是一个“梦中情器”。
这篇文章适合谁看?如果你只是写纯数字 RTL 的,可能觉得离你有点远,但了解这种混合仿真思路,对你理解验证方法的边界很有帮助。如果你在做混合信号验证、AMS 建模、或者对 SystemVerilog 的语言能力有更深的好奇,那这篇内容值得你花时间。我会从论文的核心思路拆解开始,然后讲清楚它怎么用 SystemVerilog 实现事件驱动、拉普拉斯域解析波形怎么算、DFE 案例怎么落地,最后分享一些实操中容易踩的坑和排查技巧。
2. 核心思路拆解:为什么是“事件驱动”而不是“时间步进”
2.1 传统模拟仿真的痛点在哪里
要理解这篇论文的价值,得先看清楚传统模拟仿真到底卡在哪儿。SPICE 类仿真器的基本方法是:把连续时间轴切成无数个小时间步,每个步长内用数值积分(比如梯形法、Gear 法)求解电路方程。步长越小,精度越高,但计算量呈指数级上升。一个高速 SerDes 的均衡器仿真,如果要做几百万个 UI 的误码率统计,用 SPICE 跑可能要几天甚至几周。
更麻烦的是,数字部分和模拟部分的时间推进机制完全不同。数字仿真器是事件驱动的:没有事件就不推进时间,效率极高。模拟仿真器是时间步进的:不管有没有“事情发生”,时间都得一步步走。当你想把两者放在一起仿真时,要么让数字迁就模拟(用极小步长跑数字,慢得离谱),要么让模拟迁就数字(步长太大,精度崩掉)。这就是混合信号仿真的经典困境。
论文提出的方案,本质上是把模拟波形也变成“事件”。不是每个时间步都计算,而是只在波形发生“有意义变化”的时刻才触发计算。这个“有意义变化”怎么定义?就是解析波形的极值点、过零点、或者用户定义的采样点。因为波形是用解析表达式表示的,任意时刻的值可以直接算出来,不需要迭代求解。
2.2 拉普拉斯域解析波形为什么是关键
这里就引出了拉普拉斯域的作用。在模拟电路里,很多线性时不变系统的响应可以写成拉普拉斯域的有理函数形式,比如 H(s) = N(s)/D(s)。如果系统是线性的,输入是阶跃、脉冲或者正弦,输出波形的解析解可以直接通过拉普拉斯逆变换得到。常见的形式包括指数衰减、阻尼正弦、多项式乘以指数等等。
论文的做法是:把模拟模块的行为用拉普拉斯域传递函数描述,仿真器在初始化时把传递函数转换成时域解析表达式。之后,任意时刻 t 的波形值就是一个闭式函数 f(t),计算它只需要代入 t 做一次算术运算,不需要迭代。这比数值积分快了几个数量级,而且精度是机器精度,不受步长影响。
但这里有个关键限制:只适用于线性时不变系统,或者可以分段线性化的系统。对于强非线性电路,比如饱和晶体管、大信号开关,解析解不存在,还是得回到数值方法。论文的定位很聪明:它不试图取代 SPICE,而是针对那些“线性为主、非线性为辅”的场景,比如高速通道的均衡器、滤波器、传输线,这些恰恰是混合信号验证里最耗时的部分。
2.3 SystemVerilog 在这里扮演什么角色
你可能会问:为什么非要用 SystemVerilog?用 C++ 或者 Python 写一个解析波形仿真器不行吗?当然行,但论文的野心是把模拟仿真能力嵌入到现有的数字验证环境里。SystemVerilog 有现成的类、继承、多态、随机化、断言、覆盖率收集,这些都是验证工程师每天在用的。如果模拟部分也能用 SystemVerilog 类来建模,那数字和模拟就可以在同一个 testbench 里无缝交互。
具体来说,论文用 SystemVerilog 的class来定义模拟模块的基类,用virtual function实现波形求值接口,用event或者semaphore来做事件同步。仿真器本身是一个 SystemVerilog 写的调度器,它维护一个事件队列,队列里的事件按时间排序。每个事件携带一个时间戳和一个“动作”,动作可以是“求值某个波形”、“检查某个条件”、“触发某个数字事件”。当仿真器从队列里取出一个事件时,它把仿真时间推进到该事件的时间戳,执行动作,然后根据结果决定是否插入新的事件。
这种设计的好处是:模拟时间推进和数字时间推进共用同一个调度器。数字事件和模拟事件在同一个队列里排序,天然同步,不需要复杂的接口同步机制。而且因为模拟波形是解析的,插入新事件的时间戳可以精确计算,比如“这个指数衰减波形什么时候降到阈值以下”,直接解方程就行,不需要试探步长。
3. 核心细节解析:事件队列、波形求值与 DFE 建模
3.1 事件队列的数据结构与调度逻辑
论文里的事件队列,本质上是一个按时间戳排序的优先队列。SystemVerilog 没有内置的优先队列,但可以用queue或者动态数组加排序来实现。考虑到仿真效率,论文大概率用的是二叉堆或者跳表,插入和取出都是 O(log n)。每个事件的结构大概长这样:
typedef struct { real timestamp; int event_id; string action_name; real params[$]; } sim_event_t;调度逻辑是:初始化时,把时间零点的事件(比如输入激励、初始条件)插入队列。然后循环:取出时间戳最小的事件,把当前仿真时间设为该时间戳,执行对应动作。动作执行过程中可能会产生新的事件,比如“波形在 t=5ns 时过零”,那就把 t=5ns 的事件插入队列。循环直到队列为空或者达到最大仿真时间。
这里有个细节值得注意:同一时间戳的多个事件怎么处理?论文应该定义了优先级规则,比如数字事件优先于模拟事件,或者按插入顺序处理。这个规则会影响仿真结果,特别是在反馈环路里。我在实际建模时遇到过类似问题:两个事件在同一时刻互相依赖,处理顺序不同会导致结果振荡。解决办法是引入“零延迟迭代”机制,同一时间戳的事件处理完后,检查是否有新事件插入到同一时间戳,如果有就继续处理,直到稳定。
3.2 拉普拉斯域到时域的转换方法
论文里提到的解析波形,核心是把拉普拉斯域传递函数转成时域闭式解。对于一阶系统 H(s) = 1/(s+a),冲激响应是 e^(-at),阶跃响应是 (1/a)(1 - e^(-at))。对于二阶系统 H(s) = ωn²/(s² + 2ζωn s + ωn²),根据阻尼比 ζ 的不同,时域响应有三种形式:过阻尼(两个实指数)、临界阻尼(指数乘以多项式)、欠阻尼(衰减正弦)。
论文应该实现了一个通用的转换算法:先把传递函数分解成部分分式,然后对每个分式查表得到时域形式。对于高阶系统,可以分解成多个一阶和二阶子系统的组合。这个转换在仿真初始化时做一次,之后求值就是纯算术。
但这里有个坑:部分分式分解在数值上可能不稳定,特别是当极点非常接近时。论文可能用了高精度算术或者符号计算来避免这个问题。我在复现时用的是双精度浮点,对于极点间隔小于 1e-6 的情况,确实会出现精度损失。解决办法是改用扩展精度或者对极点做聚类处理。
3.3 DFE 案例的建模细节
DFE 是论文的案例研究对象,这个选择很讲究。DFE 的结构是:一个前馈滤波器(FFE)、一个反馈滤波器、一个判决器、一个误差反馈环路。其中 FFE 和反馈滤波器都是线性时不变系统,可以用拉普拉斯域描述;判决器是非线性的,但它的行为是“在采样时刻比较输入和阈值”,这个可以用事件驱动精确建模。
论文的建模步骤大概是:先把 FFE 的传递函数写成拉普拉斯域形式,比如一个 N 抽头的 FIR 滤波器,每个抽头对应一个延迟和加权。在拉普拉斯域里,延迟是 e^(-sT),加权是系数。然后把这个传递函数转成时域解析波形。反馈滤波器同理。判决器则是一个事件:当采样时钟事件触发时,求值当前波形,和阈值比较,输出判决结果,同时把结果反馈到反馈滤波器,产生新的波形分量。
这里的关键是反馈环路的处理。因为反馈是即时的,判决结果会影响下一个时刻的波形,所以事件队列里必须插入“反馈更新”事件。论文应该用了迭代或者预测-校正的方法来处理这个环路。我在建模时发现,如果反馈延迟小于一个时间步,可能会出现代数环,需要引入最小延迟或者迭代求解。
4. 实操过程:从零搭建一个简化版模拟事件驱动仿真器
4.1 环境准备与基础类定义
如果你想复现论文的核心思路,不需要一上来就搞完整的 DFE。我建议从一个一阶 RC 低通滤波器开始,把整个流程跑通。你需要一个支持 SystemVerilog 类的仿真器,主流商业工具都支持,开源工具里 Verilator 对类的支持有限,可能要用商业工具或者 Icarus Verilog 的扩展版本。
首先定义模拟模块的基类:
virtual class analog_block; real time_scale; pure virtual function real eval(real t); pure virtual function real next_event_time(real t); endclasseval是求值函数,给定时间 t 返回波形值。next_event_time是返回下一个“有意义”的时间点,比如极值点、过零点、或者用户定义的采样点。对于一阶 RC 低通,冲激响应是 e^(-t/RC),过零点只有 t=0,极值点也是 t=0,所以next_event_time可以返回无穷大,表示没有更多事件。
然后定义事件队列和调度器:
class event_queue; sim_event_t queue[$]; function void insert(sim_event_t e); queue.push_back(e); queue.sort with (item.timestamp); endfunction function sim_event_t pop(); return queue.pop_front(); endfunction endclass这个实现用了 SystemVerilog 的sort方法,效率是 O(n log n),对于小规模仿真够用。大规模仿真建议用堆。
4.2 一阶 RC 低通的解析波形实现
一阶 RC 低通的传递函数是 H(s) = 1/(1 + sRC)。冲激响应是 (1/RC)e^(-t/RC),阶跃响应是 1 - e^(-t/RC)。假设输入是阶跃信号,幅度为 A,那么输出波形是:
class rc_lowpass extends analog_block; real R, C, A; function new(real r, real c, real a); R = r; C = c; A = a; endfunction virtual function real eval(real t); if (t < 0) return 0; return A * (1 - exp(-t/(R*C))); endfunction virtual function real next_event_time(real t); return 1e30; // 没有更多事件 endfunction endclass这个波形是解析的,任意时刻求值都是 O(1)。如果你要加一个“输出达到 90% 时触发事件”的逻辑,可以解方程 1 - e^(-t/RC) = 0.9,得到 t = -RC ln(0.1)。把这个时间插入事件队列,仿真器就会在那个时刻触发。
4.3 事件驱动调度器的完整流程
调度器的主循环:
initial begin event_queue eq = new(); rc_lowpass rc = new(1e3, 1e-12, 1.0); sim_event_t e; e.timestamp = 0; e.action_name = "eval_rc"; eq.insert(e); while (eq.queue.size() > 0) begin e = eq.pop(); current_time = e.timestamp; if (e.action_name == "eval_rc") begin real v = rc.eval(current_time); $display("t=%g, v=%g", current_time, v); // 检查是否达到阈值 if (v >= 0.9 && !threshold_triggered) begin threshold_triggered = 1; sim_event_t e2; e2.timestamp = -1e3 * 1e-12 * ln(0.1); e2.action_name = "threshold_event"; eq.insert(e2); end end if (e.action_name == "threshold_event") begin $display("Threshold reached at t=%g", current_time); end end end这个流程跑下来,你会看到仿真时间不是均匀推进的,而是从 0 直接跳到阈值时间,中间没有多余的计算。这就是事件驱动的威力。
4.4 DFE 案例的扩展实现
把一阶 RC 换成 DFE 的 FFE 和反馈滤波器,思路是一样的,只是传递函数更复杂。FFE 通常是一个 FIR,传递函数是 Σ ak e^(-sTk)。在时域里,每个抽头对应一个延迟的冲激响应。你可以把每个抽头建模成一个独立的解析波形,然后在求值时叠加。
判决器是一个事件:在每个采样时钟沿,求值 FFE 输出,和阈值比较,输出判决结果。判决结果通过反馈滤波器产生新的波形分量,插入事件队列。这里要注意反馈延迟:如果反馈是即时的,需要迭代求解;如果有一个 UI 的延迟,就可以直接插入下一个采样时刻的事件。
论文里应该用了“预测-校正”方法:先假设反馈值为零,计算 FFE 输出,判决,然后用判决结果更新反馈,重新计算 FFE 输出,迭代直到收敛。对于 DFE,通常一两次迭代就收敛了。
5. 常见问题与排查技巧实录
5.1 事件队列排序不稳定导致结果振荡
我在复现时遇到的第一个坑是:同一时间戳的多个事件,处理顺序不同会导致结果不一致。比如反馈环路里,判决事件和反馈更新事件在同一时刻,如果先处理反馈更新,判决用的就是旧值;如果先处理判决,反馈更新用的就是新值。解决办法是给事件加优先级字段,数字事件优先级高于模拟事件,反馈更新优先级高于判决。或者引入零延迟迭代,同一时间戳的事件全部处理完后再检查是否有新事件插入同一时间戳。
5.2 拉普拉斯域转换的数值精度问题
高阶系统的部分分式分解,当极点非常接近时,数值误差会放大。我试过用双精度浮点分解一个 10 阶系统,极点间隔 1e-8,结果时域波形在后期完全失真。解决办法有两个:一是用符号计算工具预计算解析表达式,把系数以高精度存储;二是对极点做聚类,把接近的极点合并成一个高阶极点,用对应的时域形式(比如 t^n e^(-at))表示。
5.3 解析波形求值的性能瓶颈
虽然解析波形求值是 O(1),但如果事件队列里插入了大量“采样点”事件,求值次数还是会很多。比如你每个 UI 都采样一次,100 万个 UI 就是 100 万次求值。每次求值涉及 exp、sin、cos 等超越函数,累加起来也不便宜。优化方法是:对于需要频繁采样的波形,预计算一个查找表,用线性插值或者样条插值代替解析求值。精度损失可控,速度提升明显。
5.4 非线性模块的接口设计
论文主要针对线性时不变系统,但实际电路里总有一些非线性模块,比如判决器、限幅器、开关。这些模块不能用解析波形表示,但可以用事件驱动建模:定义一个“状态机”,状态变化时触发事件。比如判决器有两个状态:输出高和输出低。当输入超过阈值时,状态切换,触发事件,更新输出波形。输出波形可以是分段常数或者分段线性,用解析形式表示每一段。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 仿真结果振荡 | 同一时间戳事件顺序不确定 | 打印事件处理顺序 | 加优先级字段或零延迟迭代 |
| 波形后期失真 | 部分分式分解数值误差 | 对比符号计算结果 | 用高精度算术或极点聚类 |
| 仿真速度慢 | 采样事件过多 | 统计事件数量和求值次数 | 预计算查找表或减少采样点 |
| 反馈环路不收敛 | 代数环 | 检查反馈延迟 | 引入最小延迟或迭代求解 |
| 波形求值返回 NaN | 除零或对数负数 | 检查参数范围 | 加保护条件或限制参数 |
6. 这套方法的边界与我的实操体会
论文的方法很漂亮,但它有明确的适用边界。它最适合线性时不变系统占主导、非线性行为可以用事件精确描述的场景。比如高速 SerDes 的均衡器、线性滤波器、传输线、时钟恢复环路。对于强非线性电路,比如开关电源、大信号放大器、混频器,解析解不存在,还是得用 SPICE 或者谐波平衡法。
我在实际建模中发现,最大的价值不是“取代 SPICE”,而是把模拟行为验证提前到数字验证环境里。以前你要等模拟团队跑完 SPICE,拿到波形,再手动转换成 Verilog-AMS 或者 real-number 模型,才能放进数字 testbench。现在你可以直接用 SystemVerilog 类写解析模型,和数字 testbench 一起跑,迭代速度完全不一样。
另一个体会是:事件驱动的精度控制比时间步进更直观。时间步进里,你调步长是在“精度”和“速度”之间做 trade-off,但步长和精度之间的关系不是线性的,很难调。事件驱动里,你直接控制“在哪些时刻求值”,精度是解析的,速度取决于事件数量。你可以精确地决定“我只关心过零点”或者“我只关心采样时刻”,其他时间不计算。这种控制感是时间步进给不了的。
最后分享一个小技巧:如果你要复现论文的 DFE 案例,建议先从理想判决器开始,不加噪声和抖动,把事件队列和反馈环路跑通。然后逐步加非理想因素:判决器偏移、采样时钟抖动、反馈延迟。每加一个因素,观察事件队列的变化和波形精度的变化。这样你能清楚地看到每个因素对仿真结果的影响,也更容易定位问题。