简介这份面向FPGA与Verilog初学者的数字钟工程包围绕计时、跑表、闹钟和时间调整四个功能展开适合用于课程设计、自学练手与实验复现。设计以Verilog硬件描述语言实现涉及时钟分频、计时寄存器、数码管显示驱动、按键输入处理、闹钟比较与跑表控制等典型时序逻辑模块能帮助读者把语法知识落到可综合的电路结构上。压缩包共145个文件、约7.82MB包含Verilog源码、VWF仿真波形、QSF引脚约束、QPF工程文件、SOF下载配置及综合/布局布线报告等另有CDB、HDB、RPT等Quartus中间与报告文件便于查看编译流程和工程组织。已有418人学习下载。源码注释较详细配合波形与工程配置可较快理解各模块接口和运行过程尤其适合需要理解分频计数、显示译码和按键消抖的入门者也方便在此基础上改造成个人课程设计或扩展秒表、闹钟等功能。1. 一块 50 MHz 的板子能做多准Verilog 数字钟的真实边界很多人第一次做 FPGA 数字钟卡住的不是写不出计数器而是上板之后发现一天慢了两分钟、数码管总有一位比别的暗、按键按一下时间跳三位。这份用 Verilog 写的数字钟资源围绕的就是这些真问题一颗 50 MHz 晶振做时基纯计数器搭出时/分/秒的 BCD 计时链动态扫描驱动 8 位数码管状态机做按键消抖与校时全部行为级可综合不依赖任何 IP 核放到入门级开发板上就能跑。适合两类人正在学时序逻辑、想找一个把分频、计数、状态机、扫描串起来练手的学生以及想做一个不带单片机、纯逻辑实现的小作品、并且希望代码结构能直接改的工程师。下面按模块切开讲参数怎么算、哪里会翻车都写清楚。2. 顶层架构先切三刀分频、计时、显示怎么分模块拿到需求第一件事不是敲代码是决定时钟怎么走、状态放在哪、模块边界划在哪。数字钟这个题目看着简单架构选错后面全是补丁为了省事用分频出来的 1 Hz 当第二个时钟综合工具给你报一堆门控时钟警告时序约束写不出来仿真对得上、上板偶尔抽风。所以先把这个骨架定死再往里填肉。2.1 全设计只留一个时钟域1 Hz 只做使能分频有两种写法。第一种是把 50 MHz 分频成 1 Hz 方波然后拿这根 1 Hz 信号去当 always 块的时钟沿第二种是分频器输出一个宽度等于一个系统时钟周期的脉冲接到所有下级模块的 en 端口全设计仍然跑在 50 MHz 上。前者看起来省端口实际上是给自己挖坑这根 1 Hz 信号走的是普通布线资源到各个寄存器的延迟不一样偏差能到几个纳秒工具做时序分析时对派生时钟的支持也要你手动补约束更要命的是它天然引入了第二个时钟域跨域问题得自己兜。我一般会强制走第二种。代价是所有下游模块都得带一个 en 输入代码会长一点但换来的是单一时钟域静态时序分析只针对 50 MHz 一条路径不需要任何跨域处理也不用担心时钟树偏差。这个思路在 FPGA 开发里几乎是默认做法只要你不是非要用分频时钟去省功耗就别碰它。这里有个细节值得提前说秒脉冲必须是「一个周期宽的高电平」而不是「占空比 50% 的方波」。如果输出方波下游用上升沿做 en 看起来也能跑但方波高电平持续半秒任何把 en 当电平用的逻辑都会连加几十万次这类错误在波形图上一眼能看出来上板就是时间疯跑。2.2 模块划分与端口约定按数据流切一共六个模块边界清楚每个都能单独仿真模块职责关键参数/接口clk_div_1hz50 MHz 分频出秒脉冲CNT_MAX 49_999_999输出 tick_1sbcd_counter_60秒、分 BCD 计数模 60en 使能load/load_val 校时装载carry 进位bcd_counter_24小时 BCD 计数模 24en 使能load/load_val 校时装载key_debounce按键同步 20 ms 消抖输出单周期 key_pulseseg_scan8 位数码管动态扫描SCAN_DIV 决定刷新率1 kHzset_ctrl校时状态机三状态运行 / 调时 / 调分端口约定上有两条硬规矩。第一所有模块复位统一低有效名字统一 rst_n别一半高有效一半低有效接顶层时漏一根就是几十行排查。第二跨模块传递的时间值一律用 BCD 码8 位一个字节高 4 位十位、低 4 位个位这样 seg_scan 拿到数据直接查段码表不需要任何除法或取模运算。顶层只做例化和连线不写任何逻辑这条守住了后面改功能才不痛苦。module digital_clock_top ( input wire clk_50m, // 板载 50 MHz 晶振 input wire rst_n, // 低有效复位 input wire [1:0] key, // key[0] 模式键, key[1] 加键 output wire [7:0] seg_sel, // 数码管位选低有效 output wire [7:0] seg_code // 数码管段码低有效 ); wire tick_1s; wire [7:0] sec_bcd, min_bcd, hour_bcd; wire sec_carry, min_carry; wire [1:0] set_sel; wire hour_load, min_load; wire [7:0] hour_val, min_val; wire key_mode_p, key_up_p; // 时间显示缓冲区{小时, 分钟, 秒} 拼成 32 位高 8 位在左 wire [31:0] seg_data {8h00, hour_bcd, min_bcd, sec_bcd}; clk_div_1hz u_div ( .clk (clk_50m), .rst_n (rst_n), .tick_1hz (tick_1s) ); bcd_counter_60 u_sec ( .clk (clk_50m), .rst_n (rst_n), .en (tick_1s), .load (1b0), .load_val (8h00), .bcd (sec_bcd), .carry (sec_carry) ); bcd_counter_60 u_min ( .clk (clk_50m), .rst_n (rst_n), .en (sec_carry), // 秒进位驱动分计数 .load (min_load), .load_val (min_val), .bcd (min_bcd), .carry (min_carry) ); bcd_counter_24 u_hour ( .clk (clk_50m), .rst_n (rst_n), .en (min_carry), .load (hour_load), .load_val (hour_val), .bcd (hour_bcd) ); // ... 按键消抖、校时状态机、数码管扫描的例化略 endmodule这段顶层代码里最需要注意的是 u_min 的 en 接的是 sec_carry 而不是 tick_1s。秒计数器在 59 秒那一个 tick 上拉高 carry同一拍分计数器就收到使能下一秒两者同时归零/加一时间才连贯。如果这里接错表现是每小时少一分钟而且仿真时间短了根本发现不了属于典型的血泪经验。2.3 复位异步复位、同步释放入门代码里常见always (posedge clk or negedge rst_n)直接异步复位所有寄存器功能上没毛病但复位释放的那一刻如果刚好靠近时钟上升沿不同寄存器可能采到不同的复位状态出现「复位后有的计数器是 0、有的是 1」的玄学现象。工程上的标准做法是异步复位同步释放复位信号异步拉低保证立即生效释放时用两级触发器同步到时钟域。module reset_sync ( input wire clk, input wire rst_async_n, // 外部按键复位低有效 output wire rst_sync_n // 同步释放后的复位 ); reg rst_meta, rst_sync; always (posedge clk or negedge rst_async_n) begin if (!rst_async_n) begin rst_meta 1b0; rst_sync 1b0; end else begin rst_meta 1b1; rst_sync rst_meta; end end assign rst_sync_n rst_sync; endmodule两级触发器的作用是把释放沿推迟两个时钟周期等所有寄存器都看到稳定的高电平再一起退出复位。这里 rst_meta 是第一级理论上仍可能有亚稳态但第二级采样时它已经稳定所以下游拿到的 rst_sync_n 是干净的。参数上没什么可调的唯一要注意的是这个模块本身必须放在顶层最前面所有子模块都用它的输出别有的模块用原始复位、有的用同步复位那等于没做。3. 秒脉冲与 BCD 计数器走时精度的源头在这里时间准不准全看这一章。数字钟的误差来源只有两个分频常数算错或者进位链在某个边界上多算/少算一拍。前者是算术问题后者是时序问题两个都不难但都得在写代码之前想清楚否则后面只能靠上板盯着手表对时间来反推。3.1 50 MHz 到 1 Hz位宽、常数和那一拍的差别50 MHz 意味着每秒 50 000 000 个时钟周期。计数器从 0 数到 49 999 999 正好是 1 秒所以常数是 49_999_999位宽取多少2^25 33 554 432 不够2^26 67 108 864 够所以是 26 位。这个计算别嫌啰嗦我见过把 26 位写成 25 位、结果计数器到 33 554 431 就回绕、时间快了将近一半的案例而且因为 Verilog 位宽截断不报错综合也不报错只能靠上板发现。module clk_div_1hz #( parameter integer CLK_FREQ 50_000_000, // 板载晶振频率 parameter integer SIM_FAST 0 // 1 仿真加速模式 )( input wire clk, input wire rst_n, output reg tick_1hz ); // 仿真时把 1 秒压到 20 个时钟跑 24 小时只要几百微秒 localparam integer CNT_MAX (SIM_FAST 1) ? 19 : (CLK_FREQ - 1); localparam integer WIDTH (SIM_FAST 1) ? 5 : 26; reg [WIDTH-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {WIDTH{1b0}}; tick_1hz 1b0; end else if (cnt CNT_MAX) begin cnt {WIDTH{1b0}}; tick_1hz 1b1; // 只高一个时钟周期 end else begin cnt cnt 1b1; tick_1hz 1b0; end end endmodule逻辑很直白计数到顶归零同时输出一个周期的高电平。这里cnt CNT_MAX用的是「数到 49 999 999 就归零」也就是每 50 000 000 个周期一个脉冲正好 1 秒如果写成cnt CNT_MAX - 1就变成 49 999 999 个周期一个脉冲一天下来快 1.7 秒左右短时间看不出来。参数 SIM_FAST 是给仿真留的后门第 6 章会用到综合时它会被常数折叠掉不占资源。还有一点位宽 WIDTH 在仿真模式下是 5 位是因为 CNT_MAX 只有 19这属于参数化写法的一个副作用实际综合时如果 SIM_FAST 是 0工具会把 26 位的分支保留下来逻辑上没问题但代码里混着两种位宽读起来要留神。稳妥的做法是给两个模式各写一个 generate 分支不过对这个规模的设计参数化已经够用。3.2 六十进制与二十四进制为什么用 BCD 而不是二进制计时核心用 BCD 计数也就是高 4 位存十位、低 4 位存个位好处是显示模块拿到数据直接查表不需要二进制转 BCD 的除法电路。有人会问二进制计数到 59 再转 BCD 不是更省触发器吗确实省59 用 6 位就够BCD 要 8 位。但转换电路要么走 double-dabble组合逻辑深对时序不友好要么在显示前加一级流水代码复杂度上去了省的触发器在 FPGA 里本来也不值钱。做小时钟以可读性和可调试性优先我选 BCD。BCD 计数的关键在边界判断个位到 9 就清零、十位加一十位到 5 且个位到 9也就是 59就整体清零并输出进位。写的时候要避免「个位清零」和「十位判断」写在同一拍里互相覆盖。module bcd_counter_60 ( input wire clk, input wire rst_n, input wire en, // 计数使能宽度一个时钟 input wire load, // 校时装载优先级高于 en input wire [7:0] load_val, // BCD 装载值 output reg [7:0] bcd, // {十位, 个位} output reg carry // 由 59 回 00 时拉高一个周期 ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin bcd 8h00; carry 1b0; end else if (load) begin bcd load_val; carry 1b0; end else if (en) begin if (bcd[3:0] 4d9) begin bcd[3:0] 4d0; if (bcd[7:4] 4d5) begin bcd[7:4] 4d0; carry 1b1; // 59 - 00进位 end else begin bcd[7:4] bcd[7:4] 1b1; carry 1b0; end end else begin bcd[3:0] bcd[3:0] 1b1; carry 1b0; end end else begin carry 1b0; end end endmodule逻辑上分三层load 最高优先校时时直接覆盖计数值其次是 en 有效时的正常计数en 无效时只把 carry 清掉。load 放在最前面是因为校时那一刻如果按键和秒脉冲撞上我们希望看到的是用户设置的值而不是被计数覆盖。carry 在 59 回 00 的那一拍拉高下一拍自动清零宽度正好一个时钟周期可以直接当下一级计数器的使能。小时的 24 进制结构和这个一样只是边界判断变成「个位 9 且十位 2即 23时清零」。为了省代码可以写成一个带参数的通用模块把模值和边界值做成 parameter但 24 这个模值不是整十参数化后边界判断还是得单独写反而更难读。实际项目里我倾向于写两个模块六十进制一个、二十四进制一个各三十行改起来一目了然。3.3 进位链carry 是组合量不能打拍这是整篇里最容易翻车的地方。上一节代码里 carry 是寄存器输出看起来在 59 那拍拉高实际上因为它是时序逻辑的输出在 59 这个状态被观测到的那一拍carry 还没拉高要到下一个时钟沿才高。如果下一级分计数器写成en sec_carry tick_1s那就完蛋了下一个时钟沿 tick_1s 已经回到 0分永远加不上一小时少一分钟。两种修法。第一种是把使能信号改成组合逻辑秒计数器的当前值等于 59 且 tick_1s 为高时直接组合出一个 min_en 送给分计数器同一拍就生效不需要 carry 端口。第二种是保留 carry但下一级的使能只接 carry、不再与 tick 相与这样分计数器会比秒晚一个时钟周期加一视觉上看不出来逻辑上也没错。// 写法 A组合使能同一拍生效推荐 wire min_en tick_1s (sec_bcd 8h59); // 写法 B用 carry但不能再与 tick 相与 // wire min_en sec_carry;我一般用写法 A。理由是同拍生效的时序关系最直观仿真波形上秒和分同时跳变一眼能看出对不对写法 B 虽然也能工作但「分比秒晚 20 ns」这个事实不写在注释里三个月后自己都得重新推一遍。写法 A 的代价是多了一个比较器8 位比较在 FPGA 里就是几个 LUT可以忽略。小时计数器的使能同理用min_en (min_bcd 8h59)组合出来。整条链上不存在任何寄存器延迟秒到分到时三者的跳变在同一拍完成时间刻度才是连续的。4. 按键消抖与数码管扫描两个高频翻车点时序逻辑写对了时钟也准了接下来就是跟物理世界打交道。按键是机械触点按下和松开各有几毫秒的抖动数码管是动态扫描本质上是在骗眼睛。这两个模块的代码都不长但坑的密度比计数器高得多因为它们依赖的是板级电气特性而不是纯逻辑。4.1 按键消抖先同步再计数最后出单脉冲按键引脚是异步输入直接进状态机等于让亚稳态去决定状态跳转。所以第一件事永远是两级触发器同步。同步完之后再消抖常见做法是「用计数器连续采样 N 次都一致才认」N 对应 20 ms 左右的稳定时间。为什么是 20 ms 而不是 5 ms 或者 50 ms机械按键的抖动时间典型值在 5 到 10 ms取 20 ms 留一倍余量再长的话快速连按会感觉迟钝操作校时的时候尤其明显。module key_debounce #( parameter integer CLK_FREQ 50_000_000, parameter integer DELAY_MS 20 )( input wire clk, input wire rst_n, input wire key_in, // 原始引脚低有效 output reg key_pulse // 稳定按下时输出一个时钟宽的脉冲 ); localparam integer CNT_MAX CLK_FREQ / 1000 * DELAY_MS; // 1_000_000 reg key_sync0, key_sync1; // 两级同步 reg key_stable; // 消抖后的稳定值 reg [20:0] cnt; // 2^20 1e6 2^21 always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_sync0 1b1; key_sync1 1b1; key_stable 1b1; cnt 21d0; key_pulse 1b0; end else begin key_sync0 key_in; key_sync1 key_sync0; key_pulse 1b0; // 默认拉低保证单周期 if (key_sync1 ! key_stable) begin if (cnt CNT_MAX - 1) begin // 连续 20 ms 保持一致 cnt 21d0; key_stable key_sync1; if (key_sync1 1b0) // 只在按下时出脉冲 key_pulse 1b1; end else begin cnt cnt 1b1; end end else begin cnt 21d0; // 中途抖回去就重新计时 end end end endmodule这段的核心是「不一致就累加、一致就清零」只要 key_sync1 和当前稳定值不同计数器就往上涨涨满 20 ms 才认定状态真的变了中途任何一次采样又变回去计数器归零重来。这样所有抖动窗口都被吃掉只有真正稳定按下才产生脉冲。位宽选 21 位是因为 CNT_MAX 是 1 000 0002^20 1 048 576 其实够用但边界值是 CNT_MAX-1踩在 20 位的最大值附近容易出问题多给一位不心疼。参数 DELAY_MS 是唯一的调节旋钮。如果你的板子按键手感偏硬、弹片回弹慢可以调到 30如果做的是需要快速连加的场景可以压到 10但不要低于 5否则抖动会漏过去。另外注意 key_pulse 只在按下沿产生如果业务上需要「长按连加」得另外加一个按住计时的逻辑别指望消抖模块兼做这个。4.2 动态扫描刷新率、位选极性和消隐8 位数码管共用一个段码总线靠位选快速轮流点亮利用视觉暂留看起来是同时亮的。两个参数决定效果扫描频率和每位停留时间。整屏刷新率低于 50 Hz 会看到闪烁太高则亮度不够、还浪费动态功耗我一般取 1 kHz 整屏刷新也就是 8 位每位 125 Hz既无闪烁也够亮。分频常数就是 50 MHz / 1 kHz 50 00016 位计数器足够。极性和消隐是真正的坑。位选低有效还是高有效取决于板上有没有三极管驱动、是 PNP 还是 NPN写代码前必须看原理图猜错了就是全灭或者只亮一位。消隐的意思是切换位选的那一拍先把段码全部关掉下一个周期再送新数据否则上一位的段码会在新一位上残留一瞬间看起来就是数字重影。module seg_scan #( parameter integer SCAN_DIV 50_000 // 50 MHz / 50 kHz 1 kHz 分频步进 )( input wire clk, input wire rst_n, input wire [31:0] seg_data, // 8 个 BCD 数字高 4 位对应最左一位 output reg [7:0] seg_sel, // 位选低有效 output reg [7:0] seg_code // 段码 {dp,g,f,e,d,c,b,a}低有效 ); reg [15:0] div_cnt; reg [2:0] scan_idx; wire [3:0] cur_bcd seg_data[{scan_idx, 2b00} : 4]; reg [7:0] seg_nxt; // 扫描节拍每 SCAN_DIV 个时钟换一位 always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 16d0; scan_idx 3d0; end else if (div_cnt SCAN_DIV - 1) begin div_cnt 16d0; scan_idx scan_idx 1b1; end else begin div_cnt div_cnt 1b1; end end // 段码查表共阳接法 低电平点亮 always (*) begin case (cur_bcd) 4d0: seg_nxt 8hC0; 4d1: seg_nxt 8hF9; 4d2: seg_nxt 8hA4; 4d3: seg_nxt 8hB0; 4d4: seg_nxt 8h99; 4d5: seg_nxt 8h92; 4d6: seg_nxt 8h82; 4d7: seg_nxt 8hF8; 4d8: seg_nxt 8h80; 4d9: seg_nxt 8h90; default: seg_nxt 8hFF; // 非数字一律熄灭 endcase end // 位选切换与段码输出中间隔一拍实现消隐 always (posedge clk or negedge rst_n) begin if (!rst_n) begin seg_sel 8hFF; seg_code 8hFF; end else if (div_cnt SCAN_DIV - 1) begin seg_sel ~(8h01 scan_idx); // 只有当前位拉低 seg_code 8hFF; // 本拍全灭消隐 end else if (div_cnt 16d1) begin seg_code seg_nxt; // 下一拍再给真实段码 end end endmodule参数 SCAN_DIV 直接决定亮度值越大每位停留越久、看起来越亮但整屏刷新率越低超过 20 000对应 8 位整屏约 300 Hz就开始有点闪。段码表是共阳低有效的编码如果你的板子是共阴把整张表按位取反即可不用改逻辑。{scan_idx, 2b00}等价于 scan_idx 乘 4用来定位当前位在 32 位数据里的位置这是 Verilog 里比较紧凑的写法综合出来就是几个连线。消隐那两拍是刻意错开的div_cnt 到顶时切位选并熄灭再走两个时钟才段码。中间这两个时钟40 ns远小于人眼感知但足够让上一位的残余电荷散掉重影基本消失。4.3 校时状态机把按键映射成「改哪个计数器」校时逻辑本质是个三状态机运行、调时、调分。模式键每按一次循环切换加键只在调时/调分状态有效。这里有个隐蔽的坑加键的动作是「读当前值、加一、写回去」如果读和写跨越了不同的时钟沿可能出现用旧值覆盖新值的情况。安全写法是在同一拍里算出装载值并拉高 load目标计数器下一拍采到。module set_ctrl ( input wire clk, input wire rst_n, input wire key_mode_p, // 模式键单周期脉冲 input wire key_up_p, // 加键单周期脉冲 input wire [7:0] hour_cur, // 当前小时 BCD input wire [7:0] min_cur, // 当前分钟 BCD output reg [1:0] set_sel, // 0 运行 / 1 调时 / 2 调分 output reg hour_load, output reg min_load, output reg [7:0] hour_val, output reg [7:0] min_val ); // 模式循环运行 - 调时 - 调分 - 运行 always (posedge clk or negedge rst_n) begin if (!rst_n) set_sel 2d0; else if (key_mode_p) set_sel (set_sel 2d2) ? 2d0 : set_sel 1b1; end // 加键同拍算出装载值并置 load目标计数器下一拍生效 always (posedge clk or negedge rst_n) begin if (!rst_n) begin hour_load 1b0; min_load 1b0; hour_val 8h00; min_val 8h00; end else begin hour_load 1b0; min_load 1b0; if (key_up_p set_sel 2d1) begin hour_load 1b1; hour_val (hour_cur 8h23) ? 8h00 : bcd_inc(hour_cur); end else if (key_up_p set_sel 2d2) begin min_load 1b1; min_val (min_cur 8h59) ? 8h00 : bcd_inc(min_cur); end end end // BCD 加一保留十位结构 function [7:0] bcd_inc; input [7:0] v; begin if (v[3:0] 4d9) bcd_inc {v[7:4] 4d1, 4d0}; else bcd_inc {v[7:4], v[3:0] 4d1}; end end endmodule有几个参数和边界要盯住。set_sel 用 2 位编码状态 0 是正常运行此时两个 load 都不会拉高计数器照常走时状态 1 和 2 下调时/调分秒计数器始终不受影响这是刻意的设计——校时过程中秒继续走符合真实钟表的手感。bcd_inc 里判断 23 和 59 后回绕到 0避免了「调时调到 24 点」这种非法值。注意 hour_cur 是当前计数器的输出装载发生在下一拍所以如果用户连按两次且间隔极短小于两个时钟周期第二次可能读到旧值。实际使用中按键经过 20 ms 消抖两键间隔至少在毫秒级这个窗口不可能撞上所以不需要额外加互锁。但如果做的是旋钮编码器输入这个假设就不成立了得改成在 set_ctrl 内部维护一个影子寄存器。5. 避坑排查仿真通过但上板不亮的五类现场记录仿真波形干净、综合零警告、下载之后没反应这是 FPGA 开发最经典的落差。下面五条都是实打实踩过的按「现象 → 原因 → 解决」记录遇到时直接对照。第一类数码管全灭或者只有最左一位亮着。现象程序下载后数码管毫无反应测位选引脚有电平变化但段码一直是高。 原因位选极性搞反。代码里写的是低有效板上用 PNP 三极管驱动基极低电平导通逻辑上是匹配的但如果原理图里位选直接接共阳数码管的公共端、中间没有驱动低电平反而全灭。反过来共阴数码管配低有效位选也会出现同样现象。 解决先断开段码只让位选循环拉低用万用表量每一位的公共端电压确认哪种电平点亮。确认之后改一行代码即可不要试图用「多试几次」的方式蒙。段码同理共阴共阳的编码表是整张取反的关系不要只改几个数字。第二类时间走得快一倍或慢一倍。现象上板跑十分钟对比电脑时间差了整整一倍不是随机漂移而是稳定倍率。 原因分频常数按 25 MHz 算的。有些入门板晶振是 25 MHz 或者 100 MHz代码里默认写了 50 MHz 的 49_999_999实际时钟只有一半秒就变成两秒。另一种原因是把 tick_1hz 既当使能又拿去当某个模块的时钟导致同一个事件被计了两次。 解决先查板子原理图上的晶振频率把 CLK_FREQ 参数改对再全局搜索一遍有没有always (posedge tick_1hz)这种写法有就改成 en 端口。倍率误差不会自己出现一定是常数或者使能路径的问题别怀疑晶振精度。第三类按键按一下时间跳了三位。现象校时的时候手一碰小时数直接加了三慢慢按又正常。 原因消抖模块只做了同步和滤波但下游用的是原始引脚做边沿检测或者用的是消抖后的电平而不是单周期脉冲。抖动期间电平多次跳变每次都触发一次加一。 解决按键模块只输出 key_pulse 单周期脉冲下游所有模块禁止直接引用 key_in。检查方法很简单把 key_pulse 接到一个 LED 上按一次应该只闪一下闪多下就是脉冲没做干净。另外确认 key_pulse 的默认赋值在 always 块最前面漏了会综合出锁存器。第四类数码管有重影相邻两位的数字叠在一起。现象本该显示 12:34:56结果「1」和「2」的边缘糊在一起亮度越高越明显。 原因位选切换和段码更新在同一拍完成。上一位的段码还没撤掉新一位的公共端已经拉低电荷残留导致本该熄灭的段微亮。 解决按 4.2 节的写法切位选的那一拍把 seg_code 全置高熄灭隔一到两拍再给新段码。如果重影还在把消隐时间拉长到一个完整的扫描周期也就是位选切换后先灭一整个 div 周期代价是亮度略降但绝对不会再有残影。第五类校时之后进位错乱分钟跳到 60 之后不回零。现象手动把分钟调到 59等一分钟过去显示变成了 60 而不是 00。 原因装载优先级没定死。校时的 load 和正常的 en 都作用在同一个 always 块里如果 load 写在 en 后面或者两个分支对同一个寄存器都赋值综合工具会按最后一条执行导致装载值和计数值打架。还有一种情况是 carry 在装载那一拍恰好为高被下一级误采。 解决把 load 放在 if-else 链的最前面并在 load 分支里显式把 carry 清零。装载值本身要做边界检查别让用户能设出 60 分或者 24 时。改完之后用仿真跑一遍「设到 59 分、等进位」的用例波形上应该看到 59 直接跳 00 并进位。6. 从仿真到上板把 24 小时压进 1 毫秒的验证习惯数字钟这类慢速逻辑最大的麻烦是验证周期。真按 1 Hz 跑仿真看一次小时进位要等 3600 秒的仿真时间波形文件几个 G跑完一次咖啡都凉了。所以我把分频常数做成了参数仿真时用一个 SIM_FAST 开关把 1 秒压到 20 个时钟周期整套 24 小时逻辑跑完不到 1 毫秒仿真时间。timescale 1ns / 1ps module tb_digital_clock; reg clk 1b0; reg rst_n 1b0; reg [1:0] key 2b11; // 按键低有效空闲为高 always #10 clk ~clk; // 50 MHz周期 20 ns // SIM_FAST 1秒脉冲变成 20 个时钟跑一天只要 1.7 ms 仿真时间 digital_clock_top #( .SIM_FAST (1) ) dut ( .clk_50m (clk), .rst_n (rst_n), .key (key), .seg_sel (), .seg_code () ); initial begin #100 rst_n 1b1; // 覆盖 23:59:5x 到 00:00:0x 的跨天进位 force dut.hour_bcd 8h23; force dut.min_bcd 8h59; force dut.sec_bcd 8h55; #2000 release dut.hour_bcd; release dut.min_bcd; release dut.sec_bcd; #5000 $finish; end endmodule参数 SIM_FAST 是整套验证能跑起来的关键它必须从顶层一路透传到分频器中间任何一层忘了传仿真就退化成真 1 Hz跑一天等于白等。上面这段 testbench 用 force 直接把三个计数器压到 23:59:55然后释放让它们自然走完跨天进位2000 ns 的仿真时间就能看到 00:00:00 到 00:00:05 的完整过程。force/release 这种做法在综合代码里绝对不能用但在 testbench 里是提高覆盖率的常规手段。上板前的检查我固定走一遍这五项顺序别乱检查项具体动作通过标准时钟频率对照原理图确认晶振频率改 CLK_FREQ分频常数与实物一致引脚约束逐条核对位选、段码、按键的引脚号与电平标准本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?