简介:这份面向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_1hz | 50 MHz 分频出秒脉冲 | CNT_MAX = 49_999_999,输出 tick_1s |
| bcd_counter_60 | 秒、分 BCD 计数(模 60) | en 使能,load/load_val 校时装载,carry 进位 |
| bcd_counter_24 | 小时 BCD 计数(模 24) | en 使能,load/load_val 校时装载 |
| key_debounce | 按键同步 + 20 ms 消抖 | 输出单周期 key_pulse |
| seg_scan | 8 位数码管动态扫描 | SCAN_DIV 决定刷新率,1 kHz |
| set_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 = {8'h00, 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 (1'b0), .load_val (8'h00), .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 <= 1'b0; rst_sync <= 1'b0; end else begin rst_meta <= 1'b1; 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{1'b0}}; tick_1hz <= 1'b0; end else if (cnt == CNT_MAX) begin cnt <= {WIDTH{1'b0}}; tick_1hz <= 1'b1; // 只高一个时钟周期 end else begin cnt <= cnt + 1'b1; tick_1hz <= 1'b0; 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 <= 8'h00; carry <= 1'b0; end else if (load) begin bcd <= load_val; carry <= 1'b0; end else if (en) begin if (bcd[3:0] == 4'd9) begin bcd[3:0] <= 4'd0; if (bcd[7:4] == 4'd5) begin bcd[7:4] <= 4'd0; carry <= 1'b1; // 59 -> 00,进位 end else begin bcd[7:4] <= bcd[7:4] + 1'b1; carry <= 1'b0; end end else begin bcd[3:0] <= bcd[3:0] + 1'b1; carry <= 1'b0; end end else begin carry <= 1'b0; 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 == 8'h59); // 写法 B:用 carry,但不能再与 tick 相与 // wire min_en = sec_carry;我一般用写法 A。理由是同拍生效的时序关系最直观,仿真波形上秒和分同时跳变,一眼能看出对不对;写法 B 虽然也能工作,但「分比秒晚 20 ns」这个事实不写在注释里,三个月后自己都得重新推一遍。写法 A 的代价是多了一个比较器,8 位比较在 FPGA 里就是几个 LUT,可以忽略。
小时计数器的使能同理,用min_en & (min_bcd == 8'h59)组合出来。整条链上不存在任何寄存器延迟,秒到分到时三者的跳变在同一拍完成,时间刻度才是连续的。
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 <= 1'b1; key_sync1 <= 1'b1; key_stable <= 1'b1; cnt <= 21'd0; key_pulse <= 1'b0; end else begin key_sync0 <= key_in; key_sync1 <= key_sync0; key_pulse <= 1'b0; // 默认拉低,保证单周期 if (key_sync1 != key_stable) begin if (cnt == CNT_MAX - 1) begin // 连续 20 ms 保持一致 cnt <= 21'd0; key_stable <= key_sync1; if (key_sync1 == 1'b0) // 只在按下时出脉冲 key_pulse <= 1'b1; end else begin cnt <= cnt + 1'b1; end end else begin cnt <= 21'd0; // 中途抖回去就重新计时 end end end endmodule这段的核心是「不一致就累加、一致就清零」:只要 key_sync1 和当前稳定值不同,计数器就往上涨,涨满 20 ms 才认定状态真的变了;中途任何一次采样又变回去,计数器归零重来。这样所有抖动窗口都被吃掉,只有真正稳定按下才产生脉冲。位宽选 21 位是因为 CNT_MAX 是 1 000 000,2^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 000,16 位计数器足够。
极性和消隐是真正的坑。位选低有效还是高有效,取决于板上有没有三极管驱动、是 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, 2'b00} +: 4]; reg [7:0] seg_nxt; // 扫描节拍:每 SCAN_DIV 个时钟换一位 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 16'd0; scan_idx <= 3'd0; end else if (div_cnt == SCAN_DIV - 1) begin div_cnt <= 16'd0; scan_idx <= scan_idx + 1'b1; end else begin div_cnt <= div_cnt + 1'b1; end end // 段码查表:共阳接法 + 低电平点亮 always @(*) begin case (cur_bcd) 4'd0: seg_nxt = 8'hC0; 4'd1: seg_nxt = 8'hF9; 4'd2: seg_nxt = 8'hA4; 4'd3: seg_nxt = 8'hB0; 4'd4: seg_nxt = 8'h99; 4'd5: seg_nxt = 8'h92; 4'd6: seg_nxt = 8'h82; 4'd7: seg_nxt = 8'hF8; 4'd8: seg_nxt = 8'h80; 4'd9: seg_nxt = 8'h90; default: seg_nxt = 8'hFF; // 非数字一律熄灭 endcase end // 位选切换与段码输出,中间隔一拍实现消隐 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin seg_sel <= 8'hFF; seg_code <= 8'hFF; end else if (div_cnt == SCAN_DIV - 1) begin seg_sel <= ~(8'h01 << scan_idx); // 只有当前位拉低 seg_code <= 8'hFF; // 本拍全灭,消隐 end else if (div_cnt == 16'd1) begin seg_code <= seg_nxt; // 下一拍再给真实段码 end end endmodule参数 SCAN_DIV 直接决定亮度:值越大,每位停留越久、看起来越亮,但整屏刷新率越低,超过 20 000(对应 8 位整屏约 300 Hz)就开始有点闪。段码表是共阳低有效的编码,如果你的板子是共阴,把整张表按位取反即可,不用改逻辑。{scan_idx, 2'b00}等价于 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 <= 2'd0; else if (key_mode_p) set_sel <= (set_sel == 2'd2) ? 2'd0 : set_sel + 1'b1; end // 加键:同拍算出装载值并置 load,目标计数器下一拍生效 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin hour_load <= 1'b0; min_load <= 1'b0; hour_val <= 8'h00; min_val <= 8'h00; end else begin hour_load <= 1'b0; min_load <= 1'b0; if (key_up_p && set_sel == 2'd1) begin hour_load <= 1'b1; hour_val <= (hour_cur == 8'h23) ? 8'h00 : bcd_inc(hour_cur); end else if (key_up_p && set_sel == 2'd2) begin min_load <= 1'b1; min_val <= (min_cur == 8'h59) ? 8'h00 : bcd_inc(min_cur); end end end // BCD 加一,保留十位结构 function [7:0] bcd_inc; input [7:0] v; begin if (v[3:0] == 4'd9) bcd_inc = {v[7:4] + 4'd1, 4'd0}; else bcd_inc = {v[7:4], v[3:0] + 4'd1}; 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 = 1'b0; reg rst_n = 1'b0; reg [1:0] key = 2'b11; // 按键低有效,空闲为高 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 = 1'b1; // 覆盖 23:59:5x 到 00:00:0x 的跨天进位 force dut.hour_bcd = 8'h23; force dut.min_bcd = 8'h59; force dut.sec_bcd = 8'h55; #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 | 分频常数与实物一致 |
| 引脚约束 | 逐条核对位选、段码、按键的引脚号与电平标准 |
本文还有配套的精品资源,点击获取