
简介一份基于Verilog的FPGA时钟设计工程包面向数字电路初学者和FPGA实践者适合希望用硬件描述语言完成综合小项目的读者。资源围绕时钟模块展开包含秒、分、小时计数器及进位逻辑并实现闹铃准点比较、整点/半点报时与机械按键去抖等实用功能可帮助理解同步时序电路的条件检测和状态控制。工程文件共101个以Quartus工程配置与编译下载文件.qpf/.qsf/.sof/.pof为主另有Verilog源码.v、文本说明和辅助脚本整体约3.83MB结构清晰且目录便于按功能模块检索可直接打开工程查看逻辑或重新编译下载。已有1526人学习下载通过阅读源码和工程结构可以掌握时钟分频、闹铃比较器设计、按键消抖电路等典型模块写法为后续更复杂的数字系统开发打好实践基础。1. 从“仿真全对、上板不亮”说起Verilog时钟到底在做什么很多玩FPGA的第一块上板作品就是时钟六个数码管显示时分秒加个按键能调时设个闹铃到点就响。这个项目标题看着简单但背后牵出分频、BCD计数、扫描显示、按键消抖、状态机、闹铃比较一整条数字逻辑链。我见过不少人在testbench里把波形跑得干干净净一上板秒不走、显示乱跳、闹铃不响最后卡在某个玄学细节上。这篇笔记把这条链路拆开讲为什么这么设计、代码怎么写、参数怎么调、坑在哪。新手能照着搭熟手可以拿去对边界顺带省掉一轮上板翻车的时间。2. 秒分时计数器用BCD和脉冲使能把时间“数”出来2.1 为什么不用二进制计数器初学者最容易想到的是用一个大计数器从0数到86399再拆成时分秒。这个思路没错但直接落地会别扭六个数码管要显示的数字是十进制纯二进制计数器得频繁做取模和除法才能得到每一位。这种运算在FPGA里组合逻辑开销大还会让进位链变长影响时序收敛。更关键的是闹铃比较、调时装载都发生在“某个十进制位”上二进制表示会让比较逻辑变得很绕。所以工程上普遍用BCD二进制编码十进制秒个位、秒十位、分个位、分十位、时个位、时十位各用4位寄存器秒和分做模60计数时做模24计数。每个4位寄存器的取值范围0-9直接把BCD码给到数码管的七段译码表连转换都省了。代价是多用几个触发器但FPGA的寄存器资源最不缺用面积换清晰和易维护这笔账划算。BCD的进位规则也直观个位加到9再加1就归0并向十位进1十位加到5再加1就归0并向上一级进1。这条规则完全贴着一个人类读时间的习惯调时、闹铃比较都不需要额外的转换模块而且写进RTL之后仿真波形里每一位是什么一目了然。2.2 模60/模24计数器带装载的可用代码下面是我常用的带装载功能的模60计数器。之所以加load端口是因为调时的时候需要直接把当前值改成设定值而不是从0按几百次才能调到目标时间。没有装载端口的话调时逻辑会非常痛苦。module counter60 ( input wire clk, // 系统时钟 input wire rst_n, // 异步复位低有效 input wire en, // 秒脉冲使能高电平计数一次 input wire load_en, // 装载使能高电平写入设定值 input wire [3:0] load_ones, // 设定的个位 BCD 值 input wire [3:0] load_tens, // 设定的十位 BCD 值 output reg [3:0] ones, // 个位 output reg [3:0] tens, // 十位 output reg carry // 进位脉冲持续一个时钟周期 ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin ones 4d0; tens 4d0; carry 1b0; end else if (load_en) begin ones load_ones; tens load_tens; carry 1b0; end else if (en) begin carry 1b0; // 默认不产生进位 if (ones 4d9) begin ones 4d0; if (tens 4d5) begin tens 4d0; carry 1b1; // 59 - 00输出进位脉冲 end else begin tens tens 1b1; end end else begin ones ones 1b1; end end else begin carry 1b0; end end endmodule这个模块的关键点有三个。第一en是电平使能而不是时钟整个设计只用一个系统时钟避免多时钟域带来的亚稳态和综合警告。第二carry是单周期脉冲它在en有效的那一拍产生高电平下一拍自动归0正好作为下一级计数器的en。第三load_en优先级高于en调时状态下按下add键不会同时触发计数。模24计数器写法一样只是进位条件从“个位9且十位5”换成“个位3且十位2”也就是23:59:59之后要翻到00:00:00module counter24 ( input wire clk, input wire rst_n, input wire en, input wire load_en, input wire [3:0] load_ones, input wire [3:0] load_tens, output reg [3:0] ones, output reg [3:0] tens, output reg carry ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin ones 4d0; tens 4d0; carry 1b0; end else if (load_en) begin ones load_ones; tens load_tens; carry 1b0; end else if (en) begin carry 1b0; if (ones 4d3 tens 4d2) begin // 23 - 00 ones 4d0; tens 4d0; carry 1b1; end else if (ones 4d9) begin ones 4d0; tens tens 1b1; end else begin ones ones 1b1; end end else begin carry 1b0; end end endmodule使用时按秒→分→时串起来秒计数器的carry接分计数器的en分计数器的carry接时计数器的en。这样秒跑满60次分加1分跑满60次时加1。这里的级联是“脉冲链”不是每个模块各用一个独立时钟综合工具能正确约束路径。2.3 秒脉冲产生分频参数决定仿真和上板速度有了一秒的基准信号才能让秒计数器动起来。常见做法是把开发板晶振频率分频得到1Hz。有人直接分频产生方波送给计数器当时钟我一般不这么做——方波是时钟信号会引入第二个时钟域而且门控时钟在FPGA上容易惹出毛刺。我更习惯产生“每秒一个时钟周期宽的高脉冲”用它做使能。module pulse_gen #( parameter CNT_MAX 50_000_000 - 1 // 默认适配 50MHz 晶振 )( input wire clk, input wire rst_n, output reg pulse // 每 CNT_MAX1 个时钟周期输出一个高脉冲 ); reg [25:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 26d0; else if (cnt CNT_MAX) cnt 26d0; else cnt cnt 1b1; end always (posedge clk or negedge rst_n) begin if (!rst_n) pulse 1b0; else pulse (cnt CNT_MAX); // 到达边界时拉高一拍随后自动拉低 end endmoduleCNT_MAX这个参数是整篇设计的“速度开关”。上板时50MHz晶振写成49_999_999如果你的板子是100MHz或12MHz改成对应的值减1即可。仿真时为了不真等一秒把它改成99甚至9秒脉冲就会在100个或10个时钟周期里产生一次一整天的逻辑几毫秒就跑完了。后面验证技巧章节还会细说这个参数怎么配合testbench用。注意计数器位宽。CNT_MAX最大到50_000_000-1约23位我写成26位留了余量即使换100MHz晶振也够用。用parameter而不是localparam是为了能在例化时合法覆盖。3. 六位数码管扫描显示刷新率、消影与段码表3.1 扫描显示原理与刷新率选择时钟的时间是六个数字时十位、时个位、分十位、分个位、秒十位、秒个位。FPGA开发板通常不会同时给每个数码管配独立的段驱动而是把六个数码管的相同段引脚并在一起靠位选轮流点亮。这种“分时复用”利用人眼的视觉暂留某一瞬间只有一位数码管在亮但只要每一轮刷新够快看起来就像六个数字同时在显示。刷新率怎么选人眼能感知的临界闪烁频率大概在50Hz左右LED数码管的扫描频率一般不低于60Hz。六位扫描每位点亮1ms的话一轮是6ms每秒约167轮完全够用。如果把每位点亮时间拉长到2ms一轮12ms整体约83Hz也能接受。再慢就会出现明显的闪动拍照时尤其明显。实践中我习惯把扫描时钟分频到1kHz也就是每毫秒切换一位既简单又稳定。扫描显示的代价是平均亮度只有常亮的1/6但数码管本身亮度余量大人眼感觉不明显。另一个代价是切换瞬间会发生“拖影”位选已经切到下一位但段码还是上一位的数字会短暂点亮一个错误的位置。这个问题的解决手段叫消影后面代码里会落到实处。3.2 扫描模块代码共阴共阳怎么改module seg_scan ( input wire clk, // 1kHz 扫描时钟 input wire rst_n, input wire [23:0] bcd_in, // 六位BCD{时十,时个,分十,分个,秒十,秒个} output reg [5:0] sel, // 位选 output reg [7:0] seg // 段选 ); reg [2:0] pos; // 当前扫描位置 0~5 reg [3:0] cur_digit; reg [7:0] seg_code; // 扫描位置计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) pos 3d0; else if (pos 3d5) pos 3d0; else pos pos 1b1; end // 根据位置取出对应的BCD位 always (*) begin case (pos) 3d0: cur_digit bcd_in[3:0]; 3d1: cur_digit bcd_in[7:4]; 3d2: cur_digit bcd_in[11:8]; 3d3: cur_digit bcd_in[15:12]; 3d4: cur_digit bcd_in[19:16]; 3d5: cur_digit bcd_in[23:20]; default: cur_digit 4d0; endcase end // 位选低电平有效共阴极板常见接法 always (*) begin case (pos) 3d0: sel 6b011111; 3d1: sel 6b101111; 3d2: sel 6b110111; 3d3: sel 6b111011; 3d4: sel 6b111101; 3d5: sel 6b111110; default: sel 6b111111; endcase end // 七段码译码表共阴极段码高电平点亮 always (*) begin case (cur_digit) 4d0: seg_code 8h3F; 4d1: seg_code 8h06; 4d2: seg_code 8h5B; 4d3: seg_code 8h4F; 4d4: seg_code 8h66; 4d5: seg_code 8h6D; 4d6: seg_code 8h7D; 4d7: seg_code 8h07; 4d8: seg_code 8h7F; 4d9: seg_code 8h6F; default: seg_code 8h00; endcase end // 输出寄存器用打一拍的方式避开组合竞争切换瞬间自然消影 always (posedge clk or negedge rst_n) begin if (!rst_n) seg 8h00; else seg seg_code; end endmodule这段代码有三个地方要按你的板子改。第一是位选极性上面的sel是低电平有效如果你的板子是共阳极数码管或者位选用PNP三极管驱动sel的1/0含义要反过来。第二是段码表共阳极的0-9段码是共阴极的按位取反比如0是8hC0。第三是扫描时钟clk这里应该是1kHz需要配合第2章的pulse_gen分频产生或者单独再写一个分频模块。消影在代码里是怎么实现的位选是组合逻辑即时切换而段码经过了一拍寄存器。位选切换的那一拍seg还维持旧值从硬件上看就是位选和段码错开了一拍旧段码会落在新位置上一个很短的时间。因为扫描时钟是1kHz这一拍只有1ms实际显示基本看不出问题。追求完美的话可以在位选切换的边界把seg强制清零方法是额外增加一个使能逻辑这里不再展开。至此显示链路通了bcd_in来自三个计数器的ones/tens拼接每毫秒扫描一位反复循环。接线时建议在顶层模块用assign把bcd_in拼好保持计数器模块不被显示模块污染。4. 闹铃、调时与整点报时把功能键嵌进状态机4.1 按键消抖20ms窗口与单脉冲产生机械按键按下和释放的瞬间金属触点会抖动几毫秒到十几毫秒如果不处理一次按下会被电路识别成多次。常见做法是检测电平连续稳定一段时间后才认为按键有效。我习惯用20ms窗口覆盖绝大多数按键的抖动范围。module key_debounce #( parameter CLK_FREQ 1_000_000 // 输入时钟频率默认 1MHz )( input wire clk, input wire rst_n, input wire key_in, // 按键输入按下为低电平 output reg key_pulse // 按下瞬间产生的高脉冲持续一个时钟周期 ); localparam MAX_CNT CLK_FREQ / 1000 * 20 - 1; // 20ms 对应的时钟周期数 reg [19:0] cnt; reg key_stable; // 电平稳定判断 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; key_stable 1b1; end else if (key_in key_stable) begin cnt 20d0; // 状态没变化计数清零 end else begin cnt cnt 1b1; if (cnt MAX_CNT) begin key_stable key_in; // 20ms 后仍处于相反电平认为稳定 cnt 20d0; end end end // 边沿检测key_stable 从 1 变 0说明按键按下 reg key_stable_d; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_stable_d 1b1; key_pulse 1b0; end else begin key_stable_d key_stable; key_pulse key_stable_d ~key_stable; end end endmodule这个模块输出的是单周期脉冲不是电平。按键按住不放只在按下沿产生一个脉冲适合“按一下加一”的调时逻辑。如果你想要长按连续加可以再加一个计数逻辑超过1秒后每隔200ms再产生一个脉冲这个留作扩展。注意CLK_FREQ参数如果直接用50MHz给这个模块MAX_CNT会变成一百万20位计数器刚好够我把时钟频率设计成可配置的仿真时可以降到1kHz加速跑。4.2 调时状态机mode键循环切换可调对象一个不带状态机的时钟功能键多了就乱套。我常用的状态划分是五态正常显示、调时、调分、调秒、设闹铃。mode键每按一次切到下一个状态add键在当前状态下把对应寄存器加1还能顺手处理闹铃的时和分设定。localparam S_NORMAL 3d0; // 正常走时 localparam S_SET_H 3d1; // 调时 localparam S_SET_M 3d2; // 调分 localparam S_SET_S 3d3; // 调秒 localparam S_SET_ALM 3d4; // 设闹铃 reg [2:0] state; always (posedge clk or negedge rst_n) begin if (!rst_n) state S_NORMAL; else begin case (state) S_NORMAL: if (mode_pulse) state S_SET_H; S_SET_H: if (mode_pulse) state S_SET_M; S_SET_M: if (mode_pulse) state S_SET_S; S_SET_S: if (mode_pulse) state S_SET_ALM; S_SET_ALM: if (mode_pulse) state S_NORMAL; default: state S_NORMAL; endcase end end调时动作分成两步先决定调哪个对象再决定加多少。对象的选中逻辑很简单add按键的脉冲在S_SET_H状态下拉高counter24的load_en并把当前值加1后从load_ones/load_tens送回S_SET_S和S_SET_M同理。这一步有一个容易被忽略的细节调秒的时候最好能把秒计数器一次性归零让秒从0开始走否则要从当前秒值按一下加一秒太费事。闹铃设定状态同理。闹铃寄存器和走时计数器是两套寄存器设闹铃时改的是alm_hour、alm_min、alm_sec不影响当前走时。显示模块需要一个显示源选择正常态显示当前时间设闹铃态显示闹铃时间。我喜欢在顶层用一个2选1的mux把bcd_in的来源切一下这样显示模块完全不用改。4.3 闹铃比较与响铃控制精确到分钟还是秒闹铃比较的写法取决于你想要“秒级准点”还是“分钟级准点”。精确到秒的写法是时、分、秒三组值全相等触发点是设定的那个秒数精确到分钟的写法是时、分相等且当前秒为0触发点是设定分钟的第0秒。桌面闹钟大多用后者响铃条件少一个寄存器比较也更好解释。// 秒级精确匹配 wire alm_match_sec (cur_hour alm_hour) (cur_min alm_min) (cur_sec alm_sec); // 分钟级匹配 wire alm_match_min (cur_hour alm_hour) (cur_min alm_min) (cur_sec 4d0);响铃不能无限响下去不然没人按停会一直吵。我加了一个30秒的响铃计时器alm_match触发后拉起alm_flag同时开始计数30秒后自动关断期间任意按键按下也直接关断。这里要注意alm_match在30秒窗口内会反复变化所以用“触发后锁存”的方式处理不能每次都重新判断。reg alm_flag; reg [4:0] alm_cnt; // 0~30 秒 always (posedge clk or negedge rst_n) begin if (!rst_n) begin alm_flag 1b0; alm_cnt 5d0; end else if (alm_flag) begin if (any_key_pulse) begin // 任意键停止 alm_flag 1b0; alm_cnt 5d0; end else if (alm_cnt 5d30) begin // 自动停止 alm_flag 1b0; alm_cnt 5d0; end else begin alm_cnt alm_cnt 1b1; end end else if (alm_match_min) begin alm_flag 1b1; alm_cnt 5d0; end end蜂鸣器驱动要看硬件是有源还是无源。有源蜂鸣器给电平就响可以直接用alm_flag驱动无源蜂鸣器需要PWM方波得另做一个分频器产生音频信号再和alm_flag做与逻辑。上板前先查开发板原理图确认是高电平驱动还是低电平驱动否则很可能出现“逻辑对了声音没有”的尴尬。4.4 整点报时直接复用分进位整点报时比闹铃简单得多它不需要独立比较器。分计数器从59:59翻到00:00时产生carry脉冲这个脉冲原本就是时计数器的使能信号同时也标志着“整点刚刚到达”。直接在顶层把分计数器的carry引出来作为报时的触发信号。// 顶层接线示意 wire en_hour; counter60 u_min (... .carry(en_hour) ...); counter24 u_hour (... .en(en_hour) ...); // 整点报时en_hour 每 60 分钟拉高一拍 assign chime_trigger en_hour;报时的具体动作可以是让蜂鸣器响几秒、闪烁一个LED、或者驱动数码管显示整点时间。常见做法是响一秒左右的提示音避免和闹铃响30秒重叠。如果你的报时要求是“响N次”可以再做一个小计数器把en_hour脉冲扩展成1秒窗口这些逻辑都很直观。注意整点报时和闹铃可能在同一个整点撞车设计时要先判闹铃还是先判报时我习惯让闹铃优先毕竟叫醒需求比仪式感重要。5. 上板调试避坑记录5个必踩的坑5.1 仿真全对、上板不亮先查复位和时钟现象testbench里波形完美秒计数、进位、显示全部正常烧到板子上数码管一个不亮或者隔几秒才有微弱反应。原因最常见的是时钟频率和复位极性搞混。开发板的晶振频率不一定是50MHz有的是12MHz、24MHz、100MHz分频参数写错的话秒脉冲周期会变成实测周期的若干倍看起来就是“不走”。另外异步复位是低有效还是高有效每个板子约定不同复位管脚悬空或者反接会导致模块一直处在复位状态。解决先用最简单的blinky验证通路。写一个分频到1Hz的脉冲直接接一个LED翻转确认开发板时钟频率和复位极性。这一步通过了再做时钟项目。上板调试一定要留出可观测信号仿真漂亮不是终点LED闪烁能证明时钟树正确。下面这几条都是时钟项目里出现频率最高的翻车现场几乎每条我都见人踩过。5.2 数码管乱闪带拖影刷新率和消影没做好现象六个数码管同时显示但亮度不均或者某个数字“挂”在旁边的位上整体看有鬼影。原因扫描频率太低人眼能看出每位轮流亮的节奏或者位选切换时没有消影旧段码残留到新位选上。还有可能是1kHz扫描时钟没分出来直接用了系统时钟导致每位点亮时间短到几乎不亮。解决第一步确认扫描时钟频率用示波器或让扫描时钟驱动LED看看闪烁节奏。第二步确认消影检查段码输出是否打拍以及位选切换时是否先关闭段码。第三步尝试把每位点亮时间从1ms调整到2ms拖影会增加但亮度更足属于工程权衡。5.3 秒走快或走慢分频参数和计数器位宽现象和手机对表一小时能差几秒甚至几分钟。分频参数差1理论上每个50M周期差1个周期一小时累计差约70微秒肉眼不可见。真正差得多的是参数位数不够计数到最大值就溢出翻转了或者CNT_MAX写成了整除后的商而不是周期数减1。原因常见的两个错误一是把CNT_MAX直接写成晶振频率数值忘了减1二是计数器位宽不够50MHz需要26位用了16位信号数值到65535就溢出。两个错误都不会在仿真里暴露因为仿真时间短、频率参数一致。解决把分频参数单独定义为parameter位宽固定在26位CNT_MAXCLK_FREQ-1这样写。有条件的话用频率计或者逻辑分析仪量一下pulse管脚的频率精确对时。不要靠看数码管秒表走字来估误差太小看不见。5.4 按键按一次跳好几个数缺边沿检测现象调时的时候按一下add数字连跳两三个或者跳完又自己跳一下。这是按键抖动没消干净或者是消抖输出还是电平信号直接驱动计数器累加。原因消抖模块只做了电平稳定但输出是电平而不是单脉冲。按键按住期间add信号一直为高只要en条件满足就会持续计数抖动期间也可能出现多次边沿。这在仿真里很难还原因为testbench里按键没有机械抖动模型。解决消抖之后一定做边沿检测只输出一个时钟周期的脉冲。如果已经这样做了还跳把消抖窗口从20ms加到30ms并检查add_pulse是否被多个模块同时使用。用单脉冲驱动状态机和计数器是这次项目里最值得养成的习惯。5.5 闹铃不响或一直响先查硬件驱动再查逻辑现象设定的时间到了蜂鸣器没声音或者上电就一直在响。仿真里闹铃触发一切正常上板后表现和仿真完全对不上。原因蜂鸣器分有源和无源两类有源的给电平就响无源的必须给一定频率的方波。驱动电路如果用了三极管还有个高电平导通还是低电平导通的问题。很多板子的蜂鸣器是低电平有效你拿alm_flag直接驱动逻辑上响铃条件为真反而是关闭蜂鸣器。解决先做一个固定频率的输出比如直接用分频后的1kHz方波去驱动蜂鸣器确认硬件通路和有效电平。再把alm_flag和使能结合无源蜂鸣器需要把音频方波和alm_flag做与运算。上板调试时给闹铃设成当前时间后一两分钟别设到几小时后否则等出问题的耐心会耗尽。6. 快速验证技巧把仿真时间“压缩”到秒级时钟项目最难受的验证点是“真实时间太长”。一个完整小时进位要3600秒真实跑一遍谁等得起但仿真里如果分频参数保持50MHz模拟一天需要8.6亿个时钟周期testbench也扛不住。解决办法在前面埋了伏笔pulse_gen的CNT_MAX是可覆盖的parameter。仿真testbench里例化时直接改参数把一秒钟压缩成100个系统时钟周期甚至10个。这样验证“08:59:59→09:00:00”只需要几十个时钟周期整段测试瞬间跑完波形上能清清楚楚看到个位归0、十位归0、进位拉高一拍。pulse_gen #(.CNT_MAX(99)) u_pulse ( .clk(clk), .rst_n(rst_n), .pulse(sec_pulse) );边界用例不用穷举盯住这几个进位点就够了用例关注点23:59:59 → 00:00:00小时十位 2→0跨天翻转09:59:59 → 10:00:00小时十位 0→1首次越过 919:59:59 → 20:00:00小时十位 1→208:59:59 → 09:00:00小时个位 9→0十位保持 0每个边界检查都确认三件事个位BCD不出现A-F十位不越过5小时十位不越过2carry只拉高一拍。想偷懒又想严谨可以写一个自动检查的仿真脚本每来一个秒脉冲就比对一次BCD值是否落在合法集合里不合法立刻报错几秒钟跑完几万拍。我这个项目里吃过一点亏当时只顾着手动看波形漏测了23:59:59到00:00:00的跨天结果上板后每到午夜时刻显示就乱一下。后来养成习惯把边界用例写进testbench验证做扎实了上板才敢谈玄学。希望这个技巧帮到你。本文还有配套的精品资源点击获取