简介:面向计算机组成原理课程的FPGA大作业,这套基于Quartus II设计的简易时钟工程包,完整覆盖从HDL编写到下载验证的实践链路。资源以小时、分钟、秒钟三级显示为框架,采用模24与模60同步计数器实现进位逻辑,并附带编译、仿真、适配等过程产生的工程文件,适合数字逻辑与计算机硬件方向学生对照学习。包内共61个文件,压缩包约194KB,主要包含bdf顶层原理图、qsf/qpf工程配置、vwf仿真波形、rpt与summary报告、qmsg编译信息及hdb/cdb中间数据库文件,便于复现整个设计流程并查看时序与适配结果。目前已有1584人学习,说明该案例在教学场景中具有较好的参考价值。通过打开工程,学习者既能查看计数器模块的HDL实现,也能从仿真波形、适配报告中理解分频、进位与显示译码的配合关系,还能掌握Quartus II的项目组织方式与FPGA设计的基本排错思路,是一份贴近课程要求、可直接运行和改写的完整样例。 计算机组成原理的大作业,十个组里有八个组在做时钟,这话一点都不夸张。而用Quartus II做简易时钟,几乎是数字逻辑和组成原理课程的经典保留项目。我当年做这个题目的时候,最开始也是一头雾水,觉得“时钟”不就是显示时间吗,有什么难的?真正动手才发现,从晶振的50MHz信号折腾出秒脉冲,再到时分秒的进位和数码管扫描显示,每一步都在考察你对时序逻辑的理解。这篇博客就把我当时做这个项目的完整思路、模块划分、Verilog代码思路和踩过的坑全部梳理出来,给正在为这个作业头秃的同学参考。
1. 项目概述与整体设计思路
1.1 大作业到底在考察什么
先别急着打开Quartus II写代码,想清楚这门课的老师想让你从大作业里学会什么,比闷头干重要得多。
计算机组成原理这门课,核心讲的是CPU怎么工作、数据怎么流动、控制信号怎么产生。但所有这些东西落到物理实现上,都离不开一个基础——时序逻辑。时钟信号是整个数字系统的“心跳”,寄存器靠它更新,状态机靠它跳转,CPU里的PC计数器靠它加一。而“简易时钟”这个题目,恰好把时序逻辑里最核心的几个知识点全串起来了:分频、计数、进位、译码、显示。
老师出这个题,不是真缺一个看时间的钟,而是想看到你对以下几个概念的理解:
- 时钟分频:板载晶振给的信号频率太高,怎么得到1Hz的秒脉冲。
- 模N计数器:秒和分是六十进制,小时是二十四进制,怎么写归零和进位。
- 译码显示:BCD码或二进制怎么转换成数码管的段码。
- 动态扫描:为什么六个数码管可以只用一组段选线,靠什么让它们“同时”亮。
- 模块化设计:分频、计数、显示各自独立成模块,最后在顶层例化连接。
把这些点吃透了,不仅大作业能拿高分,后续学到单周期CPU、状态机设计时,你会觉得顺很多。很多学软件的同学觉得计算机组成原理没用,但只要你做过一次这种硬件描述的项目,再看“时钟周期”“中断”“寄存器”这些概念时,感受完全不一样。
1.2 功能需求拆解与技术选型
我当时的任务要求并不复杂,总结下来就三点:
- 正常走时:上电后从00:00:00开始,每秒自动加一。
- 时间可调:通过按键可以分别调整小时、分钟和秒(或者至少能校时)。
- 数码管显示:六位数码管,分别显示时、分、秒。
这个功能集看起来简单,实际做的时候要选的技术路线却有好几条。我当时在方案上纠结了挺久,这里分享下我的选型思路。
先说开发工具。Quartus II是Intel(原Altera)官方出的FPGA开发软件,学校里配套的FPGA开发板绝大多数是Cyclone系列,所以用Quartus II基本是没得选的。版本上有人用13.1,有人用15.0,还有人用更新的版本。我的建议是:能用13.1或15.0就别追求新版,这两个版本稳定,网上教程多,破解教程一搜一大把,而且对老开发板的器件支持很全。我有同学装了最新版Quartus,结果发现板子上的Cyclone IV器件型号在器件列表里反而找不到,折腾半天。
再说开发方式。Quartus II支持原理图输入和Verilog/VHDL硬件描述语言输入两种方式。
原理图输入对新手很友好,逻辑门、触发器、计数器都是现成的元件库,拖出来连上线就行。但缺点也很明显:一旦要改功能或者排查问题,那叫一个痛苦。我亲眼见过室友用原理图搭的时钟,一根线没连对,仿真波形怎么都不对,在密密麻麻的连线里找问题找到崩溃。
Verilog则干净得多。每个功能模块就是一个文件,逻辑清晰,改起来容易,仿真和调试也方便。更重要的是,用Verilog写的模块化代码,后期如果想拓展功能(比如加闹钟、倒计时),往顶层加例化就行。
所以我的结论很明确:除非老师强制要求原理图,否则一定用Verilog。这是个能让你少吃很多苦的选择。
2. 核心原理:从晶振到时间显示
2.1 分频:50MHz到1Hz的距离
开发板上一般有一颗50MHz的有源晶振,也就是说,FPGA的全局时钟引脚上每秒会来50,000,000个上升沿。对CPU来说这个速度刚合适,但对我们做时钟来说太块了——我们需要的是每秒钟只来一个脉冲的信号。
这里就要用到分频了。分频的思路很朴素:用计数器数晶振的脉冲,数够5000万个,就输出一个脉冲,然后清零重新数。
分频系数的计算公式很简单:
N = F_clk / F_out
也就是 N = 50,000,000 / 1 = 50,000,000。
那计数器位宽需要多少?我们需要能数到50,000,000,而2^25 = 33,554,432,不够;2^26 = 67,108,864,够了。所以计数器最少得26位,也就是用一个26位的寄存器做分频计数。
我当时第一次写到这里就踩了个坑,寄存器位宽只给了25位,结果仿真时发现分频输出隔很久才翻转一次,当时还没意识到是位宽不够,以为是代码逻辑问题,查了半天。后来算了一下才反应过来,N=50,000,000超出了25位能表示的最大值。这个细节看起来小,但真的会让你的时钟“走不准”。
分频的实现方式有两种常见写法:
- 一种是计数到N-1时清零,同时产生一个高电平脉冲
- 另一种是计数到N/2-1时让输出翻转(这种主要用于产生50%占空比的对称方波)
做秒脉冲的话,用第一种就行,我们只需要每个周期有一个时钟宽的脉冲来触发秒计数器,不需要50%占空比。
2.2 六十进制和二十四进制的进位逻辑
有了1Hz的秒脉冲,接下来就是让计数器动起来。秒计数器是六十进制,但我们不能直接用一个可以数到59的计数器就完事——因为后面要显示,而数码管一位只能显示0到9。
所以常规做法是把“秒”拆成两个数字:个位和十位。个位是十进制(0到9),十位是六进制(0到5)。当个位到9后,再来一个秒脉冲,个位归零,十位加一。当个位等于9且十位等于5时,说明当前是59秒,下一秒秒整体归零,同时向分钟进位。
这个过程在数字电路里本质上就是一个状态机,只是状态比较规整,用两个计数器拼接起来就实现了。你可以把它理解成一个“逢十进一”加“逢六进一”的复合进位,关键是搞清楚什么时候产生进位信号。
分钟的进位数逻辑和秒一模一样的,毕竟分也是六十进制。小时则是二十四进制,只有小时那个模块的规则不同:小时个位是十进制(0到9),小时十位是0到2,而且十位为2时个位最大只能到3(对应23点)。也就是说,小时计数器的归零条件是“十位为2且个位为3”。
这里有个新手容易犯的错:把小时的逻辑想得太简单,直接让十位在0到2之间循环,个位在0到9之间循环,结果会出现24点、25点这种不存在的时刻。归零条件必须写成hour_ten == 2 && hour_unit == 3。
2.3 数码管动态扫描:一个人眼欺骗术
时钟做完了,怎么显示出来?六个数码管如果每个都单独接一组段选线,那FPGA的引脚根本不够用。所以实际电路里几乎都采用动态扫描的方式。
动态扫描的原理特别像人眼的视觉暂留:在任何时刻,六个数码管里其实只有一只是在亮的,但是我们让六只管子以极快的速度轮流点亮,比如先亮第1只显示小时的十位,过1ms后切换到第2只显示小时的个位,再过1ms切到第3只……当这个循环跑得足够快(一般刷新频率要在100Hz以上,最好到200Hz),人眼就分辨不出来哪只管子是“轮流亮”的,看到的效果就是六只管子全部稳定显示。
这里有两个参数很关键:
- 扫描周期:每只管子分配多少时间。我用的是1ms,六只管子一轮就是6ms,刷新率约167Hz,肉眼完全看不到闪烁。
- 消隐处理:如果切换位选时没有先把段码清掉,前一位的数字会“拖影”到后一位,看起来就是重影加亮度不均。
动态扫描这个知识点,在计算机组成原理里可能不会专门讲,但在数字电路实验里一定会碰到。理解了它,你后面学存储器刷新、DRAM的刷新机制时也会有似曾相识的感觉。
3. 模块化设计与Verilog实现
3.1 分频模块:掐着5000万次算一秒
我用Verilog写了整个工程,习惯是每类功能一个文件。下面是分频模块的核心代码:
module clk_div_1hz( input clk_50m, input rst_n, output reg clk_1hz ); parameter N = 50_000_000; reg [25:0] cnt; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; clk_1hz <= 1'b0; end else if (cnt == N - 1) begin cnt <= 26'd0; clk_1hz <= 1'b1; end else begin cnt <= cnt + 1'b1; clk_1hz <= 1'b0; end end endmodule这段代码的逻辑很简单,参数N设为50,000,000,计数器从0数到N-1,当计满时输出一个时钟周期宽的高脉冲。
注意几个细节。第一,复位用了异步复位(negedge rst_n),低电平有效,这是FPGA开发板的常见配置。第二,reg [25:0] cnt是26位,这是根据2^26 > 50,000,000算出来的。第三,clk_1hz <= 1'b0写在else分支里,确保它平时是低电平,只有计满的那一拍是高脉冲。
我在仿真时发现一个技巧:仿真50M个时钟周期来验证这个模块,虽然仿真软件能跑完,但实在太浪费时间了。更好的办法是先把N临时改成一个小值,比如500,验证分频逻辑正确后,再改回50M做整体仿真。这个是实际项目中常用的加速仿真手段,不是走捷径偷懒,而是仿真本来就该分模块验证。
3.2 时分秒计数器:BCD的妙处
计数器模块我用了BCD码,也就是每个十进制位用4位二进制表示。为什么不用一个6位二进制数直接数到59?因为后面显示的时候,每一位数码管要独立译码,BCD写法省去了除法取商和取余的操作,电路更简洁。
秒计数器部分的实现思路如下(秒个位为例):
reg [3:0] sec_unit; reg [3:0] sec_ten; always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin sec_unit <= 4'd0; end else if (sec_unit == 4'd9) begin sec_unit <= 4'd0; end else begin sec_unit <= sec_unit + 1'b1; end end always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin sec_ten <= 4'd0; end else if (sec_unit == 4'd9) begin if (sec_ten == 4'd5) sec_ten <= 4'd0; else sec_ten <= sec_ten + 1'b1; end end这样写的妙处在于,两个always块都受同一个clk_1hz驱动,当秒个位从9变成0的那一拍,秒十位同步加一,不存在竞争冒险问题。如果你试图用组合逻辑去做进位,反而容易出现毛刺。
分钟的计数器写法和秒完全一致。小时的计数器需要多一个判断条件:
// 小时归零条件:22 -> 23 -> 00 always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin hour_ten <= 4'd0; hour_unit <= 4'd0; end else if (min_inc) begin if (hour_ten == 4'd2 && hour_unit == 4'd3) begin hour_ten <= 4'd0; hour_unit <= 4'd0; end else if (hour_unit == 4'd9) begin hour_unit <= 4'd0; hour_ten <= hour_ten + 1'b1; end else begin hour_unit <= hour_unit + 1'b1; end end end这里的min_inc是分钟计数器的进位信号,也就是秒计数器出现59后产生的那个进位脉冲。这里要注意的是,所有计数器模块的驱动时钟都是clk_1hz,而进位信号只是作为条件判断,不是作为时钟。这个设计原则是时序电路里非常重要的一个点:尽量用同一个时钟沿驱动所有状态,避免用进位信号当时钟,否则时序分析会变得非常不可控。
3.3 显示驱动:段码表和动态扫描
数码管显示分为两部分:一是把BCD码翻译成七段码,二是动态扫描。
七段码的翻译用case语句实现。我用的开发板是共阴极数码管,段码表如下:
always @(*) begin case (data) 4'd0: seg = 7'b1000000; // 0 4'd1: seg = 7'b1111001; // 1 4'd2: seg = 7'b0100100; // 2 4'd3: seg = 7'b0110000; // 3 4'd4: seg = 7'b0011001; // 4 4'd5: seg = 7'b0010010; // 5 4'd6: seg = 7'b0000010; // 6 4'd7: seg = 7'b1111000; // 7 4'd8: seg = 7'b0000000; // 8 4'd9: seg = 7'b0010000; // 9 default: seg = 7'b1111111; endcase end注意段码表里的0和1,不同板子可能用的是共阳极,段码恰好是共阴极按位取反。所以拿到新板子第一件事是查原理图,确认数码管是共阴还是共阳,别想当然。
动态扫描部分需要一个扫描计数器和位选信号。我习惯用一个3位的计数器,在1kHz左右的时钟驱动下循环0到5,依次选中第0到第5个数码管:
// 位选信号,低电平有效(共阴时通常高电平选中) always @(posedge clk_1khz or negedge rst_n) begin if (!rst_n) scan_cnt <= 3'd0; else scan_cnt <= scan_cnt + 1'b1; end always @(*) begin seg_data = 7'b1111111; // 默认灭 sel_output = 6'b000000; case (scan_cnt) 3'd0: begin sel_output = 6'b000001; seg_data = display_data[0]; end 3'd1: begin sel_output = 6'b000010; seg_data = display_data[1]; end // ... 依次类推 endcase end这里我特别强调一个细节,就是组合逻辑块里的默认赋值。每次切换显示位之前,先把段码置成全灭(也就是消隐),再选中下一位,然后给段码。如果不这样做,扫描切换的瞬间会看到上一只数码管残留的数字,表现在实际效果上就是数字有“拖影”,亮度也不均匀。
3.4 顶层模块:把积木拼起来
顶层模块做的事情就三件:例化分频模块、例化计数器模块、例化显示模块,把信号连起来。
我的顶层大概长这样:
module top_clock( input clk_50m, input rst_n, input key_hour, input key_min, output [6:0] seg, output [5:0] sel ); wire clk_1hz; wire [3:0] sec_unit, sec_ten; wire [3:0] min_unit, min_ten; wire [3:0] hour_unit, hour_ten; clk_div_1hz u_clk_div( .clk_50m(clk_50m), .rst_n(rst_n), .clk_1hz(clk_1hz) ); counter_60 u_sec( .clk(clk_1hz), .rst_n(rst_n), .inc(1'b1), .unit(sec_unit), .ten(sec_ten) ); counter_60 u_min( .clk(clk_1hz), .rst_n(rst_n), .inc(sec_inc), .unit(min_unit), .ten(min_ten) ); // 小时模块和显示模块略 endmodule模块化最大的好处是每个文件短小精悍,出错了定位快。比如显示出问题,只需要看显示模块和顶层连接,不用在几千行的代码里找。
4. 实操流程:从新建工程到板级调试
4.1 Quartus II工程创建与配置
打开Quartus II之后,新建工程的流程基本上是固定的:File > New Project Wizard,填工程名和路径,选器件型号。器件型号一定得跟你手上的开发板对得上,比如Cyclone IV系列的EP4CE6E22C8。选错了后面编译会报一堆看不懂的错误。
这里有个我印象很深的坑:工程路径不能有中文,也不能有空格。当时我把工程放在了“D:\大作业\clock”下面,Quartus直接报错,一度让我以为软件装坏了。后来改成纯英文路径才正常。这个习惯建议从第一天就养成,所有跟开发相关的文件路径都保持英文,能避免很多莫名其妙的问题。
文件命名也建议用英文小写加下划线,比如clk_div_1hz.v、counter_60.v、display.v。对初学者来说,Verilog文件里模块名最好跟文件名一致,这样Quartus的自动识别会更友好。
版本选型方面,如果你是初学者或者板子比较老,13.1和15.0都是非常稳的选择。15.0在Windows 10上运行比较顺畅,13.1则兼容性更强,很多学校机房甚至还在用。装15.0的话注意破解步骤跟13.1略有不同,用Quartus自带工具的License Setup指定好license文件就行。
4.2 功能仿真:上板之前的最后一道防线
仿真这一步很多人会跳过,尤其是赶作业的时候,总觉得“代码写完直接下载到板子上不就行了”。这是大忌。
板子上出了问题,你只能看到现象不对,但看不到内部信号,排查效率极低。而仿真是白盒调试,每个内部信号都能看波形,逻辑对不对一目了然。
我做仿真用的是ModelSim,Quartus II自带集成。如果需要模拟1Hz走秒,完整仿真50M个时钟周期会很慢,所以我的做法是写一个单独的testbench,先把分频模块的N参数临时改成10,验证分频思路正确后,再单独验证计数器模块。计数器模块直接用理想1Hz时钟来驱动,不用经过分频,这样仿真时间压缩到几毫秒之内。
还有一个小技巧,ModelSim里可以用force命令直接给内部信号赋初值,或者用initial块把复位拉低再拉高。波形窗口里放大缩小、加信号光标这些操作,做一次仿真基本上就熟练了。
4.3 引脚分配与下载调试
仿真通过之后再上板,需要做引脚分配。不同开发板的引脚定义完全不同,必须查板子附带的原理图。我拿着自己板子的原理图,找到50MHz晶振接的是哪个引脚,数码管的段选和位选接的是哪些引脚,然后依次填入Quartus的Pin Planner。
引脚分配最怕的就是手抖填错。填完引脚建议做一次全编译,Quartus会重新做布线。如果有引脚冲突或者频率约束问题,编译时的报告里会提示。
下载的时候,在Quartus里打开Programmer,选择USB-Blaster下载器,加载生成的.sof文件,点Start,等进度条走完,板子上数码管应该就开始跳了。如果板子上没反应,先看下载器驱动装没装好,再看电源有没有给FPGA供电,最后再怀疑代码。这三个因素里,低级问题占了大头。
5. 调试实录:常见问题与排查技巧
5.1 上板之后时钟不走,怎么排查
这是群里被问得最多的一个问题。代码编译没问题,仿真也对,下载到板子上一动不动。我的排查顺序是这样的:
第一步,确认复位信号。很多开发板的按键按下是低电平,复位信号要用rst_n接按键,按下才复位。如果你高电平和低电平时序弄反了,板子可能一直处于复位状态,自然不走。最直接的验证方法是用板子上的LED灯看一个分频后的慢速信号会不会闪。
第二步,确认时钟引脚。50MHz晶振通常固定在某个专用时钟引脚上,你在Pin Planner里填的是那个引脚吗?有同学图省事随便填了个普通IO口,FPGA内部的时钟网络根本进不去,所有时序逻辑全废。
第三步,确认分频参数。如果秒表走得太快,很可能是N写成了50,000而不是50,000,000,或者计数器位宽不够导致溢出。这个在仿真阶段就该发现,但如果你跳过了仿真直接上板,就只能靠直觉去猜了。
5.2 数码管显示闪烁、重影、亮度不均
闪烁基本上是扫描频率太低了。每只数码管分配的时间太长,刷新率掉到50Hz以下,人眼就能明显感觉到闪。解决办法是把扫描时钟频率提高,让每位驻留时间缩短到1ms左右。
重影和亮度不均则大概率是消隐没做或者做早了。正确的时序是:关闭所有位选 -> 切换段码数据 -> 打开目标位选。如果段码还没切好就把位选打开了,就会出现上一个数字的残影。
亮度不均还有一种情况:某一位显示的时间比其他位长。比如扫描信号写错,导致第0位被选中了2ms而其他位只有1ms,那第0位就会明显更亮。
5.3 按键校时不稳定:去抖是必修课
如果加按键校时功能,十有八九会遇到按键抖动的问题。机械按键按下和松开的时候,信号会来回抖动十几毫秒,直接拿来当计数器的加一信号,会出现按一下加好几次或者乱跳。
最原始也最有效的办法是计数去抖:检测到按键电平变化后,用一个计数器延时20ms左右,再读取一次电平,如果电平稳定了才认为按键真的被按下。这个延时在1kHz时钟下就是20个周期,代码量很小,实测效果很好。
当时我偷懒没做去抖,直接在板子上按按键调小时,结果按一下跳了三四次,调个时间比登录系统还费劲。后来老老实实把去抖加进去,世界清静了。
还有一个细节,如果按键是按下去拉低,去抖之后要判断的是下降沿,也就是“之前是高电平,现在是低电平”才算一次按下,而不是“现在是低电平”就算按下。很多人把电平判断当成边沿判断用,结果按键按住不放,时钟就一路狂跳。
6. 做完这个项目的几点沉淀
这个项目做完之后,我个人最大的感受是:纸上谈兵看再多书,不如亲手跑通一次仿真和上板。分频、进位、扫描、去抖,这些概念在课本上可能就几行字,但你真的在波形图里看到它们如何一个节拍一个节拍地推进时,理解深度完全是另一回事。
如果你做完基础功能还有余力,可以试一试这些扩展方向:
- 增加闹钟功能,到点让蜂鸣器响,这就涉及到比较电路和状态控制。
- 增加秒表功能,用另一个按键控制时钟暂停和清零,背后是数据选择器的应用。
- 把整个设计改成状态机风格,体验一下Moore型和Mealy型状态机的差别,这对后续学期做CPU设计有直接帮助。
最后再分享一个小技巧:所有工程文件,包括Verilog源码、Quartus工程、仿真脚本、引脚分配报告,都放到一个文件夹里打包存好。不光是交作业要用,后面你可能会回头翻这个工程,看自己当时怎么写进位的——相信我,你会感谢当时的自己留了这些文件。
本文还有配套的精品资源,点击获取