FPGA密码锁中的VHDL状态机设计与矩阵键盘扫描实现
2026/9/16 19:24:51 网站建设 项目流程

简介:一份基于FPGA的密码锁程序,采用Altera Quartus II配合VHDL语言实现,适合FPGA入门与进阶学习者研究数字系统设计。资源压缩包约515KB,共83个文件,以.vhd源文件、.qpf工程文件、.qsf配置文件为主,另有.rpt综合/适配/时序报告、.vwf仿真波形、.pof编程文件及.readme说明,完整呈现Quartus工程从建项、编译到下载的各个阶段。项目使用4×4键盘作为输入,在FPGA内部实现密码存储、逐位比较、错误次数计数与报警、以及通过旧密码验证完成修改等核心功能;同时涉及按键消抖、状态机控制等典型时序逻辑设计。资源内容结构清晰,便于对照代码与报告理解VHDL硬件描述方法、Quartus II调试流程以及外设接口设计。已有863人学习,对于想动手实践FPGA密码锁或类似按键交互系统的开发者具有较好的参考价值。

1. 套件里藏着的密码锁:一次VHDL状态机拆解

拿到这套Quartus工程先别急着编译——mimasuo.vhdjianpan.vhdyima.vhdxiaodou.vhdseg_sweep0.vhd这五个源文件刚好对应了密码锁的五个核心模块:主控逻辑、键盘扫描、译码、按键消抖和数码管段选扫描。这种按功能拆分文件的结构,本身就是一次值得抄的模块化设计示范。常见的密码锁Demo大多只做“输入几位密码、比对、开锁”的直通逻辑,而这套工程把键盘扫描、动态扫描和主状态机分开写,意味着你可以单独仿真任何一个子模块而不需要搭整个系统——这对调试来说价值比“能跑”大得多。

核心逻辑藏在mimasuo.vhd里,它用VHDL描述了一个典型的状态机:空闲等待、输入密码、逐位比较、错误计数、报警、改密确认。这里值得注意的不是“实现了密码锁”这件事,而是它怎么用有限个状态去管理“多位输入 + 错误累计 + 修改密码”三种并发需求。下面先把状态机和键盘模块的配合逻辑说透,再给出一套可以直接改用的代码结构。

2. VHDL状态机设计:从真值表到密码锁主控逻辑

2.1 为什么用状态机而不是“顺序执行”的思路

VHDL不同于C语言,没有“先读键盘、再比较、再判断”这种天然的执行顺序。你要做的是把系统的所有可能处境枚举出来,然后用状态寄存器显式地表达当前处于哪个阶段。密码锁的核心困境在于:输入密码是一个时序过程,密码比较是组合逻辑,报警计数又要跨多个时钟周期——这三者如果用一堆if嵌套去写,综合后的电路会变得不可预测,时序约束也没法给。状态机的好处是让每个阶段的输入条件、输出动作、跳转条件都摊在台面上,综合器能明确推断出寄存器的翻转条件,布线后的时序也比较容易收敛。

以这套工程为例,主状态机的状态至少包含:S_IDLE(等待第一次按键)、S_INPUT(接收8位密码输入)、S_CHECK(比对密码)、S_ALARM(连续错误触发报警)、S_CHANGE(验证旧密码后允许输入新密码)。状态编码用的是独热码,Quartus默认会帮你选编码方式,但如果设备资源紧张,可以在State信号上显式声明独热码以减少组合逻辑的扇出。下面是一段从mimasuo.vhd主干逻辑提炼出的状态跳转骨架:

type state_type is (S_IDLE, S_INPUT, S_CHECK, S_ALARM, S_CHANGE); signal state : state_type := S_IDLE; signal key_buf : std_logic_vector(7 downto 0); signal cur_pwd : std_logic_vector(7 downto 0) := "00010010"; -- 预设密码 signal key_count : integer range 0 to 7 := 0; signal err_count : integer range 0 to 3 := 0; signal alarm_sig : std_logic;

这段代码定义了一个单进程状态机最核心的骨架。key_buf用来缓存当前输入的按键值,key_count指示已经输入了几位,cur_pwd是预设密码——注意这里直接用信号量做密码存储,对于教学级项目完全够用,但如果你想防止综合器把密码优化成常量(导致改密功能失效),可以在综合属性里加上keep约束。err_count每个错误周期加1,达到3次后进入S_ALARM

2.2 密码比较逻辑的两种写法与边界问题

密码比较看起来简单——每一位输入和预设值比对就行——但这里有个陷阱:如果每按一位就立刻比较,那么输入到第3位时前面两位已经被比对通过了,这时若第3位错误,逻辑必须能定位到“错在哪一位”,同时不能让之前的状态残留影响下一次输入。我见过不少学生作品在这个环节出问题:错误标记没有及时清除,导致下一次输入即使全对也永远无法开锁。

常见做法是用一个移位寄存器把按键值逐位推入,然后跟预设值做整体比较:

process(clk) begin if rising_edge(clk) then if key_pulse = '1' and key_count < 8 then key_buf <= key_buf(6 downto 0) & key_in; key_count <= key_count + 1; end if; end if; end process; process(key_buf, cur_pwd) begin match_flag <= '0'; if key_buf = cur_pwd then match_flag <= '1'; end if; end process;

第一段是时序逻辑,负责在按键脉冲到来时把当前按键值拼接到移位寄存器尾部;第二段是纯组合逻辑,持续比较移位寄存器的内容与预设密码。用这种写法,你要对齐的是“第几个时钟沿按键值被采入”,而不是“按键按下的瞬间是否等于某位密码”——这能避免按键释放与边沿检测之间差一个周期导致漏判。注意key_buf(6 downto 0) & key_in的方向,如果你是从高位开始移入,那么最终比较时要确认预设密码的位序和移入方向一致,否则会出现“输入12345却被当成54321”的经典错位。

边界情况是:如果用户输入了9位数字,第9位到达时key_count已经是8,那个key_count < 8条件会阻止第9位进入移位寄存器,但这个按键事件本身已经被消耗掉了——也就是说用户多按的这第9位不会被计入下一次输入。工程里的处理方式是每个完整周期后自动回到S_IDLES_INPUT,但具体怎么回,取决于状态机在key_count = 7时是否直接跳转到S_CHECK

3. 4×4矩阵键盘扫描与VHDL消抖实现

3.1 矩阵扫描的时序拆解:行扫描、列回读、边沿检测

(在后续内容中继续展开)

矩阵键盘省引脚的本质是“分时复用”——4根行线逐行拉低,4根列线轮询回读,16个按键只需要8个引脚。但FPGA引脚状态在未按下时是悬空的,这对CMOS输入结构来说是致命的,必须用内部上拉电阻。Quartus的Pin Planner中可以直接给引脚分配Weak Pull-Up属性,但更可控的做法是在mimasuo.qsf文件里手写:

set_location_assignment PIN_AB7 -to row[0] set_location_assignment PIN_AA9 -to row[1] set_location_assignment PIN_AB9 -to row[2] set_location_assignment PIN_AA11 -to row[3] set_location_assignment PIN_AC12 -to col[0] set_location_assignment PIN_AB12 -to col[1] set_location_assignment PIN_AC9 -to col[2] set_location_assignment PIN_AB8 -to col[3]

引脚分配后,扫描时序通常是:四个节拍为一轮,每个节拍内只拉低一行,读四列,然后换下一行。这个循环频率不能太快——按键按下的物理抖动通常在5~20ms内,如果扫描频率高于抖动频率,同一个按键会被识别成多次触发。常用做法是每行扫描周期设在1~2ms左右,四行一轮扫描周期约8ms,这个节奏恰好能避开大部分机械抖动窗口。

扫描逻辑的VHDL实现(从jianpan.vhd中的设计思路整理):

process(clk_1k) begin if rising_edge(clk_1k) then case scan_cnt is when 0 => row <= "1110"; col_read <= col; when 1 => row <= "1101"; col_read <= col; when 2 => row <= "1011"; col_read <= col; when 3 => row <= "0111"; col_read <= col; when others => scan_cnt <= 0; end case; end if; end process;

clk_1k通常由开发板上的50MHz时钟分频而来,分频系数50000得到1kHz的扫描基频。row输出低电平驱动行线,col信号实时回读四位列线的电平状态。case when 0/1/2/3四个节拍覆盖了四根行线,每个节拍读一次列线,共16个组合。判键就在回读的瞬间发生——当某一行拉低时,某一列读到低电平,说明该行列交叉点的按键被按下。

3.2 消抖的工程取舍:延时计数器与边沿捕获

(在后续内容中继续展开)

按键消抖是FPGA入门项目里最容易忽略但影响体验最大的细节。机械开关的抖动模式不是固定的,按键按下瞬间触点反弹会产生一系列亚毫秒级的高频脉冲,这会让边沿检测电路误判成多次按键。大部分教学demo用一段固定延时(如10ms)来跨越抖动窗口,但这段延迟同时意味着你的系统对“快速连续按键”的响应上限被锁死了——用户必须在10ms窗口后才能按第二个键。

从这套工程的xiaodou.vhd来看,它用了计数延时消抖的思路:检测到电平跳变后启动一个计数器,计数到预设阈值(由时钟频率决定消抖时间)时再次读取按键电平,如果和跳变后的电平一致,认定按键稳定。这个消抖时间窗口一般设在10~20ms,比如50MHz时钟下,计数50万次就是10ms。下面是一段可直接复用的消抖模板:

signal key_raw : std_logic; signal key_deb : std_logic; signal cnt_deb : integer range 0 to 500000 := 0; process(clk) begin if rising_edge(clk) then if key_raw = key_deb then cnt_deb <= 0; else if cnt_deb = 500000 then key_deb <= key_raw; cnt_deb <= 0; else cnt_deb <= cnt_deb + 1; end if; end if; end if; end process;

这段逻辑里,key_raw是引脚直接采到的电平,key_deb是消抖后的稳定电平。cnt_deb的计数上限500000对应50MHz时钟下的10ms延迟,数值要是换成25MHz时钟,计数上限就要减半到250000——这个换算关系务必记住,很多人移植代码到别的板子后按键失灵,就是没改这个数。另外一个关键点是key_deb要用于后续边沿检测,不推荐直接用key_deb做电平触发,因为如果密码输入期间用户一直按着键,电平触发会让移位寄存器反复工作。正确做法是检测key_deb从高到低的跳变沿,生成一个单脉冲key_pulse

3.3 译码模块:把行列坐标映射到按键值

yima.vhd负责把行列坐标翻译成实际的数字。最常见的映射是4×4键盘的16个键位分别对应十六进制数字0~F,密码锁场景里通常只启用0~9这十个键。译码的核心是一个case语句——行列四位数据组合成8位std_logic_vector,按照你的硬件接线顺序查出对应键值:

process(row, col) variable key_row : integer; variable key_col : integer; begin case row & col is when "11100001" => key_out <= "0000"; -- 0 when "11100010" => key_out <= "0001"; -- 1 when "11010010" => key_out <= "0010"; -- 2 -- ... 按实际接线对应 when others => key_out <= "1111"; -- 无效键 end case; end process;

这里有个一致性陷阱:如果键盘是共阳/共阴结构或者模块板卡上的丝印方向不同,行与列的组合可能与上表不一致。调试时不要想当然——用万用表量出每个按键按下时是哪根行线与哪根列线短接,再反推case分支。key_out <= "1111"作为无效键的兜底值,能避免未定义状态导致的译码毛刺,这个习惯值得保留。

4. 密码锁整体仿真与Quartus时序验证

4.1 用Waveform.vwf搭一套完整仿真激励

(在后续内容中继续展开)

仿真才是这套工程最有分量的部分。压缩包里的Waveform.vwfWaveform1.vwf是Quartus II的波形激励文件,对应不同测试场景——前者大概率验证基础按键输入到开锁的路径,后者可能是带错误次数触顶后报警的路径。如果你刚接触Quartus,可以直接打开这两个文件看激励是怎么建立的:时钟周期设多少、按键脉冲插在哪个时间点、复位信号的释放时机。

自己动手搭激励时,建议用一段模板化的VHDL testbench,而不是完全依赖波形编辑器。原因在于波形编辑器的一次次手动拖拽难以复现,而testbench可以用循环语句批量生成按键序列。下面是一个针对主控模块的仿真激励骨架:

entity tb_mimasuo is end; architecture sim of tb_mimasuo is signal clk : std_logic := '0'; signal rst : std_logic := '0'; signal key_pulse : std_logic := '0'; signal key_in : std_logic_vector(3 downto 0) := "0000"; signal lock_open : std_logic; begin clk <= not clk after 10 ns; -- 50MHz process begin rst <= '1'; wait for 100 ns; rst <= '0'; -- 输入“12345678” for i in 0 to 7 loop key_in <= key_values(i); key_pulse <= '1'; wait for 20 ns; key_pulse <= '0'; wait for 200 ns; end loop; wait; end process; end sim;

wait for 200 ns的意义是模拟两次按键之间的间隔,现实中人的按键间隔远大于20ns,仿真给20~200ns只是为了快速跑完——真正仿真时你的按键脉冲宽度要参考消抖模块的时钟分频,否则消抖会把仿真中的按键脉冲统统滤掉,导致testbench里明明有按键输入而密码锁毫无反应。这是初学者最容易掉的坑:仿真和实板的时钟频率不一致,导致时延相关逻辑的行为天差地别。

4.2 从仿真波形反推状态时序

仿真的好处在你能同时看到内部信号和外部输出。Quartus的Waveform Editor里可以添加内部节点观察——右键插入mimasuo_inst|state这类层次化信号名就能看状态寄存器跳变。如果你加了信号却看不到,检查mimasuo.qsf里是否设置了global assignment,部分版本需要在Settings -> Compiler Settings -> Advanced Settings里勾选“Generate simulation files”相关选项。

从Waveform.vwf的波形中读状态跳转时,注意两个关键时间点:第一个是key_pulse结束后的下一拍,状态是否从S_IDLE跳到了S_INPUT;第二个是第8位输入结束后的下一拍,状态是否跳到了S_CHECKmatch_flag拉高,继而lock_open在下一拍拉高。这三拍的延迟关系就是状态机能正常工作与否的“心电图”。

4.3 时序报告里值得看的几个指标

工程里有一批mimasuo.sta.summarymimasuo.fit.rptmimasuo.timing.rpt之类的报告文件,很多人编译完成从来看都不看。对于密码锁这种低复杂度设计,最值得检查的是FmaxSetup Slack两个指标。Fmax低不代表设计错——但如果你密码锁里有高频分频器或者异步逻辑,Fmax会异常低,这时候优先怀疑是不是把组合逻辑写在了未约束的时钟域里。Setup Slack如果是负值,说明时序不收敛,可能出现在板卡高温或电压偏低时偶发按键失灵。对该工程来说,50MHz时钟下做到50ns级的Setup Slack并不难,如果你看到几百ns级别,恭喜,但说明你给的时钟约束太松了,真实硬件可能没你想的那么稳定。

5. 动态数码管显示与改密流程的状态扩展

(在后续内容中继续展开)

数码管显示是密码锁和用户交互的“面子工程”,但它的电路原理和键盘扫描是镜像关系——都利用了人眼的视觉暂留效应。seg_sweep0.vhd就是干这个的:8位数码管不可能每个都分配独立的数据线,必须分时点亮,每位数码管在极短的时间内单独亮一次,扫描完整一圈后人眼看到的是一组同时亮着的数字。

扫描频率的设计规则和键盘消抖不一样:数码管动态扫描一般取1kHz~2kHz的扫描频率,也就是每位点亮时间在0.5~1ms之间。低于500Hz会出现明显闪烁,高于5kHz且位数多时,占空比太低会亮度不足。该工程里用seg_sweep0.vhd配合一个BCD到七段码的译码逻辑完成显示刷新。如果你要显示密码的输入位数(比如输入到第几位,对应第几位数码管闪烁),需要额外加一个位选寄存器和闪烁控制信号——闪烁本质上是对位选信号做周期性的使能翻转,可以用一个低频分频时钟的toggle信号来门控位选。

改密流程是密码锁功能完整性的最后一环,也是状态机里最容易失控的部分。工程中改密状态S_CHANGE的处理逻辑是:在密码验证通过后,额外等待一个“功能键”事件,如果该事件到来,状态机转入改密模式,接下来的8次按键值被写入新的密码寄存器;如果改密中掉了某一位,必须有超时退出机制——否则用户只输入了6位新密码就停顿,状态机会一直卡在S_INPUT(改密分支),此时无论按哪个键都会被认为是新的密码位,而不是“取消改密”。一个稳妥做法是增加一个独立的idle计数,5秒无按键自动回到S_IDLE并丢弃不完整的改密输入。

6. 容易忽略但影响实板的几个验证技巧

(在后续内容中继续展开)

编译烧录见到“成功”两个绿字,只是起点。实际跑起来之前,有几个验证步骤比看仿真波形更能说明问题。

首先检查引脚锁定。Quartus编译时如果某个引脚分配无效,通常会给warning而不是error——这很操蛋,因为你的设计可能在仿真里完美,烧进板子后却“键盘没反应、数码管全灭”。建议在mimasuo.fit.rpt里搜Fitter Input,逐项核对行线、列线、数码管的位选与段选是否映射到预期的FPGA引脚。如果你发现自己用的开发板和工程原始板的引脚定义不同,直接改mimasuo.qsf文件里对应行,不要试图在GUI里一个个点——用文本编辑器批量替换更快。

其次要验证报警逻辑的实板响应。工程里报警的触发条件是连续错误3次,报警表现形式可能是LED闪烁或数码管显示特定字符。这里有个验证技巧:输入错码时要有意让系统进入报警状态,然后观察复位是否必须断电重来,还是按某个特定键就能退出报警。如果你希望报警后必须等待10秒才能重新输入,需要在S_ALARM状态里加一个计数器,这个计数器要独立于键盘扫描频率——否则如果报警逻辑里依赖key_pulse来计时,用户在报警期间按键会干扰计数。

最后是拨码开关或跳线导致的按键映射错乱。4×4键盘模块的输入输出方向在不同开发板上定义不同,甚至同一块板子的两个键盘接口方向都是反的。如果你发现按键值错乱,先不要怀疑VHDL——用QuartusChip Planner或者SignalTap II直接观察rowcol引脚的实时电平,按下每个键对比行列状态是否与yima.vhd中的case分支一致。SignalTap II是排查这类问题最快的工具,但需要额外占用片上RAM,这在这个工程剩余的存储资源里一般没问题。

还有一个操作技巧:密码锁烧录后,如果硬件段选显示“88”或全灭,大概率是数码管的共阴共阳接法搞反了——seg_sweep0.vhd里的七段码是按共阴数码管写的,换成共阳的模块板上就是反码。你可以在源文件里加一个特性参数来切换共阴共阳,而不是为两种数码管分别维护两份逻辑:

constant USE_COMMON_ANODE : boolean := false; seg_out <= not seg_cathode when USE_COMMON_ANODE else seg_cathode;

这种“用常量开关切换硬件属性”的写法,比复制整个显示模块要干净得多——后期如果你要换板卡,改一行常量就能适配所有模块的段选逻辑,不用去翻哪几个case分支要取反。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询