本文是 FPGA 上关于复位的系列文章中的第二篇。在上一页解释了为什么异步复位并不是许多人想象的那样之后,本页将讨论 FPGA 复位与初始化的各种方案。
首先:什么是复位?
拜托,谁还不知道复位是什么呀!它就像电脑上的那个按钮,按一下,一切又重新开始。它是一个送进芯片里的信号,确保不管之前发生过什么,从今以后一切都恢复正常。也有人把复位称作“把系统带到一个已知状态的信号”。
对 FPGA 设计者来说,复位往往只是给每个模块额外加上的一个输入,然后在代码里套用一个反复用到的模板。我们只是理所当然地这么写,并不一定会去关心这个复位信号到底保证了什么、是否真的有必要,甚至能不能干脆省掉。
最常见的误解,是把注意力放在“复位信号会把系统带到已知状态”这一点上。这当然没错,但真正重要的是复位被撤销之后会发生什么。必须保证逻辑能够以可预测的方式开始工作;或者说,至少要可预测到足以保证它能正常工作。如果连撤销复位后的行为都无法确定,那复位也就没有意义了。
这个话题之所以特别麻烦,还因为其中带有运气成分:一般来说,复位信号被激活时系统的状态是未知且随机的,复位撤销的时机也同样随机。因此,复位信号处理不当,就可能造成极少数情况下才会出现的故障,而且这种故障表面看起来像是一个完全不同的问题。反过来,忽视这个问题也可能不会带来任何明显的后果,只是偶尔会冒出一些通常只能用“玄学”来解释的怪问题。
就像要正确处理时序约束(timing constraints)、妥善对待时钟和时钟域一样,正确对待 FPGA 的上电启动和复位,也是保证 FPGA 可靠工作所必需的。这些话题有一个共同点:忽视它们似乎也能凑合,而且确实有不少工程师这么做,但代价就是 FPGA 时不时表现得像中了邪一样。
仿真与硬件的区别
本页的重点是复位方案会对加载到 FPGA 后的设计产生什么影响。但显然,这些方案也会影响逻辑的仿真。
区分这两种情况很重要。仿真器会给所有寄存器分配 X(未知)作为初值,尤其是在行为仿真中。这些 X 值随后会传播到任何在逻辑函数中依赖 X 值的寄存器。因此,哪怕只有一个寄存器带有 X 值,也可能让整个设计布满 X,仿真也就失去了意义。
针对这个问题,一种常见的错误做法是给设计中所有寄存器都加上异步复位,好把 X 全部清掉。这通常是通过在仿真开始时短暂地激活复位来实现的。这样一来,所有寄存器都得到了已知值,一切看起来都很完美。遗憾的是,这种错误做法会掩盖上一页讨论过的问题,往往给人一种“干净启动”的错觉。
即使复位用得没错,为了不去追查仿真中 X 值的来源,就把所有寄存器一律复位,也仍然是一种偷懒的选择。这不仅浪费资源,还可能让时序约束更难满足:不必要的复位还可能掩盖 bug,因为一大片 X 值可能源于某一个寄存器对另一个寄存器无意中产生的依赖。因此,大量 X 值本身可以看作一个警告,说明设计里可能有问题。
与仿真相比,硬件对“不使用复位”的容忍度要高得多。但如果复位使用不当,或者在该用复位时完全不用,硬件的行为就可能相当出人意料。
总之,复位应当以硬件为出发点来考虑,而不是为了让仿真里烦人的 X 消失。正因为重点应该放在硬件上,所以关于仿真,我只需要说这几句话就够了。
复位策略
到底要不要复位寄存器,以及如何复位,需要逐个寄存器单独考虑。这样不仅避免了复位信号产生不必要的高扇出(fan-out),也提供了一个机会去思考:无论寄存器之前处于什么状态,复位之后逻辑是否都一定能正确地开始运行。
原则上,有四种选择:
- 接受寄存器的初值未知。
- 依赖 FPGA 同步元件的初值设置,不使用显式复位。
- 使用异步复位。例如:
always @(posedge clk or negedge resetn) if (!resetn) counter <= 0; else counter <= counter + 1; - 使用同步复位。例如:
always @(posedge clk) if (!resetn) counter <= 0; else counter <= counter + 1;
我的观点
下面会逐一讨论这些方案,所以我先直接说明我认为正确的做法,然后再展开细说:
- 把设计中的每个寄存器(以及其他带有复位输入的逻辑单元)逐个过一遍,看看前两种方案是否可行。换句话说,也就是这个复位能不能省掉。
- 如果确实需要复位,优先采用同步复位。
- 只有对那些明确要求异步复位的逻辑单元(尤其是复杂的 FPGA 原语(primitive)和 IP 核(IP core),例如收发器和总线控制器),才使用异步复位;即便如此,能采用同步复位就尽量采用。
- 状态机(state machine)一定要正确地复位。即使从行为描述上看状态机迟早会进入某个已知状态,也要确保有一个显式且安全的复位能把它带入已知状态。这主要是因为综合器可能出于优化而采用另一种方式来实现状态变量,尤其是 one-hot 编码。这种状态变量表示方式可能永远无法收敛到合法状态,除非显式复位状态机。
- 对于带有复位输入的 IP 核、设计模块和原语,如果文档要求复位,就一定要复位(而且无论哪种情况都建议复位)。它们很可能不复位也能工作,但这可能只是纯粹的运气。即使这个复位与你用不到的功能有关,即使复位看起来只是把某些你设计里并不需要的输出清零,规则也很简单:涉及复位,就要仔细照文档来,哪怕看不出有什么理由非得这么做不可。
这些建议与当今 FPGA 厂商的推荐基本一致。
我还要补充一条非常通用的原则:控制逻辑一定要显式复位,数据通路(data path)则让它们自己把最初的垃圾数据冲刷掉。
下面就来详细讨论每一种方案。
方案 1:初值未知
有些寄存器既不需要复位,也不需要初值。移位寄存器(shift register)以及类似的逻辑元件尤其如此。一般来说,数据通路很可能适合这一条。
请看下面这段代码:
reg [31:0] d0, d1, d2, d3, d4;
always @(posedge clk)
begin
d4 <= d3;
d3 <= d2;
d2 <= d1;
d1 <= d0;
d0 <= orig_data;
end
这显然是 5 个 32 位的延迟寄存器。在某些 FPGA(尤其是 Xilinx)上,如果只有最后一个值(@d4)被使用,而且这些寄存器都没有复位,综合器就会把它识别成移位寄存器。这样能显著减少逻辑资源消耗。
显然,连接到这些延迟寄存器输出的逻辑必须能够容忍最初出现的随机数据。一种典型的情况是,旁边有一个经过正确复位的寄存器或状态机,能够保证在未初始化值到达时将其忽略。例如,如果这些延迟线与流水线(pipeline)有关,流水线的控制逻辑自然会忽略无效数据。
更一般地说,如果一个寄存器带有伴随的标志位或状态来指示它的取值是否有效,那么不复位、不初始化它就往往是一个很容易作出的选择。再或者,代码里存在明显的“先赋值、后使用”的顺序。简而言之,只要容易判断寄存器的值在被赋予合法值之前都会被忽略,就可以不复位。
还有一种“初值未知”的情况是:初值可能取决于综合器。例如:
reg val;
always @(posedge clk)
val <= 1;
综合器可能决定让 @val 变成一根值为常量 1 的导线。另一种可能是,它给一个寄存器分配初值 0,然后在第一个时钟沿把它变为 1。实际会怎样取决于综合器。所以,即使 @val 的值除了第一个时钟周期外都是已知的,它的初值还是应该被视为未知。
方案 2:FPGA 的初值
FPGA 中基本同步元件(通常是触发器、移位寄存器和存储器)的初值,是在配置位流(bitstream)中给出的。这个特性最广为人知的用途,是给块 RAM(block RAM)赋初值并且从不写入,从而创建 ROM。
这个特性还有一个广为人知的方面:FPGA 从配置状态启动后,通常所有寄存器看起来都是 0。这是因为综合器一般会把 0 作为所有寄存器的初值。不过,除非显式指定初值,否则还是可能遇到一些意外。
某些同步元件(尤其是移位寄存器和专用 RAM 块)可以通过在 Verilog 或 VHDL 中描述其行为而被推断出来:当代码看起来像一条延迟线时,综合器通常会创建移位寄存器;同样,一个数组也会促使综合器生成 RAM 逻辑单元。然而,如果对这些寄存器使用了复位(不管是同步复位还是异步复位),综合器就无法再用这种方式利用逻辑资源了,因为移位寄存器和 RAM 都没有一个能设置内部存储内容的复位输入。
那么初值是如何设置的呢?在配置过程中,所有同步元件都会在 FPGA 即将进入工作状态之前(也就是同步元件开始响应时钟和异步复位输入之前)被赋予初值。对 Xilinx 器件而言,这是通过全局置位/复位(Global Set Reset,GSR)信号实现的,它会把全部同步元件置于初态。之后,全局写使能(Global Write Enable,GWE)被激活,同步元件开始正常工作。
由于配置过程的进行与 FPGA 应用逻辑所用的任何时钟都无关,FPGA 进入工作状态的这个过程,相对任何这些时钟来说都是异步的。因此,同步元件的表现恰好就相当于“一个与任何时钟都无关的异步复位被撤销”。换句话说,进入工作状态之后的第一个时钟沿到来时,有些同步元件会响应,另一些则可能因为时序违例而不响应。这可能导致很讨厌的 bug,正如本系列第一页所讨论的。
还要注意,FPGA 启动时,时钟不一定已经稳定。如果时钟是由 FPGA 自己的 PLL 生成的,那么它们可能严重违反时序约束。如上所述,只要在时钟稳定之前不去理会它(例如通过时钟使能),或者能够确认 FPGA 启动时时钟已经稳定,这就不是问题。另外,如果设计能保证在时钟稳定之前没有任何同步元件需要改变取值,那也没问题。否则,设置初值并不能保证太多东西。
尽管设置初值有一些局限性,但在许多场景下它已经够用了,并不需要显式复位。而且有些时候根本没有选择,因为找不到可用的复位信号。例如,FPGA 刚启动后用来产生复位信号的逻辑就属于这种情况。这样的逻辑示例见本系列第三页。
对大多数综合器来说,给寄存器设置初值相当简单:使用 Verilog 的 initial:
reg [15:0] counter;
initial counter = 1000;
你可能会惊讶:initial 竟然可以用在可综合的 Verilog 代码里。但实际上,这种用法被广泛支持。所以,如果综合器支持这种用法(换句话说,文档中明确提到了 initial 的这种用法),那它绝对是首选方法。此外,替代方法往往与厂商相关,有时甚至只适用于某个 FPGA 系列。因此,即使 initial 并不总是可移植的,它很可能仍然是最可移植的选择。
另一种设置初值的方法取决于所用的 FPGA。这种方法通常是把同步元件作为原语进行实例化(instantiation),并把初值设成实例化参数。例如 Xilinx 的一个触发器:
FDCE myflipflop (
.C(clk),
.D(in),
.Q(out),
.CLR(1'b0),
.CE(1'b1)
);
defparam myflipflop.INIT = 1;
用 initial 是不是更好?
方案 3:异步复位
如果你还没读过那篇解释异步复位为什么常常被用错的文章,我建议你先去读一下。当然,如果你本来就没打算用这种复位,那就算了。
不知什么原因,很多人把异步复位当成适合所有场合的正确方案。也许是因为它经常出现在代码示例里,也许是因为它给人一种“用简单办法就能做到全局复位、触及所有同步元件”的错觉。也可能是因为在早年 ASIC 的世界里,异步复位对制造流程中的芯片测试很有用:它能复位整个芯片,然后开始施加测试向量。
那么现实如何?正确而干净地使用异步复位,应该是在时钟关闭的情况下进行的。这才是“异步”二字真正的含义。在实际设计中,这个过程包含以下几个阶段:
- 关闭所有时钟(负责控制这一过程的逻辑所用的时钟除外)。通常是通过关闭全局时钟缓冲器的时钟使能(clock enable)输入来实现的,也就是时钟门控(clock gating)。
- 把异步复位信号激活,然后再撤销。确保脉冲足够长,能复位所有同步元件。
- 等待足够长的时间,确保所有同步元件都已准备好接收时钟。
- 重新激活时钟。
实现这个流程并不难,但要确保每个时钟的第一个边沿都能正确地产生、不出现毛刺,可能就更难了:时钟缓冲器有一个常见问题,就是它对“输出使能打开的时刻”与“通过时钟缓冲器的第一个时钟边沿”之间的时序有要求。如果违反了这个要求,时钟缓冲器就可能输出毛刺(glitch),也就是一个不满足 FPGA 的时钟要求的短脉冲。这会让所有依赖该时钟的同步元件产生不可预测的行为。
遗憾的是,FPGA 厂商提供的文档并不总能说明如何满足这个时序要求。因此,也许根本无法保证复位后的第一个时钟边沿能正确工作。而如果第一个时钟边沿得不到保证,复位就没有意义。
如果采用这种方法,请确保没有对与异步复位相关的路径(path)施加时序约束。这种约束在这类场景下没有必要,但它可能是默认开启的。
还有一种被广泛推荐的替代方法,可以可靠地施加异步复位。这种方法不涉及时钟门控,因此也不依赖时钟缓冲器。其思路是额外加入几个触发器,让异步复位信号的激活直接穿过这些触发器,但这些触发器会以同步方式撤销复位。换句话说,这是一种经过同步的异步复位。
例如,如果原始的异步复位是 @external_resetn,下面这段代码产生的就是这种复位:
reg pre_rstn1, pre_rstn2;
reg resetn;
always @(posedge clk or negedge external_resetn)
if (!external_resetn)
begin
resetn <= 0;
pre_rstn2 <= 0;
pre_rstn1 <= 0;
end
else
begin
resetn <= pre_rstn2;
pre_rstn2 <= pre_rstn1;
pre_rstn1 <= 1;
end
请注意,@clk 是那些由 @resetn 复位的同步元件所使用的时钟。
当 @external_resetn 有效(也就是为低)时,这三个寄存器都会异步地变为有效,也就是变为 0。但当 @external_resetn 撤销之后,只有 @pre_rstn1 会在下一个时钟沿变为无效,然后这个变化再在后续的时钟沿传播到 @pre_rstn2 和 @resetn。
额外增加两个寄存器的目的是防止亚稳态(metastability),让 @resetn 能以安全的方式撤销。如果 @external_resetn 相对 @clk 而言在一个不好的时机撤销,导致 @pre_rstn1 进入亚稳态,这个保护就有必要了(这个页面解释了亚稳态)。
这个同步器的好处在于,@external_resetn 可以被当作真正的异步复位来用:即使没有任何时钟处于活动状态,它也能工作。不过,同步元件收到的复位信号是按同步方式撤销的,因此时序可以得到保证。
不用说,每个时钟都需要一个属于自己的、经过同步的异步复位。
还有一点很重要:只像上面那样生成 @resetn 是不够的。必须让 @clk 的时序约束覆盖从 @resetn 到各同步元件的路径。某些 FPGA 工具的默认行为是忽略终点为同步元件异步复位输入的路径时序,为此可能需要在工具设置里作一些修改。
所以,如果 @resetn 被当作普通的异步复位来用,例如:
always @(posedge clk or negedge resetn)
if (!resetn) // Are you sure this path is timed?
the_register <= 0;
else
[ ... ]
那么上面给出的同步器还不足以保证能可靠地脱离复位。你有责任确认:从 @resetn 出发、终点为触发器异步复位输入的路径,确实都得到了时序约束。
还有一点同样很重要:这个同步器对 @external_resetn 上的毛刺无能为力。如果 @external_resetn 的有效脉冲长度比 FPGA 触发器规范要求还短,那什么都有可能发生。因此,@external_resetn 必须由某个逻辑或外部电路产生,以保证足够长的脉冲。如果做不到这一点,唯一的办法就是把复位完全同步化,例如下面这段生成 @sync_resetn 的代码:
reg pre_rstn1, pre_rstn2;
reg sync_resetn;
always @(posedge clk)
if (!external_resetn)
begin
sync_resetn <= 0;
pre_rstn2 <= 0;
pre_rstn1 <= 0;
end
else
begin
sync_resetn <= pre_rstn2;
pre_rstn2 <= pre_rstn1;
pre_rstn1 <= 1;
end
但这个同步器在 @clk 不活动时会忽略 @external_resetn。如果逻辑要求把 @external_resetn 当作异步复位来对待,也就是说,即使时钟没有活动,它也应当起作用,那就成问题了。
再回到第一个同步器。如果把 @resetn 当作普通的同步复位来用呢?就像这样:
always @(posedge clk) // @resetn not in sensitivity list!
if (!resetn)
the_register <= 0;
else
[ ... ]
这大致上没问题,因为借助时序约束,@resetn 的撤销时机肯定是被覆盖的。然而 @resetn 的异步激活没有受到约束,所以相关同步元件在复位真正生效之前的一小段时间里,可能会表现出随机行为。因此,最好还是使用完全同步的复位,例如上面定义的 @sync_resetn。
最后,关于这个话题我再补一句总结性的话:在前面的例子里,我选择了低有效复位(active-low reset),这主要是出于传统——它源自早年用一颗电容产生复位信号的做法:电容通过一个电阻接到电源电压。由于电容初始没有电压,复位输入就是 '0';这颗电容很快就充上了足够电荷,于是复位输入变成 '1'。这种古老的上电复位方式,正是直到今天许多复位信号仍然是低有效的原因。
总之,异步复位是可以可靠使用的,但要做到这一点,绝对不像很多人想的那么简单。我介绍了两种确保异步复位可靠的方法:要么暂时关闭时钟,以免出现时序问题;要么使用同步器来保证时序。就像 FPGA 领域常见的那样,时序才是关键。
在现实世界中,大多数依赖异步复位的设计都没有采用以上任何一种方法。结果,FPGA 设计的可靠性就完全取决于运气了。
方案 4:同步复位
同步复位最经典的代码模板是:
always @(posedge clk)
if (reset)
the_register <= 0;
else
[ ... ]
我稍后会给出一个我认为更好的代码模板,不过这里先基于这个继续讨论。请注意,我在这里选择的是高有效复位(active-high reset),因为这是同步复位里更常见的写法——至少我的印象是这样。
同步复位几乎在所有方面都比异步复位更好,只有以下几点例外:
- 同步复位信号可能会有很大的扇出,而且它的所有路径都必须满足时序约束。因此,这种复位可能让达成时序约束变得更难。
- 当时钟不活动时,同步复位无法使用。
- 某些 FPGA 有专门用于全局布线的资源,这些资源可能只能配合异步复位使用(我不太确定是否真有这种情况,但因为我在关于 Intel FPGA 的资料里看到过这种说法,所以还是加上了这条备注)。
FPGA 很少会在没有活动时钟的情况下工作(不像 ASIC 那样,传统上测试时确实需要让时钟停住),而且“用于布线的专用资源”这个问题究竟是否存在也还不清楚,所以我就把重点放在主要问题上:扇出。幸运的是,这个问题很容易解决。
还值得一提的是,如果采用上面建议的那个同步器,异步复位也会受到同样的扇出问题影响。所以,真正有资格说“扇出是同步复位缺点”的人,只有那些在关闭时钟(也就是门控时钟)的前提下使用异步复位的人。
遇到扇出问题,人们最先想到的办法往往是给综合器加一个约束或属性来限制扇出。但这不是最好的做法,因为综合器在达到扇出上限时只会复制触发器。于是常常出现这样的结果:被复制出来的几个触发器,输出分别送往用途完全不同的模块,而且这些输出在 FPGA 上的目的地可能散布得到处都是。这就会造成绕线很长,并带来明显的传播延迟(propagation delay)。
一种简单而高效的解决方案,是为逻辑中每个比较大的组成部分各生成一个本地复位。例如:
module medium_sized_module (
input clk,
input reset,
input [15:0] in_data,
output [15:0] out_data
);
(* dont_touch = "true" *) reg local_reset;
reg the_register;
always @(posedge clk)
local_reset <= reset;
always @(posedge clk)
if (local_reset)
the_register <= 0;
else
[ ... ]
其思路是让 @local_reset 成为 @reset 的本地副本,只是延迟一个时钟。在本模块以及它下游的层次中使用 @local_reset,而不是直接用 @reset,可以把扇出控制在合理水平。由于使用这个本地复位的逻辑彼此之间本来就连接得很紧密,它们多半会被放在 FPGA 上某个特定区域内,因此本地复位信号不需要在逻辑结构上跨越很长的距离。
还要注意,要防止综合器为了优化逻辑而删掉这些本地复位寄存器。当存在行为完全相同的寄存器时,综合器通常会这么做,即使它们属于不同模块。在上面的例子中,给出的是 Vivado 的综合属性,也就是 dont_touch。每个综合器都有自己的实现方式(在 Quartus 中,使用综合属性 dont_merge 可以达到同样效果)。
为了确认综合器确实保留了所有这些寄存器,一个好办法是给这些寄存器都取同样的名字(例如上面建议的 local_reset),然后在实现后的设计里搜索这个名字。
当然,没有必要为每一个模块都生成一个本地复位。粗略地说,扇出在 50 到 100 左右对本地复位来说是比较合理的,尤其是当它到达的是 FPGA 上一个很小物理区域内的逻辑单元时。
关于降低扇出的话题,在时序收敛(timing closure)的讨论中也有涉及。
关于同步复位的更多讨论
关于同步复位,有一个常见的误解:只要综合器遇到与同步复位模板吻合的 Verilog 代码,它就会把复位信号接到触发器的同步复位输入上。有些事情确实会这样,但通常不会。
这跟异步复位不一样。异步复位必须接到触发器的异步复位输入上,否则在没有时钟时复位不会起作用。
综合器往往不会对代码模板赋予什么特殊含义,而是去计算从 Verilog 代码推导出的逻辑方程。请看这个例子:
always @(posedge clk)
if (reset)
the_register <= 0;
else if (some_condition)
the_register <= !the_register;
else if (some_other_condition)
the_register <= 0;
一种理解这段代码的方式是:always 语句以标准的同步复位模板开头,后面才是对寄存器行为的具体定义。因此,你可能会以为 @reset 会被接到相关触发器的同步复位输入上,而某个逻辑函数(用 LUT 实现)的输出会接到触发器的数据输入。
实际上,综合器通常会尽可能简洁地实现“@the_register 在下一个时钟的值”。例如,触发器的复位输入可能被接到一个逻辑函数(也就是 LUT)上,而这个函数实现的是表达式 (reset || (some_other_condition && !some_condition) )。
但还有一种更有意思的可能:触发器的复位输入可能根本就不用。只使用数据输入,而逻辑函数把 @reset 信号当作它的一个输入。这样一来,如果 @reset 为高,逻辑函数的输出就是 0。这种方式确实能把 @the_register 带到 0,但 @reset 在这里并没有被特殊对待,和任何其他信号都一样。
所以再强调一次:即使触发器有同步复位输入,综合器也往往不会特别看待同步复位的代码模板,更不会把复位信号与任何其他信号区别对待。触发器复位输入究竟怎么用,取决于怎样最能实现 Verilog 代码要求的行为。它有时会把复位输入直接连到复位信号,有时会连到某个可能包含复位信号的逻辑函数,有时则完全不用复位输入。综合器只会去做能帮助它更好地达到性能目标的事,仅此而已。
使用 Xilinx Vivado 的用户,可以通过两个综合属性更好地控制这个问题,它们分别是 DIRECT_RESET 和 EXTRACT_RESET。
最后,我再给异步复位补一个缺点:在大多数 FPGA 中,触发器只有一个复位/置位输入。这个输入既可以被配置成同步行为,也可以被配置成异步行为。如果复位是同步的,综合器也许能找到一些技巧,利用这个输入来减少 LUT 用量,以满足所要求的行为。但当存在异步复位时,这种捷径就不可能了。所以,异步复位等于绑住了综合器的手脚,迫使它浪费更多的逻辑资源。
避免寄存器意外被“冻结”
常见的复位代码模板里有一个陷阱,如下面的代码所示:
always @(posedge clk or negedge resetn)
if (!resetn)
begin
reg1 <= 0;
reg2 <= 0;
// Ayeee! Forgot to reset reg3 !
end
else
begin
reg1 <= [ ... ];
reg2 <= [ ... ];
reg3 <= [ ... ];
end
正如注释所暗示的,@reg3 并没有出现在 @resetn 有效时的 begin-end 块里。结果,上面的 Verilog 代码就要求:只要 @resetn 有效,@reg3 就必须保持不变。这相当于用下面的代码来定义 @reg3:
always @(posedge clk)
if (resetn)
reg3 <= [ ... ];
换句话说,@resetn 对 @reg3 起到了时钟使能的作用:只有 @resetn 为高时,时钟才真正有效。
同步复位也会发生完全相同的事情:
always @(posedge clk)
if (reset)
begin
reg1 <= 0;
reg2 <= 0;
// Ayeee! Forgot to reset reg3 !
end
else
begin
reg1 <= [ ... ];
reg2 <= [ ... ];
reg3 <= [ ... ];
end
这种情况其实更好理解,因为它就是一对 begin-end 块,第二个块只在 @reset 无效时才起作用。因此,在这个例子里,@reg3 的定义实际上是这样的:
always @(posedge clk)
if (!reset)
reg3 <= [ ... ];
于是显而易见的结论(虽然不一定称得上聪明)是:不要在复位分支的 begin-end 块里漏掉任何寄存器。事实上,很多 FPGA 设计者会把所有寄存器都复位一遍,不管需不需要,因为他们认为这是唯一的写法。另一些人则采用一种编码风格,让每个寄存器都拥有自己独立的 always 语句。
但如果你是有意只想复位其中一部分寄存器呢?
对于异步复位,唯一的选择是给这些寄存器单独写一个 always 语句。但对于同步复位,有一种简单的方法可以解决:
always @(posedge clk)
begin
reg1 <= [ ... ];
reg2 <= [ ... ];
reg3 <= [ ... ];
if (reset)
begin
reg1 <= 0;
reg2 <= 0;
// I don't want to reset reg3, and that's fine!
end
end
不要在前面写一个 if (reset),然后把主要逻辑放在 else 分支里;而是把 if (reset) 放到最后,让复位里的赋值去覆盖之前所有的赋值。
请注意,这样做并不等同于上面任何一个例子:当 @reset 有效时,@reg1 和 @reg2 会被复位,但 @reg3 完全不受复位影响。
如果你对这种另类的同步复位写法感到不踏实,我能理解,原因有几个:首先,在 FPGA 设计中遵循常用的代码风格通常是比较好的做法。否则,综合器可能会暴露出某种奇怪的 bug,而在成熟代码模式下这种可能性要小得多(参见黄金规则第 4 条)。所以,即使 Verilog 标准明确要求这种写法必须有效,你仍然可以认为依赖这个特性未必是好主意。
这是一个很有力的论点,不过我可以以亲身经验告诉你:十多年来,我一直在大量不同的综合器上广泛使用这种代码风格。尤其可以说,我自己代码里的同步复位就是用这种方式定义的。我从来没有因此遇到过任何问题。
不喜欢这种方法的另一个可能理由,是担心综合器会不会因为这不是常规代码模板,就领会不到“这里想要同步复位”的意图。但正如前面已经提到的,大多数综合器本来就不会去领会什么模板暗示,它们只是把同步复位看作对所需逻辑行为的又一种定义。所以这个担心没有根据。
所以,如果你愿意相信我的经验,知道这样写是安全的,那你就能省掉不少麻烦。
那同样的手法能不能用在异步复位上呢?例如,下面这段代码会怎样?
always @(posedge clk or negedge resetn)
begin
reg1 <= [ ... ];
reg2 <= [ ... ];
if (!resetn)
reg1 <= 0;
end
这当然偏离了异步复位的常见代码模板。我在 Vivado 综合器上做过一次非正式实验,结果显示综合器领会了意图,给 @reg1 分配了异步复位。
但代码对 @reg2 所要求的行为在 FPGA 上无法实现:如上面所写,这相当于让 @clk 和 @resetn 都成为时钟,并让 @reg2 分别在 @clk 的上升沿和 @resetn 的下降沿去采样(sampling)新值。在我所知的任何 FPGA 中,都没有带两个时钟输入的触发器,因此 @reg2 的定义根本不可能被综合出来。
Vivado 的综合器对这种情况的反应是:忽略其中的 negedge resetn 部分,只创建一个以 @clk 为时钟的触发器。综合结果里找不到任何这种奇怪处理的痕迹,综合器也没有发出任何警告或抱怨——尽管它生成的逻辑并不符合这段 Verilog 代码要求的行为。
因此,至少在 Vivado 综合器上,这种代码风格实际上连异步复位也能凑效,但这不应该成为可以依赖的理由:Verilog 代码就应该表达你希望逻辑做什么。否则,综合器完全有权利按它自己喜欢的方式去误解你的意图。