人生状态机 · 2022年03月16日

[走近FPGA]之矩阵键盘

注:由于新学期较为繁忙,本文由不愿透露姓名的

@Cardia

撰写。以下为正文。

在上一篇文章中,介绍了二进制转十进制电路的实现,其文章链接如下:
[走近FPGA]之二进制转BCD码
本文的所有实例都使用硬木课堂Xilinx Artix 7 FPGA板进行上实现,且附有上板演示视频,该开发板的链接如下:
https://link.zhihu.com/?target=https://item.taobao.com/item.htm?id=612427051465
这篇文章将围绕矩阵键盘,介绍在FPGA上驱动矩阵键盘的原理和一种按键消抖的实现方法,并结合之前介绍的模块化设计思想分享两个与矩阵键盘相关的实例。

矩阵键盘原理

在键盘中按键数量较多时,为了减少I/O口的占用,通常将按键排列成矩阵形式。在矩阵键盘中,行线和列线不直接连通,而是通过一个按键进行连接。如下是本文所使用的FPGA开发板上板载矩阵键盘的原理图。

矩阵键盘电路原理图

从图中可以看到,键盘的列线已经都设置了上拉电阻,在没有按下按键时,键盘列线的输出为高电平。若给键盘的某一行输入低电平,并按下该行的一个按键,那么该按键连接的列线的输出就会被拉低。因此,使用FPGA驱动矩阵键盘时,一般通过输送高/低电平给矩阵键盘的行信号,并读取键盘列信号来判断是否有按键按下。举个例子,按照上图中的管脚名,KEY1~KEY4为矩阵键盘的行信号,KEY5~KEY8为矩阵键盘的列信号,若给KEY1输送低电平,读取键盘列信号KEY5~KEY8的结果为1011,则说明KEY6 这一列和KEY1这一行对应的按键(即第一行从左至右的第二个按键)被按下了。

基于FPGA的矩阵键盘驱动

按键消抖

为了更直观地展示按键消抖的必要性,首先进行一个简单的单个按键测试,按照矩阵键盘的原理,其参考代码如下所示。代码中未进行任何的消抖处理,其希望实现的功能是每次按下按键,LED灯显示的二进制数加一。

module key_test(key_row,key_col,led);
    input key_col;
    output key_row;
    output [7:0] led;
    //给键盘的行信号输出低电平
    assign key_row = 1'b0;
    //每个键盘列信号的下降沿LED灯显示的二进制数加一
    reg [7:0] led_reg = 0;
    always@(negedge key_col) 
            led_reg <= led_reg + 1'b1;
    assign led = led_reg;
endmodule

下面可以看一下上板效果:
image.png
按键测试(未消抖)

从该视频可以看出,有时按下一次按键后,LED灯显示的二进制数并未按预期的增加1,而是增加了2甚至3,这就是按键抖动造成的结果。一般来说,按键为机械弹性开关,当机械触点断开、闭合时,由于机械触点的弹性作用,按键开关在闭合时不会马上稳定地接通,在断开时也不会立刻断开,而是在闭合和断开的瞬间都伴随有一连串的抖动,如下图所示。因此,如果不对按键抖动进行处理,就可能对按键信号造成误判,例如将按下一次按键误判为多次按下按键。

按键抖动示意图

为了消除按键抖动的影响,就需要进行按键消抖。按键消抖有多种实现方法,相关的网络资源也有很多,这里将分享其中一种按键消抖方法。由于按键抖动的时间一般在10-20毫秒,而按键稳定的时间一般为数百毫秒,所以可以设置多个寄存器,延时读取按键的电平信号,再综合判断按键是否按下。

按键消抖示意图

下面给出了按键消抖模块的参考代码。按键消抖模块中设置了三个寄存器构成移位寄存器,每20ms进行一次移位,寄存器key\_reg0寄存的是当前的按键信号电平,寄存器key\_reg1寄存的是20ms前的按键信号电平,而寄存器key\_reg2寄存的是40ms前的按键信号电平。消抖后的按键信号为key\_deb,当key\_deb为1时代表按键按下,key\_deb为0时代表按键未按下,代码中通过assign语句对key\_deb进行赋值。这里判定两种情况下按键被按下,一是三个按键寄存器都为低电平,这显然是按键稳定的情况,第二种是40ms前的按键信号为高电平,而20ms前的按键信号和当前的按键信号为低电平。

module key_filter(clk,rstn,key_in,key_deb);
    input clk;
    input rstn;
    input key_in;
    output key_deb;
    //分频计数
    parameter CNTMAX = 999_999;
    reg [19:0] cnt = 0;
    always@(posedge clk or negedge rstn) begin
        if(~rstn)
            cnt <= 0;
        else if(cnt == CNTMAX)
            cnt <= 0;
        else
            cnt <= cnt + 1'b1;
     end
     //每20ms采样一次按键电平
     reg key_reg0;
     reg key_reg1;
     reg key_reg2; 
     always@(posedge clk or negedge rstn) begin
        if(~rstn) begin
            key_reg0 <= 1'b1;
            key_reg1 <= 1'b1;
            key_reg2 <= 1'b1;
        end
        else if(cnt == CNTMAX) begin
            key_reg0 <= key_in;
            key_reg1 <= key_reg0;
            key_reg2 <= key_reg1;
        end
    end
    assign key_deb = (~key_reg0&~key_reg1& ~key_reg2)|(~key_reg0&~key_reg1&key_reg2);
endmodule

注意上述按键消抖模块的代码中,最后的assign语句使用的全是按位运算符,所以当需要对多个按键进行消抖时,只拓宽key\_in和key\_deb的位宽即可。

接下来我们加入按键消抖模块,并例化之前的按键测试模块,再进行一次上板实验。其顶层参考代码如下所示,注意消抖后的按键信号key\_deb是高电平代表按键按下,低电平代表未按下,所以需要取反接入按键测试模块。

module key_filter_test(clk,key_row,key_col,led);
    input clk;
    input key_col;
    output key_row;
    output [7:0] led;
    //例化按键消抖模块
    wire key_deb;
    key_filter key_filter(
        .clk(clk),
        .rstn(1'b1),
        .key_in(key_col),
        .key_deb(key_deb)
    );
    //例化按键测试模块
    key_test key_test(
        .key_col(~key_deb),
        .key_row(key_row),
        .led(led)
    );
endmodule

下面还给出了该消抖测试的具体管脚约束。

按键消抖测试的管脚约束

加入按键消抖模块后的上板演示视频如下所示。可以看到,经过按键消抖后,每次按下按键,LED灯显示的二进制数都只增加了1,与预期的功能一致,也证明这种按键消抖的方法是可行的。
image.png
按键消抖测试

实例一

下面正式进入矩阵键盘实例的分享。实例一将使用到矩阵键盘的一行共四个按键,将实现一个多功能计数器,矩阵键盘四个按键分别对应预置、清零、向上计数和向下计数,每次按下某个按键,计数器就以对应模式进行工作。另外,由拨码开关作为预置数的输入,数码管进行计数器输出数据的显示。根据以上描述,整个电路按照功能划分可以大致分为三个模块,分别是处理键盘信号的键盘模块,负责多功能计数的BCD计数器模块,以及最后进行结果显示的数码管显示模块。实例一大致的电路功能框图如下所示:

实例一功能框图

首先来看看键盘模块的顶层,该模块负责处理矩阵键盘的按键信号,并输出消抖后的按键信号作为后续计数器模块的模式控制信号。该模块由一个按键消抖模块和一个带使能的4bit寄存器构成。按键消抖后的按键信号会由寄存器模块进行寄存,这里寄存器的设置是为了在保持某种计数功能时不必一直按住按键。可以注意一下顶层代码中寄存器模块使能端口的连接方式,松开按键后寄存器使能无效,仅在有新的按键被按下时才会更新输出的按键信号。

module keyboard(clk_50M,key_col,key_row,key_out);
    input clk_50M;
    input [3:0] key_col;
    output key_row;
    output [3:0] key_out;
    wire [3:0] key_deb;
    key_filter key_filter(
        .clk(clk_50M),
        .rstn(1'b1),
        .key_in(key_col),
        .key_deb(key_deb)
    );
    assign key_row = 1'b0; 
    wire en = (key_deb == 4'b0000) ? 1'b0 : 1'b1;
    register_4bit register_4bit(
        .clk(clk_50M),
        .en(en),
        .data_in(key_deb),
        .data_out(key_out)
    );
endmodule

如下是实例一键盘模块中的按键消抖子模块的参考代码,由于本例需要用到一行按键,所以要对四个按键进行消抖,故在前文中按键消抖代码的基础上将涉及的位宽拓宽到了四位,其余部分未改动。

module key_filter(clk,rstn,key_in,key_deb);
    input clk;
    input rstn;
    input [3:0] key_in;
    output [3:0] key_deb;
    //分频计数
    parameter CNTMAX = 999_999;
    reg [19:0] cnt = 0;
    always@(posedge clk or negedge rstn) begin
        if(~rstn)
            cnt <= 0;
        else if(cnt == CNTMAX)
            cnt <= 0;
        else
            cnt <= cnt + 1'b1;
     end
     //每20ms采样一次按键电平
     reg [3:0] key_reg0;
     reg [3:0] key_reg1;
     reg [3:0] key_reg2; 
     always@(posedge clk or negedge rstn) begin
        if(~rstn) begin
            key_reg0 <= 4'b1111;
            key_reg1 <= 4'b1111;
            key_reg2 <= 4'b1111;
        end
        else if(cnt == CNTMAX) begin
            key_reg0 <= key_in;
            key_reg1 <= key_reg0;
            key_reg2 <= key_reg1;
        end
    end
    assign key_deb = (~key_reg0&~key_reg1&~key_reg2)|(~key_reg0&~key_reg1&key_reg2);
endmodule

如下是实例一键盘模块中的寄存器子模块的参考代码,描述的是带使能信号的4bit寄存器。

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    

接下来介绍一下BCD码计数器模块的设计。该模块的顶层电路框图如下图所示。该模块一个时钟分频模块和两个进行级联的BCD码多功能计数器子模块构成。输入的50MHz的时钟通过时钟分频得到周期为1s的时钟,该时钟作为计数器的时钟输入,而两个BCD码多功能计数器子模块进行级联后输出可用于显示译码的BCD码计数值。

BCD码计数器模块顶层电路框图

在本专栏之前发布的动态数码管显示那一期文章中,曾介绍过BCD码向下计数器的设计,其链接如下:

这里的BCD码多功能计数器便是在其基础上进行的功能扩充。该模块的输入端口包括时钟输入、级联信号输入(对应向上计数和向下计数分别代表进位输入和借位输入)、预置数输入和模式输入。该模块的输出包括计数值输出和级联信号输出(对应向上计数和向下计数分别代表进位输出和借位输出)。

BCD码多功能计数器

BCD码功能计数器子模块的参考代码如下所示,模式输入mode为了与键盘模块的输出匹配,以独热码的形式定义了四种模式,分别对应预置、清零、向上计数和向下计数。在每个输入时钟的上升沿,该模块都会根据当前的模式设置执行不同的计数功能。

module BCD_functional_counter(
    input clk,                     //时钟信号输入
    input [3:0] mode,              //计数模式信号输入
    input [3:0] BCD_preset,         //预置数据信号输入
    output [3:0] BCD_out,          //4bitBCD码输出
    input bcin,                   //用于级联的信号输入
    output reg bcout              //用于级联的信号输出
    );
    parameter preset = 4'b0001;
    parameter clear = 4'b0010;
    parameter up = 4'b0100;
    parameter down = 4'b1000; 
    reg [3:0] cnt = 0;
    //BCD计数器功能描述
    always @ (posedge clk) 
        case(mode)
            preset : cnt <= BCD_preset;
            clear : cnt <= 0;
            up : if(bcin) begin 
                     if(cnt == 4'd9)
                         cnt <= 0;
                     else
                         cnt <= cnt + 1'b1;
                 end
            down : if(bcin) begin
                       if(cnt == 4'd0)
                           cnt <= 4'd9;
                       else
                           cnt <= cnt - 1'b1;
                   end
            default : cnt <= cnt;
        endcase
    assign BCD_out = cnt;      //BCD计数值输出
    //级联信号(借位输出/进位输出)
    always@(*) begin
        if(mode == up)
            bcout = bcin && (cnt == 4'd9);
        else if(mode == down)
            bcout = bcin && (cnt == 4'd0);
        else
            bcout = 1'b0;
    end
endmodule

时钟分频模块相信读者已经很熟悉代码的编写方式,这里就不赘述了。接下来给出了BCD计数器顶层模块的参考代码,完成两个BCD码计数器的级联,并为计数器提供分频后的时钟信号,按照构想的功能框图正确完成各端口信号的连接即可。

module BCD_counter_TOP(clk_50M,mode,BCD_preset,BCD_out);
    input clk_50M;
    input [3:0] mode;
    input [7:0] BCD_preset;
    output [7:0] BCD_out;
    //例化时钟分频模块
    wire clk_1s;
    clock_division #(
        .DIVCLK_CNTMAX(24_999_999)
    )
    my_clock_0(
        .clk_in(clk_50M),
        .divclk(clk_1s)
    );
    //例化BCD功能计数器并级联
    wire [3:0] BCD_units_preset = BCD_preset[3:0];
    wire [3:0] BCD_units_out;
    wire bcout_0;
    BCD_functional_counter BCD_units(
        .clk(clk_1s),
        .mode(mode),
        .BCD_preset(BCD_units_preset),
        .BCD_out(BCD_units_out),
        .bcin(1'b1),
        .bcout(bcout_0)
    );
    wire [3:0] BCD_tens_preset = BCD_preset[7:4];
    wire [3:0] BCD_tens_out;
    BCD_functional_counter BCD_tens(
        .clk(clk_1s),
        .mode(mode),
        .BCD_preset(BCD_tens_preset),
        .BCD_out(BCD_tens_out),
        .bcin(bcout_0),
        .bcout()
    );
    assign BCD_out = {BCD_tens_out,BCD_units_out};
endmodule

数码管显示模块

最后是数码管显示模块,其电路功能框图如上图所示。数码管显示模块的设计思路与其子模块的参考代码与本专栏数码管动态显示那一期文章完全一致,读者可以通过以下链接进行阅读:

这里只是通过如下的代码将各个小的功能子模块整合到了一个数码管显示模块中,之后在整个工程的顶层中就不必一一例化那么多小模块,也方便以后的调用。

module seg_disp(clk_50M,BCD_disp,seg_sel,seg_led);
    input clk_50M;
    input [7:0] BCD_disp;
    output [5:0] seg_sel;
    output [7:0] seg_led;
    //例化时钟分频模块,得到周期为1ms的时钟
    wire clk_1ms;
    clock_division #(
        .DIVCLK_CNTMAX(24_999)
    )
    my_clock_0(
        .clk_in(clk_50M),
        .divclk(clk_1ms)
    );
    //例化计数器模块
    wire bit_disp;
    counter counter(
        .clk(clk_1ms),
        .cnt(bit_disp)
    );
    //例化多路复用器模块
    wire [3:0] data_disp;
    Mux Mux(
        .sel(bit_disp),
        .ina(BCD_disp[3:0]),
        .inb(BCD_disp[7:4]),
        .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

最后给出了实例一顶层模块的参考代码,按照介绍该实例一开始给出的构想框图,将三个大的功能模块进行正确的连接即可。

module keyboard_instance1(clk,key_col,BCD_preset,key_row,seg_sel,seg_led);
    input clk;
    input [3:0] key_col;
    input [7:0] BCD_preset;
    output key_row;
    output [5:0] seg_sel;
    output [7:0] seg_led;
    //例化键盘模块
    wire [3:0] key_out;
    keyboard keyboard(
        .clk_50M(clk),
        .key_col(key_col),
        .key_row(key_row),
        .key_out(key_out)
    );
    //例化BCD功能计数器模块
    wire [7:0] BCD_out;
    BCD_counter_TOP BCD_counter(
        .clk_50M(clk),
        .mode(key_out),
        .BCD_preset(BCD_preset),
        .BCD_out(BCD_out)
    );
    //例化数码管显示模块
    seg_disp seg_disp(
        .clk_50M(clk),
        .BCD_disp(BCD_out),
        .seg_sel(seg_sel),
        .seg_led(seg_led)
    );
endmodule

下面为该例具体的管脚约束。

实例一管脚约束

最后是实例一的上板演示视频。该例使用了开发板上从上至下第一行的矩阵键盘按键,这一行按键从左至右分别对应计数器的置数、清零、向上计数和向下计数功能,拨码开关SW0~SW7为置数模式下的BCD码输入。通过观察视频中数码管的显示情况,可以看到设计的计数器各个模式都工作正常。
image.png
实例一演示视频

实例二

在实例一中仅使用了矩阵键盘的一行按键,而在实例二中,将使用整个矩阵键盘的所有按键。实例二想要实现的功能非常简单,为矩阵键盘的十六个按键编号为0~15,按下哪一个按键,数码管就显示该按键对应编号的十六进制数。

根据前面介绍的矩阵键盘原理可以知道,对于列线已设置上拉的矩阵键盘,应该通过对所用的行线输送低电平信号,读取列信号的值,如果某一列的信号低电平,则说名该行列交叉处的按键被按下。由于本例中将使用所有的按键,如果对所有的行线都输送低电平信号,即使读到了某一列的电平信号为低也无法判定是哪一行的按键,这时就需要采用行扫描法。其原理也非常简单,即按照合适的扫描频率依次给各行输送低电平信号,当前行列电平信号均为低的按键就是被按下的按键。

如下给出了实例二的顶层电路功能框图。

实例二顶层电路功能框图

根据实例二的需求进行功能划分,一共有四个功能模块,分别是按键扫描模块、按键消抖模块、独热码转BCD码模块和数码管段码译码模块。其中,按键扫描模块负责监测矩阵键盘的列信号并对矩阵键盘进行逐行扫描,并将判定后的按键信号输出;按键消抖模块负责接收按键扫描模块得到的按键信号并进行消抖处理;独热码转二进制数模块则用于将消抖后的按键信号转换为二进制数;最后由数码管段码译码模块进行译码并驱动数码管的显示,本例只使用一位数码管故不需要进行数码管的段选译码。

下面给出了矩阵扫描模块的参考代码,通过移位操作周期性地为矩阵键盘的行信号输送1110、1101、1011、0111的电平信号,实现行扫描。可以注意到,在时序设计上,行信号的移位操作是在扫描时钟的上升沿进行的,而列信号的采集是在扫描时钟的下降沿进行的。分析代码不难得出,如果有按键按下(这里仅考虑一次只按下一个按键),该模块的按键信号输出key会是一个16bit的独热码。

module keyboard_scan(clk,col,row,key);
    input clk;
    input [3:0] col;             
    output reg [3:0] row = 4'b1110;               
    output reg [15:0] key;  
    reg [31:0] cnt = 0;
    reg scan_clk = 0;
    always@(posedge clk) begin
        if(cnt == 2499) begin
            cnt <= 0;
            scan_clk <= ~scan_clk;
        end
        else
            cnt <= cnt + 1;
    end
    always@(posedge scan_clk)
        row <= {row[2:0],row[3]}; 
    always@(negedge scan_clk) 
        case(row)
            4'b1110 : key[3:0] <= col;
            4'b1101 : key[7:4] <= col;
            4'b1011 : key[11:8] <= col;
            4'b0111 : key[15:12] <= col;
            default : key <= 0;
        endcase
endmodule

接下来是按键消抖模块的参考代码,本例要用到矩阵键盘的所有按键,因此需要将涉及到的寄存器位宽都拓宽到16位。

module key_filter(clk,rstn,key_in,key_deb);
    input clk;
    input rstn;
    input [15:0] key_in;
    output [15:0] key_deb;
    //分频计数
    parameter CNTMAX = 999_999;
    reg [19:0] cnt = 0;
    always@(posedge clk or negedge rstn) begin
        if(~rstn)
            cnt <= 0;
        else if(cnt == CNTMAX)
            cnt <= 0;
        else
            cnt <= cnt + 1'b1;
     end
     //每20ms采样一次按键电平
     reg [15:0] key_reg0;
     reg [15:0] key_reg1;
     reg [15:0] key_reg2; 
     always@(posedge clk or negedge rstn) begin
        if(~rstn) begin
            key_reg0 <= 16'hffff;
            key_reg1 <= 16'hffff;
            key_reg2 <= 16'hffff;
        end
        else if(cnt == CNTMAX) begin
            key_reg0 <= key_in;
            key_reg1 <= key_reg0;
            key_reg2 <= key_reg1;
        end
    end
    assign key_deb = (~key_reg0&~key_reg1& ~key_reg2)|(~key_reg0&~key_reg1&key_reg2);
endmodule

下面给出了独热码转二进制模块,实现将消抖后的按键信号转化为可供现成的数码管段码译码模块进行显示译码的编码。由于位数较少,直接使用查表的方法实现。另外,该模块引入了时序电路,还含有实例一中那个带使能端的寄存器类似的功能,即松开按键后,该模块的输入为16bit的全零信号,此时按照如下代码的描述将不会更新编码输出,而是保持上次按下按键时相同的输出,故不需要一直按住按键来保持数码管的显示。

module onehot2binary(clk,onehot,binary);
    input clk;
    input [15:0] onehot;
    output reg [3:0] binary;
    always@(posedge clk) 
        case(onehot) 
            16'h0001 : binary <= 4'b0000;
            16'h0002 : binary <= 4'b0001;
            16'h0004 : binary <= 4'b0010;
            16'h0008 : binary <= 4'b0011;
            16'h0010 : binary <= 4'b0100;
            16'h0020 : binary <= 4'b0101;
            16'h0040 : binary <= 4'b0110;
            16'h0080 : binary <= 4'b0111;
            16'h0100 : binary <= 4'b1000;
            16'h0200 : binary <= 4'b1001;
            16'h0400 : binary <= 4'b1010;
            16'h0800 : binary <= 4'b1011;
            16'h1000 : binary <= 4'b1100;
            16'h2000 : binary <= 4'b1101;
            16'h4000 : binary <= 4'b1110;
            16'h8000 : binary <= 4'b1111;
        endcase
endmodule

数码管段码译码模块的代码同样可以阅读之前的文章得到,这里就不贴出来占用过多的篇幅了。最后给出实例二的顶层模块参考代码,按照介绍实例二一开始给出的电路功能框图正确连接各个模块。

module keyboard_instance2(clk_50M,col,row,seg_led,seg_sel);
    input clk_50M;
    input [3:0] col;
    output [3:0] row;
    output [7:0] seg_led;
    output [5:0] seg_sel;
    assign seg_sel = 6'b111110; 
    wire [15:0] key;
    keyboard_scan keyboard_scan(
        .clk(clk_50M),
        .col(col),
        .row(row),
        .key(key)
    );
    wire [15:0] key_deb;
    key_filter key_filter(
        .clk(clk_50M),
        .rstn(1'b1),
        .key_in(key),
        .key_deb(key_deb)
    );
    wire [3:0] data_disp;
    onehot2binary onehot2binary(
        .clk(clk_50M),
        .onehot(key_deb),
        .binary(data_disp)
    );
    seg_led_decoder seg_led_decoder(
        .data_disp(data_disp),
        .seg_led(seg_led)
    );
endmodule

下面给出实例二具体的管脚约束,如下表所示:

实例二管脚约束

如下为实例二的上板演示视频,可以看到,每按下一个按键,数码管都正确显示了对应的十六进制数。
image.png
实例二演示视频

至此,关于矩阵键盘的实例就分享完毕了。本期文章主要介绍了基于FPGA驱动矩阵键盘的原理,介绍了一种按键消抖的实现方法,并通过分享两个实例(前者仅使用一行按键,后者使用了多行按键)介绍了矩阵键盘的应用。最后,感谢您的阅读。

END

知乎:https://zhuanlan.zhihu.com/p/260372039

推荐阅读

更多内容请关注其实我是老莫的网络书场专栏
推荐阅读
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息