资讯动态

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

发布时间:2026/9/6 11:50:13 来源:尧图企业网站定制
简介一份基于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到R34根列线C0到C3键值可以定义为行\列C0C1C2C3R00123R14567R2891011R312131415这张表要在代码里用一个查找逻辑实现在报告里放在“系统设计方案”一节。我的习惯是直接用case语句把行号和列号拼成4位键值而不是用乘法或移位去算因为case语句的映射关系一眼就能看懂排查时对着表格改代码非常方便。2. 行扫描与消抖状态机的Verilog实现2.1 状态机划分四个状态把一次按键拆干净矩阵键盘的代码核心不是一个“扫描器”而是一个“状态机”。很多新手写出来的键盘模块在仿真里能用上板后却疯狂乱跳问题就出在状态划分不够严谨。我常用的状态机分四段SCAN扫描、CONFIRM消抖确认、PRESSED按键有效输出、RELEASE等待释放。这四个状态对应一次按键的完整生命周期发现疑似按键、确认不是抖动、输出有效键值、等待手指松开。SCAN状态做两件事按节拍轮流选中一行检测列输入是否有低电平。如果检测到低电平就不要继续扫描了先记下当前的行号和列值跳转到CONFIRM。CONFIRM状态里保持当前扫描行不变开始计时等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 2d0; localparam CONFIRM 2d1; localparam PRESSED 2d2; localparam RELEASE 2d3; 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 4b1111; col_sync2 4b1111; 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 4b1111; row_idx 2d0; key_pulse 1b0; key_val 4d0; end else begin case (state) IDLE: begin row_out ~(4b0001 row_idx); // 选中一行 if (col_sync2 ! 4b1111) begin // 某列被拉低 tmp_row row_idx; tmp_col col_sync2; cnt 20d0; state CONFIRM; end else if (row_idx 2d3) begin row_idx 2d0; end else begin row_idx row_idx 1b1; end end CONFIRM: begin row_out ~(4b0001 tmp_row); // 保持当前行 if (cnt DEBOUNCE_MAX) begin cnt cnt 1b1; end else begin cnt 20d0; if (col_sync2 tmp_col) begin // 再次确认仍为低 key_val {tmp_row, col_to_idx(tmp_col)}; key_pulse 1b1; state PRESSED; end else begin row_idx tmp_row 1b1; state IDLE; end end end PRESSED: begin key_pulse 1b0; if (col_sync2 4b1111) begin state RELEASE; end end RELEASE: begin if (col_sync2 4b1111) begin state IDLE; // 松开后再回扫描 end end endcase end end function [1:0] col_to_idx; input [3:0] col; case (col) 4b1110: col_to_idx 2d0; 4b1101: col_to_idx 2d1; 4b1011: col_to_idx 2d2; 4b0111: col_to_idx 2d3; default: col_to_idx 2d0; 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固定成一个值比如先给4b1110再给4b1111你就完全绕过了矩阵键盘的物理机制根本验证不了扫描逻辑是否真的按预期工作。正确建模的思路是在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[4d0] row_out[0]1b0) || (key_table[4d1] row_out[1]1b0) || (key_table[4d2] row_out[2]1b0) || (key_table[4d3] row_out[3]1b0); wire col1_pull_low (key_table[4d4] row_out[0]1b0) || (key_table[4d5] row_out[1]1b0) || (key_table[4d6] row_out[2]1b0) || (key_table[4d7] row_out[3]1b0); assign col_in[0] col0_pull_low ? 1b0 : 1b1; assign col_in[1] col1_pull_low ? 1b0 : 1b1;这个建模方式非常贴近真实物理按下某个键后只有当FPGA扫描到对应行列才会被拉低如果FPGA当前扫的是其他行就算按键一直按着列也仍然是高。这样仿真波形里能看到row_out轮流变化、col_in跟着变化的完整过程和上板后的实际表现是一致的仿真结果才有参考价值。驱动key_table的时候不要直接赋一个固定值就完事要模拟真实按键过程先出现一小段抖动再稳定按下持续一段时间后释放。比如initial begin key_table 16h0000; #100; // 模拟抖动按下、弹起、再按下 key_table[0] 1b1; #5; key_table[0] 1b0; #10; key_table[0] 1b1; #2000; // 按住2us实际按物理时间要更长 key_table[0] 1b0; #100; // 再按另一个键 key_table[5] 1b1; #2000; key_table[5] 1b0; end3.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里扫描逻辑会一遍又一遍读到同一个按键每轮扫描都输出一个脉冲。那次数码管上显示的数字疯狂往上跳串口助手里收到几十个相同的字节非常经典。解决办法就是我前面状态机里写的PRESSED和RELEASE状态输出脉冲后必须保持等待直到列线全部恢复高电平才允许重新扫描。这里有个容易写错的细节判断释放时要用所有列都恢复高因为如果你只判断当前按下那一列手指位置稍微偏移时其他列还在低电平就会立刻再次触发。调试这个问题的经验是先按着键不放观察数码管上的数字变化。如果数字稳定不变说明释放检测正常如果数字还在缓慢增加或跳动就检查PRESSED和RELEASE状态的转移条件。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_pulse和key_val各拉一根测试引脚出来直接连到示波器或逻辑分析仪上。按下键的瞬间如果key_pulse能看到一个干净的单脉冲key_val稳定保持对应电平就不用再怀疑键盘模块本身了问题一定出在下游。这个“先隔离模块、再逐级联调”的思路在后续所有FPGA项目里都适用。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价