简介:这是一份基于VHDL的4位十进制动态扫描显示电路完整工程包,面向EDA技术初学者和数字逻辑设计爱好者,解决多位数码管显示占用大量I/O口的问题。工程可实现0~9999自动计数,通过轮流点亮四位七段数码管并借助人眼视觉暂留形成连续稳定显示,适用于仪表盘、计数器及教学演示等场景。压缩包共244个文件,大小仅5.91MB,既有顶层原理图BDF、VHDL源码、仿真波形VWF,也有工程配置QSF、SOF下载文件及readme与txt说明,类型覆盖Quartus II从设计输入、编译仿真到下载验证的完整流程。已有773人学习。研读该工程可掌握模10计数器结构、动态扫描控制时序、七段译码逻辑与波形分析方法,理解原理图与文本混合设计思路,学会模块化层次化设计方法,为后续复杂数字系统开发打下扎实基础。
1. 项目定位:0~9999 的 VHDL 计数器工程,EDA 学习全流程样板
DTCNT9999 是一个用 VHDL 写的 4 位十进制计数器电路工程,计数范围从 0000 到 9999,数满自动回零。名字里的 DTCNT 就是 Decimal Counter 的缩写,9999 直接标出了设计上限,属于那种看名字就知道功能、但真要完整跑起来能把 EDA 技术学习全流程走一遍的样例。对常年在仿真和综合工具之间来回折腾的我来说,这工程最有价值的点在于它把 BCD 编码、多级进位链、时钟分频、七段译码、Testbench 验证这几块基础功组合成了一个可下载、可仿、可上板的完整工程。适合三类人:刚学 VHDL 想找一个全流程能跑通的参考,做课程设计要直接抄一个能上板的计数模块,以及想知道计数电路怎么写才不容易在仿真里翻车的新手。这篇按我拆工程的实际顺序,从文件结构讲到波形验证,最后落到怎么改成自己的计数器。
2. 工程结构:DTCNT9999 的目录、顶层实体与模块划分
先看整体再动代码。我拆每个 EDA 工程的第一步永远是列目录,搞清楚哪个文件负责什么、综合工具按什么顺序编译,而不是直接打开 VHDL 源码。这一步看着琐碎,但能省掉后面大量"为什么编译报错"的排查时间。
2.1 目录结构与文件职责
解压 DTCNT9999.zip 之后,里面按 src、tb、sim、synth、doc 五个目录组织,典型可仿真可综合的布局。
| 路径 | 文件 | 职责 |
|---|---|---|
| src/dtcnt9999_top.vhd | 顶层实体 | 例化各子模块,定义对外引脚 |
| src/counter_4digit.vhd | 计数核心 | 4 位 BCD 计数,0000-9999 |
| src/clk_div.vhd | 分频模块 | 50 MHz 降为 1 Hz 节拍,参数可配 |
| src/seg7_decoder.vhd | 译码模块 | BCD 码转七段码,低有效 |
| src/debounce.vhd | 消抖模块 | 消除按键机械抖动 |
| tb/dtcnt9999_tb.vhd | 测试平台 | 激励生成与计数值自检 |
| sim/sim.do | 仿真脚本 | 一键编译、仿真、加波形 |
| synth/dtcnt9999.qsf | 综合约束 | 器件型号与引脚分配 |
这个目录结构遵循一条 EDA 工程的老规矩:RTL 代码只放 src,仿真文件单独放 tb,脚本和约束各归各。这样做的直接好处是综合时指 src 加约束目录就能出网表,仿真时指 src 加 tb 目录就能跑测试,互相不污染。我见过不少工程把 Testbench 和 RTL 混放在一个文件夹里,结果综合工具把仿真文件也吃进去,报出一堆莫名其妙的 multiple drivers 错误。
2.2 顶层实体:接口怎么定义
顶层 dtcnt9999_top 的端口定义决定了这块计数器对外长什么样:
library ieee; use ieee.std_logic_1164.all; entity dtcnt9999_top is port ( clk_50m : in std_logic; -- 板载 50 MHz 时钟 rst_n : in std_logic; -- 异步复位,低有效 key_en : in std_logic; -- 计数使能,按键输入 seg : out std_logic_vector(6 downto 0); -- 七段码输出 an : out std_logic_vector(3 downto 0) -- 数码管位选 ); end entity;rst_n 用低有效而不是高有效,这是开发板的常见约定,大部分板子的复位按键按下时拉低,直接接进 FPGA 不用额外取反。seg 和 an 声明为低有效,对应共阳数码管公共端接高电平、段选低电平点亮的接法,这是最常见的一种板载硬件设计。如果换到共阴管,只需要把译码输出全部取反,顶层接口不用动。
顶层体内例化了 clk_div、counter_4digit、seg7_decoder 三个模块,关键连接关系是分频节拍驱动计数时钟:
architecture rtl of dtcnt9999_top is signal tick : std_logic; signal bcd_q : std_logic_vector(15 downto 0); begin u_div: entity work.clk_div generic map (DIV => 50_000_000) port map (clk_in => clk_50m, rst_n => rst_n, clk_out => tick); u_cnt: entity work.counter_4digit port map (clk => tick, rst_n => rst_n, en => key_en, q => bcd_q); -- 译码与动态扫描逻辑略 end rtl;这里有一个新手容易看懵的点:分频出来的不是占空比 50% 的方波,而是一个时钟周期宽的脉冲,叫 tick(节拍)。对计数这种每个周期加一的逻辑,tick 和方波效果等价,但综合后资源更省,因为不需要额外翻转寄存器去维持 50% 占空比。
2.3 设计思路:为什么用 BCD 而不是二进制
计数到 9999 用二进制只要 14 位,这个工程却用了 16 位,每 4 位对应一个十进制位。直接原因是为了让进位和显示天然解耦。如果用一个 14 位二进制计数器,到 9999 要展示时得先把二进制转成四位十进制,综合工具就会生成除法器和取模逻辑,一路除以 10、取余 10,组合逻辑深度和资源占用都很难看。
BCD 的做法是每一路 4 位计数到 9 就向高一位进 1,自己回 0。这本质上是把进位链从 14 位变成了四组 4 位,时序更清晰,调试时看波形也直观——个位、十位、百位、千位分别对应一组信号,一眼能定位问题。我一般建议课程设计优先选 BCD 方案,理由很简单:你写的是给老师看、给自己查的代码,容易读比少两个触发器重要得多。
3. 核心代码拆解:BCD 计数、分频与七段译码的实现
整个工程真正的逻辑就三个模块,加起来不到 150 行 VHDL,但每一块都值得逐行读。这一章按数据流顺序拆:先看计数核心,再看喂给它时钟的分频器,最后看输出端的译码,末了补上验证用的 Testbench。
3.1 counter_4digit:进位链这样写
计数核心用 4 个 unsigned 信号表示 4 个十进制位,嵌套 if 实现逐级进位:
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity counter_4digit is port ( clk : in std_logic; rst_n : in std_logic; en : in std_logic; q : out std_logic_vector(15 downto 0) ); end entity; architecture rtl of counter_4digit is signal bcd0, bcd1, bcd2, bcd3 : unsigned(3 downto 0); begin process (clk, rst_n) begin if rst_n = '0' then bcd0 <= "0000"; bcd1 <= "0000"; bcd2 <= "0000"; bcd3 <= "0000"; elsif rising_edge(clk) then if en = '1' then if bcd0 = 9 then bcd0 <= "0000"; if bcd1 = 9 then bcd1 <= "0000"; if bcd2 = 9 then bcd2 <= "0000"; if bcd3 = 9 then bcd3 <= "0000"; else bcd3 <= bcd3 + 1; end if; else bcd2 <= bcd2 + 1; end if; else bcd1 <= bcd1 + 1; end if; else bcd0 <= bcd0 + 1; end if; end if; end if; end process; q <= std_logic_vector(bcd3 & bcd2 & bcd1 & bcd0); end rtl;这个进位链的逻辑是:只有当前位等于 9 才触发进位,否则低位只管自己加 1。最内层 bcd3 也到 9 时四路全部清零,完成 9999 回 0。注意条件写的是bcd0 = 9而不是bcd0 = "1001",可读性好很多,仿真波形里查 9 也直观。
参数说明:en 为 1 时每个 clk 上升沿加一,为 0 时保持;复位是异步的,rst_n 拉低立即清零,不用等时钟沿;q 的拼接顺序是 bcd3 在高字节,读十六进制时从左往右正好是千位、百位、十位、个位。
一个常见疑问:为什么不用 integer 类型。integer 仿真很舒服,但 q 最终要喂给译码模块,用 integer 就得做类型转换,而且工具对 integer 位宽的处理有差异,综合结果不好控。用 unsigned 加 numeric_std 是更稳的做法,这也是我写计数电路的基本习惯。
3.2 clk_div:节拍式分频,不是方波分频
entity clk_div is generic ( DIV : integer := 50_000_000 ); port ( clk_in : in std_logic; rst_n : in std_logic; clk_out : out std_logic ); end entity; architecture rtl of clk_div is signal cnt : integer range 0 to DIV - 1 := 0; begin process (clk_in, rst_n) begin if rst_n = '0' then cnt <= 0; clk_out <= '0'; elsif rising_edge(clk_in) then if cnt = DIV - 1 then cnt <= 0; clk_out <= '1'; else cnt <= cnt + 1; clk_out <= '0'; end if; end if; end process; end rtl;DIV 做成 generic,默认 50_000_000,即 50 MHz 时钟分频出 1 Hz 节拍。改分频系数只改一行例化参数:要 10 Hz 就写generic map (DIV => 5_000_000),模块内部完全不用动。
这里的 clk_out 只在计数满的那一个时钟周期拉高,其余时间都是低。如果你接到示波器上看会觉得它不像时钟,但作为计数器驱动完全够用。我早期写分频习惯输出 50% 占空比方波,后来发现 tick 方式更省事:方波分频需要寄存器翻转维持高低电平,tick 只在溢出时置位,综合后面积更小,而且计数器使能逻辑天然吃这种单周期脉冲。
3.3 seg7_decoder:共阳数码管译码
entity seg7_decoder is port ( bcd : in std_logic_vector(3 downto 0); seg : out std_logic_vector(6 downto 0) ); end entity; architecture rtl of seg7_decoder is begin with bcd select seg <= "1000000" when "0000", "1111001" when "0001", "0100100" when "0010", "0110000" when "0011", "0011001" when "0100", "0010010" when "0101", "0000010" when "0110", "1111000" when "0111", "0000000" when "1000", "0010000" when "1001", "1111111" when others; end rtl;这是典型的 with-select 查表译码,纯组合逻辑,没有时钟。输出按 gfedcba 顺序排列,"1000000"表示 a 段(最低位)为 0 点亮,显示数字 0,符合低有效约定。0 到 9 共十条,对应 BCD 全部有效状态;others 分支填全 1,也就是全部熄灭,避免输入非法值时显示乱码。
参数说明:bcd 是 4 位输入,来自计数器 q 的某四分之一段;seg 是 7 位输出,每一位对应数码管的一个段。顶层用 1 kHz 左右的动态扫描轮流点亮四位数,位选 an 轮流拉低,视觉上四位数就是同时显示的,这部分在顶层做,不用改译码器。
3.4 Testbench:不给波形就白写
工程带了 tb/dtcnt9999_tb.vhd,验证思路是复位后给 100 个时钟周期,打印并自检计数值:
entity dtcnt9999_tb is end entity; architecture sim of dtcnt9999_tb is signal clk : std_logic := '0'; signal rst_n : std_logic := '0'; signal en : std_logic := '1'; signal q : std_logic_vector(15 downto 0); begin clk <= not clk after 10 ns; uut: entity work.counter_4digit port map (clk => clk, rst_n => rst_n, en => en, q => q); process begin rst_n <= '0'; wait for 30 ns; rst_n <= '1'; wait for 1000 ns; assert q = x"0064" report "期望 100,实际不符" severity error; wait; end process; end sim;这个 Testbench 带一个自动断言:复位释放后等 1000 ns,也就是 100 个时钟周期,q 必须等于 0x0064(十进制 100),不符就红字报错。clk 周期 10 ns 是仿真用的快速时钟,验证的是计数器本身逻辑,不接分频器的原因很简单:如果仿真里也跑 5000 万个时钟周期才加一,一次仿真跑到天荒地老。
这是整个工程设计上最值得学的一点:把分频和计数拆开,仿真只测计数核心,上板才接分频。很多工程仿真慢,就是因为 Testbench 里例化了完整的分频链,白白跑了几百万个周期在等一个 tick。
4. 仿真运行:把 0000 跑到 9999 的完整流程
代码读完之后就是动手跑。这一章按实际操作顺序,从仿真脚本讲到波形判读,最后落到上板验证,每一步都能照着执行。
4.1 一次性仿真脚本
工程里的 sim/sim.do 就是干这个的,标准仿真工具脚本写法:
vlib work vmap work work vcom -93 ../src/counter_4digit.vhd vcom -93 ../src/seg7_decoder.vhd vcom -93 ../tb/dtcnt9999_tb.vhd vsim -novopt work.dtcnt9999_tb add wave -hex /dtcnt9999_tb/* run 2 us启动仿真工具后在命令行敲do sim.do即可。脚本依次做的事情是:创建 work 库并映射、用 vcom 按依赖顺序编译三个 VHDL 文件、启动仿真、把所有信号以十六进制加到波形窗口、跑 2 微秒。注意这里故意没编译 clk_div,因为 tb 没例化它,少编译一个就少一个出错点。
为什么要用脚本而不是图形界面点按钮:第一,编译顺序错了(比如先编译顶层再编译子模块),图形界面报错绕来绕去,脚本里顺序固定,错了也知道改哪行;第二,do 文件本身就是工程交付的一部分,换台电脑、换个同学的环境,同一份脚本能复现同样的仿真结果,这在课程设计和团队协作里特别重要。
4.2 波形看什么
仿真启动后重点看 clk、rst_n、en、q 四个信号。刚复位时 q 应为 0000,rst_n 拉高后每个 clk 上升沿 q 加一。由于 tb 里时钟是 10 ns 周期,2 us 仿真时长相当于 197 个时钟周期,q 应该停在 0x00C5 附近。
实际观察时我习惯做两件事。第一,把 q 的显示格式改成十六进制,一眼能看出哪几位对应哪个十进制位;第二,人为把 en 拉低一段时间再拉高,确认计数暂停、恢复继续,这验证的是使能逻辑而不是靠运气跑出了正确波形。
| 时刻 | 操作 | q 期望值 |
|---|---|---|
| 0~30 ns | 复位拉低 | 0000 |
| 30 ns | 复位释放 | 开始递增 |
| 1030 ns | 计数 100 个周期 | 0064 |
| 2030 ns | 计数 200 个周期 | 00C8 |
那个 0064 是十进制 100 的十六进制表示。如果看到别的值,先查 en 是不是一直拉高,再看复位释放时间对不对。波形对了,计数器核心就算验证过。
4.3 把分频接回去,上板验证
仿真只是第一步,落地到开发板需要综合和引脚约束。打开综合工具新建工程,器件型号按你手上的板子选,把 src 目录下五个 VHDL 文件全部加入,然后做三件事:引脚分配,clk_50m 接板载时钟引脚、rst_n 接复位按键、seg 和 an 接数码管;综合;布局布线。约束文件里主要就是这几行 pin 位置的映射,按板子原理图填就行。
上板后的预期效果是:四位数码管从 0000 开始,每 1 秒加一,到 9999 后跳回 0000 继续。按下 key_en 对应的按键,计数暂停,松开继续。如果板子数码管是共阴而非共阳,把 seg7_decoder 的输出全部取反即可,这是硬件差异导致的唯一改动点,译码表本身不用重写。
综合时如果报时序违例,计数器这种极简逻辑一般不会是路径问题,多数是约束文件里时钟没定义。加一行create_clock -period 20.000 -name clk_50m [get_ports clk_50m]就解决。真正要防的是把 tick 信号当方波时钟去驱动别的时序逻辑,那是下一章的坑。
5. 避坑排查:仿真空白、计数跳变和综合报错的五个根因
跑 EDA 工程仿真翻车是常态,很多问题看着像玄学,其实都能归结到几个固定原因。这一章把我拆这个工程时踩过的和看别人踩过的坑集中写一下,每条按现象、原因、解决三步走。
5.1 最常见的四个坑
坑一:仿真波形一片空白,所有信号都是 U。
现象:运行 sim.do 之后波形窗口里 clk 正常,但 rst_n、q 全是 U。原因:Testbench 里例化用了entity work.counter_4digit,但编译时没把 counter_4digit.vhd 编进 work 库,或者编译顺序反了。仿真工具找不到实体定义时不一定直接报错,而是把端口信号初始化为 U。解决:回查 sim.do 的 vcom 编译顺序,确保子模块在顶层之前编译,或者按先 src 后 tb 的顺序全量编译。
坑二:计数跳变,个位从 8 直接变到 0,十位没进位。
现象:q 的变化不是 0008 → 0009 → 0010,而是 0008 → 0000。原因:大概率是进位条件写错,比如 bcd0 声明成了 std_logic_vector,加法bcd0 <= bcd0 + 1对 std_logic_vector 不是合法算术,综合工具会悄悄把它转成别的逻辑。解决:计数位一律用 unsigned,加法进位交给 numeric_std 处理,不要在计数模块里用 std_logic_vector 做算术。
坑三:综合时报 multiple constant drivers。
现象:RTL 仿真正常,一综合就报错,指向某个信号。原因:同一个 signal 在多个 process 里被赋值,违反了一个信号只有一个驱动源的规则。常见于把复位逻辑和计数逻辑拆成两个 process,两边都写了q <= "0000"。解决:所有对同一个寄存器的赋值收敛到一个 process 里,复位放 if 分支,正常逻辑放 elsif。前面 counter_4digit 的写法就是标准示范。
坑四:上板后计数慢一倍,2 秒才加一。
现象:实际 2 秒加一,不是 1 秒。原因:clk_div 的 DIV 写成了 100_000_000,或者例化时 generic map 参数传错,分频系数翻倍。tick 方式本身不会带来偏差,它每个 DIV 周期输出一个脉冲,计数时间由 DIV 唯一决定。解决:回查例化语句,确认 50 MHz 除以 DIV 等于目标频率,1 Hz 对应 DIV 为 50_000_000。
5.2 仿真与综合结果不一致,按这个顺序排查
还有一类更隐蔽的问题:仿真跑通、上板翻车。我自己的排查顺序固定是四步。第一步,约束文件里有没有写时钟约束,综合工具默认按宽松时序布线,真实板子可能因为布线过长跑不到目标频率。第二步,按键输入有没有过消抖,机械按键抖动一次按下可能被计成十几次,工程里的 debounce.vhd 就是干这个的,但默认在顶层是旁路的。第三步,rst_n 在板上是不是真的被拉高,悬空引脚虽然默认弱上拉,但不可依赖。第四步,数码管位选扫描频率够不够,低于 50 Hz 人眼会看到闪烁。
这套顺序基本屡试不爽。仿真工具只看逻辑,综合工具看时序,板子看电气,三者差异只能一层层剥。工程自带的 debounce.vhd 是改造成熟功能的第一步,如果你上板计数乱跳,先把它接回去再查别的,这比我当年直接怀疑时钟快慢要高效得多。那次我跳过消抖查了好几个小时分频,最后发现就是按键抖动,从此养成习惯:上板问题永远从最外层硬件开始排查。
6. 二次开发:把 9999 改成六位双向计数器
看完整个工程,最值得动手的改造是把它从"只能加"变成"能加能减、位数可变"。这两个需求几乎覆盖了课程设计里的常见变体,也是检验你有没有真读懂进位链的直接方式。
6.1 改宽度:从四位到六位
counter_4digit 的核心是 4 个 unsigned 信号加嵌套进位链。改六位,信号变 6 个,进位链多套两层,q 变 24 位。但更干净的做法是加一个 generic,让位数变成例化参数:
entity counter_n is generic ( DIGITS : integer := 6 ); port ( clk : in std_logic; rst_n : in std_logic; en : in std_logic; up_n : in std_logic; q : out std_logic_vector(DIGITS * 4 - 1 downto 0) ); end entity;改动点有两处:内部用数组信号替代单个 bcd0 到 bcd3,进位判断从手写嵌套 if 改成 for 循环生成。手写 6 层嵌套 if 能跑,但后续加功能时改起来想哭。用 generate 或数组批量生成,DIGITS 改到 8 也不用动进位逻辑。
6.2 加方向控制:双向计数
双向计数比想象中简单。在计数分支里加一个 up_n 判断,为高走原来的进位链,为低做反向借位。借位的本质是当前位为 0 时向高位借一,自己回 9;在 0000 且 up_n 为低时保持 0000 不动。改完直接复用原来的 Testbench,把 up_n 拉低跑一段,q 应该呈递减趋势,到 0000 后停住。
这类改造我的习惯是:先改代码,再改 Testbench 加断言,最后才综合。顺序反过来的话,你很难分清香波里的错误是新功能引入的还是原有逻辑被改坏了。那次我把消抖模块接回去后忘记更新 Testbench,上板功能正常但仿真全红,排查快一小时才发现是 tb 激励顺序没跟上代码改动。从那以后我每次改完代码都强制先跑一遍工程自带的 Testbench,确认回归通过再碰综合和上板。这个习惯帮我挡掉了至少三次因为进位链改错导致的整板返工,希望帮到你。
本文还有配套的精品资源,点击获取