拓冰建站拓冰建站
首页 / 资讯中心 / 正文

FPGA数字时钟设计:Verilog实现七段管驱动与动态扫描

1. 项目概述从零到一构建一个七段管数字时钟最近在论坛上看到不少朋友对用Verilog在FPGA上实现一个数字时钟很感兴趣尤其是驱动七段数码管显示的部分。这确实是一个绝佳的入门项目它几乎涵盖了数字逻辑设计的核心计数器、分频器、状态机、数据转换和显示驱动。我自己带学生做项目时也常把这个作为第一个综合实验。今天我就结合“EDA设计verilog—— 七段管时钟”这个标题把从设计思路到代码实现再到Modelsim仿真和上板调试的完整过程以及我踩过的那些坑系统地梳理一遍。无论你是刚接触Verilog和FPGA的新手还是想巩固一下数字系统设计流程的老手这篇内容都能给你提供一个可直接复现的参考方案。这个项目的核心目标很简单用Verilog HDL语言设计一个能在FPGA开发板上运行、并通过多个七段数码管显示时、分、秒的电子时钟。它听起来基础但要做好却需要处理好几个关键环节如何将一个50MHz的系统时钟转换成精确的1Hz秒信号如何实现60进制和24进制的计数如何将二进制表示的数值转换成七段管每个段a, b, c, d, e, f, g的亮灭控制信号以及如何让多个数码管轮流点亮动态扫描以节省FPGA的引脚资源接下来我们就逐一拆解。2. 整体架构与模块划分思路一个稳健的数字时钟设计切忌把所有逻辑都塞进一个always块里。模块化设计是王道它能让代码清晰、可维护、易于调试。对于这个七段管时钟我通常将其划分为四个核心模块层次非常清晰。2.1 顶层模块扮演“总指挥”角色顶层模块例如命名为top_digital_clock不处理具体的计时或显示逻辑它的职责是“连线”。它负责实例化所有子模块并将它们正确地连接起来。这就像是一个项目的总工程师确保各个部门子模块之间的沟通渠道畅通。在这个模块中你会定义整个系统对外的接口输入的系统时钟sys_clk、复位信号sys_rst_n以及输出到开发板数码管的段选信号seg和位选信号sel。2.2 时钟分频模块系统的“心跳”发生器FPGA开发板提供的晶振时钟通常是50MHz或100MHz频率非常高。而我们的时钟需要以1秒为步长跳动因此需要一个分频模块来产生精确的1Hz时钟信号作为计时基准。这里的关键在于“精确”二字。直接使用一个巨大的分频计数器比如从0数到49,999,999在理论上是可行的但实际中更稳健的做法是先生成一个较低频率的中间时钟例如1kHz再用它去驱动计时逻辑这样可以降低计时模块的时钟域频率有利于时序收敛。我会在后面的实操部分详细解释两种方法的利弊和代码实现。2.3 计时核心模块真正的“时钟大脑”这是项目的逻辑核心。它接收1Hz的时钟信号实现秒、分、时的计数。需要特别注意以下几点进制处理秒和分是60进制0-59小时是24进制0-23。当秒计数到59时下一个时钟沿到来秒应归零同时分计数加1。分和时的进位关系同理。复位同步确保在复位信号有效时所有计时寄存器都能回到一个确定的初始值如12:00:00。寄存器输出计时模块输出的时、分、秒数值应该是稳定的寄存器输出避免产生毛刺影响后续显示。2.4 显示驱动模块负责“翻译”和“刷新”这个模块任务最繁重它要完成两件事数码管译码将4位二进制表示的十进制数BCD码0-9转换成控制七段数码管各段亮灭的7位信号假设共阴极数码管1点亮0熄灭。例如数字“0”需要点亮a, b, c, d, e, f段对应的7位输出就是7b1111110这里假设seg[6:0]对应 a,b,c,d,e,f,g。动态扫描为了用有限的IO口驱动多个数码管我们采用动态扫描技术。原理是利用人眼的视觉暂留效应快速轮流点亮每一个数码管。例如有6个数码管显示“时分秒”我们用一个较高的扫描时钟比如1kHz在每个时钟周期内只让一个数码管的位选信号有效低电平使能并同时给出该管需要显示的数字对应的段选信号。只要扫描频率足够快通常60Hz人眼看到的就是一组同时稳定显示的数字。这个模块需要维护一个扫描计数器并实现数据选择器MUX的功能根据当前扫描的位选从时、分、秒数据中选取对应的那位数字送到译码器。3. 核心模块的Verilog实现与关键代码解析理论讲完了我们直接上代码。我会用最直观的写法并附上关键注释和注意事项。3.1 精准时钟分频模块的实现首先解决心跳问题。假设系统时钟是50MHz周期20ns目标是得到1Hz的秒脉冲。最直接的方法是计数50,000,000次。module clk_divider ( input wire sys_clk, // 50MHz系统时钟 input wire sys_rst_n, // 低电平复位 output reg clk_1hz // 输出的1Hz时钟 ); // 参数化设计方便移植。50MHz - 1Hz需要计数 N 50_000_000 - 1 parameter CLK_FREQ 50_000_000; parameter TARGET_FREQ 1; localparam CNT_MAX CLK_FREQ / TARGET_FREQ - 1; reg [25:0] cnt; // 需要26位才能存下50M (2^26 50M) always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin cnt 0; clk_1hz 0; end else begin if (cnt CNT_MAX) begin cnt 0; clk_1hz ~clk_1hz; // 每次计满就翻转产生方波 end else begin cnt cnt 1; end end end endmodule注意这里有一个常见的坑。CNT_MAX的计算如果直接用CLK_FREQ / TARGET_FREQ得到的是50,000,000。但我们计数器是从0开始数的所以最大计数值应该是50,000,000 - 1。很多初学者在这里会错导致实际频率是0.5Hz。另外将CNT_MAX定义为localparam或使用define宏都是好习惯避免魔法数字。对于需要动态扫描显示的情况我们还需要一个更高频率的扫描时钟比如1kHz1ms扫描一个数码管6个数码管扫描一圈就是6ms刷新率约166Hz足够流畅。// 在同一模块内产生多个分频时钟 output reg clk_1khz; // 1kHz扫描时钟 localparam SCAN_CNT_MAX CLK_FREQ / 1000 / 2 - 1; // 注意是产生1kHz且先分频再除以2因为要翻转 reg [15:0] scan_cnt; always (posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin scan_cnt 0; clk_1khz 0; end else begin if (scan_cnt SCAN_CNT_MAX) begin scan_cnt 0; clk_1khz ~clk_1khz; end else begin scan_cnt scan_cnt 1; end end end3.2 计时核心模块稳健的计数器设计计时模块是状态转移的典型应用。我们使用三个寄存器分别存储时、分、秒的BCD码。为了简化这里小时用两个4位二进制数表示十位和个位但计数逻辑按24处理分和秒各用两个4位二进制数。module time_counter ( input wire clk_1hz, // 1Hz时钟输入 input wire sys_rst_n, output reg [3:0] hour_ten, hour_unit, // 时的十位和个位 (0-2, 0-9) output reg [3:0] min_ten, min_unit, // 分的十位和个位 (0-5, 0-9) output reg [3:0] sec_ten, sec_unit // 秒的十位和个位 (0-5, 0-9) ); // 秒计数逻辑 always (posedge clk_1hz or negedge sys_rst_n) begin if (!sys_rst_n) begin {sec_ten, sec_unit} 8h00; // 复位到00秒 end else begin // 先处理个位 if (sec_unit 4d9) begin sec_unit 4d0; // 个位满9十位加1 if (sec_ten 4d5) begin // 59秒 - 00秒 sec_ten 4d0; // 秒满60触发分进位这个信号需要连接到分计数逻辑 // 在实际模块划分中我们通常会在一个大的always块里处理所有进位或者使用使能信号。 // 这里为了模块独立性我们先标记实际设计见下文。 end else begin sec_ten sec_ten 1; end end else begin sec_unit sec_unit 1; end end end上面的写法将秒的进位信号隐藏在了sec_ten 4‘d5的判断里。更清晰的做法是生成进位使能信号。更推荐的做法是使用一个统一的时序逻辑块来处理所有计数和进位这样更符合同步设计思想避免复杂的组合逻辑环路。// 更清晰、稳健的计时模块设计 reg min_en; // 分计数使能由秒模块产生 reg hour_en; // 时计数使能由分模块产生 always (posedge clk_1hz or negedge sys_rst_n) begin if (!sys_rst_n) begin {sec_ten, sec_unit} 8h00; {min_ten, min_unit} 8h00; {hour_ten, hour_unit} 8h12; // 复位到12:00:00 end else begin // 1. 秒计数 if (sec_unit 4d9) begin sec_unit 4d0; if (sec_ten 4d5) begin sec_ten 4d0; min_en 1b1; // 秒满60产生分计数使能脉冲 end else begin sec_ten sec_ten 1; min_en 1b0; end end else begin sec_unit sec_unit 1; min_en 1b0; end // 2. 分计数受min_en控制 if (min_en) begin if (min_unit 4d9) begin min_unit 4d0; if (min_ten 4d5) begin min_ten 4d0; hour_en 1b1; // 分满60产生时计数使能脉冲 end else begin min_ten min_ten 1; hour_en 1b0; end end else begin min_unit min_unit 1; hour_en 1b0; end end else begin hour_en 1b0; // 确保hour_en只在特定周期有效 end // 3. 时计数受hour_en控制 if (hour_en) begin // 处理24进制注意十位最大为2 if (hour_unit 4d9) begin hour_unit 4d0; hour_ten hour_ten 1; end else if ({hour_ten, hour_unit} 8h23) begin // 23:59:59 - 00:00:00 {hour_ten, hour_unit} 8h00; end else begin hour_unit hour_unit 1; end end end end endmodule实操心得在编写计数器时一定要清晰地画出状态转移图。对于60进制状态是从0到59然后回到0。在Verilog中我们通过判断当前是否为59即十位为5且个位为9来触发归零和进位。另外使能信号如min_en,hour_en的设计非常重要它确保了进位只在正确的时钟周期发生一次避免了因组合逻辑延迟产生的竞争冒险。hour_en信号在非使能周期被显式清零这是一个好习惯可以防止因其他逻辑错误导致的误触发。3.3 显示驱动模块译码与动态扫描这是连接逻辑和物理显示的关键。假设我们使用共阴极数码管。module display_driver ( input wire clk_scan, // 扫描时钟如1kHz input wire sys_rst_n, input wire [3:0] hour_ten, hour_unit, input wire [3:0] min_ten, min_unit, input wire [3:0] sec_ten, sec_unit, output reg [6:0] seg, // 段选信号 a~g (seg[6]a, seg[0]g) output reg [5:0] sel // 位选信号低电平有效控制6个数码管 ); // 数码管译码表针对共阴极1点亮。数字0~9。 parameter [6:0] SEG_NUM [9:0] { 7b1111110, // 0 7b0110000, // 1 7b1101101, // 2 7b1111001, // 3 7b0110011, // 4 7b1011011, // 5 7b1011111, // 6 7b1110000, // 7 7b1111111, // 8 7b1111011 // 9 }; reg [2:0] scan_state; // 扫描状态0~5对应6个数码管 reg [3:0] data_to_show; // 当前要显示的数字BCD码 // 动态扫描计数器 always (posedge clk_scan or negedge sys_rst_n) begin if (!sys_rst_n) begin scan_state 0; sel 6b111111; // 初始所有数码管关闭 end else begin // 先关闭所有位选消隐 sel 6b111111; // 状态转移 scan_state scan_state 1; if (scan_state 3d5) begin scan_state 3d0; end // 根据当前状态打开对应的位选并选择要显示的数据 case (scan_state) 3d0: begin sel 6b111110; data_to_show hour_ten; end // 第1管时的十位 3d1: begin sel 6b111101; data_to_show hour_unit; end // 第2管时的个位 3d2: begin sel 6b111011; data_to_show 4ha; end // 第3管固定显示“-”或熄灭这里用a索引一个自定义段码如横杠 3d3: begin sel 6b110111; data_to_show min_ten; end // 第4管分的十位 3d4: begin sel 6b101111; data_to_show min_unit; end // 第5管分的个位 3d5: begin sel 6b011111; data_to_show 4ha; end // 第6管同上分隔符。实际可根据需要显示秒或分隔符。 default: begin sel 6b111111; data_to_show 4h0; end endcase end end // 数码管译码组合逻辑 always (*) begin if (data_to_show 4d9) begin seg SEG_NUM[data_to_show]; end else begin // 显示非数字字符例如横杠“-”用于分隔时和分 seg 7b0000001; // 例如只点亮中间的g段显示“-” end end endmodule关键细节与避坑指南消隐在切换位选信号sel之前先将其全部置为无效全1这是一个非常重要的步骤。如果不做消隐在切换数据的瞬间上一个数码管的段选信号可能会短暂地出现在下一个数码管上导致“鬼影”现象。先关再开是动态扫描的黄金法则。译码器使用查找表LUT方式实现译码是最直接和高效的。parameter定义的数组在综合时会生成ROM。确保你的段选信号顺序a~g与开发板原理图一致否则显示会错乱。有些板子是共阳极那么段选信号的有效电平就是0需要将译码表取反。扫描频率clk_scan的频率需要仔细计算。假设有6个数码管扫描频率为1kHz则每个管点亮约167us。整个刷新周期为6ms刷新率约为166Hz远高于人眼视觉暂留约24Hz显示稳定无闪烁。如果扫描太慢会看到闪烁如果太快每个数码管点亮时间太短亮度会不足。通常选择在100Hz到1kHz之间调整找到亮度与稳定性的平衡点。数据选择case语句根据scan_state选择当前要显示的数据。注意这里我加入了分隔符例如横杠的显示让时间格式更清晰如“12-30”。你可以根据需要修改比如把最后两个管用于显示秒。3.4 顶层模块的集成最后我们把所有模块像搭积木一样连接起来。module top_digital_clock ( input wire sys_clk, // 50MHz系统时钟 input wire sys_rst_n, // 复位按键低电平有效 output wire [6:0] seg, // 7段数码管段选 output wire [5:0] sel // 6个数码管位选 ); // 内部连线声明 wire clk_1hz; wire clk_1khz; wire [3:0] hour_ten, hour_unit, min_ten, min_unit, sec_ten, sec_unit; // 实例化时钟分频模块 clk_divider u_clk_divider ( .sys_clk (sys_clk), .sys_rst_n (sys_rst_n), .clk_1hz (clk_1hz), .clk_1khz (clk_1khz) ); // 实例化计时模块 time_counter u_time_counter ( .clk_1hz (clk_1hz), .sys_rst_n (sys_rst_n), .hour_ten (hour_ten), .hour_unit (hour_unit), .min_ten (min_ten), .min_unit (min_unit), .sec_ten (sec_ten), .sec_unit (sec_unit) ); // 实例化显示驱动模块 display_driver u_display_driver ( .clk_scan (clk_1khz), .sys_rst_n (sys_rst_n), .hour_ten (hour_ten), .hour_unit (hour_unit), .min_ten (min_ten), .min_unit (min_unit), .sec_ten (sec_ten), .sec_unit (sec_unit), .seg (seg), .sel (sel) ); endmodule4. 仿真测试与上板调试全流程设计完成不代表结束仿真和调试才是重头戏。我习惯先用Modelsim做充分的功能仿真再上板验证。4.1 编写Testbench进行仿真一个完整的Testbench应该能测试复位、正常计时、进位等关键功能。timescale 1ns / 1ps module tb_digital_clock(); reg sys_clk; reg sys_rst_n; wire [6:0] seg; wire [5:0] sel; // 实例化被测设计 top_digital_clock uut ( .sys_clk(sys_clk), .sys_rst_n(sys_rst_n), .seg(seg), .sel(sel) ); // 生成50MHz时钟 initial begin sys_clk 0; forever #10 sys_clk ~sys_clk; // 周期20ns - 50MHz end // 激励生成 initial begin // 初始化 sys_rst_n 0; #100; // 保持复位一段时间 sys_rst_n 1; // 让仿真运行足够长的时间观察进位 // 1Hz时钟下仿真1秒需要10^9 ns时间太长。我们可以偷个懒在Testbench里临时将分频系数改小。 // 更专业的做法是在Testbench中直接替换DUT中的分频模块或者使用defparam重定义参数。 // 这里为了演示我们假设已经修改了分频模块使其快速计数。 #20000000; // 仿真运行20ms (假设此时已能看到若干次秒进位) $stop; // 停止仿真 end // 将关键信号记录到VCD文件便于在仿真器中查看波形 initial begin $dumpfile(wave.vcd); $dumpvars(0, tb_digital_clock); end endmodule仿真技巧在仿真像时钟这样慢速的系统时直接仿真真实的一秒10^9 ns是不现实的。通常有两种方法一是修改Testbench中的时钟频率用更快的时钟如1MHz来模拟1Hz时钟的行为二是在Testbench中通过defparam或者使用bind方法临时将被测设计中的分频参数CNT_MAX改小比如从50,000,000改为50这样仿真起来就快多了。在Modelsim中你需要重点关注复位后计时器是否从初始值开始clk_1hz的周期是否正确秒、分、时的进位逻辑是否正确触发显示模块的scan_state和sel信号是否在循环扫描。4.2 上板调试与问题排查实录仿真通过后就可以进行引脚分配、综合、实现、生成比特流并下载到FPGA开发板了。这里以常见的Xilinx Vivado或Intel Quartus为例流程大同小异。引脚分配根据你的开发板原理图将顶层模块的输入输出端口映射到具体的物理引脚。sys_clk连接到晶振时钟引脚sys_rst_n连接到按键seg和sel连接到数码管的段选和位选网络。这一步至关重要错了就全完了。建议创建一个约束文件.xdc或.qsf清晰列出每个信号的引脚编号和I/O标准。综合与实现工具会检查语法错误进行逻辑综合、布局布线。关注“警告”信息有些警告可以忽略但有些可能暗示潜在问题比如未连接的端口、时序冲突等。下载与观察下载比特流到FPGA。常见问题及排查如下问题一数码管完全不亮或全亮。排查首先检查位选信号sel。用示波器或逻辑分析仪测量看是否有规律的扫描波形。如果sel一直为全1或全0可能是扫描逻辑或复位有问题。如果sel正常再查段选信号seg。如果seg全0或全1可能是译码器逻辑错误或者输入到译码器的data_to_show始终是一个固定值如0或15。技巧可以在代码里加一个“测试模式”上电后让所有数码管固定显示数字“8.”所有段点亮这样可以快速判断硬件连接和段选信号是否正确。问题二显示的数字乱码或某些段不亮。排查这几乎是段选信号顺序定义错误或共阴/共阳极搞反的典型症状。对照开发板原理图确认你的seg信号从高位到低位或从低位到高位对应的是a,b,c,d,e,f,g中的哪一个。确认数码管是共阴极还是共阳极并相应调整译码表中的值共阴极1点亮共阳极0点亮。技巧写一个简单的测试程序依次循环点亮每一个段a, b, c...观察实际亮起的段是否符合预期这是校准段序最直接的方法。问题三显示闪烁或有“鬼影”。排查动态扫描的“消隐”没做好。请回头检查display_driver模块中在scan_state变化并更新sel之前是否先将sel全部置为无效消隐。另一个可能是扫描时钟clk_scan频率不合适太低会闪烁太高会导致亮度不均可以尝试调整分频系数。技巧在切换sel和seg的代码区域可以尝试增加一个短暂的延时例如在sel全部关闭后延迟几个时钟周期再打开新的sel并输出seg虽然这不是标准的同步设计但在调试时有助于观察问题。问题四计时不准走得快或慢。排查这是分频模块的计数常数CNT_MAX计算错误导致的。请反复核对系统时钟频率和你期望的输出频率。公式是CNT_MAX (系统时钟频率 / 目标频率) - 1。如果系统时钟是50MHz要得到精确的1HzCNT_MAX必须是49,999,999。一个常见的错误是忘了减1或者将CNT_MAX定义成了reg类型但位宽不够导致计数溢出。技巧可以在分频模块中增加一个计数器溢出脉冲信号并用LED灯来指示观察LED闪烁的频率是否与预期一致1Hz的LED闪烁肉眼可辨这是最直观的验证方法。5. 功能扩展与优化思路一个基本的时钟跑起来后你可以考虑添加更多实用功能这会让项目更有挑战性和成就感。5.1 添加时间设置功能这是最常被要求添加的功能。可以通过额外的按键如设置键、加键、减键来实现。设计思路引入一个状态机。通常有“正常显示”、“设置小时”、“设置分钟”、“设置秒”等状态。按下“设置”键在不同状态间循环切换。在设置状态下相应的数字时、分、秒会闪烁通过控制该数码管的位选信号周期性使能/禁用来实现此时按“加”或“减”键可以调整数值。调整完成后再次按“设置”键回到正常显示状态时钟从新设置的时间开始走。注意事项按键消抖是必须的否则一次按压会被识别成多次。简单的做法是用一个20ms左右的延时计数器来滤除抖动。状态机的设计要清晰避免出现毛刺或非法状态。5.2 增加闹钟或整点报时功能可以定义一个闹钟时间寄存器当时钟走到这个时间时触发一个信号如让一个LED闪烁或驱动蜂鸣器发声。设计思路增加一个存储闹钟时、分、秒的寄存器组。在计时模块的每次秒更新时将当前时间与闹钟时间进行比较如果完全匹配则产生一个使能脉冲。这个脉冲可以触发一个简单的PWM模块来驱动蜂鸣器发出“滴滴”声。同样这个功能也需要配合设置按键来调整闹钟时间。5.3 提高计时精度对于简单的分频计数器其精度完全依赖于系统晶振的精度。如果晶振有偏差时钟每天会有几秒的误差。对于要求高的场合可以考虑使用PLL/IP核FPGA内部通常有锁相环PLL或时钟管理单元MMCM。你可以使用它们来生成更精确、更稳定的低频时钟或者进行小数分频。外部高精度时钟源使用更精准的温补晶振TCXO或恒温晶振OCXO。软件校准如果FPGA有外部通信接口如UART可以接收来自PC或网络的高精度时间源如NTP进行周期性校准。5.4 优化显示效果显示小数点或冒号在时和分之间显示一个闪烁的冒号“:”是数字时钟的经典样式。这可以通过在display_driver模块的译码逻辑中增加一个特殊字符冒号通常对应某一段或某两段LED来实现并在扫描到对应位置时根据一个更低频率的闪烁时钟来控制其亮灭。亮度调节可以通过PWM控制位选信号的有效时间占空比来调节整体亮度。占空比高点亮时间长亮度高反之则暗。6. 项目总结与资源推荐走完从设计、编码、仿真到上板的完整流程一个属于自己的七段管数字时钟就在FPGA上“活”了过来。这个项目虽然基础但它像一把钥匙帮你打开了数字系统设计的大门。你实践了模块化设计、同步时序逻辑、状态机、分频、译码、动态扫描等核心概念。更重要的是你经历了完整的EDA设计流程编写RTL代码、功能仿真、综合、布局布线、时序分析、上板调试。这些经验对于学习更复杂的FPGA设计至关重要。在调试过程中逻辑分析仪如果开发板自带或者一个简单的SignalTap II/ILA内嵌逻辑分析仪工具是你的好朋友它们可以像示波器一样抓取FPGA内部的信号波形对于排查那些仿真时没想到的硬件时序问题非常有效。关于学习资源除了官方手册多逛一些电子技术社区看看别人的项目分享和问题讨论往往能学到很多书本上没有的实战技巧。对于Verilog我始终认为“动手写”比“只看书”重要一百倍。从这个时钟项目出发你可以尝试挑战更复杂的设计比如带秒表、倒计时、温湿度显示的综合系统或者用VGA/OLED屏幕来显示一个图形化的时钟界面。每一次尝试都会让你对硬件描述语言和数字逻辑的理解更深一层。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门