简介:面向数字电路与EDA课程学习者及FPGA入门开发者的设计文档,围绕基于FPGA的闹钟系统展开,用VHDL语言实现多功能数字时钟与定时闹钟。内容从EDA技术、FPGA结构与查找表原理、VHDL语法及开发流程讲起,再拆解时间存储、计时、闹钟设置、比较器、显示与用户交互等模块,并给出Quartus仿真综合、GW48开发板下载测试的完整实施路径,对初次接触硬件描述语言的读者较为友好。压缩包内仅1个doc文档,约2.71MB,以文字论述与代码说明为主,含摘要、目录与关键词,结构接近规范论文,也可作为写作模板参考。已有832人学习,适合需要现成模块划分方案、设计思路与验证流程的读者对照使用。
1. 从一块实验开发箱说起:这套 VHDL 闹钟工程到底能跑通什么
FPGA 闹钟这类题目,网上能搜到的多半是几张原理图加半页代码,真正能编译、能上板、能复现的少。这套基于 FPGA 的闹钟系统设计资源是完整的一套 VHDL 工程:秒、分、时三级 BCD 计数链,加上时间校准、闹钟设定、时间与闹钟显示切换、蜂鸣器发声,用 Quartus 做综合与布局布线,最终下载到实验开发箱的 FPGA 器件上跑通,计时准确、校准和定时响铃都验证过。
它适合三类人:正在做数字系统课程设计、需要一个能上板的最小完整工程做底子;从单片机转过来、第一次写硬件描述语言,想看清「并行」和「时序」到底差在哪;以及手里已经有开发箱,只想找个现成顶层框架往里填功能。下面按拆模块、写代码、上板、排错的顺序,把它拆到能照着复现的程度。
2. 顶层划分与端口约定:六个模块怎么切、时钟从哪来
拿到一份 VHDL 工程,先不要按文件顺序读,先看顶层实体例化了什么。顶层结构定了,后面每个模块的端口语义基本就锁死了,读代码的速度会快一个量级。这一章先把划分方式和时钟来源讲清楚,因为这两件事决定了后面所有代码能不能改得动。
2.1 按「功能角色」切,而不是按「时间单位」切
原始工程是按时间单位切的:miao、fen、shi三个计数实体,再叠加调整控制模块、闹钟控制模块、显示模块和发声模块,一共七块。这种切法的好处很直观——每根时间信号链是一一对应的,秒模块的输出接分模块的使能,分模块接时模块,读代码时顺着链条走就行,课程设计答辩时也好讲。
代价是「设置」逻辑会渗透进每一个计数模块。你会看到分模块、时模块都带着LOAD、EN这类端口,只要需求稍微一变——比如加一个「秒也可以单独设置」,或者加 12/24 小时制切换——三个计数模块全得开刀。我一般会把「当前处于什么状态」抽出来做成一个独立的状态寄存器,输出两路全局信号:SET_MODE(两位,00 正常走时、01 设置时间、10 设置闹钟)和INC_PULSE(消抖后的单拍加一脉冲)。计数模块只认这两个信号,端口全部统一成同一套,加功能时改一处就够。
各模块的职责边界建议这样划:
| 模块 | 输入 | 输出 | 职责 |
|---|---|---|---|
| 分频/使能 | 板载晶振 | CLK_1K、EN_1HZ | 提供系统时钟与秒使能 |
| 秒计数 | CLK_1K、EN_1HZ、CLR | 秒 BCD、CARRY_S | 0~59 循环 |
| 分计数 | 秒进位、设置态 | 分 BCD、CARRY_M | 0~59 循环,可校准 |
| 时计数 | 分进位、设置态 | 时 BCD | 0~23 循环,可校准 |
| 闹钟寄存器 | 设置态、加一脉冲 | 闹钟时/分 BCD | 存目标时刻 |
| 比较与响铃 | 当前时/分/秒、闹钟值 | RING | 到点置位、离位复位 |
| 显示扫描 | 六组 BCD、DISPLAY_EN | 段码、位选 | 动态扫描 + 双路选择 |
提示:这张表不是让你照抄,而是让你在动手前先填一遍。填不出来的格子,就是后面一定会返工的地方。
2.2 端口与按键语义:极性、消抖、优先级三件事先说死
原工程的按键命名是CLR、SET_M、SET_H、SET_CLOCK、SET_DINGS、DISPLAY_EN。这套名字本身没问题,问题在于语义没写死——复位是高有效还是低有效?按键按下是 0 还是 1?SET_CLOCK按一下是「进入设置」还是「进入并退出」切换?这些在代码里必须只有一个答案,否则仿真和上板会出现「仿真对、实物错」的经典局面。
我一般会在顶层注释里写死一张约定表,然后所有模块只认这张表:
ENTITY alarm_top IS PORT( CLK_50M : IN STD_LOGIC; -- 板载晶振,频率按实际板卡改 CLR : IN STD_LOGIC; -- 复位,高有效,异步 KEY_M : IN STD_LOGIC; -- 分钟加一,低有效,需消抖 KEY_H : IN STD_LOGIC; -- 小时加一,低有效,需消抖 KEY_SETC : IN STD_LOGIC; -- 进入/退出时间设置,低有效 KEY_SETA : IN STD_LOGIC; -- 进入/退出闹钟设置,低有效 KEY_DISP : IN STD_LOGIC; -- 时间/闹钟显示切换,低有效 SEG : OUT STD_LOGIC_VECTOR(6 DOWNTO 0); -- 段码 a~g,共阴 DIG : OUT STD_LOGIC_VECTOR(5 DOWNTO 0); -- 六位数码管位选,低有效 BEEP : OUT STD_LOGIC -- 无源蜂鸣器驱动 ); END ENTITY alarm_top;CLR用高有效是跟原工程保持一致的,因为开发箱上的复位按键多数是按下输出高。按键用低有效,是因为板上通常已经带了上拉电阻,按下直接对地。这两条一旦定下来,后面所有IF CLR = '1'和IF KEY = '0'的写法才有依据,不会出现「一半模块高有效、一半低有效」的血泪经验。
优先级也要一次定死:复位最高,其次是设置态的加一脉冲,最后才是正常走时的进位。写进IF-ELSIF的顺序必须和这个优先级一致,因为综合工具不会帮你重排。
2.3 三路时钟从哪来:别把分频出来的 1 Hz 当时钟用
原工程的写法是分频出 1 Hz 直接当秒模块的CLK,再用秒模块的进位CNT当日历分模块的时钟,一级一级串下去。这种「行波时钟」在教科书里能跑,在 FPGA 上综合工具会给你一串gated clock警告,上板后大概率出现计数跳变、某一位数码管乱闪,甚至整个链停住。
常见做法是只保留一个高频系统时钟,把所有分频结果做成「使能脉冲」,全设计同步于同一根时钟。我这边的习惯是:1 kHz 作为系统工作时钟,驱动消抖、扫描和计数;再由它数 1000 拍产生一个宽度一拍的EN_1HZ,作为秒计数的使能。这样整条链只有一个时钟域,时序分析也好做。
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; USE IEEE.NUMERIC_STD.ALL; ENTITY clk_div IS GENERIC( F_IN : INTEGER := 50_000_000; -- 板载晶振频率,必须按实际板卡改 F_OUT : INTEGER := 1_000 -- 目标频率,这里要 1 kHz 系统时钟 ); PORT(CLK_IN : IN STD_LOGIC; CLK_OUT : OUT STD_LOGIC); END ENTITY clk_div; ARCHITECTURE rtl OF clk_div IS CONSTANT HALF : INTEGER := F_IN / (2 * F_OUT); -- 半周期计数深度 SIGNAL CNT : INTEGER RANGE 0 TO HALF - 1 := 0; SIGNAL Q : STD_LOGIC := '0'; BEGIN PROCESS(CLK_IN) BEGIN IF RISING_EDGE(CLK_IN) THEN IF CNT = HALF - 1 THEN CNT <= 0; Q <= NOT Q; -- 翻转得到 50% 占空比 ELSE CNT <= CNT + 1; END IF; END IF; END PROCESS; CLK_OUT <= Q; END ARCHITECTURE rtl;F_IN是最容易翻车的参数。开发箱上常见的晶振有 20 MHz、50 MHz 几种,写错一位,实际秒长就会变成 2.5 秒或者 0.4 秒,你会以为是计数器逻辑错了,回头查半天代码。HALF用整数除法算出来,除不尽时会有微小误差,对闹钟这种应用完全可以接受;如果要更准,就把F_IN设成F_OUT的偶数倍。
秒使能的产生更简单,不需要再例化一个分频器:
-- 在 1 kHz 时钟域里数 1000 拍,产生一拍宽的秒使能 PROCESS(CLK_1K) BEGIN IF RISING_EDGE(CLK_1K) THEN IF CNT_1HZ = 999 THEN CNT_1HZ <= 0; EN_1HZ <= '1'; -- 只高一拍 ELSE CNT_1HZ <= CNT_1HZ + 1; EN_1HZ <= '0'; END IF; END IF; END PROCESS;这里的关键区别是:EN_1HZ是数据信号,参与的是组合逻辑判断;CLK_1K是时钟信号,进的是时钟网络。前者可以随便进IF,后者不行。把这两类信号分清楚,后面写计数模块时就不会再写出IF EN_1HZ'EVENT这种既不能综合、仿真又莫名奇妙的代码。
工程侧的流程也得跟上:在 Quartus 里新建工程时把器件型号选对,把全部.vhd加进工程,顶层实体设为alarm_top,然后按「底层模块 → 顶层」的顺序编译一遍,先看有没有语法错,再看综合报告里的警告。这一步花十分钟,能省掉后面两小时的猜谜。
3. 秒分时三级计数链:BCD 进位、校准写入与 24 进制回滚
计数链是整个闹钟的骨架,也是最容易「仿真对、上板错」的地方。原因通常不在算法,而在几个很具体的写法细节上:变量和信号的区别、进位标志取在哪个时刻、多个时钟沿判断能不能并列写。这一章把三个计数器逐个拆开,每一段都给可编译的代码和参数说明。
3.1 秒模块:进位标志取在 59 还是取在 00
秒计数要求很简单:0~59 循环,每到 59 的下一拍回到 00,同时给分模块一个进位。听起来是废话,但「进位信号什么时候拉高」这件事有两种写法,结果完全不同。取在 00 那一拍,分模块必须等到下一拍才能加一,链路上就多了一拍延迟;取在 59 这一拍,分模块可以在同一拍响应,全链严格同步。
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; USE IEEE.STD_LOGIC_UNSIGNED.ALL; -- 为了能直接对 BCD 向量做 "<" 比较和 "+1" ENTITY miao IS PORT( CLR : IN STD_LOGIC; -- 复位,高有效 CLK : IN STD_LOGIC; -- 系统时钟 1 kHz,不是 1 Hz EN : IN STD_LOGIC; -- 秒使能,宽度一拍 S1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 秒个位 BCD S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 秒十位 BCD CNT : OUT STD_LOGIC -- 处于 59 时为高,作分的使能 ); END ENTITY miao; ARCHITECTURE behav OF miao IS BEGIN PROCESS(CLR, CLK) VARIABLE M1, M2 : STD_LOGIC_VECTOR(3 DOWNTO 0); -- 必须是变量 BEGIN IF CLR = '1' THEN M1 := "0000"; M2 := "0000"; ELSIF RISING_EDGE(CLK) THEN IF EN = '1' THEN IF M1 < "1001" THEN M1 := M1 + 1; -- 个位 0~9 ELSE M1 := "0000"; IF M2 < "0101" THEN M2 := M2 + 1; -- 十位 0~5 ELSE M2 := "0000"; -- 59 → 00 END IF; END IF; END IF; END IF; IF (M1 = "1001") AND (M2 = "0101") THEN CNT <= '1'; -- 当前处于 59,下一拍进位 ELSE CNT <= '0'; END IF; S1 <= M1; S2 <= M2; END PROCESS; END ARCHITECTURE behav;这段代码有三个点值得单独说。第一,M1、M2用的是VARIABLE而不是SIGNAL。变量在进程内赋值立即生效,M1 := M1 + 1之后紧接着的IF M1 < "1001"看到的是新值;如果换成信号,信号赋值要等进程结束才更新,同一拍里读到的是旧值,计数器就会整体慢一拍,表现出来就是「59 秒要停留两拍」或者「进位丢了」。这是新手最常踩的一个坑,仿真波形上非常隐蔽。
第二,用STD_LOGIC_UNSIGNED库可以直接对STD_LOGIC_VECTOR做比较和加一,写起来短。但它不是 IEEE 标准库,严格的综合工具会报警告,工程要长期维护的话建议换成NUMERIC_STD,把 BCD 码显式转成UNSIGNED再比较。第三,CNT的赋值写在时钟沿的IF外面,属于对寄存器输出的纯组合译码,综合出来是几个与门,不占触发器,也不会引入额外延迟。
3.2 分模块:设置态和计时态谁优先
分模块比秒模块多两路输入:SET_EN(当前处于设置分钟状态)和SET_PUL(消抖后的加一脉冲)。写的时候一定要把这两个态放在IF-ELSIF的前面,因为一个人在设置分钟的时候,秒可能正好走到 59,两个事件同一拍到达。如果计时态优先,你按一下加一,结果被自动进位覆盖掉,现象就是「按了没反应」或者「按一下跳两下」。
ENTITY fen IS PORT( CLR : IN STD_LOGIC; CLK : IN STD_LOGIC; EN : IN STD_LOGIC; -- 来自秒模块的 CNT SET_EN : IN STD_LOGIC; -- 设置分钟态 SET_PUL: IN STD_LOGIC; -- 单拍加一脉冲 S1, S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); CNT : OUT STD_LOGIC -- 处于 59 时为高 ); END ENTITY fen; ARCHITECTURE behav OF fen IS BEGIN PROCESS(CLR, CLK) VARIABLE M1, M2 : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN IF CLR = '1' THEN M1 := "0000"; M2 := "0000"; ELSIF RISING_EDGE(CLK) THEN IF (SET_EN = '1') AND (SET_PUL = '1') THEN -- 设置优先 IF M1 < "1001" THEN M1 := M1 + 1; ELSE M1 := "0000"; IF M2 < "0101" THEN M2 := M2 + 1; ELSE M2 := "0000"; END IF; END IF; ELSIF EN = '1' THEN -- 正常计时 IF M1 < "1001" THEN M1 := M1 + 1; ELSE M1 := "0000"; IF M2 < "0101" THEN M2 := M2 + 1; ELSE M2 := "0000"; END IF; END IF; END IF; END IF; IF (M1 = "1001") AND (M2 = "0101") THEN CNT <= '1'; ELSE CNT <= '0'; END IF; S1 <= M1; S2 <= M2; END PROCESS; END ARCHITECTURE behav;和原工程相比,这里把SET_PUL做成了一个干净的、宽度严格一拍的脉冲,模块内部不再判断LOAD'EVENT。原写法把按键当成了异步时钟,LOAD'EVENT AND LOAD='1'这种判断综合工具能过,但会生成一个额外的时钟域,按键抖动一次就多计好几次,而且时序分析里这根线是「未约束路径」,出了问题很难定位。用同步单拍脉冲替代,是这类设计里性价比最高的一次改写。
SET_PUL从哪来?就是 2.2 里说的那套「两级同步 + 计数确认 + 边沿提取」,输出一个只在按下瞬间高一个CLK周期的信号。第四章会给完整代码。
3.3 时模块:24 进制回滚的一个边界
时模块的难点只有一个:23 点之后要回 00,而不是 24。原工程的写法是先按 0~9 个位、0~5 十位加,最后再单独判断一次「十位是 2 且个位是 3」执行清零。这种「先加后纠」的写法能跑,但中间会短暂出现 24 这个非法状态,如果显示模块的扫描刚好扫到那一刻,你会看到数码管闪一下「24」,观感很差。
改成「先判断再动作」,一次到位:
ARCHITECTURE behav OF shi IS BEGIN PROCESS(CLR, CLK) VARIABLE H1, H2 : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN IF CLR = '1' THEN H1 := "0000"; H2 := "0000"; ELSIF RISING_EDGE(CLK) THEN IF ((SET_EN = '1') AND (SET_PUL = '1')) OR (EN = '1') THEN IF (H2 = "0010") AND (H1 = "0011") THEN -- 当前正好 23 点 H1 := "0000"; H2 := "0000"; -- 直接回 00 ELSIF H1 < "1001" THEN H1 := H1 + 1; -- 个位 0~9 ELSE H1 := "0000"; H2 := H2 + 1; -- 十位 0~2 END IF; END IF; END IF; S1 <= H1; S2 <= H2; END PROCESS; END ARCHITECTURE behav;参数上要留意的只有置数范围:H2最大到"0010",所以十位只可能是 0、1、2;一旦你的逻辑允许它走到 3,说明 23 点的判断写漏了。另外这里SET_EN和EN用OR合并进了同一个IF,是因为两者执行的加一动作完全一样,没必要重复写一遍。真正需要区分优先级的是「加一」和「不进位」这两种情况,而EN本身已经是一个使能脉冲,天然互斥。
3.4 手写 testbench:把两个边界压出来
模块写完必须单独仿真,而且不要指望随便跑几个周期就能发现问题。计时器真正会翻车的只有几个边界:59→00、23:59→00:00、以及 23:59:59 这一拍三处进位同时发生。仿真的目的就是把这几个点定向压出来。
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; ENTITY tb_miao IS END ENTITY tb_miao; ARCHITECTURE sim OF tb_miao IS COMPONENT miao PORT( CLR : IN STD_LOGIC; EN : IN STD_LOGIC; CLK : IN STD_LOGIC; S1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); CNT : OUT STD_LOGIC); END COMPONENT; SIGNAL CLR, EN, CLK, CNT : STD_LOGIC := '0'; SIGNAL S1, S2 : STD_LOGIC_VECTOR(3 DOWNTO 0); CONSTANT T : TIME := 1 ms; -- 对应 1 kHz 系统时钟 BEGIN UUT : miao PORT MAP(CLR => CLR, EN => EN, CLK => CLK, S1 => S1, S2 => S2, CNT => CNT); P_CLK : PROCESS BEGIN CLK <= '0'; WAIT FOR T / 2; CLK <= '1'; WAIT FOR T / 2; END PROCESS; P_STIM : PROCESS BEGIN CLR <= '1'; WAIT FOR 3 * T; -- 复位保持 3 拍 CLR <= '0'; FOR i IN 0 TO 60 LOOP -- 61 个使能,必然覆盖 59→00 EN <= '1'; WAIT FOR T; EN <= '0'; WAIT FOR 9 * T; -- 拉大间隔,波形看得清 END LOOP; WAIT; END PROCESS; END ARCHITECTURE sim <p> <a href="https://download.csdn.net/download/mu7xuan/3101117" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>