☰
VHDL多功能数字万年历设计:从分频到数码管扫描的FPGA实战
2026/10/15 22:20:45 网站建设 项目流程

简介:一份基于VHDL语言的多功能数字万年历课程设计实验报告,面向数字电路与EDA技术课程的本科生,也适合需要参考完整VHDL工程结构与状态机设计的自学者。文档从设计思路入手,将系统划分为分频器、计数、设置、显示与控制等模块,给出了50MHz时钟到电子时钟的分频转换、数码管扫描输出、按键消抖与显示控制状态机以及RTL结构图,并说明了设置模式、闹钟和整点报警等扩展功能的实现方法。程序调试部分还整理了分进位、按键设置等测试结果,有助于减少实践中的重复踩坑。压缩包内共1个docx文件,容量约775KB,报告包含各模块VHDL源码分析、仿真调试记录和实验总结,内容完整、可直接编辑。已有836人学习下载,对正在准备数字万年历课题或复习状态机设计的读者具有不错的参考价值。

1. VHDL多功能数字万年历:一个把时序基本功全串起来的实战题

一看到“VHDL多功能数字万年历”这个题目,做过FPGA课设的人都知道它考的是什么:分频、进位、BCD显示、数码管动态扫描、按键消抖,再加上年月日跨月的月末归零,这些基本功会在同一块板子上同时发作。很多人用单片机加RTC芯片两三天就能交差,换到VHDL里自己写逻辑,却被一个肉眼很难察觉的进位错误拖上好几周。这个项目能解决的问题很明确:不依赖RTC芯片,用纯数字逻辑把年、月、日、时、分、秒、星期算出来,并且能用按键校时;它最适合正在做课程设计、备赛,或者学完VHDL语法想找一块完整练手项目的开发者。下面这套做法不是唯一答案,但按这个顺序走,翻车概率会小很多。

2. 先别写代码:把“多功能”拆成四个模块,时钟和进位规则先定死

2.1 模块划分与顶层信号:一个能对上真实日历的接口清单

这个项目的复杂度不在某一个模块,而在模块之间的接口约定。最常见的失败方式是:显示逻辑和计数逻辑写在一个大进程里,改一种显示方式就要把综合、仿真整个重来一遍。我一般会按四个实体划分,顶层只做端口映射。

模块职责关键对外信号
clk_div由板载晶振产生稳定的1秒时间基准sec_en
time_cnt年月日时分秒的计数、进位与校时累加year、month、day、hour、min、sec
key_ctrl按键消抖、生成单周期校时脉冲mode_key、adj_key
seg_disp数码管动态扫描、BCD到段码映射seg、sel

顶层端口看起来会是这样一套:一个时钟输入(板载晶振频率,常见的是50MHz)、一个低有效复位、两个或三个按键输入、八位数码管的段选与位选输出。顶层里各子模块互相连接的信号,说穿了就几根:sec_en把分频模块和时间计数模块连起来;mode_key与adj_key把按键模块和计数模块连起来;time_cnt输出的BCD总线进显示模块。这个接口清单值得先写到纸上,再开始写实体。

划分原则是边界上的“信息最小化”:时间计数模块不需要知道显示用共阳还是共阴,显示模块不需要知道闰年怎么算。只要接口不变,替换掉任何一个子模块都不影响其他部分。比如某天换一块不同极性的数码管板子,只改seg_disp,不用动time_cnt。这就是拆模块省下的调试时间。

2.2 时间基准:直接用晶振分频,还是启用PLL

时间基准的选择决定后面所有模块怎么写。板载晶振通常是50MHz,目标是一秒产生一个触发信号。这里有两条路线,差别很大。

做法实现方式跨时钟风险适用场景
方波时钟分频计数器在25M处翻转输出有,易踩简单演示、LED闪烁
使能脉冲计数到50M清零,同时输出一个时钟周期的高电平无推荐用于万年历

方波方案看起来直观:把50MHz分频到1Hz方波,再拿这个方波去驱动秒计数。问题是它引入了一个新时钟域,而这个分频时钟与原来的50MHz没有对齐关系,综合时工具会做跨时钟域检查,板上则可能出现亚稳态。使能脉冲方案只让计数器在某个时钟周期内被置“1”,其余时间都是“0”,所有模块仍然由同一个系统时钟驱动,这就是我推荐它的原因。

如果板子上有PLL可用,我的习惯是保留一个PLL选项,但不要指望它提升走时精度。PLL能把50MHz倍频到100MHz或更高,改变的是分频计数器的计数上限,晶振本身的ppm误差并不会因为PLL消失。用频率计测一下你会发现,误差来源主要在晶振的初始频偏和温漂,而不是分频参数。所以设计时把分频参数写成可配置的generic,比后期改代码要省事。

2.3 闰年规则与月末天数:最容易算错的一层组合逻辑

“万年历”里的年从0000到9999,但每一年有多少天,是整条进位链最容易出问题的地方。闰年判断的完整规则是:能被4整除但不能被100整除,或者能被400整除。缺了“百年不闰,四百年再闰”这两条,2100年就会被算成闰年,日历整体错一天。

-- leap.vhd 闰年判断组合逻辑 process(year) begin if (year mod 4 = 0 and year mod 100 /= 0) or (year mod 400 = 0) then leap <= '1'; else leap <= '0'; end if; end process;

这段代码的逻辑说明很简单:先看4整除,再排除100整除,最后用400整除把世纪闰年拉回来。注意year是integer类型端口,在VHDL里mod运算可以综合,但老一点的工具链可能对mod支持不全,如果发现综合报错,把表达式改成分步判断即可:先算year mod 4,再算year mod 100。

月末天数对应关系如下:

月份天数备注
1、3、5、7、8、10、1231固定
4、6、9、1130固定
228 / 29闰年29,否则28

写日期进位时,我会用case语句把month和leap组合起来算month_end,这样时间计数模块里只出现一行比较:day >= month_end。把月份天数判断独立出来,等于给后面第5章的“2月29日”坑提前打了疫苗。

3. 三个核心模块的VHDL实现:分频、计时、扫描显示

3.1 输出脉冲而不是时钟:秒使能的写法

分频模块是整个万年历的地基。它的任务不是产生一个50%占空比的1Hz方波,而是在每个整秒时刻输出一个高电平脉冲,宽度正好是一个系统时钟周期。下面代码以50MHz晶振为例。

library ieee; use ieee.std_logic_1164.all; entity clk_div is generic ( CLK_HZ : integer := 50_000_000 -- 板载晶振频率,可改 ); port ( clk : in std_logic; rst_n : in std_logic; sec_en : out std_logic -- 1Hz使能脉冲,宽度1个clk周期 ); end clk_div; architecture rtl of clk_div is constant DIV_LIMIT : integer := CLK_HZ - 1; signal cnt : integer range 0 to DIV_LIMIT; begin process(clk, rst_n) begin if rst_n = '0' then cnt <= 0; sec_en <= '0'; elsif rising_edge(clk) then if cnt = DIV_LIMIT then cnt <= 0; sec_en <= '1'; -- 整秒到达,输出一个脉冲 else cnt <= cnt + 1; sec_en <= '0'; -- 其余时间保持低电平 end if; end if; end process; end rtl;

逻辑说明:计数器从0数到49,999,999,共50,000,000个周期,正好一秒。计数到终点时,sec_en拉高一个周期,计数器回零。这段代码的关键是,sec_en不是时钟,它只是一个电平信号,时间计数模块在系统时钟的上升沿里判断sec_en是否为‘1’,以此决定是否计一次秒。

参数说明:generic里的CLK_HZ换成100MHz时,DIV_LIMIT变成99,999,999,不用改任何内部逻辑。一个容易写错的地方是终点值:写成CLK_HZ / 2会得到半秒脉冲,因为0到24,999,999只有25M个周期。先写generic再例化,后面换板子时就只改一个数字。

3.2 秒、分、时、日四级进位:用整数计数器省掉BCD转换

时间计数模块的常规做法是每个字段内部用整数保存,显示前再转BCD。这么做的好处是进位判断直观,秒等于59、分等于59这类条件写起来不像BCD向量那样绕。下面给出时、分、秒的核心进位逻辑。

-- time_cnt.vhd 时/分/秒进位链(核心片段) process(clk, rst_n) begin if rst_n = '0' then sec <= 0; min <= 0; hour <= 0; elsif rising_edge(clk) then if sec_en = '1' then -- 只在整秒脉冲到来时走动 if sec = 59 then sec <= 0; if min = 59 then min <= 0; if hour = 23 then hour <= 0; else hour <= hour + 1; end if; else min <= min + 1; end if; else sec <= sec + 1; end if; end if; end if; end process;

逻辑说明:这个嵌套判断是四级自然进位链,先从最底层秒开始判断,秒到59回零并触发分,分到59回零并触发时,时到23回零。每一级都用了“先判断终值再回零”的写法,而不是“大于59再进位”,这样校时过程中如果手动把某个字段加到60,也能被下一拍强制拉回合法范围。

参数说明:sec和min的integer范围分别是0到59,hour是0到23。这样设计后,输出给显示模块时用std_logic_vector(to_unsigned(sec * 16 + sec_h, 8))这类转换,或者干脆让显示模块直接接integer端口。另一种常见做法是全程用BCD向量计数,但那会让进位判断变成when "1001"这类魔法数,可读性差很多,我一般只在需要外部接口时才用BCD向量。

日期进位要单独处理,因为它的终点是动态的。核心判断是day是否超过month_end,超过就回1并月进位;月到12就回1并年进位。

-- 日期进位片段,month_end由2.3节的组合逻辑算出 if day >= month_end then day <= 1; if month = 12 then month <= 1; year <= year + 1; else month <= month + 1; end if; else day <= day + 1; end if;

这段的逻辑说明是“先比终点再回绕”,和时分秒一致。month_end如果是31日,day在31日这天走到下一拍会变成1日;如果是28日,2月28日下一拍直接进入3月1日。边界都由month_end统一管,不会再出现2月30日这类错误。

3.3 数码管动态扫描:刷新率、占空比与消隐

八位数码管不可能同时各亮各的,只能靠动态扫描:某一时刻只点亮一位,按固定速率循环。人眼看到的是所有位都在亮,前提是刷新率足够高。扫描频率的计算公式是:刷新率 = 系统时钟 / 扫描计数器终点 / 位数。

-- seg_disp.vhd 动态扫描核心片段 signal scan_cnt : integer range 0 to SCAN_LIMIT - 1; signal scan_pos : integer range 0 to 7; process(clk, rst_n) begin if rst_n = '0' then scan_cnt <= 0; scan_pos <= 0; elsif rising_edge(clk) then if scan_cnt = SCAN_LIMIT - 1 then scan_cnt <= 0; if scan_pos = 7 then scan_pos <= 0; else scan_pos <= scan_pos + 1; end if; else scan_cnt <= scan_cnt + 1; end if; end if; end process;

这段代码里,SCAN_LIMIT是每个位停留的时钟周期数。50MHz下要得到约1kHz每位的切换频率,SCAN_LIMIT取6250即可,实际刷新率是50MHz / 6250 / 8 = 1000Hz,每个数码管每秒被点亮1000次。低于800Hz时人眼能察觉闪烁,高于4000Hz时每一位的占空比太低,亮度明显下降。

段码映射用case语句按scan_pos选中当前BCD数据,再查段码表。一个关键细节是“消隐”:位选信号切换的瞬间,段选要先输出全灭,等位选稳定后再输出数据,否则相邻位会串光,出现俗称的“鬼影”。实现上就是在扫描位置变化的那一拍把seg置成全灭,下一拍再打入新数据。

共阳和共阴的段码极性相反,这一点必须按板子来。共阳数码管“0”的段码是11000000,共阴则相反。我遇到过不止一次,代码逻辑完全正确,就是不亮,最后查出来是极性选反了。

4. 校时与验证:按键怎么按出可信的时间

4.1 按键消抖:计数器、锁定与边沿检测

机械按键按下和松开的瞬间会有大约10到20ms的电平抖动,如果不做处理,一次按键可能被读成多次。常见的做法是:检测到按键电平变化后,启动一个20ms左右的稳定计数窗口,窗口结束后确认电平,再产生单周期脉冲。

-- debounce.vhd 按键消抖与脉冲生成 generic ( CLK_HZ : integer := 50_000_000; DB_CYCLES : integer := 1_000_000 -- 20ms @50MHz ); signal key_ff : std_logic; signal cnt : integer range 0 to DB_CYCLES; process(clk, rst_n) begin if rst_n = '0' then key_ff <= '1'; cnt <= 0; key_pulse <= '0'; elsif rising_edge(clk) then key_ff <= key_in; -- 寄存一拍,用于沿检测 if key_in = '0' and key_ff = '1' then cnt <= 0; -- 检测到按下沿,开始计时 key_pulse <= '0'; elsif cnt = DB_CYCLES then key_pulse <= '1'; -- 稳定时间已过,输出脉冲 else if key_in = '0' then cnt <= cnt + 1; else cnt <= 0; -- 中途松开,取消本次 key_pulse <= '0'; end if; end if; end if; end process;

逻辑说明:key_ff是按键输入的一拍延时,用来检测按下沿。检测到沿后计数器开始累加,达到DB_CYCLES时输出key_pulse。如果计数过程中按键又松开,计数器清零,不会产生脉冲。这个结构保证了一次完整的“按下并稳定”只输出一个脉冲。

参数说明:DB_CYCLES = 1_000_000对应20ms,如果按键老化、抖动时间变长,加大这个值即可。注意不要为了省资源把窗口减到2ms,那等于没做消抖,板上会频繁出现按一次跳两格的情况。按键脉冲宽度只有一个系统时钟周期,后续校时模块要在同一拍里完成状态处理。

4.2 校时状态机与日期回卷顺序

校时逻辑我用一个状态机来管:正常显示态、调年态、调月态、调日态、调时态、调分态。模式键每按一次切换到下一个状态,调整键每按一次把当前字段加1。

type state_t is (NORMAL, SET_YEAR, SET_MONTH, SET_DAY, SET_HOUR, SET_MIN); signal state : state_t; signal sec_en_effective : std_logic; -- 校时期间暂停秒计数:让时间停走 sec_en_effective <= sec_en when state = NORMAL else '0'; process(clk, rst_n) begin if rst_n = '0' then state <= NORMAL; elsif rising_edge(clk) then case state is when NORMAL => if mode_key = '1' then state <= SET_YEAR; end if; when SET_YEAR => if mode_key = '1' then state <= SET_MONTH; elsif adj_key = '1' then year <= year + 1; end if; -- 其余状态同理,调满后回到NORMAL when others => state <= NORMAL; end case; end if; end process;

这里有个必须处理的细节:校时状态下sec_en不能继续驱动时间计数,否则你调分钟的时候,秒还在走,返回正常态时时间和标准时间已经偏了。所以我引入sec_en_effective,只在NORMAL态把外部sec_en放行。顺序上先调年、再月、再日,最后调时分,这个顺序能避免一种别扭场景:只调小时但日期已经过了午夜,日期字段背着人悄悄进位。

4.3 仿真激励:把1秒压缩成一条指令

用VHDL写testbench验证万年历,最笨的方法是等仿真器跑完真实的一秒再验证进位,那要等死。正确做法是把秒使能变成可控的短周期信号,用循环产生逻辑上的“每秒”。

-- tb_time_cnt.vhd 核心激励片段 signal clk : std_logic := '0'; signal rst_n : std_logic := '0'; signal sec_en : std_logic := '0'; clk <= not clk after 10 ns; -- 10ns代表半周期,即50MHz process begin rst_n <= '0'; wait for 100 ns; rst_n <= '1'; -- 复位释放 wait for 200 ns; loop sec_en <= '1'; wait until rising_edge(clk); sec_en <= '0'; -- 一打交道,相当于过了1秒 wait until rising_edge(clk); end loop; end process;

这段激励的关键作用是把“1秒”压缩成两个时钟周期,仿真器可以在一秒内跑完几分钟的逻辑时间。验证日期边界时,先把初始值强行置为2月28日或12月31日,再反复送sec_en脉冲,观察day和month、year的跳变是否符合预期。波形里重点看三个位置:sec从59回0时min是否加1、day超过month_end时是否回1、校时脉冲期间sec_en_effective是否被屏蔽。

5. 实战避坑:仿真一遍过、上板乱走的五个现场

5.1 时间整体走快或走慢:先怀疑晶振,再怀疑分频参数

现象:数码管显示的时间明显快于真实时钟,一小时能快出几十秒,或者反过来慢得离谱。

原因:第一嫌疑是分频计数终点写错,50MHz的板子写成25M,等于一秒被当成两秒用;第二嫌疑是板载晶振本身频偏,常见晶振精度几十ppm,但一天误差也就是几秒量级,不会十分钟差一分钟。

解决:用万用表量不了高频,我一般把sec_en引出到板载LED,用手机秒表数LED闪烁次数,或者接频率计看脉宽。先排除代码,确认每两个脉冲之间的间隔是否精确等于1秒,再决定是调整generic参数还是换晶振。分频参数用generic后,校准只需要改顶层的例化参数,不用动模块内部。

5.2 数码管乱跳、亮度不均或出现鬼影

现象:数字能显示但整体闪,或者切换的瞬间有重影、暗影。

原因:扫描频率过低,低于800Hz人眼就会察觉;位选切换时没有消隐,新一位的段码提前输出,旧位还没有熄灭。

解决:把SCAN_LIMIT调到6250以下,保证每位刷新在1kHz左右。消隐的处理办法是,在每个扫描位置切换的时钟周期,先把seg全部置为高阻或全灭,下一拍再加载新数据。如果只是亮度不均,检查各位的电流是否一致,动态扫描下位数越多平均亮度越低,必要时把每一位的点亮时间做均匀分配,不要出现某位停留时间特别长的写法。

5.3 按键按一下跳两个数

现象:想从13点调到14点,结果变成15点;模式切换也一次跳两个状态。

原因:消抖窗口不够长,或者脉冲信号没有寄存一拍就直接进状态机。按下沿检测和稳定后的电平被当成两次触发,第二个沿在松开时又补了一枪。

解决:确认消抖后的脉冲宽度是严格的单周期,并且只在按下沿后的稳定窗口结束时产生一次。如果还跳,多半是消抖模块里沿检测用的是原始key_in而不是key_ff,把沿检测改成两拍寄存器链。另一个隐藏坑:状态机里mode_key和adj_key是同一个按键消抖模块出来的两个不同脉冲,它们可能出现在同一拍,需要定义优先级,否则状态会乱跳。

5.4 2月29日直接跳到3月1日

现象:仿真到闰年2月29日,下一拍没有变成3月1日,或者非闰年的2月28日直接跳过了2月29日但月份提前进位。

原因:闰年判断只写了year mod 4 = 0,漏了百年不闰规则,2100年被错误地当成29天;或者日期进位判断用的是固定值29,而不是month_end。

解决:把出生年逻辑收口到2.3节的leap信号里,month_end用case统一算。仿真时专门构造三组用例:2000年(闰年)、2100年(平年)、2004年(普通闰年),分别验证2月末的进位。这个坑在课程设计里最隐蔽,因为大多数人只测到时分秒,没人愿意把日期推到2月底再跑。

5.5 复位释放后第一秒丢失,或者偶尔复位后状态不稳定

现象:上电后数码管显示00:00:00,但第一个秒脉冲似乎没起作用,要等两秒才开始走字;偶尔复位后分钟、秒的初始值随机跳动。

原因:异步复位信号释放时刻和时钟上升沿发生竞争,寄存器可能采到半个复位态。如果复位释放刚好落在时钟边沿的建立时间窗口内,部分寄存器进入亚稳态。

解决:给复位回路加一个同步释放电路,把外部rst_n先打两拍生成rst_sync_n,再分发给所有模块;同时各进程尽量统一使用这个同步后的复位。另一个习惯是复位后不立即启动分频,让分频计数器先跑满一个周期再输出sec_en,相当于加一层“预热”,能明显减少第一秒丢失的现象。

6. 再往前走一步:参数化、闹钟与扩展接口的收尾思路

当你把万年历跑通以后,这个项目还能改造成更实用的形态。第一个值得做的是把所有模块的参数都提成generic,不只是分频器,扫描模块的位数、刷新间隔、计时模块的初始日期都可以作为顶层参数。换一块6位数码管的板子,或者想要上电默认显示某个固定日期时,不动一行逻辑代码,只改顶层例化参数就能完成适配。这是很多开发者从“能跑”走向“能复用”的分水岭。

第二个扩展方向是闹钟。闹钟的本质就是一个比较器:在time_cnt旁边维护alarm_hour和alarm_min两个寄存器,每到一分钟时比较当前时分是否相等,相等就输出一个alarm_tick脉冲,驱动蜂鸣器。需要留意的是,比较逻辑要放在同步进程里,不要直接用组合逻辑去驱动外部设备,否则产生毛刺。我一般会让alarm_tick保持一个固定的周期数,比如持续100ms,再自动熄灭,这样蜂鸣器不会响一秒全程那么刺耳。

第三个方向是给外部传感器留接口。比如挂一个温度传感器,读取到的温度值转成BCD,在数码管上轮换显示“时间-日期-温度”。扩展时注意保持现有模块边界不变:传感器模块只输出一个bcd_temperature,显示模块增加一个切换输入,由模式键选择显示源,时间计数模块完全不用动。这就是当初按接口拆模块带来的红利。

我自己每隔一段时间都会重新审视第一版时钟代码,最后发现八成踩过的坑都指向同一个习惯:总想用一个分频后的方波当新时钟,为了省事先斩后奏。现在我做FPGA项目时第一版必将时间基准统一成主时钟上的使能脉冲,这个习惯被印证过太多次了,值得你直接抄走。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询