基于Quartus与ModelSim的VHDL数码管动态扫描仿真实战
1. 从零开始:搭建你的第一个VHDL动态扫描工程
大家好,我是老张,在数字电路和FPGA这块摸爬滚打了十几年。今天想和大家聊聊一个非常经典、但又让很多新手朋友头疼的实战项目:用VHDL实现数码管的动态扫描显示,并且用Quartus和ModelSim把它完整地仿真出来。我知道,光是看到“动态扫描”、“仿真”这些词,可能有些朋友就开始发怵了,觉得是不是特别复杂?别担心,我今天就用最“大白话”的方式,带你走一遍整个流程,把我自己踩过的坑、总结的技巧都分享给你,保证你跟着做一遍就能上手。
咱们先搞清楚这个项目到底要干嘛。想象一下,你手头有一个FPGA开发板,上面有8个数码管。如果你想让8个数码管同时显示不同的数字,比如显示你的学号“20240001”,最“笨”的办法是给每个数码管单独配一套驱动电路,但这需要占用大量的硬件引脚,非常不经济。而动态扫描就是一种“聪明”的省资源办法。它的原理很像我们人眼的视觉暂留现象:在极短的时间内,依次快速点亮每一个数码管,只要这个轮转的速度足够快(比如每秒几十次),由于人眼的视觉残留,我们看起来就像是8个数码管在同时稳定地显示。这个项目,就是用VHDL语言,在Quartus里编写出实现这个“快速轮转点亮”逻辑的电路,然后用ModelSim这个仿真工具,像看“慢动作回放”一样,验证我们的电路设计到底对不对,波形是不是按照我们预想的那样在变化。
所以,这个实战的核心就是两件事:一是用VHDL把动态扫描的逻辑写对,二是用仿真工具确保它真的对。很多朋友代码写完了,下载到板子上发现显示乱码或者闪烁,根本原因往往就是仿真没做到位,脑子里想的和电路实际干的不一致。接下来,我就手把手带你过一遍,从打开Quartus到在ModelSim里看到完美的波形图。
2. 工程创建与VHDL代码编写实战
2.1 项目初始化与文件保存的“坑”
首先,你得确保Quartus Prime(我用的是18.1版本,但13.0、17.1等版本流程大同小异)和ModelSim-Altera(现在叫Intel FPGA Starter Edition)已经安装好。这里有个小建议,安装路径尽量用英文,不要有空格和中文,这是为了避免后续一些莫名其妙的工具链链接错误。
打开Quartus,第一步不是急着写代码,而是先创建工程。点击 File -> New Project Wizard。这里会有一系列向导页面,我挑重点说。第一个“坑”就在目录选择上。我强烈建议你为这个项目单独创建一个全新的文件夹,比如我放在 D:\FPGA_Project\dynamic_scan。在“Project name”里,给你的工程起个名,比如 scan_demo。工程名和顶层实体名强烈建议保持一致,比如都叫 scan_demo,这样能避免很多混淆。在“Top-level design entity”那一栏就填 scan_demo。
一路“Next”,直到选择器件型号。如果你是针对具体的开发板(比如DE2-115),就选择对应的Cyclone IV EP4CE115F29C7。如果只是做仿真学习,可以选一个比较通用的、资源足够的器件,比如Cyclone 10 LP系列的某个型号,或者直接选“Auto device selected by the Filter”。这一步对纯仿真影响不大,但好的习惯要从开始养成。
工程创建好后,我们再新建VHDL文件。File -> New -> VHDL File。现在,我们来构思代码。一个典型的动态扫描模块需要几个部分:一个时钟输入 clk,一个位选输出 sel(决定当前点亮哪个数码管),一个段选输出 seg(决定当前点亮的数码管显示什么字形)。核心是一个计数器,用时钟 clk 来驱动,计数器的值循环变化(比如0到7),这个值一方面通过译码产生 sel 信号,另一方面作为查找表的索引,输出对应的 seg 值。
我写一个显示固定数字序列“12345678”的示例代码,里面包含了一些关键技巧:
library ieee;
use ieee.std_logic_1164.all;
use ieee.std_logic_unsigned.all; -- 使用无符号数运算
entity scan_demo is
port (
clk : in std_logic; -- 系统时钟,比如50MHz
sel : out std_logic_vector(2 downto 0); -- 3位位选,可控制2^3=8个数码管
seg : out std_logic_vector(6 downto 0) -- 7段输出,从a到g
);
end entity scan_demo;
architecture behavior of scan_demo is
-- 内部信号声明
signal counter : std_logic_vector(19 downto 0) := (others => '0'); -- 分频计数器
signal scan_clk : std_logic; -- 扫描时钟(分频后)
signal scan_cnt : std_logic_vector(2 downto 0) := "000"; -- 扫描计数器(0-7)
-- 数字0-9的七段码(共阳极数码管,0点亮,1熄灭)。这里以共阳极为例。
type seg_rom is array (0 to 9) of std_logic_vector(6 downto 0);
constant seg_data : seg_rom := (
"0000001", -- 0
"1001111", -- 1
"0010010", -- 2
"0000110", -- 3
"1001100", -- 4
"0100100", -- 5
"0100000", -- 6
"0001111", -- 7
"0000000", -- 8
"0000100" -- 9
);
-- 我们要显示的数字序列:1, 2, 3, 4, 5, 6, 7, 8
type disp_rom is array (0 to 7) of integer range 0 to 9;
constant disp_data : disp_rom := (1, 2, 3, 4, 5, 6, 7, 8);
begin
-- 进程1:时钟分频,产生扫描时钟(约200Hz)
-- 50MHz / 2^20 ≈ 47.7Hz,实际可根据需要调整计数器位宽
process(clk)
begin
if rising_edge(clk) then
counter <= counter + 1;
end if;
end process;
scan_clk <= counter(19); -- 取计数器最高位作为扫描时钟
-- 进程2:扫描计数器,在scan_clk上升沿时递增
process(scan_clk)
begin
if rising_edge(scan_clk) then
scan_cnt <= scan_cnt + 1; -- 会自动从7滚回0
end if;
end process;
-- 进程3:位选信号译码。这里采用低电平有效选中,根据scan_cnt输出对应位为0。
process(scan_cnt)
begin
case scan_cnt is
when "000" => sel <= "11111110"; -- 选中第0个数码管
when "001" => sel <= "11111101"; -- 选中第1个数码管
when "010" => sel <= "11111011";
when "011" => sel <= "11110111";
when "100" => sel <= "11101111";
when "101" => sel <= "11011111";
when "110" => sel <= "10111111";
when others => sel <= "01111111"; -- 选中第7个数码管
end case;
end process;
-- 进程4:段选信号输出。根据scan_cnt索引,从disp_data中取出要显示的数字,
-- 再用这个数字作为索引,从seg_data中取出对应的七段码。
process(scan_cnt)
variable num_index : integer range 0 to 9;
begin
num_index := disp_data(conv_integer(scan_cnt)); -- 将矢量转换为整数索引
seg <= seg_data(num_index);
end process;
end architecture behavior;
代码写完后,立刻按 Ctrl+S 保存。这里又是一个关键点:保存的文件名必须与实体名(entity name)一致,即 scan_demo.vhd。Quartus会弹出一个对话框,问你是否要把这个文件添加到当前工程,一定要选“是”。这样,你的代码文件就和工程绑定在一起了。
2.2 代码要点解析与常见错误
上面这段代码看起来比简单的例子长,但每一部分都有它的作用,我来拆解一下。首先,我引入了两个查找表(ROM):seg_data 和 disp_data。seg_data 存储了0到9数字对应的七段码编码,这是一个通用映射,以后改显示内容不用动这里。disp_data 存储了我们这次想要在8个数码管上依次显示的具体数字序列,这里是1到8。如果你想显示学号,比如“20240001”,只需要修改这个常量数组为 (2,0,2,4,0,0,0,1) 即可,非常灵活。这种数据与逻辑分离的写法,是提高代码可维护性的好习惯。
其次,我做了时钟分频。开发板上的时钟通常是50MHz,直接用它来驱动扫描计数器的话,速度太快(纳秒级切换),人眼根本无法识别,仿真波形也会过于密集看不清楚。所以我用了一个20位的计数器对主时钟分频,取它的最高位 counter(19) 作为扫描时钟 scan_clk,频率大约50MHz/2^20 ≈ 47.7Hz。这意味着每个数码管每秒被点亮约47.7/8≈6次,虽然实际产品中这个频率会更高(比如200Hz以上)以避免闪烁,但为了仿真波形观察方便,我们先用一个低频。
很多新手容易犯的错误包括:位选信号逻辑搞反(共阳极和共阴极的驱动电平是相反的),段码表查错(自己手写的七段码可能有笔误),以及扫描计数器位宽不够。比如你有8个数码管,扫描计数器至少需要3位(2^3=8)。如果只用了2位,那只能循环4个状态,会导致一部分数码管永远不被选中。在保存和编译前,务必仔细检查这些地方。
代码编写完成后,点击 Processing -> Start Compilation 进行全编译。如果代码有语法错误,Quartus会在下方的“Messages”窗口用红色文字提示。常见的错误有缺少分号、信号名拼写错误、端口映射不匹配等。根据提示逐行修改,直到编译成功,出现“Full Compilation was successful”的提示。
3. 配置ModelSim仿真环境与生成测试平台
3.1 关联ModelSim与生成仿真网表
代码编译通过,只说明语法没问题,不代表逻辑功能正确。接下来就要请出我们的“电路显微镜”——ModelSim了。但在使用之前,我们需要在Quartus里做好设置,告诉它:“嘿,我打算用ModelSim来仿真,这是它的位置,并且请为我准备好仿真需要的材料。”
点击 Assignments -> Settings,打开设置窗口。在左侧分类中找到 EDA Tool Settings -> Simulation。
- Tool name:这里选择你安装的ModelSim版本。如果你安装的是Quartus自带的“ModelSim-Altera”,就选这个。如果安装的是独立的ModelSim,就选“ModelSim”。我一般用“ModelSim-Altera”,兼容性更好。
- Format for output netlist:输出网表的格式。因为我们用的是VHDL,所以这里选择 “VHDL”。这个非常重要,它决定了Quartus为你生成哪种语言的仿真模型。
- Output directory:输出目录。这里默认是
simulation/modelsim/,相对于你的工程目录。我通常就保持默认,这样所有仿真相关文件都会整齐地放在这个文件夹里。 - NativeLink settings:这是关键一步!在“Compile test bench”下面,点击
Test Benches...按钮。在弹出的新窗口中,再点击New...。
现在来到了配置测试平台(Testbench)的核心界面。Testbench你可以理解为一个“虚拟的测试台”,它负责产生输入信号(比如时钟 clk)给我们的设计模块(即 scan_demo),然后接收并观察输出信号(sel, seg)。
- Test bench name:给这个测试配置起个名字,比如
tb_scan。这个名字只是Quartus内部管理用的,可以随意。 - Top level module in test bench:这里必须填写我们Testbench顶层实体的名字。我们还没写Testbench呢,所以先计划好,我打算叫它
scan_demo_tb,那这里就填scan_demo_tb。 - File name:点击
...,我们暂时不添加任何文件。因为更高效的做法是让Quartus先帮我们生成一个模板。
所以,在这个界面,我们只填好“Test bench name”和“Top level module in test bench”,然后点击 OK,再点击 Add,就能看到 tb_scan 被添加到了列表中。最后点击 OK 关闭所有设置窗口。
接下来,让Quartus为我们生成Testbench模板。点击 Processing -> Start -> Start Test Bench Template Writer。运行成功后,Quartus不会有太明显的弹窗提示,但你可以去刚才设置的输出目录 simulation/modelsim/ 下查看,会发现多出了一个文件,名字是 scan_demo.vho(VHDL Output)。这个 .vho 文件就是我们的设计被综合后生成的“门级网表”文件,是给ModelSim仿真用的底层模型。同时,Quartus还会生成一个Testbench的模板文件,但我们需要自己找到并完善它。更常见的做法是,我们直接自己编写一个更直观的Testbench。
3.2 编写自己的VHDL Testbench
在Quartus中,File -> New -> VHDL File,新建一个文件。我们将在这个文件里编写Testbench。Testbench本身也是一个VHDL实体,但它不需要端口(port),因为它就是仿真的最顶层。它的内部会实例化我们的设计模块,并驱动时钟。
library ieee;
use ieee.std_logic_1164.all;
entity scan_demo_tb is
-- Testbench没有端口
end entity scan_demo_tb;
architecture behavior of scan_demo_tb is
-- 声明要连接到被测试设计(DUT)的信号
component scan_demo is
port (
clk : in std_logic;
sel : out std_logic_vector(2 downto 0);
seg : out std_logic_vector(6 downto 0)
);
end component;
-- 内部信号定义
signal clk_tb : std_logic := '0'; -- 初始化时钟为0
signal sel_tb : std_logic_vector(2 downto 0);
signal seg_tb : std_logic_vector(6 downto 0);
-- 定义时钟周期常数,这里假设为20ns(对应50MHz)
constant clk_period : time := 20 ns;
begin
-- 实例化被测试设计单元 (DUT: Design Under Test)
uut: scan_demo
port map (
clk => clk_tb,
sel => sel_tb,
seg => seg_tb
);
-- 时钟生成进程:产生周期性的clk信号
clk_process: process
begin
clk_tb <= '0';
wait for clk_period/2;
clk_tb <= '1';
wait for clk_period/2;
end process;
-- 激励进程(这里主要是时钟,其他输入如果有时也可以在这里定义)
-- 我们这个例子中,设计只有时钟输入,所以激励进程很简单。
stim_proc: process
begin
-- 可以在这里放置一些初始化的激励,如果有的话
wait; -- 永远等待,让时钟进程一直运行
end process;
end architecture behavior;
保存这个文件为 scan_demo_tb.vhd。注意,这个文件名不需要和实体名强制一致,但最好保持一致以便管理。保存后,同样将其添加到工程中(在Project Navigator里右键点击项目名,选择 Add/Remove Files in Project...)。
现在,我们需要回到 Assignments -> Settings -> Simulation -> Test Benches...,编辑之前添加的 tb_scan。这次,在“File name”那里,通过 ... 按钮把我们刚写好的 scan_demo_tb.vhd 添加进来。同时确保“Top level module in test bench”仍然是 scan_demo_tb。点击 OK 应用。
4. 运行ModelSim仿真与波形深度分析
4.1 启动仿真与添加信号到波形窗口
激动人心的时刻到了!点击 Tools -> Run Simulation Tool -> RTL Simulation。Quartus会自动启动ModelSim,并执行一系列编译和加载操作。第一次运行可能会稍慢,因为它需要编译我们工程的所有库和文件。
ModelSim启动后,你通常会看到几个窗口:“Transcript”(命令台)、“Library”(库)、“Sim”(仿真对象)和“Wave”(波形,可能默认未打开)。如果“Wave”窗口没自动弹出,可以在菜单栏点击 View -> Wave 打开它。
现在,我们需要把关心的信号加到波形窗口里观察。在“Sim”标签页下,通常有一个名为 work 的库,点开它,找到你的测试平台实体 scan_demo_tb。右键点击它,选择 Add Wave。这样,ModelSim会把 scan_demo_tb 这个顶层下的所有信号(包括它内部实例化的 uut 的所有端口信号)都添加到波形窗口。
但是,这样添加的信号层次结构是扁平的,不利于观察。我更推荐另一种方式:在“Sim”标签页,点开 scan_demo_tb,你会看到它下面有一个实例 uut,这就是我们的设计实体。右键点击这个 uut,然后选择 Add to -> Wave -> All items in region。这样,波形窗口里就会以 uut 为分组,清晰地显示出 clk, sel, seg 这些我们最关心的设计内部信号。
4.2 配置时钟与运行仿真
添加好信号后,波形窗口里可能还没有任何波形,因为仿真还没开始运行。我们需要先配置一下时钟信号,让它以我们定义的周期运行。在波形窗口的信号列表中,找到 uut/clk。右键点击它,选择 Clock...。在弹出的对话框中,可以设置时钟周期。因为我们之前在Testbench里定义了 clk_period = 20 ns,所以这里可以直接设置“Period”为 20 ns,占空比“Duty”为 50。点击“OK”。
现在,点击波形窗口工具栏上的蓝色“Run”按钮(或者按快捷键 F9)旁边的下拉箭头,选择 Run -All。仿真就会开始运行。由于我们没有设置结束时间,仿真会一直运行下去。你可以看到波形在滚动。为了让波形稳定显示一段,我们可以运行一个特定的时间。在Transcript命令行里,输入 run 100 ms 然后回车,让仿真运行100毫秒。这个时间足够我们的扫描计数器循环很多次了。
运行结束后,点击工具栏上的“Break”或“Stop”按钮停止仿真。现在,波形窗口里应该充满了密密麻麻的波形。由于我们的扫描时钟 scan_clk 是主时钟分频得到的,频率很低,所以 sel 和 seg 的变化相对较慢,应该能清晰地看到它们在一个 scan_clk 周期内保持稳定。
4.3 波形分析技巧与调试实战
怎么判断仿真结果对不对呢?我们需要放大波形仔细看。使用鼠标滚轮可以缩放波形的时间轴。我们放大到能看到几个 scan_clk 的周期。
- 检查扫描时序:找到
scan_clk信号(它可能没有直接显示,因为它是一个内部信号。我们可以把uut下的scan_cnt信号也加进来观察,scan_cnt的变化沿应该对应scan_clk的上升沿)。观察sel信号,它应该随着scan_cnt从0到7的变化,依次输出11111110,11111101...01111111。注意你的数码管是共阳极还是共阴极,我代码里假设位选低电平有效(共阳极数码管公共端接高电平,位选端给低电平点亮)。如果你板子是共阴极的,这个逻辑要反过来。 - 检查段码输出:在每一个
sel稳定的时间段内,观察对应的seg输出。例如,当sel = 11111110(选中第0个数码管)时,seg应该输出数字“1”的七段码1001111(对应段a-f为1001111,注意顺序,我代码里是seg(6)到seg(0)对应段a到g)。当sel = 11111101时,seg应该输出数字“2”的七段码0010010,以此类推。你可以对照代码里的seg_data常量数组一一核对。 - 使用ModelSim的数据流查看功能:在波形窗口,选中某个信号,比如
uut/seg,在它波形显示的区域,ModelSim会将其值以二进制、十六进制、十进制等多种格式显示出来。你可以右键点击信号名,选择Radix -> ASCII试试(虽然对七段码意义不大),或者选择Radix -> Binary看二进制最直观。对于scan_cnt,选择Radix -> Unsigned看无符号十进制数会更方便。
如果发现波形不对怎么办? 这是调试的精华所在。比如,你发现 seg 输出全是高电平或者全是低电平。首先,检查Testbench里的时钟 clk_tb 是否真的在周期变化。其次,回到Quartus,检查你的设计代码,特别是查找表 seg_data 和 disp_data 里的值是否正确。一个非常有效的调试方法是,在ModelSim中,将设计内部的更多信号加到波形里,比如 uut/scan_cnt, uut/num_index(如果作为信号引出)。通过观察这些中间变量的值,你可以定位问题发生在哪个环节:是计数器没工作?是查找表索引错了?还是段码表本身定义错了?
修改VHDL代码后,需要重新编译。在ModelSim中,通常需要退出当前仿真,回到Quartus重新全编译,然后再重新启动RTL Simulation。也可以使用ModelSim的“重新编译”功能,但涉及Quartus综合网表的话,从Quartus重新启动更稳妥。
5. 进阶优化与工程化实践
5.1 仿真脚本自动化与参数化测试
每次都通过图形界面点来点去进行仿真,效率太低,也容易出错。我们可以编写一个ModelSim的脚本(.do文件)来自动化这个过程。在工程目录下新建一个文本文件,保存为 simulate.do,内容如下:
# 清空之前的编译库
vlib work
vmap work work
# 编译设计文件和测试平台文件
vcom -2008 ../scan_demo.vhd
vcom -2008 scan_demo_tb.vhd
# 启动仿真,指定顶层实体为测试平台
vsim -t ns work.scan_demo_tb
# 添加信号到波形窗口(使用通配符添加uut下的所有信号)
add wave -position insertpoint sim:/scan_demo_tb/uut/*
# 配置时钟信号(假设时钟信号路径)
force -freeze sim:/scan_demo_tb/clk_tb 0 0, 1 {10 ns} -r 20 ns
# 运行仿真一段时间
run 200 ms
# 缩放波形到合适视图
wave zoom full
在ModelSim的Transcript窗口,输入 do simulate.do 并回车,就能自动执行编译、仿真、添加波形等一系列操作。你可以把不同的测试场景写成不同的 .do 文件,比如测试不同显示内容的 sim_disp_id.do,测试极限频率的 sim_max_clk.do,实现一键回归测试。
5.2 动态扫描的关键参数考量与实物调试桥梁
仿真通过,只意味着我们的设计在理想环境下逻辑正确。要真正在板子上跑起来,还需要考虑一些实际参数。
- 扫描频率:仿真时我们用47Hz是为了看波形方便。实际应用中,扫描频率太低(如低于60Hz)会导致明显的闪烁。太高则会增加功耗,且可能受限于数码管本身的响应时间。通常,扫描频率设置在200Hz到1kHz之间是比较合适的。例如,对于8个数码管,每个管子的点亮频率就是扫描频率的1/8(25-125Hz),高于人眼的临界闪烁频率。在我们的代码中,只需要调整分频计数器的位宽即可。比如,将
scan_clk <= counter(19);改为scan_clk <= counter(15);,那么扫描频率就变成了 50MHz / 2^16 ≈ 763Hz。 - 驱动能力与消影:在实物电路中,当位选信号切换的瞬间,由于数码管LED的关断延迟和寄生电容,可能会产生“重影”(上一个数码管的残影)。为了避免这个,可以在段选信号变化前,先关闭所有数码管(使位选信号全为无效电平),稳定后再打开新的位选,这被称为“消影”处理。这可以在VHDL代码中增加一个短暂的状态来实现。
- 亮度均匀性:每个数码管点亮的时间占空比是相同的(1/8)。但如果显示的数字不同,点亮段的数量不同(比如数字“1”只点亮两段,数字“8”点亮七段),会导致平均电流不同,亮度有差异。更高级的驱动会采用电流控制或脉宽调制(PWM) 来调节每个段的亮度,但这通常需要更复杂的硬件驱动电路或IP核。
仿真波形是我们连接虚拟设计与物理世界最坚实的桥梁。当你把程序下载到板子,发现显示异常时,第一反应不应该是盲目修改代码,而是回头仔细对照仿真波形。用逻辑分析仪或示波器抓取实际的 sel 和 seg 信号,与ModelSim中的波形进行比对。如果两者在时钟沿上的跳变完全一致,那问题可能出在硬件连接(上拉/下拉电阻、共阳/共阴接法)、电源驱动能力上。如果实际信号与仿真不符,那就要深入检查代码的综合结果或时序约束了。养成这种“波形为证”的调试习惯,能让你在数字系统设计中少走很多弯路。
更多推荐

所有评论(0)