1 Verilog的三种描述层次
Verilog 可以使用三种不同的方式描述模块实现的逻辑功能。它们分别是:
-
结构化描述方式:调用其他已经定义过的低层次模块对整个电路的功能进行描述,或者直接调用 Verilog 内部预先定义的基本门级元件描述电路的结构进行描述。
-
数据流描述方式:使用连续赋值语句 assign 对电路的逻辑功能进行描述。该方式特别适合于对组合逻辑电路建模。
-
行为级描述方式:使用过程块语句结构 always 和比较抽象的高级程序语句对电路的逻辑功能进行描述。
例子:对下面的电路分别使用三种不同的方式进行描述。
请写出该电路的逻辑表达式。(数字电路知识)
1.1 结构化描述方式
观察到该电路十分简单,简单到甚至可以直接从门级电路进行描述。
Verilog 常用的内置逻辑门包括:
- not(非门)
- and(与门)
- nand(与非门)
- or(或门)
- nor(或非门)
- xor(异或门)
- xnor(同或门)
module MUX2(
input a, b,
input sel,
output out
);
wire and1, and2, sel_not;
not(sel_not, sel);
and(and1, a, sel_not);
and(and2, b, sel);
or(out, and1, and2);
endmodule
1.2 数据流描述方式
数据流描述方式需要我们得到逻辑表达式。我们可以将门电路转换为对应的逻辑表达式:
and (and1, a, sel_not); // and1 = a & sel_not
and (and2, b, sel); // and2 = b & sel
not (sel_not, sel); // sel_not = ~sel
or (out, and1, and2); // out = and1 | and2
化简后,我们就得到了输出 out 关于输入 a、b 和 sel 的逻辑表达式:
out = (a & ~sel) | (b & sel);
由此可以得到基于assign语句的数据流描述:
mnodule MUX2(
input a, b,
input sel,
output out
);
assign out = (a & ~sel) | (b & sel);
endmodule
//当然,也可以写成下面的样子:
module MUX2(
input a, b,
input sel,
output out
);
wire sel_not = ~sel;
wire and1 = a & sel_not;
wire and2 = b & sel;
assign out = and1 | and2;
endmodule
1.3 行为级描述方式
很多时候,我们难以得到模块的电路结构(或者得到的结构十分繁琐),这时我们就可以使用行为级描述,以类似于高级语言的抽象层次进行硬件结构开发。这一层面的描述过程更看重功能需求与算法实现,也是对于我们最为友好的描述方式。
module MUX2(
input a, b,
input sel,
output reg out
);
always @(*) begin
if (!sel)
out = a;
else
out = b;
end
endmodule
在always语句内部:
当sel 为 0 时,out 输出 a 的内容;
当sel 为 1 时,out 输出 b 的内容。
其对应的逻辑表达式为:out = (a & ~sel) | (b & sel)
可以看出,在Vivado中进行分析RTL ANALYSIS后的简图schemaitc与前两种不一样。
在实际的硬件开发过程中,我们更多采用的是将三种描述方式结合起来,根据需要选择相应的描述方式,从而实现自己的预期设计。
2 两种特殊信号
2.1 时钟信号
时钟信号是数字电路中时序逻辑的基础,用于决定逻辑单元中的状态何时更新,是有着固定周期并且与模块运行无关的信号量。
实际硬件电路中的时钟信号是由时钟发生器产生的。它只有两个电平,一个是低电平,另一个是高电平。高电平可以根据电路的要求而不同,例如理想情况下 TTL 标准的高电平是 5V,而低电平一般默认为 0V。时钟信号有固定的翻转频率(周期),以恒定的速度进行着高电平和低电平之间的转换。
除了周期 T、频率 f 之外,另一个重要的时钟属性是占空比(Duty Ratio)。对于周期恒定的时钟信号,占空比的定义为周期电信号中,有电信号输出的时间与整个信号周期之比。
最常见的时钟信号占空比为 50% ,也就是说高电平和低电平的持续时间是一样的。也就是上图那样。
数字系统使用时钟信号的上升沿、下降沿或者双边沿作为同步驱动的参考,进而实现不同模块的同步运作,确保了整个系统的协调性与正确性。
上面的讨论中,所有的时钟信号都是理想的:时钟的翻转是在瞬间完成的,模块之间的时钟沿都是对齐的,没有延迟,没有抖动。但在实际电路中,时钟在传输、翻转时都会有延迟。一个较好的数字设计也应该考虑这些不完美的时钟特性,否则会造成潜在的设计时序不满足的状况。
2.1.1 常见的时钟特性:
时钟偏移(Skew)
由于线网的延迟,同一个时钟源的时钟信号即使同时发出,也不能保证不同模块接收到的边沿是对齐的,即不同模块端口的时钟相位存在差异。这种差异称为时钟偏移。
转换时间(Transition)
时钟从上升沿跳变到下降沿,或者从下降沿跳变到上升沿时,并不是"直上直下"、不需要时间的,而是以一种"斜坡式"的方式,需要一个过渡时间才能完成电平跳变。这个过渡时间称之为时钟的转换时间。
时钟抖动(Jitter)
在实际的时钟源中,不随时间积累的,时而超前、时而滞后的偏移称为时钟抖动。
时钟抖动可分为随机抖动和固定抖动。其中,随机抖动的来源为热噪声、半导体工艺等;固定抖动的来源为开关电源、电磁干扰或其他不合理的布局布线等。
2.1.2 本地时钟
在上板调试与运行时,我们使用板载芯片的时钟源作为时钟驱动。但在本地测试的时候,我们就需要自己编程生成符合预期的时钟信号。下面简单介绍一些基本的 Verilog 时钟编写方式。
基于 initial
我们先前提到过,initial 仅在 0 时刻开始执行一次内部的语句。而时钟信号是一个长期翻转的信号,因此我们需要在 initial 内部添加一个死循环,用于生成周期性的信号。下面的代码使用forever
关键字声明了一个周期为 T 的时钟。
parameter T = 10;
reg clk;
initial begin
clk = 0;
forever #(T/2) clk = ~clk;
end
在 Verilog 中,
forever
是一个用于行为级的关键字,它表示一个无限循环。它通常用于仿真测试代码(Testbench)中生成周期性信号(如时钟信号)或持续监控某些行为。基本特性
无限循环:
forever
会无限重复执行其后的语句块,直到仿真结束。不可综合:
forever
仅用于仿真,不能用于可综合的硬件设计(因为它没有实际的硬件对应)。必须与时间控制结合:为了避免仿真器挂起,
forever
循环内部通常需要包含时间控制语句(如#
延迟或@
事件触发)。注意事项
必须包含时间控制:如果
forever
内部没有时间控制语句(如#
延迟,或@事件触发 ),仿真器会陷入零延迟无限循环,导致仿真时间无法推进,最终挂起。(在仿真中,“挂起Hang” 指的是仿真器无法继续推进仿真时间,导致仿真过程“卡死”,看起来像程序停止响应。这种现象通常是因为代码中存在无限循环但没有时间推进,仿真器在零时间内无限重复某个操作,无法跳出。)通常用于
initial
块:forever
一般包裹在initial
块中,因为always
块本身已经是无限循环。如何终止循环?可以使用
disable
语句跳出forever
循环。与
always
的区别
always
块本身隐含无限循环,但需要依赖敏感列表(如always @(posedge clk),或#
延迟)触发。
forever
更灵活,可以手动控制循环内的时序,适合需要自定义时序的场景。持续监控信号例子
initial begin forever begin @(posedge data_valid); // 等待 data_valid 信号的上升沿 $display("Data received: %h", data);//持续打印输出data end end
基于 always
与 initial 不同,always 语句将持续执行内部的语句,因此就不需要额外的死循环了。我们可以使用 initial 语句对 clk 变量初始化,再使用 always 语句实现永久的周期变化。
parameter T = 10;
reg clk;
initial clk = 0; //注意:和always是平行关系:独立的0时刻开始;并行执行。
always #(T/2) clk = ~clk; //没有敏感列表的always将无限循环执行
parameter T_H = 10;
parameter T_L = 5;
reg clk;
always begin //没有敏感列表的always将无限循环执行
clk = 1;
#T_H
clk = 0; //也可clk = ~clk
#T_L
end
上面的 Verilog 代码生成的时钟占空比是多少?
2.2 复位信号
复位信号的引入背景:
C 语言的指针(Pointer)需要进行初始化,否则访问时可能发生意想不到的错误。Verilog 的信号变量也是同理,若不初始化,则:wire 型变量的默认赋值是 Z,reg 型变量的默认赋值是 X
那么,我们应当如何为这些变量初始化呢?
首先,wire 类型的变量是不需要初始化的,因为其只要与电路相连,就一定有确定的输出(这是组合逻辑电路的特征)。
注意:不能对 wire 变量进行初始化!!!不管是否是声明时。
问题:下面这段 Verilog 代码能正常运行吗?若不能,错在哪里?
module Init4wire ( input a, b, c, output out ); wire temp = 0; assign temp = a | b; assign out = temp | c; endmodule
上述代码的本意可能是想:在 a 和 b 都没有值的时候,让 temp 输出 0 而不是 Z。但使用 Vivado 得到的 RTL 电路图如下:(所以要注意,这是硬件电路代码)
且报出 Warning:[Synth 37-96] [ASSIGN-7]Multiple assignments detected on signal 'temp'.
这表明 temp 变量出现了重复赋值的问题。事实上我们之前提到过,形如
wire signal = value;
的语句实际上等价于下面这两条语句:wire signal; assign signal = value;
因此,上面的错误代码中,错在对 signal 进行了两次赋值。Vivado 将后面的
assign temp = a | b;
忽略了,从而让temp
变量直接接地,也就是恒为 0。
现在,我们只需要考虑 reg 类型的变量。
下面的说法似乎在现在的FPGA芯片上是不成立的,老版本的芯片可能确实是这样,但是我们现在不需要考虑下面的那种情况!不用太过于在意下面的说法。
注意:不建议在 reg 类型的变量声明时对其进行初始化操作。也就是说:推荐在非声明时初始化。
尽管在本地测试的时候这样可以成功初始化,但在上板实际验证时,信号初始值依然是不确定的,从而成为潜在的错误(因为这种方式在语法上和逻辑上都没有问题)。
例如:下面这段代码能通过语法检查,本地测试也是正确的,但上板时依然可能运行异常:
module Init4reg ( input a, b, c, output out ); reg temp = 0; always @(*) begin temp = a | b; end assign out = temp | c; endmodule
那么,我们应该怎样进行正确的初始化操作呢?这就需要引入复位信号了。
为确保实际环境下数字系统在上电后有一个明确、稳定的初始状态,且系统在运行紊乱时可以恢复到正常的初始状态,我们会在模块设计中添加复位模块。复位电路保证了系统工作的可控性,在一定程度上其重要性不亚于时钟信号。
在 Verilog 设计中,初始化赋值操作和复位信号是两个不同层次的概念,但它们共同服务于一个目标:确保电路在启动或异常情况下处于已知的确定状态。以下是两者的关联和区别:
1. 初始化赋值的作用
仿真阶段的确定性:
在仿真时,可以通过initial
块或变量声明时的初始值(如reg a = 0;
)为寄存器赋予初始值。这种初始化仅对仿真有效,确保仿真开始时逻辑状态可预测。综合工具的局限性:
大多数 FPGA/ASIC 综合工具会忽略initial
块中的初始值(某些 FPGA 支持通过initial
设置初始值,但这不是通用行为)。实际硬件上电时的寄存器状态可能仍然i是随机的,无法通过代码中的初始化赋值保证,所以复位信号才显得如此重要。
2. 复位信号的作用
硬件状态的确定性:
复位信号是物理电路中用于强制寄存器进入已知状态的机制。通过复位逻辑,可以在上电、异常或需要时,将电路重置到预设状态/初始化状态。实际硬件的必要性:
无论仿真时是否初始化,实际硬件必须依赖复位信号确保电路行为可靠。没有复位信号,上电后的随机状态可能导致功能错误或死锁。
从时序上来看,复位电路可分为同步复位(Synchronous Reset)和异步复位(Asynchronous Reset)两种。
2.2.1 同步复位
顾名思义,就是与时钟信号同步的复位信号。也就是说,复位信号仅在时钟的边沿到来时才有效/被采样。如果没有时钟边沿,无论复位信号如何变化,电路也不会进行复位操作。
一个典型的同步复位代码描述如下:
module sync_reset( input rst, // 高电平有效 input clk, input din, output reg dout ); always @(posedge clk) begin // 复位信号不在敏感列表中 if (rst) //复位信号优先级最高 dout <= 1'b0; else dout <= din; end endmodule
可以看到,上面这段代码仅在时钟信号 clk 的上升沿到来时才执行 always 内部的语句,即判断复位信号 rst 是否有效。其他时刻不管 rst 如何变化,内部信号 dout 都不会被复位。
优点:同步复位保证了信号是与时钟同步变化的,有利于保证时序的稳定性(避免毛刺),因此被广泛使用。
缺点:1、大多数时序逻辑单元并没有同步复位端,使用同步复位描述得到的电路往往会消耗更多的逻辑资源。
2、复位信号的有效电平宽度必须大于一个时钟周期,否则便有可能发生遗漏。
同步复位的遗漏:
如上图所示,复位信号 rst 是高电平有效。可以看到,前两个时钟上升沿到来时,rst 均为低电平,因此不会发生复位。在第三次时钟上升沿到来时,rst 为高电平,系统才能触发同步复位。
复位信号最小宽度 > 1个时钟周期。若复位信号高电平持续时间过短(例如半个时钟周期),可能无法被时钟边沿捕获。
2.2.2 异步复位
指无论时钟到来与否,只要复位信号有效就会执行复位操作的信号。
一个典型的异步复位代码描述如下:
module async_reset( input clk, input rst, // 高电平有效 input din, output reg dout ); always @(posedge clk or posedge rst) begin //复位信号在敏感列表中 if (rst) dout <= 1'b0; else dout <= din; //根据时钟边沿进行更新输出 end endmodule
上面这段代码中,always 语句在 clk 和 rst 信号的上升沿到来时均会执行。当复位信号 rst 由低电平变为高电平时,对应的上升沿便会触发 always 执行内部语句,进而执行复位操作。
优点:目前大多数时序逻辑单元都提供了异步复位信号的接口(RST复位 或 CLR清空),因此异步复位描述不会占用额外的逻辑资源,设计上相对简单。
缺点:异步复位会导致复位信号与时钟信号之间没有明确的时序关系,并且复位信号容易受到外部因素的干扰,产生意想不到的复位操作。
2.2.3 其他讨论
复位电路的舍得(优缺点):
复位电路会为数字系统带来更多的硬件逻辑和资源消耗,增加系统设计的复杂度。所以,在一些初始状态不影响逻辑正确性的数字设计中(例如数据处理、高速流水线中的一些寄存器等)可以考虑去掉复位信号,以达到最佳性能。
如果某个模块确实需要复位操作,Xilinx 的建议是:使用同步复位。
检查下面的 Verilog 代码,看看哪里有问题?
module Reset_test(
input clk,
input rst, // 复位信号,高电平有效
input en, // 写入控制信号
input [15:0] din,
output reg [15:0] dout1,
output reg [15:0] dout2
);
always @(posedge clk or posedge rst) begin
if (en)
dout1 <= din;
else if (rst)
dout1 <= 0;
end
always @(posedge clk or posedge rst) begin
if (rst)
dout2 <= 0;
else if (en)
dout2 <= din;
end
endmodule
直观来看,这个模块好像描述了两个异步复位的寄存器,二者只有 rst 和 en 信号的位置不同。但真的是这样吗?下面是 Vivado 给出的 RTL 电路图:
右边两个黄色的矩形是自动生成的寄存器(也就是时序逻辑单元,从名字可以看出上面的对应 dout1,下面的对应 dout2)。注意观察左侧 rst 信号的连接情况,我们发现 dout1 并没有与 rst 信号相连!此外,dout2 寄存器也比 dout1 寄存器多了一个 CLR置零 端口。这表明 dout1 并没有将 rst 视作异步复位信号。
为什么会这样呢?实际上这是 if-else 语句的优先级导致的。在 dout1 对应的描述里,rst 信号比 en 信号判断的优先级低。也就是说,如果 en 信号为高电平,那么在 rst 的上升沿到来时,dout1 信号先会进入 if (en)
的判断,结果为真,从而执行 dout1 <= din;
而不是复位操作 dout1 <= 0;
,这显然不符合异步复位的要求。所以在这里编译器将 rst 视作了同步复位信号(不是异步复位信号的立即生效,这就等同于同步复位的特性)。
综上所述,在使用异步复位时也需要格外注意自己的代码是否正确实现了异步复位。为了避免出错,统一选择同步复位是一个简单有效的策略。总而言之,今后在设计复位信号时,请使用同步复位。
知识扩展:FPGA芯片的初始复位
关键信号说明
nSTATUS
:
低电平表示 FPGA 处于错误或复位状态;拉高表示配置正常进行。
CONF_DONE
:
低电平表示配置未完成;拉高表示配置成功并通过校验。
nCONFIG
:
外部控制信号,拉低会强制 FPGA 重新配置。
MSEL
引脚:
决定配置模式(如 SPI、JTAG、主动串行等),需硬件连接匹配。1. 上电阶段(Power Up)
触发条件:
FPGA 通电后自动进入此阶段。
动作:
nSTATUS
和CONF_DONE
被驱动为低电平(逻辑0)。所有 I/O 引脚处于三态(高阻态),避免未配置时对外部电路产生干扰。
配置 RAM 的存储位被清零,确保初始状态干净。
进入下一阶段条件:
当电源稳定(Power supply stable)
该阶段持续条件:
电源供应不稳定,未达到推荐的工作电压。
2. 复位阶段(Reset)
动作:
nSTATUS
和CONF_DONE
被持续驱动为低电平(逻辑0)。所有 I/O 引脚处于三态(高阻态),避免未配置时对外部电路产生干扰。
采样 MSEL 引脚,确定配置模式(例如通过 JTAG、SPI 或并行接口加载配置数据)。
配置 RAM 的存储位被清零,确保初始状态干净。
意义:
确保所有逻辑单元处于已知状态,为加载配置数据做准备。
MSEL 引脚的采样结果决定了 FPGA 如何接收配置数据(如主动串行模式或被动模式)。
进入下一阶段条件:
nCONFIG
处于逻辑高 并且nSTATUS
被释放且拉高,都满足后进入configuration阶段。该阶段持续条件:
nCONFIG 或 nSTATUS
依旧保持低电平。
3. 配置阶段(Configuration)
动作:
写入数据:配置数据通过上一个阶段选定的接口(如 SPI、JTAG)写入 FPGA芯片中。
重新配置条件:
如果配置过程中出现 CRC 校验错误,或外部将
nCONFIG
拉低了。都会退回到上一步的复位中去,这确保FPGA芯片在异常状态下的正常工作。进入下一阶段条件:
当配置数据写入完成后,
CONF_DONE
(配置完成)信号被释放,并通过外部上拉电阻拉高了。该阶段持续条件:
CONF_DONE
(配置完成)信号处于逻辑低,也就是没有被拉高。
4. 初始化阶段(Initialization)
动作:
初始化内部逻辑和寄存器,例如复位用户设计中的触发器。
使能 I/O 缓冲器,允许 FPGA 与外部电路交互。
INIT_DONE
被释放(如果启用了该设置选项)。意义:
确保用户设计的逻辑在启动时处于预期状态(例如计数器归零、状态机复位)。
重新配置条件:
nCONFIG
被拉低,该信号让配置模式生效,会重新走一遍配置,但首先是进行复位操作。进入下一阶段条件:
初始化完成。
该阶段持续条件:
若需要更多初始化时钟周期,则继续等待。
5. 用户模式(User-Mode)
动作:
FPGA 开始执行用户设计的逻辑功能,此时系统按照既定时序开始运转。
I/O 引脚根据设计文件定义的行为工作(如输入/输出信号、通信协议等)。
重新配置条件:
若外部将
nCONFIG
引脚拉低,FPGA 会重新启动配置周期,回到 Reset 阶段,重新加载新配置。
因此,每次工作开始前,FPGA 必定会进行复位、初始化等操作。所以理论上我们的变量是不需要复位的(默认赋值成 0)。但是,一方面我们需要保证寄存器中的值是我们期望的内容,另一方面也要避免可能出现的异常状态导致后续系统工作异常,因此数字系统中的复位设计是必不可少的。
3 硬件层面的并行
通过前面的学习,我们知道:一个模块里的 always 语句和 initial 语句执行顺序与其在模块中的位置无关,它们之间是并行执行的。always 块和 initial 块内的过程赋值语句可以是阻塞赋值 =
,也可以是非阻塞赋值 <=
。其中阻塞赋值用于组合逻辑电路,为串行执行;非阻塞赋值用于时序逻辑电路,为并行执行。
需要注意的是,即使在同一个 always 块里的阻塞赋值语句也可以是并行执行的(同时执行多个赋值操作),只要其内部的信号不会产生冲突。我们来看下面这段代码:
module Test (
input sel,
output reg [1:0] out1,
output reg [1:0] out2
);
always @(*) begin
// Part 1
if (sel)
out1 = 2'd2; //阻塞赋值
else
out1 = 2'd0; //阻塞赋值
// Part 2
if (sel)
out2 = 2'd2; //阻塞赋值
else
out2 = 2'd1; //阻塞赋值
end
endmodule
这段代码包含了一个 always @(*)
语句块,表明当 sel
发生变化时,就执行内部的语句。
问题:当 sel
信号从 1'b0 变为 1'b1 的时候,out1
和 out2
谁先发生变化?
你可能会回答:因为有begin..end顺序块存在,程序会首先执行 Part 1 中的 if 语句,得到 out1
的值为 2'd2,然后执行 Part 2 中的 if 语句,得到 out2
的值为 2'd2。因此 out1
应当比 out2
先发生变化。
但真的是这样吗?作为一门硬件描述语言,我们一直在强调:Verilog 中的每一条语句都对应着一种实际的硬件结构,例如 if 语句对应的是选择器。
这段代码对应的硬件电路如下图所示:
不难看出,Part 1 和 Part 2 两部分是分离的,描述了两个不同的选择器。因此在硬件电路层面上,out1
和 out2
信号是同时生成的,二者之间不存在逻辑延迟。
问题:那么,什么时候 Verilog 中的阻塞赋值会串行执行呢?
答案是:在信号出现依赖与冲突时,自然就会串行执行了。例如下面这段 Verilog 代码:
module Test (
input [1:0] num1,
input [1:0] num2,
input [1:0] sel,
output reg [1:0] out
);
always @(*) begin
out = 0;
// Part 1
if (sel[0])
out = num1;
// Part 2
if (sel[1])
out = num2;
end
endmodule
问题:上面的代码它对应了怎样的硬件结构呢?【动手!可以试着照葫芦画瓢画出它的结构!】
很明显,这里上下两部分的结果都会对out产生冲突,因此这样的结构是错误的(并行结构)。在这里,always 中的语句是串行执行的,因为它们都对 out
变量进行了赋值操作。
分析一下这段 Verilog 代码的逻辑:
- 当 always 语句执行时,首先执行初始赋值语句,
out
的值为 0。 - 接下来执行part1的 if 语句:当
sel[0]
为 1 时将out
赋值为num1
;当sel[0]
为 0 时,不执行if语句,out值不变。 - 然后执行part2的 if 语句:当
sel[1]
为 1 时将out
赋值为num2
;当sel[1]
为 0 时,不执行if语句,out值不变。
可以看出,if语句都对out产生了赋值影响,所以整段逻辑对应的硬件结构(串行)为:
我们将上面的讨论结果总结如下:如果两个 if 的赋值对象没有冲突,那么两个 if 描述的多选器是并行的,否则是串行的。
当然,我们也可以使用 if-else-if 语句实现上面的结构,而 else-if 将显式指出多选器的串行执行顺序。
always @(*) begin
out = 0;
if (sel[0]) //第一优先级
out = num1;
else if (sel[1]) //第二优先级 <--串行执行
out = num2;
end
endmodule