本页是时钟域系列三篇中的第二篇。
范围
正如上一页所述,如果一条路径(path)跨越的是相关时钟(related clocks)的时钟域,那么逻辑上并不需要特殊处理。不过,仍然需要确保 FPGA 工具在这条路径上施加时序约束(timing constraints),以保证路径终点同步元件的时序要求(即建立时间与保持时间)。也就是说,从这条路径被纳入时序计算这个意义上讲,这个情况与完全没有跨时钟域是一样的。
但如果两个时钟是无关时钟(unrelated clocks),就需要再同步逻辑(resynchronization logic):必须用 Verilog(或其他硬件描述语言)编写专门解决这个问题的代码。本页介绍的就是如何做到这一点的基础知识。
能用 FIFO 就用 FIFO
在深入正题之前,我觉得应该先给那些有条件绕开麻烦的人指一条完全省心的路,这样才算公平。那就是:
由 FPGA 工具生成的双时钟 FIFO,绝对是跨时钟域最安全的方式。这是最常用的解决方案,而这种做法之所以如此普及,这本身就是值得信赖的理由。
尤其是如果你刚接触 FPGA,那就更有理由优先考虑 FIFO,哪怕为此需要把自己的设计稍微改一改。使用 FIFO 可能比专门为你的目的定制的逻辑多消耗一点点资源。但要注意,如果 FIFO 深度很浅,通常并不需要用到块 RAM。所以,那点资源差异未必值得拿各种风险去换。
在许多情况下,FIFO 本来就是跨时钟域的自然之选,尤其是当数据流需要从一个功能单元传到另一个功能单元时。不过,即使它不那么自然,通常也可以通过重新组织逻辑来让事情变得合适。例如,如果某个时钟域的逻辑需要把某个事件通知另一个时钟域的逻辑,可以把该事件编码成一个消息字,再写入一个浅 FIFO。这种方案不但不容易出 bug,而且更有可能让设计变得更有条理、更易读。
如何连接和使用 FIFO,在这一系列页面中有详细讨论。
单比特信号的再同步逻辑
如果 FIFO 解决不了你的问题,那就该卷起袖子,为实现安全的跨时钟域(clock domain crossing)而编写再同步逻辑了。本页剩余内容只讨论跨时钟域传递一比特宽信号的情况。这是所有再同步逻辑的基石,而且这个看似简单的例子,其实大有文章。
本系列的下一页将基于下面介绍的亚稳态保护技术,讨论如何跨时钟域传递更宽的信号。
亚稳态
在介绍解决方案之前,有必要先理解当触发器的时序要求被违反时会发生什么。换句话说,就是数据输入端的信号在某个时间段内没有保持稳定——这个时间段从时钟边沿前 tsu 开始,到该边沿后 thold 结束。这里说的时钟边沿当然是触发器时钟输入上的那个边沿,正是这个边沿让触发器对它进行采样(sampling)。
显然,如果数据在这个时间段内发生了变化,触发器在该时钟边沿之后的输出是不可预测的。但情况比这更糟:触发器可能需要比平时长得多的时间,才能在输出端呈现一个合法的 '0' 或 '1'。尽管触发器的设计目标是把输出稳定在 '0' 或 '1' 这两个合法值之一上,时序要求一旦被违反,就可能让触发器在短时间内处于不稳定状态。用正式术语说,这个触发器正处于亚稳态(metastability)。
这里还是不免俗,用一下那个常见的小球站在山顶的比喻,它常被用来描绘触发器可能进入的不稳定状态:
亚稳态带来的问题,远不只是“触发器最终会落在哪个值上”的不确定性。尤其当触发器输出连接到多个目的地时更是如此:由于触发器需要更长时间才能落到 '0' 或 '1',信号可能来不及在终点触发器的采样窗口前稳定下来。结果,终点处的一些触发器看到还在抖动的信号,会把它当成 '0',另一些触发器则把它当成 '1'。这种不一致会迫使逻辑进入非法状态,而一旦进入这种状态,逻辑就可能表现得像黑魔法(black magic)一样难以捉摸。所以,可能有亚稳态风险的触发器,其输出绝不应同时连接到多个逻辑元件上。
要对付亚稳态,最好能知道触发器需要多久才能落入某个稳定状态。可惜,这个问题没有确定答案。这就像问小球会在山顶停留多久一样:它取决于很多因素,随机振动很可能最终使它向这边或那边倒下去。触发器的亚稳态也一样:它会依靠电子电路中的随机噪声或其他机制脱离亚稳态。
所以从理论上讲,触发器可以无限期地停留在亚稳态;但在现实中,它通常会在很短时间内落入某个稳定状态。它停留在这种状态的时间是一个随机变量。为了估计这个随机变量的行为,人们进行了大量实验和仿真。然而,这些尝试没有哪一项真正具有实际指导意义,因为其行为取决于芯片制造工艺、温度、串扰带来的噪声水平,以及其他各种因素。
所以再说一遍:如果存在一个“触发器停留在亚稳态的最长时间”这样的上限,那会很方便,但事实上并没有这样的上限。甚至连近似值都无法给出,因为较新的制造工艺所造出的触发器,脱离亚稳态的倾向也更快。
相反,你需要接受这样的观念:当你在无关时钟之间跨时钟域时,某个触发器停留在亚稳态的时间超过设计所能容忍范围的可能性始终存在,并由此导致出错。作为设计者,我们唯一能做的是降低这种风险。我们最多也只能实现一个可以接受的 MTBF(平均故障间隔时间,Mean Time Between Failure)。
幸运的是,恰好有一种成熟的技巧可以实现这一点。这就引出了下一个话题。
亚稳态保护
长话短说。再看一下上一页的第一个代码示例。如果 @clk1 和 @clk2 是无关时钟,那么要从 @foo 获得稳定的 @bar,常见的再同步逻辑写法是:
reg foo, bar, bar_metaguard;
always @(posedge clk1)
foo <= !foo;
always @(posedge clk2)
begin
bar_metaguard <= foo;
bar <= bar_metaguard;
end
正如它的名字所示,@bar_metaguard 是一个亚稳态保护(metastability guard)。实现 @bar_metaguard 的触发器在采样 @foo 时,偶尔会违反时序要求,因此 @bar_metaguard 可能出现短暂的亚稳态。由于这个触发器预计会很快从亚稳态恢复,所以它能及时稳定下来,满足 @bar 的建立时间要求。这样一来,@bar 就可以在 @clk2 的时钟域内被可靠地使用了。
这个解释听起来可能不够严谨,而事实也的确如此。它其实和我前面关于亚稳态的论述有矛盾,因为亚稳态并没有万无一失的解决方案。我稍后会回到这一点。不过现在,还是先沿用常见的做法:像上面那样加一个亚稳态保护,然后就不再为它操心。说实话,我还没听说过谁在这里出过问题。
想更加保险的人会再多加几个寄存器。双亚稳态保护的写法大致是这样:
reg foo, bar, bar_metaguard_a, bar_metaguard_b;
always @(posedge clk1)
foo <= !foo;
always @(posedge clk2)
begin
bar_metaguard_a <= foo;
bar_metaguard_b <= bar_metaguard_a;
bar <= bar_metaguard_b;
end
@bar_metaguard_a 是第一级亚稳态保护。如果不走运,它停留在亚稳态的时间太长,就会违反 @bar_metaguard_b 的时序要求。于是,@bar_metaguard_b 在下一个时钟周期也会出现一段亚稳态。但希望这次持续时间会短一些:人们常说,闪电不会两次击中同一个地方。当然,@bar_metaguard_b 也可能在亚稳态停留足够长的时间,从而违反 @bar 的时序。可这种情况的概率又有多大呢?别忘了,我们的目标是获得一个合理的 MTBF。
总结一下:如果你想要一个“单比特如何跨时钟域”的菜谱式答案,那么答案就在上面——一级或两级亚稳态保护就够了。如果你想要的是永远不会失败的方案,很遗憾,那是不可能的。但如果你想尽可能降低意外发生的概率,请继续往下读。
亚稳态的时序分析
现在回到上面只有一级亚稳态保护的例子,问一问:为什么我之前那么肯定 @bar_metaguard 能恢复得足够快?答案是,前面已经说过:并没有理由那么肯定。
不过,我们还是来做一点时序分析。@bar_metaguard 和 @bar 与同一个时钟同步。假设没有专门为这两个寄存器指定特殊的时序约束,那么它们之间的路径将受到 @clk2 的时序约束(即时钟周期)的限制。换句话说,工具会保证:在没有亚稳态的情况下,@bar 的输入信号是以合法的时序被采样的。
但加入 @bar_metaguard 的意义,恰恰就在于允许它偶尔发生亚稳态。因此,上面的时序分析并不够:实际上,触发器处于亚稳态的时间,会叠加到它的时钟到输出延迟上。换句话说,亚稳态会使触发器的输出比平时更晚稳定下来。所以,如果 @bar_metaguard 到 @bar 这段路径的时序裕量(slack)几乎为零(也就是说,它的传播延迟(propagation delay)刚好能满足时序,但没有余量),亚稳态就可能把这条路径的总延迟推到允许值以上。这样的事件会造成 @bar 输入端出现时序违例。当然,这要考虑温度、电压和制造工艺的最坏组合;而关键在于:工具在亚稳态保护触发器的输出上施加的只是普通的时序约束,并不能保证这里所需的时序要求。
幸运的是,这个问题很容易解决——或者应该说,很容易改善。方法是:在从亚稳态保护触发器通往所有需要可靠工作的寄存器的路径上,施加更紧的时序约束。换句话说,思路是让这些路径上的传播延迟更短。把时间预算中分配给传播延迟的部分缩短一些,省出来的时间就能留给亚稳态恢复。
例如,如果所有亚稳态保护寄存器都带有 *_metaguard 后缀,那么只需一条时序约束,就可以要求所有从它们出发的路径多留出一些稳定时间。在 Vivado 中,这条约束可以写成:
set_max_delay -from [ get_cells -hier -filter {name=~*_metaguard*} ] 0.75
(set_max_delay 命令在关于时序例外(timing exceptions)的页面中有说明。)
这条时序约束的意思是:任何从亚稳态保护触发器出发的路径,都必须在 0.75 ns 内到达终点(这些路径是通过寄存器名字中的后缀选出来的)。0.75 ns 是我在某个具体 FPGA 上尝试时,在不导致时序约束失败的前提下能得到的最小值(所以换成其他 FPGA 可能不一样)。确定这个数值的方法是:先把数字调小,直到工具无法满足这条约束;一旦失败,再把数字调大一些,直到出现一个很小的时序裕量(例如 0.2 ns)。这样就迫使工具在这些路径上竭尽全力。
这条 set_max_delay 命令,等效于要求一个 0.75 ns(1333 MHz)的时钟周期。所以,如果实际时钟周期为 250 MHz(4 ns),那么满足这条时序约束后,至少多出 4 – 0.75 = 3.25 ns 的余量。因此,只要亚稳态保护触发器的亚稳态持续时间不超过 3.25 ns,就不会发生时序违例,这都要归功于这条 set_max_delay 约束。
这当然比完全没有保证要好。但 3.25 ns 够用吗?算长吗?足以保证跨时钟域几乎万无一失吗?
前面已经说过,答案取决于好几个因素。从已发表的一些实验结果来看,我的个人印象是:在今天实际使用的任何 FPGA 上,想看到一个触发器在亚稳态停留长达 1 ns,都是极其罕见的事。不过,对这个问题很难说出什么绝对的话。
但通过添加这条时序约束、迫使工具竭尽全力,你就让你的 FPGA 设计达到与其他人相同甚至更好的状态。因此,本着黄金法则第 4 条(不要心存侥幸)的精神,不妨让自己处在这样一个位置:如果你的设计出了问题,那将有很多人也同样有理由抱怨。
从上面的时序讨论中还能得到另一个启示:如果时钟频率相对于所使用的 FPGA 来说已经很高,那么多加一级亚稳态保护可能是个好主意。尤其当没有使用上面建议的那条额外时序约束时,更应如此。如前所述,亚稳态会消耗路径上的时序裕量。当时钟周期变小(即频率变高)时,时序裕量往往也更小,因此能为亚稳态留出的额外时间也更少。
最后,我们来问一个有趣的问题:为什么几乎所有人都忽略这一整块时序问题,却也没人抱怨出过故障?我可以给出几种可能的解释,但这些只是猜测。
第一种解释是:工具很可能将亚稳态保护触发器在物理上放得离另一个触发器非常近,也就是放在 FPGA 的逻辑结构(fabric)上。这样一来,这两个触发器之间的传播延迟在该 FPGA 上无论如何都会很短。不过正如前面所说,如果没有显式的时序约束,这种紧凑布局并不能得到保证。
在 FPGA 厂商提供的官方 FIFO 内部,这种成对的触发器往往位于同一个 slice 上。不管怎样,官方 FIFO 在这个问题上是可以放心的。
另一个原因是:随着 FPGA 变得更快、可达到的时钟频率变得更高,触发器脱离亚稳态的速度也在加快。所以,“从亚稳态恢复所需时间 + 传播延迟”仍然会明显短于时钟周期。
因此,大多数人只是加一个亚稳态保护就不再担心,这并不奇怪。但这不是偷懒、不肯加约束的借口。
对时钟频率的限制
到目前为止,我还没有过多讨论两个时钟域中时钟的频率。例如,如果源时钟域中的 @clk1 比目的时钟域的 @clk2 更快,会发生什么?
由于并不需要两个时钟之间同步,源时钟频率本身是高是低都无所谓。但如果源端信号变化太快,它可能会在终点触发器来得及采样之前来回跳变多次。因此,如果源信号的所有跳变都必须在目的端可见,源时钟的周期就应当比目的时钟的周期稍长一些(也就是频率更低一些)。
长多少呢?并不需要长很多。如果你非要一个精确答案:它主要取决于终点触发器的时序要求。这里快速算一下(除非你真感兴趣,否则可以跳过):
如果这个触发器的输入恰好在其时钟边沿前 tsu 发生变化,那它就处在“能否被正确采样的边缘”,因此这个变化必须留到下一个时钟周期再采样,否则很可能被错过。为了让变化可靠地在下一个时钟周期被采样到,输入信号必须一直保持稳定,直到下一个时钟边沿之后 thold。所以,源时钟的周期必须比目的时钟周期长出 tsu + thold + 2tj。其中 tj 是时钟周期的不确定度,也就是抖动(jitter)。抖动要计两次:一次来自源时钟,一次来自目的时钟。
所以,我前面所说的“稍长一点”的源时钟周期,指的就是 tsu + thold + 2tj。
把亚稳态保护告诉工具
有些 FPGA 工具文档建议,给用作亚稳态保护的触发器添加一个属性(attribute)。这样可以防止工具搬动或改写这些触发器,同时也能促使工具把亚稳态保护触发器放在与下一级触发器相同的 slice 上,从而使布线尽可能短。
例如,Vivado 有一个名为 ASYNC_REG 的属性。和前面一样,如果所有亚稳态保护寄存器都带有 *_metaguard 后缀,那么在 XDC 文件中加上下面这一行,就能设置所需属性:
set_property ASYNC_REG true [get_cells -hier -filter {name=~*_metaguard*}]
也可以直接在 Verilog 代码中设置这个属性。
不过,这个属性多半不会带来什么实际差别,因为工具通常本来就能正确处理亚稳态保护。
