本页是 FPGA 上关于复位的系列文章中的第三篇,也是最后一篇。建议先读前两篇,再来看这一篇。
概述
花时间设计出合适的复位产生逻辑,总是值得的。即使设计目前看起来没什么问题,跳过这一步也很可能要付出代价:日后你可能会花好几天去排查某个不稳定问题,却意识不到根源在于逻辑没有正确初始化。随着时间推移,为了解决这类问题而采取的种种丑陋补丁,往往是在没有理解问题根源的情况下拼凑出来的。关于这种诡异的稳定性问题,可以看这个页面。
从项目一开始就认真思考和设计复位信号,对于保证项目后续能健康地扩展也很重要。当一个 FPGA 项目从零开始时,通常先实现核心功能,然后再逐步加入新特性。由于不同模块往往需要各自独立的时钟和复位,人们很容易落入一种陷阱:为每个部分分别草草地凑合出一套方案。这样一来,项目往往会随着进展变得越来越混乱。
避免项目变得一团糟的一个好办法,是有一个从第一天起就认真写好的中心模块,专门负责复位和时钟。而且正如后面会进一步解释的,复位控制器与时钟资源(必要时包括 PLL 和时钟缓冲器)会互相影响,所以把它们放在同一个模块中是划算的。我通常把这个模块命名为 clkrst.v。
不过,想把这一切都集中到一个模块里往往并不可行,因为某些 IP 核(IP core)、子系统或设计模块可能会自行产生一组复位和时钟。在这种情况下,就必须仔细想清楚谁依赖谁,以及整个系统应当如何响应来自不同来源的复位请求。例如,PCIe 模块的复位信号几乎总是来自总线本身,而该模块又会产出一个复位信号,供与它相连的逻辑使用。在这种情况下,就需要考虑例如:当 PCIe 总线上来了一个复位时,整个系统应当如何响应(包括可以完全不响应的选项)。
每个项目都有各自的情况,因此没有一个放之四海而皆准的方案。本页描述一些概念,并提出一些思路和代码片段,可以作为你自己复位控制器的积木。不过,这些代码片段并不是让你直接复制粘贴到项目里的,而应当当作演示来看待。
为了简单起见,我会假设系统中没有其他模块产生时钟或复位;不过,把这里的控制器扩展到更一般的情况是相当直接的。
复位状态机
在大多数设计中,有两种主要场景需要复位:
- FPGA 上电并完成位流(bitstream)配置之后,以保证一个干净的启动。
- 当外部明确请求复位时,例如按下按键,或者收到来自处理器或计算机的命令。
此外,当看门狗定时器超时,或通过其他手段检测到重大系统故障时,也可能需要复位。
对所有这些场景的预期响应,都是一种根本性的重新启动,足以保证无论出了什么问题都能被纠正。通常最好用某种状态机(state machine)来完成,这样才能保证无论复位是由什么原因触发的,复位序列都是一致且可重复的。
话虽如此,有些场景下使用局部的、甚至周期性发生的复位也是合理的。例如,处理视频图像帧的逻辑可以在每一帧开始之前复位。这种需求通常用一个轻量级机制就够了,说白了就是在本地同步复位生效时给若干寄存器赋初值。这种复位机制和别的复位机制没有什么两样,而且往往是保证可靠而稳定运行的干净、简单的方法。
不过关于这种可能性没有太多可以补充的,所以本页其余部分将聚焦于覆盖整个 FPGA 的根本性复位。
时钟不稳定与复位的必要性
使用 FPGA 自己的 PLL 来产生时钟,这是很常见的做法,而且通常也是被推荐的做法。但这些 PLL 通常是在 FPGA 上所有其他逻辑开始活动的同时开始工作。结果,依赖这些时钟的逻辑一开始吃到的可能是不稳定的时钟,其频率还可能显著高于正常值。
一旦出现这种情况,相关逻辑路径(path)的时序就得不到保证。因此,任何依赖由 PLL 产生的时钟的逻辑,都应当被看作“未能满足时序约束(timing constraints)”,直到该 PLL 锁定为止。
一个恰当而常见的解决方案是:在相关 PLL 锁定之前,让所有这些逻辑都保持在复位状态。另一种做法是,让这些逻辑在 PLL 锁定之前忽略时钟,例如确保触发器的时钟使能(clock enable)输入(CE)无效。
如果外部时钟从 FPGA 引脚直接连到内部逻辑元件,那么问题就在于:外部时钟源(通常是 PLL)是否比 FPGA 加载位流更快地完成了锁定。要确定这一点并不总是那么容易。
因此,在大多数设计中,很多同步元件都需要复位。更准确地说,关于“是否因为时钟不稳定而需要复位”这个问题,可以把这些元件分成三组:
- 那些可能含有随机垃圾数据、而且这并不构成问题的元件。
- 那些能够保证在时钟不稳定期间忽略该时钟的元件(例如通过时钟使能)。
- 那些必须复位的元件。
值得指出的是,有些 FPGA 允许对配置过程(也就是加载位流并初始化 FPGA 的过程)进行设置,使 FPGA 的唤醒被推迟到所有 PLL 都锁定之后。这也是一种解决该问题的办法。这种方案的缺点是:如果外部时钟出了问题,FPGA 将根本不会启动。出现这种情况时,可能会让人十分困惑。
一个简单的状态机
由于根本性的启动或重启是相当罕见的事件,所以就算多花几微秒也无所谓。在许多情况下,哪怕多花 100 ms 左右也完全可以接受,而这一点可以被利用起来。因此,用一个普通的计数器来实现一组事件序列,是一种简单的做法。
它最简单的形式大致如下:
reg [4:0] reset_count;
reg rst_src_pll, rst_src_shreg, rst_src_debounce;
reg clear_counter;
reg master_reset;
initial reset_count = 0;
initial master_reset = 1;
initial clear_counter = 1;
always @(posedge wakeup_clk)
begin
clear_counter <= rst_src_pll || rst_src_shreg || rst_src_debounce;
master_reset <= (reset_count != 31);
if (clear_counter)
reset_count <= 0;
else if (reset_count != 31)
reset_count <= reset_count + 1;
end
@rst_src_pll、@rst_src_shreg 和 @rst_src_debounce 分别代表需要复位系统的不同原因。这些寄存器由其他某些逻辑赋值。我稍后会举几个这样的逻辑例子,但现在重点是:这些寄存器都与 @wakeup_clk 同步(因此不需要做跨时钟域处理)。
@clear_counter 是这些复位原因的逻辑或。这个寄存器会把 @reset_count 清零。否则,在本例中,这个计数器会从 0 数到 31,计数结束后停下来。
最后,只要 @reset_count 还没有数完,@master_reset 就保持有效。这是复位状态机最简单的形式,而且数到 31 这个长度已经相当保守了。
因此,只要任何一个 @rst_src_N 信号有效,哪怕只有一个时钟周期,同步复位都会保持 31 个时钟周期。
长的复位脉冲有两个好处。第一,如果相关的 @rst_src_N 信号随机地通了又断、断了又通(例如 PLL 锁定指示抖动、按键抖动、软件多次请求复位等),这些多次触发不会传播到用户可见的同步复位上。这类多次触发通常是无害的,但它们可能导致输出引脚上出现不必要的动作。这种动作可能带来负面影响,例如让测试硬件的人误以为出了问题。
从这个意义上说,前面的 31 个时钟周期这个例子其实非常精简。如果延迟可以接受,就更好是数到一个对应 10—100 ms 的值。这样一来,任何比这段复位时间更短的抖动都被复位控制器吸收掉了。
长复位脉冲的第二个原因是:把原始同步复位分发到各个本地副本的过程(如前面所解释的)会有延迟一个时钟周期的影响。如果要把复位信号在整个逻辑中分发,需要多次复制该信号,那么总延迟不仅会变长,而且还可能变得不均匀。长复位脉冲能保证在某个时间点上,所有逻辑都确实处于有效的同步复位之中。复位路径上的不均匀延迟仍然不是好事,因为复位的撤销也会变得不均匀;但有些时候这并不构成问题。就这一点而言,31 个时钟周期的复位脉冲比可能需要的长度长得多,不过也没有什么坏处。
为其他时钟域生成复位
@master_reset 是一个普通的同步复位,但它与某一个时钟同步,而这个时钟可能与应用程序逻辑所用的时钟不同。要想为其他时钟产生对应的同步复位,应当对每个时钟都做类似下面的处理:
reg reset_clk_pre1, reset_clk_pre2;
reg reset_clk;
always @(posedge clk)
begin
reset_clk <= reset_clk_pre2;
reset_clk_pre2 <= reset_clk_pre1;
reset_clk_pre1 <= master_reset;
end
这只是一个常规的三级跨时钟域(clock domain crossing)处理,用来产生 @reset_clk。这个信号是跟随 @clk 一起使用的同步复位。实际上两级就够了,但由于这是一个重要信号,我特意多加了一级寄存器,以求更保险。
复位控制器所用的时钟
原则上,有三种时钟可以用作 @wakeup_clk,也就是供复位状态机使用的时钟:
- 由 FPGA 外部产生,并且已知在 FPGA 唤醒时已经稳定的时钟。
- 由 FPGA 上的 PLL 产生,因此预期在 FPGA 唤醒时尚未就绪的时钟。
- 在某些 FPGA 上,可以使用由 FPGA 本身内部环形振荡器产生的配置时钟。
第一个选项最容易处理。由于几乎总是会有一个参考时钟去驱动 FPGA 的 PLL,这个参考时钟通常可以直接用作唤醒时钟。不过必须确认,FPGA 唤醒时这个时钟确实已经稳定,也就是说 FPGA 加载位流所需的时间,应当比外部振荡器产生出有效时钟所需的时间更长。从数据手册来看,这一裕量通常很大。但如果电路板的上电时序规划不当,就完全可能在振荡器的电源电压远未达到正确电平时,FPGA 就已经被允许读取位流了。
第二个选项,是使用 FPGA 内部 PLL 产生的时钟。它的明显优点是:这个时钟很可能也就是应用逻辑所用的时钟,因此在时钟资源和功耗上都更高效。这种选择要求把复位状态机保持在初始状态,直到时钟稳定为止。这可以用类似下面的代码来实现:
reg rst_src_pll;
reg rst_src_pll_pre;
initial rst_src_pll = 1;
initial rst_src_pll_pre = 1;
always @(posedge wakeup_clk)
begin
rst_src_pll <= rst_src_pll_pre;
rst_src_pll_pre <= !pll_locked;
end
@pll_locked 是产生 @wakeup_clk 的那个 PLL 的锁定检测输出(高有效)。这个信号是异步的,所以先用 @wakeup_clk 对它进行同步,然后把它用作激活 @clear_counter 的原因之一,正如上面的代码所示(也就是通过 @rst_src_pll)。
这个选项还有另一个好处:如果参考时钟暂时不稳定或消失(尤其是在电路板上电之后),锁定检测器也很可能不稳定。因此,如果在释放 @master_reset 之前让 @reset_count 数到一个较大的值(肯定远大于 31),那么 FPGA 很可能会一直牢牢保持在复位状态,直到参考时钟可以正常工作时才释放。不过,这并不能作为可以放心依赖的特性。
无论如何,由于 @wakeup_clk 是 PLL 的输出,这必然意味着在它稳定之前,已经有逻辑在享用这个时钟。所以,在这段时间内状态机是否能正常工作并不明确。也可以辩解说这无所谓:因为时钟迟早会变得足够好,到那时自然会产生正确的复位。如果一切在复位之后都已重新开始,那么谁在乎复位之前发生过什么?
一种更严谨的思路是:必须让复位持续稳定有效,直到唤醒时钟已经锁定为止,这样 FPGA 在完成位流配置之后不会立刻出现怪异行为。这就需要注意,使用该时钟的逻辑必须非常简单。换句话说,这种逻辑应当属于那种即使时钟频率高于预期也不会出大问题的类型。
为了分析 @wakeup_clk 暂时频率过高时会发生什么,请注意,@reset_count 是复位状态机中唯一一个向量型寄存器。这意味着,其他触发器最坏只会因为时序违例而晚一个时钟采样到它们计算出的“下一值”。尤其重要的一点是,当 PLL 尚未锁定时 @pll_locked 为低,因此 @rst_src_pll 很快会稳定为高,于是 @clear_counter 也稳定为高。当触发器的数据输入(下一值)不变时,时钟频率多高都无所谓。
因此唯一可能的隐患是 @reset_count。在 @clear_counter 把它计算出的下一值稳定地保持为 0 之前,它可能错误地向上计数。例如,如果当前值是 3(二进制 011),计算出的下一值是 4(二进制 100)。但如果两个低比特位因为时序问题没有被采样到,而第三个比特位却被采样到了,计数器的值就可能一下子跳到 7(二进制 111)。
为了防止这种情况发生,从 @pll_locked 到 @clear_counter 的这一串寄存器,它们的初值都应当设置成有利于把 @reset_count 稳定保持在 0 的方向。因此,只要 @pll_locked 在 @wakeup_clk 稳定之前持续保持为低(它理应如此),@reset_count 就不会离开 0,@master_reset 也就会一直保持有效。
至于最后一个选项,也就是把 FPGA 的环形振荡器用作复位控制器的时钟:我自己没有试过,所以不太确定这个主意到底好不好。不过,如果它能帮到某个别无选择的处境,那么 Xilinx FPGA 的做法大致是这样的:在所用 FPGA 的 Configuration User Guide 中查找一个叫 STARTUPE2(或类似名字)的原语(primitive)。它应当有一个名为 CFGMCLK 的输出,这是 FPGA 内部一个不太精确的环形振荡器的时钟,频率大约为 50—65 MHz。可以保证这个时钟在 FPGA 唤醒时已经稳定。我会把它的时序约束设置成明显高于实际值的频率(例如 100 MHz)。
不过,这是我只有在确实没有其他选择时才会考虑的做法。例如,外部参考时钟在 FPGA 唤醒时还不稳定,因此需要用额外的逻辑来实现一段延迟。
复位 PLL
一般来说,在复位序列中同时复位 PLL 是一个好主意。这样可以确保 PLL 是在其参考时钟已知有效之后才被复位的。此外,如果复位是由用户发起的(例如按下复位按键),那它可能正是为了应对某个与 PLL 锁定不良有关的问题。这种事本不该发生,但还是以防万一为好。
@clear_counter 不能用来复位 PLL,因为它只要 PLL 一失去锁定就会变成有效。如果真的这样做,PLL 会一直被保持在复位状态,永远无法锁定,复位也就永远无法释放。出于同样的原因,PLL 的复位信号也不能从 @reset_count 派生:@reset_count 只有在所有 PLL 都锁定时才会保持在 0,否则它就不会运行。
解决办法是为 PLL 单独创建一个复位寄存器,它和 @clear_counter 类似。因此,沿用上面的记法,用 @rst_src_pll 表示有 PLL 尚未锁定,那么代码大致如下:
reg clear_counter;
reg reset_plls;
initial clear_counter = 1;
initial reset_plls = 1;
assign reset_sources = rst_src_shreg || rst_src_debounce;
always @(posedge wakeup_clk)
begin
clear_counter <= rst_src_pll || reset_sources;
reset_plls <= reset_sources;
[ ... ]
在这段代码中,@rst_src_pll 被排除在 @reset_sources 之外,只用于驱动 @clear_counter。结果是:PLL 会随整个 FPGA 一起被复位,但不会因为自己尚未锁定这一原因而被复位。
需要澄清一下:@wakeup_clk 本身不能由那个被复位的 PLL 来产生,所以这个时钟必须基于上面讨论的其他可能性来产生。
还要注意,如果 @reset_sources 随机地变低变高,那么按这里展示的代码,@reset_plls 也会跟着随机变化。这通常是无害的,因为 PLL 的复位疯狂地开关并不会让 PLL 出什么大事。不过还是有办法避免这种情况的,下面就会说明。
更复杂的启动序列
使用一个普通计数器(@reset_count)作为状态变量,使实现更复杂的启动序列变得很简单。例如,要在关闭时钟期间产生合适的异步复位(asynchronous reset),其实非常直接:只需用简单的逻辑表达式定义何时关闭时钟、何时复位有效这些时间段即可。
因此,即使某个项目在开始时看起来对复位状态机只有很简单的需求,这种普通计数器的方法也是一个很好的起点。如果日后发现需要复杂的启动序列,也很容易扩展现有逻辑来实现这一目标。
无论如何,规划启动序列本质上就是定义:序列中每个阶段分别在哪一段时间内生效。每个这样的时间段,都可以转化为 @reset_count 所应处的一个取值范围。
例如,如果设计中有两个或更多互不相关的时钟(unrelated clocks),就可以像上面那样(见“为其他时钟域生成复位”)为每个时钟域(clock domain)产生复位信号。但如果采用那种方法,每个时钟域实际上是以随机顺序退出复位的。这通常不是问题;但如果确实是问题,就可以根据 @reset_count 的进展,让每个时钟域各自在某个明确定义的时间点撤销复位。
也可以使用多个计数器。当复位序列需要等待某些条件满足之后才能继续时,这种方法很有用。例如,如果复位序列包含“先复位 PLL,等待它们锁定,然后再继续”这样的步骤,那么合理的做法是:用一个计数器,在所有 PLL 都锁定之前一直保持为 0;第二个计数器,在第一个计数器数完之前一直保持为 0。
请注意,如果 PLL 失去锁定,PLL 本身不会被复位,但所有依赖它们的逻辑都会被复位。在大多数设计中,PLL 失锁是一个严重故障,所以这可能并不是你所期望的行为。如果希望在 PLL 失锁时请求一次完整复位,可以按下面的定义把 @pll_restart 加进用于生成 @reset_sources 的或逻辑中:
assign pll_restart = rst_src_pll && !master_reset;
这个表达式的意思是:如果 PLL 是在 master_reset 已经释放之后才失去锁定的,那就把一切都重新复位,包括 PLL 本身。要让这一点成立,复位计数的时间必须足够长,能够承受锁定检测器可能出现的抖动。换句话说,复位计数的时间必须长于“PLL 尚未真正锁定时其锁定检测器却可能输出为高”的那段窗口。不要把它与 PLL 获得锁定所需的时间混为一谈。有些锁定检测器完全不会抖动,而且即使会抖动,这种抖动时间也几乎肯定比 PLL 的锁定时间(按数据手册规定)短得多。
唤醒移位寄存器
下面这种做法也许有点过于谨慎,但我通常会在设计里加一个如下所示的唤醒移位寄存器(wakeup shift register):
reg [15:0] wakeup_shift;
reg rst_src_shreg;
initial rst_src_shreg = 1;
initial wakeup_shift = 0;
always @(posedge wakeup_clk)
begin
rst_src_shreg <= !wakeup_shift[15];
wakeup_shift <= { wakeup_shift, 1'b1 };
end
由于大多数 FPGA 会把 @wakeup_shift 实现为移位寄存器原语,它消耗的资源大约相当于一个 LUT,所以在资源上很便宜。而它又提供了另一种机制,确保上电时一定会发生一次复位。在没有 PLL 的设计中,这一点是必需的,因为没有别的东西会去激活 @clear_counter。但即使在有 PLL 的设计中,也存在一种可能性:某些 FPGA 的位流配置选项可以让 FPGA 唤醒时 PLL 已经锁定。因此无论如何,这个附加机制都是值得推荐的。
所以,无论哪种情况,这都是一个值得加上的东西。小心驶得万年船。
外部复位按键
复位按键相当常见。它们的确切作用因设计而异。一种可能是,用户眼中的“复位”按键连接到 FPGA 上那个会触发位流加载过程的引脚。在带有嵌入式处理器的 FPGA 上,它也可能连接到处理器的复位引脚。
这个复位按键也可能连接到 FPGA 的通用 I/O 引脚,目的是复位 FPGA 逻辑。在这种情况下,它就又成为一个激活 @clear_counter 的理由。
如果 @reset_count 数到对应 10 ms 或更长时间的值,就不需要对来自输入引脚的信号进行消抖(debounce)了,因为抖动会被计数器本身吸收。这种情况下,下面这段代码就够了:
reg rst_src_debounce;
reg rst_src_debounce_pre;
initial rst_src_debounce = 1;
initial rst_src_debounce_pre = 1;
always @(posedge wakeup_clk)
begin
rst_src_debounce <= rst_src_debounce_pre;
rst_src_debounce_pre <= reset_button_pin;
end
但如果计数器很快就数完了(如上面的例子,只数到 31),那么按键信号就需要消抖。有几种方法可以做到这一点,例如:
reg [17:0] debounce_count = 0;
reg reset_button_d, reset_button_d2, reset_button_d3;
wire debounce_reached = (debounce_count == 250000);
initial debounce_count = 0;
initial rst_src_debounce = 1;
always @(posedge wakeup_clk)
begin
reset_button_d3 <= reset_button_d2;
reset_button_d2 <= reset_button_d;
reset_button_d <= reset_button_pin;
if (reset_button_d2 != reset_button_d3)
debounce_count <= 0;
else if (!debounce_reached)
debounce_count <= debounce_count + 1;
if (debounce_reached)
rst_src_debounce <= reset_button_d3;
end
这是一个相对严格的消抖器,对有噪声的输入信号表现不佳。这到底是优点还是缺点,取决于你的需求。
这段代码示例是按 25 MHz 时钟写的。如果 @reset_button_pin 在 10 ms 内一直保持同一个值,该值就会被复制到 @rst_src_debounce。请注意,@debounce_count 是在 @reset_button_d3 发生变化的同一个时钟周期被清零的。因此,当 @reset_button_d3 发生变化时,这个新值绝不会被立刻复制到 @rst_src_debounce,而是要等该值长时间保持不变之后才会被复制过去。
总结
在设计 FPGA 的初始化逻辑时,有许多事情需要考虑。本页介绍了一些概念和思路,但重要的是要记住:真正的任务是正确识别哪些事件需要触发动作,并且针对每个此类事件定义正确的响应,从而可靠地把逻辑带入一个可以工作的状态。