从这一篇开始正式介绍FPGA中的硬件逻辑,第一个目标就是从零开始在NEXYS 4开发板上实现闪烁LED。
软件编程中hello world是初学语言中实现的第一个功能,而硬件编程中blink是同等的地位,有这跨出的第一步才有之后的进步
FPGA基础入门【4】Blink开发板实现
功能设计
硬件设计的过程是自上而下的,意思是先确认顶层的输入和输出分别需要什么样的,然后将任务一步步细分到其中的多个模块,每个模块也有自己的输入和输出,可以再度细分。
比如闪烁LED的设计中,我们需要先考虑要给FPGA什么信号,要求它输出什么信号。我们这样设计:给它时钟和复位信号给它,要求它输出一个半秒为高半秒为低的1Hz数字信号;
往底层去,输入的时钟频率肯定比1Hz高很多,比如50MHz,那需要一个计数器系统来每秒数50M个数,合适时候输出信号,还需要有个复位系统,每次复位信号为高的时候,就把计数器和输出信号强行拉低。这就是自上而下的设计,先考虑顶层,在考虑细节。
设计流程图
blink代码
blink.v
// Verilog code for blink project
module blink(
input clock,
input reset,
output reg led
);
reg [31:0] counter;
// Counter module
always @(posedge clock or posedge reset) begin
// On reset, clear counter
if(reset) begin
counter <= 32'd0;
end
// counter less than max value, then add one
else if(counter < 32'd50000000) begin
counter <= counter + 32'd1;
end
// counter reach max value, clear counter
else begin
counter <= 32'd0;
end
end
// Output module
always @(posedge clock or posedge reset) begin
// On reset, clear led output
if(reset) begin
led <= 1'b0;
end
// counter less than half of max value, the led output to high
else if(counter < 32'd25000000) begin
led <= 1'b1;
end
// counter higher than half of max value, the led output to low
else begin
led <= 1'b0;
end
end
endmodule
详解
作为第一段Verilog代码,把每一段都分开详细介绍。首先是基本框架:
module blink();
endmodule
它定义了一个叫blink的module模块,这个模块没有输入没有输出,相当于一个封闭的空白方块。
module blink(
input clock,
input reset,
output reg led
);
endmodule
现在给这个封闭空白方块定义了输入和输出的端口,先定义端口再写内部逻辑也是自上而下的硬件编程逻辑。端口的配置是这样的:
input/output wire/reg [WIDTH-1:0] port_name;
input/output定义了端口的方向,是输入还是输出,还有第三类inout是双向的,这个之后讲;
wire/reg定义了端口的数据类型,wire是线网,相当于一根数据线,reg是寄存器,在时钟上升沿或者下降沿将输入信号保存起来持续输出。只有输出端口可以是寄存器reg,输入必须为wire,因为你需要定义上升沿和下降沿才能有寄存器,而寄存器的输出就是持续的了。如果不定义数据类型,则默认为wire;
[WIDTH-1:0]定义了端口宽度,WIDTH是大于0的整数,比如8位端口就是[7:0],不定义宽度时默认为1;
port_name是端口名称,不能以数字为开头。
reg [31:0] counter;
除了端口,模块内部需要定义额外的线网和寄存器。这个计数器用来存放计数。
// Counter module
always @(posedge clock or posedge reset) begin
// On reset, clear counter
if(reset) begin
counter <= 32'd0;
end
// counter less than max value, then add one
else if(counter < 32'd50000000) begin
counter <= counter + 32'd1;
end
// counter reach max value, clear counter
else begin
counter <= 32'd0;
end
end
和C语言一样,双斜杠//右侧是备注,不会跨行,/* */之间也是备注,可以跨行。
always @(posedge clock or posedge reset)
Verilog中的主要逻辑就在always语句中。就像名称一样,它永远在被执行,@符号表示这个模块中的内容会在它描述的条件中触发。posedge是上升沿的意思,相对应negedge是下降沿,意味着在后面这个数字信号由低变高,或者由高变低的时候会触发always逻辑块执行。中间的Or顾名思义是在clock信号的上升沿或者reset信号的上升沿。
if(signal0) begin
//part 0
end
else if(signal1) begin
//part 1
end
else begin
//part 2
end
是和C语言一样的条件结构,如果signal0为高则执行part 0,否则判断signal1,如果signal1为高则执行part 1,否则执行part 2。
always @(posedge clock) begin
a<=b;
b<=c;
end
这里就是硬件逻辑和软件逻辑最大的不同点了,<=是Verilog中的非阻塞赋值(另一种阻塞式赋值=很容易引起混淆,在此不描述),一般寄存器reg用的都是这种赋值方式,wire类用的是assign语句。
所谓非阻塞式语句,就是没人拦着你,在同一块的信号大家都一样,一起跑,就像硬件电路里的信号一样。上面例子里的两句反一下执行的结果是一样的,b赋值给a和c赋值给b同时进行,直到下一次clock信号的上升沿。
counter <= 32'd0;
这就是一个普通的非阻塞赋值语句了,需要讲的是verilog中的数字表示形式。
电路中所有信号都是二进制的,数字32表示的是二进制位数,无论后面采用的什么进制;'d表示后面数字是十进制(decimal)的,'h表示十六进制(hexadecimal),'b表示二进制(binary)。
因此有效的数字表示形式有这些:32’d1、16’hfac8、8’b10101010
// Counter module
always @(posedge clock or posedge reset) begin
// On reset, clear counter
if(reset) begin
counter <= 32'd0;
end
// counter less than max value, then add one
else if(counter < 32'd50000000) begin
counter <= counter + 32'd1;
end
// counter reach max value, clear counter
else begin
counter <= 32'd0;
end
end
到这里就可以理解剩下的代码了,在clock上升沿或者reset上升沿触发此模块,在reset复位信号为高的情况下清空计数器,否则看计数器目前数目,如果小于设置的最大值50M,则加一计数,在达到最大值50M的情况下清零开始下一轮计数。
// Output module
always @(posedge clock or posedge reset) begin
// On reset, clear led output
if(reset) begin
led <= 1'b0;
end
// counter less than half of max value, the led output to high
else if(counter < 32'd25000000) begin
led <= 1'b1;
end
// counter higher than half of max value, the led output to low
else begin
led <= 1'b0;
end
end
同样在clock上升沿或者reset上升沿触发此模块,在reset复位信号为高的情况下led输出为0,否则看计数器目前数目,如果小于一半25M则led输出为高点亮led,否则led输出为低熄灭led。
有一点需要注意,一个寄存器reg的赋值只能在同一个always模块里面进行,虽然仿真时候不会报错,但Vivado编译器在最后会报错,所以注意对一个信号的处理不能放在多个always模块,但是可以读取别的模块的信号。
仿真testbench
仿真也叫simulation,就是模拟一遍这个代码实际的效果,效果以时序图的形式表现出。仿真的重要性非常高,因为硬件编译最终生成可以烧写进FPGA的文件耗时非常长,一个占最新FPGA大概60%资源的project要编译长达4小时以上;并且从硬件image跑的时候从中读取某一个信号的值是个很麻烦的事,虽说现在有Vivado的ChipScope和Altera的SignalTap一类内嵌逻辑分析器,但你不能实时增加你想看的信号(除非增加后再编译一次),而你在看到真的错误位置之前你根本不清楚到底该看哪些信号。这时候仿真就是最有效的debug方式。
当然仿真也有自己的弱点,它一般不会仿真超过1秒的虚拟时间,因为芯片一般以纳秒ns为单位,有时候是皮秒ps甚至飞秒fs,一秒是至少109个单位的逻辑,这样大量的计算肯定不是把image编译好以后直接开跑这样。在一些特定情况下,编译一个新的image并测试有时候比仿真有效,这些都需要在熟悉之后自己权衡定夺。教程里的小代码就直接仿真吧。
仿真的方式是写一段testbench测试代码,用偏软件的形式给你的代码输入它想要的信号,然后调用你的代码,读取你的module的输出以及内部寄存器信号,看一切的逻辑是否是自己想要的。下面是针对blink代码的testbench:
tb_blink.v
`timescale 1ns/1ns
module tb_blink;
reg clock;
reg reset;
wire led;
initial begin
clock = 1'b0;
reset = 1'b0;
// Reset for 1us
#100
reset = 1'b1;
#1000
reset = 1'b0;
end
// Generate 50MHz clock signal
always #10 clock <= ~clock;
blink blink(
.clock(clock),
.reset(reset),
.led(led)
);
endmodule
详解
这里出现了不少新东西,一样一样分解:
`timescale 1ns/1ns
这是刚才blink.v里面不曾出现过的,'timescale是时间尺度的意思,后面1ns/1ns的第一个1ns是时间单位,后面一个1ns是时间精度。
testbench中可以精确控制延迟时间,在后面出现的"#(number)"就是延迟(number)个时间单位,在时间单位是1ns的情况下就意味着延迟(number)ns;而精度的意思是模拟最小单位只到1ns,如果你想看0.5ns内的信号变化呢?对不起做不到,除非你设置成1ns/100ps之类的。
这个timescale的配置意味着在这个testbench中,我只考虑1ns整数倍的延迟时间,#10.5之类的就不考虑了,通通用整数。
module tb_blink;
endmodule
和前面说的一样,这是一个空白module模块的定义,但是testbench是一个囊括仿真对象的类似包裹一样的存在,本身不需要和外界交换信号,它自己来产生想要的信号,所以一般不会定义输入输出端口。
reg clock;
reg reset;
wire led;
testbench中信号的定义,reg是给你的仿真对象作输入用,而wire是用来接到仿真对象的输出用。
initial begin
clock = 1'b0;
reset = 1'b0;
// Reset for 1us
#100
reset = 1'b1;
#1000
reset = 1'b0;
end
initial模块在时钟为0的时候开始执行,直到其执行完后不再调用,是testbench常用手段,但在FPGA中没有对应的初始化手段,一般就靠reset复位信号。虽然综合compiler不会对initial报错,但是实际image中没有任何效果。
在FPGA中信号除了一般的1(高)和0(低),还有两种状态,分别是X(未知态)和Z(高阻态)。在仿真中,未知态出现在未定义的初始状态和冲突的信号中,例如wire同时连接到了两个分别为1和0的寄存器输出上,waveform中显示为红色;高阻态出现在没有连接任何信号的wire信号上,waveform中显示为蓝色。高阻态由于没有接信号,会一直为高阻态,未知态和其他信号运算还会是未知态,因此testbench中所有输入信号尽可能都会初始化。
在这段初始化中时钟和复位信号初始化为0,之后将复位信号拉高1us后再拉低,这样完成复位流程。
// Generate 50MHz clock signal
always #10 clock <= ~clock;
由于testbench不会被编译综合成FPGA用的image,所以这里的always模块不一样,没有触发条件,每10ns时钟信号就会取反,周期为20ns,也就是50MHz。
blink blink(
.clock(clock),
.reset(reset),
.led(led)
);
这是Verilog调用其他模块的方法,第一个blink表示调用的模块是blink,第二个blink是在这个testbench中给这个模块取名为blink,可以改成其他的。符号.之后写的是端口名称,括号中写的是连接到这个端口的信号名称,括号中可以不写任何信号,等同于悬空不接信号,端口的顺序可以和定义的顺序不同,不调用的端口也等同于不接信号。
ModelSim仿真流程
在ModelSim的transcript中可以输入指令来操作,也可以写成sim.do指令来调用。
vlib work
vlog -work ./work ./blink.v ./tb_blink.v
vsim -voptargs=+acc work.tb_blink
add wave -noupdate /tb_blink/blink/clock
add wave -noupdate /tb_blink/blink/counter
add wave -noupdate /tb_blink/blink/led
add wave -noupdate /tb_blink/blink/reset
run 1ms
在ModelSim中输入do sim.do即相当于一条一条输入上述指令,详细指令的含义可以参考
ModelSim command
cd <your path>
首先需要转到存有所有源文件的根目录,这条命令需要自己手动输入
vlib work
创建名叫work的库,所有编译后的文件需要储存在一个库中,在不指定库名的情况下,ModelSim会把编译好的文件存在work库中
vlog ./blink.v ./tb_blink.v
编译当前路径中的blink.v和tb_blink.v两个文件,默认保存至work库
vsim work.tb_blink
以模块tb_blink为testbench,开启simulation
add wave -noupdate /tb_blink/blink/clock
add wave -noupdate /tb_blink/blink/counter
add wave -noupdate /tb_blink/blink/led
add wave -noupdate /tb_blink/blink/reset
将这四个信号加入到waveform中方便观察时序图
run 2ms
开启simulation,设定时间为1毫秒
有一个问题未处理,即我们的目标是1Hz的led输出,但我们只模拟了2毫秒。模拟1秒太长,我们的做法是先将目标降低到1kHz的led输出,即将blink.v中的最大值从50M改成50k,在模拟完成后再改回去。
blink.v
// Verilog code for blink project
module blink(
input clock,
input reset,
output reg led
);
reg [31:0] counter;
// Counter module
always @(posedge clock or posedge reset) begin
// On reset, clear counter
if(reset) begin
counter <= 32'd0;
end
// counter less than max value, then add one
else if(counter < 32'd50000/*000*/) begin
counter <= counter + 32'd1;
end
// counter reach max value, clear counter
else begin
counter <= 32'd0;
end
end
// Output module
always @(posedge clock or posedge reset) begin
// On reset, clear led output
if(reset) begin
led <= 1'b0;
end
// counter less than half of max value, the led output to high
else if(counter < 32'd25000/*000*/) begin
led <= 1'b1;
end
// counter higher than half of max value, the led output to low
else begin
led <= 1'b0;
end
end
endmodule
至此应已可以看到如下的时序图,可以看到已经达到了1kHz的led输出目标,可以将其改回初始的1Hz版本。
既然逻辑已经正确了,接下来就可以到vivado中编译一个真正的image了