简介:面向电子信息类大学生的FPGA课程设计,这份万年历项目资料包提供了从题目要求到完整工程再到设计报告的整套方案。项目实现了日期、时间的走时功能,能自动区分闰年与平年,并具备闹钟提醒;通过按键可灵活调整日期、时间和闹钟设置,选中项数码管会闪烁提示,逻辑清晰,适合用于数字逻辑和可编程逻辑器件课程设计参考。压缩包共整理四百零七个文件,大小约六十七点二兆,主体包含硬件描述语言源码、工程配置文件、可下载的比特流文件以及课程设计报告,另含大量编译过程中生成的中间文件,便于进行工程回溯和错误排查。目前已有六千零二人学习下载,对希望深入研究万年历功能划分、模块化编码、按键消抖、动态扫描,或者需要参考课程设计报告规范的同学,都具有很好的借鉴价值。 每年都有朋友在课程设计里选FPGA万年历这个题目,原因无非两点:一是功能直观好验收,二是网上能搜到不少“完整代码”。但真正能讲清楚原理、还能对着老师提问对答如流的版本,其实不多。这篇博文就把我完整做一遍FPGA万年历课程设计的思路、代码结构、调试过程和报告整理经验写出来,给准备做或者正在做的同学一个可以直接落地的参考。
先交代背景:我做这个课题用的是一块Xilinx Artix-7系列开发板,板载50MHz晶振,数码管为6位共阳极,输入按键有4个独立按键。这套环境非常典型,如果你的板子是Altera或者更高频率的晶振,原理完全一样,只需改分频参数和引脚约束。下面按我实际开发的顺序来聊,从拿到题目到最终交付,每个环节里都有值得注意的坑。
1. 拿到课题先别急着写代码:从题目要求反推设计目标
1.1 基础功能与加分项的拆解
万年历课程设计最核心的功能要求一般是:能显示年月日时分秒和星期;通电后以某个初始时间开始自动走时;能通过按键对时间进行校准;要有合理的日期进位,尤其是大小月、闰年、跨年这些边界情况。稍微进阶一点的题目会要求整点报时或者闹钟功能,再往上就是温度显示、VGA显示等花活。
但大多数人的误区是,看到题目就往代码堆里扎,先找个例子再说。这样做很容易出现两个问题:第一,你拿到的代码和你的板子对不上,引脚数量、数码管极性、时钟频率通通是另一个平台的参数,综合都过不去;第二,即使能跑了,到了答辩环节,老师随便问一句“你的月末进位是怎么判断的”,现场就卡壳。我在实际做这个课题的时候,第一步是在纸上把功能清单列出来,并明确了哪些是必须项、哪些是加分项——这决定了后边的模块划分和工作量分配。
1.2 平台差异与题目要求的隐含条件
题目本身通常不会限制芯片型号,但实验室给你的板子是确定的。拿到题目后要先确认三个关键参数:系统时钟频率、复位信号是高有效还是低有效、数码管是共阳还是共阴。这三个参数几乎所有万年历代码都会用到,且不同平台差异巨大。50MHz和100MHz的分频计数目标值差一倍,共阳和共阴的段码表完全相反,复位极性搞反了甚至整个设计无法正常工作。
很多网上的代码不写清楚平台,直接搬运大概率出问题。我在指导同学做类似项目时发现,至少一半的“综合失败”和“上板不显示”都源于平台参数没对齐。所以我的建议是:动手写代码之前,先花半小时把开发板手册翻开,把上述参数在代码顶部用parameter定义好,后边所有逻辑都引用这些参数,而不是到处写魔数。这一点对后期调试影响极大。
2. 系统架构与信号流设计
2.1 顶层模块划分
万年历虽然整体功能简单,但如果不做模块拆分,把所有逻辑堆在一个Verilog文件里,后续调试会非常痛苦。我采用的模块划分方式是这样的:
- clk_div:分频模块,生成秒脉冲、显示扫描时钟、按键消抖时钟;
- time_counter:秒、分、时的累加与进位;
- date_counter:年、月、日、星期的累加与进位,其中最重要的是月末判断和闰年逻辑;
- key_ctrl:按键消抖、调时状态机、加减操作控制;
- disp_driver:动态扫描数码管驱动,或者LCD1602驱动,取决于你的显示方案。
每个模块只负责一件事,模块之间通过明确的信号连接。比如time_counter输出hour、min、sec三个总线信号给顶层,date_counter输出year、month、day、week四个总线信号,key_ctrl输出mode和adjust信号,disp_driver把所有这些信号换算成段选和位选。设计顶层时,就好比把一堆零件先分类放好,再互相接线,思路会清晰很多。
2.2 时钟分频的工程细节:为什么不用翻转时钟
分频模块是整个设计的“心跳”,几乎所有走时逻辑都由它驱动。很多新手学习FPGA时习惯用计数器分频得到一个低频时钟,然后用这个低频时钟的上升沿去驱动逻辑。比如:
reg clk_1s; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; clk_1s <= 1'b0; end else if (cnt == 24_999_999) begin cnt <= 0; clk_1s <= ~clk_1s; end else begin cnt <= cnt + 1; end end这段代码仿真完全没问题,上板走时也没有明显问题,但这种方法存在一个工程隐患:由逻辑生成的门控时钟不是全局时钟网络的时钟,在复杂系统中会增加时序收敛难度,还可能造成时钟偏斜。对于万年历这种低速设计,实际影响不大,但如果你想在报告里体现工程素养,更好的做法是生成“脉冲使能”信号:
reg [25:0] cnt; reg second_pulse; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 26'd0; second_pulse <= 1'b0; end else if (cnt == 26'd49_999_999) begin // 50MHz分频得到1Hz脉冲 cnt <= 26'd0; second_pulse <= 1'b1; end else begin cnt <= cnt + 1'b1; second_pulse <= 1'b0; end end这样做的好处是:所有计数模块仍然使用系统时钟作为触发时钟,second_pulse只是一个“使能”的脉冲信号,在每个脉冲有效的那个时钟周期执行一次累加。这种设计在时序分析和跨时钟域处理上更规范,而且便于仿真时把分频目标值改小,实现快速验证。
3. 万年历核心逻辑:日子怎么“走”起来
3.1 秒、分、时的累加与进位
秒、分、时模块的逻辑非常规律,本质上是一个60/60/24的计数器。以秒为例,每个second_pulse到来时,sec加1;当sec计数到59时再响下一个脉冲,sec归0,同时输出一个minute_carry信号给分钟模块。分钟和小时同理。关键点是进位信号必须是单周期脉冲,而不是电平,否则会导致分钟被连续进位。
推荐把BCD码转换放在显示模块,时间模块内可用二进制计数也可用BCD计数。我自己的习惯是用BCD计数,即sec的低4位是秒个位、高4位是秒十位。这么做的好处是显示模块不需要再做二进制到BCD的转换,报告里解释起来也更直观。缺点是每个位需要单独的边界判断,代码稍微长一点,但逻辑很直白。
3.2 月末判断与闰年规则
日期模块比时间模块复杂的地方在于“月底判断”。每月天数不是固定值,必须根据当前月份和年份来决定:
- 1、3、5、7、8、10、12月有31天;
- 4、6、9、11月有30天;
- 2月平年28天,闰年29天。
闰年规则是:能被4整除且不能被100整除,或者能被400整除。在Verilog里,最直观的写法是直接用取模运算:
wire div_4 = (year % 4 == 0); wire div_100 = (year % 100 == 0); wire div_400 = (year % 400 == 0); wire leap = (div_4 && !div_100) || div_400;这段代码在综合时会产生除法器,消耗一些逻辑资源,但对于万年历这种低资源设计完全可接受。如果想让报告显得更专业,可以指出FPGA不擅长除法,实际工程中通常会把year的低2位是否为0作为%4的判断(因为能被4整除等价于最低两位为0),%100则用硬件除法实现或查表,但这属于优化方向,不影响功能实现。
到达月末时的处理是:当天数计数到该月最后一天,并且有day_carry到来时,day归1,month加1;month到12时再归1,year加1。我在最初写这段代码时犯过一个典型错误——直接判断day == 31作为进位条件,结果每逢30天的月份就多走了一天。这个问题就是典型的没有分清楚“边界条件”和“当前月天数”的关系,调试时一眼看穿后,务必把月末判断做成独立函数,再被日期进位逻辑调用。
3.3 星期的连续性与基姆拉尔森公式
星期的计算有两种主流做法。第一种是在FPGA里实现基姆拉尔森公式,直接根据年月日算出星期。该公式的思路是把1月和2月当作上一年的13月和14月,然后通过一个包含月份项、年份项和日项的表达式取模7得到星期结果,具体公式网上有很多版本。但这个公式在FPGA里实现要考虑除法与取整,而且公式版本较多,不同版本对“0表示周日还是周一”的定义不同,课设阶段容易把自己绕晕。
我个人更推荐第二种做法:星期寄存器随日期走。设置一个初始日期和初始星期值,此后每当日期进位时,星期寄存器加1,到7之后回到1。这种方法实现简单、逻辑直观,而且只要初始值设置正确,星期永远都不会跑偏。初始值怎么设?以一个方便手动验证的日期为基准,比如设定2000年1月1日为星期六(这个时间点在多本教材中被用作起始基准),然后让仿真跑到你当前日期,核对星期是否正确。
这种方案还有一个好处:报告里可以专门用一段文字讲“如何验证初始星期的正确性”,体现出你确实理解了星期计算机制,而不只是把网上的公式抄了一遍。
4. 人机交互:按键调时与显示方案的选型
4.1 按键消抖:从“按一次跳好几次”说起
按键操作是万年历必备的人机接口,也是最容易出体验Bug的地方。如果不做消抖,按一次按键,FPGA采样到的是连续的高频抖动电平,状态机会像“癫痫”一样乱跳,实际效果就是按一下加了好几位、或者状态切换完全失控。
消抖的本质是“确认稳定”。我用的方法是延时采样法:检测到按键值为低(按下)后,启动一个约20ms的计数器,计数结束后再次采样按键,如果仍然是低电平,认为这是一次有效按键,产生一个单周期按键脉冲。20ms这个值是怎么来的?机械按键的抖动时间一般在5~10ms,留出两倍以上余量就能可靠滤除。注意消抖时钟不能太慢,否则20ms计数会占用大量周期;也不能太快,否则采样间隔内按键抖动还没结束。常见做法是用1kHz或1kHz以下时钟生成消抖时基。
4.2 动态扫描数码管的坑
数码管显示是很多人第一次接触“动态扫描”概念的地方。所谓动态扫描,是因为一只数码管就需要8根段选线,如果每位都独立连接,几十根引脚根本不现实。因此硬件设计上通常让所有位共用一个段选总线,用位选信号逐个点亮,只要扫描频率足够快(一般1kHz以上),人眼就会产生视觉暂留,误以为所有位同时亮。
动态扫描常见的坑有两个。一个是扫描频率太高或太低:太高会导致相邻位的显示出现拖影,太低则肉眼可见闪烁。另一个是位选和段选的时序冲突:如果你先切换了位选,再更新段选数据,新的位会瞬间显示上一个位的数据,产生“串位”现象。解决方法是先熄灭所有位,更新段选数据,再点亮目标位,这个流程也就是常说的“消隐-送段码-送位选-延时”四步。很多人在LED点阵、数码管项目里遇到的鬼影问题,本质上都是这个顺序没搞对。
4.3 LCD1602与数码管的对比选择
如果题目没有强制指定显示器件,我建议优先考虑LCD1602,数码管其次,VGA最后。原因很简单:LCD1602自带几十个字符的ASCII字库,能直接显示字母和数字,调试时可以把“2026-3-15 SUN”这样的完整信息一次性显示出来,肉眼判断错误比数码管直观太多。数码管则只能显示数字,往往还要做BCD转换,排查问题时还要一位一位对应,效率低不少。
当然,LCD1602的驱动复杂度比数码管高,它需要一个初始化序列、有总线忙标志、需要把数据写入DDRAM地址。我在驱动LCD1602时踩过最深的坑是初始化时序不够严格:上电后如果立刻发送命令,LCD可能不响应,必须严格按照数据手册中的延时要求等待几十毫秒。另一个常见问题是第二行地址不是0x08而是0x40,很多初学者把第二行地址当成0x08写,结果怎么都不显示第二行内容。所以我的建议是:如果你选择LCD方案,先对照数据手册确认初始化命令序列,不要凭记忆写。
| 对比维度 | 数码管动态扫描 | LCD1602 |
|---|---|---|
| 显示内容 | 数字与少量字母 | 字母、数字、自定义字符 |
| 驱动复杂度 | 较低 | 中等,初始化时序需严格 |
| 调试直观性 | 一般 | 高,可直接读字符串 |
| 引脚占用 | 约12根 | 约11根 |
| 适合场景 | 时钟、计数类 | 需要显示多行信息 |
5. 仿真验证与板级调试:把隐藏Bug炸出来
5.1 Testbench怎么写才能覆盖边界
万年历这类时序逻辑设计,仿真阶段最重要的不是跑通“正常走时”,而是验证边界情况。最大概率出错的时间节点只有三个:平年2月28日到3月1日、闰年2月29日到3月1日、12月31日到1月1日。这三个节点任何一个处理错,整个日历都不可信。
但问题来了:如果按真实时间仿真,1秒一个脉冲走完一年需要三千多万秒,完全不可行。所以我在写testbench时会把顶层分频模块里的分频目标参数通过parameter暴露出来,仿真时把分频值改小。比如50MHz系统时钟本来分频到1Hz需要计数49_999_999,仿真时可以把目标值改成5,这样每5个时钟周期产生一个秒脉冲,真实运行几百毫秒就能把一年跑完。同时用$display或者波形文件观察日期跳变是否符合预期。
仿真时我还会额外检查两点:星期是否连续,以及日期进位时“日”是否归1而不是归0。这两个点都是初学者容易忽略的细节,但恰恰是老师最爱问的地方。
5.2 板级调试的常见故障链路
刚上板时最常见的现象就是“数码管全灭”或者“乱码”。我的排查顺序是固定的:先确认引脚约束是否有遗漏、是否把段选和位选信号接反;然后用一个最简单的测试程序让所有数码管显示固定数字“8”,如果这一步都做不出来,说明显示链路有问题,和万年历逻辑无关;如果这一步通过,再把万年历的显示模块单独接上,通过板载拨码开关把当前时间手动设成已知值,观察显示是否正确。
还有一个高频问题:显示正常但走时不动。这种情况基本可以锁定是秒脉冲没有产生或没有接到时间模块。我习惯把秒脉冲同时引到板载LED上,如果LED一秒闪一次,说明分频模块正常,问题出在脉冲到时间模块的连接;如果LED不闪,问题就在分频器本身。这种逐级隔离的方法,比对着代码反复看要高效得多。
另外提醒一点,综合实现后一定要看时序报告,确认没有时序违例。万年历虽然只有几十兆赫兹,但如果出现很严重的建立时间违例,显示和走时都可能在实机上出现随机故障,而且非常难复现。
6. 完整代码与报告:交付物的组织建议
6.1 代码注释与文件结构
课程设计提交时,老师不仅看功能,还会看代码本身。如果你的全部逻辑堆在一个几千行的文件里,即使功能正常,印象分也会打折扣。我建议按模块分文件,每个文件在头部写好模块说明、输入输出端口、以及该模块依赖的信号。代码注释重点写“为什么”而不是“是什么”。
举一个例子:分频器的计数目标值是49_999_999,不要只写“计数器到49999999清零”,而应写成“对50MHz时钟计数50000000次得到1Hz脉冲使能”。这样老师一眼就能看出你明白自己在做什么。其他如月末判断、状态机跳转条件、消抖时间计数目标值,都应给出简明的注释。真正好的代码,是让一个没做过这个项目的人读一遍注释,就能大致复现整个设计逻辑。
6.2 报告怎么写才像“自己做的”
报告的结构通常包括:题目要求、需求分析、总体方案、各模块设计、仿真结果、板级测试、总结与心得。这里最容易翻车的是“方案设计”部分写成了代码流水账。更耐看的报告,每个模块都应该有一个框图,标清输入输出信号;每个状态机都要给出状态转移表或状态转移图(用Visio或者draw.io画就行,不推荐用文字硬堆),并解释为什么这样设计。
以我自己的报告为例,我把按键调时的状态机画成了表格:列出当前状态、触发条件、下一个状态和输出动作,这种表格在老师眼中就是“有工程思维”的信号。另外,报告中附上仿真波形截图,并在波形上标注关键时刻,比如“1Hz秒脉冲有效时秒钟加1”“2月28日到3月1日自动切换”,能让报告的说服力提升不少。
最后提醒一点:报告的总结部分,不要写“本次课程设计使我受益匪浅”这种空话,而是具体写你在调试中遇到了什么问题、通过什么手段定位和解决了什么问题。哪怕只是“发现LED扫描太快导致拖影,调整扫描周期后解决”,也比十句正确但无意义的套话有分量。
做完整套万年历课程设计,我最深的体会是:这个项目代码量不大,但它把FPGA开发里最核心的几件事——时钟分频、计数器设计、有限状态机、动态扫描、仿真验证——全部涵盖了。做完一遍,相当于把数字逻辑从课本搬到了真实的硬件上。如果你正在做这个课题,建议别急着抄代码,先按我上面的思路把模块边界画出来,再动手写,你会发现整个设计其实非常清晰。遇到显示异常或者走时不准确的问题,也沉住气按“时钟→进位→显示”的顺序逐级排查,大多数问题都能在两三次定位内找到原因。
本文还有配套的精品资源,点击获取