简介:一套基于VHDL的4位十进制动态扫描显示电路设计文件,对应DTCNT9999.zip,适用于EDA技术学习者、数字逻辑课程师生及FPGA入门工程师。设计实现0~9999计数及动态扫描显示,涵盖计数器逻辑、段驱动解码与扫描控制等核心模块,可作为仪表显示、计数设备等场景的参考范例。压缩包共244个文件,大小5.91MB,以vhd源码、bdf原理图、vwf仿真波形为主,同时包含Quartus工程配置(qpf/qsf)、sof下载文件及辅助文档,便于直接打开工程进行仿真与验证。目前已有773人学习下载。通过阅读VHDL代码可掌握模10计数器结构、动态扫描时序及七段译码方法;配合原理图能梳理模块连接关系,借助仿真波形可验证计数与扫描逻辑的正确性。包内附带readme说明文件,适合作为从基础语法到完整数字系统设计的实践案例,帮助读者逐步构建自定义计数器并延伸至更复杂的EDA项目。
1. 从一次课堂翻车说起:为什么要拆解这个计数器工程
两年前某高校的EDA课程设计现场,A同学在开发板上点了一个四位十进制计数器,数码管却跳出了乱码——高位不变化,低位疯狂闪烁,按复位键也没用。折腾半小时后发现,问题不在计数器逻辑,而在时钟分频的占空比和数码管扫描频率互相打架。这个经典的「DTCNT9999.zip」项目,就是当时用于排查问题、最终跑通的完整VHDL工程:一个支持异步复位、BCD码计数、数码管动态扫描显示的四位十进制计数器。
与其说它是个课程设计,不如说它是一份EDA技术学习的好教材。整个工程只用了几百行VHDL,却把时序逻辑设计、分频器设计、扫描显示控制器、Testbench仿真验证这些核心环节全部串了起来。对刚接触VHDL的初学者,它能帮你建立「从代码到硬件」的完整认知;对已经能写基础逻辑的开发者,它又是一份可以直接改造成可逆计数器、并行预置数计数器甚至频率计起点的底层骨架。本文就从一段段可复现代码出发,拆完整个工程怎么跑、怎么改、又会在哪里踩坑。
2. 工程结构拆解:为什么VHDL适合这种「分频+计数+扫描」架构
2.1 选型逻辑:VHDL还是Verilog,区别在哪里
在做计数器这类控制逻辑相对固定、时序要求明确的小型设计时,VHDL的强类型特性反而是个优势:信号赋值必须严格匹配类型,端口声明里端口方向、数据类型、取值范围写得明明白白,编译阶段就能拦截掉不少Verilog里要到仿真才能暴露的连接错误。DTCNT9999的核心思路是典型的三段式结构:外接时钟先经过分频器降频,得到计数时钟和扫描时钟两个基准;计数时钟驱动BCD计数器逐位累加;扫描时钟控制数码管的位选切换,让四个数码管轮流显示当前位的值。
整个工程目录并不复杂,但每个文件都承担明确职责:
DTCNT9999/ ├── src/ │ ├── clock_divider.vhd -- 分频器,产生计数脉冲与扫描脉冲 │ ├── bcd_counter.vhd -- 四个十进制计数器级联 │ ├── scan_display.vhd -- 数码管动态扫描控制器 │ └── top_counter9999.vhd -- 顶层实体,例化以上三个模块 ├── sim/ │ └── tb_counter9999.vhd -- Testbench,含时钟激励与复位测试 ├── constraints/ │ └── counter9999.xdc -- 引脚约束(以Xilinx为例) ├── doc/ │ └── design_notes.md -- 设计笔记,含时序分析 └── scripts/ └── run_sim.sh -- 一键跑ModelSim/Questa的脚本打开top_counter9999.vhd之前,先看清顶层实体定义了哪些端口——这决定了你在开发板上要接哪些引脚:
entity top_counter9999 is port ( clk_50m : in std_logic; -- 开发板自带50MHz晶振 rst_n : in std_logic; -- 异步复位,低电平有效 seg_out : out std_logic_vector(7 downto 0); -- 段选:a~g + dp sel_out : out std_logic_vector(3 downto 0) -- 位选:选通哪一位数码管 ); end entity;顶层只暴露四个端口,这在教学工程里是个很有意思的设计取舍:把分频、计数、扫描全部封装在内部,外部只关心输入时钟、复位和输出显示,初学的人不用一开始就跟底层信号纠缠,二次开发时也可以在内部随意增加预置数、使能、进位输出等端口而不影响顶层连接。
2.2 分频器模块:计数器跑得稳不稳,全看这一个
分频器是整个工程里最容易写错、也最值得反复琢磨的模块。50MHz的板载时钟直接给BCD计数器用显然不现实——每纳秒翻转一次,数码管根本来不及刷新;直接给扫描时钟用同样不行,四个位轮流亮,频率低到肉眼看到闪烁。常见的做法是两级分频:第一级从50MHz分频得到1kHz左右的扫描时钟,第二级从1kHz再分得到1Hz的计数脉冲,让计数器每秒钟加一。
architecture rtl of clock_divider is signal count_scan : integer range 0 to 49999; signal count_cnt : integer range 0 to 999; begin -- 第一级分频:50MHz -> 1kHz process(clk_50m, rst_n) begin if rst_n = '0' then count_scan <= 0; clk_1k <= '0'; elsif rising_edge(clk_50m) then if count_scan = 49999 then count_scan <= 0; clk_1k <= not clk_1k; -- 翻转一次,形成方波 else count_scan <= count_scan + 1; end if; end if; end process; -- 第二级分频:1kHz -> 1Hz process(clk_1k, rst_n) begin if rst_n = '0' then count_cnt <= 0; clk_1hz <= '0'; elsif rising_edge(clk_1k) then if count_cnt = 499 then count_cnt <= 0; clk_1hz <= not clk_1hz; else count_cnt <= count_cnt + 1; end if; end if; end process; end architecture;这段代码里有几个值得注意的地方。计数器用的是integer range 0 to 49999而不是std_logic_vector,原因在于分频器内部计数不需要位宽可读性,整数类型在VHDL里综合效率更高,也更容易写比较条件。注意分频输出clk_1k和clk_1hz是内部信号,不进敏感列表,触发条件只认内部计数是否到达阈值——这是VHDL进程里最容易混淆的一点:分频输出不能拿来做异步触发,否则会出现多个时钟域驱动的组合混乱。
参数怎么改?如果板载时钟是25MHz,把count_scan的上限49999改成24999,clk_1k的翻转阈值减半即可;如果想让计数速度变成每两秒加一,把count_cnt的阈值499改成999,逻辑不用动,只动边界值。
2.3 BCD计数器级联:从0到9再到9999的进位链路
四位十进制计数器最朴素也最容易理解的实现方式,是四个独立的BCD计数器级联:个位每收到一个计数脉冲就加一,到9之后下一个脉冲清零并产生进位;十位收到进位才加一;百位、千位依次类推。这种设计的好处是每一级的逻辑完全一样,代码可复用,改动位宽时只要多例化一次。
entity bcd_counter is port ( clk : in std_logic; rst_n : in std_logic; en : in std_logic; -- 计数使能,级联时接低一级进位 carry : out std_logic; -- 进位输出,高电平有效 bcd_out : out std_logic_vector(3 downto 0) ); end entity; architecture rtl of bcd_counter is signal count : integer range 0 to 9; begin process(clk, rst_n) begin if rst_n = '0' then count <= 0; carry <= '0'; elsif rising_edge(clk) then if en = '1' then if count = 9 then count <= 0; carry <= '1'; else count <= count + 1; carry <= '0'; end if; else carry <= '0'; end if; end if; end process; bcd_out <= std_logic_vector(to_unsigned(count, 4)); end architecture;这段代码的进位信号不是组合逻辑生成,而是在时钟沿同步赋值,这样能避免进位毛刺传导到下一级的en输入。顶层例化时,个位的en直接接clk_1hz,十位的en接个位的carry,百位接十位,千位接百位——四个模块级联后正好覆盖0000到9999的计数范围。这是一个用空间换时序的思路:不做超前进位、不做查找表,也不做状态机,靠最直白的逐级传递来完成任务,逻辑透明,调试时每一级都可以单独拉出来仿真。
需要留意的细节:carry只在count = 9且en = 1的那个时钟沿拉高一个周期。下一级的BCD计数器看到en为高时,在自己时钟的上升沿完成加一——由于两级使用同一个clk_1hz,不会出现竞态。这就是「同步级联」的含义。
3. 扫描显示控制器:把四位BCD码变成肉眼可见的数字
3.1 动态扫描原理与实现
数码管的动态扫描本质上是「视觉暂留」的工程化应用:任意时刻只点亮一位,轮流点亮四个位,刷新频率足够高时,人眼看到的就是四个数字同时点亮。DTCNT9999里把扫描时钟设为1kHz,意味着每一位停留250微秒,四轮一循环正好1毫秒,肉眼完全感受不到闪烁。
architecture rtl of scan_display is signal scan_pos : integer range 0 to 3 := 0; signal hex_data : std_logic_vector(3 downto 0); begin -- 位选扫描:每来一个clk_1k上升沿,切换一次位 process(clk_1k, rst_n) begin if rst_n = '0' then scan_pos <= 0; elsif rising_edge(clk_1k) then scan_pos <= (scan_pos + 1) mod 4; end if; end process; -- 依据当前扫描位选择对应BCD码 with scan_pos select hex_data <= bcd0 when 0, bcd1 when 1, bcd2 when 2, bcd3 when 3; -- 位选输出:低电平选通 with scan_pos select sel_out <= "1110" when 0, "1101" when 1, "1011" when 2, "0111" when 3; -- BCD码转七段码(共阴极数码管) with hex_data select seg_out <= "11000000" when "0000", -- 0 "11111001" when "0001", -- 1 "10100100" when "0010", -- 2 "10110000" when "0011", -- 3 "10011001" when "0100", -- 4 "10010010" when "0101", -- 5 "10000010" when "0110", -- 6 "11111000" when "0111", -- 7 "10000000" when "1000", -- 8 "10010000" when "1001", -- 9 "11111111" when others; -- 其他情况全灭 end architecture;这里有几个工程判断值得展开。位选信号sel_out用的是低电平选通,这是在共阴极数码管接线方式下的标准做法;如果你手里的是共阳极板,段码要按位取反,"11000000"变成"00111111"。这个反转在代码里改还是直接改约束约束文件,取决于你的板子,改段码表更直观。
另一个值得注意的点是scan_pos的到达条件:用(scan_pos + 1) mod 4而不是if scan_pos = 3 then scan_pos <= 0; else scan_pos <= scan_pos + 1;来写。前者在VHDL里会产生一个四选一的多路选择器,后者则是一个带清零的计数器,两者综合出的资源略有差异,但功能完全一样。对四位扫描来说无所谓,如果日后扩到八位、十六位,建议改用计数器写法,综合工具更容易做时序优化。
3.2 顶层例化:把三个模块焊在一起
顶层模块做的事其实很机械——例化分频器、例化四个BCD计数器、例化扫描控制器,然后拉信号线。这步真正考验的是对端口映射的理解,初学者容易在port map时把信号方向搞反,或者漏掉某个中间信号。
architecture structural of top_counter9999 is signal clk_1k : std_logic; signal clk_1hz : std_logic; signal bcd0, bcd1, bcd2, bcd3 : std_logic_vector(3 downto 0); signal carry0, carry1, carry2 : std_logic; begin -- 分频器 u_div : entity work.clock_divider port map ( clk_50m => clk_50m, rst_n => rst_n, clk_1k => clk_1k, clk_1hz => clk_1hz ); -- 个位:直接由1Hz脉冲驱动 u_bcd0 : entity work.bcd_counter port map ( clk => clk_1hz, rst_n => rst_n, en => '1', carry => carry0, bcd_out => bcd0 ); -- 十位:由个位进位驱动 u_bcd1 : entity work.bcd_counter port map ( clk => clk_1hz, rst_n => rst_n, en => carry0, carry => carry1, bcd_out => bcd1 ); -- 百位、千位例化同理,此处省略 -- 扫描器 u_display : entity work.scan_display port map ( clk_1k => clk_1k, rst_n => rst_n, bcd0 => bcd0, bcd1 => bcd1, bcd2 => bcd2, bcd3 => bcd3, seg_out => seg_out, sel_out => sel_out ); end architecture;留意u_bcd0的en接的是常量'1',表示个位永远使能。如果把en错接成一个未驱动的信号,综合时不会被报错,但仿真时个位不动,排查起来相当难受。这里建议所有未使用的输入端口都显式接'1'或'0',不悬空,避免综合工具按默认值处理后行为与仿真不一致。
4. 仿真验证:从Testbench到波形分析,把时序问题纠出来
4.1 写一个完整的Testbench
仿真这一步说难不难,说简单也容易踩坑。DTCNT9999自带的Testbench覆盖了三个关键场景:复位释放后计数器从0开始;持续计数到9999后回到0;中途复位立即清零。这三条验证线能挡住绝大多数初级设计错误。
entity tb_counter9999 is end entity; architecture sim of tb_counter9999 is signal clk_50m : std_logic := '0'; signal rst_n : std_logic := '0'; signal seg_out : std_logic_vector(7 downto 0); signal sel_out : std_logic_vector(3 downto 0); constant PERIOD : time := 20 ns; -- 50MHz begin -- 产生50MHz时钟 clk_50m <= not clk_50m after PERIOD / 2; -- 复位:先低电平200ns,再拉高 rst_n <= '0', '1' after 200 ns; -- 例化被测设计 dut : entity work.top_counter9999 port map ( clk_50m => clk_50m, rst_n => rst_n, seg_out => seg_out, sel_out => sel_out ); -- 自动检查流程 process begin wait until rst_n = '1'; wait for 1 sec; -- 仿真时间1秒,计数到1 -- 此处补充断言,检查bcd0是否为"0001" std.env.stop; end process; end architecture;这个Testbench里最容易忽略的是仿真时间尺度的选择。在ModelSim里跑1秒仿真时间,意味着要模拟50M个时钟沿,加上波形记录的开销,通常要等几分钟。这不是代码写错了,而是仿真器的正常速度。常见做法是先临时把分频器的分频系数缩小(比如49999改成99),仿真验证逻辑正确后再改回真实分频值跑上板测试。这也是DTCNT9999设计文档里明确提到的调试手段:验证功能用缩放分频,验证时序用真实分频。
4.2 看波形时该盯哪几个信号
打开波形界面后,几十个信号扑面而来,新手很容易盯着seg_out和sel_out看了半天什么结论也得不到。正确的排查顺序是先看时钟:clk_1k是否正常翻转、clk_1hz是否按预期周期变化。再看计数器:bcd0是否在上升沿后递增,carry0是否为持续一个周期的窄脉冲。最后才看显示链路:scan_pos是否按0→1→2→3顺序轮换,hex_data是否跟着位选同步切到正确的BCD值。如果显示乱码但计数器波形正常,那问题基本锁定在段码表或者位选编码上。
5. 避坑指南:计数器工程最常见的五个翻车现场
5.1 现象:数码管显示乱跳,数值无规律
原因几乎都是分频器的分频系数算错。比如板子上晶振是50MHz,结果按12MHz去配49999,实际上每秒翻转次数差了好几倍,看到的数字自然不是一秒一跳。解决方法是先用频率计核对clk_1k和clk_1hz的实际频率,或者用仿真测两个信号的确切周期。分频系数别背数字,直接按(晶振频率 / 目标频率) - 1现算。
5.2 现象:复位后第一位不是0,而是某个随机值
有些开发者图省事,把复位写成同步复位(只在时钟沿检查rst_n),如果上电时扰动脉冲刚好没被时钟沿采到,寄存器就保持上电随机状态。DTCNT9999里用的是异步复位,rst_n一旦拉低立即清零,不等待时钟沿。两种复位风格都能用,但要注意风格统一:计数器用了异步复位,分频器里的复位信号也必须同步处理,否则会出现复位释放时时钟尚未稳定的亚稳态风险。
5.3 现象:仿真通过,上板后高位数码管有半秒延迟才亮
这是扫描显示里典型的「分频级联相位漂移」问题——四个位的bcd信号从计数器传到扫描控制器,路径长度不一致,导致某一帧扫描时hex_data还在切换边沿,锁存到了旧值。修改方法是在扫描控制器里对bcd0到bcd3加一拍打拍(先存到中间寄存器再用),牺牲一个时钟周期的延迟换取稳定。实际工程里这种路径延迟在高频下会愈发明显,写代码时就该考虑给输入数据加寄存器隔离。
5.4 现象:sel_out两个位同时为低,数码管亮度异常
位选信号如果出现"1110"和"1101"之间的毛刺,也就是两个位短暂同时导通,那两个数码管会同时半亮。原因是scan_pos在0→1切换时,组合逻辑产生的译码输出叠加了延迟差。修复是在位选输出前也用时钟打一拍,或者直接用寄存器输出而不是组合逻辑输出。DTCNT9999的代码里位选是组合逻辑生成的,对教学演示够用,对上板追求稳定的场景建议改成process(clk_1k)里同步赋值。
5.5 现象:计数到9之后直接归零,十位不动
这个现象十有八九是carry端口在顶层例化时没有连对。检查u_bcd0的carry是否接到了u_bcd1的en,两个端口名字相近容易连错。VHDL不像Verilog那样端口名和信号名可以同名,必须在port map里显式对应。排查这类问题先用一个笨办法:把十位计数器的en直接接到clk_1hz,看它是否每秒加一,能加说明计数器本身没问题,问题缩窄到进位信号的连接上。
6. 二次开发:从四位数到任意功能扩展的改造路径
6.1 改成可逆计数器:加一个方向控制端口
DTCNT9999的计数器核心是BCD级联,要改成加减可逆,只需在每个计数器的en逻辑前增加方向判断。在顶层添加up_down输入,控制个位计数器的行为:up_down = '1'时加一计数不变;up_down = '0'时,当前值为0则预置到9并向上一级请求借位,其他值则减一。级联的进位信号也要多一路borrow,十位的en改为(carry or borrow),同时用up_down决定当前是否执行预置到9。这个改动涉及端口数量加1、每级计数器多两个信号,但整体结构完全不需要推倒重来。
6.2 扩展成99秒倒计时器:复用计数器核,加比较输出
把四位计数器的显示值和一个预设值比较,在计数值等于预设值的时钟周期拉高一个match信号,就能做成定时器。这个比较逻辑可以放在顶层写一个纯组合进程,也可以用when语句直接生成。比较的前提是四个位的bcd_out都稳定有效,因此match信号需要打一拍输出,避免出现毛刺。这样改完之后,原来只用于计数的工程就多了一个「到达指定值」的输出能力,后续接蜂鸣器、继电器都从这里引。
6.3 接更快的时钟:分频器参数的四个注意点
如果从50MHz换成100MHz,count_scan上限要翻倍,原来用integer range 0 to 49999的声明也要同步扩到0 to 99999,否则仿真会报范围溢出,综合工具则可能悄悄截断高位,行为完全错乱。同时分频输出信号clk_1k在硬件上的负载会变重,建议在顶层加一个BUFR或BUFG原语做时钟缓冲。这些原语在不同厂家的FPGA上名字不同,Xilinx叫BUFG,Lattice叫BUF,移植时要用条件编译区分开。
那之后我自己写计数器类项目,都强制走一遍:先确认晶振频率→再算分频系数→仿真时先缩小分频验证逻辑→上板前用示波器量真实分频信号,每一步都不省。这份工程文档我做成了固定模板,每次接手新的FPGA板子,第一件事就是跑一遍DTCNT9999验证开发环境,再在这个基座上去加业务逻辑。希望这个工程和这些排查方法也能帮到你,至少下次数码管乱跳时,你知道第一个该怀疑的是分频器而不是计数器。
本文还有配套的精品资源,点击获取