注:本文由不愿透露姓名的
同学撰写。以下为正文。
在上一篇文章中,通过LED灯的实例介绍了时序逻辑的实现,其文章链接如下:
[走近FPGA]之点亮LED
本文的所有实例都使用硬木课堂Xilinx Aritx 7 FPGA板进行上实现,且附有上板演示视频,该开发板的链接如下:
https://link.zhihu.com/?target=https://item.taobao.com/item.htm?id=612427051465
在这篇文章中,将结合之前介绍的模块化设计思想以及数字电路中组合逻辑与时序逻辑的知识,分享三个在FPGA开发板上实现的数码管动态显示的案例。
数码管动态显示原理
在介绍实例之前,我们需要先了解一下数码管动态显示的原理。在之前的文章中,曾介绍过数码管的显示原理,且实例中涉及到了数码管的静态显示,该文章链接如下:
[走近FPGA]之组合逻辑进阶——模块化设计
由于多位数码管的段选信号是连接在一起的,即数码管是共享段码的,那么所有点亮的数码管在同一时刻只能显示相同的图案。这看似大大局限了数码管的显示效果,但其实也有办法让多位的数码管在人眼看来能“同时”显示不同的图案,以获得更多样性的显示效果。其实现方法就是利用人眼的视觉暂留效应,轮流点亮数码管,并且在各位数码管点亮的期间给出对应的段码,只要进行这一过程的速度控制得当,人眼就来不及观察到各个数码管依次点亮,而是能看到多位数码管稳定地显示各自的图案。
在上个链接的文章,即介绍模块化设计的文章中,有一个实例是利用拨码开关手动地控制数码管的位选。而在实现数码管动态显示时,这个轮流选通的过程其实也是类似的,不过该过程需要电路能“自动”完成,且对位选通的时间间隔有一定要求,这一时间间隔既不能过长,否则人眼观察时有闪烁感,也不能过短,否则观察时数码管亮度会比较低。对于这篇文章中演示时使用的FPGA开发板来说,板载数码管共有六位,可以将数码管位选通的时间间隔设置为1ms。
实例一
实例一将分享一个基本的数码管动态显示案例。其实现目标是通过开发板上的三组拨码开关SW3-SW0、SW7-SW4、SW11-SW8分别控制三位数码管进行十六进制显示。根据数码管动态显示的原理和模块化设计的思想,该实例的电路框图设想如下:
实例一电路框图
如上图所示,既然涉及数码管显示,显示译码的部分是必不可少的,该模块由两个译码器组成,分别负责数码管位选译码和段码译码,进行段码译码的译码器还需要通过多路复用器与三组数据输入进行连接。另外,由于板载的晶振时钟频率为50MHz,要实现动态显示原理中介绍的以1ms为时间间隔进行位扫描,需要对时钟进行分频,得到1KHz的时钟,所以电路中需要一个时钟分频的模块。最后,便是实现数码管的轮流选通以实现动态显示,为了能在某位数码管被选通的同时能给出对应该位数码管的段码,需要有一个模块指明当前点亮的是哪一位数码管,在该设计中采用的是计数器来充当这一模块。该计数器以分频得到的时钟作为输入,计数输出既作为位选译码器的输入也作为多路复用器的控制信号输入,这样就可以实现数码管在轮流选通的同时,也得到对应的段码输出。
下面给出了实例一中负责显示译码功能的两个模块的参考代码,实质都是译码器,分别是数码管的位选译码模块和段码译码模块。这两个模块可在涉及数码管显示的工程中复用,复用时如果使用的数码管位数不同,只需要调整位选译码模块端口声明中bit\_disp的位宽以及位选译码部分的代码即可,段码译码模块一般不需要作调整。
module seg_sel_decoder(bit_disp,seg_sel);
input [1:0] bit_disp;
output reg [5:0] seg_sel;
always@(bit_disp)
case(bit_disp)
2'b00 : seg_sel = 6'b111110;
2'b01 : seg_sel = 6'b111101;
2'b10 : seg_sel = 6'b111011;
default : seg_sel = 6'b111111;
endcase
endmodule
module seg_led_decoder(data_disp,seg_led);
input [3:0] data_disp;
output reg [7:0] seg_led;
always@(data_disp)
case(data_disp)
4'h0 : seg_led = 8'h3f;
4'h1 : seg_led = 8'h06;
4'h2 : seg_led = 8'h5b;
4'h3 : seg_led = 8'h4f;
4'h4 : seg_led = 8'h66;
4'h5 : seg_led = 8'h6d;
4'h6 : seg_led = 8'h7d;
4'h7 : seg_led = 8'h07;
4'h8 : seg_led = 8'h7f;
4'h9 : seg_led = 8'h6f;
4'ha : seg_led = 8'h77;
4'hb : seg_led = 8'h7c;
4'hc : seg_led = 8'h39;
4'hd : seg_led = 8'h5e;
4'he : seg_led = 8'h79;
4'hf : seg_led = 8'h71;
default :seg_led = 8'h00;
endcase
endmodule
下面是时钟分频模块的代码,在上一篇文章中也有介绍,这里为了模块复用的方便,使用了parameter语句进行参数化设计,当需要得到其他频率的时钟时,只需要在例化时更改分频计数值的最大值即可(但该值不可以超过2^32,因为模块中cnt寄存器设置的位宽为32位)。
module clock_division(clk_in,divclk);
input clk_in;
output divclk;
parameter DIVCLK_CNTMAX = 24999; //时钟分频计数的最大值
reg [31:0] cnt = 0;
reg divclk_reg = 0;
always@(posedge clk_in) begin
if(cnt == DIVCLK_CNTMAX) begin
cnt <= 0;
divclk_reg <= ~divclk_reg;
end
else
cnt <= cnt + 1;
end
assign divclk = divclk_reg;
endmodule
接下来是计数器模块的参考代码,其功能是在每个输入时钟的上升沿使计数值加1。由于实例一使用了三位数码管,因此计数器设置的最大计数值为2’b10,该计数器模块计数值的循环序列为2’b00、2’b01、2’b10。
module counter(clk,cnt);
input clk;
output reg [1:0] cnt = 0;
always@(posedge clk) begin
if(cnt == 2'b10)
cnt <= 2'b00;
else
cnt <= cnt + 1'b1;
end
endmodule
接着是多路复用器模块的参考代码,结合实例一的框图,需要描述一个三选一的多路复用器。
module Mux(sel,ina,inb,inc,data_out);
input [1:0] sel;
input [3:0] ina,inb,inc;
output reg [3:0] data_out;
always@(*)
case(sel)
2'b00 : data_out = ina;
2'b01 : data_out = inb;
2'b10 : data_out = inc;
default : data_out = 0;
endcase
endmodule
最后是顶层模块,根据设想的框图将各个模块例化即可,我们可以再次回顾一下例化的意义,其实就是用硬件代码的方式把电路各元件之间的连接关系描述出来。如下是顶层模块的参考代码,可以注意一下例化时钟分频模块时进行参数传递的方式,该方法在进行功能仿真时也会用到。
module TOP(clk_50M,ina,inb,inc,seg_sel,seg_led);
input clk_50M;
input [3:0] ina, inb, inc;
output [5:0] seg_sel;
output [7:0] seg_led;
parameter DIVCLK_CNTMAX = 24999; //50M/1K = 5K
wire clk_1K;
//例化时钟分频模块
clock_division #(
.DIVCLK_CNTMAX(DIVCLK_CNTMAX)
)
my_clock(
.clk_in(clk_50M),
.divclk(clk_1K)
);
//例化计数器模块
wire [1:0] bit_disp;
counter counter(
.clk(clk_1K),
.cnt(bit_disp)
);
//例化多路复用器模块
wire [3:0] data_disp;
Mux Mux(
.sel(bit_disp),
.ina(ina),
.inb(inb),
.inc(inc),
.data_out(data_disp)
);
//例化数码管位选译码模块
seg_sel_decoder seg_sel_decoder(
.bit_disp(bit_disp),
.seg_sel(seg_sel)
);
//例化数码管段码译码模块
seg_led_decoder seg_led_decoder(
.data_disp(data_disp),
.seg_led(seg_led)
);
endmodule
下面进行实例一的功能仿真。上一篇文章中也提到过,由于实例中包含时钟分频的部分,如果不修改时钟分频的参数,仿真需要的时间将会过长,而且得到的仿真波形也不利于观察,所以可以在编写testbench时修改时钟分频计数最大值,更快地完成功能仿真,而且同样能观察到时钟分频模块是否正常工作,只是得到的时钟频率不同。实例一的testbench参考代码如下,例化时将分频计数的最大值改为了1,即只对时钟进行四分频,可以加快仿真过程。如下是testbench的参考代码。
module dynamic_disp_instance1_tb;
reg clk;
reg [3:0] ina,inb,inc;
wire [5:0] seg_sel;
wire [7:0] seg_led;
TOP #(
.DIVCLK_CNTMAX(1)
)
UUT(
.clk_50M(clk),
.ina(ina),
.inb(inb),
.inc(inc),
.seg_sel(seg_sel),
.seg_led(seg_led)
);
initial begin
clk = 1'b0; ina = 4'h0; inb = 4'h0; inc = 4'h0;
#200
ina = 4'h1; inb = 4'h2; inc = 4'h3;
#200
ina = 4'h6; inb = 4'h7; inc = 4'h8;
#200
ina = 4'ha; inb = 4'he; inc = 4'hf;
#200
$stop;
end
always #10 clk = ~clk;
endmodule
下面给出了在Modelsim中的部分仿真波形,可以观察到由于testbench中将分频计数最大值的参数改为了1,分频模块对时钟进行了四分频,另外,计数器工作正常,显示译码的结果也是正确的。后续两个实例的仿真也是类似的,都可以通过修改参数缩短仿真所需的时间,在过程块中设置不同的输入组合进行简单的功能测试。由于过于相似,在之后的两个实例中就不再重复这一过程,希望读者能自己尝试着编写testbench,养成上板验证前进行仿真的习惯。
实例一仿真波形
仿真完成后在Vivado中进行综合、管脚约束、布局布线并生成比特流文件,便可以上板观察现象。下面给出了本例的管脚约束以及演示视频。
实例一管脚约束
实例一演示视频
实例二
在上一例中,使用了FPGA开发板上的所有拨码开关却只够控制三位数码管进行动态显示,而在本例中将分享一个只需少部分拨码开关就可控制六位数码管动态显示的案例。实例二的实现目标是通过一组拨码开关来选定某一位数码管,再通过另一组四位的拨码开关指定该数码管显示的位数,仅用两组拨码开关实现对六位数码管动态显示的控制。根据这一实现目标,给出了如下的电路框图构想。
实例二电路框图
上图中的许多模块与实例一致,因为涉及数码管的动态显示,所以仍然需要显示译码、时钟分频、多路复用器和计数器模块。为了实现能对各个指定数码管的显示数据进行互不干扰的输入和寄存,本例的参考设计是设置六个寄存器来寄存各位数码管需要显示的数据(指图中的SW6-SW3对应的四位二进制输入),并使用译码器将控制输入SW2-SW0进行译码并得到寄存器的使能信号。事实上,在很多应用中译码器都被用于产生使能信号,显示译码中的位选译码部分也可以看作是使用译码器来产生数码管的位使能信号。
根据上面的分析,该例的许多功能模块其实与实例一相似,包括时钟分频模块、计数器模块、多路复用器模块以及数码管的位选译码模块和段码译码模块。其中时钟分频模块和数码管段码译码模块无需做任何改动,计数器模块和数码管位选译码模块由于本例使用的数码管数量发生了改变需要进行一些调整。对于计数器模块,由于数码管数量增加至六位,所以用于指明当前点亮哪一位数码管的计数值位宽拓展到3位,最大计数值设置为3’b101。参考代码如下所示:
module counter(clk,cnt);
input clk;
output reg [2:0] cnt = 0;
always@(posedge clk) begin
if(cnt == 3'b101)
cnt <= 3'b000;
else
cnt <= cnt + 1'b1;
end
endmodule
接着调整位选译码模块的代码,由于使用的数码管增加至了六位,需要将端口声明中bit\_sel的位宽改为三位,并修改位选译码部分的代码,该模块修改后的参考代码如下:
module seg_sel_decoder(bit_disp,seg_sel);
input [2:0] bit_disp;
output reg [5:0] seg_sel;
always@(bit_disp)
case(bit_disp)
3'b000 : seg_sel = 6'b111110;
3'b001 : seg_sel = 6'b111101;
3'b010 : seg_sel = 6'b111011;
3'b011 : seg_sel = 6'b110111;
3'b100 : seg_sel = 6'b101111;
3'b101 : seg_sel = 6'b011111;
default : seg_sel = 6'b111111;
endcase
endmodule
而对于多路复用器模块,根据框图可以很容易看出,在本例中需要一个六选一且每路数据位宽都为4bit的多路复用器,下面给出了实例二中多路复用器模块的参考代码。
module Mux(
input [2:0] sel,
input [3:0] ina,
input [3:0] inb,
input [3:0] inc,
input [3:0] ind,
input [3:0] ine,
input [3:0] inf,
output reg [3:0] data_out
);
always@(*)
case(sel)
3'b000 : data_out = ina;
3'b001 : data_out = inb;
3'b010 : data_out = inc;
3'b011 : data_out = ind;
3'b100 : data_out = ine;
3'b101 : data_out = inf;
default : data_out = 0;
endcase
endmodule
然后设计实例二中的译码器模块,该模块用于产生后续六个寄存器的使能信号,其参考代码如下:
module decoder(code,data);
input [2:0] code;
output reg [5:0] data;
always@(code)
case(code)
3'b001 : data = 6'b000001;
3'b010 : data = 6'b000010;
3'b011 : data = 6'b000100;
3'b100 : data = 6'b001000;
3'b101 : data = 6'b010000;
3'b110 : data = 6'b100000;
default : data = 6'b000000;
endcase
endmodule
接下来是对寄存器模块的描述,根据框图中的构想,本例中使用的寄存器需要有使能端,其参考代码如下:
module register_4bit(clk,en,data_in,data_out);
input clk;
input en;
input [3:0] data_in;
output reg [3:0] data_out;
always@(posedge clk)
if(en)
data_out <= data_in;
endmodule
最后是顶层模块的描述,需要根据设计时绘制的框图正确地连接各个模块,下面给出了实例二顶层模块的参考代码:
module TOP(clk_50M,bit_sel,data_in,seg_sel,seg_led);
input clk_50M;
input [2:0] bit_sel;
input [3:0] data_in;
output [5:0] seg_sel;
output [7:0] seg_led;
parameter DIVCLK_CNTMAX = 24999; //50M/1K = 5K
wire clk_1K;
//例化时钟分频模块
clock_division #(
.DIVCLK_CNTMAX(DIVCLK_CNTMAX)
)
my_clock(
.clk_in(clk_50M),
.divclk(clk_1K)
);
//例化计数器模块
wire [2:0] bit_disp;
counter counter(
.clk(clk_1K),
.cnt(bit_disp)
);
//译码器模块
wire [5:0] reg_en;
decoder decoder(
.code(bit_sel),
.data(reg_en)
);
//例化六个寄存器
wire [3:0] reg_0,reg_1,reg_2,reg_3,reg_4,reg_5;
register_4bit register_0(
.clk(clk_50M),
.data_in(data_in),
.en(reg_en[0]),
.data_out(reg_0)
);
register_4bit register_1(
.clk(clk_50M),
.data_in(data_in),
.en(reg_en[1]),
.data_out(reg_1)
);
register_4bit register_2(
.clk(clk_50M),
.data_in(data_in),
.en(reg_en[2]),
.data_out(reg_2)
);
register_4bit register_3(
.clk(clk_50M),
.data_in(data_in),
.en(reg_en[3]),
.data_out(reg_3)
);
register_4bit register_4(
.clk(clk_50M),
.data_in(data_in),
.en(reg_en[4]),
.data_out(reg_4)
);
register_4bit register_5(
.clk(clk_50M),
.data_in(data_in),
.en(reg_en[5]),
.data_out(reg_5)
);
//例化多路复用器模块
wire [3:0] data_disp;
Mux Mux(
.sel(bit_disp),
.ina(reg_0),
.inb(reg_1),
.inc(reg_2),
.ind(reg_3),
.ine(reg_4),
.inf(reg_5),
.data_out(data_disp)
);
//例化数码管位选译码模块
seg_sel_decoder seg_sel_decoder(
.bit_disp(bit_disp),
.seg_sel(seg_sel)
);
//例化数码管段码译码模块
seg_led_decoder seg_led_decoder(
.data_disp(data_disp),
.seg_led(seg_led)
);
endmodule
将顶层模块和各个功能模块加入Vivado建立的工程中,并按步骤进行至上板观察,通过上板验证会发现这样的设计其实是有缺陷的。下面给出了上板的演示视频,视频中试图让六位数码管分别显示1、2、3、4、5、6。
实例二改进前演示视频
从视频可以看到,想要对不同位的数码管分别设置要显示的数据时,数码管的显示会乱跳,这时因为利用拨码开关进行输入时很多时候不能一步到位得到想要的输入。例如在设置好第三位数码管的显示数据后,如果想要接着设置第四位数码管的显示数据,理想状态下,只需要把控制输入SW2-SW0从3’b011改为3’b100,再修改数据输入即可,但是实际情况下改变控制输入就至少需要三步,即拉高SW2、拉低SW1以及拉低SW0,根据顺序的不同这中间可能会产生3’b001、3’b010等输入组合,这时可能就会导致本来已经设置好的数码管其对应的寄存器又存入了新的输入数据,反应到现象上就是数码管本来设置好的显示数据又发生了变化。为了改进这一点,我们可以对RTL代码进行修改,解决办法是借助使能信号,让译码器仅在需要的时候才对控制输入进行译码。
下面给出了对译码器模块的修改参考,增添了对使能信号功能的描述,当使能信号en拉低时,该译码器产生给六个寄存器的寄存器使能信号全部拉低。
module decoder(code,en,data);
input [2:0] code;
input en;
output reg [5:0] data;
always@(code)
if(~en) data = 6'b000000;
else case(code)
3'b001 : data = 6'b000001;
3'b010 : data = 6'b000010;
3'b011 : data = 6'b000100;
3'b100 : data = 6'b001000;
3'b101 : data = 6'b010000;
3'b110 : data = 6'b100000;
default : data = 6'b000000;
endcase
endmodule
译码器修改好后还需要在顶层模块中添加译码器使能信号的端口声明,并调整例化部分的代码,这里就不赘述了。下面给出了改进后的上板演示视频并附上了实例二的管脚约束表格,可以观察到借助了新增的使能输入,成功地让六位数码管分别显示了1-6的整数。
实例二管脚约束
实例二改进后演示视频
实例三
下面将介绍本文的最后一个实例,该例的实现目标为通过拨码开关输入BCD码设置起始时间(单位为秒),由数码管显示倒计时的过程。通过前面两个实例,相信读者对数码管进行动态显示所必需的这部分电路已经很熟悉了,如下图给出的电路框图所示,显示译码、时钟分频、计数器以及多路复用器这部分电路与实例一和实例二一致,需要关心的是如何能得到正确的数据来进行显示译码。根据实例三想要实现的主要功能倒计时,可以很容易联想到计数器,倒计时本身就是一种向下计数的过程,所以可以通过设计一个向下计数器来实现。由于倒计时一般是十进制显示,因此本例需要限制拨码开关的输入为BCD码输入,同样地,上面提到的向下计数器也需要专门设计为BCD码向下计数器。通过两个BCD码计数器进行级联,就可以实现起始数据为两位数的向下计数。
实例三电路框图
通过上面的分析,电路需要的大部分模块都在实例一二中设计过,唯一需要关注的是BCD码向下计数器的设计。根据实例三的实现目标,要能实现通过拨码开关设置倒计时起始时间的功能,BCD码向下计数器需要设置有置数的功能,另外为了进行级联,还需要设置用于级联的输入输出信号。BCD码向下计数器的参考设计如下图所示。
BCD码向下计数器
下面给出了BCD码向下计数器的参考代码,计数值为4位的BCD码,在每个时钟上升沿,若借位输入有效则进行向下计数,当向下计数至0时若借位输入仍有效,则拉高借位输出。该计数器还带有异步复位和同步置数的功能。
module BCD_downcounter(
input clk, //时钟输入
input rst, //异步复位输入
input bin, //借位输入
input preset, //同步预置数输入
input [3:0] BCD_i, //BCD码计数输入
output [3:0] BCD_o, //BCD码计数输出
output bout //借位输出
);
reg [3:0] cnt;
always@(posedge clk or posedge rst) begin
if(rst)
cnt <= 0;
else if(preset)
cnt <= BCD_i;
else if(bin) begin
if(cnt == 4'd0)
cnt <= 4'd9;
else
cnt <= cnt - 1'b1;
end
end
assign BCD_o = cnt;
assign bout = bin && (cnt == 4'd0); //向下计数至0时同步输出bout信号
endmodule
其他功能模块的参考代码已在实例一中给出,由于本例只是用两位数码管,因此其他功能模块需要调整部分代码,如何调整已在实例二中详细介绍过,这里就不再赘述了。
如下给出了顶层模块的参考代码,编写时同样要注意各模块例化时的端口连接。本例中需要例化两个时钟分频模块,一个产生1KHz(周期为1ms)的时钟用于数码管依次选通位选信号,另一个产生1Hz(周期为1s)的时钟作为BCD码向下计数器的输入时钟以实现倒计时的功能。BCD码向下计数器模块的例化和级联也需要注意,级联的方法是将进行个位计数器的借位输出连接到进行十位计数器的借位输入。另外参考代码中将个位计数器的借位输入与两个计数器BCD码输出的逻辑或相连接,目的是在倒计时至0后停止计数,若想要进行循坏计数,可以在例化个位的BCD码向下计数器时将借位输入bin直接拉高。
module TOP(clk_50M,units,tens,rst,preset,seg_sel,seg_led);
input clk_50M;
input [3:0] units,tens;
input rst;
input preset;
output [5:0] seg_sel;
output [7:0] seg_led;
parameter DIVCLK_CNTMAX_1ms = 24999; //50M/1K = 5K
wire clk_1ms;
parameter DIVCLK_CNTMAX_1s = 24999999; //50M/1 = 50M
wire clk_1s;
//例化时钟分频模块,得到周期为1ms的时钟
clock_division #(
.DIVCLK_CNTMAX(DIVCLK_CNTMAX_1ms)
)
my_clock_0(
.clk_in(clk_50M),
.divclk(clk_1ms)
);
//例化时钟分频模块,得到周期为1s的时钟
clock_division #(
.DIVCLK_CNTMAX(DIVCLK_CNTMAX_1s)
)
my_clock_1(
.clk_in(clk_50M),
.divclk(clk_1s)
);
//例化BCD码向下计数器模块,并进行级联
wire bin;
wire bout_0;
wire bout_1;
wire [3:0] units_disp;
wire [3:0] tens_disp;
assign bin = units_disp || tens_disp;
BCD_downcounter downcounter_0(
.clk(clk_1s),
.rst(rst),
.bin(bin),
.preset(preset),
.BCD_i(units),
.BCD_o(units_disp),
.bout(bout_0)
);
BCD_downcounter downcounter_1(
.clk(clk_1s),
.rst(rst),
.bin(bout_0),
.preset(preset),
.BCD_i(tens),
.BCD_o(tens_disp),
.bout(bout_1)
);
//例化计数器模块
wire bit_disp;
counter counter(
.clk(clk_1ms),
.cnt(bit_disp)
);
//例化多路复用器模块
wire [3:0] data_disp;
Mux Mux(
.sel(bit_disp),
.ina(units_disp),
.inb(tens_disp),
.data_out(data_disp)
);
//例化数码管位选译码模块
seg_sel_decoder seg_sel_decoder(
.bit_disp(bit_disp),
.seg_sel(seg_sel)
);
//例化数码管段码译码模块
seg_led_decoder seg_led_decoder(
.data_disp(data_disp),
.seg_led(seg_led)
);
endmodule
最后给出了实例三的上板演示视频以及具体的管脚约束表格,视频中将起始时间设置为了24s,从视频中可以观察到数码管正确地显示了倒计时的过程。
实例三管脚约束
实例三演示视频
至此,关于数码管动态显示的三个实例就分享完毕了,本期文章中介绍了数码管动态显示的原理,分享的三个实例都采用了模块化设计的思想进行实现,同时还应用到了数字电路中组合逻辑和时序逻辑的知识。感谢您的阅读,下期再见。
END
知乎:https://zhuanlan.zhihu.com/p/195150754
推荐阅读
- [走近FPGA]之开发板介绍篇
- [走近FPGA]之工具篇(上)-Vivado
- [走近FPGA]之工具篇(下)—Modelsim
- [走近FPGA]之组合逻辑基础
- [走近FPGA]之组合逻辑进阶——模块化设计
- [走近FPGA]之点亮LED
更多内容请关注其实我是老莫的网络书场专栏