简介:基于FPGA的闹钟系统设计文档面向电子信息、自动化及计算机相关专业学生与FPGA初学者,围绕EDA开发流程和VHDL硬件描述语言,讲解多功能数字时钟从需求分析、系统划分到板级验证的完整实现思路。压缩包内为1个doc文件,约2.71MB,集中呈现设计说明、系统组成、源代码开发过程及Quartus仿真综合与GW48开发箱下载测试等内容,便于按章节查阅。文档重点覆盖时间存储、计时、闹钟设置、比较器、显示与用户交互等模块,并串联VHDL建模、功能仿真、综合布局布线等关键环节,可作为课程设计、毕业设计或FPGA入门项目的参考材料。目前已有832人学习下载,适合需要理解数字闹钟系统架构与EDA实践流程的读者对照研读。
1. 一个能被"调"的硬件时钟:这套 FPGA 闹钟系统到底在做什么
用单片机做闹钟,写个定时器中断、刷个数码管,一下午就能点灯。换成 FPGA 就完全是另一回事:没有现成的中断,没有现成的库,一个 50MHz 晶振进去,你得自己数到 5000 万下才凑出一秒,还得保证这一秒既不快也不慢。这份基于 FPGA 的闹钟系统设计,干的正是这件事——用 VHDL 把秒、分、时三级计数器、闹钟设定、显示切换、蜂鸣器驱动一层层搭起来,最后在 EDA 工具里综合、布局布线、下载进开发箱验证。它适合两类人:一是刚学完数字电路、想找一个能上板跑通完整例子的学生,二是手上有块 FPGA 板子、想拿个数字时钟项目练手感的人。系统功能不花哨:准确计时、时间校准、定时闹钟,但每一块都得自己写。
2. 系统架构与信号链路:从晶振到数码管,一条时钟树怎么搭
先看整张图,别急着写代码。这套闹钟系统拆下来是六条链路:时钟分频链、秒计数链、分计数链、时计数链、闹钟设置链、显示与响铃链。它们不是并列的,而是有明确的上下游关系——分频器在最上游,秒计数器吃它的使能信号,分计数器吃秒计数器的进位,时计数器吃分计数器的进位,闹钟比较器同时盯着时和分两路寄存器,显示模块则在"当前时间"和"闹钟时间"两组数据之间做二选一。
理解这条链的关键在于:所有计数器共享同一个系统时钟,层与层之间传的是使能脉冲和进位标志,不是时钟。这一点如果搞反,后面所有调试都会变成玄学。
2.1 总体数据通路:两组寄存器、一个比较器、一个多路选择器
把数据流画清楚之后,代码结构就自然出来了。系统里其实存在两套几乎对称的寄存器:一套是"当前时间"(时、分、秒),一套是"闹钟时间"(时、分)。前者由计时链路驱动,每个 1Hz 节拍自增;后者只在设置状态下由按键改写,平时纹丝不动。比较器把这两组值的时和分分别对比,相等就拉高一个ALARM_EQ信号。
显示模块是一个三选一的多路选择器:正常状态下输出当前时间,按下DISPLAY_EN后输出闹钟时间,设置状态下输出正在被调整的那一组。这个选择信号我一般用一个两位的状态量编码,而不是三根独立的按键线直接做组合逻辑——后者极容易在按键抖动时输出毛刺,导致数码管瞬间闪出一组不存在的数字。
响铃链路最简单也最容易翻车:ALARM_EQ一旦为高,蜂鸣器输出方波;但如果没有关断条件,它会一直响到你拔电源。所以设计里必须有一个"响铃屏蔽寄存器",按键一按就把它置位,把比较结果挡住,直到下一个 24 小时周期或者下次重新设定闹钟才恢复。
注意:
ALARM_EQ千万不要直接驱动蜂鸣器引脚。比较器输出是组合逻辑,只要时或分有一根线在变,它就可能在中间态短暂为高,听感上就是"咔"一下的杂音。
2.2 时钟分频:50MHz 怎么变出 1Hz 计时和 1kHz 扫描
开发板上给的时钟一般是 50MHz,这个频率对计时来说太快,对数码管动态扫描来说又刚好。所以分频模块要同时产出两路节拍:一路 1Hz 给计时用,一路 1kHz 左右给数码管位选切换用。
这里有个血泪经验必须先讲:不要用分频出来的信号去当时钟。很多教材上的写法是CLK_1HZ <= NOT CLK_1HZ在计数到 2500 万时翻转,然后用CLK_1HZ去驱动秒计数器的rising_edge。这种写法在仿真里完全正常,综合之后却会走全局时钟网络、引入额外偏斜,一旦系统里有多个分频时钟,跨时钟域的问题就全来了。正确做法是分频器只输出单周期使能脉冲,计数器仍然跑在 50MHz 主时钟上,只在使能为高的那一拍做自增。
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; USE IEEE.STD_LOGIC_UNSIGNED.ALL; ENTITY CLK_DIV IS GENERIC( DIV_TICK : INTEGER := 50_000_000; -- 50MHz 分频到 1Hz 计时节拍 DIV_SCAN : INTEGER := 50_000 -- 50MHz 分频到 1kHz 扫描节拍 ); PORT( CLK : IN STD_LOGIC; RST : IN STD_LOGIC; EN_TICK : OUT STD_LOGIC; -- 1Hz 单周期使能 EN_SCAN : OUT STD_LOGIC -- 1kHz 单周期使能 ); END ENTITY; ARCHITECTURE RTL OF CLK_DIV IS -- 计数器位宽按分频比自动收敛,综合时不会浪费触发器 SIGNAL C_TICK : INTEGER RANGE 0 TO DIV_TICK-1 := 0; SIGNAL C_SCAN : INTEGER RANGE 0 TO DIV_SCAN-1 := 0; BEGIN PROCESS(CLK, RST) BEGIN IF RST = '1' THEN C_TICK <= 0; C_SCAN <= 0; EN_TICK <= '0'; EN_SCAN <= '0'; ELSIF RISING_EDGE(CLK) THEN -- 计时节拍:数满归零,同时输出一拍宽的高电平 IF C_TICK = DIV_TICK-1 THEN C_TICK <= 0; EN_TICK <= '1'; ELSE C_TICK <= C_TICK + 1; EN_TICK <= '0'; END IF; -- 扫描节拍:结构完全一致,只是分频比不同 IF C_SCAN = DIV_SCAN-1 THEN C_SCAN <= 0; EN_SCAN <= '1'; ELSE C_SCAN <= C_SCAN + 1; EN_SCAN <= '0'; END IF; END IF; END PROCESS; END ARCHITECTURE;这段代码的逻辑很直白:两个独立计数器在主时钟下并行跑,各自数到终值就把输出拉高一拍再归零。DIV_TICK和DIV_SCAN做成GENERIC是有意的——仿真时把它们改成 5 和 3,一秒钟就能看到几十个周期,不用盯着波形等半天。EN_TICK的脉宽严格等于一个CLK周期,下游计数器在IF EN_TICK = '1'分支里自增,整个系统只有一个时钟域,时序分析干干净净。
参数上唯一要注意的是INTEGER RANGE的上界。写0 TO DIV_TICK-1时综合工具能推算出最小位宽(5000万需要 26 位),如果写成0 TO 50_000_000就会多占一位。另外DIV_TICK取 50_000_000 对应 50MHz 晶振,如果你的板子是 27MHz 或者 12MHz,改这个参数就行,别去改后面的计数器。
2.3 引脚与硬件资源:一张表看清谁接谁
代码写完,引脚分配是另一道坎。这套系统的外部接口不多,但每一个都不能接错:
| 信号名 | 方向 | 板载资源 | 说明 |
|---|---|---|---|
| CLK | 输入 | 有源晶振输出 | 系统主时钟,接入全局时钟引脚 |
| RST | 输入 | 独立按键,需上拉 | 高电平复位,同步释放 |
| KEY_M / KEY_H | 输入 | 独立按键 | 分/时调整,按下产生单脉冲 |
| KEY_CLOCK | 输入 | 独立按键 | 进入/退出当前时间设置 |
| KEY_ALARM | 输入 | 独立按键 | 进入/退出闹钟设置 |
| KEY_DISP | 输入 | 独立按键 | 时间显示与闹钟显示切换 |
| SEG[7:0] | 输出 | 数码管段选 | 共阴接法,段码高有效 |
| DIG[5:0] | 输出 | 数码管位选 | 六位:时十、时个、分十、分个、秒十、秒个 |
| BUZZ | 输出 | 蜂鸣器 | 方波驱动,建议串限流电阻 |
时钟引脚必须分配到板子上标注的专用时钟管脚,否则综合工具会走普通布线资源,几万根线的偏斜下计数器行为不可预期。按键那几路,硬件上如果没有外部上拉,一定要在管脚约束里打开内部弱上拉,不然悬空输入会把状态机带进随机态。
3. 计时核心的 VHDL 拆解:秒、分、时三级计数器怎么串起来
计时链路是整个系统的主干,三个计数器结构相似但语义不同:秒是 60 进制、分是 60 进制且可被设置、时是 24 进制且可被设置。原料里的写法是每个模块独立一个ENTITY,靠CNT进位输出串起来,这个思路没问题,但细节上有几处值得改。
3.1 秒模块:原文那段代码能跑,但有三处值得改
先看原料里秒模块的核心逻辑:一个PROCESS(CLR, CLK),CLR高有效异步清零,CLK上升沿下个位变量M1累加,M1到 9 就进位到十位M2,M2到 6 就清零并输出进位CNT。功能上是对的,仿真波形也出得来。但它把CLK直接当成秒节拍用——意味着外部必须给它一个精确的 1Hz 时钟,而 1Hz 时钟又是由另一个分频模块产生的。这就回到了上一节说的问题:跨时钟域。
我的改法是把CLK固定成系统主时钟,另外加一个EN使能输入:
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; USE IEEE.STD_LOGIC_UNSIGNED.ALL; ENTITY SEC_CNT IS PORT( CLK : IN STD_LOGIC; -- 系统主时钟 RST : IN STD_LOGIC; -- 异步复位,高有效 EN : IN STD_LOGIC; -- 1Hz 单周期使能 SEC_L : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 秒个位 SEC_H : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 秒十位 CARRY : OUT STD_LOGIC -- 进位脉冲,宽度 1 个 CLK ); END ENTITY; ARCHITECTURE RTL OF SEC_CNT IS SIGNAL L, H : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN PROCESS(CLK, RST) BEGIN IF RST = '1' THEN L <= "0000"; H <= "0000"; CARRY <= '0'; ELSIF RISING_EDGE(CLK) THEN CARRY <= '0'; -- 默认清零,避免进位粘连 IF EN = '1' THEN IF L = "1001" THEN L <= "0000"; IF H = "0101" THEN -- 59 秒 H <= "0000"; CARRY <= '1'; -- 只在 59->00 这一拍拉高 ELSE H <= H + 1; END IF; ELSE L <= L + 1; END IF; END IF; END IF; END PROCESS; SEC_L <= L; SEC_H <= H; END ARCHITECTURE;逻辑上有两个细节:CARRY <= '0'放在IF EN之前,保证进位是严格一拍宽的脉冲,而不是一直挂着高电平——这一步如果漏了,分计数器会以 50MHz 的速度疯跑。另一个是把个位和十位拆成两个独立的 4 位信号,用STD_LOGIC_VECTOR而不是INTEGER,因为下游要直接送数码管译码,INTEGER转VECTOR还得再写一层转换函数,多一道坑。
EN的脉宽必须是一个CLK周期,这个约束由分频器保证。RST是异步复位,但实际板子上按键释放的瞬间会有抖动,如果复位信号直接进来,计数器可能在释放沿附近被反复清零。常见做法是加一个复位同步器:两级触发器打拍后再送去各模块,或者干脆在复位按键后面加个 RC 加施密特。
3.2 分模块:双进程加混用边沿,是这套代码最容易翻车的地方
原料里的分模块有意思,它用了两个PROCESS,第一个在EN='1'时用LOAD的上升沿递增Q1/Q2,第二个在EN='0'时用CLK的上升沿递增M1/M2,然后用M1<=Q1之类的赋值在两者之间切换。这套写法在行为仿真里能跑出"设置模式"和"计时模式"两种状态,但综合的时候隐患不少。
问题出在敏感列表和边沿混用上。PROCESS(EN, CLR, LOAD)里出现了LOAD'EVENT AND LOAD='1',工具会把它识别成边沿触发,推断出触发器;可同一个进程里又有IF EN='1'这种电平条件,综合器就得猜你到底想要什么。更麻烦的是Q1和M1两组寄存器在同一个时钟域里各存一份时间,切换瞬间必然有一个周期的不一致,显示上就是数字闪一下。
我的写法是收敛成单一进程、单一主时钟,用"设置使能SET_EN"来选择是自增还是接受外部加载值:
ENTITY MIN_CNT IS PORT( CLK : IN STD_LOGIC; RST : IN STD_LOGIC; EN : IN STD_LOGIC; -- 1Hz 计时使能(来自秒进位) SET_EN : IN STD_LOGIC; -- 设置模式使能 SET_INC : IN STD_LOGIC; -- 设置时的单脉冲自增 MIN_L : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); MIN_H : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); CARRY : OUT STD_LOGIC ); END ENTITY; ARCHITECTURE RTL OF MIN_CNT IS SIGNAL L, H : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN PROCESS(CLK, RST) BEGIN IF RST = '1' THEN L <= "0000"; H <= "0000"; CARRY <= '0'; ELSIF RISING_EDGE(CLK) THEN CARRY <= '0'; IF SET_EN = '1' THEN -- 设置模式:只在按键脉冲到来时加一,到 59 回卷 IF SET_INC = '1' THEN IF L = "1001" THEN L <= "0000"; H <= "0000" WHEN H = "0101" ELSE H + 1; ELSE L <= L + 1; END IF; END IF; ELSIF EN = '1' THEN -- 计时模式:秒进位驱动,逻辑与秒计数器一致 IF L = "1001" THEN L <= "0000"; IF H = "0101" THEN H <= "0000"; CARRY <= '1'; ELSE H <= H + 1; END IF; ELSE L <= L + 1; END IF; END IF; END IF; END PROCESS; MIN_L <= L; MIN_H <= H; END ARCHITECTURE;这里SET_EN和EN是互斥的优先级关系:设置模式下停止计时,计时模式下忽略设置脉冲,两者不会打架。SET_INC要求是已经去抖过的单脉冲,宽度也是一个CLK周期——按键直接接进来是不行的,按下一次可能加七八下,这个坑后面第 5 章还会细说。
参数上唯一需要留意的是H = "0101"这个判断。它对应十位的 5,也就是 59 分的边界。如果哪天要改成 99 进制(比如做百分秒),把这个比较值改成"1001"就行,其余逻辑不动。
3.3 时模块与 24 小时翻转:进位链的最后一段
时模块的结构和分模块几乎一模一样,唯一的区别是翻转条件:个位到 9 进位,十位到 2 且个位到 3 时归零,也就是 23:59:59 的下一秒变 00:00:00。原料里给的是 24 进制,判断写成IF H="0010" AND L="0011" THEN 全零,逻辑正确但有个写法上的小毛病——它把"个位到 9 进位"和"十位到 2 归零"分成了两个独立的IF,导致 09→10 和 19→20 这两次进位要判断两遍。
我一般写成嵌套结构,路径更短:
PROCESS(CLK, RST) BEGIN IF RST = '1' THEN HL <= "0000"; HH <= "0000"; ELSIF RISING_EDGE(CLK) THEN IF EN = '1' THEN IF HL = "1001" THEN HL <= "0000"; IF HH = "0010" THEN HH <= "0000"; -- 23 -> 00,日翻转 ELSE HH <= HH + 1; END IF; ELSE -- 十位为 2 时个位只允许走到 3,这是 24 进制的关键约束 IF HH = "0010" AND HL = "0011" THEN HL <= "0000"; HH <= "0000"; ELSE HL <= HL + 1; END IF; END IF; END IF; END IF; END PROCESS;合并之后的判断只有一条主路径加一个边界分支,综合出来的比较器少一级。这段代码里EN接的是分计数器的CARRY,整条进位链是:秒 59→00 拉高CARRY一拍,分在那一拍自增,分 59→00 再拉高自己的CARRY,时在那一拍自增。整条链上每一级只延迟一个时钟周期,没有组合逻辑串联,这是它能稳定跑在高频下的原因。
3.4 顶层例化:把三个计数器连成一条链
三个子模块写完之后,顶层就是个纯粹的连线活儿。这里容易犯的错是端口映射时把CARRY和EN接反,或者把秒的十位接到分的个位上。我的习惯是端口名和信号名保持一一对应,宁可多声明几个中间信号也不要省。
ARCHITECTURE STRUCT OF ALARM_TOP IS SIGNAL EN_TICK, EN_SCAN : STD_LOGIC; SIGNAL C_SEC, C_MIN, C_HOUR : STD_LOGIC; SIGNAL SEC_L, SEC_H : STD_LOGIC_VECTOR(3 DOWNTO 0); SIGNAL MIN_L, MIN_H : STD_LOGIC_VECTOR(3 DOWNTO 0); SIGNAL HOUR_L, HOUR_H : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN U_DIV : ENTITY WORK.CLK_DIV GENERIC MAP(DIV_TICK => 50_000_000, DIV_SCAN => 50_000) PORT MAP(CLK => CLK, RST => RST, EN_TICK => EN_TICK, EN_SCAN => EN_SCAN); U_SEC : ENTITY WORK.SEC_CNT PORT MAP(CLK => CLK, RST => RST, EN => EN_TICK, SEC_L => SEC_L, SEC_H => SEC_H, CARRY => C_SEC); U_MIN : ENTITY WORK.MIN_CNT PORT MAP(CLK => CLK, RST => RST, EN => C_SEC, SET_EN => SET_MODE, SET_INC => PULSE_M, MIN_L => MIN_L, MIN_H => MIN_H, CARRY => C_MIN); U_HOUR : ENTITY WORK.HOUR_CNT PORT MAP(CLK => CLK, RST => RST, EN => C_MIN, SET_EN => SET_MODE, SET_INC => PULSE_H, HOUR_L => HOUR_L, HOUR_H => HOUR_H, CARRY => C_HOUR); END ARCHITECTURE;GENERIC MAP这一步是给仿真留的活口——仿真时把DIV_TICK传成 50、DIV_SCAN传成 5,波形上几微秒就能看完一个完整的 24 小时周期。实际综合时改回真实值,其余代码一行不动。C_HOUR这个进位信号在闹钟系统里没有下游,留着是给整点报时或者日计数器扩展用的,综合工具会自动优化掉,不占资源。
4. 闹钟设置与响铃链路:按键、状态机、比较器三件套
计时部分跑通之后,剩下的功能全围绕按键展开。这套系统的按键不少:复位、分调整、时调整、时间设置、闹钟设置、显示切换,六个键要协同工作,靠一堆IF硬拼是拼不出来的,得有个明确的状态机。
4.1 按键同步与去抖:别让一次按下变成十次设置
机械按键的抖动期通常在 5~20ms,这段时间里电平会在高低之间弹十几次。如果把按键直接接到计数器的使能端,按一次"分调整",分钟可能直接跳七八下,甚至因为抖动落在不同时钟沿上而跳到一个你完全没按过的值。
标准做法是两级同步加计数去抖。第一级把异步输入打进时钟域,第二级消除亚稳态,然后计数器连续数到稳定电平就锁存输出:
ENTITY KEY_DEBOUNCE IS GENERIC( CNT_MAX : INTEGER := 1_000_000 -- 50MHz 下约 20ms ); PORT( CLK : IN STD_LOGIC; RST : IN STD_LOGIC; KEY_IN : IN STD_LOGIC; -- 原始按键输入,低有效 PULSE : OUT STD_LOGIC -- 去抖后的单周期脉冲 ); END ENTITY; ARCHITECTURE RTL OF KEY_DEBOUNCE IS SIGNAL SYNC0, SYNC1, STABLE : STD_LOGIC := '1'; SIGNAL CNT : INTEGER RANGE 0 TO CNT_MAX := 0; BEGIN PROCESS(CLK, RST) BEGIN IF RST = '1' THEN SYNC0 <= '1'; SYNC1 <= '1'; STABLE <= '1'; CNT <= 0; PULSE <= '0'; ELSIF RISING_EDGE(CLK) THEN PULSE <= '0'; SYNC0 <= KEY_IN; -- 第一级:打入时钟域 SYNC1 <= SYNC0; -- 第二级:消除亚稳态 IF SYNC1 = STABLE THEN CNT <= 0; -- 电平没变,计数清零 ELSE IF CNT = CNT_MAX THEN STABLE <= SYNC1; -- 连续稳定 20ms,更新稳定值 CNT <= 0; IF SYNC1 = '0' THEN PULSE <= '1'; -- 只在下降沿(按下)产生脉冲 END IF; ELSE CNT <= CNT + 1; END IF; END IF; END IF; END PROCESS; END ARCHITECTURE;CNT_MAX这个参数按 50MHz × 20ms 算出来是 100 万,如果你的时钟是 27MHz,改成 54 万就行。STABLE初始值设成'1'对应按键未按下的上拉状态,如果你的板子是低有效接法,这个初值不能改。PULSE只在按键按下的瞬间出一拍,松开不再产生脉冲——设置时间时"按一下加一"靠的就是这个特性,如果按下和松开都产生脉冲,一次操作会加两下。
六个按键各例化一份这个模块,输出的脉冲再送进状态机。
4.2 设置状态机:三个键怎么协同出一个不打架的流程
系统的操作流程可以拆成四个状态:正常走时、设置当前时间、查看闹钟、设置闹钟。原料里的做法是用SET_CLOCK和SET_DINGS两个键"进入/退出",DISPLAY_EN切换显示。这个设计能用,但有个含糊的地方——在设置当前时间的过程中按DISPLAY_EN会怎样?没有定义,实际就是状态错乱。
我一般用一个两位状态量加显式的转移条件:
TYPE STATE_T IS (S_RUN, S_SET_TIME, S_SET_ALARM); SIGNAL STATE : STATE_T := S_RUN; PROCESS(CLK, RST) BEGIN IF RST = '1' THEN STATE <= S_RUN; ELSIF RISING_EDGE(CLK) THEN CASE STATE IS WHEN S_RUN => IF PULSE_CLOCK = '1' THEN STATE <= S_SET_TIME; -- 进入时间设置 ELSIF PULSE_ALARM = '1' THEN STATE <= S_SET_ALARM; -- 进入闹钟设置 END IF; WHEN S_SET_TIME => IF PULSE_CLOCK = '1' THEN STATE <= S_RUN; -- 再按一次确认退出 END IF; WHEN S_SET_ALARM => IF PULSE_ALARM = '1' THEN STATE <= S_RUN; END IF; END CASE; END IF; END PROCESS; -- 状态译码:驱动各模块的使能 SET_MODE <= '1' WHEN STATE = S_SET_TIME OR STATE = S_SET_ALARM ELSE '0'; SET_TIME <= '1' WHEN STATE = S_SET_TIME ELSE '0'; SET_ALARM <= '1' WHEN STATE = S_SET_ALARM ELSE '0';SET_MODE是给计数器用的总开关,为高时停走并接受SET_INC;SET_TIME和SET_ALARM分别控制写入哪一组寄存器。这样按键在别的状态下按下去只会被忽略,不会产生越权操作。
DISPLAY_EN这里是独立的,不参与状态机,只影响显示多路选择器:S_SET_ALARM状态强制显示闹钟,S_RUN状态下由按键的翻转标志决定。设置时间和查看闹钟的优先级要明确,否则会出现"正在设置时间,屏幕显示的是闹钟值"这种自相矛盾的画面。
4.3 比较器与蜂鸣器驱动:什么时候响、响多久、怎么关
比较器本身很简单,两个 4 位向量各比一次,相等就置位:
PROCESS(CLK, RST) BEGIN IF RST = '1' THEN EQ <= '0'; ELSIF RISING_EDGE(CLK) THEN -- 只在 1Hz 节拍后比较,避开数字跳变瞬间的组合毛刺 IF EN_TICK = '1' THEN IF (HOUR_H = AH_H) AND (HOUR_L = AH_L) AND (MIN_H = AM_H) AND (MIN_L = AM_L) THEN EQ <= '1'; ELSE EQ <= '0'; END IF; END IF; END IF; END PROCESS;把比较动作放进EN_TICK的节拍里,是为了避开数字正在跳变的那几十纳秒。如果做成纯组合比较,分个位从 09 变 10 的瞬间,各条位线的传播延迟不一致,比较器可能短暂输出一个高电平,蜂鸣器就"咔"一声。这种瞬时毛刺在示波器上不好抓,听感上却很明显——这就是我说过的玄学问题,其实一点都不玄。
蜂鸣器的驱动不能直接用EQ,得加一个屏蔽寄存器和方波发生器。屏蔽寄存器在用户按任意键时置位,切断响铃,防止无人值守时一直响;方波发生器用扫描节拍或者单独分频出 1kHz,让无源蜂鸣器发出音调:
SIGNAL MUTE : STD_LOGIC := '0'; SIGNAL BEEP : STD_LOGIC; BEEP <= EN_SCAN AND EQ AND (NOT MUTE); -- 1kHz 方波调制,静音时全低 BUZZ <= BEEP; PROCESS(CLK) BEGIN IF RISING_EDGE(CLK) THEN IF ANY_KEY = '1' THEN MUTE <= '1'; -- 任意键静音 ELSIF ALARM_CHANGED = '1' THEN MUTE <= '0'; -- 改闹钟时间后重新启用 END IF; END IF; END PROCESS;EQ AND NOT MUTE这个组合决定了响不响,AND EN_SCAN决定了音调和占空比。如果你的蜂鸣器是有源的,去掉EN_SCAN这一项直接给高电平就行,否则会持续发出断续的杂音。ALARM_CHANGED是闹钟设置寄存器的变化标志,重新设定后自动解除静音,省得用户按了静音之后忘记恢复。
5. 避坑与排查:仿真波形漂亮、上板就翻车的五种情况
数字系统调试有个特点:仿真全绿不代表板子上能跑,板子上能跑也不代表长期稳定。下面这五条是这类闹钟项目里最常遇到的。
5.1 数码管数字乱跳,按一个键跳好几个数
现象:仿真时按键脉冲干净漂亮,下载到板子上按一次"分调整",分钟连跳三到八下,有时候还会跳到一个没按过的方向。
原因:按键机械抖动没有处理,或者只在按键后加了一级触发器,没有做时间窗口的确认。抖动期内的每一个上升沿都被计数器当成了一次有效操作。
解决:用 4.1 节的计数去抖模块,稳定窗口取 15~25ms。不要在按键线上加 RC 滤波了事,电容充放电的边沿在时钟采样下依然会出多拍。另外确认所有按键都过了同一个去抖模块,别漏了复位键——复位抖动会让整个系统在释放瞬间反复进入复位态。
5.2 计时走快了或者走慢了一倍
现象:挂一晚上,早上起来发现比标准时间快了一倍,或者刚好慢一半。
原因:分频终值差一,或者把"分频计数器到 N 归零"写成了"到 N-1 归零",导致实际分频比是 N 而不是 N+1。更隐蔽的一种是把 1Hz 使能既当使能又当时钟,计数器在使能为高的整个周期里被触发了两次。
解决:分频比算清楚。50MHz 出 1Hz,计数器要数 50_000_000 个时钟周期,从 0 数到 49_999_999,所以终值判断和归零值差一是正常的,但必须是"数到 N-1 归零"这一种写法。验证方法是把DIV_TICK改小到 50,用示波器或者仿真量一下EN_TICK的周期,确认它等于 50 个CLK周期而不是 49 或 51。
5.3 综合报 multiple drivers,或者蜂鸣器一直响
现象:综合工具报某个信号有多个驱动源,或者下载后蜂鸣器上电就长鸣不停,按键无法关断。
原因:同一个信号在两个PROCESS里都被赋值了,比如原料里分模块的Q1和M1被两个进程分别驱动,或者CARRY在IF EN分支和IF CLR分支之外的地方被赋值。FPGA 内部的触发器只能有一个驱动源,多驱动在综合阶段会直接报错,在某些工具里则被"容错"成线与逻辑,行为完全不可预测。
解决:一个信号只在一个进程里赋值。需要多个来源时用多路选择器汇聚:先在各自进程里算出候选值存到不同信号,最后用一个组合WITH ... SELECT或条件赋值输出。蜂鸣器长鸣的另一个常见原因是复位初值没给,MUTE上电是'U',NOT MUTE在真实硬件上可能被解释成高电平,务必给所有控制寄存器加显式初值或复位赋值。
5.4 下载后数码管全灭或者显示乱码
现象:程序下载成功,配置灯也亮了,但六个数码管全不亮,或者亮的位置和内容跟预期对不上。
原因:三类问题。一是引脚分配没做或者分配到了错误的 bank,配置能下进去但 IO 没有真正驱动;二是段选极性搞反,共阴数码管给了低电平有效的段码;三是位选和段选的扫描顺序搞混,动态扫描时每一位的显示时间太短导致亮度极低,看起来像没亮。
解决:先确认管脚约束文件里每个 IO 都有对应的物理引脚,并且 IO 电平标准跟板子一致。段码极性用一个小测试工程验证:写死一个常数让六位全显示 8,如果全灭就取反再试。动态扫描的刷新率不要低于 200Hz,否则肉眼可见闪烁;也不要高于 2kHz,否则数码管的驱动能力跟不上,亮度会掉下来。
5.5 时序分析报告里出现建立时间违例
现象:功能都对,但综合报告里有一条路径的 slack 是负的,跑一段时间后偶尔出现某一拍计数错误。
原因:进位链上串了太多组合逻辑。比如把秒、分、时三个计数器的自增条件写成一条长长的组合表达式,或者比较器的判断直接组合输出到使能端,路径延迟超过了时钟周期。
解决:把每一级的自增条件都寄存一拍,让进位信号成为寄存器的输出而不是组合中间结果。3.4 节顶层例化里的C_SEC、C_MIN就是这么用的。另外约束文件里把主时钟周期写对,让工具按真实频率做时序分析,别用默认值蒙混过关。
6. 二次开发:把这套闹钟改成你自己的计时平台
代码跑通之后,这套东西的价值就不在"闹钟"本身了,它是一个完整的"多模块计数器加状态机加显示"模板。我一般会从三个方向改。
第一个方向是功能扩展。整点报时最省事:把分计数器CARRY和分的个位十位都为 0 的条件相与,驱动蜂鸣器响一声。贪睡功能需要一个额外的 5 分钟倒计时计数器,按下贪睡键时加载,减到 0 再触发响铃。倒计时和秒表本质上都是可加载、可暂停的计数器,改一下SET_EN和EN的优先级就能复用现有结构。
第二个方向是参数化。下面这张表里的参数改完之后,同一套代码能适配不同板子和不同需求:
| 参数 | 位置 | 默认值 | 改动影响 |
|---|---|---|---|
| DIV_TICK | 分频模块 GENERIC | 50_000_000 | 适配 27M/12M 晶振 |
| DIV_SCAN | 分频模块 GENERIC | 50_000 | 改数码管刷新率 |
| CNT_MAX | 去抖模块 GENERIC | 1_000_000 | 适配不同时钟下的 20ms 窗口 |
| 进位比较值 | 秒/分/时模块 | "0101" / "0011" | 改进制,如做 99 进制百分秒 |
第三个方向是验证方法。板上调试最痛苦的是"要看 24 小时翻转得等一天",所以 testbench 里一定要把DIV_TICK传成 5 到 50 之间的小值,让一秒钟能跑完几十个计时周期。我通常写一个激励进程模拟按键:先正常走 60 个节拍看秒进位,再用脉冲模拟一次"分调整",检查设置模式下计时是否停住,最后把闹钟设成当前时间的下一分钟,看响铃是否按时触发。
改代码之前还有个小技巧:把显示模块单独拎出来做一个顶层,输入写死几组常数,先确认数码管的段码和位选全部正确。这一层验证过之后再接回主系统,能省掉一大半"到底是显示错了还是计数错了"的排查时间。
说个我自己的教训。早几年做类似的项目,我图省事把按键直接接到计数器的使能上,仿真跑了十几种激励全过,板子一上电按一下分钟跳了七下,我当时以为是时钟分频算错了,翻来覆去查了两天分频器,最后拿示波器看按键波形才反应过来是抖动。从那以后我每次做按键输入,不管多简单的板子,都强制先过一遍同步加去抖,哪怕这个键一天只按一次。希望帮到你。
本文还有配套的精品资源,点击获取