简介:基于Quartus开发环境与VHDL硬件描述语言的FPGA密码锁完整工程,面向数字逻辑和FPGA入门者,覆盖密码预设与存储、4×4矩阵键盘接口、密码逐位比较、连续错误报警及安全改密等关键模块,帮助理解状态机设计与外设接口协同。资源包共83个文件,以VHDL源文件为主,包含编译报告、仿真波形、引脚约束与工程配置等文件,压缩包仅515KB,目录结构清晰,方便在Quartus中直接打开并完整跑通综合、仿真和下载流程。已有863人学习,尤其适合课程设计或赛前练习。附带的说明文档和多组仿真波形,可对照源码观察按键消抖、数码管译码与报警时序,深入理解信号赋值、进程调度及存储单元实现等VHDL细节,结合编译日志还能快速定位并修复逻辑错误,提升FPGA项目实战与调试效率。
1. FPGA密码锁程序不是“按键比较”,而是一个状态机
在实验板上把FPGA密码锁程序第一次跑通后,我发现一个很反直觉的事:代码里明明写的是“按键值比较”,真正决定它能不能开锁的,却是状态寄存器里的那几个比特。按键拨对了但状态机没跳过去,锁也照样不开。FPGA上做密码锁,和单片机方案最大的差别就在这里——没有操作系统在后台轮询,也没有中断帮你“等”按键,所有行为都是一次次状态迁移:当前处于哪个状态、允许接收哪一位、校验结果进了哪条分支。把这条链路想清楚,比抄一段always块值钱得多。本文围绕Quartus工具链,从一个能编译、能下载的最小密码锁工程讲起,覆盖方案拆解、Verilog实现、Quartus建工程和SignalTap排错。
2. FPGA密码锁的方案拆解:从模块划分到状态编码
2.1 密码锁的模块划分:按键、状态机、显示各管一摊
我见过不少新手把整个密码锁写进一个always块,内部塞满if-else,代码能跑,但后续改密码位数、加锁定逻辑时很容易崩。常见做法是把工程拆成三到四个模块:
- 按键输入模块:接收外部按键,完成消抖,输出单周期的“按键有效”脉冲和按键编码;
- 密码控制模块:状态机核心,负责记录已输入位数、比对密码、累计错误次数、输出上锁信号;
- 显示模块:把当前状态翻译成数码管段码,并按位扫描输出;
- 顶层模块:完成上面三者的例化和引脚连接。
这个划分不是唯一标准,但边界很清楚。尤其状态机模块的输入是key_valid和key_code,输出是lock_out,仿真时信号少,调试时好抓。下面这张接口表把每个模块的职责和关键信号列出来,后面写代码时就是按这张表来填:
| 模块 | 关键输入 | 关键输出 | 职责 |
|---|---|---|---|
| 按键消抖 | clk_1k, key_in | key_valid, key_buf | 滤掉机械抖动,输出稳定后的按键值 |
| 密码状态机 | clk_1k, rst_n, key_valid, key_buf | lock_out, state, err_cnt | 管理输入、校验、错误计数与锁定 |
| 数码管显示 | clk_1k, state, disp_data | seg_code, sel_out | 动态扫描显示当前输入进度 |
| 顶层top | clk, rst_n, key_in | seg, sel, lock_led | 例化子模块并完成信号连接 |
2.2 状态转移:密码错了怎么回退,错三次怎么锁死
密码锁的状态机不需要设计得特别复杂,我常用的状态集是IDLE、INPUT、CHECK、SUCCESS和FAIL五个。IDLE等待第一位输入,INPUT负责接收当前位,CHECK执行比对,SUCCESS是开锁状态,FAIL是错误超过三次后的锁死状态。把“收按键”和“比密码”分开,是因为按键动作和校验结果往往不在同一个时钟周期出现,揉在一起容易写出带有隐含时序依赖的组合逻辑。
整条跳转链路用文字描述是这样的:IDLE收到有效按键后进入INPUT;INPUT在按键有效时把当前位锁存,然后跳CHECK;CHECK比对当前位,若正确且未满四位,回到INPUT继续收下一位,若正确且已是第四位,跳SUCCESS;若错误,回到IDLE清空输入计数,同时错误次数加一。这里有个容易漏的细节:错误计数不清零。当错误次数达到三次,跳FAIL,之后即便密码正确也不再开锁,只有复位才能恢复。这个“第三次错误直接锁死”的行为,需要在状态机和输出逻辑里同时处理,只改一处会出现“状态已锁死但输出还是解锁”的怪现象。
2.3 状态编码:为什么密码锁推荐用独热码
状态编码是写状态机之前就要定的事。三选一或五选一的选择,直接决定综合出来的触发器和组合逻辑规模。下面这张表把三种常见编码的差异列出来:
| 编码方式 | 触发器数量 | 译码逻辑 | 适用场景 |
|---|---|---|---|
| 二进制码 | 最少,5个状态只需3个触发器 | 需要额外译码,组合逻辑较长 | 状态多、面积敏感 |
| 独热码 | 每位对应一个状态,触发器最多 | 每一位直接代表状态,译码简单 | 状态少于16、速度优先 |
| 格雷码 | 较少 | 相邻状态只变一位,毛刺少 | 状态按顺序迁移的场合 |
在密码锁这种状态数不到十个的教学项目里,我建议直接选独热码。理由很实际:第一,SignalTap里拉波形时,独热码一看就知道当前停在哪个状态,不用在脑子里把二进制换算回十进制;第二,译码组合逻辑简单,不容易写出奇怪的竞争条件。代价只是多几个触发器,对Cyclone IV、MAX 10这类器件根本不值得省。如果坚持用二进制码,记得在case的default分支里回到IDLE,防止综合出非法状态后一路跑飞。
3. 密码锁的Verilog实现:消抖、状态机、数码管三段代码
3.1 时钟分频与按键消抖:先用1kHz采样滤掉抖动
开发板上的系统时钟通常是50MHz,直接用这个时钟去采机械按键会出问题:按键按下时会有5到20ms的机械抖动,高速时钟会把整个抖动过程都采进来,一个按键动作变成几十个毛刺。常见做法是先分频出一个1kHz左右的慢速时钟,再用它去采样按键电平。下面这段分频代码把50MHz转成1kHz,参数DIV_CNT决定了分频比:
parameter DIV_CNT = 25_000; // 50MHz / (2 * 25000) = 1kHz reg [14:0] div_cnt; reg clk_1k; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin div_cnt <= 15'd0; clk_1k <= 1'b0; end else if (div_cnt == DIV_CNT - 1) begin div_cnt <= 15'd0; clk_1k <= ~clk_1k; end else begin div_cnt <= div_cnt + 1'b1; end end分频的原理很简单:计数器每数到24999就翻转一次clk_1k,半个周期25000个时钟,完整周期50000个时钟,对应1kHz。注意DIV_CNT是15位宽,因为25000小于32767。如果换到12MHz的板子,DIV_CNT要同步改成6000,否则状态机时钟不对,消抖窗口也会跟着变。再往下是消抖逻辑,用移位寄存器连续采样:
// 在1kHz时钟下连续采样,8拍全为高才认为按键稳定按下 reg [7:0] key_shf; always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) key_shf <= 8'd0; else key_shf <= {key_shf[6:0], key_in}; end assign key_stable = (key_shf == 8'hFF);8拍对应8ms,能滤掉大部分机械抖动。如果按键老化严重,可以把移位寄存器扩到16位,比较值改成16'hFFFF,消抖窗口就到16ms。这个参数不是死的,应根据实际按键手感调整。还有一个容易踩的坑:采样时钟不能直接用50MHz,否则8拍只有160ns,等于没消抖。
3.2 三段式状态机:密码校验与错误锁定的核心代码
密码锁的状态机我建议用三段式写法,状态寄存器、次态组合逻辑、输出时序逻辑分开。先定义状态和内部寄存器:
localparam IDLE = 3'd0; localparam INPUT = 3'd1; localparam CHECK = 3'd2; localparam SUCCESS = 3'd3; localparam FAIL = 3'd4; reg [2:0] state_q, state_d; reg [1:0] bit_cnt; // 已正确输入的位数,0~3 reg [1:0] err_cnt; // 连续错误次数 reg lock_out; // 锁控制输出 reg [3:0] pwd [0:3]; // 4位密码,pwd[0]~pwd[3]第一段是状态寄存器,每个时钟沿把次态锁存到当前态:
always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) state_q <= IDLE; else state_q <= state_d; end第二段是组合逻辑,决定下一个状态去哪:
always @(*) begin state_d = state_q; case (state_q) IDLE: if (key_valid) state_d = INPUT; INPUT: if (key_valid) state_d = CHECK; CHECK: if (key_buf == pwd[bit_cnt]) state_d = (bit_cnt == 2'd3) ? SUCCESS : INPUT; else state_d = (err_cnt == 2'd2) ? FAIL : IDLE; SUCCESS: state_d = SUCCESS; // 由复位退出开锁状态 FAIL: state_d = FAIL; // 第三次错误后锁死,只能复位 default: state_d = IDLE; endcase end第三段是输出逻辑,按当前状态更新错误计数和锁输出:
always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) begin bit_cnt <= 2'd0; err_cnt <= 2'd0; lock_out <= 1'b0; end else begin case (state_q) CHECK: begin if (key_buf == pwd[bit_cnt]) begin bit_cnt <= bit_cnt + 1'b1; end else begin bit_cnt <= 2'd0; err_cnt <= err_cnt + 1'b1; end end SUCCESS: lock_out <= 1'b1; FAIL: lock_out <= 1'b1; default: lock_out <= 1'b0; endcase end end这段代码的关键在CHECK状态:组合逻辑判断跳转时用的是进入CHECK那一刻的err_cnt。第一次失败时err_cnt为0,不满足等于2,回IDLE;第二次失败时err_cnt为1,也不满足;第三次失败时err_cnt已经是2,才跳FAIL,同时第三段把err_cnt累加到3。这样正好实现“第三次错误锁死”。此处key_valid先视为经过消抖的稳定按键信号,真正要加边沿防连按,见5.3节。
3.3 数码管动态扫描:把状态变成可见显示
密码锁的显示部分不复杂,但直接给每一位数码管分配独立IO不划算,而且段码线会占掉大量引脚。常见做法是动态扫描:4位数码管轮流选通,每位点亮1ms,循环刷新。扫描频率用前面的clk_1k驱动,4位轮流一遍就是4ms,刷新率250Hz,高于人眼闪烁阈值:
// 4位数码管动态扫描,sel_out低电平选中对应位 reg [1:0] scan_cnt; always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) scan_cnt <= 2'd0; else scan_cnt <= scan_cnt + 1'b1; end always @(*) begin case (scan_cnt) 2'd0: begin seg_data = show_num[0]; sel_out = 4'b1110; end 2'd1: begin seg_data = show_num[1]; sel_out = 4'b1101; end 2'd2: begin seg_data = show_num[2]; sel_out = 4'b1011; end 2'd3: begin seg_data = show_num[3]; sel_out = 4'b0111; end endcase endshow_num可以来自状态机:INPUT状态显示当前已输入位数,SUCCESS显示一个特殊符号。段码译码按共阴还是共阳数码管分开处理,共阴的0是7'b0111111,共阳则取反。这里最容易出问题的是扫描频率:如果clk_1k换成更高的时钟,每位点亮时间变短,数码管亮度会下降;反过来低于100Hz,肉眼就能看到闪烁。250Hz在这个工程里是平衡点。
3.4 顶层模块连接:实例化与Quartus引脚分配
三个子模块写完后,在顶层模块里把它们连起来:
module top ( input wire clk_50m, input wire rst_n, input wire [3:0] key_in, output wire [6:0] seg_code, output wire [3:0] sel_out, output wire lock_led ); wire clk_1k; wire key_valid; wire [3:0] key_buf; wire lock_out; wire [3:0] show_num; clk_div u_div( .clk_50m (clk_50m), .rst_n (rst_n), .clk_1k (clk_1k) ); key_debounce u_key( .clk_1k (clk_1k), .rst_n (rst_n), .key_in (key_in[0]), .key_valid(key_valid), .key_buf (key_buf) ); pwd_fsm u_fsm( .clk_1k (clk_1k), .rst_n (rst_n), .key_valid(key_valid), .key_buf (key_buf), .lock_out (lock_out), .show_num (show_num) ); endmodule引脚分配在Assignments → Pin Planner里做,Location列填开发板原理图中的引脚号。时钟引脚必须接到专用时钟输入,按键和数码管接普通IO即可。分配完切记要重新编译一次,因为Pin Planner里的改动不会自动作用到上一次的编译结果。很多新手在Pin Planner里改完引脚就直接点Programmer,下载的还是旧布局,板上自然没反应。
4. 用Quartus编译、下载和调试密码锁工程
4.1 新建Quartus工程时的器件选择与命名规则
第一次用Quartus打开这个项目,最容易栽在两个地方:一是工程名和顶层module名不一致,二是器件选错。新建工程时,File → New Project Wizard会让填Project Name和Top-Level Entity Name,这两项必须一致,否则编译直接报“Top-level design entity is not defined”。我的习惯是工程名、顶层模块名、输出文件夹全部都用pwd_lock,省去后续一堆路径和命名问题。
器件选择按手头开发板的FPGA型号来,常见的有Cyclone IV E、Cyclone 10 LP、MAX 10。如果板卡资料里写的是EP4CE10F17C8,就在Family里选Cyclone IV E,Device里找到对应型号。选错Family会导致后续编程文件格式不对,甚至Pin Planner里找不到真实引脚。如果你在Quartus II 13.1和Quartus Prime 18.1之间犹豫,就看目标器件的支持列表:老版本对Cyclone IV支持很好,新版本对MAX 10和Cyclone 10更友好,各家的教学板通常会明确指定版本。
4.2 常见编译错误和ModelSim启动失败怎么处理
新手编译密码锁工程时,报错内容翻来覆去就那么几条。下面这张表能帮你快速定位:
| 错误片段 | 可能原因 | 排查动作 |
|---|---|---|
| Error (10170): Verilog HDL syntax error | 缺分号、begin/end不匹配 | 看报错行号,往前查两个always块 |
| Error (12007): Top-level design entity not defined | 工程名和顶层module名不一致 | 在Assignments→Settings里改顶层实体名 |
| Error (10028): Can't resolve module | 子模块文件没加入工程 | 检查File列表里是否包含所有.v文件 |
| Error: Can't generate programming file | 工程没编译完整或器件未选 | 重新全编译,确认Device型号 |
还有一个高频问题:Quartus里启动ModelSim失败。Quartus II时代通常配ModelSim-Altera版,Quartus Prime时代则是ModelSim Intel FPGA Edition,两者混用就会出现启动后黑屏或直接报cannot launch。处理路径在Tools → Options → EDA Tool Options,把ModelSim路径指定到modelsim安装目录下的win32或win64文件夹,确认选中的是modelsim.exe而不是vsim.exe。这个配置和密码锁逻辑无关,但不配好,后面的仿真和波形排查都进行不下去。
4.3 SignalTap II抓状态机跳变:触发位置选Pre才有用
Quartus自带的SignalTap II逻辑分析仪可以抓FPGA内部信号,前提是信号在综合后没有被优化掉。状态寄存器这类内部信号经常被综合器打平,需要在代码里保留:
(* keep = "true" *) reg [2:0] state_q;加了keep属性后,打开Tools → SignalTap II Logic Analyzer,按下面步骤配置:
- 在Signal Configuration里选择采样时钟,这里必须选clk_1k,因为状态机跑在1kHz上;
- 在Setup页添加待测信号,把state_q、key_valid、key_buf、lock_out都加进去;
- 设置触发条件,比如state_q == SUCCESS;
- 把Trigger Position设为Pre,这样触发前的一段数据也会被保存下来;
- 重新编译工程,下载到板子,点击Run Analysis开始捕获。
采样时钟选错是最常犯的错。如果选了50MHz系统时钟,key_valid只有1ms宽,采样率太高反而容易在两个采样点之间漏掉脉冲,波形看起来像是信号丢了。触发位置选Pre也非常关键:密码锁的核心问题往往不是“开锁后怎样”,而是“密码按错的那一下状态是怎么跳的”,Pre模式才能回看触发前的输入过程。
4.4 下载到开发板:JTAG调试和AS固化的区别
密码锁工程编译通过后,output_files目录下会生成.sof文件。Programmer里Hardware Setup选择USB-Blaster,Add File加入.sof,勾选Program/Configure,点Start就能下载。这里要注意,.sof是SRAM配置文件,断电就丢,适合调试期间反复改代码。如果整个密码锁程序已经稳定,想要上电自动运行,需要用AS模式烧写.pof文件。
| 下载方式 | 文件格式 | 断电行为 | 适用阶段 |
|---|---|---|---|
| JTAG | .sof | 丢失 | 调试、在线改逻辑 |
| AS | .pof | 保留 | 脱机运行、交付 |
转换方法在File → Convert Programming Files里,Device Family选当前FPGA型号,Configuration Device选板上实际使用的串行配置芯片,加入.sof后转出.pof,再用Programmer的AS模式烧进去。如果Programmer打开文件卡顿,先把.sof复制到纯英文路径下再Add File,能避开大部分文件路径导致的界面假死。下载后板子没反应,优先检查USB-Blaster驱动和Pin Planner里时钟引脚有没有分配错。
5. 密码锁项目的三个进阶细节
5.1 状态编码换成独热码的波形可读性
把前面三位的二进制状态换成五位的独热码,SignalTap波形一眼就能看出当前状态:
localparam IDLE = 5'b00001; localparam INPUT = 5'b00010; localparam CHECK = 5'b00100; localparam SUCCESS = 5'b01000; localparam FAIL = 5'b10000;每个状态只有一位为1,波形上看到哪一根线拉高,就知道状态机停在哪。代价是多两个触发器,但译码逻辑更短,default分支必须回到IDLE,否则一旦进入非法状态,独热码会一直卡住。
5.2 手工分频换成PLL,状态机时钟别一直是普通信号
前面手写的分频器输出clk_1k是普通逻辑信号,没有进入全局时钟网络。小工程没问题,但继续往工程里加模块时,综合器会逐渐出现时序违例。常见做法是删掉自己的分频代码,改用IP Catalog里的PLL核,输入50MHz,输出1kHz或1MHz,把PLL输出作为状态机时钟。PLL输出走专用时钟资源,抖动和偏斜都更好,但要注意复位信号必须在PLL锁定后释放,否则状态机在PLL还没稳定时就开始跑,第一拍可能采到错误数据。
5.3 防连按的边沿检测:按住不松手只算一次输入
消抖保证了电平稳定,但没有解决“按住不松手”的问题。密码锁常见的bug是手指在按键上多停留半秒,状态机连续收到多个有效脉冲,第一次输入正确后第二次又被当成错误。解决办法是在消抖输出后面加一级寄存器,只在电平从低变高的那一拍产生key_valid:
reg key_stable_r; wire key_valid = key_stable && !key_stable_r; always @(posedge clk_1k or negedge rst_n) begin if (!rst_n) key_stable_r <= 1'b0; else key_stable_r <= key_stable; end因为key_stable在1kHz时钟下持续很多拍,key_valid只在key_stable从0变1后的第一个周期拉高一拍。把这条边沿信号接进状态机的key_valid输入,按住不松手也就只会触发一次INPUT跳转,密码锁的误判和连跳问题基本就消除了。
本文还有配套的精品资源,点击获取