Verilog数字钟课程设计:架构模块实现与仿真调试全攻略
2026/9/7 8:44:31 网站建设 项目流程

简介:一份Verilog数字钟课程设计文档,基于FPGA(现场可编程门阵列)平台,系统讲述了数字钟从需求分析、方案设计、模块拆分到仿真验证与下载上板的完整过程。文档围绕基本计时、校时、报时三大功能,具体说明计数器、分频器、七段数码管显示编码、按键输入与状态控制等关键模块的设计思路,并配有系统框图、模块说明、Modelsim仿真步骤以及FPGA实现流程。代码部分给出完整的Verilog源码,实验心得记录常见问题与调试经验,可直接用于课程设计报告撰写或项目改造。整个压缩包仅含1个docx文件,大小68KB,内容包含摘要、关键词、目录、正文、实验心得、参考文献等标准章节,结构清晰便于查阅。已有205人学习下载,适合数字系统设计、EDA技术等课程的本科生或Verilog初学者作为设计参考和模板。

1. 写在前面:这门课设到底在考你什么

“Verilog数字钟课程设计”这个题目,几乎每个学FPGA或者数字电路的人都会碰到。说实话,单看代码量,它不算大——核心模块加起来可能也就两三百行。但你别小看这个题目,它几乎是把你前几学期学过的数字电路知识做了个大串联:时序逻辑、组合逻辑、分频、计数、译码显示、按键消抖、仿真验证,全部揉在一起。很多同学拿到题目第一反应是“不就是写个计数器吗”,真动手才发现,麻烦事一堆:数码管乱跳、进位对不上、仿真的波形看起来对但上板就是不对,甚至有人卡在工程配置上半天出不来结果。

这篇博文我分几大块来聊:先说清楚数字钟的整体架构和模块划分逻辑;然后把分频、计时、显示、按键这几个核心模块的Verilog实现逐个拆开讲;接着给出完整的testbench仿真方案和一个可以直接落地的工程代码结构;最后整理我在调试过程中踩过的一堆坑,包括新板子、旧板子的兼容问题怎么处理。无论你是第一次写课设的本科生,还是想快速捡起Verilog的工程师,这篇文章都能让你少走不少弯路。

整个项目的核心思路是这样的:外部晶振提供50MHz时钟,经过分频模块产生1Hz的秒脉冲,秒脉冲驱动秒计数器;秒计数器计满60后向分计数器进位,分计数器计满60后向时计数器进位,时计数器按24小时制循环;计数值通过BCD码或二进制码输出到显示模块,显示模块用动态扫描的方式驱动4位或6位数码管;另外加入按键模块实现校时、校分、秒表启动/暂停等功能。听起来不复杂,但每一步都有细节。

2. 整体架构设计:模块化拆分比想象中更重要

2.1 为什么要拆模块,而不是一个always块写到底

我见过不少同学的数字钟代码,整篇就一个大always块,时钟、计数、显示、按键全部堆在里面。能跑通吗?能,仿真也能出结果,但稍微改一个功能就头疼,而且代码复用性极差。课程设计如果只是应付验收,这么写勉强能过;但如果想着后面做课设升级、加功能、甚至毕设复用,这种写法就是给自己埋雷。

模块化拆分的好处有两个:第一,每个模块可以单独仿真验证,出问题能快速定位是哪个环节的锅;第二,模块之间通过信号接口通信,改其中一个模块不影响其他模块,符合数字IC设计中“高内聚、低耦合”的基本思想。实际上,华为、中兴这些公司面试时问到数字钟,考官关注点从来不是你能不能写出来,而是你如何组织你的代码结构。

2.2 标准模块划分方案

我建议按下面这五个模块来组织工程:

模块名功能关键端口
top_module顶层例化,连接所有子模块时钟、复位、按键输入、数码管位选/段选
clk_div分频模块,50MHz转1Hz(以及扫描时钟等)clk_50M, rst_n, clk_1s
counter60秒/分计数器,模60计数clk, rst_n, en, q[7:0], co
counter24时计数器,模24计数clk, rst_n, en, q[5:0], co
key_debounce按键消抖,输出单周期脉冲clk, key_in, key_pulse
seg_display动态扫描显示,BCD码转七段码clk_scan, data, seg, sel

这里每个模块职责单一、接口清晰。顶层只做例化和连线,不写任何逻辑。这样设计的好处是,哪怕某一天你想把显示从数码管换成LCD,只需要改写seg_display模块,其他模块完全不用动。

2.3 分频参数的计算:为什么是50_000_000而不是49_999_999

时钟分频是整个系统最基础的一环,也是很多同学第一次写就写错的地方。假设外部晶振是50MHz,要产生1Hz的时钟(周期1秒),分频系数是50_000_000。计数器需要从0数到49_999_999(一共50_000_000个时钟周期),数满后清零并翻转输出或产生一个脉冲。

这里最容易搞错的就是边界值。如果你写的是“计数到50_000_000时清零”,那实际分频系数就是50_000_001,输出的频率会比1Hz略小一点点。虽然仿真看不出来,跑上一天就会慢大约1.7秒。你想想,如果课设验收时老师拿个秒表对着你的时钟看,跑了半小时差了1秒多,就问你尴尬不尴尬。

提示:在设计分频模块时,最好把分频系数定义成parameter,不要直接写死在代码里。不同的板子晶振频率可能不同,有的25MHz、有的50MHz、有的100MHz,用parameter可以做到一键适配。这也是面试时常见的加分点。

3. 核心模块实现:从分频到显示的每一个细节

3.1 分频模块:奇数分频和偶数分频的差别

对于50MHz转1Hz,5000万是偶数,直接用一个计数器翻转输出就行了,代码很简单:

module clk_div #(parameter DIV_N = 50_000_000) ( input wire clk, input wire rst_n, output reg clk_out ); reg [31:0] cnt; always @(posedge clk or negedge rst_n) begin if(!rst_n) begin cnt <= 32'd0; clk_out <= 1'b0; end else if(cnt == (DIV_N/2 - 1)) begin cnt <= 32'd0; clk_out <= ~clk_out; end else begin cnt <= cnt + 1'b1; end end endmodule

但如果你后面遇到需要奇数分频的场景(比如把50MHz分频成10MHz之外的某个非整数倍频率),翻转法就不灵了。典型做法是“上升沿计数+下降沿计数”双沿产生信号后再组合,或者用状态机实现占空比接近50%的奇数分频。数字钟本身用不上奇数分频,但在应聘笔试题里这是高频考点,我这边顺便提一句,别到时候被考懵了。

另外要留意,用这种计数翻转方式产生的1Hz信号,它的上升沿和系统时钟不同步吗?并不是,它其实还是同步的,因为翻转动作发生在时钟上升沿。只是它的占空比恰好是50%,边沿质量没法和PLL产生的时钟相比。对于驱动计数器来说完全够用,但如果要用1Hz信号去带动时序逻辑,建议在内部再打一拍或者直接用脉冲而不是电平来驱动下一级。涉及到跨时钟域处理的场景,务必小心。

3.2 计时模块:模60计数器的两种写法

秒计数器和分计数器都是模60计数器,这里有两种主流写法。第一是当计数到59后下一个时钟沿回到0,同时产生进位;第二是当计数到59且en有效时,下一个沿回到0。两种写法在行为上差一拍,但都能用。

我们来看一个带使能端的模60计数器,这个设计也是我实际工程中一直在用的模板:

module counter60 ( input wire clk, input wire rst_n, input wire en, output reg [6:0] q, output reg co ); always @(posedge clk or negedge rst_n) begin if(!rst_n) begin q <= 7'd0; co <= 1'b0; end else if(en) begin if(q == 7'd59) begin q <= 7'd0; co <= 1'b1; end else begin q <= q + 1'b1; co <= 1'b0; end end else begin co <= 1'b0; end end endmodule

需要注意q的位宽。60最大需要6位二进制(因为2^6=64),所以q定义成7位属于冗余设计,写成[6:0]也没问题。但如果你习惯用BCD码输出,即十位和个位各用4位二进制表示,那么需要8位输出,高4位是十位,低4位是个位。两种风格各有优劣:纯二进制计数代码简洁,内部信号位宽省;BCD码输出可以让显示模块直接译码,不用再做二进制转BCD的运算。

如果课设要求显示“分:秒”这种常见格式,而且要求值是十进制的,那么推荐直接用BCD码风格:秒的十位取值范围0-5,秒的个位0-9;甚至可以对十位和个位分别写两个模6和模10计数器,再拼接在一起。这种写法稍微繁琐,但逻辑直观,出错概率低。

3.3 计时模块:24小时制的边界处理

小时计数器是模24,比模60还简单,但有一个地方容易写岔:从23点跳到0点。这个处理其实和模60类似,只是把判断值从59换成23。

很多人问我,秒、分、时三个计数器之间要不要用进位信号串联?答案是肯定要。秒计数器产生进位co_sec,这个信号作为分计数器的使能en_min;分计数器产生进位co_min,作为时计数器的使能en_hour。如果你不用使能的方式,而是直接把秒计数器的co_sec接到分计数器的时钟端,这种做法在原理上也能工作,但它产生了计数器之间的级联时钟,违背了同步设计的基本原则,会导致时序分析和上板稳定性方面的隐患。课程设计可以不管,但这个概念最好早点建立。

我们再看进位链:秒计数器每到59就产生一个高电平的co_sec脉冲,持续一个时钟周期;分计数器在en_min有效时加1,然后继续等下一个co_sec。这样串下去,整条链是纯同步的,时序分析时只需要关心50MHz系统时钟,不需要额外约束1Hz、1/60Hz这些派生时钟,数据路径上的逻辑门(比较器、加法器)也都有一个完整时钟周期去收敛,不容易出现亚稳态问题。

有了这个基础,后续你想加闹钟功能,直接从计数器输出当前时间,再和预设值比较即可,同样只需要一个组合逻辑比较器,架构上不用大改。

4. 数码管显示与按键消抖:两个最容易翻车的模块

4.1 动态扫描显示的原理和刷新率计算

数字钟显示部分,典型的方案是用4位一体数码管(或者6位),通过动态扫描方式驱动。所谓动态扫描,就是在极短的时间内轮流点亮每一位数码管,利用人眼视觉暂留效应,让观察者感觉所有位都在同时亮。这样可以用一套段码线+多套位选线的方式,大幅减少FPGA引脚占用。

扫描频率怎么定?这有个经验值:每位数码管刷新率不要低于50Hz,否则会看到明显闪烁。4位扫描,每位占1/4的时间,所以整体扫描周期要小于1/(50*4)=5ms。取一个典型值:用1kHz的扫描时钟,每位亮1ms,4位一轮是4ms,相当于每位刷新250Hz,肉眼完全看不出闪烁。

扫描时钟怎么来?同样用分频器,从50MHz分频出1kHz或者1.5kHz都行。你不需要单独再写一个分频模块——在clk_div里同时输出两路时钟即可,一路1Hz、一路1kHz,或者更推荐输出一个更高频的使能脉冲信号来做分频。注意,多路分频时钟在FPGA里并不建议用来驱动不同的always块,因为会造成时钟偏斜。更好的做法是“单一高频时钟+使能信号”,例如50MHz下用计数器产生一个高电平持续1个周期的en_1k,所有always块统一由50MHz驱动,en_1k为高时逻辑才动作。这个属于编码风格层面的进阶建议,但对你之后做更复杂的项目非常有帮助。

扫描显示时容易踩的坑是“拖影”和“鬼影”。拖影的原因通常是位选切换和段码更新不同步:你先换了位选,但段码还是上一位的值,当前位就会闪一下上一位的字形。解决办法是先关掉所有位选(全置无效电平),再改段码,最后打开目标位选。这个顺序在代码里就三行,但能避免显示上很多脏数据。

4.2 按键消抖:为什么不能直接读按键电平

机械按键在按下和松开的过程中,由于簧片弹跳,会产生大约5~20ms不等的抖动。如果直接用按键的电平作为计数器的时钟或者使能信号,会导致一次按键被识别成多次,甚至一次跳好几秒。所以必须做消抖处理。

最经典的消抖方式有两种。一种是延时采样:检测到按键电平变化后,等20ms再次采样,如果电平仍然稳定,就认为按键有效。另一种是计数消抖:用计数器持续扫描按键电平,只有电平连续稳定N个时钟周期才判定有效。后者更适合FPGA的并行思维。

我给一个常用的按键消抖模块代码,它的特点是输出单周期脉冲,而不是持续的电平。这样后续逻辑只需要检测一次脉冲触发,不会因为按键按着不放导致反复触发:

module key_debounce #(parameter DELAY_CNT = 1_000_000) ( input wire clk, input wire rst_n, input wire key_in, output reg key_pulse ); reg [19:0] cnt; reg key_reg1, key_reg2; wire key_negedge; // 两级寄存器同步,消除亚稳态 always @(posedge clk or negedge rst_n) begin if(!rst_n) begin key_reg1 <= 1'b1; key_reg2 <= 1'b1; end else begin key_reg1 <= key_in; key_reg2 <= key_reg1; end end assign key_negedge = key_reg2 & ~key_reg1; // 检测按键按下瞬间 always @(posedge clk or negedge rst_n) begin if(!rst_n) begin cnt <= 20'd0; key_pulse <= 1'b0; end else if(key_negedge) begin cnt <= 20'd0; key_pulse <= 1'b0; end else if(cnt == DELAY_CNT) begin cnt <= 20'd0; key_pulse <= key_in; // 如果20ms后按键仍为低,输出一个脉冲 end else begin cnt <= 20'd0; // 注意:这里只是示意,实际需保持计数 key_pulse <= 1'b0; end end endmodule

上面代码里我留了个注释,实际写的时候要注意cnt必须持续计数而不是被清零。这里我只展示结构和思路,代码细节你自己补全。需要说明的关键点有两个:第一,按键输入跨时钟域后先打两拍,这是消除亚稳态的标准做法;第二,输出脉冲持续一个时钟周期,而不是跟随按键电平,这样后面接计时器时,每按一次只触发一次操作。

如果你用的是开发板,按键按下时通常接地(低电平有效),所以检测下降沿还是上升沿要根据你的板子原理图来定。我这里以低电平有效按键为例。

4.3 校时功能的设计思路

加了按键模块之后,校时逻辑就水到渠成了。常见的交互是:按键1切换“运行/校时/校分/校秒”模式;在对应模式下,按键2每按一次,对应计数器加1。这里要注意,进入校准模式后要暂停正常计数,否则会出现“你按一下加1秒,但正常计数也在走”的冲突。

暂停的逻辑也不复杂——顶层给秒计数器一个en_sec信号,正常计时模式下en_sec等于分频模块输出的秒脉冲;进入校准模式下,en_sec被拉低,秒计数器不走,此时按键脉冲直接作为加数信号去修改计数值。这种“使能控制”的设计思路,在后续做秒表、闹钟、倒计时等功能时都会反复用到。

5. 仿真验证与调试实录:让代码“看得见、查得清”

5.1 Testbench怎么写才能有效验证

写testbench不是随便给个时钟然后跑完就完事,你需要有针对性地构造激励,把关键功能点都覆盖到。数字钟的testbench至少要包含下面几项:复位信号;时钟信号;模拟按键操作(用#延迟和强制赋值或调用任务的方式产生按键翻转);以及最重要的,验证进位逻辑——比如让秒计数器从58跑到2,看分钟是否正常进位;让时计数器从23跑回0,看日期是否翻转。

Testbench的代码骨架大概是这样的:

`timescale 1ns/1ps module tb_digital_clock; reg clk_50m; reg rst_n; reg key_set; reg key_adj; wire [7:0] seg_data; wire [3:0] seg_sel; initial begin clk_50m = 0; forever #10 clk_50m = ~clk_50m; // 50MHz,周期20ns end initial begin rst_n = 0; #100; rst_n = 1; // 模拟按下按键 #20_000_000; // 20ms key_set = 0; #20_000; key_set = 1; // 继续仿真... end digital_clock_top u_top( .clk_50m (clk_50m), .rst_n (rst_n), .key_set (key_set), .key_adj (key_adj), .seg_data(seg_data), .seg_sel (seg_sel) ); endmodule

这里有个技巧:如果你不想费时间等真实的50M个时钟周期走完1秒,你可以在testbench中给分频模块的parameter赋小一点的值,比如让分频系数变成500而不是50_000_000。这一招在调试时极其有用——仿真时间直接从几十秒级别缩到几毫秒级别。具体做法是引用模块时用#(parameter值)重写参数。

5.2 Modelsim仿真时最常踩的坑

Modelsim本身功能没问题,但很多同学在仿真时遇到“波形一片红”或者“结果不对”的情况,多数不是RTL代码问题,而是仿真环境设置问题。以下是我总结的高频坑点:

第一,时间尺度设置不当。如果testbench里没有timescale,modelsim会默认使用全工程编译顺序里最先出现的timescale,容易导致延迟时间误差。建议每个模块文件都写timescale 1ns/1ps

第二,没有给仿真加初始值。如果初始时rst_n一直为高(没有被拉低再拉高),很多寄存器的初始值是x,仿真波形自然是一堆红线。规范做法是在initial块中先拉低复位至少100ns,再拉高,让所有寄存器完成复位动作。

第三,Modelsim里仿真div_clk模块时,如果cnt位宽不够导致计数溢出,可能会得到意料之外的频率。遇到这种情况,优先检查计数器的位宽:50MHz计数到5000万需要26位二进制数(因为2^26=67,108,864),所以[31:0]是安全的。别把位宽定义得太小,这是很隐蔽的bug。

第四,当仿真结果看起来“完全正确”但上板却不工作,大概率是复位方式或时钟约束问题。课程设计水平下,板级调试前建议先跑一遍全编译并看Timing Report,确保时序无违规。

5.3 实测过程中排查问题的方法论

上板调试FPGA和仿真完全不同,仿真里一个bug改完重新跑也就几秒钟,上板改一次要经历综合、实现、生成bitstream、下载好几个步骤。所以我强烈建议你保持一套固定的排查顺序:先确认时钟有没有正常起振(分频输出用示波器或者板载LED观察)、再确认复位后的初始状态、接着逐个模块确认信号——比如观察秒计数器的计数值是否在跳变、进位信号是否产生、数码管是否正常点亮。

如果是数码管乱码,优先怀疑段码表编码位置不对或者位选和段码时序不匹配;如果是按键没反应,先确认按键的消抖模块有没有输出脉冲(可以通过拉一个LED来观察);如果只是计数值跳变得很快,几乎可以断定是分频系数没有生效,或者时钟频率与你的分频参数对不上。

6. 常见问题排查速查表

我把自己带过的课设里学生最容易问的问题,整理成一个速查表,供你调试时对照:

现象可能原因排查方法
数码管全部不亮位选信号极性反了或扫描时钟没有产生检查段选/位选有效电平,观察扫描时钟是否翻转
数码管显示乱码段码表编码不对或数据位宽不匹配单独给显示模块输入固定数值验证
数字跳得飞快分频系数N计算错误或时钟频率不匹配确认晶振频率,换算分频系数,用仿真确认1Hz周期
计数器某一位始终为0复位后没有正常启动或者进位信号悬空检查使能端连接,用chipscope或仿真观察内部信号
按键一下跳好几秒消抖时间太短或没有消抖直接读电平增大消抖延时到20ms左右,确认输出的脉冲宽度
分到秒进位错乱进位信号保持时间不足或者valid时机不对检查co信号是否只持续一个时钟周期,避免电平直接驱动下一级
仿真正常上板异常可能是代码中有异步逻辑或者复位释放违例检查always块中的敏感列表,尽量统一使用异步复位或同步复位

这里要特别提醒第二行“数码管显示乱码”这个问题。很多开发板的数码管是共阳极,段码要为0才点亮对应的段,如果你按照共阴极去编码,那结果就是亮灭完全颠倒。接线和极性问题在课程设计里出现频率极高,入手前一定先看板子原理图。

7. 踩坑复盘:几个我没写在报告里的教训

最后聊几个我做这个项目时印象特别深的坑,不写进课设报告里,但对你实际做项目非常有用。

第一个是复位策略。数字钟这类教学项目,大家习惯用异步复位,也就是always @(posedge clk or negedge rst_n)。这种写法本身没有错,但如果你在顶层把rst_n接的按键和普通key_in混着用,就可能出现复位与释放不同步的时序问题。建议把复位信号单独接板子上的专用复位按键,不要跟功能按键复用一个引脚,否则你按功能按键时容易误触全系统复位。

第二个坑是扫描时钟和按键消抖时钟的分配。早期版本我图省事,显示模块用clk_1k,按键模块也用clk_1k,结果出现了按键脉冲和显示扫描正好撞在同一时刻,偶尔出现计数加1但显示闪一下的怪现象。后来我改成整个系统只有一个50MHz系统时钟,所有模块靠不同的使能信号区分节奏,问题就消失了。这也是前面我反复强调“通用时钟+使能信号”的原因。

第三个坑是关于扩展功能。很多人觉得数字钟做完就完事了,其实这是一个很好的基础框架——秒表、倒计时、闹钟、日期显示都是在这个架构上扩展的。你可以试着加一个“60秒倒计时”模式,把计数器从模60加法改成模60减法,再把使能信号从秒脉冲换成按键脉冲,难度低但扩展性强,拿去参加院级比赛或者写进简历都是不错的素材。

这个项目做到最后你会发现,真正的收获不是会写一个数字钟,而是理解了“一个稍微复杂的数字系统是怎么被拆解成一堆小模块,又怎么被拼装起来的”这件事。后面无论你做通信接口、图像处理还是嵌入式软核,底层思维都是相通的。

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

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

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

立即咨询