ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA电子密码锁设计:从按键消抖到状态机控制的完整实践

2026/9/11 20:47:17 拓冰建站 浏览量
FPGA电子密码锁设计:从按键消抖到状态机控制的完整实践 简介基于FPGA的电子密码锁设计是一份面向FPGA学习者和数字逻辑课程设计的工程资源包围绕六位十进制密码锁的完整实现展开涵盖密码输入、逐位确认、左移显示和正确性判断等功能逻辑。系统通过四个独立按键分别控制系统启动、密码自增、密码自减和确认操作密码确认键需按六次完成输入密码正确则绿灯点亮系统运行期间红灯常亮作为状态指示默认密码可在源码中直接修改。压缩包共147个文件大小约3.36MB以Quartus工程相关文件为主包括工程配置文件qsf与qpf、原理图bdf、源代码v与tdf、功能模块bsf、仿真波形vwf以及可烧录的sof与pof文件能够直接打开工程进行编译、仿真和下载。已有1085人学习下载适合课程设计、FPGA入门或毕业设计参考。借助这份资源可以学习状态机设计、按键消抖、数码管动态扫描、时钟分频以及模块化顶层连接等常用技巧同时源码结构完整便于根据需求修改密码或扩展功能。1. 基于FPGA的电子密码锁设计从按键消抖到状态机控制电子密码锁是个典型的FPGA入门到进阶的练手项目但它绝不是“点亮几个LED”那种级别。很多人第一反应是拿单片机做但FPGA方案的价值在于用硬件描述语言把输入捕获、状态迁移、输出驱动这一整条控制链路显式地建模出来。你在写Verilog的过程中不得不去思考时钟域、亚稳态、边沿检测、状态编码这些真正影响电路质量的问题而不是像在MCU里那样用顺序执行的思维方式把逻辑“糊”过去。本文会按照一条完整可落地的设计路径展开先把按键输入和密码存储这两个基础模块做扎实再重点拆解核心的状态机控制逻辑然后解决密码锁必须面对的错误锁定和倒计时解锁机制最后把结果显示和告警输出这些外围驱动收尾。整个设计基于Xilinx Artix-7系列FPGA开发环境为Vivado。你可以用任何主流厂商的芯片和工具链跟着实现核心思想完全通用。2. 按键输入处理与密码存储先把“手抖”和“掉电”两个坑填平2.1 为什么机械按键在FPGA里不能直接接逻辑机械按键按下和释放的瞬间触点会因物理弹性产生持续5到20毫秒的抖动。如果你把这串抖动的电平直接送进状态机一次按键会被识别成好几次触发轻则密码输入错乱重则在连续错误计数逻辑里直接误判。很多刚接触FPGA的人在这里犯的第一个错误就是想在状态机里“等抖动过去”结果把自己绕进了时序泥潭。处理抖动的正确思路是在按键信号进入系统逻辑之前就用专门的模块把它“洗干净”。我常用的方案是计数延时消抖检测到电平变化后启动一个计数器持续采样一段时间如果这段时间内电平保持稳定才确认这次变化有效。module debounce #( parameter CLK_FREQ 50_000_000, parameter DELAY_MS 10 )( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); localparam CNT_MAX CLK_FREQ / 1000 * DELAY_MS; reg [31:0] cnt; reg key_tmp; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 32d0; key_tmp 1b1; key_out 1b1; end else begin if (key_in ! key_tmp) begin cnt 32d0; key_tmp key_in; end else if (cnt CNT_MAX) begin key_out key_tmp; end else begin cnt cnt 1b1; end end end endmodule代码的逻辑分三段第一段是用key_in ! key_tmp检测电平跳变一旦发现变化就重置计数器第二段是当电平在设定时间内保持稳定就把这个稳定电平输出第三段是计数器递增。这样做的效果是按键按下后需要稳定持续10毫秒才会被确认有效抖动脉冲因为持续时间太短会被自动过滤掉。CNT_MAX的计算是关键。在50MHz时钟下10毫秒对应500000个时钟周期所以计数器的位宽至少要留到32位。如果你想调整消抖灵敏度改DELAY_MS参数即可机械按键建议10到20毫秒轻触开关可以适当降到5毫秒。需要注意的是这个模块用的是单比特输入如果你的设计里有多个按键需要例化多份模块或者把位宽扩展成向量。2.2 密码比较的三种方案寄存器阵列、ROM查表、分布式RAM密码存储和比较是密码锁的核心运算。最直观的做法是把正确密码按位存在寄存器阵列里输入一位比对一位。但这样做有个隐患如果密码需要经常修改比如提供给不同用户每次改密码都要重新综合布线这在工程实践里完全不可接受。我一般会把正确密码固化在ROM或者分布式RAM里。ROM方案适合固定出厂密码用case语句写一个查表逻辑综合后会映射到LUT实现RAM方案则支持上电后通过配置接口写入密码。第三种方案是用移位寄存器构造一个循环缓冲区每次按键输入都把新数据移入同时和存储的密码逐位比较。module password_check #( parameter PASS_WIDTH 16 )( input wire clk, input wire rst_n, input wire [3:0] key_value, input wire key_valid, output reg pass_match, output reg [3:0] input_shift ); reg [PASS_WIDTH-1:0] stored_pass; reg [PASS_WIDTH-1:0] temp_shift; // 默认密码:1234BCD编码 initial begin stored_pass 16h4321; end always (posedge clk or negedge rst_n) begin if (!rst_n) begin temp_shift 16d0; input_shift 4d0; pass_match 1b0; end else if (key_valid) begin // 新按键移入低位原数据左移 temp_shift {temp_shift[11:0], key_value}; input_shift input_shift 1b1; if (input_shift 4d3) begin if (temp_shift stored_pass) begin pass_match 1b1; end else begin pass_match 1b0; end end end end endmodule这个模块用了一个很关键的移位技巧{temp_shift[11:0], key_value}把新输入的4位BCD码放到最低位原来的数据整体左移。每按一个键密码就“推进”一格。当输入位数达到4位时进行一次完整比较。需要注意的是input_shift计数到3就表示已经收到4个有效按键0到3此时判断temp_shift与stored_pass是否相等。这种移位方式的好处是密码位数改变时只需要调整PASS_WIDTH参数比较逻辑不需要大改。2.3 输入位数自适应与清零机制实际使用中用户可能输入超过4位密码这时候需要决定是直接报错还是只取最后4位。从用户体验出发我倾向于采用“滚动覆盖”策略超过4位的输入只保留最近4位参与比较输入过程中随时可以按清除键归零。这样实现起来只需要在状态机里加一个clear信号把temp_shift和input_shift同步清零。这里还要考虑一个位宽匹配问题如果密码长度恰好是16位但输入了20位temp_shift在高位会溢出丢弃。Vivado综合时会给出warning提示你需要确认这个行为是你想要的——如果不想丢弃数据可以把temp_shift的位宽扩展到PASS_WIDTH 4比较时只取低16位。这个细节看似不起眼但在实际调试中会导致密码总是核对不上。3. 核心状态机控制逻辑把“开锁”这件事拆成可验证的电路状态3.1 状态迁移图先行四个状态怎么定义密码锁的控制逻辑本质上是一个有限状态机。我见过不少人上来就写always块写到一半发现控制信号满天飞。正确做法是先画状态图明确每个状态下的输入输出行为。经典的状态划分包含四个IDLE等待输入、INPUT接收密码、VERIFY校验结果、UNLOCK开锁状态。看起来简单但每个状态都有细节要处理。IDLE状态下如果有按键按下要判断是数字键还是功能键INPUT状态下要实时显示当前已输入的位数VERIFY状态下必须保证校验只执行一个时钟周期避免重复触发UNLOCK状态下要有解锁保持时间通常5秒后自动回到IDLE。状态编码我用独热码one-hot四个状态用4位寄存器每个状态只占一位。虽然比二进制编码多用寄存器资源但FPGA的触发器资源非常充裕独热码的组合逻辑更简单状态迁移路径清晰时序收敛也更容易。3.2 错误计数与锁定判定3次输错不是随便定的密码锁必须要有防暴力破解机制。连续输错N次锁定一段时间这个N的选择有讲究。取3次是个比较合理的值对正常用户来说偶尔手滑输错一两次不会触发锁定对暴力尝试来说3次之后就强制暂停把猜测速率从每秒几次降到每30秒一次。parameter MAX_ERROR 3; reg [1:0] error_cnt; reg [31:0] lock_timer; reg locked; always (posedge clk or negedge rst_n) begin if (!rst_n) begin error_cnt 2d0; locked 1b0; lock_timer 32d0; end else if (verify_fail !locked) begin if (error_cnt MAX_ERROR - 1) begin locked 1b1; lock_timer 32d0; error_cnt 2d0; end else begin error_cnt error_cnt 1b1; end end else if (locked) begin if (lock_timer LOCK_CYCLES) begin locked 1b0; end else begin lock_timer lock_timer 1b1; end end end锁定的持续时间LOCK_CYCLES用参数化定义我习惯默认设30秒。注意这里error_cnt用的位宽是2位因为它的取值范围只有0到3超过3就会被强制回零。判断条件是error_cnt MAX_ERROR - 1即当前错误计数已经累计到2次那么这次再失败就是第3次直接触发锁定。这样写的好处是参数修改时只需改MAX_ERROR不需要动逻辑结构。3.3 解锁后自动回锁定时器该由谁来驱动当状态机进入UNLOCK状态后电磁锁或继电器需要保持一段时间的吸合让用户推门或拉门。这个时间通常在3到8秒之间。实现方式是在UNLOCK状态里置位一个unlock_signal同时启动一个倒计时计数器。需要特别注意的一点是这个定时器应该用统一的系统时钟驱动不要用低频时钟。在Vivado里如果产生多个时钟域后续可能会遇到跨时钟域的问题。简单的做法是在UNLOCK状态里用累加器计算时间达到阈值后状态机自动跳回IDLE。显示模块可以在此期间显示“OPEN”字样提示用户门锁已打开。4. 显示与告警模块数码管扫描和蜂鸣器的时序配合4.1 段选与位选的级联驱动方式密码锁的输出信息主要是两块已输入的密码位数和当前系统状态。常见的载体是4位一体数码管和蜂鸣器。数码管驱动要处理“动态扫描”的问题——人眼的视觉暂留效应让我们看起来4位数码管同时点亮实际上它们是轮流导通的。module seg_driver #( parameter SEG_WIDTH 8 )( input wire clk, input wire rst_n, input wire [15:0] display_data, output reg [7:0] seg_sel, output reg [3:0] digit_sel ); reg [15:0] scan_cnt; reg [1:0] digit_index; always (posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt 16d0; digit_index 2d0; end else begin if (scan_cnt 16d10000) begin // 1kHz扫描频率 scan_cnt 16d0; digit_index digit_index 1b1; end else begin scan_cnt scan_cnt 1b1; end end end always (*) begin case (digit_index) 2d0: begin digit_sel 4b0111; seg_sel seg_decode(display_data[3:0]); end 2d1: begin digit_sel 4b1011; seg_sel seg_decode(display_data[7:4]); end 2d2: begin digit_sel 4b1101; seg_sel seg_decode(display_data[11:8]); end 2d3: begin digit_sel 4b1110; seg_sel seg_decode(display_data[15:12]); end endcase end function [7:0] seg_decode; input [3:0] bin; begin case (bin) 4h0: seg_decode 8hC0; 4h1: seg_decode 8hF9; 4h2: seg_decode 8hA4; 4h3: seg_decode 8hB0; 4h4: seg_decode 8h99; 4h5: seg_decode 8h92; 4h6: seg_decode 8h82; 4h7: seg_decode 8hF8; 4h8: seg_decode 8h80; 4h9: seg_decode 8h90; default: seg_decode 8hFF; endcase end endfunction endmodule扫描频率的选择需要权衡太低了数码管会明显闪烁太高了可能导致显示不完整或产生重影。1kHz的扫描频率意味着每位数码管每1毫秒刷新一次4位循环一遍只要4毫秒人眼完全感觉不到闪烁。scan_cnt从0数到10000在50MHz时钟下正好是0.2毫秒所以digit_index每0.2毫秒切换一次实际每位点亮时间也是0.2毫秒。段码表里用了共阴极数码管的编码方式seg_decode函数把BCD码映射成8位段选信号。如果你用的是共阳极数码管把整个数组按位取反即可。这里我把段选和位选都定义成输出reg用组合逻辑产生避免引入不必要的寄存器延迟。4.2 错误告警的三种模式短鸣、长鸣、连续快响蜂鸣器的驱动不能只是简单地给高低电平要区分不同的告警场景。按键按下时给一个短促的提示音约50毫秒密码错误时给一个较长的蜂鸣提示约200毫秒触发锁定时用连续快响应提醒用户系统已进入保护状态。module buzzer_ctrl ( input wire clk, input wire rst_n, input wire key_beep, input wire error_beep, input wire lock_beep, output reg buzzer_out ); reg [31:0] beep_timer; reg [2:0] beep_type; always (posedge clk or negedge rst_n) begin if (!rst_n) begin buzzer_out 1b0; beep_timer 32d0; beep_type 3d0; end else if (key_beep) begin beep_type 3d1; beep_timer 32d2_500_000; // 50ms 50MHz end else if (error_beep) begin beep_type 3d2; beep_timer 32d10_000_000; // 200ms end else if (lock_beep) begin beep_type 3d3; beep_timer 32d5_000_000; // 100ms end else if (beep_timer 0) begin beep_timer beep_timer - 1b1; case (beep_type) 3d1: buzzer_out 1b1; 3d2: buzzer_out 1b1; 3d3: buzzer_out (beep_timer[15] 1b1); // 内部闪烁 default: buzzer_out 1b0; endcase end else begin buzzer_out 1b0; end end endmodule这个模块的优先级很关键key_beep、error_beep、lock_beep三个输入是互斥的每次只会有其中一个拉高所以用if else if依次判断不会冲突。定时器的值是根据CLK_FREQ换算出来的50MHz时钟下50毫秒就是2500000个周期。锁定模式下用beep_timer[15]作为高频调制信号产生“嘟嘟嘟”的断续音效这和单纯长鸣在用户感知上区别明显。4.3 显示内容的优先级状态提示比数字输入更重要数码管显示区域有限需要确定显示优先级。我的做法是锁定状态下任何按键输入都不再更新显示只显示锁定的倒计时剩余时间解锁成功后显示“OPEN”持续3秒再回到输入前的状态。这样用户在任何时刻扫一眼数码管就能理解系统当前在做什么。倒计时显示需要把秒数转换成BCD码可以直接用除法取余但如果追求资源优化可以用一个减法计数器配合查表实现。对于入门项目直接用除法器也没问题——Artix-7上有现成的DSP切片做算术运算综合工具会自动映射。5. 参数调整与调试技巧把每个可调点变成可测试的接口5.1 分频系数与扫描频率的计算基准整个设计中涉及多个频率相关参数它们之间存在联动关系。我用一张表把常用的基准值列出来方便你在改时钟频率时快速换算用途默认持续时间/频率50MHz下的计数值计算公式按键消抖10ms500000CLK_FREQ / 1000 × 10数码管扫描500Hz100000CLK_FREQ / 1000 × 2短提示音50ms2500000CLK_FREQ / 1000 × 50密码错误音200ms10000000CLK_FREQ / 1000 × 200锁定保持30s1500000000CLK_FREQ × 30需要33位寄存器LOCK_CYCLES需要扩展寄存器位宽是很容易被忽略的一点。30秒在50MHz下是15亿个周期而32位无符号数的上限约42.9亿虽然不会溢出但如果你在计算时不小心用了有符号数就会在超过21亿时变成负数。我在调试过程中就遇到过这种“灵异现象”——锁定期满后系统非但不解锁反而像是重新进入锁定状态最后定位到是有符号位宽导致的溢出。5.2 Vivado中综合后仿真与在线逻辑分析仪的配合仿真和上板调试的侧重点不同。行为仿真阶段你主要验证状态机的迁移是否正确用Testbench给各个输入信号注入激励观察输出是否符合预期。这个阶段不需要考虑时序时钟周期可以设得很短以加快仿真速度。上板之后硬件调试要借助ILA集成逻辑分析仪或者Vivado的硬件管理器。ILA可以实时抓取内部信号波形对调试状态机非常有帮助。我建议把下面这些信号接到ILA观测列表里current_state当前状态编码error_cnt错误计数lock_timer[31:0]锁定倒计时key_valid按键有效性脉冲temp_shift[15:0]移动中的密码寄存器有了这些信号当密码锁表现异常时你能快速判断是按键没被正确捕获、状态迁移没发生、还是密码比较逻辑出错。比对着LED灯瞎猜要高效得多。5.3 密码可配置化的实现路径从Project到实用产品的关键一跃课设版密码锁把密码写死在源码里即可但如果你想让这个设计具备产品雏形需要把密码改成可配置的。常见做法是增加一组拨码开关或一个简单的串口接口在系统上电时读取配置信息写入RAM。由于FPGA掉电后配置丢失你需要额外的非易失存储。Xilinx FPGA可以在配置完成后通过内部SPI控制器访问外部Flash或者用板上EEPROM。需要注意的是这部分逻辑会增加设计复杂度但它在工程实践中的价值很直接用户可以随时更换密码而不需要重新编译固件。如果你用的是带软核的FPGA比如Zynq把密码存储在ARM端的文件系统里更简单。但对于纯FPGA实现一个可上电加载的RAM模块加上一个配置状态机就足够了。5.4 时序收敛与跨时钟域的两个必查点设计频率不高时时序约束通常一次就能通过但有两个点需要特别注意按键输入到状态机的路径可能因消抖模块内部寄存器而引入延迟导致按键响应慢半拍显示扫描逻辑里的组合逻辑路径如果过长在高频率下可能不满足建立时间要求。解决思路所有输入信号必须先经过同步器两级触发器再进入系统逻辑这是避免亚稳态的标准做法组合逻辑如果过长在中间插入寄存器做流水线切分。对于50MHz的系统时钟前一个必查点是硬件正确性的关键后一个则更多是给将来升级预留余量。调试时如果遇到按键偶尔“失灵”或数码管显示跳变优先检查是否缺少同步器。本文还有配套的精品资源点击获取