
简介这份棋钟电子秒表设计基于Vivado工具链完成面向FPGA课程设计与数字逻辑实验场景帮助学习者掌握分频、计时、按键消抖、状态机控制及外设驱动等核心知识点。工程共包含391个文件包体约1.07MB以8个Verilog源码文件.v为核心辅以XDC约束文件、DCP综合/实现结果、BIT配置文件以及数量丰富的RPT报告和RST/TXT说明文档构建出完整可综合的FPGA工程结构。设计实现了棋类比赛中15秒/25秒两档倒计时切换、双人交替按键启动对方计时、最后5秒蜂鸣器与LED同步提示以及超时判负后长鸣并显示获胜方编号等功能具备清晰的模块划分和扩展空间。目前已有157人学习下载适合需要参考完整课设方案或快速上手Vivado数字系统设计的同学使用。1. 棋钟计时的核心矛盾为什么数字秒表在FPGA上要拆成状态机分频做棋钟课设时最容易踩的坑是把“倒计时”想成一个简单的减法器按下键就开始减减到0就响。真用Vivado写起来你会发现问题根本不在减法而在“谁在计时”这组状态迁移上。A落子按A键A方秒表恢复初值B方秒表开始倒计时B落子再切回来。这个乒乓逻辑一旦和按键抖动、秒脉冲分频、5秒提示、超时判负混在一起代码会迅速膨胀。本文以一套可综合的Verilog棋钟为例从分频、消抖、状态机、数码管显示到比特流生成讲清怎么把课设要求落成bit流以及生成比特流失败、仿真闪退这些常见坑。适合正在做课设的学生也适合想用FPGA实现计时器但不想用IP核的工程师。2. Vivado工程结构与棋钟模块划分从顶层代码到bit流生成拿到课设包里面通常有runme.bat、top_bit等文件。runme.bat是Vivado批处理脚本按顺序跑综合、布局、布线和生成比特流top_opt.dcp、top_placed.dcp、top_routed.dcp分别对应优化后、布局后、布线后的网表快照top.bit是最终下载文件。如果你按Vivado安装教程装好环境直接在GUI里新建工程也能复现不一定要跑runme.bat。但看清这批文件能帮你判断一份课设是否真正走到了Generate Bitstream这一步也能在答辩时说明你了解非工程模式的产出物。2.1 顶层模块信号定义与管脚规划创建RTL工程后我习惯先把顶层信号定义清楚再去写内部逻辑。棋钟顶层至少需要两个按键、一个蜂鸣器、几位LED和若干位选段选信号。以一块常见Artix-7开发板为例顶层接口可以这样设计module chess_clock_top #( parameter DIV_CNT 100000, parameter DEB_CNT 1000000 )( input wire clk_100m, input wire rst_n, input wire key_a, input wire key_b, input wire mode_select, output wire beep, output wire [3:0] led, output wire [5:0] sel, output wire [7:0] seg ); endmoduleclk_100m是板载晶振rst_n低电平复位key_a和key_b是A/B两枚按键按下表示该方落子完成mode_select用于选择15秒或25秒初始值我在课设里用一个拨码开关输入。beep、led、sel、seg都是典型输出有源蜂鸣器只要拉高就会响。DIV_CNT和DEB_CNT两个参数留着仿真时用参数覆盖来加速后面会详细说。管脚分配应该单独写在XDC约束里不写在Verilog中。下面是一张常见的信号规划表实际管脚号以开发板原理图为准信号方向位宽建议连接clk_100minput1晶振单端或差分时钟脚rst_ninput1拨码开关或独立按键key_ainput1独立按键Akey_binput1独立按键Bmode_selectinput1拨码开关beepoutput1蜂鸣器驱动管脚ledoutput4LED灯seloutput6数码管位选segoutput8数码管段选把顶层接口写全的好处是后续模块例化时不会出现信号名不一致的问题。很多课设失败不是因为算法而是顶层漏接信号综合成功但上板完全没反应。2.2 分频模块从100MHz到1Hz的计数器棋钟里最难说清的是时间基准。板载100MHz时钟如果直接用于计数每一拍只有10ns倒计时到秒需要数一亿个数。更关键的是蜂鸣器需要1kHz左右的短音脉冲倒计时需要1Hz的秒脉冲如果各写一个分频器逻辑会重复且难维护。我通常先分频到1kHz再用时钟使能方式生成1Hz。module clk_div #(parameter DIV_CNT 100000) ( input wire clk, input wire rst_n, output reg clk_1k_en ); reg [16:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; clk_1k_en 0; end else if (cnt DIV_CNT - 1) begin cnt 0; clk_1k_en 1; end else begin cnt cnt 1b1; clk_1k_en 0; end end endmoduleDIV_CNT表示计数多少个时钟产生一个使能脉冲。100MHz分频到1kHzDIV_CNT100000分频到1Hz则需要在1kHz使能下再计1000次。注意clk_1k_en不是门控时钟它每个时钟周期只拉高一个周期这样的写法综合后不会引入新时钟域时序报告会更干净。下面在倒计时模块里再对clk_1k_en计数生成second_en。如果把DIV_CNT写成10仿真时秒信号会被加速一万倍这在testbench里非常有用。reg [9:0] sec_cnt; wire tick_1s; always (posedge clk or negedge rst_n) begin if (!rst_n) sec_cnt 0; else if (clk_1k_en) begin if (sec_cnt 999) sec_cnt 0; else sec_cnt sec_cnt 1b1; end end assign tick_1s (sec_cnt 999) clk_1k_en;2.3 按键消抖与A/B回合切换状态机按键是异步输入按下瞬间会有几毫秒抖动。直接作为状态机触发信号一次按键可能产生多个脉冲导致回合切错。常见做法是连续采样一段时间电平稳定后才认为有效。消抖代码我习惯放在顶层模块内部这样可以直接使用DEB_CNT参数reg [19:0] deb_cnt; reg key_a_d0, key_a_d1; reg key_a_stable; always (posedge clk or negedge rst_n) begin if (!rst_n) begin key_a_d0 0; key_a_d1 0; key_a_stable 0; deb_cnt 0; end else begin key_a_d0 key_a; key_a_d1 key_a_d0; if (key_a_d0 key_a_d1) begin if (deb_cnt DEB_CNT - 1) begin key_a_stable key_a_d1; end else begin deb_cnt deb_cnt 1b1; end end else begin deb_cnt 0; key_a_stable 0; end end end reg key_a_stable_d; always (posedge clk or negedge rst_n) begin if (!rst_n) key_a_stable_d 0; else key_a_stable_d key_a_stable; end wire key_a_pulse key_a_stable ~key_a_stable_d;当按键电平连续DEB_CNT个时钟不变时认为消抖完成。默认DEB_CNT1000000100MHz下对应10ms抗抖效果足够。key_a_pulse是稳定信号延迟一拍后产生的上升沿脉冲只持续一个时钟周期正好作为状态机切换条件。如果板子按键按下为低电平需要在采样前对key_a取反否则状态机会反过来工作。状态机部分重点在回合切换的方向。规则是A落子按下A键A方秒表恢复初值并停止B方开始倒计时B落子按下B键则反过来。所以当前A在计时时一定是用key_a_pulse触发切到B计时localparam RUN_A 2d1, RUN_B 2d2; reg [1:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state RUN_A; else state next_state; end always (*) begin next_state state; case (state) RUN_A: if (key_a_pulse) next_state RUN_B; RUN_B: if (key_b_pulse) next_state RUN_A; default: next_state RUN_A; endcase end组合逻辑块里必须先给next_state赋默认值否则综合时会推断出锁存器状态机行为会变得非常诡异。按键和状态的对应关系也很容易写反判断标准是当前谁在倒计时谁落子后切换。A在计时时A落子按A键切到B计时所以RUN_A用key_a_pulse触发RUN_B用key_b_pulse触发。2.4 倒计时初值15/25秒的设置逻辑题目要求初始值可设15秒或25秒。我用一个拨码开关mode_select0对应15秒1对应25秒。回合恢复时把初值加载到时间寄存器中。加载逻辑必须放在减法逻辑的优先级前面否则按键和秒脉冲同时到达时先减一秒再恢复初值时间会多出一秒。mode_select初值倒计时范围典型场景015秒15到0快棋赛125秒25到0常规赛倒计时逻辑如下wire [5:0] init_value mode_select ? 6d25 : 6d15; reg [5:0] time_a, time_b; always (posedge clk or negedge rst_n) begin if (!rst_n) begin time_a init_value; time_b init_value; end else begin if (key_a_pulse) begin time_a init_value; end else if (tick_1s (state RUN_A) (time_a ! 0)) begin time_a time_a - 1b1; end if (key_b_pulse) begin time_b init_value; end else if (tick_1s (state RUN_B) (time_b ! 0)) begin time_b time_b - 1b1; end end endtime_a只在RUN_A状态下递减time_b只在RUN_B状态下递减天然互斥不会出现两个时间同时减少的情况。key_a_pulse的优先级高于tick_1s落子瞬间立即恢复初值不会等到下一个秒脉冲才加载。time_a和time_b用6位足够容纳25以内数值即使以后扩展成60秒也不怕。到这里棋钟的核心骨架已经完成后面的提示音、显示和排错都建立在这个状态机和两组计数寄存器上。3. 功能实现5秒提示、超时判负与数码管显示核心倒计时就绪后剩下的是把“5秒内提醒”和“判负显示”做干净。不少课设到这里把逻辑全部塞进一个always块里代码又长又难看。我建议分成“时间判断”和“输出驱动”两块先把当前是谁计时这个信息抽成公共信号再分别驱动蜂鸣器、LED和数码管。3.1 蜂鸣器与LED的联动控制需要明确三种行为5秒内每秒短响并闪灯、超时后长响并持续闪灯、正常期不响不闪。下面这个表描述行为编码时逐条对应不容易漏当前情况蜂鸣器LED当前方剩余时间 5秒不响不闪当前方剩余时间 1到5秒每秒响一下每秒闪一下当前方剩余时间 0长响持续闪烁根据当前处于A计时还是B计时选中对应的剩余时间wire [5:0] cur_remain (state RUN_A) ? time_a : time_b; wire cur_timeout (state RUN_A) ? (time_a 0) : (time_b 0); reg beep_reg; reg led_reg; reg [3:0] beep_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin beep_reg 0; led_reg 0; beep_cnt 0; end else begin if (cur_timeout) begin beep_reg 1b1; led_reg ~led_reg; end else if (cur_remain 6d5 cur_remain ! 0) begin if (tick_1s) begin beep_reg 1b1; led_reg ~led_reg; beep_cnt 0; end else if (clk_1k_en beep_reg) begin if (beep_cnt 4d4) begin beep_reg 1b0; end else begin beep_cnt beep_cnt 1b1; end end end else begin beep_reg 0; beep_cnt 0; end end endtick_1s到来时把beep_reg拉高蜂鸣器开始响同时led_reg翻转一次。因为每个clk_1k_en周期beep_cnt加15个1kHz周期后beep_reg拉低所以每次提示音持续约5ms听起来就是清脆的“嘀”一下。cur_timeout分支里beep_reg持续为高对应“长响”led_reg每个时钟翻转一次实际人眼看到的就是持续闪烁。有源蜂鸣器直接拉高会长鸣无源蜂鸣器需要PWM驱动课设板子一般是有源蜂鸣器这段代码够用。3.2 数码管动态扫描显示A/B胜出数码管是典型的动态扫描外设逐位轮流点亮利用视觉暂留让人以为六位同时亮。扫描周期建议在1到2ms切换一位过短会拖低亮度过长会有闪烁感。先把二进制时间拆成两位BCD一位十位一位个位wire [3:0] a_tens (time_a 20) ? 4d2 : (time_a 10) ? 4d1 : 4d0; wire [3:0] a_ones time_a - a_tens * 4d10; wire [3:0] b_tens (time_b 20) ? 4d2 : (time_b 10) ? 4d1 : 4d0; wire [3:0] b_ones time_b - b_tens * 4d10;a_tens和b_tens只有0、1、2三种取值25秒以内足够。接下来是动态扫描主体。这里我把胜负判断的优先级放在最前面避免时间归零瞬间数码管闪一下00再变成字母wire a_win cur_timeout (state RUN_B); wire b_win cur_timeout (state RUN_A); reg [2:0] scan_cnt; always (posedge clk_1k_en) scan_cnt scan_cnt 1b1; reg [5:0] sel_reg; reg [3:0] disp_data; always (*) begin case (scan_cnt) 3d0: sel_reg 6b111110; 3d1: sel_reg 6b111101; 3d2: sel_reg 6b111011; 3d3: sel_reg 6b110111; default: sel_reg 6b111111; endcase if (a_win) disp_data 4d10; else if (b_win) disp_data 4d11; else begin case (scan_cnt) 3d0: disp_data a_tens; 3d1: disp_data a_ones; 3d2: disp_data b_tens; 3d3: disp_data b_ones; default: disp_data 4d0; endcase end enda_win表示B超时A获胜b_win表示A超时B获胜。disp_data用10和11分别表示字母A和b这样在段码翻译时直接映射。共阴数码管段码如下共阳取反即可字符共阴段码00x3F10x0620x5B30x4F40x6650x6D60x7D70x0780x7F90x6FA0x77b0x7C段码输出的组合逻辑就是查表reg [7:0] seg_reg; always (*) begin case (disp_data) 4d0: seg_reg 8h3F; 4d1: seg_reg 8h06; 4d2: seg_reg 8h5B; 4d3: seg_reg 8h4F; 4d4: seg_reg 8h66; 4d5: seg_reg 8h6D; 4d6: seg_reg 8h7D; 4d7: seg_reg 8h07; 4d8: seg_reg 8h7F; 4d9: seg_reg 8h6F; 4d10: seg_reg 8h77; 4d11: seg_reg 8h7C; default: seg_reg 8h00; endcase end把这块组合逻辑放在段码输出最前端优先级天然高于数字显示。扫描位选是低有效还是高有效由板卡决定上板后如果数码管乱跳先检查sel_reg的极性和时钟频率不要急着怀疑代码逻辑。3.3 仿真验证与testbench编写状态机这种时序逻辑不仿真直接烧板故障出现时很难定位。Vivado自带XSim足够跑棋钟这种小设计。仿真里一个常见问题是真实分频参数太大15秒的倒计时仿真要跑15亿个时钟周期完全没有意义。所以分频和消抖参数必须参数化testbench里覆盖成小值快速跑通timescale 1ns/1ps module tb_chess_clock; reg clk_100m 0; reg rst_n 0; reg key_a 0; reg key_b 0; reg mode_select 0; wire beep; wire [3:0] led; wire [5:0] sel; wire [7:0] seg; chess_clock_top #( .DIV_CNT(10), .DEB_CNT(4) ) uut ( .clk_100m(clk_100m), .rst_n(rst_n), .key_a(key_a), .key_b(key_b), .mode_select(mode_select), .beep(beep), .led(led), .sel(sel), .seg(seg) ); always #5 clk_100m ~clk_100m; initial begin #100 rst_n 1b1; #100 key_a 1; #100 key_a 0; #500 key_b 1; #100 key_b 0; #1000 $finish; end endmoduletestbench里forever块产生100MHz时钟initial块在100ns后拉高复位然后模拟A按键和B按键。DIV_CNT改成10之后clk_1k_en每10个时钟产生一次秒脉冲再对clk_1k_en计1000次也就是每100us产生一个tick_1s15秒倒计时仿真约1.5ms就能跑完完全在XSim可接受范围内。DEB_CNT改成4消抖时间也只有几个时钟周期。想验证超时就设置mode_select0不按任何键等150个tick_1s观察beep是否会稳定拉高。如果这里发现状态切换不对优先查key_a_pulse的上升沿检测很多同学卡在这一步。4. Vivado综合实现与比特流生成的常见问题代码能仿真通过接下来就是让它变成可以下载的bit文件。很多课设卡在这里出错的点其实高度一致。尤其是第一次生成比特流报错信息一大片但真正需要看的只有几行。4.1 约束文件XDC写法与时钟约束Vivado使用XDC文件做约束它不是Verilog而是Tcl语法。最小约束除了管脚分配一定要有时钟约束否则时序分析没有基准。很多新手只写PACKAGE_PIN综合布线都能过但打开Timing Summary全是无约束的时序路径。约束建立时间这类问题根源就在create_clock没写或写错。set_property PACKAGE_PIN N18 [get_ports clk_100m] set_property IOSTANDARD LVCMOS33 [get_ports clk_100m] create_clock -period 10.000 -name sys_clk [get_ports clk_100m] set_property PACKAGE_PIN P1 [get_ports key_a] set_property IOSTANDARD LVCMOS33 [get_ports key_a]period 10ns对应100MHz。如果板卡时钟是50MHz这里要改成20ns。IO标准必须和板卡一致常见是LVCMOS33也有的板卡是LVCMOS18。XDC注释用#不要用//。从网上下载的约束文件如果本身有问题Vivado会在Import Constraints时报错先看Messages窗口而不是直接重新生成bit。管脚冲突这类问题也在这个阶段暴露比如两个信号被分到同一个物理管脚Implementation会在DRC阶段直接拦截。4.2 生成比特流失败排查时序未收敛与管脚冲突vivado生成比特流失败是很多新手搜得最多的问题之一。最常见的是Implementation后时序未收敛报告里显示setup违例且WNS为负。这时候不要一上来就调实现策略先打开Schematic看状态机的路径。最典型的病根是把分频器输出当成了时钟边沿// 错误写法把 clk_1k 当成时钟 always (posedge clk_1k) cnt cnt 1b1; // 正确写法clk_1k作为时钟使能 always (posedge clk or negedge rst_n) if (!rst_n) cnt 0; else if (clk_1k_en) cnt cnt 1b1;第一段代码综合后clk_1k会被当作门控时钟或衍生时钟导致路径约束特别难收敛。改成第二段后所有时序路径都回到clk_100m域布局布线压力会小很多。如果代码没问题但时序仍然差一点可以在Implementation Settings里把Strategy从Vivado Implementation Defaults改成性能优先的Performance_Explore也就是俗称的改实现策略。Vivado会换一批布局布线算法有时能把WNS直接拉正。再不行就检查是不是用了太多组合逻辑比如把数码管段码转换写成一个巨大case这种组合路径在100MHz下一般没事但加上扫描选择后可能变长。管脚冲突是另一个频发点。XDC里给两个端口分配同一个管脚Vivado在Implementation阶段报错有的板卡按键按下是低电平代码里却按高电平消抖bit流能生成但上板一按就乱跳。下面表里整理了三种最常见的生成比特流失败场景报错类型常见原因排查方向Timing分频输出被当时钟、计数器位宽不够改成时钟使能写法DRC约束管脚冲突、IO标准不一致检查XDC管脚表BitstreamIP核未生成完整、工程路径有中文打开IP重新Generate Output Products4.3 仿真闪退与中文注释乱码的工程习惯Vivado仿真闪退很多情况下不是软件崩溃而是仿真时间没设好。testbench里forever块会一直产生时钟仿真器默认跑无限长时间如果忘记$finish工程大的时候会卡死甚至闪退。除了在testbench末尾加#1000 $finish;之外Simulation Settings里的xsim.simulate.runtime也要设一个数值比如10us防止仿真退出时资源没有释放。另外XSim对系统路径比较敏感工程不要放在带中文或空格的路径下。中文注释乱码在Vivado里太常见了。Vivado自带编辑器默认按系统本地编码解析文件Windows中文系统下新建的.v文件用GB2312保存而工程内部按UTF-8处理注释就会变成乱码。有人把vivado语言设置成中文后反而更乱因为这些设置只改界面语言不影响文件编码。我的习惯是统一用VS Code或Notepad把.v文件保存成UTF-8 without BOM工程路径和文件名全部用英文注释里不出现全角标点。已经乱码的文件用Notepad批量转换编码能救回大部分内容但最稳定的方式还是一开始就统一编码。5. 把棋钟扩展成真正能用的计时器的几个验证细节课设交差后如果你想继续加功能或者在上板演示时少出问题下面三个细节值得做。5.1 用ILA抓状态机跳变Vivado的ILA不需要写testbench直接在综合后的设计里把state、time_a、time_b标记为debug信号重新综合布局布线上板打开Hardware Manager。设置触发条件为state RUN_A抓一拍看按下key_a后是否跳到RUN_B。ILA波形能直接验证消抖是否生效也能看到time_a恢复初值的时刻和状态切换是否同一拍。对于棋钟这种小设计ILA占用的逻辑资源可以忽略但它能帮你省下大量上板盲调的时间。5.2 分频系数校准板载晶振有误差长时间计时会漂。用ILA或示波器量一下tick_1s脉冲的实际周期假设实测是1.0003s说明时钟偏快DIV_CNT应该从100000调大一点。计算公式是新DIV_CNT 旧DIV_CNT * 实测周期(ms) / 1ms。把计算结果写回分频模块的参数重新生成bit后用秒表卡10分钟微调到误差小于0.1秒。这个校准过程本身就是课设答辩里很加分的亮点。5.3 一个容易忽略的边界剩余0秒的显示超时判负那一拍数码管很容易先显示00再显示A或b看起来非常不专业。原因是扫描逻辑的优先级没有把胜负判断放在最前面。我在3.2节把段码输出的第一级设置为a_win/b_win判断数字显示只作为默认分支这样time_a减到0的下一个扫描周期数码管立刻显示胜方字母不会闪现00。同时倒计时逻辑里的time_a ! 0判断也不能省否则time_a会从0减到63产生一个错误的大数BCD转换立刻出错。把胜负显示优先级放在段码转换最前端再配合ILA观察状态切换棋钟的演示效果就能稳定下来了。如果还打算继续扩展可以把mode_select扩展成四档初值或者加一个暂停键——暂停键的实现与普通按键消抖完全一致只是在状态机里插入一个PAUSE状态在RUN_A和RUN_B的case分支里加入对暂停键的判断即可。状态机多一个状态显示和倒计时逻辑基本不用动这也是棋钟课设真正考察状态机设计能力的地方。本文还有配套的精品资源点击获取