4.1 敏感列表与事件队列 本节摘要:敏感列表是过程块的触发资格名单,漏写一个信号,仿真就「看不见」那次变化,而综合器仍按完整逻辑出电路——仿真综合从此分道扬镳。事件队列则是同一时刻内部的裁决规则,理解它,非阻塞赋值的时序才有底层解释。 上一章定下了赋值语义的规则,这一节回答两个更底层的问题:过程块何时被触发(敏感列表),同一时刻内谁先谁后(事件队列)。两问合起来,是「时间证词」的资格审与排序审。 敏感列表:触发资格的名单 电平敏感块(组合逻辑)的敏感名单必须包含块内所有右值信号。少写一个,后果是错位的:仿真器严格按名单行事——不在名单上的信号变了,块不重算,输出保持旧值,表现出「有记忆」的假象;综合器则忽略敏感列表、按块内逻辑直接出组合电路——它不认这个名单。
本节摘要:敏感列表是过程块的触发资格名单,漏写一个信号,仿真就「看不见」那次变化,而综合器仍按完整逻辑出电路——仿真综合从此分道扬镳。事件队列则是同一时刻内部的裁决规则,理解它,非阻塞赋值的时序才有底层解释。
上一章定下了赋值语义的规则,这一节回答两个更底层的问题:过程块何时被触发(敏感列表),同一时刻内谁先谁后(事件队列)。两问合起来,是「时间证词」的资格审与排序审。
电平敏感块(组合逻辑)的敏感名单必须包含块内所有右值信号。少写一个,后果是错位的:仿真器严格按名单行事——不在名单上的信号变了,块不重算,输出保持旧值,表现出「有记忆」的假象;综合器则忽略敏感列表、按块内逻辑直接出组合电路——它不认这个名单。同一个文件,两种解释,这就是漏敏导致「仿真对、综合错」的完整链条。
// 漏敏现场:sel 不在名单里 always @(a or b) begin // 错:漏了 sel y = sel ? a : b; end // 现象:sel 单独翻转时 y 不更新,直到 a 或 b 恰好也变 // 综合结果:正常的三端选择器,与仿真行为不一致 // 解药:通配敏感,交给工具构造完整名单 always @(*) begin y = sel ? a : b; end
@(*)(等价写法 @*)自动扫描块内全部右值信号与条件判断信号,生成完整名单。它从 2001 版进入标准,今天的 Lint 工具一律把手写组合敏感列表列为可疑项。纪律可以定死:组合块只写 @(*),手写名单只属于边沿敏感块——因为边沿块的名单不是「所有右值」,而是「时钟与异步复位」,这个名单工具替你猜不了。
边沿敏感块还有一条语义细节:posedge 的判定是「值从非 1 变为 1」,包括从 x 到 1 的跳变。这意味着上电未复位阶段的第一次跳变也会触发块执行——复位的重要性在这里又加了一分。
事件驱动仿真器维持一张按仿真时间排序的事件表:时间推进到最近的待处理时刻,取出该时刻的所有事件执行,执行中产生的新事件按规则插入。难点在「同一时刻内」:某个时刻触发了多个过程块,块与块之间、语句与语句之间的顺序,由分层调度区决定。

这张分层图解释了第 3 章的赋值铁律为什么成立。非阻塞赋值「先记账、沿末生效」,账本就是非阻塞更新区;阻塞赋值当场生效,活在活跃区。于是下面的经典对照有了严格解释:
always @(posedge clk) begin b <= a; $display("display 看到 b = %b", b); // 活跃区执行:b 还是旧值 $strobe("strobe 看到 b = %b", b); // 监视区执行:b 已是 a 的旧值 end
同一时刻内,$display 先于非阻塞更新区执行,看到旧值;$strobe 排在最后,看到本时刻的最终值。验证代码里想「打印这一拍结束后寄存器的值」,用 $strobe;想「打印触发瞬间的输入采样值」,用 $display。混用的结果不是错误,而是「看到你以为不该出现的值」的困惑现场。
案例展开:半拍延迟的选择器。 背景:某组合选择器偶发输出旧值一拍,波形显示 sel 已变而输出未跟。操作:审查 RTL 发现组合块敏感列表手写为 @(a or b),恰漏 sel;sel 单独变化时块不重算,直到 a 或 b 下次变化才「补算」。结果:改为 @(*),回归通过。解读:这个案子的狡猾之处在于「间歇性」——只要 a、b 翻转频繁,漏敏被掩盖;一旦输入活动稀疏,输出就悬挂在旧值上。漏敏缺陷的症状强度与输入活动率负相关,这让它在实验室容易漏网、在客户现场集中爆发。变式:评审老代码时把手写组合敏感列表一律视为待整改项;无法立即改的,先在仿真里对比 @(*) 版本的行为差异,确认无影响再切换。
事件在同一个时刻内可以「自我续期」:一个事件的处理触发新事件,新事件仍落在本时刻内,时间轴不动。这叫 delta 周期——时间不推进、事件再轮转的机制。组合环(第 3 章讲过)落到仿真器上就是 delta 死循环:事件无穷接力,时间永远停在原点,仿真器报「迭代超限」。理解 delta 还能解释另一个怪象:两个信号「同时」变化,经过不同长度的组合路径汇合时,路径上的中间节点并非同时稳定——毛刺就是在这几拍 delta 里诞生的,这是下一节的主题。
纪律层面把话说透:任何依赖「同刻内顺序」的代码都该重写。两个 always 块用阻塞赋值互相驱动、期望「我先执行你再执行」,就是典型的同刻依赖——换个仿真器或换个调度实现,结果就翻面。硬件世界里同沿触发的寄存器真正同时更新,你的代码只该依赖「沿」,不该依赖「沿内」。
边沿块的触发条件只有时钟与异步复位,但块内可以读任意信号——这些信号在沿上被采样,不参与触发。这个区别正是同步设计的根基:数据信号不触发任何行为,只被时钟沿采样,于是整个设计的时序都收敛到「沿」这一个参考点上。初学者容易把「敏感列表」误解为「可见信号名单」,写出在电平敏感块里做时序逻辑、在边沿敏感块里依赖多信号触发的混搭代码——记住分工:触发资格归敏感列表,数据来源归采样。
#100 是一百个时间单位,与真实纳秒的距离隔着一条 timescale 声明。单位定 1ns 精度定 10ps 时,#100 是 100ns;换一套声明,同一句代码的真实时长就变了。跨工具交换代码时,timescale 不一致导致的「时间标尺漂移」是老项目的经典暗坑——延迟的绝对值全对不上,时序检查全部失真。纪律是全工程统一时间刻度声明,并在文件头显式写出,不依赖工具默认值。
@(*):通配名单交给工具构造,手写名单保留给边沿块的时钟与异步复位;触发与调度讲完,下一节看时间刻度本身:延迟模型的三种口径,以及毛刺从哪里来。