news 2026/10/26 13:06:19

Verilog数字钟设计:模块划分、核心代码与上板调试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Verilog数字钟设计:模块划分、核心代码与上板调试

简介:面向FPGA开发初学者、数字逻辑课程设计及Verilog练手人群的数字钟工程包,聚焦计时、跑表、闹钟与时间调整等常见功能,可作为从语法学习走向完整项目实践的参考案例。压缩包共145个文件、约7.82MB,内含Verilog/VHDL源码、Quartus II工程配置与引脚约束、编译综合报告、仿真波形、sof配置文件及说明文本等,基本覆盖编写、仿真、综合适配到板级下载的流程。已有418人学习下载,说明该工程在入门学习者中有一定参考价值与使用热度。设计中按时钟分频、计时寄存器、显示驱动、闹钟比较、跑表控制和按键处理等模块拆分,代码注释较详细,便于读者对照理解各模块职责与信号连接关系。读者可借助仿真波形和编译报告复核时序逻辑、定位常见问题,并在此基础上完成课程设计、实验报告或功能扩展,适合作为FPGA数字钟项目的完整参考。

1. 一块板子上的第一件“能看”的作品

多数人 fpga开发 的第二次尝试,都是从 FPGA 数字钟开始的。第一次是点灯,灯一亮,实验就结束了,什么也没留下;数字钟不一样,它把分频、计数、译码、扫描、消抖这几件基础活儿全串起来,最后在六个数码管上跑出一个能跟手机对表的东西。用 Verilog 写一个数字钟,代码量通常不到 500 行,但它几乎覆盖了 Verilog 入门到能用的全部坑:时钟怎么分、进位怎么串、按键怎么消抖、数码管为什么会重影。适合刚学完语法、手里有一块带数码管和按键的开发板、想做出第一个“完整作品”的人;也适合已经工作、想拿它当模板去改倒计时器、跑表、温控面板的开发者。下面按我拆这份工程的顺序讲:先看模块怎么切,再看核心代码怎么落,最后讲上板会翻车在哪、怎么改成自己的东西。

2. 模块划分与时序骨架:从 50MHz 板载晶振到 1Hz 秒脉冲

数字钟看着简单,真正决定它好不好改的,是模块怎么切。我见过不少初版工程把所有逻辑塞进一个always块里,跑是能跑,但想加个闹钟就得从头捋一遍。合格的做法是按“时基—计数—译码—显示—交互”五层拆开,每层只关心自己那一件事,层与层之间靠单周期使能信号和窄位宽数据线连接。

2.1 顶层连线路:谁驱动谁,信号往哪走

顶层模块只做一件事:把下面几个子模块用 wire 连起来,自己不含任何时序逻辑。这条约束很重要,一旦顶层开始写if,说明某个子模块的职责没划清。

// rtl/top_clock.v module top_clock #( parameter integer CLK_FREQ = 50_000_000 // 板载晶振频率,换板子只改这一处 )( input wire clk, input wire rst_n, input wire [1:0] key, // key[0]=模式切换, key[1]=加数 output wire [5:0] seg_sel, // 六位数码管位选,低有效 output wire [7:0] seg_data, // 段选 {dp,g,f,e,d,c,b,a},低有效 output wire buzzer ); wire tick_1hz, tick_1khz; wire [1:0] set_mode; wire inc_pulse, key_mode_pulse, key_inc_pulse; wire [5:0] sec, min; wire [4:0] hour; wire [7:0] hour_bcd, min_bcd, sec_bcd; wire ringing; // 1) 时基:一个 50MHz 拆出两个单周期使能 clk_div #(.CLK_FREQ(CLK_FREQ)) u_div ( .clk(clk), .rst_n(rst_n), .tick_1hz(tick_1hz), .tick_1khz(tick_1khz) ); // 2) 交互:两路按键各自消抖成单脉冲 key_debounce #(.CNT_MAX(CLK_FREQ/50)) u_key_mode ( .clk(clk), .rst_n(rst_n), .key_in(key[0]), .key_pulse(key_mode_pulse) ); key_debounce #(.CNT_MAX(CLK_FREQ/50)) u_key_inc ( .clk(clk), .rst_n(rst_n), .key_in(key[1]), .key_pulse(key_inc_pulse) ); // 3) 状态机:决定当前在走时还是在校时 ctrl_fsm u_fsm ( .clk(clk), .rst_n(rst_n), .key_mode_pulse(key_mode_pulse), .key_inc_pulse(key_inc_pulse), .set_mode(set_mode), .inc_pulse(inc_pulse) ); // 4) 计数核心 time_counter u_cnt ( .clk(clk), .rst_n(rst_n), .tick_1hz(tick_1hz), .set_mode(set_mode), .inc_pulse(inc_pulse), .sec(sec), .min(min), .hour(hour) ); // 5) 译码 + 扫描 + 闹钟 bcd_convert u_bcd_h (.bin({1'b0, hour}), .bcd(hour_bcd)); bcd_convert u_bcd_m (.bin(min), .bcd(min_bcd)); bcd_convert u_bcd_s (.bin(sec), .bcd(sec_bcd)); seg_scan u_seg ( .clk(clk), .rst_n(rst_n), .tick_1khz(tick_1khz), .data_bcd({hour_bcd, min_bcd, sec_bcd}), .seg_sel(seg_sel), .seg_data(seg_data) ); alarm u_alarm ( .clk(clk), .rst_n(rst_n), .tick_1hz(tick_1hz), .hour(hour), .min(min), .sec(sec), .set_mode(set_mode), .ringing(ringing) ); buzzer_pwm u_bz ( .clk(clk), .rst_n(rst_n), .en(ringing), .buzzer(buzzer) ); endmodule

顶层这段代码没什么技术含量,但它把整份工程的“信号流”说清楚了:tick_1hz是从时基模块单向流到计数模块的使能,seg_sel/seg_data是扫描模块单向输出到引脚,没有任何跨层回读。参数CLK_FREQ抽出来放顶层,是为了换板子时只改一个数字;这一点在仿真里更关键,后面第 6 章会展开。

2.2 分频器:为什么用使能信号而不是分出一个新时钟

新手最常写错的写法是“分出一个 1Hz 的时钟去驱动计数器”,也就是把分频结果接到always @(posedge clk_1hz)。这样写仿真能过,上板大概率出问题:分频出来的信号走的是普通布线资源,不是全局时钟网络,扇出稍大就有偏斜,而且会引入门控时钟,时序分析也没法按同步路径处理。

正确做法是保留唯一的 50MHz 主时钟,把分频结果做成“单个时钟周期的高电平使能”。

// rtl/clk_div.v module clk_div #( parameter integer CLK_FREQ = 50_000_000 )( input wire clk, input wire rst_n, output reg tick_1hz, // 每 1 秒高一个 clk 周期 output reg tick_1khz // 每 1 毫秒高一个 clk 周期 ); // 位宽按最大值算:2^26 > 50_000_000,2^16 > 50_000 localparam integer CNT_1HZ = CLK_FREQ; localparam integer CNT_1KHZ = (CLK_FREQ >= 1000) ? CLK_FREQ / 1000 : 1; reg [25:0] cnt_1hz; reg [15:0] cnt_1khz; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1hz <= 26'd0; tick_1hz <= 1'b0; end else if (cnt_1hz == CNT_1HZ - 1) begin cnt_1hz <= 26'd0; tick_1hz <= 1'b1; // 只高一个周期,下一拍自动归零 end else begin cnt_1hz <= cnt_1hz + 1'b1; tick_1hz <= 1'b0; end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_1khz <= 16'd0; tick_1khz <= 1'b0; end else if (cnt_1khz == CNT_1KHZ - 1) begin cnt_1khz <= 16'd0; tick_1khz <= 1'b1; end else begin cnt_1khz <= cnt_1khz + 1'b1; tick_1khz <= 1'b0; end end endmodule

两个计数器结构完全一样,区别只在计数上限。CNT_1HZ - 1这个减一是容易漏的细节:从 0 数到 CNT-1 正好 CNT 个周期,写成== CNT_1HZ会让周期多一拍,一天下来慢好几秒,这种误差拿手机对表根本看不出来,跑一晚上才发现。tick_1hz采用“先置高再在 else 里清掉”的写法,保证它是严格的单周期脉冲;如果写成组合逻辑assign tick = (cnt == 0),那它宽度会跟着 cnt 变化,接到下游计数器的使能端就会出现“一秒加两次”的玄学现象。

tick_1khz是给数码管扫描用的。人眼视觉暂留大概在 50Hz 以上,六位数码管轮流点亮,每位刷新率要有 60Hz 以上才不闪,所以整屏刷新要 360Hz 以上,1kHz 的节拍留了足够余量。

2.3 工程目录与文件职责

一份能被别人接手改的工程,目录结构比代码风格更重要。我习惯按下面这样分,综合工具、仿真工具、约束文件各占一块,互不干扰。

digital_clock/ ├── rtl/ # 可综合源码,一个文件一个模块 │ ├── top_clock.v # 顶层,只做例化连线 │ ├── clk_div.v # 时基:1Hz / 1kHz 使能 │ ├── time_counter.v # 秒分时三级计数器 │ ├── bcd_convert.v # 二进制转 BCD │ ├── seg_scan.v # 六位数码管动态扫描 │ ├── key_debounce.v # 按键消抖 + 边沿检测 │ ├── ctrl_fsm.v # 校时状态机 │ ├── alarm.v # 闹钟比较与响铃时长 │ └── buzzer_pwm.v # 蜂鸣器方波驱动 ├── sim/ │ ├── tb_top_clock.v # 顶层仿真,验证进位与显示 │ └── tb_key_debounce.v # 单模块仿真,验证消抖窗口 ├── constr/ │ ├── clock.xdc # 引脚与时序约束(对应厂商工具) │ └── clock.qsf # 另一套工具链的等价约束 └── doc/ └── pin_map.md # 引脚对照表,上板前必看

rtl里每个文件只放一个 module,文件名和模块名一致,综合工具的增量编译能少走很多弯路。sim和rtl分开的意义在于:仿真用的 testbench 含$finish、#delay这类不可综合语句,混进 rtl 目录后,某些工具在综合时会直接报错或者悄悄忽略,排查起来很费劲。constr下同时放两种格式,是因为不同厂商的工具吃不同后缀,.xdc是 XDC 约束、.qsf是 QSF 引脚分配,内容等价,改的时候两边都要同步,否则换个工具打开就全红。

3. 核心代码拆解:计时进位、BCD 拆分与六位数码管动态扫描

这一章是整份工程真正有技术含量的部分。计时看着就是加一,但进位链写错会让时间“跳变”;BCD 转换写错会让数码管显示出A、F这种鬼东西;扫描写错就是一层重影。三块代码加起来不到 150 行,值得逐行过一遍。

3.1 计时核心:秒、分、时三级计数器怎么写才不跑偏

计时模块的输入只有三个:秒使能、当前校时模式、加数脉冲。输出是三个窄位宽的计数值。把“校时”和“走时”放在同一个always块里,靠优先级区分,是这个模块最省资源也最不容易出错的写法。

// rtl/time_counter.v module time_counter( input wire clk, input wire rst_n, input wire tick_1hz, // 秒使能,单周期 input wire [1:0] set_mode, // 0=正常走时 1=调时 2=调分 3=调秒 input wire inc_pulse, // 消抖后的加数脉冲,单周期 output reg [5:0] sec, // 0~59,6 位足够 output reg [5:0] min, output reg [4:0] hour // 0~23,5 位足够 ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sec <= 6'd0; min <= 6'd0; hour <= 5'd0; end // 校时优先:手动加数时秒脉冲不起作用 else if (set_mode == 2'd1 && inc_pulse) begin hour <= (hour == 5'd23) ? 5'd0 : hour + 1'b1; end else if (set_mode == 2'd2 && inc_pulse) begin min <= (min == 6'd59) ? 6'd0 : min + 1'b1; end else if (set_mode == 2'd3 && inc_pulse) begin sec <= 6'd0; // 调秒习惯上直接清零,比加一好用 end // 正常走时:从秒开始逐级进位 else if (tick_1hz) begin if (sec == 6'd59) begin sec <= 6'd0; if (min == 6'd59) begin min <= 6'd0; hour <= (hour == 5'd23) ? 5'd0 : hour + 1'b1; end else begin min <= min + 1'b1; end end else begin sec <= sec + 1'b1; end end end endmodule

这段代码的关键在优先级顺序。set_mode的三个分支写在tick_1hz之前,意味着校时期间秒脉冲被完全屏蔽,不会出现“手按着按键,秒自己还在跳”的情况——这正是很多人第一版会遇到的问题。位宽也不是随便给的:秒和分最大 59,6 位二进制够用(最大 63);小时最大 23,5 位够用(最大 31)。多给位宽综合器会优化掉,但会让人误以为有更大的取值范围,改代码时容易越界。

进位链没有用sec_carry、min_carry这类中间信号,而是直接嵌套if。这么写在 50MHz 下时序毫无压力,而且逻辑一眼能看懂;如果要做多级级联(比如带日的日历),再抽进位信号更合适。

3.2 二进制转 BCD:除十取余写法与移位加三的取舍

计数器给的是二进制,数码管要的是十进制每一位。中间这层转换,最常见的写法就是除十取余:

// rtl/bcd_convert.v module bcd_convert( input wire [5:0] bin, // 0~59 output wire [7:0] bcd // {十位[3:0], 个位[3:0]} ); // 除数和模都是常数,综合器会展开成移位+加法,不会真的例化除法器 assign bcd = {bin / 10, bin % 10}; endmodule

这段看着像“软件写法”,其实综合器处理常数除法时很聪明:/10和%10会被展开成若干次移位和加法,在 50MHz 下也就是几级逻辑的事。前提是除数是常数——如果写成bin / div,div是个输入端口,那就真的会生成一个除法器,资源占用能吃掉小器件的一大半。所以这个模块的端口只留bin,除数写死在代码里。

另一种写法是“移位加三”(double dabble),把二进制逐位左移,每移一位检查各 BCD 位是否大于等于 5,是就加 3。它的好处是不依赖综合器对除法的优化,时序更可控;坏处是代码长、可读性差,6 位输入要写十几行循环。对于 0~59 这种小范围,除十取余完全够用,没必要上移位加三。真正需要移位加三的场景是转换 16 位以上的二进制数,那时候常数除法展开出来的逻辑深度会拖慢时序。

实例化三次,分别转时、分、秒,输出拼成 24 位:

bcd_convert u_bcd_h (.bin({1'b0, hour}), .bcd(hour_bcd)); // hour 是 5 位,补零到 6 位 bcd_convert u_bcd_m (.bin(min), .bcd(min_bcd)); bcd_convert u_bcd_s (.bin(sec), .bcd(sec_bcd)); wire [23:0] data_bcd = {hour_bcd, min_bcd, sec_bcd};

拼接顺序决定数码管从左到右显示什么。{hour_bcd, min_bcd, sec_bcd}对应“时十、时个、分十、分个、秒十、秒个”,扫描模块按下标取用,顺序一旦颠倒,显示就成了乱码——这个坑我在第 5 章会单独说。

3.3 动态扫描:1kHz 刷新、位选轮询与消隐处理

六位数码管共用一组段选线,靠位选线轮流点亮。扫描模块要做三件事:按 1kHz 节拍换位、把当前位的 BCD 码译成段码、换位瞬间消隐。

// rtl/seg_scan.v module seg_scan( input wire clk, input wire rst_n, input wire tick_1khz, // 1ms 换一位 input wire [23:0] data_bcd, output reg [5:0] seg_sel, // 位选,低有效 output reg [7:0] seg_data // 段选,共阳低有效 ); reg [2:0] scan_idx; reg [3:0] cur_digit; reg [7:0] cur_seg; // 位选轮询:0→1→…→5→0 always @(posedge clk or negedge rst_n) begin if (!rst_n) scan_idx <= 3'd0; else if (tick_1khz) scan_idx <= (scan_idx == 3'd5) ? 3'd0 : scan_idx + 1'b1; end // 用 case 做数据选择,比变量下标 [scan_idx*4 +: 4] 更好综合 always @(*) begin case (scan_idx) 3'd0: cur_digit = data_bcd[23:20]; 3'd1: cur_digit = data_bcd[19:16]; 3'd2: cur_digit = data_bcd[15:12]; 3'd3: cur_digit = data_bcd[11:8]; 3'd4: cur_digit = data_bcd[7:4]; default: cur_digit = data_bcd[3:0]; endcase end // 段码译码:共阳数码管,0 亮 1 灭 always @(*) begin case (cur_digit) 4'd0: cur_seg = 8'hC0; 4'd1: cur_seg = 8'hF9; 4'd2: cur_seg = 8'hA4; 4'd3: cur_seg = 8'hB0; 4'd4: cur_seg = 8'h99; 4'd5: cur_seg = 8'h92; 4'd6: cur_seg = 8'h82; 4'd7: cur_seg = 8'hF8; 4'd8: cur_seg = 8'h80; 4'd9: cur_seg = 8'h90; default: cur_seg = 8'hFF; // 非法值全灭,避免出现乱码 endcase end // 关键:换位和消隐在同一拍完成,先灭再换 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin seg_sel <= 6'b111111; seg_data <= 8'hFF; end else if (tick_1khz) begin seg_sel <= ~(6'b000001 << scan_idx); // 只拉低当前位 seg_data <= cur_seg; end end endmodule

scan_idx从 0 数到 5 循环,6'b000001 << scan_idx生成一位低电平,取反后只有当前位有效。这里的位置顺序要和data_bcd的拼接顺序对齐:scan_idx == 0时输出data_bcd[23:20],也就是时的十位,正好配最左边那一位。

消隐的处理藏在那句seg_sel <= ...; seg_data <= ...;里:位选和段选在同一个时钟沿更新,两个信号同时变化。如果段选滞后位选一拍,新选的位会短暂地亮出上一位的段码,肉眼看到的就是“每个数字边上带一点残影”。更保守的写法是加一个小状态机,先把seg_data清成全灭,下一拍再更新位选和段码,多花一个时钟周期但彻底没残影。1kHz 节拍下多一拍完全不影响刷新率,值得。

4. 人机交互:按键消抖、校时状态机与闹钟比较

显示跑起来之后,时钟还只是个“会走的表”。真正让它能用的,是按键校时和闹钟这两块。这部分代码量不大,但状态机没设计好,按一下跳好几下、按键按住不放时间乱飞,这些都是血泪经验。

4.1 按键消抖:20ms 窗口加边沿检测

机械按键的抖动一般在 5~20ms 之间,消抖窗口取 20ms 是行业惯例。50MHz 下 20ms 对应 1,000,000 个时钟,正好 20 位计数器的量程上限。

// rtl/key_debounce.v module key_debounce #( parameter integer CNT_MAX = 1_000_000 // 50MHz × 20ms )( input wire clk, input wire rst_n, input wire key_in, // 按键输入,低有效(按下为 0) output reg key_pulse // 按下瞬间输出一个单周期高脉冲 ); reg [1:0] sync; // 两级同步,防亚稳态 reg [19:0] cnt; reg key_stable; // 消抖后的稳定电平 reg key_stable_d; // 打一拍用于边沿检测 // 关键:跨时钟域输入先打两拍,这是防亚稳态的标准动作 always @(posedge clk or negedge rst_n) begin if (!rst_n) sync <= 2'b11; else sync <= {sync[0], key_in}; end // 电平持续 20ms 不变才认为是真的按下/松开 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 20'd0; key_stable <= 1'b1; end else if (sync[1] != key_stable) begin if (cnt == CNT_MAX - 1) begin cnt <= 20'd0; key_stable <= sync[1]; end else begin cnt <= cnt + 1'b1; end end else begin cnt <= 20'd0; // 电平回到一致,计数清零重来 end end // 边沿检测:只在"高→低"(按下)那一拍给出脉冲 always @(posedge clk or negedge rst_n) begin if (!rst_n) key_stable_d <= 1'b1; else key_stable_d <= key_stable; end always @(posedge clk or negedge rst_n) begin if (!rst_n) key_pulse <= 1'b0; else key_pulse <= key_stable_d & ~key_stable; end endmodule

两级触发器sync是必须的。按键是异步输入,不经过同步就直接进状态机,亚稳态会让下游逻辑在某个时钟沿收到不确定值,症状是“偶尔按一下没反应,偶尔按一下跳两下”,这种问题仿真里永远复现不了。sync <= {sync[0], key_in}这种移位写法等价于两级 DFF 串联,是最省事的同步器写法。

输出端只给“按下”脉冲,不给“松开”脉冲,是刻意的设计选择:校时只需要“加一”,不需要知道按键什么时候松手。如果以后要做长按连续加数,才需要额外引出“按住状态”信号。

4.2 校时状态机:一个键切换模式,一个键加数

两个按键要覆盖“调时、调分、调秒、回正常”四个状态,最自然的是循环状态机。

// rtl/ctrl_fsm.v module ctrl_fsm( input wire clk, input wire rst_n, input wire key_mode_pulse, // 模式键 input wire key_inc_pulse, // 加数键 output reg [1:0] set_mode, // 0 走时 1 调时 2 调分 3 调秒 output reg inc_pulse ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin set_mode <= 2'd0; end else if (key_mode_pulse) begin // 0→1→2→3→0 循环 set_mode <= (set_mode == 2'd3) ? 2'd0 : set_mode + 1'b1; end end // 加数脉冲只在非走时模式下透传,避免正常走时时误触 always @(posedge clk or negedge rst_n) begin if (!rst_n) inc_pulse <= 1'b0; else inc_pulse <= key_inc_pulse && (set_mode != 2'd0); end endmodule

状态机只有 4 个状态,不需要三段式写法,一段式就够,可读性还更好。inc_pulse那一行做了个门控:只有校时模式下加数键才生效。这个门控如果省略,正常走时时手一抖碰到按键,时间就跳一分钟,用户会觉得这钟“坏了”。

set_mode同时送到time_counter和alarm:校时的时候闹钟比较要暂停,否则你把时间往前调的过程中万一正好经过闹钟点,蜂鸣器就响了。这个细节很多人第一版都漏了。

4.3 闹钟与整点报时:比较器加无源蜂鸣器 PWM

闹钟逻辑简单到只有一行比较,难的是“响多久”和“怎么停”。我一般让它在设定时刻响 30 秒,期间按模式键可以手动静音。

// rtl/alarm.v module alarm( input wire clk, input wire rst_n, input wire tick_1hz, input wire [4:0] hour, input wire [5:0] min, input wire [5:0] sec, input wire [1:0] set_mode, output reg ringing ); // 闹钟时刻,实际工程里用按键写入,这里给个固定值方便先跑通 localparam [4:0] ALARM_H = 5'd7; localparam [5:0] ALARM_M = 6'd30; wire hit = (hour == ALARM_H) && (min == ALARM_M) && (sec < 6'd30); always @(posedge clk or negedge rst_n) begin if (!rst_n) ringing <= 1'b0; else if (set_mode != 2'd0) ringing <= 1'b0; // 校时期间强制静音 else ringing <= hit; // 命中且前 30 秒内响 end endmodule

sec < 30这个条件就是“响 30 秒”的实现,比再开一个计数器省事。ringing是电平台,不是脉冲,所以不需要边沿检测。

蜂鸣器要分有源和无源。有源蜂鸣器内部带振荡电路,给高电平就响;无源蜂鸣器必须给方波,频率决定音调。开发板上常见的都是无源,直接接ringing只会听到“嗒”一声,所以要加 PWM:

// rtl/buzzer_pwm.v module buzzer_pwm #( parameter integer CLK_FREQ = 50_000_000, parameter integer TONE_HZ = 2700 // 2.7kHz,无源蜂鸣器比较响的频点 )( input wire clk, input wire rst_n, input wire en, output reg buzzer ); localparam integer HALF = CLK_FREQ / (TONE_HZ * 2); reg [15:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; buzzer <= 1'b0; end else if (!en) begin cnt <= 16'd0; buzzer <= 1'b0; // 不响时停在低电平,避免持续直流 end else if (cnt == HALF - 1) begin cnt <= 16'd0; buzzer <= ~buzzer; // 翻转产生方波 end else begin cnt <= cnt + 1'b1; end end endmodule

HALF = CLK_FREQ / (TONE_HZ * 2)算的是半周期计数,因为要翻转两次才是一个完整周期。50MHz 下 2.7kHz 对应半周期约 9259 个时钟,16 位计数器够用。en拉低时把输出钉在低电平,是为了避免蜂鸣器两端长期加直流——无源蜂鸣器通直流只会发热,不会响。

5. 上板排查:综合能过不等于灯能亮

仿真波形全绿、综合报告零 error,插上板子一片黑,这是 FPGA 新手最崩溃的时刻。这一章把上板前后能撞上的坑整理成清单,每条都按“看到什么、为什么、怎么修”写,照着对一遍能省掉大半天。

5.1 上板前的三项硬检查:约束、时钟、极性

在点“下载”之前,有三件事必须先确认,任何一件错了,后面的调试都是白费。

第一是引脚约束。约束文件里的引脚号必须对着板子原理图逐个填,不能沿用别人工程的默认值。XDC 格式长这样:

# constr/clock.xdc —— 引脚号务必对照原理图逐个改,这里用占位符 create_clock -name sys_clk -period 20.000 [get_ports clk] # 50MHz → 20ns set_property -dict {PACKAGE_PIN <PIN_CLK> IOSTANDARD LVCMOS33} [get_ports clk] set_property -dict {PACKAGE_PIN <PIN_RST> IOSTANDARD LVCMOS33} [get_ports rst_n] set_property -dict {PACKAGE_PIN <PIN_K0> IOSTANDARD LVCMOS33} [get_ports {key[0]}] set_property -dict {PACKAGE_PIN <PIN_K1> IOSTANDARD LVCMOS33} [get_ports {key[1]}] set_property -dict {PACKAGE_PIN <PIN_BZ> IOSTANDARD LVCMOS33} [get_ports buzzer] set_property -dict {PACKAGE_PIN <PIN_SEG0> IOSTANDARD LVCMOS33} [get_ports {seg_sel[0]}]

create_clock那一行是时序约束,不写的话工具不知道主频多少,布局布线会按最松的约束跑,跑出来的电路可能在高低温下不稳定。另一套工具链的等价写法是set_location_assignment加set_instance_assignment -name IO_STANDARD,语义一样,只是语法不同。

第二是时钟源。板子上可能有好几个晶振,有源的、无源的、不同频率的,原理图上标着50M、100M、25M的都有。CLK_FREQ参数必须和实际接的那个对上,填错了时间会按比例跑快或跑慢——填成 100MHz 而实际是 50MHz,钟会走成两倍速。血泪经验是上板第一件事先让一个 LED 按 1Hz 闪,眼睛数 10 秒闪 10 下,确认时基没错再往下做。

第三是数码管的共阳共阴。共阳数码管段选低电平点亮,共阴反过来。段码表是按共阳写的(8'hC0对应数字 0),如果板子是共阴,显示出来会是反的——该亮的灭、该灭的亮,看到的是六个“黑块”。改法是把段码表整体取反,或者输出前加一级~。

5.2 五条踩坑记录:现象、原因、解决

下面这五条是我和身边人真撞过的,按出现频率排序。

现象一:数码管每个数字边缘带一层淡影,快速眨眼能看到重影在跑。原因是位选和段选没有同步更新,上一拍的段码在新位上多亮了几微秒。解决是把seg_sel和seg_data放到同一个always块、同一个时钟沿赋值;如果还有残留,加一级消隐状态,先把段选拉全灭,下一拍再换位。

现象二:仿真里一天 24 小时进位正常,上板跑一晚上慢了将近一分钟。原因是分频计数的比较值少减了 1,或者位宽不够导致计数器溢出提前归零。检查CNT_1HZ - 1有没有写对,再看计数器位宽是不是能装下CLK_FREQ的值——50,000,000 需要 26 位,写成 25 位就会在 33,554,432 处溢出,导致每秒实际比 1 秒短。

现象三:按一下校时键,时间跳了三四下。两种可能:一是消抖窗口太短,机械抖动被当成多次按下;二是只做了消抖没做边沿检测,key_stable是电平台,按住期间每个时钟沿都在触发加数。前者的修法是加大CNT_MAX,后者的修法是补上key_stable_d & ~key_stable这段边沿检测。

现象四:综合报告显示逻辑资源占用异常高,布线失败或者时序不收敛。多半是除法器惹的祸。检查代码里所有/和%,除数必须是常数,一旦除数来自端口或寄存器,工具就会例化一个真正的除法器。把bin / 10改成查表或者移位加三,资源占用能降一个数量级。

现象五:蜂鸣器只在闹钟触发那一瞬间“嗒”一声,之后没声了。无源蜂鸣器被当成了有源的在用。无源蜂鸣器必须给方波,直接把ringing这个电平台接到引脚上,只有在电平跳变的那一下有声音。检查板子原理图确认蜂鸣器类型,无源的接 PWM 模块,有源的才可以直接接电平。

6. 二次开发:把它改成真能摆在桌上的时钟

跑通之后,这份工程最值钱的地方是它能当模板。三个改造方向按难度从低到高排:12/24 小时制切换、亮度调节、串口对时。

12/24 小时制切换改动最小,只需要在time_counter里加一个模式位,显示时把hour > 12的情况减 12,再单独驱动一位 AM/PM 指示。亮度调节靠的是扫描模块的占空比:在seg_scan里加一个 4 位亮度寄存器和一个小计数器,只有当bright_cnt < brightness时才输出段码,其余时间全灭。人眼积分后就是不同亮度,比改限流电阻灵活得多。

串口对时稍微复杂,本质是加一个 UART 接收模块,收到一帧HH:MM:SS

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 13:05:44

MATLAB M文件驱动Simulink S函数自动调参工程实践

简介&#xff1a;面向具备一定Simulink基础、希望摆脱手工反复试参的高校学生与控制算法开发者&#xff0c;这份资料围绕在MATLAB中用M文件驱动Simulink模型、对S函数模块参数进行自动扫描与择优展开。压缩包共4个文件&#xff0c;约24KB&#xff0c;由3个m脚本和1个slx模型组成…

作者头像 李华
网站建设 2026/10/26 13:05:37

微信电脑版4.1.8存储路径设置问题分析与解决方案

1. 问题现象描述 最近在安装微信电脑版4.1.8时遇到了一个奇怪的存储路径选择问题。具体表现为&#xff1a;当尝试将聊天记录存储路径设置为我新建的特定文件夹时&#xff0c;系统会弹出"该位置无法使用&#xff0c;请重新选择"的错误提示。但令人困惑的是&#xff0c…

作者头像 李华
网站建设 2026/10/26 13:05:13

AI for Science:从蛋白质折叠到材料设计的革命性突破

1. 项目概述&#xff1a;AI for Science研究全景速览当AlphaFold2在2020年破解蛋白质折叠难题时&#xff0c;整个科学界都意识到&#xff1a;AI正在重塑基础科研的范式。如今&#xff0c;AI for Science已从单点突破发展为涵盖生物、化学、材料、物理等多学科的完整技术矩阵。这…

作者头像 李华
网站建设 2026/10/26 13:05:12

macOS下OpenCV cv2.imshow()黑屏终极解决方案

1. 项目概述&#xff1a;为什么一个博客翻译系列值得持续跟进六十九期&#xff1f;PyImgSearch 这个名字在计算机视觉和图像处理领域&#xff0c;几乎等同于“可信赖的实操指南”——它不是那种堆砌公式、空谈理论的学术博客&#xff0c;而是由一位常年泡在 OpenCV 实战一线的开…

作者头像 李华
网站建设 2026/10/26 13:05:03

MSTP多生成树协议原理与eNSP实验:域、实例、负载分担与排障

1. 单生成树在多网段场景下的三个硬伤先说一个我见过太多次的场景&#xff1a;实验环境里搭了三台交换机&#xff0c;接了两个业务网段&#xff0c;VLAN 10 和 VLAN 20。所有人第一反应都是"开个 STP 不就行了"&#xff0c;敲完stp enable&#xff0c;网络确实通了&a…

作者头像 李华
网站建设 2026/10/26 13:04:39

九轴姿态解算MATLAB实战:从传感器标定到Mahony融合

简介&#xff1a;面向惯导定位、机器人及无人机开发者的九轴传感器姿态解算MATLAB工程包&#xff0c;围绕陀螺仪、加速度计与磁力计的数据校准、融合与姿态输出展开&#xff0c;适合已有一定信号处理基础、希望从仿真走向实机验证的学习者。压缩包共12个文件、约555KB&#xff…

作者头像 李华