动态扫描数字时钟是利用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值的选择要看系统时钟频率。如果是50MHz,DIV就是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_cnt=0且sec_cnt=0”的状态,输出一个蜂鸣器控制信号。这个信号如果只持续一秒,蜂鸣器会响一声;如果想模拟“几点就响几声”,可以再加一个循环计数器控制响铃次数。
闹钟稍微复杂一点,需要增加一个比较器,将当前时分与设定的闹钟时间做相等判断,相等时触发蜂鸣器,同时设计一个“停止闹钟”的按键。这里要注意比较器不能放在组合逻辑里直接驱动蜂鸣器,最好先寄存一拍,避免因为切换瞬间的毛刺导致闹钟提前或者重复响。
秒表功能本质上是把1Hz脉冲源换成一个可手动启动/停止的高速计数时钟,比如用1kHz脉冲驱动毫秒计数器。原来的时分秒计数器可以复用,只要在最顶层通过一个功能选择信号切换计时时钟源即可。模块化设计的优势在这里体现得很明显:改顶层,不动子模块。
5.2 用UART或RTC同步时间
如果愿意加一点串口通信的知识,可以用PC通过UART把标准时间发送给FPGA,FPGA解析后直接写入计数器的当前值,实现“自动校时”。这个功能非常好玩,相当于把FPGA当成一个可编程的串口设备对待。VHDL实现UART接收并不算太难,波特率可以用和分频器一样的计数器生成。
还有一种更稳妥的做法是外接I2C接口的RTC芯片,比如常见的DS1302或PCF8563等,FPGA通过I2C协议读写芯片内的寄存器,掉电后时间依然保存。如果说纯FPGA时钟只是逻辑训练,加上RTC之后就是一个真正的嵌入式系统雏形了,能串起状态机、I2C时序、数据校验、中断处理等一堆知识点。
5.3 低资源消耗的优化思路
如果将来要在更低端的FPGA上实现,或者想挑战一下资源占用,可以优化几个点:第一,用计数器代替整数信号,因为整数范围越大,综合出的二进制位宽越宽;第二,把分频器和扫描计数器共享,只要它们的分频比不同,不需要开两个独立计数器;第三,段码译码表可以用ROM初始化格式存放,通过地址索引取代case语句,虽然对FPGA来说没有本质区别,但代码更简洁。
有一类优化是要避免的,就是过度追求代码精简而牺牲可读性。VHDL综合器非常擅长优化组合逻辑,你手写的“节省资源”版本未必比简单写法更省。我见过有人为了省一个寄存器,把位选信号和段码信号放在同一个进程里,结果产生了一个非常难查的毛刺。对于学习项目,稳定性和可读性远比那一点资源重要。
写这个项目最大的体会是:VHDL的语法其实不难,难的是理解每个信号在硬件里到底怎么“走”的。第一次上板时,我以为代码仿真通过就万事大吉,结果被数码管乱码、按键连跳、晶振漂移连续教育了好几天。后来我把每个模块单独拿到逻辑分析仪上观察波形,才发现大部分问题都出在信号同步和边界处理上。如果你也在这个项目上卡住了,别急着怀疑开发板,先用万用表查电平,再用仿真器查时序,最后看看是不是自己的计数器边界写错了。这套排查思路,比任何现成代码都值钱。