news 2026/9/6 11:50:05

FPGA矩阵键盘从原理到上板:状态机与消抖实现详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA矩阵键盘从原理到上板:状态机与消抖实现详解

简介:一份基于FPGA的4*4矩阵键盘课程设计报告,面向电子信息类专业学生和FPGA入门开发者,核心解决矩阵键盘扫描识别与LED数码管显示的控制逻辑问题。设计基于Quartus II 9.0开发环境,采用Verilog硬件描述语言,从硬件电路连接、行列扫描原理、按键识别到数码管段驱动与位选通控制均有完整说明,并配有程序流程图与调试方法,便于读者复现整个实验流程。资源为PDF格式,单文件共827KB,内容精炼但覆盖完整课题设计过程,包括需求分析、原理介绍、模块划分和结果验证,适合作为课程设计报告模板或毕业设计选题参考。已有333人学习下载,可帮助FPGA初学者在较短时间内理清矩阵键盘与数码管协同工作的设计脉络,减少自行摸索成本,提升项目完成效率。 如果你在淘宝上随便买一块FPGA开发板,十有八九板子上会焊一个4×4的矩阵键盘,但你翻开配套例程,往往只看到流水灯和数码管扫描,键盘例程要么缺失,要么就是那种编译都过不了的半成品工程。我当年做课程设计时就是卡在这里:仿真明明对了,上板子就是没反应;改了几次消抖时间,按键又开始连跳。后来把这个模块彻底吃透,才发现矩阵键盘算是FPGA里最“小而全”的项目——它逼着你把状态机、时序参数计算、引脚约束、仿真建模挨个过一遍。这篇文章就把我从原理到上板的完整过程写下来,给正在做FPGA课程设计,或者刚入门想找个像样项目练手的朋友做个参考。

1. 课程设计定案:扫描方式、IO资源与整体框图

1.1 逐行扫描和行列反转,到底该选谁

矩阵键盘的核心目的是用较少的IO口读入尽可能多的按键。以4×4为例,独立按键需要16个IO,矩阵键盘只需要8个IO——4根行线加4根列线。代价是按键不能直接读,必须先“扫描”。最常见的两种扫描策略是逐行扫描和行列反转,它们的取舍直接决定了代码复杂度,所以第一步就得定下来。

逐行扫描的思路很直白:一次只把一行拉到有效电平,其余行设为非有效电平,然后读列线;如果某列变成了有效电平,说明这一行和这一列的交叉点被按下。因为一轮只扫描一行,一轮4拍就能把16个键扫完。行列反转的思路则是先让行输出、列输入,记下第一次读到的列值;然后交换方向,让列输出、行输入,再读一次行值;两次结果叠加就能定位按键。它的IO方向需要动态切换,FPGA里虽然可以做,但状态切换的时序比逐行扫描复杂,还要额外处理三态缓冲。

从我实际做的课程设计来看,逐行扫描更合适。原因有三:第一,IO方向固定,行永远输出、列永远输入,顶层模块的接口非常清晰;第二,扫描时序是规整的4拍循环,非常适合用状态机或者移位寄存器来表达;第三,后续如果要加“同时按下多个键”的检测,逐行扫描天然知道当前扫到哪一行,排查起来直观很多。行列反转在单片机上很流行,因为单片机IO可以软件配置方向,一条指令就切过去了,但FPGA里切换IO方向需要额外的控制逻辑,为了省两个IO去增加状态复杂度,不划算。

1.2 引脚分配和上下拉规划

引脚分配这件事看起来简单,实际上决定了一半的调试命运。我的建议是:行线接FPGA的普通输出脚,列线接普通输入脚,并且列线必须配上拉。

为什么列要上拉?因为键盘本身没有任何电源,按键只是一个机械开关。当没有按键按下时,列线和行线之间是断开的,列线相当于悬空。一个悬空的CMOS输入引脚,电平是不确定的,读回来可能是0也可能是1,这会让扫描逻辑彻底乱掉。所以列线必须通过上拉电阻保持为高电平,当按键按下时,行线的低电平通过开关把列拉低,FPGA才能读到确定的0。很多开发板在PCB上已经给矩阵键盘加了上拉电阻,但如果你的板子是裸键盘模块或者自己飞线,一定要确认这一点。

另外还要注意电平标准。现在主流FPGA开发板的IO电压一般是3.3V,引脚约束时要把电平标准设成LVCMOS33,不要默认用LVCMOS18,否则外部设备电压不匹配,轻则功能异常,重则损伤引脚。Vivado里可以在XDC文件里写set_property IOSTANDARD LVCMOS33 [get_ports {col_in[3]}],Quartus则在Pin Planner里设置。课程设计报告的“引脚锁定时序图”部分,这一步是必画的,别漏了。

1.3 键值表:先定义好再写代码

矩阵键盘的键值表和PCB上的丝印位置强相关。我见过太多人默认左上角是0号键、右下角是15号键,结果上板后按键错乱。正确做法是先打开开发板原理图,找到矩阵键盘部分的网络标号,确认哪根线接到哪一行哪一列,然后定义一张完整的键值映射表。

以最常见的接法为例,假设4根行线R0到R3,4根列线C0到C3,键值可以定义为:

行\列C0C1C2C3
R00123
R14567
R2891011
R312131415

这张表要在代码里用一个查找逻辑实现,在报告里放在“系统设计方案”一节。我的习惯是直接用case语句把行号和列号拼成4位键值,而不是用乘法或移位去算,因为case语句的映射关系一眼就能看懂,排查时对着表格改代码非常方便。

2. 行扫描与消抖状态机的Verilog实现

2.1 状态机划分:四个状态把一次按键拆干净

矩阵键盘的代码核心不是一个“扫描器”,而是一个“状态机”。很多新手写出来的键盘模块在仿真里能用,上板后却疯狂乱跳,问题就出在状态划分不够严谨。

我常用的状态机分四段:SCAN(扫描)、CONFIRM(消抖确认)、PRESSED(按键有效输出)、RELEASE(等待释放)。这四个状态对应一次按键的完整生命周期:发现疑似按键、确认不是抖动、输出有效键值、等待手指松开。

SCAN状态做两件事:按节拍轮流选中一行,检测列输入是否有低电平。如果检测到低电平,就不要继续扫描了,先记下当前的行号和列值,跳转到CONFIRMCONFIRM状态里保持当前扫描行不变,开始计时,等20ms后再读一次列。如果列值和之前一致,说明这不是抖动,按键确实按下了,于是输出键值并进入PRESSED;如果列值变了或者恢复高电平,说明是干扰或按键提前松开,回到SCAN继续扫描。

PRESSED状态下我已经把键值输出给下游模块(数码管、UART等),此时要持续等待列线全部恢复高电平。注意这里必须等到全部列恢复,而不是只等当前列恢复,否则用户按下一个键后手指稍微偏一点碰到相邻键,状态机可能误判为松开。最后RELEASE状态做一个释放后的短暂延时,防止释放瞬间的机械抖动又触发一次按键。

2.2 扫描节拍和消抖延时的计算

参数设计是课程设计报告里最能拉开分差的部分。先算扫描节拍:机械按键从按下到稳定,通常要5~10ms;手指从按下到松开,最短也有30~50ms。为了保证不漏检,一轮扫描周期应该远小于最短按键持续时间。我的做法是每1ms切换一次扫描行,一轮4ms。这样即使是最快的快速敲击,一轮扫描里也能击中至少7次,完全不会漏。

50MHz系统时钟下,1ms就是50_000个时钟周期,计数器的上限是CLK_FREQ / 1000。然后是消抖延时:20ms是一个很稳的经验值,它大于一般机械抖动的时间,又不会让按键响应慢到令人烦躁。20ms对应1_000_000个时钟周期,正好是1_000_000,这个数在50MHz下特别好记。计数阈值最好用局部参数定义,可读性好,换时钟频率时只改一处:

localparam CLK_FREQ = 50_000_000; localparam SCAN_CNT_MAX = CLK_FREQ / 1000; // 1ms扫描节拍 localparam DEBOUNCE_MAX = CLK_FREQ / 50; // 20ms消抖延时

这里提醒一句:如果板子上的晶振不是50MHz,比如是12MHz或者100MHz,一定要同步修改这两个参数,否则消抖时间会变成4.8ms或者40ms,前者消抖不够,后者响应太慢。课程设计答辩时老师特别喜欢问这个计算过程,能答上来就是加分项。

2.3 核心Verilog代码骨架

下面的代码是我简化后的核心框架,重点关注状态机的跳转逻辑。完整的工程还包括分频模块、列同步模块和键值编码模块,这里把最关键的逻辑展示出来。

module key_matrix_4x4 ( input wire clk, input wire rst_n, input wire [3:0] col_in, output reg [3:0] row_out, output reg [3:0] key_val, output reg key_pulse ); localparam IDLE = 2'd0; localparam CONFIRM = 2'd1; localparam PRESSED = 2'd2; localparam RELEASE = 2'd3; reg [1:0] state; reg [1:0] row_idx; reg [19:0] cnt; reg [3:0] col_sync1, col_sync2; // 异步输入打两拍 reg [3:0] tmp_col; reg [1:0] tmp_row; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin col_sync1 <= 4'b1111; col_sync2 <= 4'b1111; end else begin col_sync1 <= col_in; col_sync2 <= col_sync1; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; row_out <= 4'b1111; row_idx <= 2'd0; key_pulse <= 1'b0; key_val <= 4'd0; end else begin case (state) IDLE: begin row_out <= ~(4'b0001 << row_idx); // 选中一行 if (col_sync2 != 4'b1111) begin // 某列被拉低 tmp_row <= row_idx; tmp_col <= col_sync2; cnt <= 20'd0; state <= CONFIRM; end else if (row_idx == 2'd3) begin row_idx <= 2'd0; end else begin row_idx <= row_idx + 1'b1; end end CONFIRM: begin row_out <= ~(4'b0001 << tmp_row); // 保持当前行 if (cnt < DEBOUNCE_MAX) begin cnt <= cnt + 1'b1; end else begin cnt <= 20'd0; if (col_sync2 == tmp_col) begin // 再次确认仍为低 key_val <= {tmp_row, col_to_idx(tmp_col)}; key_pulse <= 1'b1; state <= PRESSED; end else begin row_idx <= tmp_row + 1'b1; state <= IDLE; end end end PRESSED: begin key_pulse <= 1'b0; if (col_sync2 == 4'b1111) begin state <= RELEASE; end end RELEASE: begin if (col_sync2 == 4'b1111) begin state <= IDLE; // 松开后再回扫描 end end endcase end end function [1:0] col_to_idx; input [3:0] col; case (col) 4'b1110: col_to_idx = 2'd0; 4'b1101: col_to_idx = 2'd1; 4'b1011: col_to_idx = 2'd2; 4'b0111: col_to_idx = 2'd3; default: col_to_idx = 2'd0; endcase endfunction endmodule

这段代码有几个细节值得解释。第一,col_in是异步信号,我没有直接用来做状态判断,而是先通过了两个D触发器打两拍,把信号同步到系统时钟域。虽然课程设计里很多人不写这段,但同步器能显著降低亚稳态导致的偶发误触发,属于“面试可以拿出来讲”的加分点。第二,在CONFIRM状态中我保持row_out不变,只等计数器走完,防止消抖期间换行导致按键读取错乱。第三,key_pulse只在确认按下时拉高一个周期,下游模块看到这个脉冲就锁存键值,不会重复触发。

3. 仿真阶段的重难点:把testbench里的按键“建模”出来

3.1 为什么矩阵键盘仿真不能照搬数码管

很多人在矩阵键盘仿真上栽跟头,是因为testbench的写法不对。数码管、LED这类模块,输入输出方向都是固定的,testbench里直接给输入信号赋波形就行。但矩阵键盘不一样:你写的模块把row_out输出到物理引脚,同时从col_in读回电平;在仿真环境里,col_in并不是一个直接由testbench赋值的信号,它更像一个“被外部电路决定的被动信号”——只有在用户按下某个键、且对应的行被选中时,它才会被拉到低电平,其他时候都应该因为上拉而保持高电平。

如果testbench里简单地把col_in固定成一个值,比如先给4'b1110再给4'b1111,你就完全绕过了矩阵键盘的物理机制,根本验证不了扫描逻辑是否真的按预期工作。正确建模的思路是:在testbench里维护一个“按键表”,模拟哪几个按键当前处于按下状态;再用组合逻辑根据row_out的当前值去生成对应的col_in,就像真实键盘电路那样。

3.2 用按键状态表驱动行列逻辑

我习惯在testbench里用一个16位的寄存器key_table,每一位对应一个按键,1表示按下。然后写一个生成col_in的组合逻辑:对每一列来说,只要这一列所在行被选中并且该行对应按键按下,这一列就会被拉低。因为列是“线与”的关系,所以用连续assign加OR逻辑即可:

reg [15:0] key_table; wire col0_pull_low = (key_table[4'd0] && row_out[0]==1'b0) || (key_table[4'd1] && row_out[1]==1'b0) || (key_table[4'd2] && row_out[2]==1'b0) || (key_table[4'd3] && row_out[3]==1'b0); wire col1_pull_low = (key_table[4'd4] && row_out[0]==1'b0) || (key_table[4'd5] && row_out[1]==1'b0) || (key_table[4'd6] && row_out[2]==1'b0) || (key_table[4'd7] && row_out[3]==1'b0); assign col_in[0] = col0_pull_low ? 1'b0 : 1'b1; assign col_in[1] = col1_pull_low ? 1'b0 : 1'b1;

这个建模方式非常贴近真实物理:按下某个键后,只有当FPGA扫描到对应行,列才会被拉低;如果FPGA当前扫的是其他行,就算按键一直按着,列也仍然是高。这样仿真波形里能看到row_out轮流变化、col_in跟着变化的完整过程,和上板后的实际表现是一致的,仿真结果才有参考价值。

驱动key_table的时候,不要直接赋一个固定值就完事,要模拟真实按键过程:先出现一小段抖动,再稳定按下,持续一段时间后释放。比如:

initial begin key_table = 16'h0000; #100; // 模拟抖动:按下、弹起、再按下 key_table[0] = 1'b1; #5; key_table[0] = 1'b0; #10; key_table[0] = 1'b1; #2000; // 按住2us,实际按物理时间要更长 key_table[0] = 1'b0; #100; // 再按另一个键 key_table[5] = 1'b1; #2000; key_table[5] = 1'b0; end

3.3 至少覆盖三种情况的仿真用例

课程设计的仿真报告如果只给一个“按键按下”的波形,答辩时很容易被问住。我建议至少跑三个用例:抖动后确认按下、按下后立即松开不输出、多键同时按下的表现。

第一个用例验证正常的按-放流程,观察key_pulse是否正确产生。第二个用例故意让按键在消抖窗口内弹起,预期状态机应该回到扫描状态,key_pulse保持不变,这能证明消抖逻辑不是摆设。第三个用例可以同时按下同一列的多个键,看看扫描逻辑是否只会输出一个键值,或者是否直接丢弃本次扫描——两种行为都可以接受,但你必须知道自己模块的实际行为,并被问到时能解释清楚。

仿真通过后也别高兴太早,矩阵键盘的仿真和上板之间还隔着一层“真实世界”,接下来才是最容易掉头发的地方。

4. 上板调试中我实际踩过的四个坑

4.1 坑一:列输入悬空导致按键全部无效

这是我第一次上板遇到的第一个问题,现象非常诡异:仿真波形完美,下载到板子上之后,按下任何一个键,数码管都没反应,用逻辑分析仪抓col_in,发现所有列信号都是乱跳的,甚至像一条噪声线。我第一个反应是代码状态机写错了,反复检查了十几遍,最后拿万用表量键盘模块两端的电压才发现,列线在没按键时根本没有稳定的高电平——因为开发板的矩阵键盘区域没有焊接上拉电阻。

FPGA内部虽然可以开启弱上拉(比如Vivado里set_property PULLUP true),但这需要你在引脚约束里显式开启,而且不同系列的FPGA支持的内部上拉强度不一样,不一定能可靠抵抗环境噪声。稳妥的做法是在PCB上给每个列线接一个10kΩ的上拉电阻到3.3V。如果是使用开发板自带的键盘区域,先翻开原理图确认有没有这个电阻;如果是面包板飞线,务必自己接上。

排查顺序建议是:先量硬件,再查约束,最后怀疑逻辑。我用ILA抓内部信号的时候,数据总是异常的,于是怀疑硬件,用万用表验证后加上了拉电阻,问题立刻消失。很多课程设计翻车不是因为代码多复杂,而是这种硬件细节没确认。

4.2 坑二:没等按键释放,一个键触发了几十次

这个问题发生在仿真用例没覆盖“长时间按住”的情况时。仿真里我按一次键只按了2us就松开,所以状态机输出的key_pulse看起来很干净。但真实手指按一个键,至少会按100ms,如果状态机在输出一次脉冲后直接回到扫描状态,而又没有等所有列恢复高电平,那么在手指还没松开的那100ms里,扫描逻辑会一遍又一遍读到同一个按键,每轮扫描都输出一个脉冲。

那次数码管上显示的数字疯狂往上跳,串口助手里收到几十个相同的字节,非常经典。解决办法就是我前面状态机里写的PRESSEDRELEASE状态:输出脉冲后,必须保持等待,直到列线全部恢复高电平才允许重新扫描。这里有个容易写错的细节:判断释放时要用所有列都恢复高,因为如果你只判断当前按下那一列,手指位置稍微偏移时其他列还在低电平,就会立刻再次触发。

调试这个问题的经验是:先按着键不放,观察数码管上的数字变化。如果数字稳定不变,说明释放检测正常;如果数字还在缓慢增加或跳动,就检查PRESSEDRELEASE状态的转移条件。

4.3 坑三:键值表与PCB丝印不一致

这个问题在开发板自带的矩阵键盘上特别容易踩。开发板的矩阵键盘丝印通常画的是一个电话键盘布局,1、2、3、A、B、C之类的,但PCB布线时并不一定按“左上角为0”的顺序连接。我遇到的问题是按丝印上的“7”,数码管显示的却是“3”,一开始以为键值编码算错了,后来把模块代码里的键值表逐行打出来才发现,是原理图上列线的顺序和我想的不一样。

解决办法很土但很有效:写一个极简的“按键读数”测试工程,不接任何外设,只把当前键值通过LED以二进制方式显示出来,然后从左上角开始一个键一个键地按,把每个按键位置对应的实际键值整理成一张表,再回头修正键值编码逻辑。这个表就是你的开发板真正的键值映射表,比对着原理图猜可靠多了。

另外,键值编码里还要注意行列优先级。我的代码用的是{tmp_row, col_to_idx(tmp_col)},也就是高两位是行号、低两位是列号。如果你把行和列的优先级写反,整个键值表会像转置了一样错位,但代码逻辑又不会报错,这种隐藏问题最费时间。

4.4 坑四:多键同时按引发的“鬼键”

有次我在测试时无意间同时按下了三个键,结果数码管上跳出了一个我根本没按的键值。这个现象就是矩阵键盘经典的“鬼键”问题。原因在于矩阵键盘的交叉点网络本质是一个电阻网络,当三个按键按下时,电流可能通过按键通路串到第四个交叉点,让扫描逻辑误以为那个点也被按下。

具体来说,同时按下某行的两个键和另一行的一个键,就可能在两行两列形成的矩形第四角产生一个“虚短”路径。扫描到某一行时,原本不该拉低的列也被其他通路拉低了,于是多报一个键。

解决思路很多:最简单的是一旦检测到同一行有两列同时为低,就认为发生冲突,丢弃本次扫描结果。稍好一点的是优先级扫描,即按照固定顺序只认第一个读到的键,忽略同行的其他列。如果你的设计目标本身就是“单键输入”,比如密码锁,那么在报告里说明“模块支持单键操作,多键同时按下时本次扫描无效”即可,答辩时反而显得你考虑过这个问题。

5. 键值输出之后:把键盘模块接进更大的系统

课程设计做到这一步,矩阵键盘本身已经能稳定输出键值和脉冲了。但一个完整的课程设计往往还需要“落地”到一个具体应用场景里,这时候键值输出后的处理就很关键。

最常见的是串口回传:把key_val查表转换成ASCII码,通过UART模块发送到电脑串口助手。我一般用一个小型只读存储器,把16个键对应的ASCII码存进去,收到key_pulse后用key_val作为地址读出来,直接给UART发送模块。这样键盘摇身一变就成了一个16键的USB转串口键盘,而且完全不占用CPU,纯硬件逻辑完成。这个扩展的代码量不大,但在报告里可以单独开一节“系统应用扩展”,很能体现设计能力。

另外,状态机的思维模式可以平移到很多外设控制上。矩阵键盘这套“扫描-确认-输出-释放”的流程,和按键编码器、旋转编码器、红外遥控解码的处理思路高度相似。以后你再做这类有机械触点的输入设备,都可以复用这套状态框架,只需要改改触发电平和延时参数。这也是我为什么说矩阵键盘值得认真做一遍——它留给你的不仅是一个模块,而是一套处理真实世界异步事件的思考方法。

最后分享一个调试小技巧:给key_pulsekey_val各拉一根测试引脚出来,直接连到示波器或逻辑分析仪上。按下键的瞬间,如果key_pulse能看到一个干净的单脉冲,key_val稳定保持对应电平,就不用再怀疑键盘模块本身了,问题一定出在下游。这个“先隔离模块、再逐级联调”的思路,在后续所有FPGA项目里都适用。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/6 11:49:58

从裸机到RTOS:FreeRTOS多任务系统设计与STM32实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 11:48:11

多核AI Core数据一致性:生产者消费者模型与同步机制详解

不用写主标题&#xff0c;直接正文。第一次接触"AI Core 数据一致性"这个问题&#xff0c;是在调一个多核NPU上的矩阵分块算子。当时明明每个核算的都是对的&#xff0c;最后拼出来的结果却乱七八糟&#xff0c;排查了整整两天&#xff0c;最后发现是跨核的数据同步没…

作者头像 李华
网站建设 2026/9/6 11:47:33

CAN FD一致性测试自动化系统设计与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 11:43:32

Linux驱动开发详解:从字符设备到设备树,揭秘内核高薪技能

干了十来年Linux驱动开发&#xff0c;身边总有人问我&#xff1a;你们这行是不是特神秘&#xff1f;一不开源&#xff0c;二不露脸&#xff0c;是不是天天跟黑科技打交道&#xff1f;还有人更直接&#xff1a;听说你们工资特别高&#xff1f;我通常都会笑着回一句&#xff1a;神…

作者头像 李华
网站建设 2026/9/6 11:41:19

信息化项目初步设计及概算编制:从框架到评审的全流程指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/6 11:35:59

SPI通信FPGA实现全攻略:从协议细节到板级调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华