简介:面向FPGA初学者的Verilog数字钟完整工程,基于Quartus II 13.0开发环境,使用硬件描述语言实现计时、跑表、闹钟与时间调整四种核心功能,覆盖秒分时计数、七段数码管显示驱动、按键输入处理及闹钟比较触发等模块,适合学习时序逻辑、分频电路、状态机与模块化设计的开发者。资源共145个文件,压缩包约7.82MB,主要包含Quartus工程配置文件(qpf/qsf)、Verilog源码、仿真波形(vwf)、编译报告(rpt)、配置文件(sof)等,类型覆盖从RTL代码到综合适配、下载验证的完整流程。目前已有416人学习/下载。该项目代码注释详细,模块边界清晰,读者既可以按“计时—显示—按键—闹钟—跑表”的顺序逐段阅读源码,也可以借助仿真波形理解信号时序关系;编译报告和配置文件还能帮助了解工程综合与适配结果,方便在主流FPGA开发板上直接复现,并进一步扩展为带整点报时、日期显示等功能的实用数字钟。 提到“FPGA + Verilog 数字钟”,很多人第一反应是“这有什么好写的,课设而已”。但说句实在话,我在带新人入门FPGA的时候,最推荐练手的板级项目就是数字钟——不是因为它简单,而是因为它麻雀虽小、五脏俱全:计数器、分频、BCD码、扫描显示、按键消抖、跨时钟域基础,甚至RTL风格和状态机的感觉全都覆盖到了。这个项目做完,你对“时序逻辑到底在干什么”“阻塞赋值为什么不能用在这里”这类问题的理解,会比啃十篇教程都有用。
这篇文章我就围绕用Verilog在FPGA上实现数字钟这件事,把项目从需求拆解、模块划分、代码实现到仿真上板踩坑完整过一遍。适合刚学完Verilog语法但不知道做什么的新手,也适合准备参加课设、找实习想做FPGA项目的在校生。我会把每一段代码背后的设计意图和参数计算过程讲清楚,尽量让你能照着搭出自己的版本。
1. 项目需求拆解与整体设计思路
1.1 数字钟这个项目到底考你什么
数字钟的功能描述很简单:显示时、分、秒,秒走60下分走1格,分走60下时走1格,时走24小时回到0,整体再来一天。但落到FPGA实现上,它背后牵出的是一套完整的数字逻辑设计思路。
首先是“时间从哪来”。FPGA开发板上通常有一颗外部有源晶振,常见频率有50MHz、100MHz、27MHz等。数字钟需要的是“秒”这个单位,所以第一步要做的就是把高频时钟分频到1Hz。这个步骤不是简单接一根线就能解决的,它需要典型的计数器分频逻辑,而计数器恰恰是Verilog里使用频率最高的结构之一,后面做频率计、PWM、UART波特率发生器都要用同一套思维。
其次是“秒分时各怎么存”。秒的取值是0到59,分的取值是0到59,时的取值是0到23。如果直接用二进制数存储,到显示环节还得再做一次二进制到十进制的转换。但如果直接用BCD码,也就是把十位和个位分开存,比如59存成5和9两个数字,那么做显示驱动的时候会省掉大量译码逻辑。很多初学Verilog的人在这里容易踩坑,没有从后续模块的角度去统一数据格式,结果写出来的模块之间接口比想的复杂得多。
再次是“怎么让人看得到”。需要驱动数码管或者LCD显示,这里涉及到动态扫描、段码译码、位选控制。以6位8段数码管为例,如果6位全部用独立引脚去驱动,管脚占用会非常夸张,所以工程上几乎都用动态扫描方式,逐毫秒点亮一位,利用人眼视觉暂留让所有位看起来同时点亮。
整体来看,数字钟的架构可以切成三层:时基产生层、进位计数层、显示控制层。每一层都有清晰的接口和边界,这正是FPGA工程和写单片机裸机程序最大的不同点——更强调模块化和并行思维。
1.2 选型思考:开发板、芯片与工具链
做数字钟不需要高端的FPGA芯片,市面上入门级的开发板完全够用。以Xilinx Artix-7系列或Intel Cyclone IV为例,逻辑单元数量从几千到几万不等,而数字钟项目可能只用到几百个触发器和LUT,资源占用连零头都算不上。换句话讲,这是个典型的“单片机式小项目”,但跑在FPGA上能帮助你理解底层并行执行的逻辑。
我在实际推荐时通常更看重开发板的板载资源:板上有没有6位数码管、有没有独立按键、有没有有源晶振,这些直接决定了你能少画多少杜邦线。我见过有的同学用核心板加外部面包板搭电路做这个项目,结果因为接触不良反复排查了很久,反而失去了练习Verilog本身的意义。
工具链方面,用Xilinx就是Vivado,用Intel就是Quartus。如果只是做纯逻辑设计,选哪家都行,语法用Five百年不会变的Verilog-2001子集。需要多说的是,写RTL代码的时候习惯一定要养成好——模块名、信号命名、寄存器变量后缀这些规则尽量统一,等到后面项目迭代、写testbench甚至和别人协作的时候,命名混乱带来的成本会被无限放大。
2. 核心模块拆解与Verilog实现
2.1 关键第一步:从50MHz到1Hz的时钟分频
时钟分频是整个数字钟的地基。如果地基歪了,后面所有模块都对着错误的时间乱跑。我们以最常见的50MHz晶振为例,目标是产生一个周期恰好为1秒的脉冲信号。
50MHz的意思是1秒内震荡5000万次,所以每计数到5000万次,就等于度过了1秒。用Verilog实现时,需要一个计数寄存器counter,从0计数到49999999,计满就输出一个高脉冲,同时计数器归零重新开始。这里很容易犯的一个错误是分频计数终值算错,少一个周期或多一个周期都会导致走时误差,累积下来一天能差接近一秒甚至更多。
module clk_div_1s ( input wire clk_50m, input wire rst_n, output reg clk_1s ); reg [25:0] cnt; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; clk_1s <= 1'b0; end else if (cnt == 26'd49_999_999) begin cnt <= 26'd0; clk_1s <= 1'b1; end else begin cnt <= cnt + 1'b1; clk_1s <= 1'b0; end end endmodule注意这里的计数终值是5000万减1,因为计数器从0开始计。如果晶振频率是27MHz或100MHz,只需要把常量改成对应的N-1即可,逻辑结构完全一致。判断计数终值时,我建议直接把时钟周期和目标频率的关系写进注释里,避免日后维护时重新推导。
实际工程中,这里还有另外一个思路:用PLL把输入时钟倍频或整分频成1Hz。但PLL在入门项目里没必要,因为PLL主要解决高速时钟域的需求,而计数器分频的做法规整、可控且完全符合数字钟的需求。PLL除了增加复位和锁定处理的复杂度,没有任何额外收益。
2.2 秒分时计数器:为什么建议用BCD码而不是二进制
有了1Hz的秒脉冲,下一步就是做秒、分、时的累加进位逻辑。我用BCD码来设计,也就是说秒个位sec_unit的范围是0到9,秒十位sec_ten的范围是0到5,两者合起来就是0到59。同理,分个位和十位也是0到9和0到5,时个位0到9,时十位0到2——但是时的上限比较特殊,23之后要回到0,所以进位判断不能简单按固定模值计数。
整个计数的核心就一句:秒个位每个秒脉冲加1,加到10就清零并让秒十位加1;秒十位加到6就清零并让分个位加1;分十位加到6就清零并让时个位加1;时个位加到10就清零并让时十位加1;当时十位等于2且时个位等于3时,下一拍全部清零。这套逻辑从秒到分到小时一环套一环,写成代码就是一组带有使能条件的计数器。
always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin sec_unit <= 4'd0; sec_ten <= 4'd0; end else if (clk_1s) begin if (sec_unit == 4'd9) begin sec_unit <= 4'd0; if (sec_ten == 4'd5) sec_ten <= 4'd0; else sec_ten <= sec_ten + 1'b1; end else begin sec_unit <= sec_unit + 1'b1; end end end秒模块如此,分模块就是把clk_1s换成秒模块的进位条件。时模块则稍有不同,24进制判断要在十位等于2并且个位等于3时清零。写这段代码的时候,我建议把每位计数器的取值范围写成一个localparam常量,比如SEC_TEN_MAX = 4'd5,这样代码的可读性会提高一截。
这里多解释一句BCD码的好处。如果只用二进制存60,比如8比特计数器从0走到59,显示模块要把59这个值拆成5和9两个数码管,需要做除法和取余运算,综合成硬件后逻辑会明显变多。而BCD码从存储结构上就把十位和个位分开了,显示驱动的译码逻辑可以做得非常浅,这正是“数据格式的统一性影响后续模块复杂度”的典型例证。
2.3 数码管动态扫描显示:分时复用避免引脚爆炸
显示部分用6位数码管,位选和段码分开处理。动态扫描的思路是:任意时刻只点亮一位数码管,让6位以极高频率轮流显示,利用视觉暂留让6位看起来同时在亮。扫描频率选择很关键,一般每一位的刷新频率不能低于50Hz,否则肉眼能明显看到闪烁。以6位为例,整体扫描周期如果控制在1ms到4ms之间,也就是每一位显示大约166微秒到666微秒,表现非常稳定。
实现上需要一个毫秒级的计时信号,可以从1Hz时间基准模块单独派生一个分频模块,也可以独立写一个计数器分频产生scan_clk。我这里用1kHz作为扫描时钟,也就是每个数码管每1ms被点一次,6位共6ms一轮循环,换算成每一位的刷新率约166Hz,远超肉眼阈值,实测看不到闪烁。
显示数据的选择用case语句完成,根据当前扫描到第几位,去选择对应的BCD码,再通过段码译码模块输出到七段数码管的段选引脚。下面是一个数据选择的核心片段:
always @(*) begin case (scan_cnt) 3'd0: current_bcd = sec_unit; 3'd1: current_bcd = sec_ten; 3'd2: current_bcd = min_unit; 3'd3: current_bcd = min_ten; 3'd4: current_bcd = hour_unit; 3'd5: current_bcd = hour_ten; default: current_bcd = 4'd0; endcase end位选信号则由scan_cnt直接译码产生,比如pos_sel信号对应数码管位号。如果开发板的数码管是共阳极,位选通常低电平有效;段码也一样,得根据硬件原理图确认是高有效还是低有效。这些极性处理错了,最典型的现象就是数码管乱码或者亮度不均匀,排错时优先查这个。
实际做显示模块还要注意一个细节:在切换扫描位的时候,有些数码管会因为段码数据还没稳定就切换位选,产生短暂的“拖影”或“鬼影”。优化方式很简单,在改变位选之前先把段码输出置空,或者在段码和位选之间插入一拍寄存器做打拍处理,让信号变化错开。这个技巧我从做温度显示项目时就一直在用,效果非常靠谱。
3. 完整工程的仿真验证与上板实测
3.1 为什么先仿真再上板:不写testbench一定后悔
很多新手觉得仿真麻烦,写完代码就急着上板,结果出现问题后在板上测信号非常痛苦——FPGA的信号在芯片内部,又不能用示波器直接测。正确做法是先写testbench做充足仿真,用波形图把每个模块的行为验证到位,再烧到板子上。
testbench至少要做三件事:生成时钟和复位信号,给分频器模块喂时钟并观察输出脉冲周期是否符合预期;给秒计数器模块喂秒脉冲做“秒→分→时”的进位仿真;给显示模块喂不同的BCD码组合,验证段码和位选波形是否正确。
这里要特别提一个仿真加速技巧:不要直接等50MHz时钟跑5000万拍出第一个秒脉冲,在testbench中你可以把分频模块的计数终值通过parameter覆盖掉,比如把CNT_MAX = 26'd49_999_999替换成26'd49,这样仿真中每50拍就能产生一个“秒脉冲”,快速验证后续计数进位,显示模块同理。等到分频器模块单独验证时,再用完整参数跑一次看脉冲宽度即可。
module tb_top; reg clk_50m; reg rst_n; wire clk_1s; clk_div_1s #(.CNT_MAX(26'd49)) u_clk_div ( .clk_50m (clk_50m), .rst_n (rst_n), .clk_1s (clk_1s) ); initial begin clk_50m = 1'b0; forever #10 clk_50m = ~clk_50m; end // ... endmodule为了让代码支持这种参数覆盖,最佳实践是把分频系数作为module的parameter而不是直接写死成常量。这样,仿真时用小的分频系数,上板时用实际值,这份代码既能仿真又能直接综合,非常灵活。
3.2 仿真波形里到底要看什么
仿真跑完之后,不要以为看到波形在动就行,必须有针对性地检查几个关键点。
第一个是分频输出clk_1s的频率和脉冲宽度是否符合设计。启动仿真后,从波形里任意截取两个相邻的clk_1s上升沿,计算周期是否等于分频系数乘以20ns(50MHz的周期)。如果周期偏了,说明计数终值或计数器初值的写法有问题,比如多写了一拍导致计到50000000才清零。
第二个是进位链的正确性。检查秒个位从9跳到0时秒十位是否加1,秒十位从5跳到0时分个位是否加1。这种跨模块的传导关系是数字钟最容易出错的地方,因为所有模块并行工作,进位信号必须持续一个周期以上才能被下一级可靠捕获。
第三个是BCD码的范围限制。专门把时间设置到23点59分59秒,让下一拍归0后观察各计数器输出是否同时清零。如果出现时十位变成3的情况,说明24进制的判断条件写错了。
我在新手代码评审中见到最多的问题是:秒模块的进位条件只用了clk_1s信号,却忽略了一秒脉冲的宽度问题。如果分频模块输出的clk_1s高电平持续多个时钟周期,那么秒计数器会在这多个周期内连续加了好几次。正确做法是只要检测到clk_1s的上升沿,保证每个秒脉冲只让计数器加一次;或者让分频模块只输出一个单周期的高脉冲,后续所有模块都通过判断这个高脉冲来推进。单周期脉冲的做法更简洁,推荐优先采用。
3.3 上板前的集成测试和引脚分配
当各模块仿真通过后,进入顶层集成阶段。顶层模块要做三件事:例化分频模块、例化秒分时计数模块、例化数码管显示模块,并把它们之间的信号对接起来。同时要把复位按钮、时钟引脚、数码管段选和位选信号引到顶层端口。
引脚分配和综合设置这步看似机械,其实最容易掉链子。我在Vivado里分配引脚时习惯先对照原理图查三遍:时钟引脚是否和BANK支持的电压标准匹配,复位按键是否接了上拉电阻,数码管位选信号是否有三极管驱动。有些开发板的数码管位选引脚是低有效,有些是高有效,千万别只凭经验。如果引脚分配错误,非常容易引起“上板没反应”或“某个数码管不受控”。
综合实现后查看时序报告,数字钟的逻辑路径很短,在50MHz下时序收敛一般没问题。如果你看到时序违例的报告,大概率是某个时钟域没用统一时钟,比如在一个always块里用clk_1s当敏感列表的时钟而不是把它当成脉冲使能。这种写法虽然仿真能过,但综合后容易产生多个时钟域,造成不可思议的上板问题。记住一条铁律:尽量所有时序逻辑都使用同一个系统时钟,低频信号一律做成“时钟使能”而非“同步时钟”。
4. 常见问题与避坑经验速查
4.1 我踩过的几个典型坑
第一个坑是“秒脉冲宽度导致计数一次跳好几秒”。如前所述,分频模块如果输出的clk_1s是一个持续高电平的周期信号,那么秒计数器在每个时钟上升沿都会判断一次“clk_1s是否为1”,从而在一个秒周期里不断进位。这个问题的隐蔽性很强,因为单看分频模块波形好像没什么不对,但连上计数器后瞬间暴露。解决方式是把分频模块设计成输出一个单时钟周期的脉冲,也就是“pulse”而非“clock”。
第二个坑是数码管扫描频率太低时看到的闪烁。有的同学把扫描时钟直接拿分频秒脉冲来驱动,结果一秒才刷新一次,数码管闪得没法看。扫描频率要按显示位数和视觉暂留来推算,6位数码管推荐扫描时钟至少500Hz以上。如果你看到某一位明显偏亮或者偏暗,通常是扫描停在一个位上的时间不均匀,检查case条件和位选译码逻辑。
第三个坑是按键消抖不彻底。如果数字钟带“调时”“调分”按键,按键按下的一瞬间和松开的瞬间都会产生机械抖动,如果不做消抖处理,一次按键可能被识别成两三次,时间设置会乱跳。消抖最简单可靠的办法是检测到按键电平变化后启动一个10ms到20ms的计时,让采样点落在抖动结束后再判断最终电平。
4.2 问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 数码管完全不亮 | 位选极性相反、段码极性相反、复位一直在拉低 | 对照原理图检查位选/段码的有效电平,检查复位按键按下时是高还是低 |
| 秒走一位时多跳了几秒 | 分频输出是电平信号而非脉冲,计数器在每个时钟周期都判断到有效 | 改成分频模块输出单周期脉冲,或者计数器检测上升沿而非电平 |
| 设置键按一次触发多次 | 按键抖动未被消除 | 增加10ms~20ms消抖逻辑,用采样后的稳定电平作为触发 |
| 到59后无法进位到0 | 计数终值判断写错,比如把==4'd9写成了>=4'd9且else分支冲突 | 检查每个计数器的边界值,秒十位5、分十位5、时特殊判断 |
| 某一位数码管亮度异常 | 扫描时间不均匀或位选信号存在毛刺 | 优化扫描时序,切换前送空段码,或位选信号打一拍 |
| 编译报错多驱动 | 同一个信号在多个always块中赋值,或寄存器信号既做输入又做输出 | 检查RTL代码,确认每个寄存器只有唯一的驱动来源 |
4.3 工程习惯和代码风格建议
做数字钟这种项目,你会花很多时间在仿真、修改、再仿真上面,代码风格直接影响效率。我强烈建议从始至终保持统一规范:信号命名要语义化,clk_50m、rst_n、sec_unit这类一眼就能看出含义;时序逻辑统一用always @(posedge clk_50m or negedge rst_n)的模板;寄存器变量用reg,连线用wire,不能混用。
写注释的时候,不要写“这句代码是什么意思”,而要写“为什么这样设计”。比如分频系数49_999_999旁边标注“50MHz时钟计满5000万次得1秒”,这样三个月后再看到代码还能快速恢复上下文。好的工程习惯和写一手能看的RTL代码,比多做一遍功能重要得多。
这个项目做完之后,我建议你尝试几个扩展方向:加一个校时按键组,实现通过按键加1调整小时和分钟;把时间通过UART串口发送到上位机显示;用模式切换把秒表、倒计时功能加进去。这些扩展方向都能进一步锻炼状态机和数据通路设计能力,做完之后你会发现自己对Verilog和FPGA的理解已经上了一个新台阶。
本文还有配套的精品资源,点击获取