简介:面向FPGA入门学习与课程设计场景的Verilog数字钟工程,适合已掌握基础语法、希望打通“代码—仿真—综合—下载”完整流程的开发者。设计围绕时钟分频、计时寄存器、数码管显示驱动与按键输入处理展开,并集成跑表启停、暂停、复位,闹钟设定与时间比较,以及手动校时等功能;工程基于Quartus II 13.0构建,代码注释较为详尽,便于对照理解各模块职责与信号时序。压缩包共145个文件、约7.82MB,内含Verilog/VHDL源码、qsf/qpf工程配置、vwf波形仿真文件、do仿真脚本、sof下载文件,以及综合适配产生的rpt、summary、log等报告和数据库中间文件,可完整复现编译与验证过程。已有418人学习下载。读者可据此梳理分频、按键消抖、动态扫描、闹钟触发等典型写法,并借助现有工程快速修改参数、扩展功能,是较为实用的FPGA时序逻辑练习素材。
1. 从一次“分钟每 58 秒跳一次”的答辩说起:这套数字钟方案到底解决什么问题
某高校一位同学把 FPGA 数字钟的计时模块写完了,仿真波形看着漂亮,一上板却出现分钟每 58 秒跳一次。问题不在逻辑思路,而在他把 50 MHz 晶振分频写成了“计数到 50 000 000”,差一个数就差了 20 ppm 量级的偏差,再叠加扫描节拍不稳,肉眼就能看出来。FPGA 数字钟之所以容易出问题,是因为计时、按键消抖、数码管动态扫描这三条时间线交织在同一个时钟域里,任何一条算错,板子都不会报错,只会“慢慢走歪”。这套纯 Verilog 方案要解决的就是这件事:把 24 小时 BCD 计时、三键校准、四位数码管扫描拆成职责清晰的模块,每条时间线都能单独仿真、单独验收。适合正在做 FPGA 课程设计或毕设、需要一份能跑通也能讲清楚的完整结构的读者直接照搬。
2. 需求与选型:四路显示、三路按键与 50 MHz 时钟域怎么定边界
先把需求写成可以逐条验收的句子,再谈代码怎么写。数字钟看起来功能简单,但如果不把“显示什么、按键怎么用、时间精度到哪一位、上电初值是多少”这几件事提前定清楚,写到一半一定会回头改端口,改一次端口就要改顶层、改约束、改仿真激励,工作量直接翻倍。我的习惯是先拿出一页纸,把功能和非功能需求各列一栏,确认没有互相矛盾的地方再开始画框图。
功能侧核心只有四条:四位数码管显示“时十位、时个位、分十位、分个位”;三个独立按键分别承担“模式切换、加、减”;支持 24 小时制循环,23:59 之后回到 00:00;调时状态下被调整的那一位以 1 Hz 或 2 Hz 闪烁提示。秒位不显示,所以秒只需要内部计数并产生进位,不需要驱动数码管,这直接决定了扫描模块只需要四个位选信号而不是六位,省下一批引脚和一组译码逻辑。
2.1 功能需求拆解:时分手动校准与整点提示怎么落地
模式切换按键通常做成三态循环:正常走时 → 调时 → 调分 → 回到正常走时。关键在于状态切换的瞬间要冻结当前显示值,而不是让计时继续跑,否则用户一边调时一边秒进位,调好的分钟会被“吃掉”一次。稳妥的做法是让计时模块始终运行,但显示模块在设置态下选择“设置寄存器”而不是“计时寄存器”作为数据源,这样退出设置时把设置寄存器整体写回计时寄存器,两个动作彼此独立,不会互相干扰。
加/减按键要区分短按和长按。短按加一,长按 0.5 秒后以 10 Hz 连加,否则从 00:59 调到 00:00 要按 59 次,演示时手会按到怀疑人生。连加的实现不需要额外的定时器,复用消抖模块输出的“稳定按下”电平和按键按下计数器即可,计数到 5 之后把步进节拍从 1 Hz 切到 10 Hz。整点提示是加分项:在分钟从 59 进位到 00 时拉高一个 500 ms 的脉冲去驱动蜂鸣器或 LED,这个脉冲必须由计时模块产生,不能由显示模块推断,否则设置态下会被误触发。
非功能需求容易被忽略但直接影响验收:上电初值建议固定为 12:00:00 而不是 00:00:00,因为 00:00:00 和“复位没生效”在肉眼上无法区分,答辩时老师问一句“你怎么证明它复位了”,初值就是现成的答案。时间精度方面,1 Hz 使能脉冲由 50 MHz 整数分频得到,理论误差约 20 ppm,一天累计不到 2 秒,肉眼完全够用;如果非要更准,那是时钟源的问题,不是 Verilog 的问题,不要在代码里做补偿。
2.2 器件与工具链选型:为什么坚持纯 Verilog 而不是堆 IP 核
整套设计只使用 Verilog-2001 的可综合子集,不用状态机生成器、不用计数器 IP、不用 ROM 查表存段码(段码用 case 语句就够了,四位数码管十个数码字形,写成常量表反而增加了 BRAM 综合的风险)。理由很实际:课程设计和毕设的评分点往往在“你能不能自己写出可综合的逻辑”,一旦贴上一堆 IP 核,答辩时被追问“这个分频器的位宽为什么是 26 位”就答不上来。纯 RTL 还有一个好处是移植成本低,换一块开发板只需要改引脚约束和时钟频率参数,逻辑主体不用动。
时钟频率是选型里最容易出错的地方。市面上常见的教学板晶振有 50 MHz、25 MHz、12 MHz 几种,写代码前必须先看原理图确认,把频率做成localparam放在一个公共头文件里,分频系数和扫描节拍全部由它推导,换板子只改一个数字。我见过不止一次翻车案例:代码里硬编码 50 000 000,结果板子是 12 MHz 晶振,表现是“数字钟走得飞快”,学生以为是逻辑错,查了两天。
// params.vh —— 全工程唯一的频率来源,换板子只改这一行 `ifndef PARAMS_VH `define PARAMS_VH // 依据开发板原理图填写,常见取值 50_000_000 / 25_000_000 / 12_000_000 `define CLK_FREQ_HZ 50_000_000 // 1 Hz 计时节拍的分频上限 `define TICK_1HZ_MAX (`CLK_FREQ_HZ - 1) // 1 kHz 数码管扫描节拍 `define SCAN_1KHZ_MAX (`CLK_FREQ_HZ / 1000 - 1) // 1 ms 按键采样节拍 `define SAMPLE_1MS_MAX (`CLK_FREQ_HZ / 1000 - 1) `endif这段参数头的价值在于把“器件相关”和“逻辑相关”彻底分开。TICK_1HZ_MAX之所以写成CLK_FREQ_HZ - 1而不是直接写 49_999_999,是因为从 0 计到 N-1 才是 N 个周期,分频比才准确等于 N;SCAN_1KHZ_MAX用整除,50 MHz 除以 1000 正好整除,如果晶振是 12 MHz 就会出现 12000 的整除结果,扫描频率仍有 1 kHz,误差可以忽略。位宽不用手写,交给综合工具根据上限推导,或者在声明时用$clog2宏包一层,避免出现“计数器溢出后从头开始”的隐蔽 bug。
2.3 时钟与复位策略:全局时钟加使能脉冲,别造第二个时钟
整个设计只有一个时钟域,就是板载晶振输入的全局时钟。所有分频出来的 1 Hz、1 kHz、1 ms 一律做“使能脉冲”而不是“分频时钟”,也就是拉高一拍然后立刻拉低,让下游模块仍然在 50 MHz 时钟下工作,只是条件判断里多一个if (tick_1hz)。这么做的好处很直接:全设计单时钟域,不需要处理跨时钟域同步,静态时序分析只有一条时钟路径,不会出现“分频时钟走了普通布线资源导致偏斜过大”的问题。反面教材是用always @(posedge clk_1hz)当时钟沿,综合工具虽然能过,但布局布线后 1 Hz 信号会经过一堆 LUT,抖动大得离谱。
复位采用异步复位、同步释放的标准结构。异步复位的目的是上电时不需要时钟就能把寄存器拉到确定状态;同步释放的目的是避免复位信号撤销的时刻恰好落在时钟沿附近,导致部分寄存器早一拍出复位、部分晚一拍,出现亚稳态传播。
// rst_sync.v —— 异步复位、同步释放 module rst_sync ( input wire clk, // 全局时钟 input wire rst_async_n, // 按键或上电复位,低有效,异步 output wire rst_n_sync // 同步后的复位,低有效,供全设计使用 ); reg [1:0] sync_ff; always @(posedge clk or negedge rst_async_n) begin if (!rst_async_n) sync_ff <= 2'b00; // 异步清零,立即生效 else sync_ff <= {sync_ff[0], 1'b1}; // 两级移位,第二级输出 end assign rst_n_sync = sync_ff[1]; endmodule两级触发器的第一级负责采样异步复位信号,第二级把已经同步好的电平送给全设计,这样复位撤销的时刻一定对齐到时钟沿之后。rst_async_n接板上的复位按键时要注意按键本身有抖动,短按一下可能产生多次复位脉冲,虽然对功能影响不大,但会让显示闪一下;如果介意,可以在复位按键后面串一级消抖再进rst_sync。另外,综合工具默认会把复位信号拉到全局网络,如果没有专门的复位引脚,就在约束文件里给它分配一个普通 IO,不要强求。
3. 总体架构设计:一个顶层加五个子模块的职责划分
架构上我倾向“薄顶层 + 厚子模块”。顶层只做三件事:例化子模块、连线、把分散的控制信号打包成总线。计时、消抖、扫描、译码、模式管理全部下沉到独立文件,每个文件一个模块,端口不超过十个。这样做的直接收益是仿真时可以单独把计时模块拎出来跑 24 小时加速仿真,不用带着扫描模块一起等;调试时可以只替换消抖模块,其它文件不动。缺点是文件数量多,但对课程设计和毕设来说,文件多反而显得结构清楚,答辩时打开工程目录一眼就能看懂。
模块之间的关系是单向的:key_debounce输出三个干净的单拍脉冲给mode_ctrl,mode_ctrl输出“当前显示的是计时值还是设置值”的选择信号给display_mux,time_core始终以 1 Hz 节拍自增并在设置态接收mode_ctrl送来的加减脉冲,seg_scan只负责把四位 BCD 变成段码和位选。数据流从上到下,没有任何模块回头去读下游的状态,避免了组合环和难以定位的时序问题。
3.1 顶层端口定义与例化骨架
顶层端口就是芯片对外的窗口,定义时要按“输入在前、输出在后、时钟复位单独放”的顺序写,方便约束文件逐条对应。数码管如果采用共阳接法,段码输出是低有效,位选也是低有效,这一点必须在端口注释里写清楚,否则换一块共阴的板子就要改译码表,容易漏改。
// top_clock.v —— 顶层仅做例化与连线 module top_clock ( input wire clk, // 板载晶振,频率见 params.vh input wire rst_n, // 复位按键,低有效 input wire [2:0] key_n, // KEY[0]模式 KEY[1]加 KEY[2]减,低有效 output wire [3:0] seg_sel, // 四位位选,低有效 output wire [7:0] seg_data, // a~g + dp,低有效(共阳) output wire beep // 整点提示,高有效 ); wire clk_50m = clk; wire rst_n_sync; wire tick_1hz, tick_1khz, tick_1ms; wire [3:0] key_pulse; // 每键一个单拍脉冲 wire [1:0] set_sel; // 0正常 1调时 2调分 wire [3:0] h_hi, h_lo, m_hi, m_lo; wire blink_en; // 复位同步 rst_sync u_rst (.clk(clk_50m), .rst_async_n(rst_n), .rst_n_sync(rst_n_sync)); // 节拍生成:三个使能脉冲,全部单拍 tick_gen u_tick ( .clk(clk_50m), .rst_n(rst_n_sync), .tick_1hz(tick_1hz), .tick_1khz(tick_1khz), .tick_1ms(tick_1ms) ); // 按键消抖 key_debounce u_key ( .clk(clk_50m), .rst_n(rst_n_sync), .tick_1ms(tick_1ms), .key_n(key_n), .key_pulse(key_pulse) ); // 计时核心 + 设置逻辑 time_core u_time ( .clk(clk_50m), .rst_n(rst_n_sync), .tick_1hz(tick_1hz), .key_pulse(key_pulse), .set_sel(set_sel), .blink_en(blink_en), .h_hi(h_hi), .h_lo(h_lo), .m_hi(m_hi), .m_lo(m_lo), .beep(beep) ); // 动态扫描显示 seg_scan u_seg ( .clk(clk_50m), .rst_n(rst_n_sync), .tick_1khz(tick_1khz), .blink_en(blink_en), .set_sel(set_sel), .h_hi(h_hi), .h_lo(h_lo), .m_hi(m_hi), .m_lo(m_lo), .seg_sel(seg_sel), .seg_data(seg_data) ); endmodule例化时全部使用命名端口连接,不用位置连接,这是硬性习惯:后面增删端口时命名连接会直接报错提示,位置连接则会静默连错。tick_gen把三个节拍合并到一个模块里,好处是分频计数器集中管理、位宽一目了然,代价是这个模块的扇出比较大,需要在综合后看一眼 fanout 报告,必要时对使能脉冲加一级寄存器打拍。key_pulse定义为 4 位而不是 3 位,留一位给后续扩展(比如加一个“秒清零”键),预留位在综合时会被优化掉,不占资源。
3.2 模块划分表与信号命名规范
模块边界定好之后,用一张表把职责、输入输出和时钟域固定下来,写代码时照着表走,评审时也照这张表核对。
| 模块 | 职责 | 关键输入 | 关键输出 | 时钟域 |
|---|---|---|---|---|
rst_sync | 异步复位同步释放 | rst_async_n | rst_n_sync | 全局 |
tick_gen | 产生 1 Hz / 1 kHz / 1 ms 使能 | clk | 三个单拍tick_* | 全局 |
key_debounce | 20 ms 消抖 + 长短按区分 | key_n[2:0] | key_pulse[3:0] | 全局 |
time_core | 24 小时 BCD 计时、设置写回、整点脉冲 | tick_1hz、按键 | h_hi/h_lo/m_hi/m_lo | 全局 |
seg_scan | 四位动态扫描、段码译码、闪烁 | 8 位 BCD、tick_1khz | seg_sel、seg_data | 全局 |
命名规范我一般强制三条:所有低有效信号后缀加_n;所有使能脉冲前缀用tick_;所有寄存器输出不加reg_前缀但必须在声明时用reg,靠语法区分。BCD 数据统一用“高低位分开”的命名,h_hi表示小时十位,m_lo表示分钟个位,绝不出现hour[7:0]这种打包形式——打包看着简洁,但每次取位都要写hour[7:4]和hour[3:0],改一次位宽就要全文搜索,是典型的省事一时、维护一世。
4. 关键模块实现:分频、计时、消抖与动态扫描的代码细节
这一章是整套方案的主体。四个模块里,分频器决定准不准,计时核心决定对不对,消抖决定手感,扫描决定看不看得清。四个模块都可以单独做仿真:分频器看计数到上限时tick是否只高一拍;计时核心把tick_1hz换成每 10 个时钟周期一次的测试激励,几毫秒就能跑完 24 小时;消抖用随机抖动激励验证输出只有一个脉冲;扫描看位选是否严格互斥。分开仿真的效率比整体仿真高一个数量级,这是我在实际项目里被“仿真跑一晚上还没到 00:01”逼出来的习惯。
4.1 分频器:从 50 MHz 到 1 Hz,差一个数就是 20 ppm
分频器的核心只有一句话:计满 N 个时钟周期产生一个单拍使能,N 等于时钟频率除以目标频率。写错的方式通常有三种:计数上限写成 N 而不是 N-1,导致周期多一拍;位宽不够,计数器提前溢出,实际分频比变成 2 的幂;使能信号拉高后忘记清零,变成电平而不是脉冲。第三种最隐蔽,因为下游如果用if (tick)而不是if (tick && !tick_d),看上去也能工作,但一旦下游改成边沿检测就会漏掉一次。
// tick_gen.v —— 一个模块生成三种使能,全部单拍 module tick_gen ( input wire clk, input wire rst_n, output reg tick_1hz, output reg tick_1khz, output reg tick_1ms ); `include "params.vh" reg [25:0] cnt_1hz; // 2^26 = 67_108_864 > 50_000_000 reg [15:0] cnt_1khz; // 2^16 = 65_536 > 50_000 reg [15:0] cnt_1ms; // 1 Hz:计到 CLK_FREQ_HZ-1 归零,输出一拍高 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1hz <= 26'd0; tick_1hz <= 1'b0; end else if (cnt_1hz >= `TICK_1HZ_MAX) begin cnt_1hz <= 26'd0; tick_1hz <= 1'b1; // 单拍脉冲 end else begin cnt_1hz <= cnt_1hz + 1'b1; tick_1hz <= 1'b0; end end // 1 kHz:驱动动态扫描,决定刷新率 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1khz <= 16'd0; tick_1khz <= 1'b0; end else if (cnt_1khz >= `SCAN_1KHZ_MAX) begin cnt_1khz <= 16'd0; tick_1khz <= 1'b1; end else begin cnt_1khz <= cnt_1khz + 1'b1; tick_1khz <= 1'b0; end end // 1 ms:按键采样节拍,20 ms 消抖即连续 20 次采样一致 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1ms <= 16'd0; tick_1ms <= 1'b0; end else if (cnt_1ms >= `SAMPLE_1MS_MAX) begin cnt_1ms <= 16'd0; tick_1ms <= 1'b1; end else begin cnt_1ms <= cnt_1ms + 1'b1; tick_1ms <= 1'b0; end end endmodulecnt_1hz声明为 26 位是有依据的:50 000 000 介于 2^25 和 2^26 之间,25 位最大只能表示 33 554 431,用它计数会在到达上限前溢出,实际分频比变成 33 554 432,时钟会走快约 33%。这类错误在仿真里跑 1 秒看不出来,因为仿真时间尺度短,只有上板比对真实时间才会暴露。cnt_1khz和cnt_1ms的计数上限相同(都是CLK_FREQ_HZ/1000-1),但刻意分成两个独立计数器,而不是用一个计数器同时产生两个使能——共用计数器时两个使能会严格同拍,扫描和按键采样在同一时钟沿动作,扇出集中在一个 LUT 上,布局后容易出现建立时间紧张。分开写各占一个计数器,资源代价可以忽略。
4.2 计时核心:BCD 六十进制与 24 小时进位的写法
计时核心最容易写错的地方是进位。用二进制计数再转 BCD 会引入除法器,综合出来是一大片组合逻辑,时序很差;直接用 BCD 计数最省事,每一位用四位二进制表示 0~9,进位条件写成“低位等于 9”而不是“等于 15”。秒和分是 60 进制,时是 24 进制,三个进位的判断条件不一样,必须分开写清楚。
// time_core.v 片段:秒计数与向分钟的进位 // 秒个位 0~9,秒十位 0~5,59 之后归零并产生一拍 carry_sec always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_lo <= 4'd0; sec_hi <= 4'd0; carry_sec <= 1'b0; end else if (tick_1hz) begin if (sec_lo == 4'd9) begin sec_lo <= 4'd0; if (sec_hi == 4'd5) begin sec_hi <= 4'd0; carry_sec <= 1'b1; // 59 -> 00,向分钟进位 end else begin sec_hi <= sec_hi + 1'b1; carry_sec <= 1'b0; end end else begin sec_lo <= sec_lo + 1'b1; // 0~8 直接加一 carry_sec <= 1'b0; end end else begin carry_sec <= 1'b0; // 进位只保持一拍 end end // 分钟:在 carry_sec 或按键加脉冲时递增,59 之后进位到小时 // 小时:23:59 之后回到 00:00,判断条件是 h_hi==2 && h_lo==3carry_sec设计成单拍脉冲而不是电平,是为了让分钟模块在else分支里明确清零,避免出现“秒停在 59 不动、进位却一直为高、分钟跟着狂跳”的连锁故障。分钟的写法和秒完全对称,只是触发源从tick_1hz换成carry_sec || min_add_pulse。小时的进位条件要单独判断:先看m_hi==5 && m_lo==9且秒进位成立,再看h_hi==2 && h_lo==3,满足就整体清零,否则h_lo从 9 进位到h_hi。
加减按键的写回逻辑要防止“越界”。加的时候如果当前是 23,加一应该是 00 而不是 24;减的时候如果是 00,减一应该是 23。BCD 做减法的常见错法是直接h_lo <= h_lo - 1,当h_lo为 0 时会借位成 15,显示成一位乱码。正确的写法是先判断h_lo == 0,是则把h_lo置 9 并让h_hi减一,再单独判断h_hi下溢。这一段代码不长,但分支多,建议在仿真里把 00:00 减一和 23:59 加一这两个边界各跑一遍,波形对上再上板。
4.3 按键消抖:20 ms 采样加长短按区分,手感全在这里
机械按键的抖动时间典型值在 5~20 ms,消抖窗口取 20 ms 比较稳。实现方式不用延时,用“连续采样一致计数”:每 1 ms 采一次键值,和上一次采样相同则计数器加一,不同则计数器清零并更新采样值,计数器到 20 就认定稳定。这样得到的是一个稳定的电平key_stable,再用两级寄存器做边沿检测,得到单拍脉冲key_pulse。
// key_debounce.v —— 单键消抖 + 下降沿脉冲(按键低有效) module key_debounce_one ( input wire clk, input wire rst_n, input wire tick_1ms, // 1 ms 采样使能 input wire key_n, // 原始按键,低有效 output reg key_pulse // 稳定按下时输出一拍高 ); reg key_d0, key_d1; // 同步 + 边沿检测 reg key_stable; // 消抖后的电平,低有效 reg [4:0] stable_cnt; // 0~20,5 位够用 localparam integer STABLE_MAX = 20; // 连续 20 次一致 = 20 ms // 第一级:把异步按键同步到本时钟域 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_d0 <= 1'b1; key_d1 <= 1'b1; end else begin key_d0 <= key_n; key_d1 <= key_d0; end end // 第二级:1 ms 采样计数,连续一致才更新稳定电平 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin stable_cnt <= 5'd0; key_stable <= 1'b1; end else if (tick_1ms) begin if (key_d1 == key_stable) begin stable_cnt <= 5'd0; // 与稳定值一致,计数清零 end else if (stable_cnt >= STABLE_MAX) begin key_stable <= key_d1; // 连续 20 ms 不一致,采纳新值 stable_cnt <= 5'd0; end else begin stable_cnt <= stable_cnt + 1'b1; end end end // 第三级:下降沿检测,输出单拍脉冲 reg key_stable_d; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_stable_d <= 1'b1; key_pulse <= 1'b0; end else begin key_stable_d <= key_stable; key_pulse <= key_stable_d & ~key_stable; // 1 -> 0 时拉高一拍 end end endmodule三级结构各管一件事:第一级解决亚稳态,因为按键是异步输入,直接进组合逻辑可能采到中间电平;第二级解决抖动,用时间换稳定;第三级解决“按一下算一次”,把电平转成脉冲。key_stable_d & ~key_stable这个表达式只在稳定电平从高变低的那一拍为真,正好对应按键按下。若要做长按连加,不要在这里加,把key_stable引出去,在模式控制模块里用独立计数器实现,保持消抖模块的单一职责。
三个按键复用同一个模块,例化三次即可。注意每个实例的stable_cnt是独立的,不会互相干扰;但如果三个按键共用同一个 1 ms 节拍,按下时刻可能落在同一拍上,此时三个key_pulse会同时为高,模式控制和加减逻辑要有优先级——通常让“模式切换”优先,避免在切换模式的同一拍里把时间也改了。
4.4 数码管动态扫描:1 kHz 刷新率与重影的根源
动态扫描的原理是分时复用:四位位选轮流有效,每次只亮一位,靠人眼视觉暂留合成四位同时亮的观感。刷新率低于 50 Hz 会看到闪烁,高于 1 kHz 则单个数码管的点亮时间太短、亮度不够,工程上一般取 200 Hz 到 1 kHz,这里选 1 kHz,四位轮一遍是 250 Hz,不会闪。
// seg_scan.v 片段:位选轮转与段码译码 reg [1:0] scan_idx; // 0~3,对应四位 reg [3:0] cur_bcd; // 当前位要显示的数字 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_idx <= 2'd0; seg_sel <= 4'b1111; // 共阳位选低有效,全灭 end else if (tick_1khz) begin scan_idx <= scan_idx + 1'b1; case (scan_idx) 2'd0: seg_sel <= 4'b1110; // 时十位 2'd1: seg_sel <= 4'b1101; // 时个位 2'd2: seg_sel <= 4'b1011; // 分十位 default: seg_sel <= 4'b0111; // 分个位 endcase end end // 按位选选择对应的 BCD 数据 always @(*) begin case (scan_idx) 2'd0: cur_bcd = h_hi; 2'd1: cur_bcd = h_lo; 2'd2: cur_bcd = m_hi; default: cur_bcd = m_lo; endcase end // 共阳段码:0 亮 1 灭,位序 {dp,g,f,e,d,c,b,a} always @(*) begin case (cur_bcd) 4'd0: seg_data = 8'b1100_0000; 4'd1: seg_data = 8'b1111_1001; 4'd2: seg_data = 8'b1010_0100; 4'd3: seg_data = 8'b1011_0000; 4'd4: seg_data = 8'b1001_1001; 4'd5: seg_data = 8'b1001_0010; 4'd6: seg_data = 8'b1000_0010; 4'd7: seg_data = 8'b1111_1000; 4'd8: seg_data = 8'b1000_0000; 4'd9: seg_data = 8'b1001_0000; default: seg_data = 8'b1111_1111; // 非法输入全灭,防止乱码 endcase end重影是动态扫描最典型的问题,表现是相邻两位有淡淡的叠影。原因通常是位选切换和段码更新不在同一拍:位选已经切到下一位,段码还停留在上一位的值,那一瞬间就亮了错误的字形。解决办法是把位选和段码都用同一个tick_1khz驱动,并让seg_data在tick_1khz之后的一拍再更新,或者更简单——位选切换时先把所有段关掉(消隐),下一拍再输出新段码。多花一拍时间,亮度损失可以忽略,重影彻底消失。
闪烁提示的插入点是seg_data输出之前:在设置态下,如果当前位是被调整的位(调时对应scan_idx为 0 或 1,调分对应 2 或 3),并且闪烁相位为灭,就把seg_data强制为全 1。闪烁相位用一个 1 Hz 的使能翻转寄存器产生,占空比 50%,亮 0.5 秒灭 0.5 秒,肉眼看着舒服。
5. 常见问题排查:仿真通过但上板出错时会遇到的问题
FPGA 数字钟最让人难受的地方在于“仿真波形全对,板子上就是不对”。这不是玄学,绝大多数情况能归到三类:时间基准类、显示与输入类、综合与约束类。下面每一条都按“现象 → 原因 → 解决”写,都是我在带人和自己做的时候真实遇到过的,可以当成一份排查顺序表用。
5.1 计时相关:走快走慢与进位错乱
现象一:数字钟明显走快,一天能快好几分钟。原因通常有两个,一是分频计数器位宽不足导致提前溢出,实际分频比小于设计值;二是板载晶振频率与代码里的CLK_FREQ_HZ不一致,比如板子是 12 MHz 却按 50 MHz 分频,会快将近四倍。排查方法很简单:在tick_1hz上接一个测试引脚,用示波器或逻辑分析仪量周期,看是不是 1 秒;没有仪器就用手机秒表比对 10 分钟,误差超过 1 秒就说明分频比错了。解决方式是把频率参数化到params.vh,位宽用$clog2推导,改板子只改一个宏。
现象二:秒到 59 之后分钟不动,或者分钟一次跳两格。原因是进位信号被写成了电平而不是单拍脉冲,或者进位信号的清零分支漏了。电平形式会让分钟在每个时钟周期都看到进位,一分钟内跳几十次;跳两格则是进位信号保持了整整一个 1 Hz 周期,分钟模块在同一拍里既响应进位又响应按键。解决方式是把进位信号严格做成单拍,在else分支无条件清零,并在仿真里专门跑 59→00 这一段,波形上数清楚高电平持续几个时钟周期。
5.2 显示与按键相关:重影、闪烁、按一下跳好几下
现象三:数码管有重影,或者某一位偶尔显示错误数字。除了位选与段码不同步,还有一个常被忽略的原因:seg_sel用了组合逻辑直接由scan_idx译码,毛刺在切换瞬间产生一个极窄的位选脉冲,恰好落在段码更新的时刻。解决方式是给seg_sel加一级寄存器输出,同时在切换时插入一拍全灭消隐,两招一起用基本可以彻底消除。
现象四:按一次键,时间跳了好几下。说明消抖没生效,或者脉冲产生逻辑写成了电平。检查两处:key_stable是否真的只在连续 20 次采样一致后才更新;key_pulse表达式里是否漏了“上一拍稳定值”这个条件。另一个隐蔽原因是按键引脚没有分配上拉,悬空时电平乱跳,内部上拉能解决大部分问题,但最好在约束文件里显式配置上拉,不要依赖默认值。
5.3 上板与约束类:综合通过、下载成功、板子没反应
现象五:工程综合通过,下载也显示成功,数码管全灭。先看引脚约束是不是漏了段码或位选,再确认电平极性。共阳数码管段码低有效,如果译码表按共阴写的,表现就是“该亮的灭、该灭的亮”,看起来像乱码而不是全灭;全灭更可能是位选全高(共阳位选低有效),也就是复位后seg_sel的初值给成了4'b1111但扫描模块的tick_1khz没跑起来。查时序:把tick_1khz引到一个 LED 上,看它是否以 1 kHz 闪(肉眼看到的是常亮偏暗),从而判断节拍链是否正常。
现象六:时序报告有警告,但功能看上去正常。不要忽略建立时间违例的警告。数字钟逻辑简单,通常不会有时序问题,但如果把分频计数器写成了组合逻辑输出、或者用了always @(*)里带反馈的写法,综合工具会插入 latch,时序和功能都不可预期。养成看综合报告里 latch 数量的习惯,正常应该是 0,出现 latch 一定要回去改成完整的if/else或case加default。
6. 验收与答辩:用一份测试清单和十分钟演示证明它是对的
数字钟这类项目的验收,最忌讳“跑起来看着对就交”。老师在答辩时通常只问三个问题:准不准、能不能调、凭什么证明。前两个靠现场演示,第三个靠一份提前写好的测试清单。我的做法是列一张表,把每一项的“操作方法、通过判据、失败原因”写清楚,测试时逐项打勾,答辩时把这张表投出来,比讲十页原理图管用。
| 编号 | 测试项 | 操作方法 | 通过判据 | 常见失败原因 |
|---|---|---|---|---|
| T1 | 复位初值 | 上电或按复位键 | 显示固定为 12:00,秒内部从 0 开始 | 初值写成 00:00,无法区分复位是否生效 |
| T2 | 走时精度 | 与标准时间同时起动,比对 10 分钟 | 误差不超过 1 秒 | 分频上限差一、晶振频率参数写错 |
| T3 | 秒→分进位 | 观察 59 秒到 00 秒 | 分钟恰好加一,不跳两格 | 进位信号是电平而非单拍 |
| T4 | 24 小时回绕 | 调到 23:59 等一分钟 | 回到 00:00,星期/日期不参与 | 小时进位条件写成h_lo==9 |
| T5 | 模式切换 | 连续按模式键三次 | 正常 → 调时 → 调分 → 正常,循环无卡死 | 状态机漏写default分支 |
| T6 | 加减边界 | 在 00:00 按减、在 23:59 按加 | 分别回到 23:59 和 00:00 | BCD 直接减法产生借位乱码 |
| T7 | 按键手感 | 快速连按、长按各三次 | 每次只加一,长按连续加 | 消抖窗口过短或脉冲逻辑错 |
| T8 | 显示质量 | 距离 30 cm 观察 10 秒 | 无重影、无闪烁、亮度均匀 | 位选与段码不同步、刷新率过低 |
| T9 | 整点提示 | 调到 59 分等待进位 | 蜂鸣器响约 0.5 秒,仅响一次 | 脉冲未做单拍处理 |
演示流程我一般固定成十分钟:开场先复位,让老师看到初值;然后用手机秒表同步比对两分钟,边比边讲分频比怎么算;接着演示调时,特意在 23:59 加一,展示回绕;最后在设置态展示闪烁提示和整点蜂鸣。每一步都对应表里的一行,讲完正好把 T1 到 T9 走一遍,剩下时间留给提问。
答辩提问里最容易卡住的是“你这个 1 Hz 是怎么来的,精度多少”。答案要能一句话说清:50 MHz 整数分频,计数 50 000 000 个周期产生一个单拍使能,理论误差约 20 ppm,一天累计不到 2 秒,误差来源是晶振本身而不是逻辑。如果被问到“为什么不用分频时钟”,就答单时钟域便于时序分析和跨模块复用。还有人会问“按键为什么要 20 ms”,答机械按键抖动典型 5~20 ms,取上限覆盖绝大多数器件,代价只是 20 ms 的响应延迟,人感觉不到。
从那以后我每次带人做这块板子,都强制先跑一遍“10 分钟秒表比对 + 23:59 回绕”这两项,再谈其它功能,因为这两个动作能一次性暴露分频和进位这两条最容易出错的时间线,比盯着波形看半天有效得多。希望这套结构能帮到你,把课程设计交得踏实一点。
本文还有配套的精品资源,点击获取