本页属于关于时序的系列文章之一。前面几页解释了时序计算背后的理论,展示了如何编写多条时序约束,并讨论了时序收敛(timing closure)的原则。本页专门讨论多周期路径(multi-cycle paths)的时序约束。
引言
关于多周期路径,首先要知道的是:使用它通常并不是一个好主意。尽管本页会解释如何使用多周期路径约束(multi-cycle path constraints),但你最终得到的结论应当是:尽量完全避免这种技术。在 ASIC 领域,使用这种技术确实有一些理由;但在 FPGA 设计中,通常更好的做法是增加一个时钟。
话虽如此,我们还是来看一看,这种时序例外(timing exception)到底在什么时候才真正值得一提。
考虑下面的 Verilog 代码:
reg foo, bar;
reg en, pre_en;
always @(posedge clk)
begin
pre_en <= !pre_en;
en <= pre_en;
if (en)
begin
foo <= !foo;
bar <= foo;
end
end
注意,这个示例并不完整:你很可能需要为 @pre_en 和 @en 添加综合属性(synthesis attributes)。否则,综合器在做优化时可能带来一些意料之外的结果。关于这一点,下文还会讨论。
@pre_en 在每个时钟周期都会在 0 和 1 之间翻转。@en 也做同样的事情,只是比 @pre_en 晚一个时钟周期。
if (en) 这一段代码的含义是:@en 扮演了 begin 和 end 之间所有逻辑的时钟使能(clock enable)。当 @en 为低电平时,这段 Verilog 代码不会产生任何更新。换句话说,当 @en 为低电平时,@foo 和 @bar 表现得就像时钟边沿根本不存在一样。
在本例中,@en 每两个时钟周期会出现一次高电平。因此,@foo 和 @bar 的表现就好像时钟频率只有实际频率的一半。时序要求也就可以相应地放宽:在计算 tsetup 时,可以采用两倍大的时钟周期。
至于 thold,计算方式没有变化:计算这个时序要求时,假定同一个时钟边沿同时到达两个触发器。正如在thold 分析的例子中已经讨论过的,时钟周期本身在这里没有意义。因此,“时钟变慢”这种假象对 thold 没有任何影响。
什么时候应该使用时钟使能
使用时钟使能只有两个正当理由:
- 设计本身已经基于时钟使能正常工作。
- 时钟资源不足。这种情况并不常见,因为 FPGA 上的时钟资源通常都多于实际需要。
时钟使能是否真的能降低 FPGA 上的功耗,目前还不清楚。可以说,额外多一个时钟确实会浪费功耗。但时钟使能本身也是一个高扇出(fan-out)信号,而且一般来说,时钟使能翻转的频率与它所替代的时钟一样高。因此,就逻辑状态翻转(这是功耗的主要来源)这一点而言,两者没有区别。
除非你有某个特殊原因,迫使你必须使用多周期路径,否则完全可以跳过本页。即使你的设计中有一个在技术上适合使用多周期路径的时钟使能,也最好还是不要去用那条时序例外。尤其是当时序约束不加这条例外也能轻松满足时,为它冒险去引入错误并不值得。
这条时序例外
对应上面的 Verilog 代码,下面是 Vivado 中使用的多周期路径约束:
set en_regs [all_fanout -endpoints_only -only_cells -flat [get_nets en]] set_multicycle_path -setup -from $en_regs -to $en_regs 2 set_multicycle_path -hold -from $en_regs -to $en_regs 1
Quartus 中则是这样:
set en_regs [get_fanouts en] set_multicycle_path -setup -from $en_regs -to $en_regs 2 set_multicycle_path -hold -from $en_regs -to $en_regs 1
Vivado 与 Quartus 之间的区别只在于第一行:Vivado 使用 all_fanout 命令,Quartus 使用 get_fanouts 命令。对于其它支持 SDC 的工具,约束写法也类似。
第一行找出所有从 @en 出发的路径的终点上的时序元件,也就是所有被 @en 驱动的 cell 对象。这个 cell 对象列表被保存在 $en_regs 中。接下来两行修改那些起点和终点都在这个列表中的路径的时序要求。
这里有太多地方需要解释。我为什么要用这种方式来定义 $en_regs?为什么需要两条 set_multicycle_path 命令?第二条命令中为什么要写 -hold?我刚刚不是说多周期路径对 thold 没有影响吗?
我先从 set_multicycle_path 命令说起,因为这部分比较容易解释。
set_multicycle_path 命令
如上所示,这两条命令是:
set_multicycle_path -setup -from $en_regs -to $en_regs 2 set_multicycle_path -hold -from $en_regs -to $en_regs 1
为了把上面的例子稍微推广一点,我们再考虑一种情况:如果时钟使能每八个时钟周期才有效一次,Verilog 代码可以写成:
reg en;
reg [2:0] pre_en;
always @(posedge clk)
begin
pre_en <= pre_en + 1;
en <= (pre_en == 0);
end
注意,@en 的行为更像一个选通脉冲(strobe):它每次只在一个时钟周期内有效,而不是某个分频器(clock divider)的最高位(MSB)。
针对这种情况,多周期路径约束应当是:
set_multicycle_path -setup -from $en_regs -to $en_regs 8 set_multicycle_path -hold -from $en_regs -to $en_regs 7
结合上面两个例子不难看出,第一条 set_multicycle_path 命令中的数字,其实就是时钟使能的分频比。
至于第二条命令,它使用同样的分频比,但要减 1。所以永远是 N 和 N-1。
关于第一条命令,没有太多需要解释的:如果时钟使能每 N 个时钟周期里有效一次,那么允许的延迟也乘以 N。这对应 tsetup 的时序要求。
但为什么还需要第二条命令?为什么还要对 thold 说什么?答案是:第一条命令也会改变最小延迟的要求。也就是说,带 -setup 的这条命令同样会影响 thold 的计算。至于为什么,这恐怕很难给出一个合理的解释。
第二条命令正是为此而设:它把最小延迟要求改回原来的值。因此,在执行第二条命令之后,thold 的计算方式就与之前一样了。
关于第二条命令为什么要用 N-1,文档中写了很长的解释。但说实话,这些额外信息并没有什么意思。set_multicycle_path 对 thold 的行为本来就很奇怪,即使你理解了为什么数字是 N-1,它也不会变得不那么奇怪。
不过,set_multicycle_path 只是比较容易的部分。真正的难点在于:如何生成正确的 cell 对象列表,作为 $en_regs 使用。
选择寄存器
要决定 $en_regs 中应该列出哪些寄存器,首先必须理解什么样的路径才适合作为多周期路径。规则如下:只有当时钟使能同时控制路径的两端时,才允许放宽这条路径的时序要求。也就是说,当时钟使能无效时,可以保证路径两端的两个时序元件都不会在时钟边沿后改变状态。
从时钟域的角度来想:所有受这个时钟使能控制的时序元件,都属于同一个假想的时钟域。在这个假想时钟域内部,时钟频率更低,因此内部路径的时序要求可以放宽。
但如果一条路径只有一端属于这个假想时钟域,那么这就相当于该假想时钟域与某个相关时钟之间的交叉。对这种路径不需要做任何特殊处理,因为现有的时序约束已经把它照顾到了。不过,如果把多周期路径例外也套用到这样的路径上,那就错了。
上面给出的约束正体现了这一思想:所有受 @en 控制的时序元件都以 cell 对象的形式列在 $en_regs 中。然后,两条 set_multicycle_path 命令所作用的,是那些起点和终点都在这个列表中的路径。
多周期路径最困难的地方,就是确保这个时序元件的列表是正确的:它应当包含所有受该时钟使能控制的时序元件,但绝不能包含其它任何时序元件。
如果列表中漏掉了某个时序元件,相关路径上的时序要求会比必要的更严格。这算不上灾难,但会让这条时序例外的效果打折扣。
但如果列表中错误地加入了本不该加入的时序元件,后果就严重了:这会使得某些路径上的时序要求不够严格。换句话说,工具不再保证这些时序元件能够可靠地工作。而当时序要求得不到满足时,就可能出现各种奇怪的现象。
我选择用 all_fanout 命令(或者 get_fanouts)来生成这个时序元件列表。但这种方法并不能保证在所有情况下都正确。这正是下面要讨论的问题。之后我还会介绍其它生成这个列表的方法。当 FPGA 工具不支持 all_fanout 或类似命令时,这些替代方法尤其重要。
all_fanout 与 get_fanouts 可能带来的问题
多周期路径约束最容易犯的错误,是让时钟使能信号本身(也就是 @en)也出现在列表($en_regs)里。一旦出现这种情况,多周期例外就会被应用到从 @en 自身出发、到达它所控制的时序元件的所有路径上。这实际上意味着这些路径上的时序要求得不到保证。由于时钟使能往往是高扇出信号,这个问题会产生明显的影响。
在上面的例子中,我们用 @pre_en 避免了这种情况。你可能会问,为什么 @en 不直接用下面这种方式定义:
always @(posedge clk)
en <= !en; // Wrong!
如果真的像上面那样定义 @en,那么就会存在一条从 @en 出发、又回到 @en 的路径。结果,@en 自己也会被列入 $en_regs。
所以 @pre_en 解决了这个问题。但必须注意,要确保综合器不会为了优化而把这个寄存器删掉。例如,Quartus 的综合器会发现,@pre_en 的唯一用途就是为 @en 提供数值(见本页开头的 Verilog 代码),于是综合器会删除 @pre_en,然后按 en <= !en 来处理。结果,@en 仍然会被列入 $en_regs。一个可行的解决办法,是把 @pre_en 声明为:
reg pre_en /* synthesis preserve */;
这个简单例子说明,综合器的一次意外优化可能带来灾难性后果。虽然解决办法很简单,但人们很容易忽略去阻止这种优化的必要性。
另一个与时钟使能有关的潜在问题,是它往往具有很高的扇出。工具因此可能会自动复制这个寄存器,使每个副本的扇出都低于某个上限。可这会对 $en_regs 造成什么影响?列表的收录标准是基于某个特定网络的,因此那些由 @en 的副本所控制的时序元件,就不会出现在列表中。
这类情况的结果倒不是灾难性的:如前所述,它只会让某些路径的时序要求比必要的更严格,设计的可靠性不受影响。
寄存器复制的问题,在讨论高扇出时已经提到过。正如那里所说,最好手动复制 @en,而不是等着综合器来做。为了避免意外,始终应添加一条禁止复制该寄存器的综合属性。如果以后真的因为高扇出而导致时序收敛出现问题,再用手动复制来解决。这样更容易理解问题的来源。如果综合器是因为项目越做越大而突然复制了 @en,那么你就不容易意识到时序约束为什么没有满足。
无论属于哪种情况,定义 $en_regs 的那条命令都必须更新,以便把 @en 的副本也包含进来。
顺便提一句,$en_regs 的定义依赖的是某个网络的名称。正如前面讨论过的,这意味着只要综合器把这个网络的名字从 en 改成别的,$en_regs 就会变成一个空列表。结果是多周期路径约束完全失效。这种可能性倒也不是灾难:设计仍然可靠,只是更难满足时序约束。
另一个潜在问题是:由于 $en_regs 是这样定义的,@en 除用作时钟使能外,不能同时用于其它用途。例如,考虑下面的 Verilog 代码:
reg [7:0] counter;
always @(posedge clk)
if (en)
counter <= counter + 1;
在这个例子中,@en 显然只用作时钟使能,因此与 @counter 相关的所有路径都可以作为多周期路径。但下面这段代码呢?
reg [7:0] counter;
always @(posedge clk)
if (en)
counter <= counter + 1;
else
counter <= counter - 1;
这里的 @en 被用得就像一个普通寄存器一样。@counter 在每个时钟周期都会变化,因此它与多周期路径完全沾不上边。但问题是,@counter 的所有触发器都会被纳入 $en_regs:从 @en 到这些触发器之间都存在路径。
解决这个问题相对容易:复制一个 @en 的副本即可。
reg [7:0] counter;
reg non_ce_en;
always @(posedge clk)
non_ce_en <= pre_en;
always @(posedge clk)
if (non_ce_en)
counter <= counter + 1;
else
counter <= counter + 2;
注意,还需要一条综合属性,防止综合器把 @en 与 @non_ce_en 合并成同一个寄存器。
总之,基于“所有从 @en 出发的路径”来定义 $en_regs,确实简单、简洁,但同时也是一片雷区。下面我们来看几种替代方案。
生成 $en_regs 的其它方法
为多周期路径例外生成时序元件列表,最安全的方法是利用设计层次:所有受该时钟使能控制的逻辑都应放在一个单独的模块里(也可以包含它的子模块)。这样,就可以根据 cell 对象的完整名字来创建 $en_regs。例如,在 Vivado 中可以这样做:
set all_sync [all_fanout -endpoints_only -only_cells -flat \ [get_nets -of_objects [get_clocks clk]]] set en_regs [filter $all_sync {name =~ module_ins/multicycle_ins/* }]
第一条命令找出所有与 @clk 相连的逻辑元件,但时钟缓冲器本身除外。这个时钟由名字为 clk 的时钟对象表示。查找结果保存在 $all_sync 中。这是生成一个列表的办法,使它包含所有可能相关的时序元件。第二条命令从 $all_sync 中选出位于上述那个独立模块里的所有逻辑元件。
注意,这种方法依赖的是时钟对象的名字以及实例化的名字。这些名字预计不会改变。在这种方法下,即使时钟使能被复制,或者名字被综合器改变,也都没有关系。
把逻辑放进独立模块还有一个好处:处理 Verilog 代码时更不容易混淆哪些时序元件受时钟使能控制、哪些不受它控制。
不过,把依赖于时钟使能的逻辑分离出来并放进独立模块,这种做法有时并不自然。此外,如果 Verilog 代码已经写好并确认可以正常工作,那么对它大改可能并不是一个好主意。
我还要提另一种在某些情况下可能合适的替代方案:为所有相关寄存器制定统一的命名规范。例如,可以让所有受时钟使能控制的寄存器名字都以 MC_ 开头。这样,生成 $en_regs 的命令就变得很简单:只需根据名字搜索 cell 对象即可。其它逻辑元件(例如块 RAM)也可以相应地为实例化名字加上前缀来纳入其中。有些人会说这种方法让 Verilog 代码变丑了,也有些人会说它让代码更容易维护。没有哪种方法是十全十美的。
为什么不要用 -of_objects
用很简单的方法来定义 $en_regs,看上去可能很有吸引力:找到名为 en 的网络,把所有与该网络相连的寄存器都加进去。例如,在 Vivado 中可以写成:
set en_regs [get_cells -of_objects [get_nets en]]
这种做法是错的,原因有几条。第一条原因是:它会把 @en 自己也包含进去。于是,多周期约束会被应用到从时钟使能自身出发、到达它所控制的时序元件的所有路径上。如上所述,这是一个严重的错误。
第二条原因是:某些时序元件可能被漏掉。按照上面这条命令,收录的判据是 cell 是否与某个特定网络(@en)相连。当 @en 直接连接到触发器的 CE 输入时,这种办法有效。但综合器经常不这么做,而是选择用一个基于 @en 的组合逻辑函数来代替。
例如,综合器可能选择按照下面的代码来实现 @foo:
foo <= foo ^ en;
这在功能上与原来的表达式等价:
if (en)
foo <= !foo;
这类优化是合法的,也应当预料到:综合器反正都需要用一个 LUT 来实现反相器。那为什么不直接用这个 LUT 得到触发器下一周期的值呢?为什么还要多接一根线到触发器的 CE 输入端呢?
这种优化的副作用是:触发器本身不再与 @en 直接相连,因此不会被纳入 $en_regs。这种情况会造成什么后果,前面已经讨论过了。
通常可以让综合器只把 @en 用作时序元件的时钟使能输入。例如,有些综合器支持名为 direct_enable 或类似名字的综合属性。注意,使用这个功能后,综合器优化逻辑的自由度会降低。因此,为了解决一个工具层面的技术问题,设计的性能可能会受到负面影响。
除此之外,如果 @en 被复制或改名,前面提到过的那些问题也同样会出现。
因此,基于“是否直接连接到某个网络”作为收录判据,是一个非常糟糕的选择。
与复位信号的相互作用
假设我们给上面的 Verilog 示例加上一个同步复位(synchronous reset):
always @(posedge clk)
begin
pre_en <= !pre_en;
en <= pre_en;
end
always @(posedge clk)
if (reset)
begin
foo <= 0;
bar <= 0;
end
else if (en)
begin
foo <= !foo;
bar <= foo;
end
回忆一下多周期时序例外的思想:所有时序元件都表现得像处在一个假想时钟域中。这个假想时钟域里的时钟,频率只有 @clk 的一半。因此,所有寄存器都必须在 @en 为低时忽略 @clk。但在这个最新的 Verilog 示例中,情况并非如此:@reset 无论 @en 为高还是低,都会起作用。
例如,考虑如果把 @reset 定义成下面这样会发生什么:
assign reset = foo;
这是一个合法的同步复位,尽管它大概没有任何实际用处。但这个定义很好地展示了多周期路径例外的问题:当 @en 为高时,@foo 会在下一个时钟周期变为高。可那同时也让 @reset 变为高。于是,再下一个时钟周期,@foo 又变回低。也就是说,@foo 在每个时钟周期都会改变。这样一来,从 @foo 到 @foo 的多周期路径,会让这条路径上的时序要求不够严格。
解决办法很简单:让 @en 也同时控制同步复位。例如:
always @(posedge clk)
if (en && reset)
begin
foo <= 0;
bar <= 0;
end
else if (en)
begin
foo <= !foo;
bar <= foo;
end
这样做是正确的,但 @reset 必须与 @en 同时有效。一个简单的办法是让 @reset 保持几个时钟周期的高电平。
同样的原则也适用于异步复位(asynchronous reset)。关于异步复位的页面中写到的内容在这里同样适用,但在多周期路径约束下会更复杂。最简单的办法,大概是像另一页所建议的那样,使用一个同步器。
@en 与异步复位
@pre_en 的一个好处,是它能保证 @en 的所有副本在任意时刻都具有相同的逻辑电平。这听起来是理所应当的,但如果异步复位使用不当,这一点就不能得到保证。例如,假设原来的 Verilog 是这样写的:
reg en;
always @(posedge clk or posedge reset)
if (reset)
en <= 0;
else
en <= !en; // This is not recommended!
如果 @en 被复制,结果可能等价于下面这段代码:
reg en, en_1, en_2;
always @(posedge clk or posedge reset)
if (reset)
begin
en <= 0;
en_1 <= 0;
en_2 <= 0;
end
else
begin
en <= !en;
en_1 <= !en_1;
en_2 <= !en_2;
end
注意,@en_1 的下一个值只取决于它自己当前的值,与 @en 无关。这是寄存器复制后很常见的一种实际结果。
如果异步复位以一种不安全的方式撤销,会发生什么?可能 @en 的一些副本会响应复位后的第一个时钟边沿,而另一些副本则忽略这个时钟边沿。结果就是,这些副本的逻辑状态直到下一次复位为止,再也无法保持一致。
@pre_en 解决了这个问题,因为所有副本都是从同一个来源复制自己的下一个值。即使开局不顺利,长期来看也能保证正常工作。
小结
使用时钟使能很容易。使用 set_multicycle_path 这条命令来写时序例外也很容易。但要想让它可靠地工作,就完全不是一件容易的事了。有太多地方可能出错,而且有时原因在于,随着逻辑设计不断变大,综合器的行为也会发生变化。
这些意外问题的结果,可能是多周期路径没有发挥应有的作用:如果放宽后的时序要求没有应用到某些路径上,这种方法的收益就值得怀疑。更糟的是,如果多周期例外被错误地应用到了不该影响的路径上,设计可能变得不可靠。
因此,必须阅读相关路径的时序报告,确保没有发生意外。可惜,这并不能防止将来再出问题:随着设计演变,综合器的行为很难预测。
所以,如果条件允许,更好的做法是让同一个 PLL 再产生一个时钟,而不是使用时钟使能。使用新时钟的时钟域交叉是可靠的,因为两个时钟属于相关时钟。工具会自动为新时钟生成时序约束。这样做不会有任何意外之险。
所以,如果你是因为想在自己的设计里加入时钟使能和多周期例外而读到这一页,希望本页能给你一些值得思考的东西。
到这里,关于 FPGA 内部路径时序约束的讨论就结束了。但 I/O 时序呢?这正是下一页开始讨论的内容。