简介:基于Quartus的多功能数字钟设计文档是一份面向数字电子技术课程实验的完整报告,适合电子信息类学生、课程设计者及电路入门者参考。文档从实验内容和设计要求出发,系统阐述了计时电路、译码显示、脉冲发生、报时、校分与清零电路的工作原理,并给出附加的自动停启功能,能够帮助读者理解数字钟的整体设计流程与模块化实现方法。其中涉及晶振与分频产生1Hz秒脉冲、4518与74161配合完成计数、CD4511译码驱动共阴极数码管,以及逻辑门组合实现定点报时等关键细节,内容具体且贴近实际实验操作。此外还包含实验中常见问题的解决思路,如仿真元件替代、开关抖动干扰排查等,并附有元器件名称、引脚图和功能表,便于对照电路进行调试。资源共1个docx文件,压缩包大小约897KB,已有698人浏览学习,是一份结构清晰、可操作性强的课程设计参考资料。
1. 多功能数字钟在 Quartus 里到底做了什么
把标题拆开看,核心不是“钟”,而是“多功能”三个字背后的设计取舍。一个只显示时分秒的计数器,用 74 系列芯片搭或者用单片机定时器都能做,但放在 Quartus 里用 FPGA 实现,意味着你要在一颗可编程逻辑器件里同时解决分频、计数、译码显示、按键消抖、校时逻辑这几个互相关联的问题。常见的课程设计和工程入门项目里,数字钟往往承担着“第一个完整状态机”的角色,它逼着你从写一个独立模块过渡到把多个模块拼成一个可综合系统。
这篇文章面向两类人:一类是刚装好 Quartus、准备交实验报告的学生,想知道代码怎么分层、仿真怎么看波形;另一类是工作后回头补 FPGA 基础的工程师,想把计数器的边界条件、竞争冒险和仿真技巧一次看透。我按自己平时做小系统的那套流程来讲:先拆功能,再写模块,然后给仿真和约束,最后收在几个真正值得记住的调试技巧上。目标是你照着做能跑通,跑通之后还敢改参数。
2. 数字钟的功能拆解与模块划分:先定计数边界,再写代码
2.1 多功能到底指哪几个功能
不同题目里“多功能”的范围差别很大,但最常出现的是这四类:时分秒的正常走时、手动校时(时/分独立调整)、整点报时或闹钟、秒表或倒计时。我一般建议第一版只做前三项,秒表放在整个系统跑通之后再往上挂,原因很简单——秒表涉及暂停、清零、继续三个控制信号,它一进来,状态机立刻多一组状态,调试成本翻倍。
功能明确了,就要把“边界”定死。24 小时制的话,小时计数器到 23 之后必须清零;59 秒到 60 的瞬间要产生一个进位脉冲给分钟;而进位脉冲宽度必须是单周期,否则分钟会被重复累加。这些边界条件在写代码之前不列清楚,写出来就是到处打补丁的毛病。
2.2 计数器模块的三个输入和一个输出
数字钟的核心是“秒脉冲”。板载晶振常见是 50MHz,少数是 12MHz,不管是哪个,都要先分频得到 1Hz 的使能信号。这里有个关键区别:分频出来的不应该是周期性翻转的时钟,而应该是一个“高电平持续一个时钟周期、每秒出现一次”的使能信号。原因很直接——如果 1Hz 信号用作时钟域,跨时钟域处理会引来一堆麻烦,而用作计数使能,所有计数器都在同一个时钟域里,仿真和上板行为完全一致。
分频模块参数化写法如下:
module clk_div_en #(parameter DIV = 50_000_000) ( input wire clk_50m, input wire rst_n, output reg clk_1hz_en ); reg [25:0] cnt; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; clk_1hz_en <= 1'b0; end else if (cnt == DIV - 1) begin cnt <= 26'd0; clk_1hz_en <= 1'b1; end else begin cnt <= cnt + 1'b1; clk_1hz_en <= 1'b0; end end endmodule这个写法里计数器每到 DIV 就归零,同时把使能拉高一拍。下一拍必须拉低,否则连续两个周期都是高电平,秒计数会被推进两次。DIV 参数留出来的好处是,如果以后换 12MHz 晶振,只需要传 12_000_000,不需要改逻辑。下载到板子上如果发现走时偏快,先把这两个零数清楚。
2.3 分频是使能信号而不是时钟
很多人第一次做数字钟会把 1Hz 信号always @(posedge clk_1hz)拿来做计数。这在仿真里没问题,但上板之后综合器会认为这个信号是门控时钟或者衍生时钟,时序约束不好写,而且容易在边沿处产生毛刺。更麻烦的是,如果后续你要在 FPGA 里同时处理按键采样和显示刷新,全系统就变成多时钟域,每个时钟域之间的信号都要做同步。
所以正确的做法是所有模块共用 50MHz 主时钟,1Hz 信号只作为计数器的使能门控。这带来一个直接的好处:你不需要为分频时钟单独加约束,工程里只需要约束主时钟一个对象。综合器会自动优化使能端逻辑,不会把它变成真正的时钟。在 Quartus 的 Compilation Report 里,如果看到 Fmax 报告提示时钟问题,先检查是不是写了posedge clk_1hz。
2.4 时钟节拍使能模式下,计数器要如何避免漏计数
3. 用 Verilog 把数字钟写进 Quartus:核心代码、参数表与测试文件
3.1 数字钟的四个模块:分频、计数、显示、按键控制
数字钟的 Verilog 工程建议拆成四个文件:分频模块、时间计数模块(含时/分/秒)、数码管扫描显示模块、按键消抖与控制模块。顶层模块用例化方式把这四个模块连起来,不要全部写在一个always块里。原因不只是代码规范,更重要的是 Quartus 的 RTL Viewer 会直接显示这四个实例,信号没连通一眼就能看出来。
下面的代码段直接用于时间计数模块,它接受 1Hz 使能信号,输出时、分、秒的 BCD 码:
module time_counter ( input wire clk_50m, input wire rst_n, input wire en_1hz, // 1Hz 使能 input wire adjust_hour, // 小时校时按键(上升沿有效) input wire adjust_min, // 分钟校时按键(上升沿有效) output reg [3:0] sec_lsb, sec_msb, output reg [3:0] min_lsb, min_msb, output reg [3:0] hour_lsb, hour_msb ); always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin {sec_msb, sec_lsb} <= 8'h00; {min_msb, min_lsb} <= 8'h00; {hour_msb, hour_lsb} <= 8'h10; // 初始值 10:00:00 end else if (adjust_hour) begin if ({hour_msb, hour_lsb} >= 8'h23) {hour_msb, hour_lsb} <= 8'h00; else {hour_msb, hour_lsb} <= {hour_msb, hour_lsb} + 1'b1; end else if (adjust_min) begin if ({min_msb, min_lsb} >= 8'h59) {min_msb, min_lsb} <= 8'h00; else {min_msb, min_lsb} <= {min_msb, min_lsb} + 1'b1; end else if (en_1hz) begin if ({sec_msb, sec_lsb} == 8'h59) begin sec_msb <= 4'd0; sec_lsb <= 4'd0; if ({min_msb, min_lsb} == 8'h59) begin min_msb <= 4'd0; min_lsb <= 4'd0; if ({hour_msb, hour_lsb} == 8'h23) begin hour_msb <= 4'd0; hour_lsb <= 4'd0; end else begin {hour_msb, hour_lsb} <= {hour_msb, hour_lsb} + 1'b1; end end else begin {min_msb, min_lsb} <= {min_msb, min_lsb} + 1'b1; end end else begin {sec_msb, sec_lsb} <= {sec_msb, sec_lsb} + 1'b1; end end end endmodule代码里把秒、分、小时分别表示成两个 BCD 位,秒十位最高值 5,小时十位最高值 2。注意 59 到 00 的判断用的是整组 8 位比较,写成{sec_msb, sec_lsb} == 8'h59比分开判断 sec_msb 和 sec_lsb 更贴近人的读法。三个进位点用else if串联,优先级排列是:校时调整 > 校分调整 > 正常走时。这样设计的好处是无论什么时候按下校时键都能立即改时间,不需要先停表。
3.2 BCD 码输出:把数据从 0-59 计数改成数码管能用的形态
计数器内部用的是 BCD 码,不是二进制。原因只有一个——显示模块要驱动七段数码管,而七段译码器按 0-9 的编码来工作最直接。如果计数器用 6 位二进制表示 0-59,显示前要做除 10 取余,在 FPGA 里除法要消耗逻辑单元,不如直接在计数端做成两个 4 位的reg。
需要注意的是秒钟十位 0-5 只占 4 位的高三位,实际编码是 0000-0101,而 0110(6)到 1111(9)这些状态永远不会出现。仿真时如果看到 sec_msb 跳成 6,说明进位判断写错了位置。这也是我在 3.1 中直接把 8 位拼在一起比较的原因——减少综合器优化时产生冗余状态的几率。
3.3 按键消抖与校时逻辑:单脉冲化处理
机械按键最麻烦的不是抖动本身,而是消抖后的信号仍然可能是长电平。在校时逻辑里,如果按键按着不放,计数器会连续加一,这在调整小时时可以接受,但分钟调整就会觉得反应太慢,如果按一下跳多个数,就说明消抖参数给小了。
消抖模块最常规的写法是基于计数器:检测到按键电平变化后,连续采样 N 毫秒,若电平稳定不变则输出有效。N 的选择我一般取 20ms 到 40ms 之间,太短滤不掉抖动,太长按起来会粘滞。
module debounce #(parameter DELAY_MS = 20, parameter CLK_MHZ = 50) ( input wire clk_50m, input wire key_in, output reg key_out ); localparam MAX_COUNT = (DELAY_MS * CLK_MHZ) / 1000; reg [9:0] cnt; reg [1:0] key_sync; // 两级同步器 reg key_stable; always @(posedge clk_50m) begin key_sync <= {key_sync[0], key_in}; if (key_sync[1] != key_stable) begin cnt <= 0; key_stable <= key_sync[1]; end else if (cnt == MAX_COUNT) begin key_out <= key_stable; end else begin cnt <= cnt + 1'b1; end end endmodule这段代码实际上把“消抖”和“边沿检测”合成了一步:key_out 在电平稳定持续 MAX_COUNT 周期后才变成稳定值。而不是每次电平变化都输出一个毛刺。上板接 Quartus 的 SignalTap 观察时,这个实现比单纯延时输出的写法更安全——因为计数器计数区间内 key_out 不再翻转。要注意的是参数里 CLK_MHZ 必须和实际板子对应,否则 20ms 的延时会在 FPGA 里变成不同的物理时间。按键上升沿触发在 time_counter 里已经处理,消抖后的电平信号不需要再额外取沿,只要 time_counter 的always检测到adjust_hour为高就行。
3.4 顶层模块怎么连线:Quartus 里的例化与引脚规划
顶层模块是最后一步手工操作。在顶层里只需要例化 4 个子模块,给出 6 个数码管的位选信号、段码输出、按键输入和时钟输入。下面是典型例子:
module top_digital_clock ( input wire clk_50m, // 板载 50MHz input wire rst_n, // 复位键 input wire key_hour, // 校时按键 input wire key_min, // 校分按键 output reg [5:0] seg_sel, // 位选,低电平有效 output reg [7:0] seg_data // 段码,包含小数点 ); wire en_1hz; wire [3:0] sec_lsb, sec_msb, min_lsb, min_msb, hour_lsb, hour_msb; clk_div_en #(.DIV(50_000_000)) u_clk ( .clk_50m(clk_50m), .rst_n(rst_n), .clk_1hz_en(en_1hz) ); time_counter u_time ( .clk_50m(clk_50m), .rst_n(rst_n), .en_1hz(en_1hz), .adjust_hour(key_hour), .adjust_min(key_min), .sec_lsb(sec_lsb), .sec_msb(sec_msb), .min_lsb(min_lsb), .min_msb(min_msb), .hour_lsb(hour_lsb), .hour_msb(hour_msb) ); display_mux u_disp ( .clk_50m(clk_50m), .rst_n(rst_n), .sec_lsb(sec_lsb), .sec_msb(sec_msb), .min_lsb(min_lsb), .min_msb(min_msb), .hour_lsb(hour_lsb), .hour_msb(hour_msb), .seg_sel(seg_sel), .seg_data(seg_data) ); // 按键消抖例化略,实际需要两路,分别为 key_hour_in / key_min_in endmodule例化时命名不加多余下划线,例化名直接写 u_ 前缀。引脚分配在 Quartus 里点击 Assignments > Pin Planner 完成,给 FPGA 的 GPIO 管脚加上clk_50m的 Location 约束(比如 EP4CE10 上通常是 PIN_E1),并把I/O Standard设为板子上标注的电压(常见是 3.3-V LVTTL 或 2.5V)。分配完成后看 Warning 列表里有没有 "no clock" 的提醒——如果时钟管脚没分配,Quartus 会把它当普通 IO 用,上板后所有模块全部不跑。
| 模块 | 输入 | 输出 | 关键参数 |
|---|---|---|---|
| clk_div_en | clk_50m, rst_n | clk_1hz_en | DIV=50_000_000 |
| time_counter | en_1hz, adjust_hour, adjust_min | 6 个 BCD 位 | 初始值 10:00:00 |
| debounce | key_in | key_out | DELAY_MS=20, CLK_MHZ=50 |
| display_mux | 6 个 BCD 位 | seg_sel, seg_data | 扫描频率约 1kHz |
系统级的 BCD 参数决定走时范围。如果把 hour 的判断从 23 改成 12,再增加一个pm_flag,就变成 12 小时制,但 12 小时的进位逻辑比 24 小时多一个分支(从 12:59 到 01:00),不建议初学者第一版尝试。
3.5 在 ModelSim 或 Quartus 自带的仿真器里写测试文件
写一个能验证封装进位逻辑的 testbench,是仿真最有价值的部分。下面的 tb 只验证 23:59:59 到 00:00:00 的翻转:
`timescale 1ns / 1ps module tb_time_counter; reg clk_50m = 0; reg rst_n = 0; reg en_1hz = 0; reg adjust_hour = 0, adjust_min = 0; wire [3:0] sec_lsb, sec_msb, min_lsb, min_msb, hour_lsb, hour_msb; time_counter uut ( .clk_50m(clk_50m), .rst_n(rst_n), .en_1hz(en_1hz), .adjust_hour(adjust_hour), .adjust_min(adjust_min), .sec_lsb(sec_lsb), .sec_msb(sec_msb), .min_lsb(min_lsb), .min_msb(min_msb), .hour_lsb(hour_lsb), .hour_msb(hour_msb) ); always #10 clk_50m = ~clk_50m; // 50MHz 时钟 initial begin #100 rst_n = 1; repeat (60) begin @(posedge clk_50m); en_1hz = 1; @(posedge clk_50m); en_1hz = 0; #20; end // 触发校时:按小时 + 1 @(posedge clk_50m); adjust_hour = 1; @(posedge clk_50m); adjust_hour = 0; #100; $stop; end endmoduletb 里最关键的是 en_1hz 手动拉 1 一个时钟周期再拉 0,这对仿真器来说代表一个秒脉冲。不要试图用always #50000000直接生成 1Hz 信号,那样整个 tb 要仿真一小时,根本等不到结果。手动给脉冲,一条路径全部跑通。
4. 在 Quartus 里跑通实现:时钟约束、引脚分配与板上验证步骤
4.1 Quartus Prime 里建工程的最低步骤
工程创建有几个必须对齐的选项。打开 Quartus Prime(Lite 版本够用,不需要 license),File > New Project Wizard,器件系列选择 Cyclone IV E(或者你手中板子对应的型号),具体器件写 EP4CE10F17C8 这类完整编号。
工程创建里如果选错器件型号,引脚分配时会发现 no valid pin,因为不同封装管脚名完全对不上。下面是工程参数的参考表:
| 界面位置 | 推荐选项 | 备注 |
|---|---|---|
| Family | Cyclone IV E(或其他,按板子来) | Lite 版自带 |
| Device | 按板子丝印选择 | 例:EP4CE10F17C8 |
| EDA Tool Settings | ModelSim-Altera 或 None | 不影响综合 |
| 顶层实体 | top_digital_clock | 必须与顶层文件名一致 |
顶层文件添加到工程后,先做一次 Analysis & Synthesis(图标是紫色三角)确认无语法错误。然后打开 Pin Planner,分配时钟、复位和按键、数码管引脚。提示:Quartus 的引脚分配表格支持从 FPGA 板卡的引脚图文档复制过来,比手敲快。分配完保存.qsf文件,Quartus 会自动生成引脚约束,不需要手动编辑。
4.2 时序约束文件:主时钟约束必须手工写
如果不上板,只做仿真,时序约束可以不写。但要走完整流程就必须在 Synopsys Design Constraints(SDC)文件里写明主时钟频率。工程建好之后 Quartus 会生成一个默认的.sdc文件,你可以直接打开它加一行:
create_clock -period 20.000 -name clk_50m [get_ports {clk_50m}]这段的意思是:clk_50m 端口的时钟周期是 20ns(50MHz),名字叫 clk_50m。get_ports后面的大括号可以省略,但写成大括号是 Tcl 的列表语法,不会误展开变量,所以保留是安全的。编译后在 TimeQuest Timing Analyzer 里能看到 Clock Setup 报告显示这个时钟的建立时间余量为正,说明时序收敛,不会出现上板稳定性随机的现象。
没有写 SDC 直接点 Start Compilation,工程也能生成.sof文件,但时序报告里会显示 "no clock",驱动数码管扫描频率如果超过几十 kHz 可能没问题,因为扫描频率低,实际看不出时序违规。真正危险的是数码管扫描频率过高时,位选信号和段码信号之间产生偏斜,导致重影或乱码。设置扫描频率的方式在 display_mux 模块里。
4.3 数码管扫描频率:为什么位选要另外分频,以及扫描周期怎么算
6 个数码管共用一组段码线,为了避免残留,位选信号必须轮流拉低,且在切换位选时,段码要先切到对应位的数据,等 1-2 个时钟周期后再切换位选。扫描频率建议 500Hz 到 1kHz,太低会肉眼看到闪烁,太高会超出人眼暂留效应且段码建立时间不够。
扫描分频代码我一般用计数器直接扫,不单独做分频模块:
always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 0; seg_sel <= 6'b111111; // 全部熄灭 end else begin scan_cnt <= scan_cnt + 1'b1; if (scan_cnt == 16'd5000) begin // 10kHz scan_cnt <= 0; case (seg_sel) 6'b111110: seg_sel <= 6'b111101; 6'b111101: seg_sel <= 6'b111011; // 依此类推 endcase end end end这里 scan_cnt 到达 5000 时切换位选,50MHz / 5000 = 10kHz,轮流点亮 6 位就是约 1.67kHz 每管,不会闪。如果发现显示亮度不均,把 5000 系数调大,比如 10000,频率降低,每位点亮时间变长,亮度会增加。这个参数要写在顶层或者 display_mux 的localparam里并加注释,方便复用。
4.4 上板验证流程:先测秒闪烁,再测校时,最后测进位
上板验证有步骤可循。第一步用 SignalTap II Logic Analyzer(在 Tools 菜单下)抓en_1hz信号,确认它有周期性的高脉冲,间隔是 1s,Quartus 里跑了一个小时不闪烁;第二步,检查数码管是否均匀点亮,用眼睛看不闪烁;第三步按校时键,观察小时/分钟是否跳变;最后才去测整点进位。
一个重要提醒:在做第一步之前,把时钟 LED 呼吸灯或者蜂鸣器这类无关功能先禁掉,否则看不到现象会误判是时钟没走。常见现象是六个数码管全都亮同一个数字,这多半是位选反了(高电平选通还是低电平选通错了),而不是时间计数逻辑错误。查一下板子的原理图,把seg_sel的输出极性反过来即可。
5. 进阶与常见坑:如何把数字钟从“能走”改成“走得准且好用”
5.1 用 32768Hz 晶振替代 50MHz:走时精度为什么差 10 倍以上
50MHz 晶振是开发板上的通用时钟,它的频率误差一般在 ±50ppm 左右,换算成一天就是 86400 秒 × 50/1e6 ≈ 4.3 秒误差。这对实验够用,但真正当钟用就不合格。我在板子上保留 50MHz 给逻辑,另外把 32768Hz 的晶振接进 FPGA 的专用时钟脚,用同样使能方式做分频,误差从此降到 ±20ppm 以内。
替换分频参数时可以保留原来的 DIV 参数模块不变,另起一个clk_div_en_32768例化,并把它的使能信号接到 time_counter。注意 32768Hz 分频到 1Hz 需要计数器计到 32767,这个数不是 2 的幂,所以在 32768 模块内不要用casez清零,而是要判断cnt == 32767,代码模式与 2.2 的 50MHz 版本一致,只改 DIV 值。仿真时如果直接仿真真实晶振,一秒一个脉冲要跑很久,可以临时把 DIV 改成 32,验证分频逻辑进位正确后,再改回真实值。
5.2 上电瞬间和复位释放的毛刺问题
FPGA 上电后所有寄存器初始值默认是 0,所以代码里如果用initial给寄存器赋值,在低端 Cyclone 系列里是不可靠的。我在时 3.1 的 time_counter 中写的是rst_n复位分支里给初始值 10:00:00,这样保证无论什么时候复位键按下,都能恢复到一个确定状态。不要在顶层用initial把 sec_msb 之类的变量赋初值,Quartus 综合时会忽略它,导致上电时状态随机。
5.3 按键消抖、扫描显示和计数模块的时钟域问题
这三个模块都在同一个时钟域里,这是目前设计最稳妥的约束。如果在后续扩展中加入蜂鸣器报时功能,要让蜂鸣器响多长,可以用一个计数器实现,不要用delay语句,在可综合代码里delay不产生任何硬件,只会让综合器报错。我见过有人把整点报时直接写成循环触发蜂鸣器,结果蜂鸣器响不停,最后查出来是因为计数器溢出后反复进位,进位信号没有被单脉冲化。报时控制信号的正确做法是:在 en_1hz 到来的同一个时钟沿,检测到分钟和秒钟都为零时,拉高一个beep_en,并让蜂鸣器驱动模块计数 500ms 后自动拉低。
5.4 用 SignalTap 验证进位是否一次只跳一
SignalTap 是排查这类问题最直接的工具。在 Quartus 里双击 SignalTap II Logic Analyzer,添加要观察的hour_msb、hour_lsb和min_msb等信号,设置触发条件为min_lsb == 4'h9 && min_msb == 4'h5,采样深度选 1K。编译下载后触发一次,看秒翻转后分钟是否在同一时刻加 1;如果分钟加了 2,说明秒进位信号持续了两个周期,往回查 en_1hz 是否有一拍没有拉低。
把 SignalTap 采样时钟设为 50MHz 主时钟,采样窗口就能看到完整的进位时序。触发条件比较难写的话,可以把触发信号改成en_1hz && sec_lsb == 4'h9 && sec_msb == 4'h5,在秒到 59 之前就触发,把 59 到 00 的翻转完整抓下来。
5.5 给 BMP 文件烧写通常用 JTAG,下载器驱动找不到怎么办
Quartus 自带的下载器驱动在安装目录的drivers子目录里。在 Windows 设备管理器里看到 USB-Blaster 带黄色感叹号,手动更新驱动时指向 Quartus Prime 安装路径下的drivers/usb-blaster文件夹即可,系统会自动匹配 64 位驱动。驱动装好之后,打开 Programmer 界面加载.sof文件默认走 JTAG 配置,点击 Start 之后观察进度条旁的进度百分比。
对于逻辑较复杂的工程(比如加了几层流水线),下载完出现不跑的现象,优先排时钟约束问题,而不是改代码。再提醒一次:仿真通过不等于上板正常,仿真不会指出你引脚分配错误,只有到了 Pin Planner 阶段才能发现。全套流程走到这里,数字钟已经从“能显示”变成“能测、能改、能校时”的完整小系统。要不要再加闹钟或者温度显示,就看你手上还有多少空余管脚了。
本文还有配套的精品资源,点击获取