简介:这份棋钟电子秒表设计基于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_100m | input | 1 | 晶振单端或差分时钟脚 |
| rst_n | input | 1 | 拨码开关或独立按键 |
| key_a | input | 1 | 独立按键A |
| key_b | input | 1 | 独立按键B |
| mode_select | input | 1 | 拨码开关 |
| beep | output | 1 | 蜂鸣器驱动管脚 |
| led | output | 4 | LED灯 |
| sel | output | 6 | 数码管位选 |
| seg | output | 8 | 数码管段选 |
把顶层接口写全的好处是,后续模块例化时不会出现信号名不一致的问题。很多课设失败不是因为算法,而是顶层漏接信号,综合成功但上板完全没反应。
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 + 1'b1; clk_1k_en <= 0; end end endmoduleDIV_CNT表示计数多少个时钟产生一个使能脉冲。100MHz分频到1kHz,DIV_CNT=100000;分频到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 + 1'b1; 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 + 1'b1; 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_CNT=1000000,100MHz下对应10ms,抗抖效果足够。key_a_pulse是稳定信号延迟一拍后产生的上升沿脉冲,只持续一个时钟周期,正好作为状态机切换条件。如果板子按键按下为低电平,需要在采样前对key_a取反,否则状态机会反过来工作。
状态机部分,重点在回合切换的方向。规则是:A落子按下A键,A方秒表恢复初值并停止,B方开始倒计时;B落子按下B键则反过来。所以当前A在计时时,一定是用key_a_pulse触发切到B计时:
localparam RUN_A = 2'd1, RUN_B = 2'd2; 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_select,0对应15秒,1对应25秒。回合恢复时,把初值加载到时间寄存器中。加载逻辑必须放在减法逻辑的优先级前面,否则按键和秒脉冲同时到达时,先减一秒再恢复初值,时间会多出一秒。
| mode_select | 初值 | 倒计时范围 | 典型场景 |
|---|---|---|---|
| 0 | 15秒 | 15到0 | 快棋赛 |
| 1 | 25秒 | 25到0 | 常规赛 |
倒计时逻辑如下:
wire [5:0] init_value = mode_select ? 6'd25 : 6'd15; 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 - 1'b1; 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 - 1'b1; 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 <= 1'b1; led_reg <= ~led_reg; end else if (cur_remain <= 6'd5 && cur_remain != 0) begin if (tick_1s) begin beep_reg <= 1'b1; led_reg <= ~led_reg; beep_cnt <= 0; end else if (clk_1k_en && beep_reg) begin if (beep_cnt == 4'd4) begin beep_reg <= 1'b0; end else begin beep_cnt <= beep_cnt + 1'b1; end end end else begin beep_reg <= 0; beep_cnt <= 0; end end endtick_1s到来时把beep_reg拉高,蜂鸣器开始响,同时led_reg翻转一次。因为每个clk_1k_en周期beep_cnt加1,5个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) ? 4'd2 : (time_a >= 10) ? 4'd1 : 4'd0; wire [3:0] a_ones = time_a - a_tens * 4'd10; wire [3:0] b_tens = (time_b >= 20) ? 4'd2 : (time_b >= 10) ? 4'd1 : 4'd0; wire [3:0] b_ones = time_b - b_tens * 4'd10;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 + 1'b1; reg [5:0] sel_reg; reg [3:0] disp_data; always @(*) begin case (scan_cnt) 3'd0: sel_reg = 6'b111110; 3'd1: sel_reg = 6'b111101; 3'd2: sel_reg = 6'b111011; 3'd3: sel_reg = 6'b110111; default: sel_reg = 6'b111111; endcase if (a_win) disp_data = 4'd10; else if (b_win) disp_data = 4'd11; else begin case (scan_cnt) 3'd0: disp_data = a_tens; 3'd1: disp_data = a_ones; 3'd2: disp_data = b_tens; 3'd3: disp_data = b_ones; default: disp_data = 4'd0; endcase end enda_win表示B超时,A获胜;b_win表示A超时,B获胜。disp_data用10和11分别表示字母A和b,这样在段码翻译时直接映射。共阴数码管段码如下,共阳取反即可:
| 字符 | 共阴段码 |
|---|---|
| 0 | 0x3F |
| 1 | 0x06 |
| 2 | 0x5B |
| 3 | 0x4F |
| 4 | 0x66 |
| 5 | 0x6D |
| 6 | 0x7D |
| 7 | 0x07 |
| 8 | 0x7F |
| 9 | 0x6F |
| A | 0x77 |
| b | 0x7C |
段码输出的组合逻辑就是查表:
reg [7:0] seg_reg; always @(*) begin case (disp_data) 4'd0: seg_reg = 8'h3F; 4'd1: seg_reg = 8'h06; 4'd2: seg_reg = 8'h5B; 4'd3: seg_reg = 8'h4F; 4'd4: seg_reg = 8'h66; 4'd5: seg_reg = 8'h6D; 4'd6: seg_reg = 8'h7D; 4'd7: seg_reg = 8'h07; 4'd8: seg_reg = 8'h7F; 4'd9: seg_reg = 8'h6F; 4'd10: seg_reg = 8'h77; 4'd11: seg_reg = 8'h7C; default: seg_reg = 8'h00; 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 = 1'b1; #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_1s,15秒倒计时仿真约1.5ms就能跑完,完全在XSim可接受范围内。DEB_CNT改成4,消抖时间也只有几个时钟周期。想验证超时,就设置mode_select=0,不按任何键,等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 + 1'b1; // 正确写法:clk_1k作为时钟使能 always @(posedge clk or negedge rst_n) if (!rst_n) cnt <= 0; else if (clk_1k_en) cnt <= cnt + 1'b1;第一段代码综合后,clk_1k会被当作门控时钟或衍生时钟,导致路径约束特别难收敛。改成第二段后,所有时序路径都回到clk_100m域,布局布线压力会小很多。如果代码没问题但时序仍然差一点,可以在Implementation Settings里把Strategy从Vivado Implementation Defaults改成性能优先的Performance_Explore,也就是俗称的改实现策略。Vivado会换一批布局布线算法,有时能把WNS直接拉正。再不行就检查是不是用了太多组合逻辑,比如把数码管段码转换写成一个巨大case,这种组合路径在100MHz下一般没事,但加上扫描选择后可能变长。
管脚冲突是另一个频发点。XDC里给两个端口分配同一个管脚,Vivado在Implementation阶段报错;有的板卡按键按下是低电平,代码里却按高电平消抖,bit流能生成,但上板一按就乱跳。下面表里整理了三种最常见的生成比特流失败场景:
| 报错类型 | 常见原因 | 排查方向 |
|---|---|---|
| Timing | 分频输出被当时钟、计数器位宽不够 | 改成时钟使能写法 |
| DRC | 约束管脚冲突、IO标准不一致 | 检查XDC管脚表 |
| Bitstream | IP核未生成完整、工程路径有中文 | 打开IP重新Generate Output Products |
4.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分支里加入对暂停键的判断即可。状态机多一个状态,显示和倒计时逻辑基本不用动,这也是棋钟课设真正考察状态机设计能力的地方。
本文还有配套的精品资源,点击获取