资讯动态

FPGA密码锁程序从零到上板:Verilog状态机与按键消抖完整实战

发布时间:2026/9/16 16:50:43 来源:尧图企业网站定制
简介面向FPGA入门与进阶学习者的密码锁实践工程基于Altera Quartus II开发采用VHDL描述数字逻辑适合课程设计、实验教学或自学硬件描述语言的读者参考。项目把按键输入、密码预设与存储、逐位比较、错误计数报警以及密码更改等模块落到可综合代码中呈现了从功能定义到硬件实现的完整思路。压缩包共83个文件约515KB以vhd源码、qpf与qsf工程配置、vwf仿真波形、pof编程文件以及rpt、summary和多种编译数据库为主覆盖设计、仿真、综合、适配与下载各阶段。已有863人学习读者可据此理解4×4键盘扫描时序、密码比较与错误阈值报警机制结合波形图和编译报告排查引脚约束与时序问题掌握Quartus II工程组织与VHDL调试方法源码按功能模块组织便于按需查阅与二次开发对课程设计与自学均较友好。1. 为什么密码锁程序是FPGA入门最该完整走一遍的项目FPGA 密码锁程序这个标题听起来像课程设计但它牵出的东西比本身值钱得多按键消抖、矩阵扫描、状态机、定时器、比对逻辑、仿真验证、引脚约束、烧录调试正好是 FPGA 开发的完整闭环用 Quartus 从零到上板跑通一遍后面做串口解析、倒车雷达、协议控制都是在复制这套结构。我见过不少让 FPGA 做密码锁的方案有纯组合逻辑硬拼的有拿单片机思路写个超长 always 块的这些都能亮灯但仿真和上板对不上问题几乎都出在输入侧没有做时序处理或者状态机把“比较”和“状态”揉在一起。这篇就按我实际做这个项目的顺序写先解决按键怎么进得来再把核心状态机写稳然后说清楚 Quartus 从建工程到仿真、约束、烧录的操作细节最后给上板后的验证手段。适合刚学完 Verilog 语法想做第一个完整项目的人也适合有经验的工程师当作可复用的外设模板。2. 按键输入侧:4x4矩阵扫描与Verilog消抖的必调参数2.1 机械抖动和亚稳态是密码锁的第一道坎绝大多数密码锁程序第一次上板翻车不是状态机写错而是按键输入没处理好。轻触开关的簧片从接触到稳定电平会在 5~20ms 内来回跳变对 50MHz 时钟来说这 20ms 里能采到上百万个边沿。如果不做处理一次物理按下会被逻辑层理解成好几次密码位数直接错乱表现为“按一下 1 进来三个 1”。更隐蔽的是亚稳态。按键信号的变化恰好落在时钟沿附近时触发器输出会在一段时间内既不是 0 也不是 1这个不稳定值如果被下一级直接采走你会看到完全不符合按键矩阵逻辑的键值比如按 1 进来的是 3。处理方法是固定的先用两级触发器同步再用计数器做连续多次采样确认。两级同步把亚稳态收敛到确定电平计数确认把机械抖动过滤掉。这两步做完按键才能安全地进入状态机。2.2 4x4矩阵键盘扫描与键值译码模块开发板上常见的是 4x4 矩阵键盘8 根线解决 16 个按键4 根列线做输出4 根行线做输入。扫描原理是让一列输出低电平其余列输出高电平然后读行线哪一行读到低就说明这个交点的按键被按下。扫描每 1ms 换一列完整扫一轮 4ms人手指按一个键至少保持几十毫秒时间上完全来得及。// keypad_scan.v — 4x4矩阵键盘扫描 module keypad_scan #( parameter CLK_HZ 50_000_000 )( input wire clk, input wire rst_n, output reg [3:0] col, // 列驱动, 低电平扫描 input wire [3:0] row, // 行回读, 低电平表示按下 output reg [3:0] key, // 键值 0~F output reg valid // 有键按下时高电平 ); reg [15:0] cnt; reg [1:0] col_idx; wire scan_tick (cnt CLK_HZ/1000 - 1); // 1ms 扫描节拍 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 16d0; col_idx 2d0; end else if (scan_tick) begin cnt 16d0; col_idx col_idx 2d1; // 换下一列 end else begin cnt cnt 16d1; end end always (*) begin case (col_idx) 2d0: col 4b1110; // 第0列拉低 2d1: col 4b1101; 2d2: col 4b1011; default: col 4b0111; endcase end always (*) begin key 4hF; // 无按键时返回F valid 1b0; case ({col_idx, row}) 6b00_1110: begin key 4h1; valid 1b1; end 6b00_1101: begin key 4h2; valid 1b1; end 6b00_1011: begin key 4h3; valid 1b1; end 6b00_0111: begin key 4hA; valid 1b1; end 6b01_1110: begin key 4h4; valid 1b1; end 6b01_1101: begin key 4h5; valid 1b1; end 6b01_1011: begin key 4h6; valid 1b1; end 6b01_0111: begin key 4hB; valid 1b1; end 6b10_1110: begin key 4h7; valid 1b1; end 6b10_1101: begin key 4h8; valid 1b1; end 6b10_1011: begin key 4h9; valid 1b1; end 6b10_0111: begin key 4hC; valid 1b1; end 6b11_1110: begin key 4hE; valid 1b1; end 6b11_1101: begin key 4h0; valid 1b1; end 6b11_1011: begin key 4hD; valid 1b1; end default: begin key 4hF; valid 1b0; end endcase end endmodule注意{col_idx, row}的 6 位拼接是组合逻辑译码某个按键在哪个行列取决于开发板原理图上板前要对着板子丝印和管脚映射表核对。key的默认值设成4hF是为了让“无按键”成为一个可过滤状态后面状态机里遇到 F 就直接忽略。扫描节拍CLK_HZ/1000是分频计数值不是把时钟真正切成 1kHz 方波这样避免在 FPGA 里产生除主时钟以外的第二时钟域综合和时序约束都更干净。2.3 两级同步加计数确认的消抖模块扫描给了键值但还没解决抖动。行线的抖动在扫描周期内一样存在正确的做法是把每个行输入信号做两级同步加计数消抖消抖后的电平才进入译码。这里给出一个可直接复用的消抖模块采用单时钟加采样使能的结构// key_debounce.v — 两级同步 计数确认 module key_debounce #( parameter STABLE_CNT 20 // 连续采样确认次数 )( input wire clk, input wire rst_n, input wire sample_en, // 1kHz 采样使能脉冲 input wire key_in, // 按键电平, 按下为低 output reg key_level, // 消抖后的稳定电平 output reg key_press // 按下瞬间的单周期脉冲 ); reg [5:0] cnt; // 计数到20, 6位足够 reg in_d1, in_d2; // 两级同步触发器 reg level_prev; always (posedge clk or negedge rst_n) begin if (!rst_n) begin in_d1 1b1; in_d2 1b1; key_level 1b1; // 复位为高 未按下 cnt 6d0; end else if (sample_en) begin in_d1 key_in; in_d2 in_d1; // 同步后的按键电平 if (in_d2 key_level) cnt 6d0; // 与当前稳定值一致, 计数清零 else if (cnt STABLE_CNT - 1) begin key_level in_d2; // 连续20次采样相同才翻转 cnt 6d0; end else begin cnt cnt 6d1; end end end always (posedge clk or negedge rst_n) begin if (!rst_n) level_prev 1b1; else if (sample_en) level_prev key_level; end assign key_press level_prev ~key_level; // 高到低跳变 endmodule采样使能的产生很简单主时钟分频一个 1ms 节拍即可计数器从 0 数到CLK_HZ/1000 - 1时输出一个高电平脉冲。这个模块有三个参数直接影响手感列出来方便调整参数推荐值作用调整方向采样使能周期1ms决定采样间隔间隔越小越灵敏消抖窗口 周期 × STABLE_CNTSTABLE_CNT20连续相同次数机械按键 20 次即 20ms薄膜按键可降到 5~8同步级数2 级消除亚稳态时钟高于 100MHz 建议加一级成 3 级key_press输出的是单周期脉冲只在检测到高电平变低电平的那一个时钟沿出现一次。状态机只认这个脉冲就不会出现“按住一个键被连续采样”的问题。可以看到这里避免了一个常见误用用always里的条件去判断按键值而不是判断脉冲结果一次按键变成了多次触发。2.4 扫描输出与消抖的接法:先消抖还是先扫描这个顺序容易踩坑。我一般建议对每个行信号单独调用key_debounce实例8 路线里行线接了弱上拉列线本来由扫描驱动不存在按键抖动问题。每个行线消抖后再进keypad_scan的译码译码结果valid天然就是稳定的。另一种常见做法是先扫描得到valid再对valid做一次边沿检测好处是例化少但按下和释放瞬间的临界片段如果恰好被扫描捕捉到仍然可能漏键或重键。开发板资源紧张时用这种简化的接法可以工整起见还是每根行线一个消抖实例Cyclone IV 的资源量对 4 个消抖模块完全没有压力。提示行线输入务必在引脚约束里打开弱上拉否则悬空时电平不确定消抖模块会随机翻转表现为开机后自动输入密码。3. 密码锁核心:FSM状态机与4位密码逐位比对3.1 五个状态的定义与转移条件密码锁的行为可以归纳成五个状态等待输入、接收密码、比对、解锁保持、错误锁定。我见过有人把比对结果写进组合逻辑然后直接驱动 LED看起来简单但“错误次数累计”和“锁定倒计时”没有地方放后续加功能必然重写。状态机才是这个项目该有的结构。状态进入条件离开条件输出表现IDLE复位完成或比对失败后重置收到第一个有效数字键全部 LED 灭INPUT收到数字键输满 4 位或者按确认键数码管显示已输入位数CHECK输入完成比对结束蜂鸣器短暂提示OPEN比对通过保持时间到绿灯常亮、继电器吸合LOCK错误次数达到上限30 秒倒计时结束红灯常亮、蜂鸣器报警把 CHECK 单独设为一个状态而不是让 IDLE 或 INPUT 直接判断结果好处是错误计数和转移条件集中在同一段case里仿真时波形上一眼能看到比对发生的位置。OPEN 和 LOCK 都靠同一个定时器计数只是到期时间不同这也是状态机的常见优化定时资源尽量复用。3.2 FSM主模块代码与逐位比对下面这段是顶层密码锁状态机的核心代码密码默认存成 1-2-3-4按键值来自前文扫描译码后的key_valuekey_valid必须是单周期脉冲// top_pwd_lock.v — 密码锁状态机主模块 module top_pwd_lock #( parameter CLK_HZ 50_000_000, parameter MAX_WRONG 3, // 允许错误次数 parameter LOCK_SEC 30 // 锁定秒数 )( input wire clk, input wire rst_n, input wire [3:0] key_value, // 键值 0~F input wire key_valid, // 单周期有效脉冲 output wire led_open, output wire led_lock, output wire buzzer ); localparam S_IDLE 3d0; localparam S_INPUT 3d1; localparam S_CHECK 3d2; localparam S_OPEN 3d3; localparam S_LOCK 3d4; reg [2:0] state; reg [3:0] pwd_in [0:3]; // 本次输入缓冲 reg [3:0] pwd_set [0:3]; // 存储密码 reg [1:0] idx; // 输入位数索引 reg [3:0] wrong_cnt; reg [31:0] timer; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin pwd_set[0] 4d1; pwd_set[1] 4d2; pwd_set[2] 4d3; pwd_set[3] 4d4; end end // 纯组合比较, 不依赖状态 wire match (pwd_in[0]pwd_set[0]) (pwd_in[1]pwd_set[1]) (pwd_in[2]pwd_set[2]) (pwd_in[3]pwd_set[3]); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; idx 2d0; wrong_cnt 4d0; end else begin case (state) S_IDLE: begin if (key_valid key_value ! 4hF) begin pwd_in[0] key_value; idx 2d1; state S_INPUT; end end S_INPUT: begin if (key_valid) begin if (key_value 4hC) begin // C 键确认 state S_CHECK; end else if (key_value 4hB) begin // B 键清空 idx 2d0; state S_IDLE; end else if (key_value ! 4hF) begin pwd_in[idx] key_value; if (idx 2d3) // 第4位输入完毕 state S_CHECK; else idx idx 2d1; end end end S_CHECK: begin if (match) begin state S_OPEN; wrong_cnt 4d0; end else begin wrong_cnt wrong_cnt 4d1; if (wrong_cnt 4d1 MAX_WRONG) state S_LOCK; // 错误满3次锁定 else state S_IDLE; end end S_OPEN: begin if (timer CLK_HZ) // 保持1秒 state S_IDLE; end S_LOCK: begin if (timer LOCK_SEC * CLK_HZ) // 锁定到时间 state S_IDLE; end endcase end end always (posedge clk or negedge rst_n) begin if (!rst_n) timer 32d0; else if (state S_OPEN || state S_LOCK) timer timer 32d1; else timer 32d0; end assign led_open (state S_OPEN); assign led_lock (state S_LOCK); assign buzzer (state S_CHECK) !match; endmodule几个关键点。pwd_in和pwd_set是 reg 数组对应 Verilog 里的存储器语义综合后会被映射成寄存器堆比用 4 个单独的 reg 拼接可读性好。match是纯组合比较不依赖 state这样它既可以在 CHECK 状态里当跳转条件也可以直接驱动蜂鸣器避免重复写一遍比较逻辑。错误计数wrong_cnt用wrong_cnt 4d1 MAX_WRONG判断是因为进入 CHECK 状态时wrong_cnt还是旧的计数值必须先加再比否则第三次错误会延后一个周期才锁定。如果按确认键时输入不满 4 位缓冲里的残留值会参与比较这属于设计取舍实际产品里可以在 S_INPUT 里加一个idx 2d3的前置判断让未满 4 位按 C 无效。3.3 锁定倒计时:计数器位宽是隐藏雷区定时器的位宽是密码锁项目里最容易翻车的细节。LOCK_SEC * CLK_HZ在 50MHz 时钟下是 30 × 5000 万 15 亿这个数用二进制表示需要 31 位因为 2 的 30 次方只有 10.7 亿。很多入门教程喜欢写reg [24:0] counter在这个场景里计数器到 3355 万就会回绕大约 0.67 秒表现为“锁定状态自己跳回 IDLE”锁了个寂寞。计数目标数值所需位宽1 秒 50MHz5×10^726 位30 秒 50MHz1.5×10^931 位60 秒 50MHz3×10^932 位工程上的习惯是位宽统一给 32 位不够了再扩不会为省几百个 LUT 去精算。逻辑综合工具通常会对未定义的截断报 Warning别只当没看见把LOCK_SEC * CLK_HZ的最大值代入$clog2算一次心里就有底了。仿真时把LOCK_SEC覆写成 1上板再改回 30这条经验在后面 Quartus 流程里还会用到。4. Quartus II 编译仿真与引脚约束的完整操作4.1 新建工程时的器件和EDA工具设置无论你用 Quartus Prime Lite 还是 Quartus II 13.1工程创建流程一样。New Project Wizard 里要特别注意三点工程目录和工程名必须全英文、不要带空格器件型号按开发板实际芯片选Cyclone IV 还是 MAX 10 都行不用刻意挑贵的最后一个页面 EDA Tool Settings 里把 Simulation 选成 ModelSim-Altera语言设为 Verilog。这一步不做后面想启动功能仿真时会发现 NativeLink 是灰的。Quartus II 13.1 自带配套的 ModelSim-Altera 10.1d安装时会提示是否装 ModelSim很多人一跳过后面就启动不了。Quartus Prime Lite 18.1 以后的版本不带 ModelSim 了分两条路要么单独装 Modelsim Intel FPGA Edition要么直接用 Prime 自带的 QSIM。QSIM 对几百行的密码锁项目完全够用波形界面朴素一点而已。我一般先新建工程接着写 RTL然后在 Assignments → Settings → EDA Tool Settings → Simulation 里确认 Tool name 和 Testbench 编译路径避免编译到一半才发现选错了工具链。4.2 Testbench里模拟“按下1234”的波形验证功能仿真的核心是让状态机快速跑通“输入正确密码→解锁”和“输入错误密码→锁定”两条路径。写 Testbench 时用 task 封装按键动作是标准做法避免在每个时序点重复写key_valid的拉高拉低timescale 1ns/1ps module tb_pwd_lock; reg clk 0, rst_n 0; reg [3:0] key_value; reg key_valid; wire led_open, led_lock, buzzer; always #10 clk ~clk; // 50MHz // 仿真时把 CLK_HZ 覆写成 1000, 让1秒保持变成20us仿真时间 top_pwd_lock #(.CLK_HZ(1000)) dut ( .clk(clk), .rst_n(rst_n), .key_value(key_value), .key_valid(key_valid), .led_open(led_open), .led_lock(led_lock), .buzzer(buzzer) ); task press_key(input [3:0] k); begin key_value k; (posedge clk); key_valid 1b1; (posedge clk); key_valid 1b0; repeat(10) (posedge clk); // 间隔10个时钟模拟手指抬起 end endtask initial begin rst_n 0; repeat(5) (posedge clk); rst_n 1; press_key(4d1); // 正确密码 1 2 3 4 press_key(4d2); press_key(4d3); press_key(4d4); // 观察 led_open 拉高一个周期, 随后回到 IDLE repeat(2000) (posedge clk); press_key(4d1); // 错误密码 1 2 3 5 press_key(4d2); press_key(4d3); press_key(4d5); // 观察 led_lock 拉高, 开始 30s 倒计时 repeat(2000) (posedge clk); $stop; end endmodulepress_key里的(posedge clk); key_valid 1b1;是先等一个时钟沿再拉高保证赋值不是写在时钟沿上避免仿真器出现建立时间警告。关键波形看三处第一次输完 4 位后led_open拉高说明 match 判定正确state从 S_CHECK 走到 S_IDLE说明定时结束错误密码后wrong_cnt从 1 递增到 3第三次触发 S_LOCK。把CLK_HZ在仿真里覆写成 1000 是常见技巧仿真时钟仍按#10跑 50MHz但逻辑里的“1 秒”变成了 1000 个时钟周期整个用例不会跑出几百万个 cycle。4.3 ModelSim启动失败的常见原因与对应处理搜索“quartus ii cannot launch modelsim”能看出这是高频问题基本就是下面四种情况排查顺序也按此来。现象原因处理启动仿真提示 cannot launch工程路径或系统用户名含中文把工程复制到纯英文目录重新 Open Project找不到 vsim 可执行文件ModelSim-Altera 没装或没配套版本Quartus II 13.1 配 10.1dPrime Lite 用 QSIM 兜底NativeLink 设置失效EDA Tool Settings 里没选 Tool nameAssignments → Settings → Simulation 选 ModelSim-Altera启动后波形窗口空白32/64 位混用检查 Quartus 和 ModelSim 安装目录里的 bin 是 win32 还是 win64保持一致其中路径带中文是最坑的Quartus 对 Unicode 路径支持一直不算好报错信息还不直接。排查时分两步先看 Assignments → Settings → EDA Tool Settings 里的 Tool name 和 Testbench 是不是都选对再打开 ModelSim 手动跑一遍vsim work.tb_pwd_lock能起来就说明工具链没问题是 Quartus 的启动配置问题起不来就先解决路径和位数。4.4 引脚约束、弱上拉设置与烧录编译通过只是第一步引脚约束不对下载到板子上一样不工作。建议直接改 .qsf 文件比在 Pin Planner 里一格格点快内容格式如下set_location_assignment PIN_E1 -to clk set_location_assignment PIN_M1 -to rst_n set_location_assignment PIN_L1 -to led_open set_location_assignment PIN_L2 -to led_lock set_location_assignment PIN_R8 -to col[0] set_location_assignment PIN_T8 -to col[1] set_location_assignment PIN_R9 -to col[2] set_location_assignment PIN_T9 -to col[3] set_location_assignment PIN_U9 -to row[0] set_location_assignment PIN_V9 -to row[1] set_location_assignment PIN_AB3 -to row[2] set_location_assignment PIN_AB4 -to row[3] set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to row[0] set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to row[1] set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to row[2] set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON -to row[3] set_instance_assignment -name IO_STANDARD 3.3-V LVTTL -to *上面的引脚编号是示意实际要看开发板原理图。弱上拉这句别漏否则行线悬空时消抖模块会乱翻。IO 电平统一设成 3.3-V LVTTL 是大多数廉价开发板的默认如果板上有电平转换芯片就按芯片手册改。全部约束后重新 Analysis Synthesis 和 Fitter看 Assignment 相关 Warning没有红色错误才能进 Programmer。Cyclone IV 下载.sof到 SRAM断电就丢MAX 10 用.pof可以固化到内部 flash这点在产品化时很重要。5. 上板验证:用状态编码、SignalTap和仿真短参数抓问题5.1 让每颗LED报告状态机的当前位置板子上没有屏幕时LED 就是最便宜的状态指示。把状态机的 3 位state直接引到 3 颗 LED 上比你用数码管逐位显示密码更直观因为你看到的是逻辑内部而不是经过译码的间接结果。状态state 编码LED 显示IDLE000全灭INPUT001输入几位就亮几盏CHECK010全部熄灭一瞬OPEN0113 盏全亮LOCK100每秒快闪按错密码到第三次时如果 LOCK 灯闪起来了说明错误计数和跳转正常问题就锁定在定时器位宽或者时钟频率参数上这时候去看timer是不是提前回绕。5.2 SignalTap II直接观察FSM内部寄存器LED 只能看状态跳不跳看不到具体数值比如wrong_cnt是 2 还是 3。Quartus 自带的 SignalTap II Logic Analyzer 可以实时抓内部信号而且不用改 RTL。操作路径是 Tools → SignalTap II Logic Analyzer新建一个 instance把时钟设为clk把state、wrong_cnt、key_valid、led_open加进信号列表采样深度设 2K 条目触发条件设为state 4也就是 LOCK 状态然后重新编译并下载。有一点要注意SignalTap 只能抓到综合网表里存在的信号。如果某个寄存器被优化掉了信号列表会显示不可用解决办法是在 RTL 里加综合属性(* keep *) reg [2:0] state; (* keep *) reg [3:0] wrong_cnt;keep属性告诉综合工具保留这个寄存器不要因为它在综合过程中看起来不会被使用就删除。抓到的波形里能清楚看到wrong_cnt递增长到 3 后state跳到 LOCK比你在 ModelSim 里对着仿真波形猜上板行为准确得多。5.3 把20ms消抖变成仿真短参数上板验证到只剩消抖手感的问题时你大概率已经受够了仿真里那 20ms。功能仿真时 20ms 意味着 100 万个时钟周期一个用例跑下来几分钟就没了。我的做法是给顶层加一个仿真宏让消抖计数和定时参数在仿真和综合两种配置下分别取值ifdef SIM localparam STABLE_CNT 2; // 仿真: 2ms 就认 else localparam STABLE_CNT 20; // 上板: 20ms 才认 endif在 ModelSim 里通过命令vlog defineSIM或 Quartus 的 EDA Tool Settings 里加宏定义来启用。这样 RTL 仿真在几毫秒内就能跑完整个开锁流程而综合时宏关闭芯片里的消抖窗口仍然是真实的 20ms。同样的宏还可以缩短 OPEN 保持和 LOCK 倒计时等全部用例通过再把宏删掉做一次完整综合最后用 SignalTap 验证宏关闭后的时序行为这一套下来密码锁程序的验证就闭环了。本文还有配套的精品资源点击获取

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

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

免费获取报价