01signal.com

跨时钟域传递数据

本页是时钟域系列三篇中的最后一篇。

单比特不够用的时候

很多时候,需要跨时钟域(clock domain crossing)的信号是一个数据字(data word),而不是单个比特。这种情况下最直接的解决方案是 FPGA 厂商提供的双时钟 FIFO,正如上一页所建议的那样。但有时这个方案并不可行。再说,当年也总得有人去实现那个 FIFO。

所以,目标是把一个向量信号正确地带到另一个时钟域上。先来看一个天真的、而且是错误(incorrect)的跨时钟域示例,借此说明为什么这件事并不容易:

reg [7:0] foo, bar, bar_metaguard;

always @(posedge clk1)
  foo <= foo + 1;

always @(posedge clk2)
  begin
    bar_metaguard <= foo; // This will fail sometimes!
    bar <= bar_metaguard;
  end

这看起来与上一页那个简单的亚稳态保护示例一模一样,只不过这里的寄存器是 8 位向量,而且 @foo 是一个计数器,不再是在 '0' 和 '1' 之间来回翻转。

那为什么这样是错的呢?问题在于,从 @foo 到 @bar_metaguard 的各条路径(path)之间的布线延迟(routing delay)存在差异。这 8 个比特各自有不同的延迟。当 @foo 变化时,其中一些比特的变化也许能够以合法的时序到达 @bar_metaguard 的触发器,另一些则来不及。

因此,即使组成 @bar_metaguard 的 8 个触发器都没有出现亚稳态(metastability),也可能出现这样的情况:@foo 发生变化后,@bar_metaguard 只有一部分比特拿到了新值,其余比特还保留着旧值。所以如果 @foo 从 0xff 变成 0x00,@bar_metaguard 的下一个值就可能是任何东西。这是因为有的比特在变化之前就采到了 @foo,另一些则在变化之后才采到。这个错误的值会在一个 @clk2 时钟周期之后出现在 @bar 上。

要解决这个问题,首先得明确需求:是需要 @bar 始终保存一个有效的值,还是只是偶尔把信息从一个时钟域传到另一个时钟域?下面分别讨论这两种情况。

方案一:持续采样(continuous sampling)

如果要求目的端的字(例子中的 @bar)持续地对另一个时钟域的字(@foo)进行采样,并且始终保存合法且有意义的值,那就只有一种办法:使用上面展示的那种朴素的跨时钟域做法,但要保证在 @clk1 的每一个时钟周期里,@foo 中只有一比特发生变化(或者没有变化)。也就是说,对向量信号使用亚稳态保护,但同时要避免同一时刻多个比特变化所带来的问题。

因为每个时钟周期只有一个比特会变化,所以 @bar_metaguard 要么采到这次变化,要么错过它。无论采到还是错过,它反映的都是 @foo 曾经出现过的某个值。

所以,如果上面例子中的 @foo 是一个使用格雷码(Gray code)而不是普通二进制码的计数器,那就完全没问题:格雷码的本质就在于,字每次递增时只有一比特发生变化,因此 @bar 能够保证始终携带一个有意义的值。

但等等,如果 @clk1 的频率高于 @clk2 会怎样?只要允许 @bar 跳过 @foo 的某些值,这就无关紧要。例如,如果 @foo 是使用格雷码格式的计数器,那么在看 @bar 时可能会跳过某些计数值。尽管如此,@bar 上出现的所有值都是正确的,因为它们确实在某个时刻出现在 @foo 上。

这种方法最常见的应用是在双时钟 FIFO 内部:FIFO 使用格雷码把 RAM 中的地址从一个时钟域传到另一个时钟域。写入端把最后一个写入 RAM 的字的地址编码成格雷码,再通过亚稳态保护把这个编码后的字送到另一个时钟域的读取端。反方向也是如此。

因为每一端都知道对端更新后的地址(在亚稳态保护的延迟之后),每一端也都能算出 FIFO 里有多少数据,从而产生诸如 empty(空)和 full(满)这样的信号。

以更高的速率传递事件

回想一下上一页的内容:用单比特做亚稳态保护时,源时钟域的频率会受到限制。如果这一比特的每次翻转都表示向对端通知一个事件,那么当事件发生得太频繁时,接收端就可能漏掉一些事件。

解决方案是把一个用格雷码编码的计数器送过时钟域。这样一来,接收端就知道一共发生了多少个事件,因此不会丢失任何信息。这个计数器的比特数要选得足够大,使得即使事件在 @clk1 的每个时钟周期都会发生一次,接收端也仍然能够推算出已经发生了多少个事件。

从这个意义上说,使用向量有时反而比单个比特更容易处理:如果单个比特发生了变化,而这个变化因为目的时钟较慢而没有被看到,结果就是错过了“有事件发生”这个事实。但若用一个能正确跨过时钟域的字(例如使用格雷码),信息就不会丢失。

而如果这类用途用一比特就足够了,那么格雷码计数器其实就是一个每次事件发生时翻转一次的比特。换句话说,对于单比特来说,“格雷码计数器”方案与简单亚稳态保护完全是一回事。

关于路径时序的一点较真

正如本节标题所暗示的,直接跳到下一节也许是个好主意。

对向量信号使用亚稳态保护时,有一个隐含的假设:这些路径之间的延迟差不能超过源时钟的一个周期。

这个假设几乎肯定可以满足,根本不需要特别处理。不过我们还是来看一个理论上的例子:假设 @clk1 的频率是 500 MHz,@foo 到 @bar_metaguard 的其中一条路径的布线延迟为 1 ns,另一条路径的布线延迟为 4 ns(这种情况当然极不可能发生),看看会出什么问题:

其中一个比特翻转了,这个变化沿着路径花了 4 ns 才到达。下一个时钟周期,也就是 2 ns 之后,另一个比特也翻转了,而它只花了 1 ns 就到了。也就是说,它比第一个比特早到 1 ns。这样一来,@bar_metaguard 可能会采到一个 @foo 从未出现过的整个字。

实际布线延迟通常比这个例子短得多,所以现实中不太可能发生。但理论上,任何布线延迟都是可能的。为了彻底消除这种可能性,可以添加下面这样的时序约束(以 Vivado 格式书写):

set_max_delay -datapath_only -from [ get_pins -hier -filter {name=~*/C} ] -to [ get_pins -hier -filter {name=~*_metaguard*/D} ] 1.5

这条约束与上一页中给出的 set_max_delay 约束相似。不过请注意,上一页中 "-from" 部分是亚稳态保护,而这里是在 "-to" 部分。所以这两条约束并不作用于同一类路径。上一页那条约束的目的是给亚稳态保护留出从亚稳态恢复的时间,因此它约束的是同一个时钟域内部的路径。而上面这条约束针对的是跨时钟域本身。

这条约束的写法也因此不同:由于相关路径连接的是无关时钟(unrelated clocks)的两个时钟域,把这些时钟的时钟偏斜(clock skew)和抖动(jitter)也考虑进去没有意义。这正是 -datapath_only 的意思:不用管时钟到达触发器需要多长时间,只测量路径本身。

这条约束容易让人困惑的地方在于,它的路径是从源触发器的时钟引脚(C)开始,到终点触发器的数据输入引脚(D)结束。也就是说,秒表从源触发器收到时钟的那一刻开始计时,到更新后的信号到达终点并满足其建立时间为止。因此,这条路径把两侧的时序要求都包含了进去。

像这条约束那样把所有路径都限制在 1.5 ns 以内之后,任何路径都不能超过这个时限,路径之间延迟的差异自然也被限制在这个范围内。于是,即使 @clk1 的时钟周期只有 2 ns,这些路径也不可能以错误的顺序到达信号。当然,这种情况本来就极不可能发生,但上面这种做法可以保证它不会发生。

还要注意,如果通往亚稳态保护的路径受到伪路径(false path)约束(例如 set_false_paths 或 set_clock_groups)的影响,set_max_delay 很可能不会起作用,因为伪路径约束很可能会优先。所以一定要在时序报告中检查这些路径,确认工具确实按照你的意图理解约束。关于时序的另一个页面对此有讨论。

方案二:偶尔更新寄存器

“每个时钟周期只允许一个比特变化”这个限制往往太严格了。当数据只是偶尔更新时,可以用另一种技术。在下面的例子中,假设 @do_update 在好几个时钟周期里才有效一次(即值为 '1')。同时,假设这个信号用来表示应该用 @new_value 更新 @foo 的值:

reg [7:0] foo, bar;
reg       toggle, toggle_metaguard, toggle_a, toggle_b;
reg       new_bar;

always @(posedge clk1)
  if (do_update)
    begin
      foo <= new_value;
      toggle <= !toggle;
    end

always @(posedge clk2)
  begin
    toggle_metaguard <= toggle;
    toggle_a <= toggle_metaguard;
    toggle_b <= toggle_a;

    if (toggle_a != toggle_b)
      bar <= foo; // No metastability guard, because foo is stable
    new_bar <= (toggle_a != toggle_b); // Not necessary, just side info
  end

先别管 @new_bar,我稍后会谈到它。

这个机制的工作原理如下:@foo 只在 @do_update 有效时更新。每当它更新时,@toggle 会在同一个时钟周期翻转到相反的值。

在 @clk2 的时钟域里,@toggle_metaguard 作为亚稳态保护先取得 @toggle 的值。下一个时钟周期,这个值被复制到 @toggle_a。再下一个时钟周期,@foo 的值被直接复制到 @bar。之所以在这个时候复制,是因为 @toggle_a 和 @toggle_b 恰好只在一个时钟周期内不相等。

@bar 和 @foo 属于不同的时钟域这件事在这里并不重要,因为 @foo 早就稳定了,稳定时间远远超过满足时序要求所需的时间。

我为什么这么肯定?这次我有充分的理由,逻辑如下:整个过程始于 @toggle 发生了变化,从而引起 @toggle_metaguard 发生变化。假如 @bar 在同一个 @clk2 时钟周期就去采样 @foo,那确实不安全,运气好的话也许没问题。但接下来还要再等一个 @clk2 时钟周期,@toggle_metaguard 的新值才会传到 @toggle_a。而且即使到了那时 @bar 也还不会更新,要等到 @clk2 的下一个时钟周期。

所以,从 @foo 发生变化到它被 @bar 采样,中间隔着至少两个 @clk2 时钟周期的时间。与任何触发器的建立时间相比,这简直像永远那么长。话虽如此,对 @toggle_metaguard 应用上一页那样的 set_max_delay 约束仍然是合理的。对通往 @bar 的路径也可以做同样的事,不过由于刚才说的“天长地久”,多半没有这个必要。

这种方法的致命弱点是:@do_update 必须不能太频繁,这样才能保证 @foo 在被 @bar 采样时保持稳定。两次更新之间合理的间隔至少相当于 @clk2 的四个时钟周期。具体做法是:算出 @clk2 的四个时钟周期相当于 @clk1 的多少个时钟周期,然后向上取整。如果 @clk1 比 @clk2 慢四倍(或更慢),那这个限制根本算不上限制。否则,就必须在逻辑中加入某种机制,确保 @do_update 不会比允许的间隔更频繁地有效。

说实话,在实际设计中,当更新频率很低时,有时候人们会漫不经心地跨时钟域,完全不用 @toggle 这类保护。做法就是把 @foo 持续地复制到 @bar。当 @foo 偶尔变化一次时,@bar 可能会在一个时钟周期内出现错误的值,但谁在乎呢?这种错误多半是忽视了整个时钟域问题造成的,因为嘿,它能工作。直到它偶尔不能工作

说到马虎,请注意在上面的例子中,@toggle 以及它相关的寄存器都没有复位,也没有赋初值。这通常没关系,因为综合器(synthesizer)多半会把它们都初始化为 0。即使这些寄存器初值不一致,也只会导致对 @foo 多进行一次本来不必要的采样,仅此而已。尽管如此,还是建议把这些寄存器也复位一下

更多变体

到目前为止,我给出了三个简单示例:

这些简单示例是许多其他机制的基础。

首先,我说过要回头谈谈上面例子里的 @new_bar。它其实只是一个在 @bar 获得新值的那一个时钟周期内为高的寄存器。这本身没什么特别,但请注意,@bar 和 @new_bar 分别反映了另一个时钟域中的 @foo 和 @do_update。所以,这是一种跨时钟域传递命令和状态消息的方法(我是不是已经提过,可能的情况下还是应该改用 FIFO?)。

对最后一个示例的另一个有趣扩展是:把 @foo 和 @bar 这一对寄存器换成双口 RAM。这是一种跨时钟域传递数据缓冲区的方法:假设 @clk1 时钟域的逻辑把数据写入 RAM,过一段时间后填满了 RAM 的一半。当逻辑继续填充另一半 RAM 时,它让 @toggle 翻转。这个寄存器按照上面的方法被复制到 @clk2 时钟域。不过,目的端并不像例子中那样更新 @bar,而是去消费 RAM 前半部分里的数据。

这正是这个简单寄存器如何同步一个双缓冲(double-buffer)机制的:一侧往 RAM 中写入数据,另一侧从 RAM 中读取数据。事实上,@toggle 的作用不仅是改变取值,它还告诉对端:RAM 的哪一半当前正在被写入。

不过,在可能的情况下还是最好用 FIFO。这种双缓冲机制听起来挺诱人,但只在没有更好选择时才应使用。例如,当从 RAM 读取数据的顺序与写入顺序不同时。

总结

归根结底就是:在无关时钟的两个时钟域之间过渡时,总会涉及再同步逻辑。穿过再同步逻辑的数据字受到限制——在源时钟(例子中的 @clk1)的每个时钟周期里只能有一比特变化,否则可能会有非法数据到达目的地。

在某些应用中,这样已经足够了。但当这种限制过于苛刻时,也可以让数据通过一个向量寄存器或一片 RAM 在时钟域之间移动,而不对数据本身使用任何再同步逻辑。这之所以可行,是因为逻辑在数据的写入操作与读取操作之间保证了最小的时间间隔。这个间隔确保数据字在被目的端采样时已经稳定。不过,这种保证所依赖的逻辑本身仍然基于同样的跨时钟域技术。因此,这种方案同样需要“每次只翻转一个比特”的再同步逻辑,过程中可能还会借助格雷码。

所以,一旦涉及无关时钟,再同步逻辑和“一次只变一个比特”这条规则始终都在那里;差别只在于如何应用它们。

本页由机器从英文翻译而来。如有疑问,请参阅原文
Copyright © 2021-2026. All rights reserved. (dcc38493)