两级触发器同步器:打两拍原理与亚稳态仿真实测
更新时间:2026-10-25。本文是「集成电路大类 · 芯片设计」入门层(beginner)的一篇实验文。理论篇 跨时钟域 CDC 基础 讲过亚稳态与 MTBF:单 bit 信号跨域要「打两拍」,第一级留给亚稳态收敛、第二级输出稳定。那一篇回答「为什么」,本篇用一份 20 行 RTL 加一个定向 testbench,在 iverilog 里把「异步输入在任意时刻翻转、输出三拍内稳定跟随」这件事真跑一遍,每拍采样都留在日志里。
本文要回答的问题
- 两级触发器同步器的 RTL 到底长什么样,为什么中间不能插组合逻辑?
- 「打两拍」之后,输出延迟到底是几拍?能不能实测出来?
- 亚稳态在普通数字仿真里看不到,那仿真还能验证同步器的什么性质?
- 为什么窄脉冲不能直接打两拍跨域?
实验总览先给一张数据卡,数字均来自后文真实日志:
四个数字分别对应激励次数、失败计数、电路结构、判据窗口。下文的 termshot 实录里可以逐条找到它们。
一、两级同步器:20 行 RTL 的全部
电路简单到只有一条移位链:
module sync_2ff (
input wire clk,
input wire rst_n,
input wire d_async,
output wire q_sync
);
reg meta, sync;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
meta <= 1'b0;
sync <= 1'b0;
end else begin
meta <= d_async;
sync <= meta;
end
end
assign q_sync = sync;
endmodule数据流向与每一级承担的角色,一张图说清:
读这张图抓住颜色语义:红色第一级是「风险区」——d_async 翻转时刻与时钟沿无关系,第一级采样可能落在 setup/hold 窗口里、输出短暂停在中间电平;绿色第二级是「安全区」——它采样的是已经收敛了一个完整周期的 meta,输出必定是干净 0/1;蓝色是异步来源,紫色是下游使用者。规则只有一条但极其关键:meta 到 sync 之间不许插任何组合逻辑,否则收敛时间被吃掉,第二级又可能采到没收敛完的值。
为什么「打一拍」不够?假设第一级以某个小概率 p 进入亚稳态、且在一个周期内没收敛完,那么只打一拍时这个没收敛完的值直接送进下游逻辑——下游把它当合法电平用,错误就传播了。打两拍后,第二级再踩中「还没收敛」的概率是 p 乘上一个小得多的条件概率,理论篇给出的 MTBF 公式里,tr(留给收敛的时间)在指数上,多一级触发器就把平均无故障时间从「每月几次」推到「数千年一次」。这是用一拍延迟换指数级可靠性,是数字设计里最划算的交易之一。
二、仿真能验证什么、不能验证什么
先泼一盆冷水:普通事件驱动仿真(iverilog/VCS 的纯数字模式)看不到亚稳态。仿真里的触发器是理想模型,不满足 setup/hold 只会报 X 或静默采到新值,不会输出「中间电平停留 300ps」。要真模拟亚稳态得用带时序反标的门级仿真加随机注入,那是签核工具的活。
那本篇 testbench 验证什么?验证功能级契约:无论异步输入在周期内哪个时刻翻转,同步器输出都应在至多 3 个目的时钟内稳定跟随到新电平,且跟随期间不抖动(一个值保持住再变)。这个性质是可测、可回归的,也是下游逻辑依赖的时序假设。testbench 的做法:
task toggle_and_check;
input integer delay_ps;
input val;
begin
#(delay_ps / 1000.0) d_async = val; // 刻意避开时钟沿
$display("[%0t] TOGGLE d_async -> %b (async, off clock edge)", $time, val);
repeat (3) @(posedge clk);
#1;
checks = checks + 1;
if (q_sync !== val) begin
errors = errors + 1;
end else begin
$display("[%0t] OK: q_sync followed d_async=%b within 3 clocks", $time, val);
end
end
endtask五次跳变的偏移量刻意取 7、3、9、1、7 ns——时钟周期 10 ns(100 MHz),等于把异步沿撒遍周期内各个相位,覆盖「刚过时钟沿翻」和「贴着下一个沿翻」两种极端。判定窗口取 3 拍而不是 2 拍,留了一拍余量:异步沿若刚错过一个采样沿,第一级要等到下一个沿才采到,第二级再晚一拍,最坏情况接近 2 拍多一点才反映到 q_sync,3 拍窗口保证正常实现下不可能误判 FAIL。这个「判定窗口 = 级数 + 1」的取法,写其他同步器 testbench 时可以直接搬用。
另外注意 testbench 里每次采样都打在 posedge 后 #1,理由和 FIFO 实验一样:躲开非阻塞赋值的更新窗口,拿到的是本拍落定后的值,避免假报错。
三、实测实录:逐拍采样日志
这份实录最值得读的是三组信号的相位差。看 42000-56000 这段:42000 时刻 d_async 在离沿 2 ns 处翻到 1;下一个采样点 46000,meta=1 已经跟上但 q_sync 还是 0——这就是「打两拍」的代价,输出比输入晚约两拍;到 56000,q_sync=1 完成跟随,日志打出 OK: q_sync followed d_async=1 within 3 clocks。五次跳变全部重复这个模式,最终 checks=5 errors=0、RESULT: PASS。
| 日志证据 | 说明 |
|---|---|
[46000] sample d_async=1 meta=1 q_sync=0 | 第一级已跟上,第二级还差一拍——两拍延迟的直接证据 |
[56000] OK: q_sync followed ... | 3 拍判定窗口内完成跟随 |
checks=5 errors=0 | 5 次定向跳变全部通过 |
RESULT: PASS | 脚本级判据命中 |
注意一个工程推论:同步器引入约 2 拍延迟且延迟可能在 1-2 拍间抖动一拍(取决于异步沿落在周期哪个相位)。下游逻辑绝不能假设「信号变了立刻可见」,握手机制、边沿检测都要容忍这个延迟。
把这份日志和「只打一拍」的对照情形放在一起看,差别更直观:
| 对比维度 | 只打一拍(单级) | 打两拍(本篇实现) |
|---|---|---|
| 输出延迟 | 约 1 拍 | 约 2 拍,可能抖动一拍 |
| 亚稳态收敛时间 | 几乎为零,直接送下游 | 完整一个时钟周期 |
| MTBF 量级 | 差(月级甚至周级故障) | 指数级提升(千年级) |
| 适用场景 | 只用于同域、时序有保证的路径 | 一切单 bit 跨域电平信号 |
多花一拍延迟,把「偶发随机死机」换成「设计寿命内几乎不可能发生」——这张表就是面试里「为什么打两拍不打一拍」的标准答案,也是本篇 RTL 每一行存在的理由。
再把后三次跳变的采样过程原样摘录一段,方便逐拍核对「输入翻转 → meta 跟随 → q_sync 跟随」这条链的拍数关系:
[105000] TOGGLE d_async -> 1 (async, off clock edge)
[106000] sample d_async=1 meta=1 q_sync=0
[116000] sample d_async=1 meta=1 q_sync=1
[126000] sample d_async=1 meta=1 q_sync=1
[126000] OK: q_sync followed d_async=1 within 3 clocks
[127000] TOGGLE d_async -> 0 (async, off clock edge)
[136000] sample d_async=0 meta=0 q_sync=1
[146000] sample d_async=0 meta=0 q_sync=0
[156000] sample d_async=0 meta=0 q_sync=0
[156000] OK: q_sync followed d_async=0 within 3 clocks
[163000] TOGGLE d_async -> 1 (async, off clock edge)
[166000] sample d_async=1 meta=1 q_sync=0
[176000] sample d_async=1 meta=1 q_sync=1
[186000] sample d_async=1 meta=1 q_sync=1
[186000] OK: q_sync followed d_async=1 within 3 clocks这段摘录覆盖三次完整跳变周期。以 127000 时刻的下降沿为例:d_async 在离时钟沿 3 ns 处归零,下一个采样点 136000 处 meta 已归零但 q_sync 仍是 1——输出还保持着旧电平一拍,到 146000 才归零,整条链从输入翻转到输出落定跨了约两拍,随后打出 OK。三次跳变的跟随模式完全一致,说明无论异步沿落在周期的哪个相位,两级链的延迟上界都是确定的。
还有一个值得亲手改一改的练习:把 RTL 里的 sync <= meta 连同 sync 寄存器一起删掉、让 q_sync = meta,重跑同一个 testbench。因为本仿真器是理想模型,功能上它大概率还是 PASS——这恰好印证了前面的论断:打一拍的风险是概率性的物理效应,功能仿真抓不到,只能靠结构纪律(强制两级)和静态签核防住。理解「仿真 PASS 不等于电路安全」,是跨域设计最重要的一课。
这份日志里还有一个容易忽略但值得指出的细节:d_async 翻转与 meta 跟随之间永远只差一个采样点,而 meta 到 q_sync 之间恒有一整拍延迟——整条链的行为完全确定、可建模。下游模块做形式验证或写时序约束时,「输入变化后 2 拍内输出有效」就是可以写进接口契约的那句话。
四、三条使用红线
第一,只同步电平/状态,不同步窄脉冲。源脉冲若短于目的时钟周期,可能两个采样沿之间就消失了,打几拍都没用。本篇 testbench 里 d_async 每次翻转后都保持多个周期,正是「电平信号」的用法。脉冲跨域要先在源域做 toggle 展宽、目的域边沿检测,这套电路在 跨时钟域 CDC 基础 第三节有完整讲法。
第二,多 bit 总线绝不能逐位打两拍。各 bit 线延迟不同,目的域可能采到高低位新旧混搭的非法组合——比如计数值 0111 跳 1000 时采到 1111。多 bit 走格雷码(连续值)、req/ack 握手(低速数据)或异步 FIFO(高速流)。同步 FIFO 的实测见本站同族实验文,异步 FIFO 的格雷码指针则直接把本篇的两级同步器当基本件用——每根指针线跨域时就是打两拍。
第三,两级触发器必须被工具识别成同步器、不得被优化打散,且 CDC 路径要在 SDC 里设异步时钟组(set_clock_groups -asynchronous),别让时序工具去收敛一条本就没有时序关系的路径。综合工具如果看不到「这是同步器」的标记,可能把两级寄存器合并或复制,悄悄毁掉收敛结构——流片前靠 Spyglass CDC 之类的静态签核工具把这类问题兜住,仿真是兜不住的。
动手复现时可以做两个延伸实验:一是把 testbench 里五次跳变的偏移量改成 0 ns(恰好压在时钟沿上),观察理想仿真器如何处理这个退化情形、日志里的跟随拍数如何变化;二是把判定窗口从 3 拍收紧到 1 拍,看有多少次跳变会误报 FAIL——这能直观体会「判定窗口必须大于级数」这条取值的来历。两个实验都只需改一行代码,跑完对照日志,对同步器延迟特性的理解会从纸面落到手上。
这一篇在知识体系里的位置
- 理论承接:跨时钟域 CDC 基础(亚稳态、MTBF、为什么必须两级);
- 同族实验:同步 FIFO 的 iverilog 回归实测(读写同钟场景的对照组);
- 进阶方向:异步 FIFO 与格雷码指针、Spyglass CDC 静态签核。
一句话总结
两级触发器同步器 = 一条两拍移位链:第一级用一整个周期吸收亚稳态,第二级输出稳定电平;仿真虽看不到亚稳态本身,但可以定向验证「异步沿任意相位翻转、输出 3 拍内稳定跟随」这条功能契约——本篇 5 次跳变实测零失败,换来下游逻辑可依赖的时序假设。
实验环境
- 机器/系统:云主机 S6.4XLARGE32 16C32G / Ubuntu 22.04(eda-golden-v1 镜像)
- 工具版本:iverilog -g2012(eda-golden-v1 镜像自带 Icarus Verilog)
- 日期:2026-10-25
- 完整命令:
bash run.sh(内部执行iverilog -g2012 -o sim.vvp sync_2ff.v tb_sync_2ff.v,随后vvp sim.vvp,最后grep 'RESULT: PASS' sim.log)
代码获取
完整 RTL 与测试平台已同步到公开仓库:geek-doc.cn/experiments/exp-two-flop-synchronizer-mtf,克隆后进入该目录执行 bash run.sh 即可复现本文全部结果。