基于FPGA的电子密码锁设计:Verilog实现与仿真调试全解析
2026/9/17 13:21:16 网站建设 项目流程

简介:《基于FPGA的电子密码锁设计》是一份面向电子信息类本科毕业设计的中文技术文档,适合正在选题、开题或需要快速搭建FPGA数字系统方案的学生参考。文档以电子密码锁为研究对象,先对比机械锁与单片机电子锁的差异,指出单片机方案程序容易跑飞、可靠性差的不足,继而阐述用FPGA硬件电路实现算法以提高系统可靠性的设计理念,并说明现场可编程带来的升级便利;整体覆盖了FPGA设计流程、VHDL语言结构、自顶向下设计方法,以及Quartus II平台下的工程实现等关键内容。资源包共1个doc文件,大小约942KB,正文包含摘要、关键词、目录、绪论、关键技术简介与系统设计等模块,可作为论文写作和方案论证的完整参照。目前已有205人学习,适合需要理解FPGA应用方法、EDA工具操作流程或电子密码锁实现细节的读者参考。

1. 毕业设计选 FPGA 做电子密码锁,到底在练什么

每年毕业季都能看到《基于FPGA的电子密码锁设计》这个题目,它出现的频率高到几乎成了数字逻辑课程的“期末大作业 Plus”。很多同学第一反应是:密码锁用单片机做不香吗?几十行 C 代码加一个矩阵键盘就能跑起来,为什么要用 FPGA 和自己较劲?这个反问本身,恰恰是这个题目存在的意义——它根本不打算让你做一个能卖钱的产品,而是让你在 8 周时间里把数字电路的核心基本功全部过一遍:同步时序设计、跨时钟域处理、按键消抖、状态机编码、数码管动态扫描,再加上 Modelsim 仿真和板级调试。这些东西在单片机上被库函数和中断屏蔽掉了,在 FPGA 上全部暴露在你面前。

如果你正在为选题纠结,或者已经拿到题但不知道从哪下手,这篇文就按我的习惯带你从头到尾理一遍。我会从模块划分讲到具体 Verilog 代码,到仿真和上板验证,最后补充几个能让答辩老师眼前一亮的进阶技巧。全程不依赖任何开发板厂商的定制 IP,通用的 Xilinx 或 Altera 板子都能直接上手。

2. 密码锁的功能拆解与 FPGA 内部模块划分

电子密码锁听起来像个小系统,但只要把它拆成“输入-判断-输出-复位”这条链路,就会发现它其实是一个特别标准的数字系统设计题目,非常适合用自顶向下的方法来做设计。

2.1 系统整体功能需求表:先搞清楚要做什么

在做任何代码之前,先把需求用白纸黑字固定下来。下面是电子密码锁最基础的指标描述,我给它做成了需求规约表格:

功能项规格说明
密码位数4 位(也可以设计为 6 位,但毕业设计按 4 位写,逻辑简单且容易演示)
键盘输入4x4 矩阵键盘,0-9 数字键 + A/B/C/D 功能键
开锁机制顺序输入 4 位密码后按确认键,比对正确且密码有效期内触发开锁信号
错误处理密码错误输出错误指示;连续错误 3 次进入 10 秒锁定状态
数码管显示输入位数提示,用 6 位数码管左起显示当前输入的位置计数
时钟频率开发板系统时钟 50MHz,分频得到扫描时钟和消抖计时基准
复位机制异步复位,上电自动进入待输入状态

这里有一个很关键的模块边界问题:蜂鸣器、LED 指示灯这类设备,驱动逻辑极其简单,没必要单独开模块。把它们放到顶层文件的 assign 语句里即可,把模块粒度控制在“复杂逻辑单独成模块”的水平。

2.2 模块间信号流与数据通路设计

基于上面的功能表,我一般会把设计分成 6 个模块:分频器(clk_div)、按键消抖(debounce)、键盘扫描(key_scan)、密码控制核心(controller)、数码管动态扫描(seg_display)、锁定定时器(lock_timer)。模块之间的关系如下。

键盘扫描模块按行扫描的方式捕捉按键动作,输出一个按键值 key_value 和一路数据有效脉冲 key_pulse。由于机械按键存在抖动,key_pulse 不能直接送进状态机,必须先过 debounce 模块。消抖输出的稳定按键值进入 controller 模块,这个模块里跑着一个摩尔型状态机,专门负责密码比对和状态跳转。controller 的输出包括密码正确标志、错误标志、当前输入位数,它们分别去驱动 LED/蜂鸣器和数码管模块。锁定定时器只在错误次数达到 3 时被控制器启动,期间控制器暂停响应所有按键输入。

这个数据通路里最值得注意的设计决策是把消抖放在扫描之后。有些教材把消抖放在模块最前端,也就是先消抖再扫描。实际效果差别不大,但先扫描后消抖有一个好处:扫描模块只负责产生“某个按键被按下”的事件,消抖模块可以对同一个事件连续采样 N 次,而不需要分辨具体是哪一行哪一列的电平变化,这样消抖状态机可以写得很干净。

2.3 为什么用三段式状态机而不是一段式

controller 模块是整个设计的灵魂。密码锁的控制逻辑用状态机来描述非常自然,常见实现有一段式、两段式和三段式,其中三段式是 FPGA 开发中的主流推荐写法。

三段式状态机在代码结构上必须分成三个 always 块。第一块负责状态寄存器的时序更新,第二块负责组合逻辑的次态判断,第三块负责输出赋值。它相对于一段式的最大优势是输出信号不会产生组合逻辑毛刺。在密码锁场景里,开锁信号如果出现一个 10ns 的毛刺,就可能让电磁锁误动作一下,这在演示现场非常尴尬。

使用三段式还有一个隐藏的好处,便于调试。在 Modelsim 仿真里可以直接把状态寄存器的值引到波形窗口,查看当前处于哪个状态。如果使用一段式,状态和输出耦合在一起,波形看起来会乱得多。

代码结构确定之后,接下来的章节会给出 core 模块的完整 Verilog 实现,包括业界常用的一种“打两拍同步 + 计数确认”的消抖结构和状态机的详细跳转逻辑。

3. 核心模块的 Verilog 实现:从消抖到状态机

原理讲清楚之后,本节直接进入代码实现阶段。我选用 Verilog HDL 来描述整个设计,开发环境以 Vivado 为例,代码风格是完全可综合的寄存器传输级风格,不是仿真专用代码。如果你用的是 Quartus,代码可以原封不动地拿过去用,只在管脚约束文件上需要做对应修改。

3.1 按键消抖与键盘扫描的逻辑层次

按键消抖这个问题,在单片机里面一个 delay(20ms) 就解决了。在 FPGA 里,任何延时都不能用阻塞式的等待来实现,否则整个系统时钟都会被卡住。正确做法是使用计数器对时间进行量化。

module debounce #( parameter CNT_MAX = 1_000_000 // 50MHz时钟下 20ms 计数上限 )( input wire clk_50m, input wire rst_n, input wire key_in, // 来自矩阵键盘扫描模块的原始按键事件 output wire key_pulse // 消抖完成后的单周期脉冲 ); reg [19:0] cnt; reg key_dly1; reg key_dly2; wire key_negedge = key_dly2 & ~key_dly1; // 下降沿捕获 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; end else if (key_in) begin cnt <= 20'd0; // 按键仍处于按下状态,持续清零 end else if (cnt < CNT_MAX) begin cnt <= cnt + 1'b1; // 释放状态持续计数 end end always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin key_dly1 <= 1'b1; key_dly2 <= 1'b1; end else begin key_dly1 <= (cnt == CNT_MAX) ? 1'b0 : key_dly1; key_dly2 <= key_dly1; end end assign key_pulse = key_negedge; endmodule

逻辑说明:这个消抖模块用了一个反向思维,通常我们会在按键按下时计数,这里改成在按键释放且保持稳定 CNT_MAX 个时钟周期后才认为一个完整的按键事件已经结束。key_dly1 和 key_dly2 构成一个双寄存器同步链,提取下降沿作为单周期脉冲。CNT_MAX 的默认值是 1000000,对应 50MHz 时钟下的 20ms,这是机械按键从抖动到完全稳定的典型时间。

参数说明:CNT_MAX 的大小直接决定了按键响应速度。取 20ms 对普通轻触开关绰绰有余,但如果使用薄膜键盘,抖动时间可能达到 30ms 以上,建议把参数改成 1_500_000(对应 30ms)。这个参数必须保证是整数,且需要预留足够位宽的计数器。1_500_000 是 20 位二进制数上限内,但 2_000_000 恰好是 2^21,如果你改成这个值,计数器位宽就得用 22 位。

键盘扫描模块的职责是生成哪些按键被按下的组合逻辑编码。4x4 矩阵键盘的行列信号分布在两个端口组上,如果直接在 always 块里读一组 wire 型数据,综合器不会报错,但在时序上极容易出现多列同时导通导致的误判。

module key_scan ( input wire clk_50m, input wire rst_n, input wire [3:0] col_in, // 列输入 output reg [3:0] row_out, // 行驱动 output reg [3:0] key_value, // 编码后的按键值 0~15 output reg key_valid // 有效按键事件脉冲 ); reg [15:0] cnt_scan; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) cnt_scan <= 16'd0; else cnt_scan <= cnt_scan + 1'b1; end // 扫描节拍:0~3分别驱动四行 wire [1:0] scan_phase = cnt_scan[15:14]; // 每 65536 周期换一行 always @(*) begin case (scan_phase) 2'd0: row_out = 4'b1110; // 第一行拉低 2'd1: row_out = 4'b1101; 2'd2: row_out = 4'b1011; 2'd3: row_out = 4'b0111; default: row_out = 4'b1111; endcase end always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin key_value <= 4'd0; key_valid <= 1'b0; end else begin case ({scan_phase, col_in}) 6'b00_1110: begin key_value <= 4'h1; key_valid <= 1'b1; end 6'b00_1101: begin key_value <= 4'h2; key_valid <= 1'b1; end 6'b00_1011: begin key_value <= 4'h3; key_valid <= 1'b1; end 6'b00_0111: begin key_value <= 4'hA; key_valid <= 1'b1; end 6'b01_1110: begin key_value <= 4'h4; key_valid <= 1'b1; end 6'b01_1101: begin key_value <= 4'h5; key_valid <= 1'b1; end 6'b01_1011: begin key_value <= 4'h6; key_valid <= 1'b1; end // 其他行同理 default: key_valid <= 1'b0; endcase end end endmodule

逻辑说明:cnt_scan 是一个 16 位自由跑计数器,最高两位作为行选信号。每个行选信号持续 65536 个时钟周期(1.3ms),四行一轮就是 5.2ms 完成整盘扫描。在组合逻辑中根据当前行号和列输入拼成 6 位查找表,直接映射到按键编码。

参数说明:key_value 的高位只用到 4 位,所以 A、B、C、D 功能键的编码分别是 4’hA~4’hD。在 controller 模块中如果没有对 B/C/D 做处理,它们会被默认当成无效键丢弃。键盘扫描的频率也不需要太高,人手指按下一次键盘至少 50ms,扫描周期在 5ms 左右是合理的。如果你把 cnt_scan 位宽改成 14 位,扫描周期缩到 1.3ms,也能正常工作,代价是更频繁地读取列信号,在高速抖动环境下更容易捕到毛刺。

3.2 密码控制状态机的状态跳转与安全机制

controller 模块的状态定义是整个设计的核心。我设计了 5 个状态:IDLE、INPUT、CHECK、UNLOCK、LOCK。IDLE 是上电初始态,也作为每次输入完成后的复位态;INPUT 是在收集 4 位密码的阶段;CHECK 是确认键按下后打一拍比较;UNLOCK 是开锁成功状态,维持一定时间后自动回到 IDLE;LOCK 是连续错误三次后的锁死状态,持续 10 秒。

module controller ( input wire clk_50m, input wire rst_n, input wire key_valid, input wire [3:0] key_value, output reg lock_open, output reg error_flag, output reg [1:0] digit_pos, output reg [3:0] input_buf, input wire lock_timeout // 来自timer模块,10秒计时结束信号 ); localparam IDLE = 3'd0; localparam INPUT = 3'd1; localparam CHECK = 3'd2; localparam UNLOCK = 3'd3; localparam LOCK = 3'd4; reg [2:0] state, next_state; reg [1:0] input_cnt; reg [3:0] pwd_reg [0:3]; // 存储用户输入 reg [3:0] pwd_default [0:3]; // 预设密码,也可以parameter定义 reg [2:0] err_cnt; // 状态寄存时序块 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 次态组合逻辑 always @(*) begin next_state = state; case (state) IDLE: if (key_valid && key_value < 4'hA) next_state = INPUT; else if (key_valid && key_value == 4'hC) next_state = CHECK; // 直接按确认则无效 INPUT: begin if (key_valid && key_value == 4'hC) next_state = CHECK; else if (input_cnt >= 2'd3 && key_valid && key_value < 4'hA) next_state = INPUT; // 超过4位不跳转,最后一位数据覆盖显示 end CHECK: next_state = (lock_open) ? UNLOCK : ((err_cnt >= 3) ? LOCK : IDLE); UNLOCK: if (key_valid && key_value == 4'hD) next_state = IDLE; // D键手动复位 LOCK: if (lock_timeout) next_state = IDLE; endcase end always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin lock_open <= 1'b0; error_flag <= 1'b0; err_cnt <= 3'd0; digit_pos <= 2'd0; input_buf <= 4'd0; end else begin case (state) INPUT: begin if (key_valid && key_value < 4'hA) begin pwd_reg[input_cnt] <= key_value; input_buf <= key_value; if (input_cnt < 2'd3) digit_pos <= input_cnt + 1'b1; end if (key_valid && key_value == 4'hC) begin // 比对逻辑 if (pwd_reg[0]==pwd_default[0] && pwd_reg[1]==pwd_default[1] && pwd_reg[2]==pwd_default[2] && pwd_reg[3]==pwd_default[3]) begin lock_open <= 1'b1; error_flag <= 1'b0; err_cnt <= 3'd0; end else begin lock_open <= 1'b0; error_flag <= 1'b1; err_cnt <= err_cnt + 1'b1; end end end LOCK: begin lock_open <= 1'b0; error_flag <= 1'b0; end default: begin error_flag <= 1'b0; end endcase end end endmodule

逻辑说明:这个模块里有两个比较隐蔽的设计点。第一,pwd_reg 的赋值和比对发生在同一个时钟周期内,确认键按下时,最后一次输入的数字在寄存器中已经稳定存在(因为输入循环里,输入第四位数字是在一个周期完成的,而确认键是后续周期),所以直接比较不会出现少比一位的问题。第二,err_cnt 在 UNLOCK 状态才清零,避免在 CHECK 状态清掉导致无法进入 LOCK。

参数说明:state 编码使用独热码还是格雷码,这里我选的是直接二进制编码(3 位宽保存 5 个状态)。状态数不多的情况下二进制编码最省寄存器资源,而且综合器可以自动做状态转移优化。如果你用的是 Cyclone V 之类的芯片,Altera 推荐使用独热码,因为 FPGA 中寄存器资源丰富而组合逻辑单元相对缺乏。不过 5 个状态的差别实在有限,两种写法在最终资源占用上几乎没有区别。

数码管动态扫描模块属于纯组合逻辑加少量时序逻辑,这里给出端口定义和核心代码片段:

module seg_display ( input wire clk_50m, input wire rst_n, input wire [3:0] data_in, input wire [1:0] pos_in, output reg [5:0] seg_sel, // 6位数码管位选 output reg [7:0] seg_data // 8段数据(含小数点) ); reg [15:0] cnt; reg [2:0] scan_addr; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) cnt <= 16'd0; else cnt <= cnt + 1'b1; end assign scan_addr = cnt[15:14]; always @(*) begin case (data_in) 4'h0: seg_data = 8'hC0; 4'h1: seg_data = 8'hF9; 4'h2: seg_data = 8'hA4; // ... 共阳极数码管段码表 default: seg_data = 8'hFF; endcase end endmodule

逻辑说明:scan_addr 由计数器最高两位产生,循环选中 4 个数码管(示例中只用了 4 位变量,实际 6 个可以增加位宽)。由于人眼视觉暂留约为 20ms,每位数码管点亮时间约 1.3ms,四轮扫描周期 5.2ms,刷新率约 192Hz,不会有闪烁感。

参数说明:段码表数值针对共阳极数码管,如果你是共阴极,则取反即可。8’hC0 是共阳极下显示 0 的编码。如果你用的是 Altera DE2-115 开发板,它的数码管是共阳极;黑金或正点原子的板子绝大多数是共阳极。这个细节在移植时最容易出错,板级调试时你会发现数码管显示乱码,多半是段码表取反问题。

3.3 顶层例化与时钟分频的取舍

顶层文件是连线管理器,它本身不包含任何逻辑计算。每个模块在这里完成端口映射。关键的时序决策是使用什么时钟作为全局时钟。50MHz 时钟直接驱动全局时钟网络是所有模块的基准,不复用任何其他分频时钟。

module top_password_lock ( input wire sys_clk, input wire rst_n, input wire [3:0] col, output wire [3:0] row, output wire [1:0] led_err, output wire buzzer, output wire [5:0] sel, output wire [7:0] seg ); wire [3:0] key_val; wire key_pls; wire open_flag; wire err_flag; wire lock_timeout; debounce u_debounce ( .clk_50m (sys_clk), .rst_n (rst_n), .key_in (key_pulse_raw), .key_pulse(key_pls) ); key_scan u_scan ( .clk_50m(sys_clk), .rst_n(rst_n), .col_in(col), .row_out(row), .key_value(key_val), .key_valid(key_pls) ); controller u_ctrl ( .clk_50m(sys_clk), .rst_n(rst_n), .key_valid(key_pls), .key_value(key_val), .lock_open(open_flag), .error_flag(err_flag), .lock_timeout(lock_timeout) ); lock_timer u_timer ( .clk_50m(sys_clk), .rst_n(rst_n), .enable(lock_state), .timeout(lock_timeout) ); assign buzzer = err_flag | open_flag; assign led_err = {2{err_flag}}; endmodule

逻辑说明:顶层文件把各模块的输出直接连接到端口,没有插入额外逻辑。key_pulse 信号同时被 controller 使用和数码管模块使用,这个多扇出没有时序收敛风险,因为它的频率极低(按键事件频率),而且由全局时钟同步,在 FPGA 中会被自动优化走全局资源。

参数说明:led_err 用了位拼接运算符把错误指示复制到两个 LED 上,这在资源富余的 FPGA 里并没问题。如果你要控制单独的 PWM 蜂鸣器,那需要另写一个发声模块,一般来说直接 assign 驱动蜂鸣器会发出连续响声,不够优雅。好的做法是让 buzzer 输出一个频率约 2kHz 的方波,用锁存器分频即可,而不是直接拉高。

到这里,核心代码全部给出。下一节解决的是从“仿真通过”到“板子跑起来”之间的鸿沟——也就是仿真激励的写法和板级调试时最常遇到的坑。

4. Modelsim 仿真验证与 FPGA 板级调试

代码写完不仿真直接上板,是 FPGA 开发里最忌讳的做法。一个时序错误在仿真里可能只需要 5 分钟就能抓出来,上板后可能得花一晚上。与此同时,仿真通过也不等于板子能工作,综合器和布局布线后的仿真结果更接近真实,但时序约束又是另一个大坑。本节把从仿真到上板的全过程讲清楚。

4.1 Testbench 编写方法:最容易被忽视的系统级仿真

Testbench 里面最容易犯的错误是给所有的输入信号同时打上非阻塞赋值,导致产生毛刺。按键输入的激励模型需要考虑机械抖动特性,直接在 testbench 里用随机延时模拟。

`timescale 1ns/1ps module tb_password_lock; reg clk_50m; reg rst_n; wire [3:0] col; reg [3:0] col_drive; wire [3:0] row; // 时钟生成 initial clk_50m = 0; always #10 clk_50m = ~clk_50m; // 激励序列 initial begin rst_n = 0; col_drive = 4'b1111; #100; rst_n = 1; // 输入第一个数字 1:行0被拉低,列0导通 #100; // 模拟按键抖动:先接触再断开再接触 col_drive = 4'b1110; #5; col_drive = 4'b1111; #3; col_drive = 4'b1110; #100; col_drive = 4'b1111; #50; // 输入第二个数字 2:行0,列1 col_drive = 4'b1101; #5; col_drive = 4'b1111; #3; col_drive = 4'b1101; #100; col_drive = 4'b1111; #50; // 确认键 C:行2,列0 col_drive = 4'b1110; #5; col_drive = 4'b1111; #3; col_drive = 4'b1110; #100; col_drive = 4'b1111; #50; #1000; $finish; end // 行驱动连接 assign col = col_drive; top_password_lock dut ( .sys_clk(clk_50m), .rst_n(rst_n), .col(col), .row(row), .led_err(), .buzzer(), .sel(), .seg() ); endmodule

逻辑说明:testbench 中 col_drive 直接驱动顶层模块的 col 端口,同时注意 row 是顶层模块的 output 类型,在 testbench 中不需要也不能对它赋值。这段激励模拟了完整的键盘输入过程,包含了机械抖动的毛刺脉冲,用来验证 debounce 模块是否正常滤除抖动。

参数说明:激励的时间标度是 1ns/1ps,#10 表示 10ns 半个周期,因此 50MHz 的时钟周期是 20ns。按键按下的时间长度只有 100ns 左右,实际板级是毫秒级,但仿真中为了快速看到结果可以缩短时间,Debounce 模块的 CNT_MAX 在仿真时需要临时改小,比如 50 个时钟周期就能验证逻辑流程。如果坚持用 1000000,仿真会耗时极长,这一点在跑仿真前一定要记得修改参数。

4.2 仿真波形观察的 3 个关键断言点

Modelsim 中把 testbench 添加到工程,在 Wave 窗口观察信号,有三个地方必须逐项确认。

第一,复位释放后状态机停在 IDLE 状态。它不会自动跳转,除非有按键事件。如果复位后状态机乱跳,检查 rst_n 是否同步释放,或者是否存在未初始化的寄存器,在 Vivado 中未初始化的 FPGA 寄存器默认接地,如果你定义了 reg [2:0] state = 3’bxxx,综合时会给你直接拉低,与你期望的状态冲突。

第二,确认键按下后的下一个时钟沿,lock_open 信号是否变为高电平。如果比对逻辑正确,锁存应该在确认键脉冲出现后的一个时钟周期内完成,在这个周期中 controller 会从 CHECK 状态跳转。如果比对正确而 lock_open 没有拉高,大概率是 pwd_reg 比较时少了一个时钟周期的有效数据,因为 pwd_reg 的最后一位和确认键可能位于同一时刻,注意检查输入计数器的时序。

第三,错误次数达到 3 时,状态机进入 LOCK 而不是回到 IDLE。在 LOCK 状态下再按键,key_valid 信号会被 controller 忽略,你需要观察 key_valid 是否还能触发新的事件。如果不能,问题可能出在 debounce 模块的复位逻辑没有清掉 key_dly1,导致持续产生下降沿脉冲,让状态机误以为按键没有释放。

4.3 上板调试:引脚约束、Vivado 综合与波形验证

仿真通过后需要生成比特流,在上板前先在约束文件 XDC 里完成所有引脚分配。这是一个勤快活,但也最容易出低级错误。

set_property PACKAGE_PIN AF14 [get_ports {col[0]}] set_property IOSTANDARD LVCMOS33 [get_ports {col[0]}] set_property PACKAGE_PIN AE14 [get_ports {col[1]}] # 其他引脚同理 set_property PACKAGE_PIN N18 [get_ports sel[0]] # 数码管段选

罗列全部约束会很冗长,这里只展示列输入和数码管位选的写法。实际项目里 row_out、seg_data、led 等都要逐一脚引。有个提速技巧:从开发板原理图里把引脚定义复制到 Excel 后用脚本生成 XDC,比手打快得多。

引脚约束做完后,综合运行 Implementation,打开 Timing Summary。电子密码锁的绝大多数路径都能满足时序要求,唯一的危险路径是 debounce 模块中用于计数的进位链。如果时序报告里出现 setup time violation,优先检查是否在 debounce 模块中让计数器嵌入了比较器,一个 CNT_MAX 比较器如果位宽是 20 位,加法器和比较器会消耗较多的组合逻辑级数。解决方案是将计数器拆成两个 10 位计数器级联,或者是直接用仅仅一个计数器来判定是否到达最大值,使用 if (cnt == 20’hFFFFF) 而不是 if (cnt >= 20’hF423F),这样节省一个比较器。

上板调试时最好把 led_err 和 buzzer 信号接到示波器或者逻辑分析仪上,通过触发条件在确认键按下时捕捉波形。如果逻辑分析仪不方便,Vivado 的 ILA IP 也可以直接在线抓取信号,不过使用 ILA 会额外占用 FPGA 内部逻辑资源,且对毕业设计而言通常在 LabVIEW 或逻辑分析仪环境下更直接。

在板级调试中,最常见的一个现象是按键按下去数码管乱跳。这通常不是扫描模块的问题,而是矩阵键盘的列输入在上拉电阻不足的情况下,引脚处于悬浮状态。此时需要在键盘排线连接器上外接 10kΩ 上拉电阻排,或者直接在 XDC 中把引脚内部上拉开启。

set_property PULLUP true [get_ports {col[*]}]

上面这一句会在引脚内部使能弱上拉,对解决列输入悬空导致的乱码非常有效。很多入门开发者忽略这个细节,导致明明仿真很完美,到板子上跑却无法按键识别。

5. 复用边沿检测与看门狗定时的几个进阶技巧

前四节的内容已经完全能满足毕业设计的要求,但如果想让项目有更完整的工程意识和更高的仿真覆盖率,还有几个点值得深入处理。这一节聚焦在三个不增加硬件成本但显著提升代码健壮性的技巧上。

5.1 边沿检测的统一封装

键盘扫描和消抖模块里频繁使用下降沿捕获,其实在锁存器模块、定时器模块中也会反复用到。与其每次都写一个 reg 缓存变量,不如直接封装成一个函数或任务。在 Verilog 中通常建议封装成独立的小模块,命名为 edge_detect,通过参数控制是上升沿还是下降沿触发。

module edge_detect #( parameter FALLING = 1 )( input wire clk, input wire rst_n, input wire signal_in, output wire pulse_out ); reg signal_dly; always @(posedge clk or negedge rst_n) begin if (!rst_n) signal_dly <= 0; else signal_dly <= signal_in; end assign pulse_out = FALLING ? (signal_dly & ~signal_in) : (~signal_dly & signal_in); endmodule

逻辑说明:FALLING 参数为 1 时输出信号下降沿的脉冲,为 0 时输出上升沿。这个模块的好处是可以直接用 generate for 或者数组例化多路信号边沿检测,大量简化代码。

5.2 密码存储的时序保护

在 controller 模块中,密码比对使用的 pwd_reg 是寄存器类型。如果内部没有设定默认密码,每次上电都是随机值,这会导致用户无法预知当前密码。大多数设计中会用一个 case 语句初始化,但 Verilog 中 initialize at power-up 不可综合。正确做法是在复位时单独给 pwd_default 赋值。

always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin pwd_default[0] <= 4'd1; pwd_default[1] <= 4'd2; pwd_default[2] <= 4'd3; pwd_default[3] <= 4'd4; end end

在 FPGA 中复位信号释放时这些寄存器会得到确定初值。有些开发者会在综合时不小心加了(* preserve *)(* keep = "true" *)属性,这会把寄存器引到不触发复位的专用路径上,导致复位失效,密码永远无法匹配。排查此类问题时,查看综合后的 schematic 中 pwd_default 是否连接复位信号即可。

5.3 看门狗定时器的引入价值

原题可以通过连续错误三次锁定 10 秒来满足安全性要求,但这个设计依赖 controller 状态机自身记录错误次数。如果增加一个独立看门狗定时器,当用户 30 秒内没有任何有效输入时自动清空已输密码和计数位,就能够防止不完整密码长期占用输入状态。这个模块非常简洁:

module watchdog #( parameter TIMEOUT_CNT = 3_000_000_000 // 30ns * 30亿 = 30秒,需调整 )( input wire clk_50m, input wire rst_n, input wire key_activity, output reg reset_input ); reg [31:0] idle_cnt; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin idle_cnt <= 32'd0; reset_input <= 1'b0; end else if (key_activity) begin idle_cnt <= 32'd0; reset_input <= 1'b0; end else if (idle_cnt >= 32'd1_500_000_000) begin reset_input <= 1'b1; idle_cnt <= 32'd0; end else begin idle_cnt <= idle_cnt + 1'b1; reset_input <= 1'b0; end end endmodule

逻辑说明:key_activity 接 controller 输出的 key_valid,每次按键都会清零计数器。当 idle_cnt 达到 15 亿(对应 30 秒)时,输出一个高电平脉冲 reset_input,这个信号接入 controller 后作为同步清零信号使用。

参数说明:idle_cnt 的位宽是 32 位,最大计数 42 亿,对于 50MHz 时钟下 30 秒的 15 亿计数来说刚好够用。如果系统时钟频率不同,这个值需要重新折算。例如 100MHz 系统时钟下对应 60 秒的计数变成了 60 亿,就会超出 32 位上限,需要把位宽扩到 33 位。

防呆优化与验证方法:在板级验证看门狗时,可以先输入一位密码,然后等待超过 30 秒确认数码管位数指示清零。如果期待时间太长可以使用仿真缩短时间参数。此外在 UNLOCK 状态下看门狗应该被禁用,或者在输出 reset_input 时不影响 UNLOCK 状态的保持,这是另一个边界情况,可以在 controller 中判断状态再做响应。

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

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

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

立即咨询