FPGA数字万年历设计实战:从分频到消抖的完整Verilog实现
简介面向FPGA学习者和数字电路课程设计人群这份基于EP4CE6芯片的Verilog数字万年历工程完整实现了年月日星期及时分秒的LCD显示支持按键加减校准时间同时加入闹钟与整点报时功能当闹钟和整点时间冲突时闹钟优先触发并通过LED灯区分指示。资源包共212个文件压缩后约8.78MB主要包含25个TDF文本设计文件、3个Verilog源文件、6个RPT报告、7个RDB数据库文件以及QPF/QSF工程配置、SOF下载文件和仿真辅助数据涵盖源码、约束、编译日志与配置信息。已有5074人学习/下载该工程。通过阅读工程内核心Verilog源码可系统掌握万年历计数器与闰年判断、LCD驱动时序、按键消抖调时、闹钟优先级比较和LED状态指示等关键设计同时工程目录中大量数据库与报告文件也便于分析Quartus编译流程整套工程结构完整适合FPGA入门与中高级应用练习。 很多初学FPGA的朋友在选练手项目的时候都会把数字万年历列在清单里觉得这东西不就是“数数显示”么有什么难的但我带过的学员里真正能把一个“FPGA数字万年历verilog”做得像样的不到三成。有人卡在闰年判断上2月永远按28天算有人卡在数码管显示上要么闪烁要么拖影还有人卡在按键调节上按一下时间跳好几秒。问题其实都不在单点逻辑上而是这个项目把FPGA开发里最核心的几块基本功全串到了一起计数器与分频、时序状态机、模块化设计、组合逻辑算法、动态扫描、按键消抖甚至还包括仿真验证和上板调试。这篇文章我就按自己实际做过的完整流程把这个项目从头到尾拆一遍讲清楚每一个模块为什么这么写、常见的坑在哪、上板之后又该怎么排查希望能帮你一次跑通。1. 从2024年2月29日说起万年历项目的真实分量1.1 一个“数数”项目背后藏了四类基本功万年历的核心需求是把系统时钟一步一步分频成1Hz秒脉冲然后让秒、分、时、日、月、年逐级进位再通过数码管把年月日时分秒显示出来最后还要支持按键调时。这件事听起来简单但“秒进分、分进时、时进日、日进月、月进年”这一串进位逻辑里包含了计数器的级联设计进到“日”之后又必须处理“这个月有几天”和“今年是不是闰年”的问题这是典型的组合逻辑算法把内部二进制数值转成数码管要用的BCD码是一个转换电路设计数码管动态扫描是一个多路选择加时间片轮转的控制逻辑按键消抖更是标准的状态机应用。你仔细数一下就会发现FPGA入门阶段的那几座大山这个项目一个不落。相比之下很多人简历上写的“LED流水灯”“按键控制蜂鸣器”这类项目练的就只有单一知识点含金量完全不在一个层次。我经常跟新人说一句话不要小看万年历能把万年历做明白你的数字逻辑基础和Verilog代码能力基本就到了可以做中等复杂度项目的水平了。1.2 和单片机做万年历相比FPGA版难在哪如果你用STM32或者51单片机做万年历思维模型是这样的主循环里先读按键再更新日历变量然后扫描一遍数码管最后延时一会儿回到循环开头。所有事情都是“顺序执行”的哪怕有中断本质上也还是有一个程序计数器在串行跑。FPGA完全不是这个玩法。万年历在FPGA里跑起来之后日历核心在进位数码管扫描在轮流点亮位选按键消抖在计时采样这三个逻辑是同时、并行在运行的。你不需要写任何“调度代码”但你必须从设计的一开始就想清楚各个模块之间的数据通路是什么使能信号怎么传递哪些信号是跨时钟域的。这就是硬件思维和软件思维最本质的区别。很多从C语言转过来写Verilog的人会习惯性地到处用阻塞赋值写出来的代码一看就是“用Verilog写单片机”——always块里塞一大串逻辑该用非阻塞的地方用了阻塞该拆状态机的地方用了一堆if else嵌套。万年历这种多模块协同的项目恰恰能把这种坏习惯暴露得淋漓尽致。1.3 面试的时候这个项目能讲出什么有学员问我万年历都烂大街了写在简历上会不会显得Low我的看法恰恰相反万年历是一个非常适合“往深了问”的项目。面试官可以从这个壳里一层一层问出很多硬核问题50MHz怎么分频到1Hz直接计数器分频和PLL分频有什么区别年月日状态为什么用BCD码存储BCD和二进制互转怎么做按键消抖为什么不能直接用延时状态机怎么设计数码管扫描频率选多少为什么不是越高越好。这些问题全部是FPGA开发的基础盘。所以你完全不用不好意思写这个项目。关键不在于项目名字是否高大上而在于你能不能把里面的原理讲透。我自己面试人的时候如果候选人能把万年历的分频、消抖、BCD转换讲得明明白白我反而更愿意给Offer——这比嘴上说“我熟悉FPGA开发流程”有价值得多。2. 顶层架构先行模块边界、时钟分频与接口宽度2.1 我习惯先把模块图“画”在纸上再动手写Verilog最容易犯的一个错误就是一上来就打开编辑器开始敲代码。万年历的模块虽然不多但如果模块边界不清晰后面调试起来会非常痛苦。我一般会在纸上先把顶层结构画出来分为五个模块时钟分频模块把板载50MHz分成1Hz秒脉冲以及1kHz左右的扫描/消抖基准时钟日历核心模块维护秒、分、时、日、月、年、星期的计数值支持按键调时按键消抖模块对外部按键信号做同步和消抖输出干净的调节脉冲显示驱动模块接收日历核心的BCD数据完成动态扫描输出段选和位选顶层模块负责例化以上四个模块连接所有信号模块之间用明确的接口信号传递数据。我举个具体例子日历核心输出到下级的接口通常长这样output [7:0] bcd_year, // 年份高4位 低4位即两位BCD output [7:0] bcd_month, // 同理 output [7:0] bcd_day, output [7:0] bcd_hour, output [7:0] bcd_min, output [7:0] bcd_sec, output [3:0] bcd_week先把这个接口想清楚再写内部逻辑。否则你写着写着就会发现显示模块想用BCD数据日历核心却输出了一个二进制整数消抖模块想输出持续一个周期的脉冲你却给了他一个电平信号后面全是返工。2.2 时钟分频万年历不需要PLL一个计数器就够了开发板上的系统时钟通常是50MHz或100MHz。万年历这个场景核心的秒脉冲是1Hz显示扫描一般用1kHz到2kHz按键消抖用1kHz到10kHz都可以。这些频率都属于“慢得要命”的范畴直接用计数器分频就够了完全不需要动用PLL。我见过一些初学者一上来就在工程里加了PLL IP核觉得这才是“正规做法”。但实际上PLL适合的是高速、低抖动、需要频率精确锁定的场合万年历对1Hz的抖动和精度要求几乎为零。计数器分频代码简单、行为透明、便于仿真这才是正确的工程选型。下面这段代码就是把50MHz分频成1Hz占空比50%的时钟reg [25:0] cnt_1s; reg clk_1s; always (posedge clk or negedge rst_n) begin if(!rst_n) begin cnt_1s 26d0; clk_1s 1b0; end else if(cnt_1s 26d24_999_999) begin cnt_1s 26d0; clk_1s ~clk_1s; end else begin cnt_1s cnt_1s 1b1; clk_1s clk_1s; end end注意计数器上限是24_999_999不是25_000_000。因为计数从0开始0到24_999_999一共是25_000_000个时钟周期时钟翻转一次再翻转一次合计50_000_000个周期才是完整的1秒。这个细节我见过太多人在这里翻车算出来的秒脉冲要么是0.5秒一次要么是2秒一次。2.3 为什么扫描和消抖要用统一基准时钟而不是从clk_1s再分频有些图省事的做法是让数码管的动态扫描直接跟着clk_1s走结果刷新率只有1Hz出来的效果就是数字肉眼可见地一闪一闪。正确做法是在系统时钟下再分出一路高频时钟典型值1kHz到2kHz专门给扫描和消抖用而这路时钟只做“节奏控制”不参与日历核心的秒计数。一个模块一个明确的时钟域逻辑清晰也不容易产生奇怪的竞争冒险。按键消抖也一样。很多人误以为消抖只是“检测到一个低电平就等一会儿再看一眼”但在FPGA里这个“等一会儿”是靠计数器实现的而这个计数器需要跑在一个比按键频率高得多的时钟下。所以我会在顶层统一例化一个分频模块同时输出clk_1s和clk_1k两路信号一个给日历核心一个给显示和按键控制。这样整条时钟树是清晰的后面对时序约束也有好处。3. 核心逻辑拆解闰年判断、月份天数表与BCD码转换3.1 先写一个“每天都能自增”的状态机日历核心本质上是一个多层进位计数器。它的行为可以描述为秒计数到59后归0同时分钟加1分钟计数到59后归0同时小时加1小时计数到23后归0同时日期加1日期到“当月最后一天”后归1同时月份加1月份到12后归1同时年份加1。这个链路就是万年历的主干。我强烈不建议把这一大串逻辑全写进一个always块里那样代码会变成一坨谁也看不懂的嵌套if。更好的做法是分层设计底层是秒、分、时顶层是天、月、年层与层之间用“使能脉冲”传递。比如当second等于59且秒脉冲到来时产生一个minute_en当minute等于59且minute_en有效时产生hour_en。这样每一层的逻辑都足够简单单独仿真也不费劲。wire sec_pulse (bcd_sec 8h59) clk_1s; wire min_pulse (bcd_min 8h59) sec_pulse; wire hour_pulse (bcd_hour 8h23) min_pulse; wire day_pulse (bcd_day days_of_month) hour_pulse; wire month_pulse (bcd_month 4d12) day_pulse;这样的写法信号名本身就是注释层次感非常强。后面调bug的时候只要从最低位的进位信号往上看很快就能定位是哪一级出了问题。3.2 闰年判断只有一行但很多人第一步就错“四年一闰百年不闰四百年再闰”。这句口诀每个人都会背但写进Verilog里漏掉“百年不闰”这条的人一大把。正确的闰年判断如下wire leap; assign leap (year % 4 0) ((year % 100 ! 0) || (year % 400 0));如果年份以二进制整数形式存储绝大多数综合器都能处理取模运算。有的朋友会说我不想用赋值语句我想用case或者if那也可以但可读性远不如直接写表达式。还有一点要注意如果你内部存储的是BCD码的年份比如2024年存成16位的0x2024直接对16位BCD取模是错的需要先做一个BCD转二进制的模块或者干脆内部年份就用二进制计数只在输出显示时转BCD。我在工程里一般选后者让计算路径最直接。3.3 月份天数表用case查表比一堆if else顺手每月的天数是固定的除了2月要跟着闰年走。这个地方用case写一个组合逻辑查表比写一堆if else清晰得多reg [4:0] days_of_month; always (*) begin case(month) 4d1, 4d3, 4d5, 4d7, 4d8, 4d10, 4d12: days_of_month 5d31; 4d4, 4d6, 4d9, 4d11: days_of_month 5d30; 4d2: days_of_month leap ? 5d29 : 5d28; default: days_of_month 5d30; endcase end注意这里的month表示的是1到12不是0到11。很多从C语言过来的人习惯把月份存成0到11然后在显示的时候再加1这么做不是不行但会在月份进位判断、查表、显示转换好几个地方引入1/-1的偏移非常容易出错。我的建议是日历内部就直接用1到12表示月份宁可查表多写两个case分支也不要在代码里到处做偏移运算。3.4 转BCD的两种思路查表与移位加3数码管的段码表是按0到9来编码的所以无论是秒、分、时还是日期显示之前都需要把数值转成十进制的十位和个位。这个转换有两种典型做法。第一种是直接除以10和取余数比如hour_bcd {4d0, hour / 10, hour % 10}代码最少但除法运算在FPGA里会被综合成比较复杂的组合逻辑或DSP算术块资源消耗不太可控。第二种是移位加3算法也叫“double dabble”是硬件领域非常经典的二进制转BCD方法综合后资源占用稳定更适合作为学习项目的一部分。8位二进制转12位BCD的移位加3代码套路如下reg [19:0] bin_bcd; integer i; always (*) begin bin_bcd {12d0, bin_data}; for(i 0; i 8; i i 1) begin if(bin_bcd[19:16] 4d5) bin_bcd[19:16] bin_bcd[19:16] 4d3; if(bin_bcd[15:12] 4d5) bin_bcd[15:12] bin_bcd[15:12] 4d3; if(bin_bcd[11:8] 4d5) bin_bcd[11:8] bin_bcd[11:8] 4d3; if(bin_bcd[7:4] 4d5) bin_bcd[7:4] bin_bcd[7:4] 4d3; bin_bcd bin_bcd 1; end end assign bcd_data bin_bcd[11:0];这个for循环在综合时会展开成组合逻辑所以它是可以综合的不是仿真专用语句。我第一次手写这段代码的时候总是忘记对每一个BCD位都要做“大于4就加3”的判断结果一转出来全是乱码。后来才真正想明白原理十进制数的每一位在BCD里只有4位当某一位数值超过4时再往左移位就会超过9提前加3就是为了纠正这个进位偏移。你在仿真里跑一遍就会对这个算法有非常直观的感受。3.5 万年历最容易错的角落除了闰年万年历还有几个经典的边界问题。跨月时1月31日23:59:59之后必须是2月1日00:00:00跨年时12月31日23:59:59之后必须是次年1月1日00:00:00闰年2月28日之后是2月29日平年2月28日之后直接是3月1日。这些边界少覆盖一个上板之后就可能在那个时刻“卡死”或者“跳变”。还有一个容易被忽略的点是星期几的计算。如果项目要求显示星期建议用一个单独的星期寄存器跟随日期自增遇到周日归零或复位。至于用蔡勒公式在FPGA里实时算星期虽然通用但组合逻辑链路会拉得比较长练手项目真没必要给自己加这个难度。4. 数码管动态扫描与按键消抖两个“看着简单但翻车率高”的模块4.1 动态扫描的原理和参数选择数码管的动态扫描原理就是利用人眼的视觉暂留在一段时间内轮流点亮每一位数码管让所有位看起来都是亮的。对于8位数码管扫描频率一般取1kHz到4kHz每一位停留的时间大约在0.125ms到1ms之间。太慢了会看到明显的闪烁太快了段选切换太频繁容易产生拖影或亮度不均。我实测下来1kHz到2kHz是一个比较舒服的区间。扫描逻辑的经典写法是每个扫描周期选中一位把该位对应的BCD数据锁存到段码寄存器同时拉低对应的位选信号。下面是个4位扫描的示意always (posedge clk_1k) begin if(!rst_n) begin scan_cnt 2d0; seg_data 8h00; sel_data 4b1111; end else begin scan_cnt scan_cnt 1b1; case(scan_cnt) 2d0: begin sel_data 4b1110; seg_data seg_disp[7:0]; end 2d1: begin sel_data 4b1101; seg_data seg_disp[15:8]; end 2d2: begin sel_data 4b1011; seg_data seg_disp[23:16]; end 2d3: begin sel_data 4b0111; seg_data seg_disp[31:24]; end endcase end end这里一个非常重要的细节是“消隐”在切换位选之前应该先把段选清掉输出全灭段码再切换位选然后才把下一位的段码输出。很多人忽略了这个顺序结果就是前一位的残影串到了下一位上数字边缘出现重影。这个问题的排查过程我后面还会详细说。4.2 共阴共阳、低电平还是高电平先查原理图网上流传的“数码管段码表”十有八九都是针对共阳数码管的。如果你的开发板是共阴的段码位全部要取反。位选也一样有的板子高电平选中有效有的低电平选中有效。所以拿到一块新板子第一件事绝对应该是打开原理图确认数码管是共阴还是共阳段选和位选是低电平驱动还是高电平驱动中间有没有三极管或ULN2803这类驱动芯片。驱动能力也是一个隐藏坑。直接用FPGA的GPIO去灌电流驱动多位共阳数码管IO口的灌电流能力可能不够导致数码管亮度不均或者变暗。很多现成的开发板会加上三极管驱动阵列但如果你是自己在面包板上搭电路务必在段选或位选线上加驱动电路否则上板后的表现会和仿真完全不是一个画风。4.3 按键消抖状态机比延时更加“FPGA思维”按键消抖的原理很简单按键在按下的瞬间机械触点会产生大约5ms到20ms的电平抖动需要在检测到电平变化之后等待一段时间再确认电平是否稳定。C语言里写个delay就行但FPGA里不能这么干——你不能让整个系统阻塞在一个延时循环里所以必须用状态机加计数器。我常用的消抖设计是一个简单的三态状态机空闲态检测到按键按下时进入“可能按下”状态并启动20ms计数器计时结束后再次采样如果仍然是按下电平就确认按键有效输出一个单周期脉冲并进入“等待释放”状态直到检测到释放才回到空闲态。这样做的好处是一次完整的“按下-释放”过程只会输出一个脉冲不会出现按一下时间猛跳好几秒的问题。调试的时候还有一个非常典型的坑在调时模式下如果按键消抖输出的脉冲和秒脉冲同时有效你刚按完一次加键下一秒进位又把数值改了。所以调时模式下必须通过一个模式信号把秒脉冲的自动进位屏蔽掉或者把秒脉冲在调节状态中暂停。很多万年历上板后“调好了但一运行又乱跳”问题就出在这里。5. testbench设计让万年历在仿真里“跑”过闰年5.1 一个能快速验证跨月跨年的testbench怎么写万年历如果按真实1Hz的时钟去仿真跑到下一个闰年需要上亿个时钟周期完全不现实。所以testbench里我一般会做一个技巧直接给日历核心提供一个比1Hz快很多的“秒脉冲”。比如在tb里用100Hz代替1Hz这样仿真跑几十秒就能覆盖到分钟、小时的进位要验证跨月和跨年可以再直接把日历核心的初值设成2024年2月28日23:59:57然后跑上几秒仿真时间观察是否正确地跳到了2月29日。initial begin clk 0; rst_n 0; #100 rst_n 1; // 手动初始化到2024-02-28 23:59:57 force u_calendar.bcd_year 16h2024; force u_calendar.bcd_month 8h02; force u_calendar.bcd_day 8h28; force u_calendar.bcd_hour 8h23; force u_calendar.bcd_min 8h59; force u_calendar.bcd_sec 8h57; end always #10 clk ~clk; // 50MHz这里用force来打初值比在RTL里写复位初值更方便因为复位之后你可以任意改变初始日期去测试不同的边界条件。等到功能验证通过之后再把这些force语句删掉恢复正常的复位上电流程。5.2 仿真时重点盯哪几个时刻我一般会列一个checklist每次做完万年历都对照着跑一遍23:59:59之后秒归零、分进一23:59:59且分和时都是最大值时日期11月31日23:59:59之后跳到2月1日而不是2月0日闰年2月28日23:59:59之后跳到2月29日平年2月28日23:59:59之后直接跳到3月1日12月31日23:59:59之后年1、月变1、日变1按键调节期间秒脉冲不能影响已调的值有一个仿真里特别常见的“误判”你看到波形的BCD输出是0x23就以为代码错了其实0x23在BCD里就是十进制23你的显示模块要的就是这个值。很多初学者把十六进制波形直接当成十进制来读白白浪费了很多排查时间。在ModelSim或Vivado的波形窗口里把数据格式改成Unsigned或Decimal再观察能省不少事。5.3 用文本打印把运行结果导出来对比波形看多了眼睛会花。对于万年历这种需要连续观察几百个周期行为的项目我强烈建议在testbench里加上文本打印把每个秒脉冲时刻的BCD时间打印到文件里然后和真实日历逐行对照。integer file; initial file $fopen(calendar_log.txt, w); always (posedge clk_1s) begin $fdisplay(file, %0t: %02d-%02d-%02d %02d:%02d:%02d, $time, year_bcd, month_bcd, day_bcd, hour_bcd, min_bcd, sec_bcd); end这样跑完一次仿真打开文本文件就能看到完整的“时间轴”哪一秒的进位没对一目了然。这个方法看起来土但在排查跨月、跨年这种低频偶发问题时比盯着波形图高效得多。6. 上板调试那些文档里不会写的硬件坑6.1 1Hz分频真的“准”吗先说一个很多新手不知道的事实计数器分频出来的1Hz精度完全取决于板载晶振。普通开发板上的有源晶振精度一般在几十ppm的量级换算下来一天偏差零点几秒到几秒都很正常。对万年历来说这个偏差完全可以接受开着跑一个月差个十几秒也无所谓。但如果你以后把这个方案扩展成精确计时器就会发现光靠计数器分频不行这时候要么换TCXO温补晶振要么干脆用RTC芯片做外部校准。我做过的一个计时项目就吃过这个亏只靠分频跑了七天慢了将近两分钟不是代码问题纯粹是晶振精度问题。6.2 下载后显示乱码、只亮一位、亮度不均怎么办上板之后出现问题先别怀疑代码逻辑按这个顺序排查现象最可能的原因排查方向显示乱码共阴共阳反了段码表不对查原理图换算段码只亮一位位选信号极性反了检查位选是低有效还是高有效明显闪烁扫描频率太低把扫描时钟从100Hz提到1kHz亮度不均驱动能力不足加三极管或更换限流电阻数字有重影位选切换前没有消隐先清段选再切位选再送数据我自己遇到过最诡异的一次是单独驱动每一位数码管都正常一进入扫描模式就重影。排查了很久最后发现是位选切换的瞬间段选仍然在输出前一位的码值导致有一个极短的瞬间点亮了错误位。虽然这个瞬间非常短但视觉上就是能看到拖影。解决方式就是我在4.1节强调过的先关段选再切位选再送新段码。6.3 时钟约束与综合警告要不要管万年历这种低速率工程不加时序约束多半也能跑起来但我不建议直接跳过。至少要先在工程里建好主时钟约束create_clock让综合工具和布局布线工具知道你的时钟频率是多少。这不是摆样子而是为了检查有没有严重的时序违例。如果某个路径真的违例了波形上可能表现为偶发的毛刺或跳变这种问题在仿真里根本看不见。另外综合报告里的latch推断警告一定不要忽略。出现latch往往意味着你的组合逻辑里漏写了一个else或default分支。万年历里最容易出这个问题的地方就是月份天数表和数码管段码表。看到latch警告就去查case语句十有八九能找到一个漏掉的分支。做万年历这个项目上板跑通只是第一步。能把每一项设计选择背后的“为什么”讲清楚把边界条件测到位把综合布线里的警告都看明白这个项目才算真正吃透了。而这套思维方法等到你后面去做图像处理、高速接口、嵌入式软核协同这些更复杂的FPGA项目时会发现全都是通用的。本文还有配套的精品资源点击获取