基于FPGA实现两位十进制加、减、乘、除计算器
一、项目背景
在寒假的时候,经过朋友推荐,了解到了硬禾学堂,电子森林这一个平台以及本次的寒假练活动。觉得这是一次非常好的学习FPGA的机会,就参加了。因为之前一直没有怎么上手过FPGA,于是选择了两颗星难度的计算器作为本次活动的寒假练项目。
二、项目需求
使用小脚丫STEP BaseBoard V4.0搭配Lattice MXO2小脚丫FPGA核心板实现一个两位十进制数加、减、乘、除运算的计算器,运算数和运算符(加、减、乘、除)由按键来控制。计算器的按键布局以及使用到的FPGA开发板布局如下:
三、需求分析
本次任务需要实现一个计算器,其中运算数和计算结果通过8个八段数码管显示,每个运算数使用两个数码管显示,左侧显示十位数,右侧显示个位数。当输入两位十进制数时,最高位先在右侧显示,然后其跳变到左侧的数码管上,低位在刚才高位占据的数码管上显示。
1.模块需求
分析上述需求后,我们可以知道,想要实现本次项目,我们需要使用到的硬件模块有:4x4矩阵键盘,数码管。代码部分应该包括的模块有顶层模块、数码管驱动模块、计算模块、矩阵按键模块、按键编码模块以及十进制转二进制BCD码模块。
2.设计思路
(1)根据计算器的布局,按键分为数字键和符号键,默认的4x4矩阵键盘只有哪个键被按下或者没被按下的状态,因此需要使用一个变量存储键盘按下的位置,并且通过译码得到要求的数字或符号。
(2)根据计算器的计算原理,需要先按下操作数,然后按下操作符后继续按下操作数,最后按下等于键完成一次完整的计算,考虑使用状态机来控制运算和操作数输入。
(3)由于运算的结果是十进制的二进制数,而显示的画面是每一个数码管显示四位二进制数,所以需要十进制转二进制BCD模块。因此就需要一个寄存器存储计算所得到的值,通过转码,最后将结果显示在数码管上。
四、功能实现
由上述的需求分析我们得出,需要实现的功能模块有:
(1)矩阵按键模块
(2)按键编码模块
(3)数码管显示模块
(4)计算模块
(5)BCD转码模块
1.矩阵按键模块
STEP BaseBoard V4.0底板上的4x4矩阵键盘的电路图如下所示:
根据原理图我们可以看到在这个硬件模块中有4根行线(ROW1、ROW2、ROW3、ROW4)和4根列线(COL1、COL2、COL3、COL4),同时通过上拉电阻连接到VDD3.3V(高电平)上。对于这个矩阵键盘,行线作为输入,由FPGA控制拉高或者拉低,列线作为输出,由行线的状态以及按键的状态决定,输出到FPGA。
所以我们设计的思路就是四位行线信号在[1110]、[1101]、[1011]、[0111]之间来回扫描,四位列线信号由按键来控制,当所处的行信号为0的时候,按下第几列的按键的时候,列信号的第几位就变为0,其余全部为1(因此用低电平表示按键按下)。
举个例子:
当某一时刻,FPGA控制的4根行线分别为:ROW1=0、ROW2=1、ROW3=1、ROW4=1时(表示按键扫描到第一行,只有按下第一行的按键才有用),对于第一行的按键K1、K2、K3、K4,按下时对应的COL信号就置为0,其他的就置为1,比如我们按下K1按键,代表按下的是第一列的按键。我们现在的COL输出情况就为:COL1=0、COL2=1、COL3=1、COL4=1,而如果按下了K5-K16中的任何一个按键,COL的每一位都等于1。
上述示例表示一行的情况,我们一共有四行,按照扫描的方式,就是分成四个时刻,我们可以使用状态机的四个状态表示,由于按键按下会产生抖动,抖动时间大概在10ms,所以我们取按键的采样周期为20ms,因此对应四个状态的时间就是20ms,每5ms进行一次状态转换。这样就得到了我们最后的按键输出。
矩阵按键主要代码如下:
状态机实现
always@(posedge clk_200hz or negedge rst_n) begin
if(!rst_n) begin
c_state <= STATE0;
row <= 4'b1110;
end else begin
case(c_state)
//状态c_state跳转及对应状态下矩阵按键的row输出
STATE0: begin c_state <= STATE1; row <= 4'b1101; end
STATE1: begin c_state <= STATE2; row <= 4'b1011; end
STATE2: begin c_state <= STATE3; row <= 4'b0111; end
STATE3: begin c_state <= STATE0; row <= 4'b1110; end
default:begin c_state <= STATE0; row <= 4'b1110; end
endcase
end
end
键盘采样判定
//因为每个状态中单行有效,通过对列接口的电平状态采样得到对应4个按键的状态,依次循环
always@(negedge clk_200hz or negedge rst_n) begin
if(!rst_n) begin
key_out <= 16'hffff; key_r <= 16'hffff; key <= 16'hffff;
end else begin
case(c_state)
//采集当前状态的列数据赋值给对应的寄存器位
//对键盘采样数据进行判定,连续两次采样低电平判定为按键按下
STATE0: begin key_out[ 3: 0] <= key_r[ 3: 0]|key[ 3: 0]; key_r[ 3: 0] <= key[ 3: 0]; key[ 3: 0] <= col; end
STATE1: begin key_out[ 7: 4] <= key_r[ 7: 4]|key[ 7: 4]; key_r[ 7: 4] <= key[ 7: 4]; key[ 7: 4] <= col; end
STATE2: begin key_out[11: 8] <= key_r[11: 8]|key[11: 8]; key_r[11: 8] <= key[11: 8]; key[11: 8] <= col; end
STATE3: begin key_out[15:12] <= key_r[15:12]|key[15:12]; key_r[15:12] <= key[15:12]; key[15:12] <= col; end
default:begin key_out <= 16'hffff; key_r <= 16'hffff; key <= 16'hffff; end
endcase
end
end
下降沿检测
always @ ( posedge clk or negedge rst_n )
if (!rst_n) key_out_r <= 16'hffff;
else key_out_r <= key_out; //将前一刻的值延迟锁存
assign key_pulse= key_out_r & ( ~key_out); //通过前后两个时刻的值判断
endmodule
2.矩阵按键编码模块
在上面的按键模块中,我们得到了key_pulse输出,在上个模块我们知道了key_pulse是16位的信号,不方便我们直观的判断按下的是哪一个按键,因此我们需要对此进行编码,使得其可以符合我们计算器的布局。编码模块如下:
always@(posedge clk or negedge rst_n) begin
if(!rst_n) begin
key_data <= 4'h0;
end else begin
case(key_pulse) //key_pulse脉宽等于clk_in的周期
16'h0001: begin key_data <= 4'h0; en <= 1'b1; end //0
16'h0010: begin key_data <= 4'h1; en <= 1'b1; end //1
16'h0020: begin key_data <= 4'h2; en <= 1'b1; end //2
16'h0040: begin key_data <= 4'h3; en <= 1'b1; end //3
16'h0100: begin key_data <= 4'h4; en <= 1'b1; end //4
16'h0200: begin key_data <= 4'h5; en <= 1'b1; end //5
16'h0400: begin key_data <= 4'h6; en <= 1'b1; end //6
16'h1000: begin key_data <= 4'h7; en <= 1'b1; end //7
16'h2000: begin key_data <= 4'h8; en <= 1'b1; end //8
16'h4000: begin key_data <= 4'h9; en <= 1'b1; end //9
16'h0008: begin key_data <= 4'ha; en <= 1'b1; end //+
16'h0080: begin key_data <= 4'hb; en <= 1'b1; end //-
16'h0800: begin key_data <= 4'hc; en <= 1'b1; end //×
16'h8000: begin key_data <= 4'hd; en <= 1'b1; end //÷
16'h0004: begin key_data <= 4'he; en <= 1'b1; end //=
16'h0002: begin key_data <= 4'hf; en <= 1'b1; end //.
default: begin
key_data <= key_data; en = 1'b0;//无按键按下时保持
end
endcase
end
end
3.数码管显示模块
本次所使用的开发板上有底板和核心板的两种数码管,但是驱动方式不同:核心板的数码管为独立显示,底板的数码管为扫描显示。
独立显示相对简单,但是控制每一个数码管需要至少8个I/O接口,如果是8个数码管,就需要8*8=64个I/O接口,浪费大量的FPGA资源。而扫描显示是将每个数码管的同一段选信号连接在一起,通过位选信号控制数码管的显示,这样控制8个数码管仅需8+8=16个根信号线,相比独立显示,节省了四倍!而在我们的STEP BaseBoard V4.0中,通过74hc595芯片作为驱动,可以控制3路串行输入控制8路并行输出,而且可以级联使用,也就是说我们可以使用2个74hc595芯片实现三路串行输入控制16路输出,这就使得我们在仅仅有3个输入信号的情况下控制了8个数码管!STEP BaseBoard V4.0中的74hc595及数码管的原理图如下:
如图所示,我们有三个输入信号,分别为DIN(SER)、SCK(SCK)、RCK(RCK)。他们的作用分别是:
DIN:用以传输数据,每次串行传输一位信号。
SCK:SCK每个上升沿到来时,都会将DIN的数据采样到8位寄存器中。
RCK:RCK每个上升沿到来时,8位寄存器的数据被锁存到8位锁存器中,同时QA~QH管脚刷新输出。
在这里我们使用的两个74hc595级联,级联的方式就是将一个595的QH`(图上的第9个引脚)连接到另一个595的DIN引脚。QH`引脚的作用是当寄存器中的数据多余8位时,会把已有的数据移位出去,也就是送到第二个595的DIN引脚,实现级联。
这样我们的数码管的驱动问题就解决了,接下来解决数码管的显示问题。由于数码管的段选部分是和独立显示的一样,所以字库也是和独立数码管一样的,重点是解决显示的时序问题,如何能让数码管同时显示不同的数字?这在逻辑上是无法实现的,因为同一时刻,QA-QH的值是固定的,所以要么只显示一个数码管,要么所有数码管显示同样的值。但是我们可以使用分时扫描的办法,一次只显示一个数码管,扫描得够快,肉眼看不出来,就可以看上去是一直处于显示的效果。比如每一次一个数码管只显示1ms,也就是每过1ms就切换到下一个数码管显示,我们扫描一个周期只需要8ms,在1s内,每一个数码管都闪烁了125次,我们肉眼是很难看出来的,如果再继续增加频率,就更加看不出来了。
接下来,就是595数据传输的问题,我们在上面已经解决了显示的问题,这里的传输只需要在扫描一个数码管的时间内传输16位数据,因为是串行传输,所以SCK的时钟频率应该至少是数码管扫描频率的16倍。
最后,我们设计一个状态机,将数码管扫描的程序和74HC595串行驱动程序分别做成两个状态MAIN和WRITE,控制数码管扫描程序每次产生一组控制数据,执行一次74HC595串行通信,就可以完成我们数码管模块电路的驱动设计了
数码管扫描显示主要代码如下:
//计数器对系统时钟信号进行计数
reg [9:0] cnt = 1'b0;
always@(posedge clk or negedge rst_n) begin
if(!rst_n) cnt <= 1'b0;
else if(cnt>=(CNT_40KHz-1)) cnt <= 1'b0;
else cnt <= cnt + 1'b1;
end
//根据计数器计数的周期产生分频的脉冲信号
reg clk_40khz = 1'b0;
always@(posedge clk or negedge rst_n) begin
if(!rst_n) clk_40khz <= 1'b0;
else if(cnt<(CNT_40KHz>>1)) clk_40khz <= 1'b0;
else clk_40khz <= 1'b1;
end
//使用状态机完成数码管的扫描和74HC595时序的实现
reg [15:0] data;
reg [2:0] cnt_main;
reg [5:0] cnt_write;
reg [2:0] state = IDLE;
always@(posedge clk_40khz or negedge rst_n ) begin
if(!rst_n) begin //复位状态下,各寄存器置初值
state <= IDLE;
cnt_main <= 3'd0; cnt_write <= 6'd0;
seg_din <= 1'b0; seg_sck <= LOW; seg_rck <= LOW;
end else begin
case(state)
IDLE:begin //IDLE作为第一个状态,相当于软复位
state <= MAIN;
cnt_main <= 3'd0; cnt_write <= 6'd0;
seg_din <= 1'b0; seg_sck <= LOW; seg_rck <= LOW;
end
MAIN:begin
cnt_main <= cnt_main + 1'b1;
state <= WRITE; //在配置完发给74HC595的数据同时跳转至WRITE状态,完成串行时序
case(cnt_main)
//对8位数码管逐位扫描
//data [15:8]为段选, [7:0]为位选
3'd0: data <= {{dot_en[7],seg[dat_1]},dat_en[7]?8'hfe:8'hff};
3'd1: data <= {{dot_en[6],seg[dat_2]},dat_en[6]?8'hfd:8'hff};
3'd2: data <= {{dot_en[5],seg[dat_3]},dat_en[5]?8'hfb:8'hff};
3'd3: data <= {{dot_en[4],seg[dat_4]},dat_en[4]?8'hf7:8'hff};
3'd4: data <= {{dot_en[3],seg[dat_5]},dat_en[3]?8'hef:8'hff};
3'd5: data <= {{dot_en[2],seg[dat_6]},dat_en[2]?8'hdf:8'hff};
3'd6: data <= {{dot_en[1],seg[dat_7]},dat_en[1]?8'hbf:8'hff};
3'd7: data <= {{dot_en[0],seg[dat_8]},dat_en[0]?8'h7f:8'hff};
default: data <= {8'h00,8'hff};
endcase
end
WRITE:begin
if(cnt_write >= 6'd33) cnt_write <= 1'b0;
else cnt_write <= cnt_write + 1'b1;
case(cnt_write)
//74HC595是串行转并行的芯片,3路输入可产生8路输出,而且可以级联使用
//74HC595的时序实现,参考74HC595的芯片手册
6'd0: begin seg_sck <= LOW; seg_din <= data[15]; end //SCK下降沿时SER更新数据
6'd1: begin seg_sck <= HIGH; end //SCK上升沿时SER数据稳定
6'd2: begin seg_sck <= LOW; seg_din <= data[14]; end
6'd3: begin seg_sck <= HIGH; end
6'd4: begin seg_sck <= LOW; seg_din <= data[13]; end
6'd5: begin seg_sck <= HIGH; end
6'd6: begin seg_sck <= LOW; seg_din <= data[12]; end
6'd7: begin seg_sck <= HIGH; end
6'd8: begin seg_sck <= LOW; seg_din <= data[11]; end
6'd9: begin seg_sck <= HIGH; end
6'd10: begin seg_sck <= LOW; seg_din <= data[10]; end
6'd11: begin seg_sck <= HIGH; end
6'd12: begin seg_sck <= LOW; seg_din <= data[9]; end
6'd13: begin seg_sck <= HIGH; end
6'd14: begin seg_sck <= LOW; seg_din <= data[8]; end
6'd15: begin seg_sck <= HIGH; end
6'd16: begin seg_sck <= LOW; seg_din <= data[7]; end
6'd17: begin seg_sck <= HIGH; end
6'd18: begin seg_sck <= LOW; seg_din <= data[6]; end
6'd19: begin seg_sck <= HIGH; end
6'd20: begin seg_sck <= LOW; seg_din <= data[5]; end
6'd21: begin seg_sck <= HIGH; end
6'd22: begin seg_sck <= LOW; seg_din <= data[4]; end
6'd23: begin seg_sck <= HIGH; end
6'd24: begin seg_sck <= LOW; seg_din <= data[3]; end
6'd25: begin seg_sck <= HIGH; end
6'd26: begin seg_sck <= LOW; seg_din <= data[2]; end
6'd27: begin seg_sck <= HIGH; end
6'd28: begin seg_sck <= LOW; seg_din <= data[1]; end
6'd29: begin seg_sck <= HIGH; end
6'd30: begin seg_sck <= LOW; seg_din <= data[0]; end
6'd31: begin seg_sck <= HIGH; end
6'd32: begin seg_rck <= HIGH; end //当16位数据传送完成后RCK拉高,输出生效
6'd33: begin seg_rck <= LOW; state <= MAIN; end
default: ;
endcase
end
default: state <= IDLE;
endcase
end
end
endmodule
(4)计算模块
在这一部分,我们仍然使用状态机来完成整个计算以及赋值等操作。我们的状态机有两个状态,第一个状态用来对操作数1进行赋值,也就是在计算器刚开始的时候,通过按键输入操作数1的值。有一点值得注意的是,我们不能直接把按键的值拿去运算,比如我们按下了4和5,我们想表达的值是45,但是如果直接拿去算的话,这个值就是0100 0101(十进制的69).所以我们每按下一个按键,存储的值应该是前一时刻的值*10 加上当前按键的值,比如先按4再按5 现在的值就是4*10 +5 = 45(十进制),就可以拿去运算了。
第一个状态
STATE0: begin
if(en == 1'b1) begin
if(key_data<4'ha) begin
result <= result * 10 + key_data;
end
else begin
if(key_data<4'he) begin
opera <= key_data;
c_state <= STATE1;
num <= result;
result <= 16'b0;
remainder<=16'b0;
end
else begin
result <= 16'h0;
remainder<=16'b0;
c_state <= STATE0;
end
end
接下来是状态的转换,当我们按下符号数+,-,*,÷后,就会跳到第二个状态,到第二个状态刚开始和第一个状态类似,通过按键的值来判断是输入第二个操作数还是计算结果或者是改变当前的操作符,如果按下等号就按照操作符的值计算结果。比如在前一状态我们通过按了+进入到这个状态,如果现在按等号就该计算a+b,但是如果按别的操作符,就会将+替换为当前操作符。
最后,因为我们在计算结果后会回到第一个状态,所以我们的结果就相当于第一个状态的操作数,使得我们可以做连续运算而不用重新归零。
第二个状态
STATE1: begin
if(en == 1'b1) begin
if(key_data < 4'ha) begin
result <= result * 10 + key_data;
end
else if(key_data == 4'he) begin
case(opera)
4'ha:begin
result <= num + result;
end
4'hb:begin
if(num>=result) begin
result <= num - result;
signal <= 4'h0;
end else begin
result <= result -num;
signal <= 4'hf;
end
end
4'hc:begin
result <= num * result;
end
4'hd:begin
tempa = num;
tempb = result;
temp_a = {16'h0000,tempa};
temp_b = {tempb,16'h0000};
for(i = 0; i<16; i= i+1) begin
temp_a = {temp_a[30:0],1'b0};
temp_r = {temp_r[14:0],1'b0};
if(temp_a>=temp_b) begin
temp_a=temp_a - temp_b;
temp_r[0] = 1;
end
else begin
temp_a=temp_a;
temp_r[0] =0;
end
end
result = temp_r;
remainder <= temp_a[31:16];
end
default:begin
result <= 16'h0;
remainder <= 16'h0;
end
endcase
c_state <= STATE0;
end
else if(key_data == 4'hf) begin
result <= 16'h0;
c_state <= STATE0;
end else begin
opera <= key_data;
c_state <= STATE1;
end
end
end
其中运算的过程,由于加减乘都可以直接调用实现得到结果,不用特别的麻烦,但是对于除法,verilog只用简单的a/b只能得到结果的整数部分,因此我们写了一个除法器(所以状态2代码比较长)来计算商和余数,从而显示到数码管上。我们的状态机流程图大致如下:(非常非常简略版)
(5)BCD转码模块
最后我们得到的结果是二进制的十位数,无法直接通过数码管显示,跟运算模块里面的输入一样,我们可以理解为输入的时候是编码,现在是译码,就是将十位数的二进制码 转换成8421BCD码,从而可以在数码管上显示出来。比如拿之前的45为例,45的二进制数为0010 1101,如果直接放数码管显示,得到的结果是2D(因为我们数码管是每四位显示一个数码管)。所以我们需要使用一个BCD转码,将十位数转化成二进制的BCD码的形式,就可以通过数码管显示了。
always@(*) begin
temp = 32'b0;
temp[15:0] = binary;
repeat(16) begin
if(temp[31:28]>4)
temp[31:28] = temp[31:28] + 2'b11;
if(temp[27:24]>4)
temp[27:24] = temp[27:24] + 2'b11;
if(temp[23:20]>4)
temp[23:20] = temp[23:20] + 2'b11;
if(temp[19:16]>4)
temp[19:16] = temp[19:16] + 2'b11;
temp[31:1] = temp[30:0];
end
thousand = temp[31:28];
hundred = temp[27:24];
ten = temp[23:20];
one = temp[19:16];
end
五、项目总结
本次项目使用FPGA简易的完成了一个具有两位十进制数加、减、乘、除运算功能的计算器。
各个模块之间的硬件框图如下:
RTL综合图如下:
资源占用如下:
六、心得体会
这次并不是第一次认识FPGA了,很久之前就开始关注FPGA,但是之前只是进行过一些简单的LED点亮,流水灯等项目,碰巧遇到这次寒假练活动,便选了一个较为简单的计算器来进行学习。在完成项目的过程中,虽然很多模块,如矩阵按键模块,74hc595驱动数码管模块都在例程里,但是学习起来依然没有那么容易。最大的一点就是Verilog是一种硬件描述语言,和C语言python不一样。我认为最难设计的点应该就在数码管显示按键,因为有符号的参加,所以要想办法分清两个操作数,我一开始用软件编程的语言,用if else语句进行编写,但是结果总是不尽人意,无法显示第二个操作数。后面尝试了状态机,一切就豁然开朗了。很大程度上改变了我的一些编程思维。还有一个点就是verilog里的阻塞赋值和非阻塞赋值,虽然都是赋值但是有时候区别千差万别,我又一次在一个for循环中用了非阻塞赋值<=,结果就全是0,找了好久没有发现问题,最后将其改为阻塞赋值,才能得到正确结果。其他的还有一个难点就是时序电路,因为会有很多个带时钟的always块,各个信号之间的时序逻辑关系就会很混乱,当时也理了好久,特别是显示数码管的时候。
总之,通过本次活动,我对verilog有了进一步的认识,对FPGA有了更多的上板经验,感谢硬禾学堂给的这次宝贵的机会,在未来的学习中,我会探索使用本次小脚丫Base Board V4.0上板载的更多的外设以及功能模块,继续学习下去,期待下次活动,加油!