本页是关于时钟域(clock domains)的三页系列中的最后一页。
当一台比特(bit)不够用时
很多时候,需要通过时钟域的信号是一个数据字,而不是单个比特(bit)。在这种情况下,直接的解决方案是由 FPGA的供应商提供的 dual-clock FIFO ,如前所述。但有时这不是一个选择。此外,过去必须有人实现 FIFO 。
所以目标是让一个 vector 信号在另一个时钟域(clock domain)上正确显示。让我们首先从跨时钟域的一个幼稚且不正确的示例开始,只是为了解释为什么它不那么容易:
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
这与上一页的简单 metastability guard 示例完全相同,但寄存器(registers)是 8-bit vectors,而 @foo 是计数器,而不是在 '0' 和 '1'之间转换。
那么为什么这是错误的呢?问题是路径(paths)从 @foo 到 @bar_metaguard在布线时延(routing delays)中的差异: 八个比特中的每一个都有不同的时延(delay)。当 @foo 发生变化时,这些比特的一些变化可能会随着合法的时序(timing)到 @bar_metaguard的触发器(flip-flops),而其他的则不会及时到达。
所以即使组成 @bar_metaguard的8个触发器(flip-flops)中没有一 metastability ,也会出现 @foo 发生变化的情况, @bar_metaguard 的比特中只有一部分获得了新的值,而其他的则保持旧值。因此,如果 @foo 从 0xff 变为 0x00,则 @bar_metaguard 的下一个值可能是任何值。那是因为有些比特在更改前获得了 @foo的值,而有些则在更改后获得了 @foo的值。这个不正确的值将在 @clk2的时钟周期(clock cycle)之后在 @bar 上可见。
要解决这个问题,首先需要定义需求: @bar 是否需要始终包含一个有效值,或者它是否打算偶尔将信息从一个时钟域传递到另一个时钟域?我将分别讨论这两个选项。
选项1: 连续采样
如果目标字(示例中为@bar )需要连续采样另一个时钟域(@foo)上的字,并且始终包含合法且有意义的值,则只有一种方法可以确保这一点: 使用上面显示的跨时钟域的朴素方法,但要确保在 @clk1的每个时钟周期(clock cycle)上,只有一 @foo的比特发生变化(或没有变化)。换句话说, metastability guard 和 vector 信号一起使用,但要确保避免在同一个时钟周期上更换多个比特时出现问题。
因为每个时钟周期上只有一个比特可以更改,所以每个更改都被 @bar_metaguard采样或遗漏,无论哪种方式,它都反映了 @foo 所具有的值之一。
因此,如果上面示例中的 @foo 是使用 Gray code 而不是普通 binary 代码的计数器,它会工作得非常好: Gray coding 的本质是每次字数增加只有一个比特变化,所以 @bar 可以保证总是携带一个有意义的值。
但是,等一下,如果 @clk1 的频率高于 @clk2,会发生什么情况?如果 @bar 跳过 @foo的一些值,那就没关系了。例如,如果 @foo 是使用 Gray 代码格式的计数器,则查看 @bar时可能会跳过一些计数数字。然而,在 @bar 上看到的所有值都是正确的,因为它们确实在某个时间点出现在 @foo 中。
这种方法最常见的使用是在 dual-clock FIFOs内部,其中 Gray 代码用于跨两个时钟域传输 FIFO的 RAM 中的地址: FIFO的写入端将其写入 RAM 的最后一个字的地址(address)编码为 Gray 代码。编码后的字通过对该字使用 metastability guards 传输到位于另一个时钟域上的读取端。另一个方向使用相同的方法。
因为每一方都知道对方更新后的地址(经过 metastability guards的延迟),所以每一方还可以计算出 FIFO中有多少个元素,从而产生像 empty 和 full这样的信号。
以更高的速率传送事件
回想一下上一页, metastability guard 搭配单颗比特对音源时钟域的时钟频率有限制。如果此比特的每次更改都是向对方告知事件的一种方式,那么如果这些事件经常发生,接收方就有可能错过事件。
解决方案是在时钟域上传送一个以 Gray 代码编码的计数器。这样,接收方就知道发生了多少事件,因此不会丢失任何信息。选择这个计数器的比特的数量,以便即使事件发生在 @clk1的每个时钟周期上,接收方仍然能够推断出发生了多少事件。
所以从这个意义上说,使用 vector 比使用单个比特更容易: 如果单个比特发生变化,并且由于目的地的时钟速度较慢而错过了这一事实,那么结果就是错过了任何发生的事情。但是,通过时钟域正确传输的单词(例如,使用 Gray 代码),不会丢失任何信息。
如果一个比特就足以达到此目的,那么 Gray code 计数器只是一个在每个事件上改变其值的位。换句话说,使用单个比特,使用 Gray code 计数器的解决方案与简单的 metastability guard完全相同。
对时序和路径的一点小评论
正如本节的标题所暗示的,跳到下一节可能是个好主意。
关于 vector 信号的 metastability guard 有一个基本假设: 这些路径的时延之间的差异不超过源的时钟的时钟周期。
几乎可以肯定地满足了这个假设,而无需对其进行任何特殊处理,仍然让我们考虑这个理论示例: 假设 @clk1 的频率为 500 MHz,而 @foo的路径到 @bar_metaguard 中的一个具有 1 ns的布线时延。另外,假设另一条路径(path)有一个布线时延(routing delay)的 4 ns,这当然是极不可能发生的,但让我们看看会发生什么:
其中一台比特改变了它的价值,而改变开始了 4 ns的旅程。在后面的时钟周期,以后的 2 ns 上,另一个比特变化,经过 1 ns时间到达 @bar_metaguard 。但这比第一台比特的到来要早 1 ns 。因此, @bar_metaguard 可以使用 @foo 从未有过的值对整个单词进行采样。
由于布线时延通常比本示例中的短得多,因此预计不会在现实中发生这种情况。尽管如此,理论上任何布线时延都是可行的。为了完全消除这种可能性,可以使用如下的时序约束(timing constraint)(以 Vivado 格式编写):
set_max_delay -datapath_only -from [ get_pins -hier -filter {name=~*/C} ] -to [ get_pins -hier -filter {name=~*_metaguard*/D} ] 1.5
这个约束(constraint)类似于上一页中给出的带有 set_max_delay 的那个。但是请注意,在上一页中, metastability guard 位于“-from”部分,而此处位于“-to”部分。所以约束(constraints)不适用于同一条路径。上一页中约束的目的是给 metastability guard 一些时间从 metastability恢复。因此,约束适用于相同时钟之间的路径。另一方面,上面的约束与跨时钟域本身有关。
因此,这个约束的写法不同: 由于相关的路径连接在 unrelated 时钟的时钟域之间,因此将这些时钟的偏移(skews)和这些时钟的 jitters 考虑在内是没有意义的。这就是 -datapath_only 部分所说的: 不要介意时钟到达触发器所需的时间。只需测量路径。
这个约束令人困惑的地方在于,路径从源触发器的时钟引脚开始,到目的地的数据输入引脚(data input pin)(D) 结束。因此,秒表从源触发器获得其时钟时开始,到更新信号到达目的地(destination)时结束,这是满足其 setup time的必要条件。因此,这条路径包括双方的时序要求(timing requirements)。
通过将所有这些路径限制为 1.5 ns,就像在这个时序约束(timing constraint)中一样,没有一条路径可以超过这个时间限制,因此路径时延(path delays)之间的偏移也被限制在这个数量之内。所以即使 @clk1 有 2 ns的时钟周期,路径也不可能以错误的顺序到达。再一次,无论如何,这是极不可能的,但这是确保这一点的方法。
请注意,如果路径到 metastability guard 受到 false path 约束(例如 set_false_paths 或 set_clock_groups)的影响,则 set_max_delay 可能不会产生任何影响: false path 约束可能优先。因此,请始终检查时序报告(timing report)中的路径,以验证工具是否按需要解释约束。关于时序的不同页面对此进行了讨论。
选项#2: 寄存器的不定期更新
每个时钟周期上只能更改一个比特的限制通常过于严格。当数据偶尔更新时,可以使用另一种技术。对于以下示例,假设 @do_update 在多个时钟周期中仅激活一次(即具有值 '1')。另外,我们假设这个寄存器(register)用于指示 @foo 中的值应该用 @new_value更新:
reg [7:0] foo, bar;
reg toggle, toggle_metaguard, toggle_a, toggle_b;
reg new_value_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 的值作为 metastability guard。在下一个时钟周期上,这个值被复制到 @toggle_a中。之后的时钟周期,直接将 @foo 中的值复制到 @bar中。这是因为 @toggle_a 和 @toggle_b 在恰好一个时钟周期期间具有不同的值。
@bar 和 @foo 在不同的时钟域中这一事实没有任何意义,因为 @foo 已经稳定了远远超过足够的时间来满足时序要求。
为什么我对此如此肯定?这次我有一个很好的理由,它是这样的: 整个过程从 @toggle_metaguard 改变值开始,因为 @toggle 改变了。如果 @bar 在同一 @clk2 周期对 @foo 进行采样,那将是不安全的,但如果运气好的话,可能会没事。但是在 @toggle_metaguard的新值达到 @toggle_a之前,还有另一个时钟周期的 @clk2 。而且 @bar 甚至没有更新,只是在 @clk2的下一个时钟周期上。
所以从 @foo 发生变化到 @foo 被 @bar 采样到有一段时间,对应 @clk2中至少两个时钟周期。与任何触发器的 setup time相比,那是永恒的。也就是说,应用 set_max_delay 是有意义的,如 @toggle_metaguard上一页所示。路径到 @bar也可以这样做,尽管由于刚才提到的永恒,这不太可能是必要的。
这种方法的致命弱点是 @do_update 必须很少被激活,以确保 @foo 在被 @bar采样时保持稳定。此类更新之间的合理最短时间是对应于 @clk2的四个时钟周期的时间。所以计算的是 @clk1 中有多少个时钟周期对应 @clk2的四个时钟周期,并向上取整到最接近的整数。如果 @clk1 比 @clk2 慢四倍(或更慢),那根本不是限制。否则,逻辑中必须有某种机制来确保 @do_update 不会比允许的更频繁地激活。
事实是,在现实生活中的设计中,当更新速度非常慢时,有时会不小心越过时钟域,而没有任何 @toggle 提供的保护。这样一来, @foo 就会被连续复制到 @bar 中。当 @foo 长时间改变一次时, @bar 可能在一个时钟周期中包含不正确的值,但谁在乎呢?很多时候,这个错误是忽略整个时钟域问题的结果,因为嘿,它有效。直到它没有,偶尔。
说到草率,请注意在上面的示例中, @toggle 及其任何相关的寄存器都没有被重置或分配初始值。这通常很好,因为很可能综合工具(synthesizer)为它们分配了一个初始值 0。即使这些寄存器最初没有相同的值,它也会导致对 @foo进行一次不必要的采样,仅此而已。不过,重置这些寄存器可能是个好主意。
更高级的变体
到目前为止,我已经提出了三个简单的例子:
- 凭借 metastability guard (在上一页中),使用单个比特跨越时钟域。
- 多台比特也一样,但限制是每台时钟周期只能更换一台比特。
- 跨过时钟域对单词没有限制,但是对允许更改该单词的频率有限制。 toggle 比特用于确保仅在单词稳定时对其进行采样。
这些简单的例子是其他几种机制的基础。
首先,我答应在上面的例子中说一下 @new_bar 。所以它只是一个寄存器在一个时钟周期期间是高的,当 @bar 有一个新的值时。这没什么特别的,但请注意, @bar 和 @new_bar 在另一个时钟域中反映了 @foo 和 @do_update 。所以这是一种通过时钟域传递命令和状态消息的方法(我是否提到过应该尽可能使用 FIFO ?)。
最后一个例子的另一个有趣的扩展是: 用 dual port RAM 代替寄存器、 @foo 和 @bar对。这是一种跨时钟域传输缓冲区数据的方法: 假设 @clk1 的时钟域中的逻辑向 RAM写入数据,一段时间后填充了这 RAM 的一半。随着逻辑继续填充 RAM的后半部分,它改变了 @toggle的值。这个寄存器复制到 @clk2的时钟域上,一模一样。但不是像示例中那样更新 @bar ,而是逻辑消耗 RAM前半部分的数据。
这就是这个简单的寄存器可以同步 double-buffer 机制的方式,其中一侧从 RAM 读取数据,另一侧从 RAM读取数据。事实上, @toggle 的作用不仅仅是改变值,它还告知对方当前正在写入 RAM 的哪一半。
然而,最好尽可能使用 FIFO 。尽管这种 double-buffer 机制听起来很诱人,但只有在没有更好的选择时才应该使用它。例如,当从 RAM 读取数据的顺序与写入数据的顺序不同时。
概括
最后,归结为: 在时钟域和 unrelated 时钟之间的转换中,总是涉及到 resynchronization 逻辑。通过这 resynchronization 的数据字是有限的,所以只有一个比特可以改变源时钟的每个时钟周期上的值(示例中为@clk1 )。否则,非法数据可能会到达目的地。
在某些应用程序中,这已经足够了,但是当这种限制过于严格时,数据可以在时钟域与 vector 寄存器之间或通过 RAM之间移动,而无需在数据本身上使用任何 resynchronization 逻辑。这要归功于逻辑在数据的写入操作和读取操作之间保持最小的时间间隔。这个时间间隔确保数据字在目的地被采样时是稳定的。尽管如此,这款逻辑还是基于与跨时钟域相同的技术。因此,此解决方案涉及 resynchronization 逻辑,它仅限于一次更改一个比特,可能通过使用 Gray 代码。
所以当涉及到 unrelated 时钟时, resynchronization 逻辑和一个比特的规则总是存在的。这只是他们如何应用的问题。