简介:EDA实训数字日历电路设计文档,面向数字电路、EDA技术及51单片机实训相关课程的学习者与参赛者,提供一套完整的数字日历实现方案。该设计基于EDA实训仪的I/O设备与PLD芯片,实现年、月、日、时、分、秒显示,并支持复位和校准操作,能帮助读者理解从计数器构建、年月日联动反馈到调时与显示切换的整体流程。文档包含总体设计思路、模块划分框图和分频器、控制端口、闹钟、按键调节、时分秒计数、年月日计数等核心模块的Verilog HDL代码,内容涉及60进制与24进制计数器、年月日反馈控制、K1K2调时与显示切换、8只数码管分屏显示等关键点,可直接作为课程设计或实训项目的参考。包体为1个doc文件,大小132KB,内容集中,便于查阅与复用。已有774人学习/下载,适合需要快速上手数字日历电路的EDA实践者。
1. EDA实训里数字日历电路设计到底在练什么
数字日历不是把闹钟搬到FPGA上,它是数字逻辑课程里最能检验“级联思想”的一个实训题目:秒、分、时、日、月、年,每一级都是不同模值的计数器,级与级之间通过进位使能耦合,还要处理大小月、闰年这些非标准周期。很多同学在Multisim里用74LS161搭过日历,一转到EDA工具(比如Quartus、Vivado、嘉立创EDA)写Verilog就懵了,主要卡在“时序到底怎么对齐”和“仿真波形怎么看”。这篇博文按我在实训里常用的做法,从原理拆分到Verilog实现,再到仿真上板,把数字日历电路的设计路径说全。适合刚学完Verilog语法、正在做EDA课程设计的人,也适合那些想从几百行例程里提炼出通用计数器架构的硬件工程师。
2. 数字日历的级联计数结构与时序参数
2.1 秒、分、时、日、月的模值不是拍脑袋定的
任何数字日历首先得有一个稳定的时钟基准,通常是50MHz晶振,经分频得到1Hz秒脉冲。有了秒脉冲后,整个日历就是一条计数器链:秒计数器模60,分钟计数器模60,小时计数器模24,日期计数器模28/29/30/31(由月份和闰年决定),月份计数器模12,年份计数器可以做成模100的两位循环。所有计数器在使能信号有效时递增,本级产生进位后把使能传给下一级。这种设计叫做“同步计数器链”,好处是每个计数器只在时钟边沿变化一次,不会出现异步进位导致的毛刺。
| 时间段 | 计数模值 | BCD码位宽 | 进位条件 |
|---|---|---|---|
| 秒 | 60 | 8 | 秒值达59且秒脉冲到来 |
| 分 | 60 | 8 | 分值达59且秒进位有效 |
| 时 | 24 | 8 | 小时值达23且分进位有效 |
| 日 | 28/29/30/31 | 8 | 日期达当前月最大天数且时进位有效 |
| 月 | 12 | 8 | 月份达12且日进位有效 |
| 年(两位) | 100 | 8 | 年份达99且月进位有效 |
这里日期模值是变化的,必须由“当前月份+是否闰年”动态计算。二月平年28天、闰年29天,大月31天,小月30天。这个计算会放在组合逻辑或时序逻辑里,对下一周期的日历产生最大天数约束。常见错误是只判断了月份没判断闰年,导致2100年这种非闰年也给了29天,虽然FPGA实训里通常不跑那么久,但设计规范必须写对。
2.2 用Verilog参数化计数器生成进位使能
计数器链的核心可以抽象成一个带模值的通用计数器模块。不同时间单位的差异只体现在模值上,所以完全可以用一个参数化模块生成所有计数器,避免把6段重复代码堆在顶层。下面是实训里我常用的模值计数器,它输出当前计数值和“进位使能”信号。
module mod_counter #( parameter MOD = 60, // 计数模值 parameter WIDTH = 6 // 二进制位宽,2^WIDTH >= MOD )( input wire clk, input wire rst_n, input wire en, // 输入使能,由上一级进位产生 output reg [WIDTH-1:0] q, // 当前计数值 output reg carry // 进位使能,高脉冲表示计数满 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin q <= {WIDTH{1'b0}}; carry <= 1'b0; end else if (en) begin if (q == MOD - 1) begin q <= {WIDTH{1'b0}}; carry <= 1'b1; // 只维持一个时钟周期 end else begin q <= q + 1'b1; carry <= 1'b0; end end else begin carry <= 1'b0; end end endmodule这个模块每次使能上升沿判断一次,产生一个周期的carry脉冲。下一级计数器把carry作为自己的en,就完成了级联。注意q == MOD - 1意味着从0计到MOD-1共MOD次,之后清零并进位。比如模60计数器,q从0变到59,第60个使能脉冲到来时q清零同时carry为高。使用同步复位的习惯要保留,实训中一定要把rst_n统一接入,否则复位释放时计数器状态可能不定。
2.3 日期的动态模值取法
日期计数器不能用固定参数MOD,因为它的模值取决于月份寄存器month和闰年标志。一般我会在顶层用两个寄存器:days_in_month(当月天数)和day_q(当前日期值)。每个时钟周期判断day_q是否等于days_in_month,相等且有时进位时,日期清零并给月进位。每月结束时更新days_in_month,这条逻辑用组合逻辑写出即可。下面的代码片段展示了如何从月份和闰年标志得到天数。
function automatic [4:0] get_days; input [3:0] month; // 1~12 input is_leap; case (month) 4'd1, 4'd3, 4'd5, 4'd7, 4'd8, 4'd10, 4'd12: get_days = 5'd31; 4'd4, 4'd6, 4'd9, 4'd11: get_days = 5'd30; 4'd2: get_days = is_leap ? 5'd29 : 5'd28; default: get_days = 5'd31; endcase endfunctionfunction在综合时会展开成组合逻辑,不额外占用寄存器。需要留意的是,调用该function的时机必须在月份寄存器已经稳定后,也就是“日清零并月加一”的那个时钟沿之后再用新月份计算,否则会出现一天时间的偏差。很多实训代码把days_in_month更新和日期递推放在同一个always块里,结果仿真时边界错位,这是很典型的时序位置问题。
3. 在EDA工具里用Verilog搭建数字日历顶层电路
3.1 模块划分与端口定义
设计数字日历电路,我习惯把顶层分成三块:时钟分频模块(产生1Hz秒脉冲)、时间日期计数模块(核心状态链)、BCD码输出模块(供数码管或上位机显示)。端口定义上,顶层输入只有两个系统信号(clk、rst_n)和可选校时按键,输出直接用BCD码总线。为了仿真方便,分频器会设计成参数化,仿真时可以改小分频系数,不必真的等50M个周期。
module digital_calendar_top ( input wire clk_50m, input wire rst_n, input wire key_hour_inc, // 可选校时 input wire key_min_inc, output wire [3:0] sec_lo, sec_hi, output wire [3:0] min_lo, min_hi, output wire [3:0] hr_lo, hr_hi, output wire [3:0] day_lo, day_hi, output wire [3:0] mon_lo, mon_hi, output wire [3:0] yr_lo, yr_hi );3.2 完整的分频、计数、日期调整流程
分频器可以写成一个简单的模值计数器,输入时钟为50MHz时分频系数为50_000_000,即计数到50_000_000-1翻转一次。需要注意分频得到的1Hz信号是周期信号,不能直接当作下一级计数器的使能,否则两个1Hz脉冲容易重叠。更稳妥的做法是产生一个单周期的高脉冲,用状态机检测上升沿。下面是我常用的同步分频器写法:
module clk_div_1hz #(parameter DIV_CNT = 50_000_000) ( input wire clk, input wire rst_n, output reg clk_1hz, output reg pulse_1hz ); reg [25:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 0; clk_1hz <= 1'b0; pulse_1hz <= 1'b0; end else if (cnt == DIV_CNT - 1) begin cnt <= 0; clk_1hz <= ~clk_1hz; pulse_1hz <= 1'b1; end else begin cnt <= cnt + 1'b1; pulse_1hz <= 1'b0; end end endmodule这段代码里pulse_1hz每个计数周期只高一个时钟周期,适合直接作为秒计数器使能。clk_1hz是为了查看波形方便而保留的分频时钟,理论上不应作为后续模块的时钟输入,否则会导致跨时钟域问题。秒计数器就是之前mod_counter模块的一个实例:mod_counter #(.MOD(60), .WIDTH(6)) u_sec(...)。分钟计数器、小时计数器同理,只是使能脚分别接秒、分的进位脉冲。
日期模块不能用参数化计数器直接例化,因为模值动态变化。我一般把日期、月、年三个计数器写在一个always块里,这样便于同时处理“跨月”、“跨年”和“闰年”逻辑。代码如下:
always @(posedge clk_1hz or negedge rst_n) begin if (!rst_n) begin day_q <= 4'd1; mon_q <= 4'd1; yr_q <= 4'd0; end else if (pulse_1hz && hr_carry) begin // hr_carry 是小时计数器的进位,也就是每天一次 days_in_month <= get_days(mon_q, leap_year_flag); if (day_q >= days_in_month) begin day_q <= 4'd1; if (mon_q == 4'd12) begin mon_q <= 4'd1; yr_q <= (yr_q == 4'd99) ? 4'd0 : yr_q + 1'b1; end else begin mon_q <= mon_q + 1'b1; end end else begin day_q <= day_q + 1'b1; end end end这里用clk_1hz作为日期的时钟,实际上日历核心逻辑依然在1Hz节拍下工作。days_in_month是寄存器,每月更新一次,当天数比较时它先于日期递推一个周期更新,因此判断条件用的是新月份的天数,逻辑正确。闰年标志leap_year_flag需要单独计算,标准规则是“能被4整除且不能被100整除,或能被400整除”。这个表达式可以用一行 assign 写出,避免在always块里嵌套过多。
3.3 BCD码转换与显示输出
计数器内部用二进制存储数值,而数码管或上位机通常需要BCD码。常见做法是不用转换电路,直接让计数器以BCD码形式递推,也就是每个十进制位独立计数。以秒为例,秒个位计到9后清零并给秒十位进位,秒十位计到5后和个数上的9一起给分钟进位。这样做的好处是显示简单,缺点是模值判断逻辑绕。我倾向于在顶层输出前加一个bin2bcd函数,把6位二进制转成两个4位BCD输出,逻辑更清晰。
function [7:0] bin2bcd; input [5:0] bin; integer i; begin bin2bcd = 8'b0; for (i = 0; i < 6; i = i + 1) begin bin2bcd = {bin2bcd[6:0], bin[5-i]}; if (i < 5 && bin2bcd[3:0] > 4) bin2bcd[3:0] = bin2bcd[3:0] + 3; if (i < 5 && bin2bcd[7:4] > 4) bin2bcd[7:4] = bin2bcd[7:4] + 3; end end endfunction这是“加3移位法”的组合逻辑实现,综合后占用少量查找表资源。在仿真里它能给出正确的BCD输出,但在时序报告里这根逻辑链会有点长,如果时钟频率是50MHz一般没问题,若工作时钟很高则可以改成流水线寄存器。上板展示时用扫描数码管显示这些BCD总线,注意每个字段需要单独锁存,否则刷新频率不够会看到闪烁。
4. 仿真激励设计与上板调试的必经之路
4.1 最小仿真验证:从秒到小时的时序
写testbench时不能真的模拟50M分频,否则仿真时间会非常长。通用做法是把顶层设计里分频器的参数用局部参数override掉,或者直接在实例化时用#传值。比如仿真时把DIV_CNT改成5,那么5个仿真时钟周期产生一个秒脉冲。下面是一段可用的testbench代码:
module digital_calendar_top_tb; reg clk_50m; reg rst_n; wire [3:0] sec_lo, sec_hi, min_lo, min_hi; wire [3:0] hr_lo, hr_hi; digital_calendar_top #(.DIV_CNT(10)) uut ( .clk_50m(clk_50m), .rst_n(rst_n), .sec_lo(sec_lo), .sec_hi(sec_hi), .min_lo(min_lo), .min_hi(min_hi), .hr_lo(hr_lo), .hr_hi(hr_hi) ); initial begin clk_50m = 0; forever #10 clk_50m = ~clk_50m; // 20ns周期,50MHz end initial begin rst_n = 0; #30 rst_n = 1; // 复位释放 end endmodule仿真时观察几个关键点:秒计数器每过DIV_CNT个时钟加一;秒从59跳变到0时分钟加一,且秒进位carry只持续一个周期;分钟从59跳变到0时小时加一;小时从23跳变到0时日期加一。这三个时间节点的波形需要对照着时钟沿检查,尤其是carry信号的宽度。如果发现秒的carry持续时间过长,比如两个时钟周期,就会导致分钟跳两秒,这通常是因为没有在mod_counter里对carry做复位或清零。
4.2 用断言检查闰年和大小月
仿真时序正确性最让人头疼的点是日期边界。手工查看波形太慢,可以在testbench里加入简单的断言宏,用$error在错误时刻报出来。实训中我给你一个常用的“检查日期有效边界”方法:额外拉出一个参考日历模块(用系统任务或脚本生成),将待测模块的日期输出与参考模型比对。下面是用SystemVerilog断言检查日期连续性的例子:
// 假设 u_cal.day_q, u_cal.mon_q 是待测模块输出 property day_increase; @(posedge clk_1hz) (hr_carry) |=> (day_q != $past(day_q) || mon_q != $past(mon_q)); endproperty day_assert: assert property(day_increase);这里信号名称要根据实际波形树调整,重点是“小时进位之后日期必须变化”。对于闰年测试,可以把初始时间改到2月27日,运行两天看是否跨到2月29日。更简单的办法是直接改yr_q寄存器的初值,在testbench中通过层次引用强制赋值来制造特定初始状态,比如令uut.u_cal.year_q = 4'd3(代表2003年),验证二月天数。
4.3 上板后的时序约束与信号实测
把设计下载到FPGA实训板前,必须正确设置时钟约束,否则时序分析器会认为50MHz时钟没有约束,很容易出现不明原因的错误。常见EDA工具里添加约束的命令如下:
create_clock -period 20.000 -name sys_clk [get_ports clk_50m] set_clock_groups -asynchronous -group [get_clocks {sys_clk}]实际上对单时钟系统,第一行就够了。create_clock的period 20对应50MHz。之后运行Analysis & Synthesis,再运行TimeQuest Timing Analyzer(Quartus)或Vivado的report_timing_summary,看到满足约束即可。上板后如果发现秒走时快或慢,可以检查晶振频率和分频参数:有些实训板的晶振不是50MHz而是12MHz或27MHz,要如实修改分频系数,否则日期跳变节奏全错。
按键校时是另一个容易出问题的点。直接接在使能端上的按键会引入机械抖动,一次按下可能产生多个脉冲。实训中常见的做法是给按键信号做边沿检测,并加防抖计数器。下面是防抖后的按键单脉冲产生代码,可直接接在“小时加一”使能端上:
reg [19:0] cnt; reg key_reg, key_rr; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_reg <= 1'b1; end else begin if (key_in != key_reg) cnt <= cnt + 1'b1; else cnt <= 20'd0; if (cnt == 20'd999999) key_rr <= key_in; // 稳定后的按键值 key_reg <= key_in; end end wire key_pulse = key_reg & ~key_rr; // 下降沿脉冲,假设按键按下为低这个防抖逻辑用两个寄存器做同步和边沿检测,计数器只在按键状态变化时开始累计,累计满约20ms才认为真正按下。代码里key_reg和key_rr分别应该分级处理,实际使用时还需要把key_pulse同步到时钟域。仿真时看不到真实抖动,但上板效果立竿见影。
5. 三个必调的进阶参数:进位脉冲宽度、复位初值、校时优先级
调试数字日历到了最后,真正决定“能不能用”的是三个细节参数。第一个是进位脉冲宽度,前面反复提到必须严格单周期。如果你在mod_counter里用了begin q<=0; carry<=1; end,那么当使能持续高电平时,carry会在清零后的下一个周期自动拉低,很好。如果把carry写成电平信号,就会造成多级计数器同时接收持续使能,数据瞬间乱掉。检查方法非常简单:在仿真波形里测量carry信号的高电平长度是否只有一个时钟周期,如果大于一个周期,就改成“先清零再在下一周期拉低”的逻辑。
第二个参数是复位后的初值。实训板复位上电后,数字日历通常希望恢复到“2024年1月1日00:00:00”,而不是全零。对于BCD计数器,全零的月份、日期都是非法值。修改方式是在复位always块里给寄存器赋枚举初值:
if (!rst_n) begin sec_q <= 0; min_q <= 0; hr_q <= 0; day_q <= 4'd1; mon_q <= 4'd1; yr_q <= 4'd24; end这里yr_q如果你设成8位可以存2024,但为了省资源往往只存两位,就设成4'd24代表“24年”。无论哪种,都要保证days_in_month在复位时也被置成31,否则首个“日加一”的时刻会拿未初始化的天数做判断。
第三个参数是校时功能与自动计数的优先级。在校时模式下,秒脉冲使能应该被屏蔽,否则你正按着小时键,下一秒脉冲又把分钟推进去。推荐的做法是做一个adjust_en信号,在校时状态下把日历链的使能全部强制为0,只允许按键脉冲进入小时或分钟计数器。下面的接口时序能直观看出优先级:
| 工作模式 | 秒脉冲使能 | 按键小时/分钟 | 结果 |
|---|---|---|---|
| 正常走时 | 有效 | 关闭 | 每秒走时 |
| 调小时 | 屏蔽 | 有效 | 小时快速加一 |
| 调分钟 | 屏蔽 | 有效 | 分钟快速加一 |
这个模式切换可以用一个状态机的idle和adjust两个状态完成。调整结束后,不要忘了用内部的adjust_done信号把days_in_month强制更新一遍,否则你手动把日期调到3月时,日期计数器还认为当前是2月,天数判断会错。最后,给按键加一条约束:任意时刻只能有一个按键生效,不要让小时和分钟键同时按下。窄脉冲按键在高速时钟下可能被采到,再叠加防抖逻辑后通常能满足实训课的验收要求。
本文还有配套的精品资源,点击获取