说实话,这几年接触了不少想入门FPGA的朋友,如果你问哪个项目能一口气把Verilog的核心基本功串起来,我通常会推荐电子密码锁。这个项目看着不起眼,却把矩阵键盘扫描、按键消抖、状态机设计、动态显示刷新、外设控制这些数字逻辑里的"硬骨头"全部放在了一个具体场景里。今天要分享的就是这样一个基于Verilog的FPGA密码锁工程,核心交互是通过4x4矩阵键盘输入按键值,在待机状态下连续输入"12"进入修改密码功能,整个工程从模块划分、状态机设计到仿真上板,我按实际开发顺序完整讲一遍。
1. 密码锁整体功能设计与方案选型
1.1 功能清单与用户交互流程
先把功能需求列清楚。这个工程模拟的是日常使用的电子密码锁,核心功能是:上电后处于锁定状态,用户通过矩阵键盘输入6位密码,输入完成后按确认键触发校验;密码正确进入解锁状态,密码错误则蜂鸣器报警并累计错误次数;连续错误达到3次,系统锁定一段时间。修改密码的入口就是标题里说的"输入12":在待机状态下,先按数字1,紧接着按数字2,系统识别到这个组合指令后进入修改密码模式。
完整的用户交互流程大致如下:
- 系统上电,默认密码为000000,显示界面提示待机。
- 在待机状态输入"12",进入修改密码模式;如果输入的是其他数字,则当作密码输入的开始。
- 修改密码时,先要求输入原密码验证身份,验证通过后再输入新密码,并且新密码需要连续输入两遍,两次一致才真正写入存储。
- 正常密码输入以"#"键确认,"*"键可以清除当前输入内容。
- 密码校验通过后进入开锁状态,绿色指示灯点亮,数码管显示"OPEN",此时按下"*"可以重新锁定。
- 密码校验失败,红色指示灯闪烁,蜂鸣器长鸣,错误计数加一;连续三次错误进入锁定状态,30秒内不接受任何输入。
这个流程覆盖了FPGA入门阶段最常用的几个外设交互方式,也把状态机的"状态迁移"玩明白了。很多教程里的密码锁只做校验不解锁,或者只解锁不能改密,其实是不完整的。加上"12改密"这个入口后,状态机至少要多出"验证原密码""输入新密码""确认新密码"三个子状态,复杂度上来了,但学习价值也上来了。
1.2 为什么不用单片机,而用FPGA做这件事
用单片机做密码锁可能几十行C代码就搞定了,为什么还要在FPGA上用Verilog重写一遍?这个问题几乎每个做这个项目的人都会问。我的看法是:这个项目在工程上确实不是最优解,但在学习上是很好的训练场。
FPGA和单片机最本质的区别是硬件并行性。单片机是"顺序执行指令",按键扫描、显示刷新、蜂鸣器控制都靠CPU轮流处理,一旦任务多了就会出现实时性问题;FPGA则是每个模块都在独立运行,键盘扫描模块在跑的时候,数码管动态扫描也在跑,状态机也在跑,它们之间通过信号握手和时序配合协同工作。这种"多个硬件电路同时工作"的思维方式,只有在FPGA上才能体会到。
另一个原因是,密码锁的核心逻辑非常适合用状态机表达。输入密码、校验、开锁、修改密码,这本身就是一个典型的有限状态机模型。在Verilog里用三段式状态机实现,代码结构清晰,综合后的硬件电路也对应真实的寄存器、组合逻辑和输出逻辑。把这个工程的FSM写稳了,后续做串口协议、以太网控制等复杂逻辑,状态机的设计范式都是一脉相承的。
1.3 顶层信号与模块划分
工程采用自顶向下的设计方式,顶层模块只做信号连接,不写具体逻辑。顶层输入输出信号如下表所示:
| 信号名 | 方向 | 位宽 | 功能说明 |
|---|---|---|---|
| clk_50m | input | 1 | 系统时钟,50MHz |
| rst_n | input | 1 | 异步复位,低有效 |
| row | output | 4 | 矩阵键盘行输出,低电平扫描 |
| col | input | 4 | 矩阵键盘列输入,读取按键状态 |
| seg | output | 8 | 数码管段码输出 |
| seg_sel | output | 6 | 数码管位选输出,动态扫描 |
| led_open | output | 1 | 开锁状态指示 |
| led_err | output | 1 | 错误状态指示 |
| beep | output | 1 | 蜂鸣器控制 |
模块划分上,我拆成了四个核心模块再加一个顶层:
- key_scan:矩阵键盘扫描模块,产生行扫描信号,读取列信号,输出键值。
- key_debounce:按键消抖模块,消除机械按键的抖动毛刺。
- ctrl_fsm:主控制状态机,负责密码校验、指令识别、修改密码流程控制。
- display_driver:数码管动态扫描驱动,根据状态机输出显示不同内容。
- beep_driver:蜂鸣器控制模块,管理提示音和报警音。
异步复位的设计要特意讲一下。绝大多数FPGA工程都用rst_n做低有效复位,复位信号进来了,所有寄存器和状态机回到初始状态。密码存储寄存器也在复位时加载默认密码000000。这个设计保证了系统任何时候掉电重启都能回到一个确定的起点,不会出现上电后状态机处于未知状态的情况。
2. 矩阵键盘输入链路:扫描、消抖与指令识别
2.1 4x4矩阵键盘扫描原理
矩阵键盘用4根行线和4根列线组成4x4的按键阵列,共16个按键。它的原理就是分时扫描:把4根行线依次拉低,然后读取4根列线的电平,哪根列线被拉低了,就说明当前行和该列交叉位置上的按键被按下。
举个例子:如果把第0行拉低,即row输出0111,其他行都输出高电平,此时去读col,如果col变成1101,说明第0行第1列交叉处的按键被按下了。把行列编码组合起来,就能得到唯一的键值。常见的键值布局如下:
| 行列位置 | 键值 | 行列位置 | 键值 |
|---|---|---|---|
| 第0行第0列 | 1 | 第2行第0列 | 7 |
| 第0行第1列 | 2 | 第2行第1列 | 8 |
| 第0行第2列 | 3 | 第2行第2列 | 9 |
| 第0行第3列 | A | 第2行第3列 | C |
| 第1行第0列 | 4 | 第3行第0列 | * |
| 第1行第1列 | 5 | 第3行第1列 | 0 |
| 第1行第2列 | 6 | 第3行第2列 | # |
| 第1行第3列 | B | 第3行第3列 | D |
扫描频率不能太高也不能太低。太高了按键按下时间的采样次数很多,但是浪费资源;太低了可能漏掉快速短按。我一般让扫描周期在1~2ms左右,结合消抖模块一起工作。注意一点:矩阵键盘扫描本身是一个持续进行的循环过程,不是按一次只扫一次,而是每时每刻都在轮询,这样才能保证按键按下时第一时间被捕获。
2.2 按键消抖模块的实现与分析
机械按键按下和松开的瞬间,触点会因为物理弹跳产生一串毛刺信号,持续时间一般几毫秒到十几毫秒。如果不做消抖,一次按键会被识别成多次,表现出来就是"按1跳出来好几个1"。消抖的思路很简单:对输入信号连续采样,只有信号稳定一段时间后才认为按键状态有效。
我习惯用"边沿检测加延时确认"的写法,先对输入信号打两拍消除亚稳态,然后用计数器判断电平是否稳定了足够长的时间。50MHz时钟下,20ms对应的计数值是1_000_000,所以消抖计数器位宽至少20位。
module key_debounce( input wire clk, input wire rst_n, input wire key_in, output reg key_out ); parameter CNT_MAX = 20'd1_000_000; // 50MHz时钟下约20ms reg [19:0] cnt; reg key_tmp1, key_tmp2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_tmp1 <= 1'b0; key_tmp2 <= 1'b0; end else begin key_tmp1 <= key_in; key_tmp2 <= key_tmp1; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_out <= 1'b0; end else begin if (key_tmp1 != key_tmp2) begin cnt <= 20'd0; end else if (cnt < CNT_MAX) begin cnt <= cnt + 1'b1; if (cnt == CNT_MAX - 1) key_out <= key_tmp1; end end end endmodule这个模块对每个按键输入信号单独做消抖,所以矩阵键盘的8根行列线不能直接塞进来,而是要结合扫描状态来判断。更常见的做法是:扫描模块先通过行列状态计算出某个按键的原始触发信号,把这个触发信号送给消抖模块,消抖稳定后再作为有效按键脉冲交给状态机。我在工程里就是这样处理的:key_scan模块只负责判断"哪个键被按下",输出一个高电平脉冲,key_debounce模块负责把这个脉冲滤掉抖动,变成干净的键值。
2.3 键值编码与"12"组合指令侦测
键值编码就是把行列扫描结果映射成具体的数字和功能键。我在key_scan模块中用一个case语句完成映射,同时输出一个key_valid信号,告诉后续模块当前键值有效。这个key_valid是脉冲信号,持续一个时钟周期,方便状态机在上升沿采样。
"输入12修改密码"这个指令,不能简单理解成"检测到1就进改密模式",那样太容易误触发了。我的处理方式是:在待机状态下,状态机首先进入一个"指令等待子状态"。此时如果收到键值1,并不立刻当作密码的第一位,而是进入一个临时的"等待第二个键"状态,记录按键序列的前缀。如果紧接着收到的键值确实是2,才真正进入修改密码模式;如果收到的是其他键值,就把刚才那个1当作密码输入的第一位,同时转入正常的密码输入状态。
// 待机状态下的指令侦测逻辑示意 S_IDLE: begin if (key_valid && key_value == 4'd1) next_state = S_CMD_WAIT; // 收到1,先别急着当密码位 else if (key_valid && key_value == 4'hF) // 其他键,按密码首位处理 next_state = S_PWD_INPUT; end S_CMD_WAIT: begin if (key_valid && key_value == 4'd2) next_state = S_CHG_VERIFY; // 连续收到12,进入修改密码 else if (key_valid) next_state = S_PWD_INPUT; // 不是2,回到密码输入流程 end这种设计在真实使用中非常重要。如果密码恰好以1开头,用户输入第一个1就被强行带走,整个密码输入流程就乱了。所以工程里把"12"当成一个组合指令,放在待机状态单独侦测,这个逻辑在仿真时一定要专门写测试用例覆盖到。
3. 核心状态机与修改密码逻辑
3.1 三段式状态机:把流程控制变成硬件思维
整个密码锁最核心的部分是主控制状态机,我建议用三段式写法:第一个always块负责状态寄存器更新,第二个always块负责组合逻辑决定次态跳转,第三个always块负责根据当前状态产生输出信号。
为什么分三段而不是一段式?一段式把状态跳转和输出逻辑写在一起,代码短,但是状态迁移和输出之间互相耦合,改一处容易带崩另一处,而且输出的组合逻辑容易产生毛刺。三段式里状态更新是纯粹的时序逻辑,次态判断是纯组合逻辑,输出逻辑可以做成寄存器输出,时序更干净,排查问题也方便。做复杂状态机的时候,三段式的优势会非常明显。
3.2 状态定义与跳转关系
这个工程的状态定义如下:
| 状态名 | 含义 | 说明 |
|---|---|---|
| S_IDLE | 待机状态 | 系统空闲,侦测"12"指令或密码输入 |
| S_CMD_WAIT | 指令等待状态 | 已经收到1,等待确认下一个键是否为2 |
| S_PWD_INPUT | 密码输入状态 | 允许输入6位密码,显示输入进度 |
| S_CHECK | 密码校验状态 | 用户按下确认键,触发比较逻辑 |
| S_OPEN | 开锁状态 | 密码正确,系统开锁 |
| S_ERR_WAIT | 错误等待状态 | 密码错误提示,等待下一次输入 |
| S_LOCKED | 锁定状态 | 连续3次错误,系统锁定30秒 |
| S_CHG_VERIFY | 改密验证状态 | 修改密码前先验证原密码 |
| S_CHG_NEW1 | 输入新密码状态 | 第一次输入新密码 |
| S_CHG_NEW2 | 确认新密码状态 | 第二次输入新密码,与第一次比较 |
状态跳转的核心路径:S_IDLE在收到数字键后进入S_PWD_INPUT;S_PWD_INPUT输入满6位后按"#"进入S_CHECK;S_CHECK比较通过进S_OPEN,不通过进S_ERR_WAIT;S_ERR_WAIT在错误计数满3次后进S_LOCKED,否则回到S_IDLE。修改密码路径是S_IDLE收到"12"后进S_CHG_VERIFY,验证原密码通过后进S_CHG_NEW1,再进S_CHG_NEW2,两次一致后写回密码寄存器回到S_IDLE。
关键的状态跳转代码:
always @(*) begin next_state = current_state; case (current_state) S_IDLE: begin if (key_valid && key_value == 4'd1) next_state = S_CMD_WAIT; else if (key_valid && key_value != 4'd2 && key_value != 4'd1) next_state = S_PWD_INPUT; end S_CMD_WAIT: begin if (key_valid && key_value == 4'd2) next_state = S_CHG_VERIFY; else if (key_valid) next_state = S_PWD_INPUT; end S_PWD_INPUT: begin if (key_valid && key_value == 4'hF) // # 确认键 next_state = S_CHECK; else if (key_valid && key_value == 4'hE) // * 清除键 next_state = S_PWD_INPUT; end S_CHECK: next_state = pwd_match ? S_OPEN : S_ERR_WAIT; S_OPEN: begin if (key_valid && key_value == 4'hE) next_state = S_IDLE; end S_ERR_WAIT: next_state = S_IDLE; S_LOCKED: begin if (lock_cnt == 0) next_state = S_IDLE; end S_CHG_VERIFY: begin if (key_valid && key_value == 4'hF) next_state = verify_ok ? S_CHG_NEW1 : S_IDLE; end S_CHG_NEW1: begin if (key_valid && key_value == 4'hF) next_state = S_CHG_NEW2; end S_CHG_NEW2: begin if (key_valid && key_value == 4'hF) next_state = S_IDLE; end default: next_state = S_IDLE; endcase end代码里三个状态需要注意:S_CMD_WAIT是不稳定中间态,它的存在时间只够判断下一个键值;S_LOCKED中lock_cnt是30秒倒计时的计数器,减到0才允许跳出;S_CHG_NEW2中按下确认键后,要同时判断两次输入是否一致,一致才更新密码寄存器。
3.3 密码校验与错误计数逻辑
密码校验的位宽是6位十进制数,每一位对应一个4位BCD码,所以密码寄存器的总位宽是24位。用户输入的6位密码也存在一个24位的临时寄存器里。在S_CHECK状态下,比较这两个24位寄存器是否相等,同时清除输入缓冲。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin err_cnt <= 3'd0; end else if (current_state == S_OPEN) begin err_cnt <= 3'd0; end else if (current_state == S_CHECK && !pwd_match) begin if (err_cnt == 3'd2) err_cnt <= 3'd0; // 进入锁定,清零等待下次解锁计数 else err_cnt <= err_cnt + 1'b1; end end错误计数为什么要单独设计?一个常见错误是把"连续3次错误"理解成"只要错过一次,等下次输入时清零"。实际上产品化的密码锁通常要求连续错误计数,如果中间有一次正确输入,计数就应该清零。我在设计里把清零条件放在S_OPEN,也就是只有成功开锁才清零,这样更加贴近真实需求。
连续3次错误后进入S_LOCKED,锁定时间我设定为30秒。锁定计数器的设计是:以1秒为基准,30次计时结束后才退出锁定状态。1秒的基准可以用50MHz时钟分频得到,也可以用系统里已有的扫描时钟倍频,注意复位和暂停逻辑要处理好。
3.4 修改密码流程的细节与二次确认
修改密码流程看着简单,实际实现时有一个特别容易忽略的点:在S_CHG_NEW1输入的新密码,和S_CHG_NEW2再输入一次的新密码,要分别存放在两个独立的临时寄存器里。如果共用一个寄存器,第二次输入就会把第一次覆盖掉,后面做一致性比较时两次数值永远相等,这个bug在仿真阶段很难暴露,上板测试才能发现。
正确做法是定义两份24位寄存器。S_CHG_NEW1状态下,按键值写入new_pwd_1;S_CHG_NEW2状态下,按键值写入new_pwd_2。在S_CHG_NEW2按下确认键时,同时判断new_pwd_1和new_pwd_2是否相等,相等才把new_pwd_2赋给密码存储寄存器,否则回到S_IDLE并给出报错提示。
这里还有个交互设计问题:用户在新密码输入过程中输错了某一个数字怎么办?我的处理是"*"键做退格删除,每次按下删除最后一位。这要求状态机同时处理"数字键追加""确认键结束""删除键回退"三类输入事件。实现上不复杂,但对状态机的健壮性要求提高了,建议把删除逻辑放在每个密码输入状态里都处理一遍。
3.5 密码存储方案与默认密码加载
密码存储我用了一个24位寄存器pwd_reg,复位时初始化为24'h000000。为什么不用RAM?因为密码锁只保存一个密码,寄存器完全够用,而且读写逻辑清晰,综合后占用的逻辑资源也很少。RAM适合存储大量数据,用在这种场景反而增加复杂度。
pwd_reg的更新只发生在两个地方:复位初始化和修改密码成功时。修改密码成功条件要写清楚——必须在S_CHG_NEW2状态下且两次输入一致。这个写入条件如果漏掉状态限制,可能会出现状态机还在S_CHG_NEW1时就把半截密码写进去的情况,导致密码变成乱码。
另外建议把pwd_reg的输出单独做一个pwd_match组合逻辑,不要在每个状态里直接比较。用assign pwd_match = (input_pwd == pwd_reg);统一处理,状态机只需要读这个信号,逻辑清晰,也不容易出现比较条件写错的问题。
4. 数码管显示、蜂鸣器与LED反馈
4.1 数码管动态扫描原理与段码表
六位数码管显示用了动态扫描方式,核心原理是人眼的视觉暂留效应。六个数码管共用8根段码线,如果同时点亮,所有管子会显示一模一样的数字,所以必须分时选通:某一时刻只点亮一个位,显示这个位对应的段码,然后迅速切换到下一位,循环往复。当切换频率超过60Hz,人眼看到的就是六个位同时稳定显示。
真正的刷新频率我一般做到1kHz以上,也就是每个位每秒被刷新1000多次,这样不仅没有闪烁感,亮度也足够平均。动态扫描的核心是一个分频计数器,产生位选信号:
// 六位数码管扫描位选 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 16'd0; end else if (scan_cnt == 16'd50000 - 1) begin scan_cnt <= 16'd0; // 1kHz扫描基准 end else begin scan_cnt <= scan_cnt + 1'b1; end end always @(*) begin case (scan_cnt[16:14]) // 实际需要用专门的扫描计数器 3'd0: begin seg_sel = 6'b111110; end // 依次选通第0位到第5位 endcase end段码表是按共阴数码管或者共阳数码管来设计的,这个千万不能搞混。工程里用的某学习板是共阴数码管,所以段码是 "0"=8'h3f,"1"=8'h06的写法。如果板子是共阳,段码就要按位取反。上板前先让一个数码管显示一个固定数字,验证段码表方向,再继续往下做,别等全部写完再调试,不然出了问题都不知道是扫描时序错还是段码错。
4.2 状态相关显示内容切换
不同状态下,数码管显示的内容完全不一样。我在display_driver模块里做了一个显示数据选择器,根据当前状态选择要显示的四位数据源:
| 状态 | 显示内容 | 说明 |
|---|---|---|
| S_IDLE | 显示"------"或默认提示 | 待机界面 |
| S_PWD_INPUT | 显示输入位数的"*" | 保护密码隐私,也可以显示实际数字便于调试 |
| S_ERR_WAIT | 显示"Err" | 错误提示 |
| S_OPEN | 显示"OPEN" | 开锁提示 |
| S_LOCKED | 显示"Lock" | 锁定提示 |
| 修改密码状态 | 显示"Old""New""Agai"等 | 区分不同改密阶段 |
输入密码时显示"*"还是显示数字,我在工程里留了一个调试开关。正式演示时用星号,调试时打开开关能看到实际输入的数字,排查按键映射问题非常方便。这个开关就是一个parameter,仿真的时改成显示数字,上板演示时改成显示星号,不用改逻辑。
4.3 蜂鸣器提示音与LED指示
蜂鸣器分有源和无源两种。有源蜂鸣器内部有振荡源,通电就响,直接给高电平就行;无源蜂鸣器需要外部提供特定频率的方波才能发声,但可以通过改变频率发出不同音调。我用的学习板是无源蜂鸣器,所以beep信号不能一直拉高,而是要产生一个方波。
提示音的设计:按键按下时短响一下,持续50ms;密码校验失败时长响1秒;开锁成功时响两下短音。这里就需要一个beep_driver模块管理发声的持续时间和节奏。实现思路是用状态机和计数器:beep_en信号来了,计数器开始计时,同时在时钟分频下翻转beep输出,产生1kHz左右的方波;时长到了就关闭输出。
LED指示相对简单,开锁状态点亮绿色LED,错误状态让红色LED闪烁。闪烁效果可以在已有的1Hz分频时钟下控制LED翻转。这里要提醒一点:LED指示和数码管显示是配合工作的,数码管显示"OPEN"的同时,绿色的开锁LED必须同步点亮,否则用户感知会混乱。输出逻辑统一在状态机的第三个always块生成,就能保证一致性。
5. 仿真验证与上板调试
5.1 Testbench与模拟按键任务
仿真阶段的目标是验证状态机的所有路径,尤其要覆盖"12改密"这个组合指令。我习惯在Testbench里把按键操作封装成task,这样激励代码看起来像在描述操作步骤,而不是一堆信号赋值。
task press_num; input [3:0] val; begin // 模拟一次按键按下并消抖 key_valid = 1'b0; #20; key_value = val; key_valid = 1'b1; #100; key_valid = 1'b0; #100; end endtask task press_ok; begin press_num(4'hF); // # 确认键 end endtask实际仿真时,Testbench更常见的是直接驱动ctrl_fsm模块的输入端口,把key_valid和key_value当作按键模块处理后的结果,这样避开矩阵键盘扫描时序,专注于状态机验证。矩阵键盘扫描模块的验证另写一个Testbench,模拟行列信号的变化。
仿真的关键路径至少覆盖:正确密码开锁、错误密码三次锁定、"12"进入改密、原密码错误拒绝改密、两次新密码不一致回退、修改密码后使用新密码成功开锁。这几条路径走通了,功能上基本就没有大问题。
5.2 仿真阶段容易踩的时序坑
第一个坑是状态机没有复位初始值。如果复位时current_state没有明确赋值为S_IDLE,仿真波形里就会出现X态,导致整个状态机乱跑。所有状态寄存器、计数器、数据寄存器,复位时一定要给确定值。
第二个坑是组合逻辑的锁存器推断。写状态机的第二个always块时,如果某条分支没有覆盖所有变量赋值,综合工具会推断出锁存器,导致行为异常。避免的办法是在case前先给所有信号赋默认值:next_state = current_state;并且明确列出default分支。
第三个坑是按键脉冲和时钟沿的竞争。我设计的key_valid是脉冲信号,在Testbench里如果赋值和状态机采样沿完全对齐,仿真可能看到0或1不确定。解决办法是让Testbench里的脉冲偏离时钟沿,比如在时钟下降沿附近改变信号,或者使用非阻塞赋值的时序。真实硬件里按键脉冲突发时间远大于一个时钟周期,不会出现这个问题,但仿真里必须处理。
5.3 上板调试方法与引脚约束
上板调试的第一步不是烧整个工程,而是先烧一个最简单的点亮LED工程,确认板卡和下载链路正常。然后逐个验证外设:先让数码管显示固定数字,验证段码表;再写一个扫描程序,按矩阵键盘的键观察键值是否正确;最后才烧整体工程联调。分段验证能大幅缩小出问题的范围。
引脚约束是FPGA上板最容易出错的地方。矩阵键盘的8根线要分配到指定的IO管脚,还必须确认板卡原理图上的行列定义。比如原理图上某个位置标着"KEY_ROW0",实际连接到FPGA的某个Bank某个引脚,约束文件里就写对应位置。行列定义反了会导致按键映射错乱,可能的表象是"按1出来的是5"。这类问题不需要改逻辑,把约束里的行和列交换就行。
调试内部信号建议用FPGA自带的逻辑分析仪,抓取key_value、current_state、pwd_match这几个关键信号,配合按键操作观察它们的变化是否符合预期。我遇到过的一个情况是:数码管显示输入位数的"*"数量总是不对,逻辑分析仪一看,发现状态机确实收到了6个按键脉冲,但第一个脉冲被当成了指令前缀的1,进入S_CMD_WAIT后第二个键才真正开始密码输入,导致密码位数少一位。这类问题用仿真很难发现,逻辑分析仪抓到真实时序立刻真相大白。
5.4 硬件实测中的一次真实调整
模拟项目X的实测过程中遇到过一个问题:修改完密码后,用新密码开锁,数码管显示密码输入确实按了6下,但校验总是失败。上板抓信号后发现,S_CHG_NEW2状态下第二次输入密码的存入逻辑和按键确认逻辑产生了冲突:按下"#"确认的瞬间,key_valid信号同时触发了一次数字键存入,结果把确认键当成了新增的一位数字,密码多了一位,比较自然失败。
这个问题的根因是输入状态的按键处理只判断了数字键类别,没有把确认键从数字键里分离出来。修复方式是检查所有密码输入状态,必须同时判断"是否有效按键""键值是否为确认键"两个条件,只有既有效又不是确认键时才把数据存入缓冲区。这个改动很小,但暴露了状态机设计时"事件优先级"的重要性——确认事件必须优先于数据事件处理。
6. 常见问题与排查技巧实录
6.1 问题速查表
把开发过程中容易遇到的问题整理成表格,方便大家对照排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 按键按下去没反应 | 行列信号接反、消抖时间过长、key_valid没接上 | 用逻辑分析仪抓行列信号 |
| 按键按一次识别两次 | 消抖模块没生效或消抖时间太短 | 确认key_out输出不抖动,拉长CNT_MAX |
| 数码管显示乱码 | 段码表方向错、位选信号接错 | 先让固定位显示固定值,逐个排除 |
| 密码永远校验失败 | 位宽不一致、输入缓冲没清、比较时序错 | 抓pwd_match信号,确认两个24位值 |
| "12"进入不了改密 | 指令侦测状态没实现、S_CMD_WAIT被跳过 | 看状态机是否进入S_CMD_WAIT |
| 修改密码后老密码还能开 | 新密码写入条件没限制在S_CHG_NEW2 | 检查pwd_reg的赋值条件 |
| 蜂鸣器一直响 | 有源无源选错、方波频率没控制好 | 确认蜂鸣器类型和驱动方式 |
6.2 排查方法论
我的排查习惯是从现象往前倒推。比如"密码修改失败",先确认是不是进入了改密状态,再确认原密码验证是否通过,然后是两次输入是否一致,最后是写入条件是否触发。每一步用逻辑分析仪抓对应的状态和信号,就像沿着管道追溯水流一样,走到哪一段断了,故障点就在那一段。
这里想特别强调一下把功能分段验证的重要性。整个工程在综合前就拆成模块逐个确认:键盘扫描模块能否正确输出键值、消抖模块能否滤掉毛刺、状态机在仿真里能否跑通所有路径、显示模块能否正确刷出内容。模块边界测试通过后再做联调,一般不会出现那种让人摸不着头脑的"玄学问题"。
6.3 几个容易被忽略的设计细节
第一个是密码输入缓冲区的清零时机。S_IDLE进入密码输入前,必须把上次输入残留的6位数据清零。如果不做这个动作,用户上次输入了4位就按了确认,密码校验失败后再进入输入状态,会导致缓冲区还是上一次的前4位加新输入的部分内容,拼出来一个乱码密码。
第二个是显示刷新和状态切换的同步问题。状态切换发生在时钟沿,显示模块读取状态值可能产生一个周期的延迟,这是正常的。但如果显示数据源切换导致某个瞬间看到上一位的数据,需要在位选切换和段码输出之间加一拍寄存器打拍,避免"拖影"现象。
第三个是"12"指令的时效性。S_CMD_WAIT状态如果没有超时机制,用户按了1之后长时间不按2,状态机就卡在等待状态,再也无法输入密码。我的处理是增加一个短延时计数器,比如50ms内没有收到下一个有效按键,就自动把1当作密码第一位跳入密码输入状态,这样既保留了组合指令,又不会阻塞正常使用。
第四个是复位按键的必要性。为了调试方便,可以在顶层外加一个手动复位按键,强制让整个系统回到初始状态。这个按键在开发阶段几乎不可或缺,因为状态机一旦进入某个不可恢复的状态,不可能每次断电重启,手动复位最直接。正规设计中复位信号本身也要消抖,否则可能出现复位不彻底的情况。
最后一个值得说的是代码风格。Verilog代码的模块化程度直接决定调试效率。每个模块只做一件事,接口信号用有意义的名字命名,状态机状态名用全大写加前缀,这样逻辑分析仪里看到的信号名一目了然。我做这个密码锁时,把所有模块的端口都加上了注释,过了两个月回头看代码还能快速上手,这一点在工程上比炫技式写法重要得多。
这个密码锁工程做完,回头看最有价值的不是"能开锁"这个结果,而是把状态机的设计方法从"能跑通"练到了"稳定可靠"。后面做复杂的通信协议、数据处理流程时,遇到多状态多条件的分支控制,第一反应就是从状态图开始画起,再落到三段式Verilog状态机。对FPGA入门来说,这个项目是个很好的分水岭:它把语法知识、模块划分、时序概念、上板调试串成了一整条链路,一旦全流程跑通过一次,后面再碰其他项目,心里就有底了。