按键数量较多时,为了减少 I/O ⼝的占⽤,通常将按键排列成矩阵形式。在矩阵式键盘中,每条⽔平线和垂直线在交叉处不直接连通,⽽是通过一个按键连接。⼋根线就可以控制4*4=16个按键,⽐之直接将端⼝线⽤于键盘多出了⼀倍,⽽且线越多,区别越明显,⽐如多加⼀条线就可以构成20键的键盘。
由此可⻅,在需要的键数⽐较多时,采⽤矩阵法来做键盘是合理的。
1、模块端口
模块相应的端口信号如下表所示。
表1 模块端口信号
信号 | I/O | 位宽 | 含义 |
---|---|---|---|
clk | I | 1 | 系统⼯作时钟,默认50MHz。 |
rst_n | I | 1 | 系统复位信号,低电平有效。 |
key_col | I | 4 | 矩阵键盘列输入信号,默认⾼电平,被按下时,所在列为低电平。 |
key_row | O | 4 | 矩阵键盘⾏输出信号,默认输出低电平,⾏扫描时当前⾏输出低电平,其余⾏输出。 |
key_out | O | 4 | 被按下按键的编号 |
key_vld | O | 1 | 被按下按键的编号有效指⽰信号 |
2、设计思路
下图是4*4矩阵键盘原理图,4条列输入线均被上拉到高电平,所以没有按键按下时,列输入引脚就是高电平。行线KEY_R1~KEY_R4一般被FPGA驱动。4根⾏线和4根列线形成16个相交点。
⾏扫描法(逐⾏(或列)扫描查询法)如下:
1. 判断键盘中有⽆键按下:将全部⾏线KEY_R1~KEY_R4置为低电平,同时检测列线KEY_C1~KEY_C4的状态。只要有⼀列的电平为低,则表⽰键盘中有键被按下,⽽且被按下的按键位于列信号为低电平的列。 若列线全为⾼电平,则键盘中⽆键按下。
2. 判断被按下按键所在的位置:在确认有键按下后,即可进⼊确定具体被按下的按键所在位置的过程。依次将⾏线设置为低电平(即在置某根⾏线为低电平时,其它线为⾼电平),同时检测列线的状态,如果有列线为低电平,且列数与步骤1一致,则表示该被按下的按键就在这一行,即可计算出被按下按键的具体位置。
对于按键和触摸屏等机械设备来说,都存在 “抖动”问题,按键从最初接通到稳定接通要经过数毫秒,其间可能发⽣多次“接通-断开”这样的⽑刺。如果不进⾏处理,会使系统识别到抖动信号⽽进⾏不必要的反应,导致模块功能不正常,为了避免这种现象的产⽣,需要对按键消抖。
软件消抖,检测有按键闭合后执⾏延时程序,抖动时间的⻓短由按键的机械特性决定,⼀般为5ms〜20ms。让前沿抖动消失后再⼀次检测键的状态,如果仍保持闭合状态电平,则确认按下按键操作有效。
本文通过一个状态机实现对矩阵键盘的检测,对应的状态转化图如下图所示,包括4个状态,检测矩阵键盘列状态、检测被按下按键所在行状态、延时状态、等待按键释放状态。
CHK_COL状态:该状态下向矩阵键盘的所有行输出低电平,同时检测列输入信号。当检测到列输入不全为高电平时,计数器shake_cnt对时钟进行计数,当计数到20ms时,表示按键被按下,将低电平所在列寄存,表示被按下按键就在这一列,然后状态机跳转到CHK_ROW检测行。如果在中途发现列输入全为高电平,则计数器清零,表示之前检测的是按键抖动,继续检测。
CHK_ROW状态:该状态需要检测被按下按键所在行,检测方式就是逐行扫描,所以需要两个计数器,一个计数器row_cnt用来记录一行扫描的时间,目前一行扫面时间设置为16个时钟周期。另一个计数器row_index用来记录当前扫描到第几行了,因为该键盘只有四行,所以row_index最大值为3。
计数器row_index对应行输出低电平,其余行输出高电平,同时检测列信号,如果列信号不全为高,并且为低电平的列与CHK_COL状态下检测到低电平的列一致,表示此时计数器row_index的值就是被按下按键所在行。根据row_index的值和之前记录按键的列就可以计算出被按下按键的位号,然后输出给下游模块。
DELAY状态:就是为了多等待几个时钟周期,让行信号全部驱动为低电平之后,在跳转到等待按键被释放状态,否则会出现错误。
WAIT_END状态:当检测到被按下按键位号后,需要等待被按下的按键释放,之后才能开始下一次检测,不然会重复检测同一个按键。防止按键被按下1次,但是却检测出多次按下的错误。
其余计数器,标志信号这些就不细讲了,也比较简单,直接看代码吧。参考代码如下所示:
module key_scan #(parameter TCLK = 20 ,//系统时钟周期,单位ns。parameter TIME_20MS = 20_000_000 //按键消抖时间,单位为ns。
)( input clk ,//系统时钟信号,默认50MHz。input rst_n ,//系统复位,低电平有效;input [3 : 0] key_col ,//矩阵键盘的列号;output reg [3 : 0] key_row ,//矩阵键盘的行号;output reg [3 : 0] key_out ,//矩阵键盘被按下按键的数值;output reg key_vld //矩阵键盘被按下按键数据输出有效指示信号;
); //自定义参数;localparam CHK_COL = 4'b0001 ;//状态机的列扫描状态;localparam CHK_ROW = 4'b0010 ;//状态机的的行扫描状态;localparam DELAY = 4'b0100 ;//状态机的延时状态;localparam WAIT_END = 4'b1000 ;//状态机的等待状态;localparam TIME_20MS_NUM = TIME_20MS / TCLK ;//计算出TIME_20MS对应的系统时钟个数;localparam TIME_20MS_W = clogb2(TIME_20MS_NUM-1) ;//利用函数计算出TIME_20MS_NUM对应的寄存器位宽;reg [3 : 0] key_col_ff0 ;reg [3 : 0] key_col_ff1 ;reg [1 : 0] key_col_get ;reg [3 : 0] state_c ;reg [TIME_20MS_W - 1 : 0] shake_cnt ;reg [3 : 0] state_n ;reg [1 : 0] row_index ;reg [3 : 0] row_cnt ;wire end_shake_cnt ;wire col2row_start ;wire row2del_start ;wire del2wait_start ;wire wait2col_start ;wire add_row_cnt ;wire end_row_cnt ;wire add_shake_cnt ;wire add_row_index ;wire end_row_index ;//自动计算位宽函数;function integer clogb2(input integer depth);beginif(depth == 0)clogb2 = 1;else if(depth != 0)for(clogb2=0 ; depth>0 ; clogb2=clogb2+1)depth=depth >> 1;endendfunction//将输入的列信号打两拍,降低亚稳态出现的机率。always@(posedge clk)begin{key_col_ff1,key_col_ff0} <= {key_col_ff0,key_col};end//计数器shake_cnt,如果有按键被按下,则key_col_ff1!=4'hf,此时计数器计数。always@(posedge clk or negedge rst_n)beginif(rst_n==0)beginshake_cnt <= 0;endelse if(add_shake_cnt)beginif(end_shake_cnt)//按键被按下20ms时,计数器清零;shake_cnt <= 0;else//否则当按键被按下时,计数器进行计数;shake_cnt <= shake_cnt + 1;endelse begin//没有按键被按下时,计数器清零;shake_cnt <= 0;endendassign add_shake_cnt = (key_col_ff1!=4'hf) && (state_c == CHK_COL);assign end_shake_cnt = add_shake_cnt && shake_cnt == TIME_20MS_NUM-1 ;//当列检查结束时,将被按下按键所在列寄存;always@(posedge clk or negedge rst_n)beginif(rst_n==1'b0)begin//初始为0,没有按键被按下;key_col_get <= 0;endelse if(col2row_start)begin//当状态机从列检查跳转到行检查时,将按键对应列保存;if(key_col_ff1==4'b1110)//最低位为0,则表示第0列按键被按下;key_col_get <= 0;else if(key_col_ff1==4'b1101)//第1位位0,则表示第1列按键被按下;key_col_get <= 1;else if(key_col_ff1==4'b1011)//第2位位0,则表示第2列按键被按下;key_col_get <= 2;else//否则表示第3列按键被按下;key_col_get <= 3;endend//状态机的第一段;always@(posedge clk or negedge rst_n)begin if(rst_n==1'b0)begin state_c <= CHK_COL; end else begin state_c <= state_n; end endalways@(*)begin case(state_c)CHK_COL: begin//检查列触发;if(col2row_start)beginstate_n = CHK_ROW;endelse beginstate_n = CHK_COL;endendCHK_ROW: begin//检查行触发;if(row2del_start)beginstate_n = DELAY;endelse beginstate_n = CHK_ROW;endendDELAY : begin//这个状态的存在是为了等待行扫描结束后,计算结果输出。if(del2wait_start)beginstate_n = WAIT_END;endelse beginstate_n = DELAY;endendWAIT_END: begin//此时四行全部输出低电平,如果按键被按下,没有松开,那么会持续之前的状态,就需要一致等待按键松开;if(wait2col_start)beginstate_n = CHK_COL;endelse beginstate_n = WAIT_END;endenddefault: state_n = CHK_COL;endcaseend//状态机第三段,描述assign col2row_start = (state_c==CHK_COL ) && end_shake_cnt;//检查到有对应列持续20MS被按下。assign row2del_start = (state_c==CHK_ROW ) && end_row_index;//行扫描完成;assign del2wait_start= (state_c==DELAY ) && end_row_cnt;assign wait2col_start= (state_c==WAIT_END) && key_col_ff1==4'hf;//4'hf表示前面的按键已经被松开,状态机重新回到列检测状态。//控制行数据的输出,在检查被按下按键所在行时,进行行循环扫描。//从第一行开始一次拉低,其余行拉高,其余时刻所有行全部拉低。always@(posedge clk or negedge rst_n)beginif(rst_n==1'b0)beginkey_row <= 4'b0;endelse if(state_c==CHK_ROW)begin//行扫描,依次将每行的电平拉低。key_row <= ~(1'b1 << row_index);endelse beginkey_row <= 4'b0;endend//行扫描的计数器,对行进行扫描。//每行扫描持续时间为行计数器row_cnt的计数值,目前为16个时钟周期。//当4行全部扫面完毕时,计数器清零;always@(posedge clk or negedge rst_n)beginif(rst_n==0)beginrow_index <= 0;endelse if(add_row_index) beginif(end_row_index)row_index <= 0;elserow_index <= row_index + 1;endelse if(state_c!=CHK_ROW)beginrow_index <= 0;endendassign add_row_index = state_c==CHK_ROW && end_row_cnt;assign end_row_index = add_row_index && row_index == 4-1 ;//每行扫描持续时间,初始值为0,此处设置每行扫面16个时钟周期;//状态机位于行扫描或者等待状态时进行计数,当计数到最大值16时清零。always@(posedge clk or negedge rst_n)beginif(rst_n==0)beginrow_cnt <= 0;endelse if(add_row_cnt)beginif(end_row_cnt)row_cnt <= 0;elserow_cnt <= row_cnt + 1;endendassign add_row_cnt = state_c==CHK_ROW || state_c==DELAY;assign end_row_cnt = add_row_cnt && row_cnt == 16-1;always@(posedge clk or negedge rst_n)beginif(rst_n==1'b0)beginkey_out <= 0;end//计算被按下按键的数值;else if(state_c==CHK_ROW && end_row_cnt && key_col_ff1[key_col_get]==1'b0)beginkey_out <= {row_index,key_col_get};endend//按键数值有效指示信号,高电平时表示key_out输出的值是有效的。always@(posedge clk or negedge rst_n)beginif(rst_n==1'b0)beginkey_vld <= 1'b0;endelse begin//当没扫描一行,前面暂存的列为低电平的时候,表示这一行,这一列的按键被按下。key_vld <= (state_c==CHK_ROW && end_row_cnt && key_col_ff1[key_col_get]==1'b0);endendendmodule
3、仿真
TestBench写起来有点小麻烦,可能存在一些小错误,本文提供的参考代码如下所示:
`timescale 1 ns/1 ns
module test();localparam CYCLE = 20 ;//系统时钟周期,单位ns,默认20ns;localparam RST_TIME = 10 ;//系统复位持续时间,默认10个系统时钟周期;localparam TIME_20MS = 2_000 ;//按键消抖时间,默认20ms,单位ns,仿真时将时间缩短;reg clk ;//系统时钟,默认100MHz;reg rst_n ;//系统复位,默认低电平有效;reg [3 : 0] key_col ;reg [3 : 0] key_col_r ;reg key_col_sel ;reg [1 : 0] now_row ;//当前行reg [1 : 0] now_col ;//当前列;wire [3 : 0] key_row ;wire [3 : 0] key_out ;wire key_vld ;//例化需要测试的模块;key_scan #(.TCLK ( CYCLE ),.TIME_20MS ( TIME_20MS ))u_key_scan (.clk ( clk ),.rst_n ( rst_n ),.key_col ( key_col ),.key_row ( key_row ),.key_out ( key_out ),.key_vld ( key_vld ));//生成周期为CYCLE数值的系统时钟;initial beginclk = 0;forever #(CYCLE/2) clk = ~clk;end//生成复位信号;initial beginrst_n = 1;key_col = 4'hf;key_col_sel=0;key_col_r = 4'hf;now_col = 0;now_row = 0;#1;rst_n = 0;//开始时复位10个时钟;#(RST_TIME*CYCLE);rst_n = 1;#(20*CYCLE);key_task(0,1);//按下第0行第1列按键;1号按键key_task(1,2);//按下第1行第2列按键;6号按键key_task(3,1);//按下第3行第1列按键;13号按键key_task(2,2);//按下第2行第2列按键;10号按键key_task(0,0);//按下第0行第0列按键,0号按键#(2000*CYCLE);$stop;//停止仿真;end//生成对应按键的信号;task key_task(input [1 : 0] row ,//被按下按键的行号;input [1 : 0] col //被按下按键的列号;);beginnow_col <= col;now_row <= row;key_col_r <= 4'hf;//初始时,没有按键被按下;key_col_sel <= 1'b0;@(posedge clk);key_col_r[col] <= 1'b0;//repeat(20) begin//将信号随机翻转20次,模拟按键按下时的抖动。#(({$random} % (TIME_20MS/(CYCLE+5))) * CYCLE);key_col_r[col] <= ~key_col_r[col];endkey_col_sel <= 1'b1;//列选通信号拉高;repeat(TIME_20MS/7)begin//按键按下的保持时间;@(posedge clk);endkey_col_sel <= 1'b0;//列选通信号拉低;key_col_r <= 4'hf;//按键被释放;repeat(20) begin//将信号随机翻转20次,模拟按键释放时的抖动。#(({$random} % (TIME_20MS/(CYCLE+5))) * CYCLE);key_col_r[col] <= ~key_col_r[col];endrepeat(TIME_20MS)begin//释放按键的保持时间;@(posedge clk);endendendtaskalways@(*)beginif(key_col_sel)begincase (now_col)2'd0 : key_col <= {3'd7,key_row[now_row]};2'd1 : key_col <= {2'd3,key_row[now_row],1'd1};2'd2 : key_col <= {1'd1,key_row[now_row],2'd3};2'd3 : key_col <= {key_row[now_row],3'd7};endcaseendelse beginkey_col <= key_col_r;endendendmodule
仿真结果如下图所示,橙色信号key_row输出全为低电平,当列输入信号key_col_ff1不全为高电平时,消抖计数器shake_cnt就一直对时钟进行计数,没有计数20ms,但是检测到key_col_ff1全为高电平了,那么消抖计数器清零,表示是抖动,图中黄色信号为消抖计数器。
当消抖计数器shake_cnt计数到20ms(为了加快仿真,在TestBench中该数值设置的比较小)时,表示有按键被按下,则此时记下被按下按键所在列,且状态机跳转到CHK_ROW状态,图中紫红色信号分别为状态机的现态state_c与状态机的次态state_n。
下图是检测到按键被按下后,因为key_col_ff1为4’hd=4’b1101,所以第1列为0,表示第1列有按键被按下。此时key_col_get赋值为1,表示被按下的按键位于第1列,用于后文计算被按下按键位号。
然后就是对行进行扫描,确定被按下按键所在行,如下图所示,每一行扫描16个时钟周期,总共扫描4行。
有上图可知,key_vld在扫描第0行时拉高,表示被按下的按键在第0行,将这部分波形放大,得到下图。
key_row信号输出4’he,最低位为0,其余位高电平,对第0行进行扫描。当计数row_cnt计数到最大值时,检测列输入信号key_col_ff1=4‘hd。表示被按下按键就在第0行,根据4*此时row_index的值+ key_col_get = 4 * 0 + 1 = 1。所以检测到被按下的按键位号为1,key_out输出1,且key_vld拉高一个时钟周期,表示1号按键被按下1次。
后续仿真较为简单,就行扫描结束后,状态机跳转到延时状态,等待16个时钟周期后跳转到等待状态,直到所有列均为高电平时跳转到列检测状态,进行下次检测。
TestBench写的有点复杂了,但是使用比较简单,因为我提供了一个任务。直接任务key_task()函数,输入被按下按键所在行和列,注意列和行的取值均为[0,3]闭区间。使用该函数会自动生成一些按键的抖动,更有利于仿真测试。
代码已经贴在文中了,这种模块贴出来的原因也是方便之后自己使用,需要使用直接打开百度复制自己验证过的模块即可。由于我平时写代码都是vscode+modelsim搞定仿真,不会用quartus或者vivado创建工程,所以本文就不存在工程文件了,如果要代码文件可以在公众号后台回复“基于FPGA的矩阵键盘驱动“(不包括引号)即可。
最后说明该模块此处没有上板测试,但是我已经在其余工程中使用过该模块,没有出现任何问题,所以放心使用,也会在后续的设计中以子模块的形式出现,到时候不会在对该模块讲解。