简介:这份FPGA万年历课程设计资料面向电子信息、集成电路及相关专业的大学生与自学者,针对课程设计选题与实现难点提供一套可直接参考的完整方案。包内共407个文件,约67.2MB,以Verilog源码(.v、.vhd)、Quartus工程与综合报告(.qsf、.qpf、.rpt、.sof)、仿真与波形数据(.vcd、.do、.dat)以及课程设计报告文档(.docx、.doc)为主,另含大量中间编译与数据库文件,完整保留了从设计到综合下载的全过程痕迹。资源实现日期(年月日)与时间(时分秒)走时,能区分闰年与平年,并具备闹钟功能,可通过按键调整日期、时间和闹钟,选中调整项时数码管闪烁提示,具体指标见内部题目要求。目前已有6006人学习下载,适合需要快速搭建工程、对照报告撰写与查漏补缺的读者参考借鉴。
1. FPGA万年历课程设计:从时钟分频到按键校时的完整落地路径
很多人第一次拿到 FPGA 万年历课程设计这个题目,第一反应是「不就是个数码管显示时间吗」,然后花两天把秒脉冲分频、数码管动态扫描写完,仿真波形一跑,觉得稳了。结果上板之后发现:时间走得不准,按键校时按一下跳好几位,闰年二月直接显示 30 号,数码管还有肉眼可见的闪烁。这些问题的根源不在 Verilog 语法,而在于你有没有把「万年历」当成一个完整的时序系统来设计。
这个题目真正要解决的是三件事:一是用 FPGA 内部逻辑产生一个足够准的 1Hz 基准,二是用状态机管理年月日时分秒的进位与闰年规则,三是用按键消抖加边沿检测实现可靠的人机交互。它适合正在做课程设计的学生、想补数字系统设计实操经验的工程师,以及需要一套可复现参考方案的人。下面按「时钟怎么来 → 计时逻辑怎么写 → 按键怎么接 → 显示怎么驱 → 坑在哪」的顺序拆开讲,每个环节都给可综合的代码和参数说明。
2. 时钟分频与秒脉冲生成:别再用计数器硬分频了
2.1 为什么 50MHz 直接分频到 1Hz 会翻车
常见做法是写一个 26 位计数器,从 0 数到 24_999_999 翻转一次,得到 1Hz。这个逻辑本身没错,但它有两个隐患。第一,如果你用的是板载晶振直接分频,晶振的初始精度和温漂会全部传递到计时结果上,一天差几秒很正常。第二,很多同学把分频计数器和计时逻辑写在同一个 always 块里,导致综合工具无法做时钟树优化,时序报告里会出现大量违例。
我一般会把分频和计时彻底分开:分频模块只负责产生一个干净的 1Hz 使能信号(enable),而不是产生一个新的时钟。计时逻辑仍然跑在 50MHz 主时钟下,用使能信号控制进位。这样做的好处是整个设计只有一个时钟域,不需要处理跨时钟域同步,时序约束也简单得多。
// 1Hz 使能信号生成模块 // 输入:clk_50m (50MHz), rst_n (低电平复位) // 输出:en_1hz (单周期脉冲,宽度为一个主时钟周期) module clk_en_1hz ( input wire clk_50m, input wire rst_n, output reg en_1hz ); // 50_000_000 / 2 - 1 = 24_999_999 localparam CNT_MAX = 25_000_000 - 1; reg [24:0] cnt; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt <= 25'd0; en_1hz <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= 25'd0; en_1hz <= 1'b1; // 只拉高一个周期 end else begin cnt <= cnt + 1'b1; en_1hz <= 1'b0; end end endmodule这段代码的关键参数是CNT_MAX。如果你板子上的晶振不是 50MHz,比如是 12MHz,那CNT_MAX要改成6_000_000 - 1,位宽也要相应调整。en_1hz只拉高一个主时钟周期,后面所有计时模块都用if (en_1hz)来判断是否走秒,而不是用posedge en_1hz。这一点是很多新手翻车的地方:把使能信号当时钟用,综合出来会多出一堆时钟缓冲,时序很难收敛。
2.2 使能信号和门控时钟的区别
有人会问,既然都是控制进位,为什么不用门控时钟?门控时钟在 ASIC 里是省功耗的手段,但在 FPGA 里,时钟资源是专用的全局网络,你手动用逻辑门去「与」一个时钟,综合工具会报 warning,而且容易产生毛刺。使能信号的方式本质上是让寄存器在每个时钟沿都工作,只是数据输入端根据使能条件决定是否更新。这样写出来的电路是纯同步的,STA 分析起来很干净。
如果你确实需要多个不同频率的使能信号,比如 1Hz 给秒计时、100Hz 给数码管刷新,可以写一个参数化的分频模块,用parameter传入分频比,例化多次即可。但注意每个使能信号的计数器位宽要单独算,不要图省事全用 32 位,那样会浪费大量寄存器资源。
3. 年月日时分秒计时逻辑:闰年规则和进位链怎么写才不出错
3.1 计时状态机的核心:用 BCD 码还是二进制
年月日时分秒的存储方式有两种选择:二进制和 BCD。二进制省寄存器,但每次显示都要做除法转十进制,除法在 FPGA 里要么消耗大量 LUT,要么需要多个时钟周期。BCD 码每个数字用 4 位表示,显示时直接拆位就行,代价是进位逻辑稍微复杂一点。
对于万年历这种规模的设计,我建议直接用 BCD 码。因为数码管显示本身就需要十进制位,用 BCD 可以省掉二进制转 BCD 的转换模块,整体资源反而更省。下面以秒计时为例,展示 BCD 进位逻辑。
// 秒计时模块(BCD 码) // sec_ones: 秒个位 (0-9), sec_tens: 秒十位 (0-5) module sec_counter ( input wire clk, input wire rst_n, input wire en_1hz, // 来自分频模块的使能 output reg [3:0] sec_ones, output reg [3:0] sec_tens, output reg carry_min // 进位到分的脉冲 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec_ones <= 4'd0; sec_tens <= 4'd0; carry_min <= 1'b0; end else if (en_1hz) begin if (sec_ones == 4'd9) begin sec_ones <= 4'd0; if (sec_tens == 4'd5) begin sec_tens <= 4'd0; carry_min <= 1'b1; // 59 秒到,产生进位 end else begin sec_tens <= sec_tens + 1'b1; carry_min <= 1'b0; end end else begin sec_ones <= sec_ones + 1'b1; carry_min <= 1'b0; end end else begin carry_min <= 1'b0; // 非使能周期清零进位 end end endmodule这里有个细节:carry_min只在en_1hz有效的那个周期拉高,其他时候必须清零。如果你忘了清零,分计时模块可能会连续进位多次。同样的结构可以复制到分、时、日、月、年,只是进位条件不同。
3.2 闰年判断的三种情况和边界
闰年规则是万年历里最容易写错的部分。完整的规则是:能被 4 整除但不能被 100 整除的年份是闰年;能被 400 整除的年份也是闰年。用 Verilog 写就是:
// 闰年判断(输入年份为 BCD 码,输出 is_leap) // year_h: 千位和百位, year_l: 十位和个位 wire [7:0] year_bin = year_h * 100 + year_l; // 先转二进制方便取模 wire is_leap = ((year_bin % 4 == 0) && (year_bin % 100 != 0)) || (year_bin % 400 == 0);注意%取模在综合时会生成除法器,资源消耗较大。如果只是课程设计,年份范围有限(比如 2000-2099),可以直接用查表法或者简化逻辑:2000 是闰年,2100 不是,中间每 4 年一闰。用 BCD 码判断时,只需要看年份低两位是否能被 4 整除,再单独处理整百年。
月份天数的处理也有坑。二月平年 28 天、闰年 29 天,四月、六月、九月、十一月是 30 天,其余 31 天。我一般用一个 case 语句根据月份输出当月最大天数,然后日计数器比较到最大值时进位。
// 根据月份和闰年标志输出当月最大天数 reg [5:0] days_in_month; always @(*) begin case (month) 4'd1, 4'd3, 4'd5, 4'd7, 4'd8, 4'd10, 4'd12: days_in_month = 6'd31; 4'd4, 4'd6, 4'd9, 4'd11: days_in_month = 6'd30; 4'd2: days_in_month = is_leap ? 6'd29 : 6'd28; default: days_in_month = 6'd31; endcase end这段组合逻辑的输入是月份和闰年标志,输出是最大天数。日计数器在en_1hz有效且时进位时加一,加到days_in_month就清零并产生月进位。注意days_in_month用 6 位是因为最大 31,4 位不够。
4. 按键消抖与校时状态机:为什么你的按键一按就跳好几位
4.1 机械按键的抖动窗口和消抖参数
机械按键按下和释放时,触点会在几毫秒内反复弹跳,产生多个边沿。如果你直接用按键信号去触发计数器,按一下可能被识别成七八次,时间就跳了好几位。消抖的本质是在检测到边沿后等待一段时间,确认电平稳定再输出。
常见做法是采样加计数:用一个 20ms 的窗口,如果按键电平在窗口内保持不变,就认为有效。20ms 是经验值,对应 50MHz 时钟就是 1_000_000 个周期。参数太小消抖不干净,太大响应迟钝。我一般用 20ms,实测手感刚好。
// 按键消抖模块 // 输入:clk (50MHz), rst_n, key_in (原始按键) // 输出:key_press (单周期脉冲,表示一次有效按下) module debounce ( input wire clk, input wire rst_n, input wire key_in, output reg key_press ); localparam DELAY = 1_000_000; // 20ms @ 50MHz reg [19:0] cnt; reg key_sync0, key_sync1, key_stable; // 两级同步器,消除亚稳态 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 <= 1'b1; key_sync1 <= 1'b1; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; end end // 计数消抖 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_stable <= 1'b1; key_press <= 1'b0; end else if (key_sync1 != key_stable) begin if (cnt == DELAY - 1) begin key_stable <= key_sync1; cnt <= 20'd0; if (key_sync1 == 1'b0) // 按下时产生脉冲 key_press <= 1'b1; end else begin cnt <= cnt + 1'b1; end end else begin cnt <= 20'd0; key_press <= 1'b0; end end endmodule两级同步器是必须的,因为按键信号相对于 FPGA 内部时钟是异步的,不加同步器可能引入亚稳态,导致后续逻辑行为不确定。key_press只在按下确认后拉高一个周期,校时逻辑用这个脉冲来加一。
4.2 校时状态机:四个按键怎么分工
典型的万年历课程设计会用到四个按键:模式选择、加、减、确认。模式选择循环切换「正常显示 → 调年 → 调月 → 调日 → 调时 → 调分 → 调秒 → 正常显示」。加和减在当前选中的字段上加一或减一,确认键保存并退出。
状态机用一段式或两段式都可以,我习惯用两段式:一段时序更新当前状态,一段组合逻辑决定次态和输出。状态编码用localparam定义,不要用define,因为 `define 是全局的,容易冲突。
// 校时状态机(简化版) localparam S_NORMAL = 3'd0; localparam S_SET_YEAR = 3'd1; localparam S_SET_MONTH = 3'd2; localparam S_SET_DAY = 3'd3; localparam S_SET_HOUR = 3'd4; localparam S_SET_MIN = 3'd5; localparam S_SET_SEC = 3'd6; reg [2:0] state, next_state; always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S_NORMAL; else state <= next_state; end always @(*) begin next_state = state; case (state) S_NORMAL: if (key_mode) next_state = S_SET_YEAR; S_SET_YEAR: if (key_mode) next_state = S_SET_MONTH; // ... 依次类推 S_SET_SEC: if (key_mode) next_state = S_NORMAL; default: next_state = S_NORMAL; endcase end加减操作只在对应状态下生效。比如在S_SET_YEAR状态下,key_plus让年份加一,加到 99 回绕到 00。注意年份加减时要同步更新闰年标志,否则二月天数会不对。
5. 数码管动态扫描与显示驱动:亮度不均和闪烁怎么解
5.1 动态扫描的频率和占空比
多位数码管动态扫描的原理是快速轮流点亮每一位,利用人眼视觉暂留形成同时显示的错觉。扫描频率太低会闪烁,太高则亮度不足。经验值是刷新率在 200Hz 到 1kHz 之间,对应 6 位数码管,每位的切换频率就是 1.2kHz 到 6kHz。
我一般用 1kHz 的扫描时钟,6 位轮流,每位点亮约 166us。这个参数下肉眼完全看不到闪烁,亮度也够。扫描时钟可以从主时钟分频得到,不需要精确的 1kHz,差不多就行。
// 数码管动态扫描 // seg_data: 当前位要显示的段码 // seg_sel: 位选信号(低电平有效) reg [15:0] scan_cnt; reg [2:0] scan_idx; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scan_cnt <= 16'd0; scan_idx <= 3'd0; end else if (scan_cnt == 16'd49_999) begin // 1kHz @ 50MHz scan_cnt <= 16'd0; scan_idx <= (scan_idx == 3'd5) ? 3'd0 : scan_idx + 1'b1; end else begin scan_cnt <= scan_cnt + 1'b1; end endscan_idx从 0 到 5 循环,对应 6 位数码管。每改变一次scan_idx,就更新段码和位选。段码用 case 语句根据当前要显示的数字查表得到。
5.2 段码表和位选驱动的注意事项
段码表是共阴和共阳的区别。共阴数码管高电平点亮段,共阳相反。写代码前一定要确认板子上的数码管类型,搞反了要么全亮要么全灭。下面是一个共阴段码表的例子:
// 共阴数码管段码表 (a,b,c,d,e,f,g,dp) function [7:0] seg_decode; input [3:0] num; begin case (num) 4'd0: seg_decode = 8'b0011_1111; 4'd1: seg_decode = 8'b0000_0110; 4'd2: seg_decode = 8'b0101_1011; 4'd3: seg_decode = 8'b0100_1111; 4'd4: seg_decode = 8'b0110_0110; 4'd5: seg_decode = 8'b0110_1101; 4'd6: seg_decode = 8'b0111_1101; 4'd7: seg_decode = 8'b0000_0111; 4'd8: seg_decode = 8'b0111_1111; 4'd9: seg_decode = 8'b0110_1111; default: seg_decode = 8'b0000_0000; endcase end endfunction位选信号要注意驱动能力。FPGA 的 IO 口输出电流有限,直接驱动数码管可能亮度不够,通常需要三极管或专用驱动芯片。如果板子已经集成了驱动电路,那直接输出位选即可。另外,位选信号在切换时最好先关断所有位,更新段码后再打开下一位,避免出现「鬼影」——即上一位的段码短暂显示在下一位上。
6. 避坑与排查:课程设计里最容易翻车的五个点
6.1 时间走不准,一天差好几秒
现象:上板后对比手机时间,发现每小时慢几秒。原因通常有两个:一是晶振实际频率和标称值有偏差,二是分频计数器算错了。先检查CNT_MAX是否等于晶振频率 / 2 - 1,注意是除以 2 不是除以 1,因为计数器从 0 数到 CNT_MAX 是 CNT_MAX+1 个周期。如果分频没错,那就是晶振精度问题,课程设计阶段可以接受,工业产品需要外接 RTC 芯片。
6.2 按键按一下跳好几位
现象:校时模式下按一次加键,时间加了 3 到 5。原因是没有消抖,或者消抖后没有做边沿检测。消抖模块输出的是电平信号,如果直接用这个电平去触发计数器,只要按键按住不放,计数器就会一直加。正确做法是消抖后再做一次上升沿或下降沿检测,只在按下的瞬间产生一个单周期脉冲。
6.3 闰年二月显示 30 号
现象:到了闰年二月,日期从 28 跳到 29 再跳到 30。原因是闰年判断逻辑写错,或者days_in_month没有根据闰年标志更新。检查闰年条件是否覆盖了「能被 100 整除但不能被 400 整除的不是闰年」这条规则。另外注意年份的 BCD 转二进制是否正确,千位百位和十位个位要分别处理。
6.4 数码管有鬼影或亮度不均
现象:某一位数码管上隐约看到其他位的笔画,或者各位亮度明显不一样。鬼影是因为位选切换和段码更新没有对齐,解决方法是位选切换时先关闭所有位,等段码稳定后再打开。亮度不均通常是因为扫描占空比不同,检查scan_idx的循环是否均匀,以及每位点亮的时钟周期数是否一致。
6.5 综合报时序违例,上板不稳定
现象:综合工具报 setup 或 hold 违例,上板后偶尔跑飞。原因可能是逻辑级数太深,比如把分频、计时、显示全塞在一个 always 块里。解决办法是模块化拆分,每个模块只做一件事,寄存器输出,组合逻辑尽量浅。另外检查时钟约束文件,确保主时钟周期设置正确。
7. 进阶技巧:用参数化设计让代码适配不同板子
课程设计最尴尬的是换一块板子就要改一堆代码。我的习惯是把所有跟硬件相关的参数抽出来,放在一个params.vh文件里,用parameter或localparam定义。这样换板子时只需要改这一个文件。
// params.vh - 硬件相关参数集中管理 `ifndef PARAMS_VH `define PARAMS_VH // 时钟频率(Hz) `define CLK_FREQ 50_000_000 // 1Hz 使能计数最大值 `define CNT_1HZ_MAX (`CLK_FREQ / 2 - 1) // 消抖时间(ms) `define DEBOUNCE_MS 20 // 消抖计数最大值 `define CNT_DEBOUNCE (`CLK_FREQ / 1000 * `DEBOUNCE_MS - 1) // 数码管扫描频率(Hz) `define SCAN_FREQ 1000 // 扫描计数最大值 `define CNT_SCAN_MAX (`CLK_FREQ / `SCAN_FREQ - 1) `endif用宏定义的好处是改一个地方,所有模块自动适配。注意宏定义在综合时是文本替换,不占资源。但宏没有类型检查,写错了不容易发现,所以命名要规范,最好加前缀。
另一个技巧是给计时模块加一个「快速校时」模式。正常走时是 1Hz,校时时可以用 10Hz 或 100Hz 的使能,这样按住加键时时间快速滚动,不用按几百次。实现方式很简单:在状态机里判断当前是否在校时状态,如果是,就把使能信号切换到高频。
// 根据状态选择计时使能频率 wire en_tick = (state == S_NORMAL) ? en_1hz : en_10hz;en_10hz可以从分频模块再分一路出来,或者用en_1hz计数到 5 产生。这个功能加上去之后,校时体验会好很多,答辩时也是个加分项。
最后说一个我自己的教训:早期做这个设计时,我把所有逻辑写在一个模块里,仿真没问题,上板就是跑不起来。后来用 SignalTap 抓波形,发现是按键信号没有同步,亚稳态导致状态机乱跳。从那以后我养成了一个习惯:任何异步输入进 FPGA,第一件事就是打两拍。这个习惯帮我省了很多调试时间。希望帮到你。
本文还有配套的精品资源,点击获取