简介:面向学习EDA与FPGA数字系统设计的高校学生及电子工程师,这份文档围绕多功能数字闹钟的完整实现展开,涵盖EDA基础、VHDL语法与开发流程、需求分析、模块划分、Quartus仿真综合以及GW48开发板下载测试。内容以时间存储、计时、闹钟设置、比较器、显示和用户交互等模块为主线,给出源代码开发与验证思路,可用于课程设计、实验报告和小型数字系统入门参考。压缩包共1个doc文件,约2.71MB,便于集中阅读和打印,目录按前言、EDA技术、系统设计与实现流程组织。已有832人学习,适合希望从VHDL建模走到FPGA原型验证的读者查漏补缺。
1. 答辩演示只给三分钟
按下校时键,分钟跟着走;设到 23:59 等一分钟,必须回到 00:00;闹钟时间一到,扬声器立刻响。这就是基于 FPGA 的闹钟系统设计最扎实的需求起点——不是在板上挂一颗时钟芯片,而是用 EDA 工具链把计时、校准、闹铃三件事拆成可单独仿真、可单独测量、再拼回整体的模块,用 VHDL 描述成可综合的逻辑。它适合正在做数字电路、EDA 方向课程设计的人:需要在 Quartus 里跑通综合与时序仿真,再把配置文件下载到实验开发箱上做实物演示;也适合需要一个模块化计数与比较设计参照的从业者。下面按交付顺序走:需求怎么落成条目,模块怎么切,代码怎么写,坑怎么避,最后怎么验。
2. 总体架构设计:三条计数链、两条控制链、一个输出链
数字闹钟看着简单,真正动手写之前最容易犯的错是「上来就敲代码」。我一般会先把需求翻译成一张可测的条目表,再决定模块切到哪一层。这一步做扎实,后面 VHDL 写起来几乎是填空;这一步糊弄过去,综合能过、仿真能跑,一上板就是秒针不走、按键连跳、闹铃不响。
2.1 功能清单先落到可测的粒度
把「做一个多功能数字闹钟」拆成五条可以单独验证的行为,每条都带边界条件,这是后面写测试激励的直接依据:
| 功能域 | 行为描述 | 边界条件 | 验证方式 |
|---|---|---|---|
| 计时 | 每 1 秒计一次,秒→分→时自动进位 | 59 秒进 1 分,59 分进 1 时,23 时归 00 时 | 仿真里连续跑 86400 个秒脉冲 |
| 校时 | 显示时间态下进入设置,用 SET_M / SET_H 改分和时 | 秒不参与校准;分到时 59 再 +1 回到 00 | 手工按键,看数码管跳变 |
| 闹钟设定 | 显示闹钟态下进入设置,改闹钟的时和分 | 与校时互斥,不能同时进入 | 两个使能信号做互锁 |
| 闹铃 | 当前时间与闹钟时间一致时扬声器发声 | 只比对时和分,秒不参与;可手动关闭 | 设到当前时间 +1 分钟,等 60 秒 |
| 显示切换 | DISPLAY_EN 在时间显示与闹钟显示之间切换 | 设置过程中不响应切换 | 拨动开关看数码管内容整体变化 |
对应的输入资源一共八个:CLR 复位、SET_M 分调整、SET_H 时调整、SET_CLOCK 校时使能、SET_DINGS 闹钟设定使能、DISPLAY_EN 显示切换,加上系统时钟和扬声器输出。这套按键定义是原方案的骨架,改动它等于重做整个控制逻辑,所以先在表里定死。
2.2 模块划分:三级级联的计数链
整体结构是典型的串行进位:秒模块自己数到 59,产生一个进位信号给分模块当节拍;分模块数到 59,再产生进位给时模块;时模块数到 23 归零。三条计数链并排站着,各自输出自己的 BCD 个位和十位,最后统一送到显示模块。
1Hz 秒脉冲 ──► [秒计数 MIAO] ──进位──► [分计数 FEN] ──进位──► [时计数 HOUR] │ │ │ 秒个/十位 分个/十位 时个/十位 └──────────┬───────────┴──────────┬───────────┘ ▼ ▼ [显示切换 MUX] ◄──── DISPLAY_EN / 闹钟设定值 │ ▼ [动态扫描显示 SEG] 当前时+分 ──► [比较器 CMP] ◄── 闹钟时+分 / 闹钟使能 ──► [蜂鸣器驱动 BUZZ]两条控制链是并行的:一条管校时(SET_CLOCK 使能 + SET_M/SET_H 步进),一条管闹钟设定(SET_DINGS 使能 + 同样的两个步进键)。这两条链永远只有一个处于激活状态,用互锁逻辑保证——这是最容易漏掉的设计约束,一旦两条同时使能,分模块的计数分支和写入分支会打架。
我为什么坚持串行进位而不是「用一个 17 位二进制计数器数总秒数再转换成 BCD」?后者代码短,但拆分验证几乎不可能:你没法单独证明「秒模块数到 60 归零」这一件事是对的,出错时只能整段重来。串行结构每个模块只有十来个触发器,波形图上一眼能看出是哪一级没进位,这对要写设计文档、要在答辩时讲清原理的人是刚需。
2.3 进位信号当时钟用:能用,但要知道代价
原方案里分模块的时钟输入直接接秒模块的进位输出,写法上非常自然:秒数满 60 那一拍,进位线拉高,分模块的上升沿到了,分加一。仿真跑起来完全正确。
问题在后端。这种接法在综合工具眼里是一段「派生时钟」,也就是由组合逻辑或普通寄存器输出驱动的时钟网络,不在全局时钟树上。Quartus 综合后通常会给一条警告,时序分析也会把它归到另一组约束里。开发箱上跑几十赫兹的秒脉冲时看不出毛病,因为它慢到任何毛刺都被吞掉了;但这个习惯一旦带到高速设计里就是灾难。
我的做法是保留「进位脉冲」这个概念,但在模块内部改成统一时钟加使能:
-- 更稳妥的写法:所有模块吃同一根系统时钟,进位变成使能 process(sys_clk, clr) begin if clr = '1' then min_u <= "0000"; min_t <= "0000"; elsif rising_edge(sys_clk) then if sec_carry = '1' then -- 1 个时钟周期宽的使能脉冲 if min_u < "1001" then min_u <= min_u + 1; else min_u <= "0000"; min_t <= min_t + 1; end if; end if; end if; end process;差别就一行:把「进位当 clk」换成「进位当 if 条件」,整条链就都挂在同一根全局时钟上,时序报告干净,跨级联也不会有建立/保持的隐忧。给定点是实验箱、节拍只有 1Hz 的场合,两种写法都能演示成功;但要写进文档里被人挑刺,后者更站得住。
2.4 引脚分配与资源估算
引脚约束是把设计从仿真拽到现实的那一步,也是学生最容易在实验室耗掉一晚上的地方。做法是先确认开发箱的时钟选择区哪个跳线对应 1Hz、哪些按键是带上拉的低有效、数码管是共阴还是共阳,再一条条填进引脚分配表。
| 信号名 | 方向 | 开发箱资源 | 说明 |
|---|---|---|---|
| clk_1hz | 输入 | 时钟选择区 1Hz 输出 | 秒计数节拍,接专用时钟脚 |
| clr | 输入 | 按键 K1 | 全局复位,高有效 |
| set_m | 输入 | 按键 K2 | 分 / 闹钟分 步进 |
| set_h | 输入 | 按键 K3 | 时 / 闹钟时 步进 |
| set_clock | 输入 | 按键 K4 | 进入 / 退出校时 |
| set_dings | 输入 | 按键 K5 | 进入 / 退出闹钟设定 |
| display_en | 输入 | 拨码开关 SW1 | 时间显示 / 闹钟显示 |
| seg[7:0] | 输出 | 数码管段选 | 共阴则高电平点亮 |
| sel[5:0] | 输出 | 数码管位选 | 6 位:时时分分秒秒 |
| buzz | 输出 | 蜂鸣器 | 闹铃输出 |
资源量算一下:秒模块 8 个触发器,分模块 8 个,时模块 8 个,闹钟寄存 8 个,扫描计数器 3 个,消抖计数器每个按键 5 位共 25 个,合起来不到 80 个触发器。任何一款入门级 FPGA 的查找表资源都绰绰有余,所以选型的重点不在容量,而在开发箱上有没有 1Hz 时钟源、有没有六位数码管、有没有蜂鸣器这三件事。
提示:共阴共阳搞反是新手第一晚的固定节目,症状是「数码管亮但笔画全错」。动手前先用万用表二极管档量一下段选脚,比事后改译码表省事得多。
3. 核心模块的 VHDL 实现:从秒计数到顶层例化
这一部分给出可以直接照着敲的源码骨架。全部基于 IEEE 标准库,综合工具用 Quartus,语法层面没有依赖任何厂商私有包,换到别的综合工具也能过。每个模块我都按「端口 → 架构 → 关键判断」的顺序讲,方便你改参数。
3.1 秒计数模块:个位十位分离与 59 归零
秒模块是整个系统的节拍源,它只做一件事:每来一个 1Hz 上升沿加一,数到 59 之后回零并吐出一个进位。
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; USE IEEE.STD_LOGIC_UNSIGNED.ALL; -- 允许对 std_logic_vector 直接做 + 和比较 ENTITY MIAO IS PORT( CLR : IN STD_LOGIC; -- 复位,高有效 CLK : IN STD_LOGIC; -- 1Hz 秒脉冲 S1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 秒个位 S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 秒十位 CNT : OUT STD_LOGIC -- 59→00 的进位 ); END ENTITY; 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"; CNT <= '0'; ELSIF CLK'EVENT AND CLK = '1' THEN IF M1 < "1001" THEN -- 个位 0~8 直接加 M1 := M1 + 1; ELSE -- 个位到 9,归零并向十位进位 M1 := "0000"; M2 := M2 + 1; END IF; IF M2 = "0110" THEN -- 十位到 6 说明已满 60 M2 := "0000"; CNT <= '1'; ELSE CNT <= '0'; END IF; END IF; S1 <= M1; S2 <= M2; END PROCESS; END ARCHITECTURE;三个要点必须说清。第一,M1、M2用变量而不是信号,是因为同一个进程里先写后读要拿到当拍新值,用信号会引入一拍延迟,个位到 9 时十位会晚一个周期才进位,60 就数成 61 了。第二,M1 < "1001"这种写法依赖STD_LOGIC_UNSIGNED包,没有这个 USE 语句,比较会按字典序而不是数值来,"1001" < "0110"的结果会让人怀疑人生。第三,CNT 的宽度不是「一个脉冲」,而是一整个时钟周期——1Hz 时钟下它高一秒。这个细节决定了下一级怎么用它。
3.2 分模块:计时与校时写入的优先级
分模块比秒模块多两件事:接收校时按键的步进,以及向时模块进位。核心难点在于同一个时钟沿上可能同时发生「自然计时」和「用户按键」两种请求,必须有明确的优先级。
LIBRARY IEEE; USE IEEE.STD_LOGIC_1164.ALL; USE IEEE.STD_LOGIC_UNSIGNED.ALL; ENTITY FEN IS PORT( CLR : IN STD_LOGIC; -- 复位 CLK : IN STD_LOGIC; -- 秒模块进位,上升沿有效 EN : IN STD_LOGIC; -- 校时使能,1 = 处于设置态 LOAD : IN STD_LOGIC; -- SET_M 消抖后的单脉冲 S1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 分个位 S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 分十位 CNT : OUT STD_LOGIC -- 59→00 进位 ); END ENTITY; ARCHITECTURE BEHAV OF FEN IS SIGNAL M1, M2 : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN PROCESS(CLR, CLK) BEGIN IF CLR = '1' THEN M1 <= "0000"; M2 <= "0000"; CNT <= '0'; ELSIF CLK'EVENT AND CLK = '1' THEN IF EN = '1' AND LOAD = '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; CNT <= '0'; ELSIF EN = '0' THEN -- 非设置态:正常走时 IF M1 < "1001" THEN M1 <= M1 + 1; ELSE M1 <= "0000"; M2 <= M2 + 1; END IF; IF M1 = "1001" AND M2 = "0101" THEN M1 <= "0000"; M2 <= "0000"; CNT <= '1'; ELSE CNT <= '0'; END IF; END IF; END IF; END PROCESS; S1 <= M1; S2 <= M2; END ARCHITECTURE;这段代码有两个设计决定值得讲。第一个是EN = '1' AND LOAD = '1'这个条件——只有处于设置态且按键脉冲到来才改值。如果没有EN这个约束,正常走时的时候误触按键会直接改掉当前时间,演示时按一下表就乱了。第二个是ELSIF EN = '0'这个分支,它保证了设置态下走时暂停:用户在改分钟的时候,秒进位不会偷偷把分加一,改完退出正好接上。原方案里用EN去控制「装载」和「计数」两个分支的优先级,逻辑上是同一个思路,我在这里把它写得更直白,是因为见过太多把使能写成独立 if 导致两个条件同时成立时结果不确定的版本。
另外注意IF M1 = "1001" AND M2 = "0101"这句:它是拿「当前值」判断而不是「加完的值」,因为信号赋值是非阻塞语义,这一拍读到的还是旧值,符合「数到 59 才归零」的直觉。如果换成变量,就得改成「加完等于 60 归零」,写法不通用,容易翻车。
3.3 时模块:23 进制与跨天归零
时模块的进位规则比分级更麻烦:它不是整齐的十进制或六十进制,而是 0~23 的二十四进制,十位只到 2。
ENTITY HOUR IS PORT( CLK : IN STD_LOGIC; -- 分模块进位 CLR : IN STD_LOGIC; EN : IN STD_LOGIC; -- 校时使能 LOAD : IN STD_LOGIC; -- SET_H 单脉冲 H1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); -- 时个位 H2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0) -- 时十位 ); END ENTITY; ARCHITECTURE BEHAV OF HOUR IS SIGNAL M1, M2 : STD_LOGIC_VECTOR(3 DOWNTO 0); BEGIN PROCESS(CLR, CLK) BEGIN IF CLR = '1' THEN M1 <= "0000"; M2 <= "0000"; ELSIF CLK'EVENT AND CLK = '1' THEN IF EN = '1' AND LOAD = '1' THEN -- 手动设置:23 之后回 00 IF M1 = "1001" THEN M1 <= "0000"; IF M2 = "0010" THEN M2 <= "0000"; ELSE M2 <= M2 + 1; END IF; ELSE M1 <= M1 + 1; END IF; ELSIF EN = '0' THEN -- 自动走时 IF M2 = "0010" AND M1 = "0011" THEN -- 23 点,跨天归零 M1 <= "0000"; M2 <= "0000"; ELSIF M1 = "1001" THEN -- 个位 9 向十位进位 M1 <= "0000"; M2 <= M2 + 1; ELSE M1 <= M1 + 1; END IF; END IF; END IF; END PROCESS; H1 <= M1; H2 <= M2; END ARCHITECTURE;判断顺序是这里的关键。如果把M1 = "1001"放在最前面,那么 23 点会先被判成「个位进位」,变成 30 点,然后才想起来要归零,时间就飞了。所以必须先判23(M2=2 且 M1=3),再判个位 9,最后才是普通加一——这是级联计数器里最容易写反的一段,改代码时手一抖就成玄学。
手动设置那条分支我单独处理了十位的上限(IF M2 = "0010" THEN M2 <= "0000"),因为校时的时候用户可以从 23 一路按上去,如果不夹住,十位会数到 3、4、5,显示区直接跑出 2 位数的表达能力。
3.4 顶层例化:把六个模块连成一张网
底层模块各自仿真通过之后,顶层只做一件事——连线和例化,不写任何逻辑判断。
ARCHITECTURE STRUCT OF CLOCK_TOP IS COMPONENT MIAO PORT(CLR, CLK : IN STD_LOGIC; S1, S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); CNT : OUT STD_LOGIC); END COMPONENT; COMPONENT FEN PORT(CLR, CLK, EN, LOAD : IN STD_LOGIC; S1, S2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0); CNT : OUT STD_LOGIC); END COMPONENT; SIGNAL c_sec, c_min : STD_LOGIC; -- 两级进位 SIGNAL su, st, mu, mt, hu, ht : STD_LOGIC_VECTOR(3 DOWNTO 0); -- BCD 输出 SIGNAL en_min, en_hour, ld_m, ld_h : STD_LOGIC; -- 控制链 BEGIN U_SEC : MIAO PORT MAP(CLR => clr, CLK => clk_1hz, S1 => su, S2 => st, CNT => c_sec); U_MIN : FEN PORT MAP(CLR => clr, CLK => c_sec, EN => en_min, LOAD => ld_m, S1 => mu, S2 => mt, CNT => c_min); -- 时模块、闹钟寄存器、显示扫描等按同样方式例化 END ARCHITECTURE;用位置关联还是名字关联?我固定用名字关联(PORT MAP(CLR => clr, ...)),虽然多敲几个字,但端口一多就不会把S1和S2接反——位置关联接反了编译照样过,只是显示出来的数字个位十位颠倒,查起来极其费劲。顶层的信号一律用命名清晰的前缀(c_表进位、en_表使能、ld_表装载),这样在波形窗口里一搜就能定位。
4. 校时、闹钟与显示:按键处理与显示切换
前面三个模块跑通,闹钟其实只完成了一半。剩下的一半全是「人机交互」——按键、显示、发声,这部分在仿真里最枯燥,在上板时最容易出问题。
4.1 按键消抖:20ms 与单周期脉冲
机械按键按下的一瞬间,触点会在几毫秒内反复弹跳,FPGA 的采样速度足够把每一次弹跳都当成一次有效按键。症状很直观:按一下 SET_M,分钟跳了三格。
标准做法是用一个 1kHz 的采样节拍,连续 20 次采到同一电平才认账,然后在「稳定值变化」的那一刻产出一个单周期脉冲。
-- key_debounce:输入原始按键,输出 1 个 clk_1k 周期宽的脉冲 process(clk_1k, clr) begin if clr = '1' then cnt <= (others => '0'); key_sync <= '0'; pulse <= '0'; elsif rising_edge(clk_1k) then key_sync <= key_in; -- 一级同步,防亚稳态 if key_sync /= key_prev then -- 电平还在变,计数器重来 cnt <= (others => '0'); key_prev <= key_sync; elsif cnt = 19 then -- 连续 20 个 1ms 稳定 key_stable <= key_sync; pulse <= key_stable and (not key_stable_d); key_stable_d <= key_stable; else cnt <= cnt + 1; end if; end if; end process;参数含义:clk_1k是 1ms 节拍;cnt计到 19 意味着 20 次采样,也就是 20ms 的确认窗口;pulse只有一拍高,所以下一级的计数器一次按键只会加一。把窗口从 20ms 改到 10ms 也能用,但按键质量差一点就会连跳;改到 50ms 会明显感觉按下去「迟钝」。20ms 是经过大量实验后比较舒服的值。
那个key_sync <= key_in的一级同步不能省。按键是异步信号,直接进状态机会有极小概率采到亚稳态,表现为「偶尔按一下没反应」——这种偶发问题在答辩现场出现一次就够呛。
4.2 显示切换与六位数码管动态扫描
显示器要复用:平时显示当前时分秒,按 DISPLAY_EN 之后显示闹钟设定的时分。做法不是做两套显示驱动,而是在译码前加一级数据选择。
-- 数据选择:disp_en = '0' 显示当前时间,'1' 显示闹钟设定 d0 <= hu when disp_en = '0' else ahu; -- <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>