简介:这份FPGA万年历课程设计资料面向电子信息、自动化等专业修读FPGA/数字电路课程的学生,提供从题目要求到完整实现的一站式参考方案,帮助解决课程设计选题、代码编写与报告撰写中的实际困难。压缩包共407个文件,约67.2MB,以Verilog源码(v、vhd)、Quartus工程与综合报告(qsf、qpf、rpt、sof)、仿真与波形数据(vcd、vwf、do)以及课程设计报告文档(docx、doc)为主,另含大量中间编译与数据库文件,完整保留了工程目录结构。资源实现日期(年月日)与时间(时分秒)走时,区分闰年平年,并具备闹钟功能,可通过按键调整日期、时间和闹钟,选中调整项时数码管闪烁提示,具体指标见内部题目要求。目前已有6006人学习下载,读者可据此对照题目指标复现工程、理解模块划分与仿真流程,并参考完整报告组织设计思路与结论,适合作为课程设计参考与排错对照材料。
1. FPGA万年历课程设计:从时钟分频到按键消抖,一个能跑通的完整方案长什么样
每年到了课程设计季,问得最多的一类问题就是“FPGA万年历到底怎么做”。表面上看它只是个显示年月日时分秒的小系统,但真正动手才会发现,它把数字逻辑设计里最典型的几块硬骨头全串在了一起:时钟分频、BCD计数、按键消抖、状态机切换、数码管动态扫描,还有闰年判断这种看似简单却极容易写错的组合逻辑。我带过几届学生的课设,也帮人排查过不少“仿真对、上板错”的案例,最后发现翻车点几乎都集中在跨时钟域和消抖这两件事上。
这篇文章面向的是正在做FPGA万年历课程设计的人,不管你是刚学完Verilog语法、第一次把代码烧进开发板的新手,还是已经能写状态机、但想看看别人怎么组织工程结构的老手,都能从里面找到能直接抄作业的模块划分、参数设置和排错思路。我会按“功能拆解→模块实现→上板调试→避坑”的顺序讲,代码给的是可综合的Verilog,参数会标清楚为什么这么取,坑会按现象、原因、解决三段写。你照着搭一遍,至少能少走两星期的弯路。
2. 万年历的功能拆解与模块划分:先想清楚数据怎么流
2.1 从需求反推模块:六个必须独立出来的功能块
拿到题目要求之后,不要急着写代码。先做一件事:把“万年历”这三个字拆成数据流。时间从哪来、经过哪些变换、最后怎么显示,这条链路想清楚了,模块划分自然就出来了。
一个能稳定运行的FPGA万年历,通常需要下面六个功能块。第一是时钟分频模块,把板载晶振的高频时钟降到1Hz,作为秒脉冲的基准。第二是计时核心模块,负责秒、分、时、日、月、年的进位逻辑,这里面闰年判断是重点。第三是按键处理模块,完成消抖和边沿检测,输出干净的调整脉冲。第四是模式选择模块,用一个状态机在“正常走时”和“调整时间”之间切换。第五是显示驱动模块,把BCD码转成七段码并做动态扫描。第六是顶层模块,负责把上面这些连起来。
为什么强调“独立出来”?因为课程设计最容易犯的错误就是把所有逻辑塞进一个always块,最后连自己都看不懂哪根信号控制哪一步。模块化之后,每个模块可以单独仿真,出问题能快速定位到具体文件。我一般会建议学生先画一张模块连接图,标清楚每个模块的输入输出位宽,再开始写代码。这张图不用多漂亮,但必须能回答“秒脉冲从哪来、按键信号经过谁、显示数据由谁提供”这三个问题。
2.2 位宽与编码约定:BCD码还是二进制,一开始就要定死
模块划分完之后,紧接着要定编码方式。计时数据用BCD码还是纯二进制,这个决定会影响后面所有模块的接口。我的建议是:对外显示用BCD码,内部计数也用BCD码,中间不做进制转换。原因是数码管本身按位显示十进制数字,如果内部用二进制,每次刷新都要做除法取余,既浪费逻辑资源,又容易在综合时产生意想不到的时序问题。
具体约定是这样的:秒、分各用两个4位BCD码表示,范围00到59;时用两个4位BCD码,范围00到23;日、月各用两个4位BCD码;年用两个4位BCD码表示后两位。这样每个时间字段的位宽都是8位,接口整齐,顶层连线不容易搞错。闰年判断需要完整年份,所以额外保留一个12位的二进制年份寄存器,只在判断闰年时使用。
提示:BCD码计数时,个位到9、十位加1、个位清零,这个逻辑要写成一个独立的进位函数或任务,不要在秒、分、时里各写一遍,否则改一处漏一处。
2.3 顶层接口定义:先把端口列表写出来再填逻辑
很多新手习惯从底层模块往上写,写到顶层发现端口对不上,又回头改底层。更高效的做法是先定顶层端口,再往下拆。下面是一个常见的顶层端口定义,你可以直接参考这个结构。
module calendar_top( input wire clk_50m, // 板载50MHz晶振 input wire rst_n, // 低电平复位 input wire key_mode, // 模式切换按键 input wire key_inc, // 加调整按键 output wire [7:0] seg_data, // 七段码段选 output wire [5:0] seg_sel // 六位数码管位选 );这里把50MHz时钟直接引入顶层,分频放在内部做,是为了方便后续换板子时只改一个参数。按键只留两个,一个切模式,一个做加法,减少接线复杂度。数码管用6位,分别显示时、分、秒,调整模式下切换显示年月日。端口定好之后,每个子模块的接口就围绕这些信号展开,不会出现“写到最后发现少一根线”的情况。
3. 核心计时逻辑怎么写:秒脉冲、进位链和闰年判断
3.1 从50MHz到1Hz:分频计数器的参数怎么算
秒脉冲是整个系统的心跳,它必须精确。板载晶振通常是50MHz,要得到1Hz,分频系数是50_000_000。这个数用二进制表示需要26位,所以计数器位宽取26位足够。写法上有两种常见风格:一种是计数到一半翻转,另一种是计数到满值产生一个周期脉冲。我推荐后者,因为脉冲信号作为使能更直观。
// 50MHz -> 1Hz 分频,产生一个clk_enable脉冲 parameter CNT_1HZ = 26'd49_999_999; reg [25:0] cnt_1hz; reg en_1hz; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt_1hz <= 26'd0; en_1hz <= 1'b0; end else if (cnt_1hz == CNT_1HZ) begin cnt_1hz <= 26'd0; en_1hz <= 1'b1; // 只拉高一个时钟周期 end else begin cnt_1hz <= cnt_1hz + 1'b1; en_1hz <= 1'b0; end end这段代码的关键点是en_1hz只在高电平维持一个50MHz周期,后面所有计时模块都用它作为使能信号,而不是用它当时钟。用使能而不用门控时钟,是为了避免在FPGA里产生毛刺和时序违例。参数CNT_1HZ如果换成其他频率的板子,只需要改这个数,比如24MHz晶振就改成23_999_999。
3.2 秒分时进位:BCD计数的边界条件与代码模板
秒、分、时的进位逻辑结构相同,只是上限不同。秒和分是00到59,时是00到23。写的时候用一个always块处理一个字段,每个字段内部再分个位和十位。下面以秒为例。
reg [3:0] sec_lo, sec_hi; // 秒的个位和十位 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin sec_lo <= 4'd0; sec_hi <= 4'd0; end else if (en_1hz) begin if (sec_lo == 4'd9) begin sec_lo <= 4'd0; if (sec_hi == 4'd5) sec_hi <= 4'd0; // 59秒后回00,同时产生分进位 else sec_hi <= sec_hi + 1'b1; end else begin sec_lo <= sec_lo + 1'b1; end end end分和时的写法完全一样,只是时的十位上限是2,个位上限是3(23之后回00)。这里有一个容易忽略的点:分进位信号不能直接用sec_hi==5 && sec_lo==9去驱动,因为那是一个组合条件,在时钟边沿可能不稳定。正确做法是让秒模块输出一个carry_sec脉冲,在秒从59跳00的那个时钟周期拉高,分模块用这个脉冲作为使能。这样每一级进位都是同步的,不会出现竞争。
3.3 闰年判断:被4整除、被100整除、被400整除的三层逻辑
闰年判断是万年历里唯一涉及“年”这个字段的组合逻辑。规则大家都知道:能被4整除但不能被100整除,或者能被400整除。写成Verilog的时候,注意用完整的年份,不要只用后两位。下面是一个纯组合逻辑的实现。
wire [11:0] year_full; // 完整年份,例如2025 wire is_leap; assign is_leap = ((year_full % 4 == 0) && (year_full % 100 != 0)) || (year_full % 400 == 0);取模运算在综合时会被展开成比较逻辑,资源消耗可以接受。如果想让代码更省资源,可以用年份的低两位判断被4整除,用低两位是否同时为0判断被100整除,但可读性会下降。我一般建议课设阶段优先保证正确和可读,资源够用就行。月份天数用一个查找表实现,二月根据is_leap选择28或29,其余月份固定。
注意:月份天数表建议写成case语句而不是数组,因为数组在综合时可能被推断成分布式RAM,反而增加不确定性。case语句综合出来是纯组合逻辑,时序更可控。
4. 按键消抖与模式切换:状态机怎么写得既稳又好调
4.1 20ms消抖窗口:计数器深度和采样频率的取舍
机械按键按下和释放时会有5到10毫秒的抖动,如果不处理,按一次会被识别成很多次。消抖的常规做法是用一个低频采样时钟去采按键,连续采到相同值才认为状态改变。采样频率一般取100Hz到1kHz,对应消抖窗口10ms到20ms。我通常用20ms,因为更稳。
具体实现:先用分频产生一个200Hz的采样使能(5ms一次),然后用一个移位寄存器或计数器记录连续采样值。下面是一个计数器风格的消抖模块。
parameter DEBOUNCE_CNT = 4; // 连续4次采样相同才确认,4*5ms=20ms reg [2:0] sample_shift; reg key_stable; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin sample_shift <= 3'b111; key_stable <= 1'b1; end else if (en_sample) begin sample_shift <= {sample_shift[1:0], key_in}; if (sample_shift == 3'b000) key_stable <= 1'b0; else if (sample_shift == 3'b111) key_stable <= 1'b1; end endDEBOUNCE_CNT取4,配合5ms采样,总消抖时间20ms。如果按键质量差,可以加到8,但响应会变慢。这个模块输出的是稳定电平,后面还需要一个边沿检测,把按下瞬间转成一个时钟周期的脉冲,供状态机使用。
4.2 边沿检测:把稳定电平转成单周期脉冲
消抖之后得到的是稳定电平,但状态机需要的是“按下的那一刻”。边沿检测用两级寄存器打拍,然后做逻辑运算。
reg key_stable_d1, key_stable_d2; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin key_stable_d1 <= 1'b1; key_stable_d2 <= 1'b1; end else begin key_stable_d1 <= key_stable; key_stable_d2 <= key_stable_d1; end end wire key_press = key_stable_d2 & (~key_stable_d1); // 下降沿,按下这里假设按键按下是低电平,所以检测下降沿。key_press只在按下瞬间拉高一个时钟周期,状态机用它做触发条件,不会重复响应。
4.3 模式状态机:正常走时、调时、调分、调日四状态切换
状态机是整个系统的调度中心。我一般用四个状态:IDLE(正常走时)、SET_HOUR(调时)、SET_MIN(调分)、SET_DAY(调日)。key_mode按下在四个状态间循环,key_inc在调整状态下对对应字段加一。状态机用一段式或两段式写法都可以,课设推荐两段式,组合逻辑和时序逻辑分开,综合结果更清晰。
localparam IDLE = 2'd0, SET_HOUR = 2'd1, SET_MIN = 2'd2, SET_DAY = 2'd3; reg [1:0] state, next_state; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end always @(*) begin next_state = state; case (state) IDLE: if (key_mode_press) next_state = SET_HOUR; SET_HOUR: if (key_mode_press) next_state = SET_MIN; SET_MIN: if (key_mode_press) next_state = SET_DAY; SET_DAY: if (key_mode_press) next_state = IDLE; default: next_state = IDLE; endcase end调整逻辑放在另一个always块里,根据当前状态决定key_inc加到哪个字段。注意调整时秒脉冲仍然在走,如果不想让时间在调整时继续跑,可以在调整状态下屏蔽en_1hz。这个细节看题目要求,有的要求调整时停走,有的不要求。
5. 显示驱动与上板调试:数码管动态扫描和常见翻车点
5.1 六位数码管动态扫描:刷新频率与位选时序
六位数码管如果同时点亮,需要六组段选,引脚不够用。所以采用动态扫描:每次只点亮一位,快速轮流,利用视觉暂留形成同时显示的效果。刷新频率要高于50Hz,否则会看到闪烁。六位的话,每位刷新时间约3ms,整体刷新周期18ms,对应约55Hz,刚好够用。
reg [15:0] scan_cnt; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) scan_cnt <= 16'd0; else scan_cnt <= scan_cnt + 1'b1; end wire [2:0] scan_sel = scan_cnt[15:13]; // 约1.6kHz切换,6位轮流scan_cnt的高三位用来选择当前点亮哪一位,低十三位用来控制段选稳定时间。段选数据根据scan_sel从对应的BCD码查表得到。七段码表用case语句写,共阳和共阴要区分清楚,这个搞反了会显示乱码。
5.2 上板前必做的三件事:约束文件、引脚分配、复位检查
代码仿真通过不代表上板能跑。上板前必须确认三件事。第一,约束文件里时钟频率写对,50MHz对应20ns周期,如果写错综合工具会按错误频率优化,导致时序违例。第二,引脚分配和开发板原理图一致,特别是数码管段选和位选,接反了显示全乱。第三,复位信号极性正确,低电平复位和低电平有效要对应上,否则系统一直处于复位状态,什么都不显示。
我一般会建议先烧一个最简单的“数码管显示固定数字”的程序,确认显示通路没问题,再烧完整万年历。这样能把显示问题和逻辑问题分开排查,省很多时间。
5.3 仿真波形看什么:秒脉冲、进位脉冲和状态切换
仿真的时候不要只看最终输出。重点看三个信号:en_1hz是否每50M个时钟周期拉高一次;秒进位时carry_sec是否在59跳00的那个周期出现;状态机在key_mode_press时是否正确切换。如果这三个都对,基本逻辑就没问题。仿真时间至少跑2秒,才能看到一次完整的秒进位。跑10秒能看到分进位,跑60秒能看到时进位。课设阶段建议至少仿真到分进位,确认进位链没问题。
6. 避坑与常见问题:那些仿真对、上板错的瞬间
6.1 现象:数码管显示闪烁或重影,原因:扫描频率太低或段选未同步
闪烁通常是刷新频率低于50Hz。检查scan_cnt的位宽和取位,如果取的是高位但计数器位宽不够,切换频率会偏低。重影则是段选和位选没有同步更新,当前位的段选数据还是上一位的。解决办法是在位选切换的同一个时钟周期更新段选,或者用寄存器打一拍,保证两者对齐。
6.2 现象:按键按一次跳好几个数,原因:消抖不彻底或边沿检测重复触发
按一次跳多个数,说明消抖窗口不够或者边沿检测逻辑有问题。先确认采样频率和消抖计数是否匹配,20ms窗口对应4次5ms采样。如果还跳,检查key_press是否只持续一个周期,有时候两级寄存器打拍没做好,会输出多个脉冲。可以在key_press后面再加一级寄存,确保干净。
6.3 现象:时间走时偏快或偏慢,原因:分频系数算错或晶振频率不符
走时不准,先查分频系数。50MHz对应50_000_000,如果板子实际是24MHz,系数要改成24_000_000。另外注意计数器是从0计到CNT_1HZ,实际周期是CNT_1HZ+1个时钟,所以系数应该写目标值减一。这个差一错误很常见,走时偏快一点点往往就是这里。
6.4 现象:调整模式下时间仍在走,原因:使能信号未屏蔽
调整时如果不想让时间继续走,需要在调整状态下把en_1hz屏蔽掉。做法是在计时模块的使能条件里加上“当前状态为IDLE”。如果忘了这一步,调整的时候秒还在跳,调好的数马上又变了。
6.5 现象:闰年判断在整百年份出错,原因:只判断了被4整除
整百年份比如1900年不是闰年,但能被4整除。如果代码只写了year_full % 4 == 0,就会把1900判成闰年。必须加上被100整除和被400整除的两层判断。这个错误在仿真时如果只跑非整百年份是发现不了的,测试用例要专门覆盖整百年。
7. 进阶技巧:把万年历做成可配置、可扩展的工程模板
如果你已经跑通了基本版本,下一步可以考虑把它做成一个可复用的工程模板。具体做法是:把分频系数、消抖窗口、显示位数这些参数全部提到顶层用parameter定义,换板子的时候只改参数不改逻辑。另外,把计时核心里的进位逻辑写成一个通用的BCD计数器模块,秒、分、时、日、月、年各例化一次,只是上限参数不同。这样代码量会减少很多,而且改一处就能影响所有字段。
验证方面,除了仿真,我习惯在板上加一个“秒脉冲输出”引脚,用示波器或者逻辑分析仪量一下实际周期。50MHz分频出来的1Hz,周期应该是1秒,误差在晶振精度范围内。如果量出来偏差大,说明分频系数或者晶振频率有问题。这个实测步骤比看仿真波形更让人放心,因为仿真环境是理想的,实际板子有时钟抖动和电源噪声。
还有一个实用技巧:把当前状态机的状态用数码管的一位显示出来,比如正常走时显示0,调时显示1,调分显示2。这样调试的时候一眼就能看出状态机有没有卡死或者跳错。等调试完再把这位关掉。这个习惯帮我省过很多次“明明按了键但没反应”的排查时间。
最后说一个我自己的教训:早期做课设的时候,我总想把所有功能一次写完再上板,结果一出问题就不知道从哪查起。后来改成每写一个模块就单独仿真,确认波形对了再集成,集成一个模块就上板测一次。虽然看起来麻烦,但总时间反而更短。万年历这个题目,难点不在单个模块有多复杂,而在模块之间的配合和时序对齐。希望这些经验能帮到你,少熬几个通宵。
本文还有配套的精品资源,点击获取