本页是 FPGA 上关于复位的系列文章三篇中的第一篇。由于许多人都在使用异步复位(asynchronous reset),却不知道它并不像自己预期的那样真正可靠,因此本页将解释为什么这个话题并不是看上去那么简单。
你的复位真的有效吗?
看看下面这个对状态机(state machine)进行复位的例子,它是错误的:
always @(posedge clk or negedge resetn)
if (!resetn)
state <= ST_START;
else
case (state)
ST_START:
begin
state <= ST_NEXT;
[ ... do some stuff maybe? ... ]
end
ST_NEXT:
begin
[ ... do something ... ]
end
endcase
你可能会问,这里的复位到底有什么问题?这跟教科书上讲的一模一样啊!一个低有效(active low)的异步复位,把实现 @state 的寄存器带回初始状态。这能出什么错呢?
为了便于讨论,我们假设 @resetn 信号本身没有问题。也就是说,它没有直接连到按键或类似的东西上。它可能来自一颗专门产生复位信号的芯片,也可能由 FPGA 内部生成。无论属于哪种情况,我们都假设复位能稳定地变为有效,保持足够长的时间,然后再变为无效。但即便如此,这段代码仍然是错的。
而我说的“错”,是指那种会让 FPGA 时不时无缘无故出现怪异行为的错。
那么问题到底在哪呢?因为复位有效的时间足够长,所以它确实能把 @state 带入初始状态。但当复位变为无效(也就是在上面的例子中回到 '1')时,会发生什么?此时,相关触发器(flip-flop)应当开始响应 @clk 的上升沿。
然而,触发器需要花一点点时间才能从复位信号中恢复过来,并开始在时钟上升沿对数据输入进行采样(sampling)。而且,由于复位按定义本来就是异步的,它可以相对 @clk 在任意时刻被撤销。
如果复位撤销后 @clk 的第一个上升沿来得太早,触发器就会忽略这个上升沿。假如连接到 @clk 的所有触发器都这样做,那倒没什么问题。但并不是所有触发器都完全一样:有些会比其他的稍微早一点收到时钟沿,有些则会比其他更晚收到复位的撤销信号。
说实话,这种解释有些过于简化。要想更准确地看待这个问题,请参阅讲解时序基础的页面中的恢复(recovery)与移除(removal)部分。
所以归根结底是这样的:运气不好时,复位撤销的时刻可能离时钟上升沿非常近,结果一部分触发器响应了复位后的第一个上升沿,另一部分却忽略了它。实际上,有些触发器可能还要多花一点时间来做出决定。你可以把这归咎于同一颗芯片上触发器之间的差异,也可以归咎于时钟偏斜(clock skew)和复位偏斜。核心问题是:有些触发器会比其他的领先一个时钟周期。
要理解后果有多严重,可以看看上面的例子。如果综合器(synthesizer)识别出这是一个状态机,它很有可能会用 one-hot 编码来实现状态变量。也就是说,它为每一个状态分配一个单比特寄存器。当状态机处于某一状态时,对应的那个寄存器有效。假设综合器给名为 ST_START 的状态分配了一个叫 hot_state_0 的寄存器,给 ST_NEXT 分配了 hot_state_1。显然,复位让 hot_state_0 有效,同时让 hot_state_1 无效。
请注意,这个状态机会无条件地从 ST_START 变到 ST_NEXT。因此,复位释放后的第一个时钟,hot_state_0 变为无效,而 hot_state_1 变为有效。
但如果复位撤销的时机不巧,导致一些触发器错过了第一个时钟沿,另一些却没有呢?一种可能的情况是,hot_state_0 错过了第一个时钟,hot_state_1 却响应了它。结果两个都变有效,这在 one-hot 编码下是非法的。反过来,如果两个寄存器都变成无效,那么状态机的所有 one-hot 寄存器实际上都无效。无论哪种情况,状态机都可能永远无法恢复到合法状态。
这在实践中会是什么表现呢?当然要取决于应用,但很可能是某个功能会一直不正常,直到你再次对 FPGA 施加复位。要找到这种行为的原因可能极其困难,因为这个问题是随机出现的,而且不一定很频繁。它还很可能因 FPGA 设计的编译不同而表现不同,甚至不同板卡上表现也不同。简而言之,这是那种能让人抓狂的 bug。在这个讨论里也许听起来没那么严重,因为问题的根源正是这个讨论的主题。但当这种不稳定在真实世界里出现时,它可以表现为任何现象,而且往往让人感觉 FPGA 中了邪。
但我一直这么写,也没出问题啊!
确实。在绝大多数情况下,就算某些触发器错过了复位后的第一个时钟,也没多大影响。
上面那个状态机例子之所以会失效,关键原因在于它在第一个时钟周期就要离开初始状态。而实际设计中的大多数状态机,在离开初始状态之前需要满足一定条件,所以它们通常会在初始状态停留几个时钟周期。这样,人们犯了这种错误也能侥幸过关。
不过再看另一个例子。一个简单的计数器:
reg [15:0] counter;
always @(posedge clk or negedge resetn)
if (!resetn)
counter <= 0;
else
counter <= counter + 1;
这里 @counter 由 16 个触发器组成。每个触发器的数据输入端收到的都是计数器在下一个时钟周期的值,同时它的异步复位输入端收到 @resetn。
当 @resetn 有效时,@counter 被置为 0,而计数器在下一个时钟周期的值将是 1。因此,除了 counter[0] 以外,所有触发器无论是否错过第一个时钟沿,都会保持为 0。所以无论哪种情况,它都能正确开始计数。在绝大多数这样写的代码里,计数器是否错过第一个时钟周期其实无关紧要。
但下面这个就是另一回事了:
reg [15:0] counter;
always @(posedge clk or negedge resetn)
if (!resetn)
counter <= 0;
else
counter <= counter - 1;
差别并不大,却很重要:如果计数器从 0 开始向下计数,那它在下一个时钟周期的值就是 0xffff。换句话说,所有触发器都必须在复位后的第一个时钟沿改变自己的值。因此,如果有些触发器响应了复位后的第一个时钟沿,另一些却没有,计数器就可能几乎从任意随机值开始。
可谁会把一个计数器复位成 0,然后让它倒数呢?
那就看一个更现实的例子:用一个时钟使能(clock enable)信号,让逻辑表现得像是时钟频率被减半了一样(因此,如果需要,也就可以形成多周期路径(multi-cycle path)):
reg en;
always @(posedge clk or negedge resetn)
if (!resetn)
en <= 0;
else
en <= !en;
always @(posedge clk)
if (en)
[ ... do something ... ]
也许有人会觉得这里不可能出错:时钟使能 @en 只是一个寄存器,它从什么时候开始翻转似乎没什么关系,难道不是吗?问题在于,时钟使能信号往往有很高的扇出(fan-out),所以综合器可能会复制它,以避免超出扇出限制。
我在 Vivado 综合器上做过一次个人实验,结果显示,每个被复制出来实现 @en 的寄存器都依赖自己的输出信号。换句话说,并不存在一个所有触发器都用来决定下一拍输出的统一信号。相反,实际存在的是许多彼此独立的触发器,它们总是在时钟上升沿改变自己的输出。因此,如果这些触发器不是从同一个时钟周期开始翻转,它们的输出就会永远保持不一致。
一旦发生这种意外,逻辑很有可能会彻底失灵。所以如果你坚持要用异步复位,至少要让所有时钟使能都依赖同一个来源。例如可以像下面这样:
reg pre_en; // Apply some don't-touch synthesis directive on this
reg en;
always @(posedge clk or negedge resetn)
if (!resetn)
pre_en <= 0;
else
pre_en <= !pre_en;
always @(posedge clk or negedge resetn)
if (!resetn)
en <= 0;
else
en <= pre_en;
always @(posedge clk)
if (en)
[ ... do something ... ]
关键之处在于,@pre_en 才是决定下一拍值的那个寄存器。该触发器的扇出很低,而且最好带有某种属性,让综合器不要对它做手脚。这样一来,它必然就是唯一的一个寄存器。所有实现 @en 的触发器都依赖 @pre_en,所以它们在下一个时钟沿到来时,对 @en 的取值都会保持一致。至于复位后的第一个时钟,即使某些触发器错过了这个时钟也没关系,因为 @en 在第一个时钟周期的值本来就是 0。
所以归根结底,错误地使用异步复位通常也不会出问题,主要原因是逻辑一般能容忍“复位相对时钟在何时撤销”的这种不确定性。然而,粗心大意地使用异步复位仍然会导致偶发的不正常行为,而且这种问题极难解决。
在复位与时钟之间使用时序约束
要避免复位撤销与时钟上升沿之间的不确定时序关系,一个看似顺理成章的办法就是对复位信号使用时序约束(timing constraints)。但这样做的同时,复位信号就变成同步的了。
但它是与哪个时钟同步的呢?为了方便,人们常常希望整个逻辑设计只有一个全局异步复位。这个复位由与某个特定时钟同步的逻辑产生。如果把它用在与另一个时钟同步的逻辑中,就出现了跨时钟域(clock domain crossing)。这是一个独立的话题,但最重要的一点是:工具可能会忽略相关路径(path)上的时序。
因此,要对异步复位使用时序约束,前提是每个时钟都要有各自独立的异步复位;或者,如果你坚持,至少每组相关时钟(related clocks)都要有自己独立的异步复位。否则,时序约束就没有意义。如果这听起来有些奇怪,那是因为这时候的“异步复位”其实已经不再异步了。
Verilog 代码中采用异步复位模板这件事本身并不能改变什么。同样,不管用的是触发器的异步复位输入,还是把触发器配置成把复位输入当作异步,也都无所谓:只要复位是与某个时钟同步的,并且又使用了时序约束,那么复位实际上就是同步的。既然如此,你也许可以考虑直接在 Verilog 中使用同步复位(synchronous reset)的模板:
always @(posedge clk)
if (!resetn)
state <= ST_START;
[ ... ]
话虽如此,Intel 关于时序收敛(timing closure)的 YouTube 视频却提倡使用与时钟同步的异步复位,并且也应使用时序约束。选择异步复位是为了利用 FPGA 中用于全局布线的专用资源。我觉得这很奇怪,因为即使是全局布线也可能有相当大的延迟,尤其是在大型 FPGA 上。不过肯定有些场景下这样做是有意义的。
实际上,即使复位在效果上已经是同步的了,仍有一个理由继续采用异步复位,这个理由会在下一页讨论:它允许以异步方式传播复位信号的有效状态,这在仿真以及 ASIC 测试中都很用。
如果采用这种经过同步处理的异步复位方法,就必须确保时序约束覆盖所有通向触发器异步复位输入的信号路径(path)。仅仅因为产生复位的触发器与目标触发器由同一个时钟驱动,并不能自动保证它们之间的路径得到时序约束。默认情况下,一些时序工具会忽略任何终止于异步输入的路径,因此这种约束可能需要显式地开通。务必在时序报告中确认这些路径确实被时序约束覆盖了。这一点上很容易栽跟头。