经常有学习Verilog的新人问我:为什么我仿真的时候发现所有的模块输出都是红线?一开始我都还是不厌其烦地帮助大家一点点分析代码,因为红线在仿真器中代表不确定态,而不确定态的成因是多种多样的。但是看过了无数新人案例后我发现,其实新人出现这种问题的原因基本上就两条:没有写复位逻辑,或者复位方式错误。
I. 仿真器红线的含义
通常情况下,红线是仿真器对于不定态的图形表示。而不定态产生的原因有很多种。很多新人,尤其是从计算机专业转到硬件设计的新人,心中往往缺乏对数字硬件电路工作方式的基本认识,所以仿真过程中无论是内部信号还是输出信号出现红线都是非常常见的。那么硬件设计和软件设计的最大区别是什么呢?我举一个很简单的例子:
[source lang="Cpp"]
int sel;
int a = 6;
int b = 8;
int out;
out = sel ? a : b;
[/source]
在这个C语言的例子里,由于sel没有初始值,编译器会自动将sel的初始值设置为0,因此在后面的out数值运算时,b的值8会被赋值给out变量。
但是Verilog是用来描述硬件电路的,硬件电路没有自动初始值的说法,同样的代码如果描述成Verilog:
[source lang="Verilog"]
wire sel;
wire[31:0] a = 6;
wire[31:0] b = 8;
wire[31:0] out;
assign out = sel ? a : b;
[/source]
则out将永远是不定态,原因是未经赋值的sel信号将不定态传递到了out上。是的,你没有听错,不定态在电路中是会向后级传递的。一旦在电路中某个节点出现了不定态,这个不定态就会像滚雪球一样逐级向后一直传递下去。而在硬件电路中,给信号赋初值的正确方式就是复位,所以正确的复位是一个电路系统正常工作的基本条件。
在上面示范的代码里,不定态是由于信号没有赋初值产生的,而实际的物理电路中,出现不定态的原因还有很多很多。不定态除了因为复位导致,另外一种比较常见的原因就是跨时钟域处理失败。什么是跨时钟域处理?如果有前后级联的两个异步寄存器(异步的意思就是他们的驱动时钟是不完全同频同相的),那么如果前一个寄存器输出变化的过程中刚好碰上了后级寄存器的采样时钟,即是说一个非0非1的电平逻辑被采样的话,后级寄存器就会进入亚稳态(关于亚稳态可参考为什么建立时间和保持时间如此重要?),主要表现就是输出电位变成介于逻辑0和逻辑1之间。同样的关系继续向后传递下去,整个电路就陷入了不可恢复的瘫痪状态。
跨时钟域问题(CDC)在这里就不展开讨论了,更详细的讨论可以看看跨时钟域问题学习笔记。本文只讨论与复位有关的不定态问题,因为通常新人还没有机会接触跨时钟域导致的不定态问题。
II. 原因一:没有复位逻辑
没有复位逻辑的代码长什么样?我给大家示范一下。
[source lang="Verilog" title="+ 无复位两拍同步器"]
reg q0;
reg q1;
always@(posedge clk) begin
q0 <= #DLY signal;
q1 <= #DLY q0;
end
[/source]
在这个always块的敏感列表里只有posedge clk,这是一个典型的无复位时钟同步电路。那么这样的电路就一定是错的吗?不一定。我同学当年参加毕业面试的时候就曾经被问过这个问题:处理器内部的通用寄存器需要被复位吗?答案是不需要。这个原理跟存储器不需要被复位是一样的。原因是,存储功能的数字逻辑,在读取它保存的数据之前,我们一定是要先把数据写进去的。除了泛存储类的电路不需要被复位外,另外一种不需要被复位的特殊电路叫做同步器电路,其实上面这个always模块就构成了一个最简单的两拍同步器电路。这种电路主要用在前面提到过的跨时钟域边界上,可以起到隔离亚稳态的效果,这样的电路在很多情况下也是不需要被复位的,因为他的设计结构本身就可以起到从不定态恢复的功能。两拍同步器的更具体介绍也可以参考这篇文章。
因此,除以上几种情况及其他一些极个别情况外,大部分时序电路都必须在正确复位的前提下才可以正常工作。
正确的复位通常有两种方式:同步复位和异步复位。
同步复位,顾名思义,就是复位生效的时刻点与时钟同步。换句话说,不管你按复位键的时间点是什么时候,它都必须等到时钟边沿(通常是上升沿)才生效。而异步复位则是,无论你何时按下复位键,复位就会立刻生效。
给一段示范代码大家体会一下这两种复位的区别:
[source lang="Verilog" title="+ 同步复位"]
reg q0;
reg q1;
always@(posedge clk) begin
if (!rst_n) begin
q0 <= #DLY 1'd0;
q1 <= #DLY 1'd0;
end
else begin
q0 <= #DLY signal;
q1 <= #DLY q0;
end
end
[/source]
[source lang="Verilog" title="+ 异步复位"]
reg q0;
reg q1;
always@(posedge clk or negedge rst_n) begin
if (!rst_n) begin
q0 <= #DLY 1'd0;
q1 <= #DLY 1'd0;
end
else begin
q0 <= #DLY signal;
q1 <= #DLY q0;
end
end
[/source]
可以明显看到,同步复位和异步复位的代码其实差别不大,最主要的差别就是:是否将复位下降沿列入到always的敏感列表中。但是这两种复位在实际使用中却有着天壤之别。下面我们就这两种复位方式的优点和缺点进行分别说明。
A) 同步复位
同步复位最大的优点就是时序好,而最大的缺点就是依赖于时钟信号生效。
时序好的意思是,在后端布局布线,或是在FPGA的综合约束过程中,不需要过多的分析就可以轻易满足时序要求。
但这种“时序好”有一个隐藏的前提,那就是复位信号必须来自被复位寄存器的驱动时钟域。如果复位信号产生的时钟域与被复位寄存器的驱动时钟域是异步关系,那么这里的“同步”复位就成了伪命题,此时的“同步”复位就面临着跟“异步”复位类似的问题。
很多情况下,新人会意识不到自己在不知不觉中引入了“异步”复位的问题。比如说,复位信号来自于FPGA或芯片外部的复位按键,此时按下按键的时机是随机的,而被复位的寄存器可能由芯片或FPGA内部的不同时钟驱动,就会造成即使设计的本意是同步复位,却实际上变成了异步复位的问题。
通常解决这个问题的办法是,用上文中提到过的那个“两拍同步器”对芯片外部输入的复位信号进行2拍同步,再送到内部时序逻辑的复位信号端,这样就能保证复位信号的变化边沿是跟寄存器的时钟同步的了。如果芯片内部存在多个时钟域,例如有10MHZ和100MHZ驱动的不同寄存器,那么就需要用两个不同的“两拍同步器”分别用10MHz和100MHz对外部输入的复位信号进行同步,然后把重新生成的2个复位信号rst_n100mhz和rst_n10mhz分别送给100MHz和10MHz时钟驱动的寄存器用作复位信号。
依赖于时钟信号生效的意思是,如果复位发生的过程中没有时钟边沿到来,复位就会失效。举一个极端情况的例子,假如外部时钟因为特殊原因被关闭了,此时芯片没有时钟输入,这个时候无论你在芯片外部按下多少次复位按键,芯片都是无法正常复位成功的。即使时钟并没有关闭,我们也必须保证复位按下的时间足够长,至少达到3个连续时钟边沿的时长,才能保证复位生效。
B) 异步复位
异步复位的优点就在于生效有保证,不需要依赖于时钟,但它最大的缺点就是:需要保证复位释放的同时不能撞上有效时钟沿。
由于异步复位的生效和释放都与时钟无关,因此复位释放的时刻点是非常有可能与时钟有效边延相撞的,这里相撞的意思就是复位释放的瞬间,刚好时钟正在发生有效边沿的变化,例如上升沿从逻辑0变为逻辑1的过程中。从寄存器的物理原理来说,复位释放与时钟相撞是会导致寄存器输出不定态的(也就是上文说到的红线),因此这种情况是必须避免的。
通常情况下,避免复位与时钟相撞的办法就是把复位信号进行同步,也就是上文描写同步复位问题的解决办法,用“两拍同步器”将复位信号和目标时钟进行同步,然后再用来复位使用同一时钟边沿的寄存器。
III. 原因二:复位测试激励错误
用了正确的方法描述复位逻辑是不是就万事大吉了呢?当然不是啦。
新人写testbench测试代码的时候,经常犯的一个错误就是复位给的激励不对。激励不对的情况通常也分为两种。
A) 同步复位测试激励
同步复位的缺点在上文中已经说过了,它必须在有时钟的情况下才可以生效。所以,如果模块中使用的都是同步复位,但是在testbench中写激励的时候,复位过程中没有同时给出有效的时钟边沿,或者是时钟有翻转但是复位发生的时长不足一个时钟周期,那么这个复位激励很可能是无效的。下面给出错误和正确的例子大家感受一下:
[source lang="Verilog" title="+ 错误的同步复位测试激励"]
initial begin
clk = 0;
#100 rst_n = 1;
#100 rst_n = 0;
#100 rst_n = 1;
forever #10 clk = ~clk;
end
[/source]
[source lang="Verilog" title="+ 正确的同步复位测试激励"]
initial
clk = 0;
forever #10 clk = ~clk;
end
initial begin
#100 rst_n = 1;
#100 rst_n = 0;
#100 rst_n = 1;
end
[/source]
在错误示范的例子中,由于clk初始化为0之后一直没有发生翻转,也就是没有产生过posedge clk这样的边沿,所以在后面的300ns时间里,虽然rst_n经历了从高电平到低电平,再到高电平的过程,实际上这个复位是失败的。
而在正确示范的例子中,clk从一开始就保持着每10ns一次的翻转。在后来rst_n保持为低电平的100ns时间里,可以很容易算出clk应该经历了最少100/20 = 5次翻转,所以rst_n肯定是生效的。
A) 异步复位测试激励
那么异步复位的测试激励又会犯下什么错误呢?假设我们都从0时刻开始输出时钟(虽然异步复位不依赖于时钟),然后我们来看一下以下两份不同的示范代码。
[source lang="Verilog" title="+ 错误的异步复位测试激励"]
initial
clk = 0;
forever #10 clk = ~clk;
end
initial begin
rst_n = 0;
#100 rst_n = 1;
end
[/source]
[source lang="Verilog" title="+ 正确的异步复位测试激励"]
initial
clk = 0;
forever #10 clk = ~clk;
end
initial begin
rst_n = 1;
#100 rst_n = 0;
#100 rst_n = 1;
end
[/source]
在错误的异步复位测试激励里,rst_n初始值就是0(复位生效),然后经历了100ns后变成1。而在正确的示范代码中,rst_n首先是1,然后变成0后再变回1。这2份代码的区别仅仅在于,错误代码的rst_n只产生了上升沿,而正确代码的rst_n先经历了下降沿,然后再上升为1。这下你看明白了吗?由于我们在模块的复位逻辑里写的是negedge rst_n,所以如果rst_n的初始值就是0,而不是先初始为1再变为0,那么rst_n从头到尾都根本没有产生过“下降沿”,这样的情况下,基于下降沿(negedge)触发的复位当然就不会发生啦。
另外,最近有同学告诉我说,他按照本文的解释检查了代码,发现复位逻辑是正确的,复位激励也给对了,可是输出依然是红线。我帮他看过代码后发现,他的复位激励看起来貌似对了,但其实是错误的。大家注意到我在上文中提到的复位激励时间单位都是ns,例如有“rst_n经过100ns后变成1”这样的描述。但是回头看看示范代码,大家不难发现,我在代码中写的对应语句只是“#100 rst_n = 1”,那么这里的#100是如何对应成100ns的呢?我们在写测试平台的时候,通常会给一个仿真时间的说明,如“`timescale 1ns/1ps”,这里的1ns就是我们代码中#数字所表示的时间单位,而“/”后面的1ps是仿真精度,一般来说仿真精度越小,则仿真所花费的时间就越长。仿真精度是绝对不允许大于仿真单位的,测试平台中使用的时间参数精度也绝对不可以小于仿真精度。
如果我们不小心把仿真时间的说明写成了“`timescale 1ps/1ps”,则我们之前写的#100就会变成100ps而不是100ns。由于在大部分的Verilog仿真器中,默认门电路的延迟都是1ns左右,显然这样一个100ps的激励是不足以穿越任何门电路而对后级输出产生任何影响的。如果有其他同学发现自己的复位逻辑正确,激励给出的边沿也正确,但输出依然是红线的话,不妨检查一下自己的仿真时间说明是否写对了。
上述这个问题其实在新的SystemVerilog语言中已经得到了纠正,在SV语言中,时间精度不再是用#100的方式表示,而是可以显式地标出时间的具体单位,如“#100ns rst_n = 1”,因此这里也建议各位同学尽量使用SystemVerilog语言来搭建验证平台,减少出错的几率。
总结
仿真过程中出现红线的主要原因有2种,一种是跨时钟域出现问题,另一种是复位问题导致的,而新手遇到模块仿真一开始就是红线的情况多半是后者造成的。解决这个问题要从2个方面着手。一方面要把复位逻辑写对,另一方面就是要检查自己的复位测试激励信号有没有产生正确。如果是要在FPGA上实现电路,那么正确的时序约束必不可少。在复位逻辑的处理上也要保证复位信号的同步性,比较常见的方式就是使用“两拍同步器”来完成复位信号的同步。