动态扫描数字时钟是利用FPGA学习数字系统设计时几乎绕不开的一个题目。我在做这个项目的过程中发现很多资料喜欢直接贴一段VHDL代码却不解释为什么这么写结果新手照抄之后数码管要么乱码要么闪烁要么秒跳不正常。这篇文章不打算重复教科书而是围绕“基于VHDL的动态扫描数字时钟”这个具体项目把设计框架、分频计数、动态扫描、上板调试整个链条拆开讲把我验证过的代码和踩过的坑一起放出来。无论你是刚接触VHDL还是准备课程设计这篇文章都能让你快速上手并理解每一步背后的原因。1. 项目拆解先把“动态扫描数字时钟”吃透1.1 动态扫描为什么必须存在很多人第一次接触数码管显示时第一反应是“六位数码管每位给一组段选信号不就行了”理论上的确可以但实际一算就麻烦了。六位数码管如果全部独立连接每一段都需要单独驱动8段含小数点乘以6位一共48个信号这还没算公共端。大部分入门级FPGA开发板根本不会有这么多空闲引脚就算引脚够用布线也会变得非常痛苦综合时序更难收敛。动态扫描的核心思路是让六位数码管轮流工作每个时刻只有一位被点亮段选信号在所有位之间共享。人眼有视觉暂留效应只要切换速度足够快看起来六位就是同时亮的。这个过程和电影院放电影很像画面一秒切换24帧观众看到的却是连续运动数码管扫描也是同样的道理。扫描周期怎么选是关键。位选信号每个周期只导通一位如果每一位停留时间太长比如50ms人眼会明显看到闪烁如果太短数码管亮度又会不够看起来发暗。我实际测试下来每一位停留时间控制在1到2ms比较合适六位完整扫描一轮大概6到12ms也就是刷新频率在80到160Hz左右。这个区间既不会有闪烁感亮度也足够而且对50MHz的系统时钟来说计数分频实现起来非常容易。1.2 系统模块边界怎么画动手写代码之前先把系统拆成几个相对独立的模块能让后面的调试省掉大量时间。我习惯分成四块分频模块、计时模块、扫描显示模块、按键消抖模块。分频模块负责把板载时钟变成1Hz脉冲计时模块负责时分秒的计数和进位扫描显示模块负责将计时结果转换成段码并产生位选信号轮流点亮数码管按键消抖模块负责处理校时按键的抖动避免一次按压产生多次触发。这四个模块之间的连接关系也不复杂分频模块输出1Hz脉冲给计时模块计时模块输出秒、分、时三个数值给扫描显示模块按键消抖模块输出经过处理的按键标志给计时模块。用VHDL实现时顶层文件只负责例化这些子模块不写任何具体逻辑这样仿真时可以单独验证每一个模块定位问题会快很多。模块划分还有一层好处将来想扩展闹钟、秒表、整点报时功能时不需要改动底层显示逻辑只要在顶层增加新的模块和必要的信号连接就行。很多初学者喜欢把所有功能写在一个进程里一个文件几百行看起来能跑但一旦出错找问题就像在一堆线团里扯线头越扯越乱。模块名称输入信号输出信号核心职责分频模块系统时钟、复位1Hz脉冲将50MHz时钟分频为每秒一次的脉冲计时模块1Hz脉冲、复位、按键秒、分、时数值实现60进制秒分计数和24进制小时计数扫描显示模块秒、分、时数值段码、位选译码并动态刷新数码管按键消抖模块按键输入、时钟干净的单脉冲消除机械抖动和亚稳态影响2. 核心实现分频、计数与显示三件套2.1 分频器从50MHz到1Hz的可靠做法数字时钟的“心跳”是1Hz秒脉冲但FPGA开发板上的晶振通常是50MHz直接拿这个时钟记秒一秒钟要数50000000个上升沿。分频模块的作用就是把这个高频时钟“降频”每当计数满一个固定值就输出一个高电平脉冲。library ieee; use ieee.std_logic_1164.all; use ieee.std_logic_unsigned.all; entity clk_div is generic ( DIV : integer : 50_000_000 ); port ( clk_50m : in std_logic; rst : in std_logic; one_hz : out std_logic ); end clk_div; architecture rtl of clk_div is signal cnt : integer range 0 to DIV - 1 : 0; signal pulse : std_logic : 0; begin process(clk_50m, rst) begin if rst 1 then cnt 0; pulse 0; elsif rising_edge(clk_50m) then if cnt DIV - 1 then cnt 0; pulse 1; else cnt cnt 1; pulse 0; end if; end if; end process; one_hz pulse; end rtl;这里有一个容易写错的细节计数器的范围定义成0到DIV-1判断条件一定要用DIV-1而不是DIV。如果写成“cnt DIV”计数器永远等不到那个值1Hz脉冲永远不会出现。我最早写分频器时就栽在这里仿真波形一片空白排查了好久才发现是边界值错了。DIV值的选择要看系统时钟频率。如果是50MHzDIV就是50000000这是一个26位二进制数所以计数器必须声明成integer且范围能容纳这个值。如果把计数器声明成8位或者16位综合时要么报错要么计数溢出后脉冲频率完全不对。还有一个常见做法是把分频输出做成占空比50%的方波但这里只需要一个周期的脉冲来触发计数器加1所以输出高电平只维持一个系统时钟周期就够了没必要纠结占空比。2.2 时分秒计数器60进制和24进制计时模块是整个时钟的核心本质是两个60进制计数器和一个24进制计数器串在一起。秒计数器在1Hz脉冲的驱动下从0数到59再回到0同时产生一个进位给分钟计数器分钟计数器同样从0到59回到0时进位给小时计数器小时计数器从0到23回到0表示新的一天开始。entity time_counter is port ( clk : in std_logic; rst : in std_logic; one_hz : in std_logic; sec : out integer range 0 to 59; min : out integer range 0 to 59; hour : out integer range 0 to 23 ); end time_counter; architecture rtl of time_counter is signal sec_cnt : integer range 0 to 59 : 0; signal min_cnt : integer range 0 to 59 : 0; signal hour_cnt : integer range 0 to 23 : 0; begin process(clk, rst) begin if rst 1 then sec_cnt 0; min_cnt 0; hour_cnt 0; elsif rising_edge(clk) then if one_hz 1 then if sec_cnt 59 then sec_cnt 0; if min_cnt 59 then min_cnt 0; if hour_cnt 23 then hour_cnt 0; else hour_cnt hour_cnt 1; end if; else min_cnt min_cnt 1; end if; else sec_cnt sec_cnt 1; end if; end if; end if; end process; sec sec_cnt; min min_cnt; hour hour_cnt; end rtl;这段代码的写法属于“嵌套if”风格优点是直观缺点是可读性一般。如果电路规模再大一些我更推荐把进位拆成单独的信号比如先把秒满59产生一个min_en再在下一个进程中处理分钟这样每个进程只负责一件事代码结构更清楚。不过对于这个项目嵌套if完全够用综合面积也不会增加太多。这里还有一个设计选择问题校时按键按下时是直接修改计数器的值还是暂停秒脉冲再手动调整我采用的做法是按键给一个“加1”脉冲每按一次对应计数器在下一个时钟周期加1同时在按键处理过程中屏蔽1Hz脉冲对秒计数的影响。具体怎么屏蔽要看后面的消抖模块怎么设计但核心原则是按键操作和秒脉冲不能同时驱动同一个计数器否则会出现竞争这在FPGA里是一种很难排查的时序错误。2.3 显示译码与动态扫描的配合计时模块输出的秒、分、时都是整数数码管不认识整数只认识段码。需要把0到9的数值转换成对应的七段码这就是译码过程。我习惯用一个组合逻辑进程实现signal seg_data : std_logic_vector(7 downto 0); process(display_number) begin case display_number is when 0 seg_data 11000000; when 1 seg_data 11111001; when 2 seg_data 10100100; when 3 seg_data 10110000; when 4 seg_data 10011001; when 5 seg_data 10010010; when 6 seg_data 10000010; when 7 seg_data 11111000; when 8 seg_data 10000000; when 9 seg_data 10010000; when others seg_data 11111111; end case; end process;这里用的段码是共阳极数码管的编码规则低电平点亮对应段。不同开发板的数码管共阴极和共阳极不一样段码表完全不同上板前一定要仔细看原理图。如果是共阴极数码管上面的段码需要全部取反。这一点很容易翻车我见过不少同学直接拿网上代码下载到板子上结果数码管显示一团乱以为是FPGA烧了实际上只是段码极性问题。动态扫描部分需要一个内部计数器产生位选信号。最简单的方式是用一个扫描计数器在2ms左右切换一次档位轮流点亮第0到第5位数码管。位选信号可以用独热码表示也可以做成3位二进制再用译码器展开。我更喜欢直接把位选信号写成6位std_logic_vector哪个位导通就把对应的位置“0”共阳极或“1”共阴极这样在综合时不会引入多余的译码电路。扫描显示模块有一个非常容易出现的问题段码和位选的切换时刻必须错开否则会在切换瞬间出现“串位”。我采用的方案是在扫描计数器的上升沿先更新位选信号同时把当前对应位的数值锁存到段码寄存器等段码稳定后再改变位选这样能明显减少数码管乱闪和拖影。很多人直接在组合逻辑里同时切换段码和位选结果就是某个不相关的位会闪一下相机拍出来尤其明显。3. 上板实操从代码到看见数字跳动3.1 开发环境与引脚准备代码写完只是第一步真正让人头疼的是上板。我用的是最常见的FPGA开发板板上有一个50MHz有源晶振、六个共阳极数码管、四个按键资源对于这个项目绰绰有余。开发工具我选了VHDL语法检查比较严格的某款EDA软件仿真用自带的仿真器综合布线之后再通过下载器烧录到板子。引脚分配是上板前的必做功课。FPGA开发板的原理图会清楚标出每个数码管段选信号和位选信号连接到了FPGA的哪个引脚比如段选a接到PIN_N13位选sel0接到PIN_T10。每个工程都需要根据实际原理图编写约束文件把VHDL代码里的端口信号映射到具体引脚。这一步绝不能凭经验猜不同开发板的引脚定义差异很大我曾经因为偷懒没查原理图把一个段选信号接到了按键引脚上结果下载之后数码管怎么都不亮按键倒是有反应了。引脚约束文件里还要注意信号电平标准大部分开发板是3.3V的LVCMOS33如果默认电平不对信号虽然能下载但高电平驱动能力不足数码管亮度会偏低。对于这种低速显示项目时序约束可以适当放宽但引脚约束必须准确这是跑通项目的底线。3.2 一种可直接参考的顶层结构顶层模块的作用是把所有子模块“拼”在一起。我习惯在顶层只做端口声明和例化不写任何计算逻辑这样结构一目了然。entity clock_top is port ( clk_50m : in std_logic; rst_n : in std_logic; key_h : in std_logic; key_m : in std_logic; key_s : in std_logic; seg : out std_logic_vector(7 downto 0); sel : out std_logic_vector(5 downto 0) ); end clock_top; architecture structural of clock_top is signal one_hz_pulse : std_logic; signal sec_value : integer range 0 to 59; signal min_value : integer range 0 to 59; signal hour_value : integer range 0 to 23; begin u_div : entity work.clk_div generic map (DIV 50_000_000) port map ( clk_50m clk_50m, rst not rst_n, one_hz one_hz_pulse ); u_time : entity work.time_counter port map ( clk clk_50m, rst not rst_n, one_hz one_hz_pulse, sec sec_value, min min_value, hour hour_value ); u_display : entity work.display_scan port map ( clk clk_50m, rst not rst_n, sec sec_value, min min_value, hour hour_value, seg seg, sel sel ); end structural;这里有几个信号需要特别说明。rst_n是低电平复位而我在子模块里统一用高电平复位所以顶层例化时加了“not rst_n”取反。按键信号同样要考虑低有效还是高有效很多开发板的按键按下时引脚为低电平不处理直接接进计数器会导致按下没反应松开反而触发。顶层例化时用“work.实体名”这种写法是为了让工具直接找到对应实体避免同名实体冲突。VHDL-93之后这种写法很常见综合器也支持得很好。如果项目里有多个结构体可以再加“configuration”指定但对这个小项目没必要直接默认结构体就够了。3.3 综合与时序约束别忽略刷新频率和毛刺综合完成后开发工具通常会给出一个资源占用报告。这个项目用不了多少资源但有几个警告不能无视一是“寄存器无复位信号”的警告如果某个进程里的signal没有明确的复位分支综合器会认为该寄存器的初始状态不确定上电后可能出现随机值二是“锁存器推断”的警告这通常是因为case语句漏掉了others分支导致某些输入组合下输出没有赋值综合器只能生成锁存器来保持旧值。对于动态扫描信号我不建议做过于严格的时序约束。数码管的段码和位选本质上属于“慢速外设”几十兆的时钟约束反而会让布局布线工具为了满足根本不存在的时序要求而绕大圈增加编译时间。我在约束文件里会把这些输出信号所在的路径设置成伪路径false path告诉工具“不用管它的时序”因为我需要的是稳定的高低电平而不是纳秒级的建立时间。但“不用管时序”不代表可以随便产生毛刺。毛刺最典型的来源是不同进程之间的信号同步。比如计时模块输出的sec_value是一个多比特整数如果扫描显示模块直接拿它做组合逻辑译码可能在计数变化的瞬间出现中间态比如59变成0时如果各位翻转速度不一致译码电路会瞬间看到一个错误的中间值数码管就闪一下。解决思路是把sec_value先寄存一拍再译码或者在扫描模块内部用一个同步寄存器锁存当前要显示的数值之后再进入组合逻辑。4. 调试记录我踩过的坑和排查方法4.1 数码管乱码、闪烁、亮度不均匀的根源这个项目最常见的现象就是“能下载但显示不对”乱码、闪烁、某位特别亮或特别暗基本都出自扫描模块。先说乱码。乱码通常是段码和位选没有同步导致的。我遇到过一次非常典型的乱码所有数码管显示的都是同一个数字而且会变。排查之后发现我的位选信号更新得太慢扫描计数器用的时钟频率太低位选每20ms才切一次段码却已经切走了结果就是所有位都显示最后一次切换的段码。解决办法是把扫描计数器的分频系数改小让位选切换周期缩短到2ms左右。再说闪烁。闪烁的原因比较简单要么扫描频率太低要么某一位的点亮时间占空比太小。六位数码管共用一个段码每一位实际亮的时间只占总周期的六分之一如果每一位停留2ms完整周期12ms刷新率约83Hz人眼基本看不出闪。但如果把每一位停留时间拉到10ms刷新率就降到16Hz闪烁感非常明显。这个参数调节并不难关键是要记住刷新率至少60Hz最好80Hz以上。亮度不均是位选驱动能力的问题和代码关系不大。六个位选信号都接到FPGA引脚如果引脚输出电流能力不同或者板上的三极管驱动电路没有均匀设计就会导致某两位比别的位暗。检查方法是用万用表量各引脚的电平波形确认占空比一致如果波形没问题就要怀疑硬件电路本身而不是VHDL代码。4.2 按键校时容易连跳和进位异常按键校时是另一个重灾区。物理按键按下和松开时触点会因为机械弹性产生多次通断持续时间几毫秒到十几毫秒不等。如果不处理按一次“小时1”实际可能会连续加五六次。解决方法是消抖原理很简单检测到按键电平跳变后先延时20ms左右再确认一次电平如果仍然有效就认为这是一次可靠的按键。process(clk, rst) begin if rst 1 then key_clean 1; cnt 0; key_pulse 0; elsif rising_edge(clk) then if key_in / key_clean then if cnt 1_000_000 then key_clean key_in; key_pulse 1; cnt 0; else cnt cnt 1; end if; else cnt 0; key_pulse 0; end if; end if; end process;上面代码里的1_000_000是针对50MHz时钟的延时也就是20ms。如果在其他时钟频率下使用这个数要按比例调整。消抖后的key_pulse是一个单时钟周期脉冲只能触发一次计数器加1不会连跳。还有一种更隐蔽的问题是按键调整分钟时秒脉冲还在继续导致“分钟按到59下一秒又被秒进位变成0”。我的处理方式是在校时模式下用按键脉冲直接驱动对应计数器同时把1Hz脉冲对秒计数的路径切断。简单说增加一个“adjust_en”当有按键按下时这一秒内不让计时模块响应one_hz脉冲。否则你会遇到一种很尴尬的情景好不容易把小时调对了结果秒一跳又把小时进位了。4.3 分频误差与长时间漂移数字时钟上电跑几分钟时间看起来完全准确但放一晚再看可能已经慢了几秒甚至几十秒。原因很简单FPGA板载晶振精度有限常见的有源晶振频率误差在50ppm左右50MHz对应每秒可能偏差几十赫兹累积一天下来误差就是几秒。想彻底解决漂移要么换高精度温补晶振要么使用外部RTC芯片要么接收网络时间同步。对于课程设计和学习项目板载晶振完全够用但如果你要把这个时钟当成真正的桌面时钟用久了会发现它并不“准”。我的建议是设计时保留一个“误差校正”接口比如通过拨码开关设置每天快慢多少秒在分频计数器中做微调。这个功能实现起来不复杂却能明显提升项目的完整度。另外分频模块里的DIV是一个generic参数默认50000000如果实际板载晶振频率不是整50MHz或者你想让时钟走得更准一点可以用频率计实测晶振输出然后把DIV改成实测值对应的分频系数。比如实测49.98MHz那么DIV就设成49980000这样走时误差会小很多。这个小技巧很多资料不会写但实际效果非常明显。5. 扩展方向从“能跑”变成“好用”5.1 闹钟、整点报时与秒表切换这个项目做完基本功能后还可以加很多实用扩展。我首先推荐加整点报时实现思路是在计时模块里增加一个“min_cnt0且sec_cnt0”的状态输出一个蜂鸣器控制信号。这个信号如果只持续一秒蜂鸣器会响一声如果想模拟“几点就响几声”可以再加一个循环计数器控制响铃次数。闹钟稍微复杂一点需要增加一个比较器将当前时分与设定的闹钟时间做相等判断相等时触发蜂鸣器同时设计一个“停止闹钟”的按键。这里要注意比较器不能放在组合逻辑里直接驱动蜂鸣器最好先寄存一拍避免因为切换瞬间的毛刺导致闹钟提前或者重复响。秒表功能本质上是把1Hz脉冲源换成一个可手动启动/停止的高速计数时钟比如用1kHz脉冲驱动毫秒计数器。原来的时分秒计数器可以复用只要在最顶层通过一个功能选择信号切换计时时钟源即可。模块化设计的优势在这里体现得很明显改顶层不动子模块。5.2 用UART或RTC同步时间如果愿意加一点串口通信的知识可以用PC通过UART把标准时间发送给FPGAFPGA解析后直接写入计数器的当前值实现“自动校时”。这个功能非常好玩相当于把FPGA当成一个可编程的串口设备对待。VHDL实现UART接收并不算太难波特率可以用和分频器一样的计数器生成。还有一种更稳妥的做法是外接I2C接口的RTC芯片比如常见的DS1302或PCF8563等FPGA通过I2C协议读写芯片内的寄存器掉电后时间依然保存。如果说纯FPGA时钟只是逻辑训练加上RTC之后就是一个真正的嵌入式系统雏形了能串起状态机、I2C时序、数据校验、中断处理等一堆知识点。5.3 低资源消耗的优化思路如果将来要在更低端的FPGA上实现或者想挑战一下资源占用可以优化几个点第一用计数器代替整数信号因为整数范围越大综合出的二进制位宽越宽第二把分频器和扫描计数器共享只要它们的分频比不同不需要开两个独立计数器第三段码译码表可以用ROM初始化格式存放通过地址索引取代case语句虽然对FPGA来说没有本质区别但代码更简洁。有一类优化是要避免的就是过度追求代码精简而牺牲可读性。VHDL综合器非常擅长优化组合逻辑你手写的“节省资源”版本未必比简单写法更省。我见过有人为了省一个寄存器把位选信号和段码信号放在同一个进程里结果产生了一个非常难查的毛刺。对于学习项目稳定性和可读性远比那一点资源重要。写这个项目最大的体会是VHDL的语法其实不难难的是理解每个信号在硬件里到底怎么“走”的。第一次上板时我以为代码仿真通过就万事大吉结果被数码管乱码、按键连跳、晶振漂移连续教育了好几天。后来我把每个模块单独拿到逻辑分析仪上观察波形才发现大部分问题都出在信号同步和边界处理上。如果你也在这个项目上卡住了别急着怀疑开发板先用万用表查电平再用仿真器查时序最后看看是不是自己的计数器边界写错了。这套排查思路比任何现成代码都值钱。