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。

  1. Tool name:这里选择你安装的ModelSim版本。如果你安装的是Quartus自带的“ModelSim-Altera”,就选这个。如果安装的是独立的ModelSim,就选“ModelSim”。我一般用“ModelSim-Altera”,兼容性更好。
  2. Format for output netlist:输出网表的格式。因为我们用的是VHDL,所以这里选择 “VHDL”。这个非常重要,它决定了Quartus为你生成哪种语言的仿真模型。
  3. Output directory:输出目录。这里默认是 simulation/modelsim/,相对于你的工程目录。我通常就保持默认,这样所有仿真相关文件都会整齐地放在这个文件夹里。
  4. 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 的周期。

  1. 检查扫描时序:找到 scan_clk 信号(它可能没有直接显示,因为它是一个内部信号。我们可以把 uut 下的 scan_cnt 信号也加进来观察,scan_cnt 的变化沿应该对应 scan_clk 的上升沿)。观察 sel 信号,它应该随着 scan_cnt 从0到7的变化,依次输出 11111110, 11111101 ... 01111111。注意你的数码管是共阳极还是共阴极,我代码里假设位选低电平有效(共阳极数码管公共端接高电平,位选端给低电平点亮)。如果你板子是共阴极的,这个逻辑要反过来。
  2. 检查段码输出:在每一个 sel 稳定的时间段内,观察对应的 seg 输出。例如,当 sel = 11111110(选中第0个数码管)时,seg 应该输出数字“1”的七段码 1001111(对应段a-f为 1001111,注意顺序,我代码里是 seg(6) 到 seg(0) 对应段a到g)。当 sel = 11111101 时,seg 应该输出数字“2”的七段码 0010010,以此类推。你可以对照代码里的 seg_data 常量数组一一核对。
  3. 使用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 动态扫描的关键参数考量与实物调试桥梁

仿真通过,只意味着我们的设计在理想环境下逻辑正确。要真正在板子上跑起来,还需要考虑一些实际参数。

  1. 扫描频率:仿真时我们用47Hz是为了看波形方便。实际应用中,扫描频率太低(如低于60Hz)会导致明显的闪烁。太高则会增加功耗,且可能受限于数码管本身的响应时间。通常,扫描频率设置在200Hz到1kHz之间是比较合适的。例如,对于8个数码管,每个管子的点亮频率就是扫描频率的1/8(25-125Hz),高于人眼的临界闪烁频率。在我们的代码中,只需要调整分频计数器的位宽即可。比如,将 scan_clk <= counter(19); 改为 scan_clk <= counter(15);,那么扫描频率就变成了 50MHz / 2^16 ≈ 763Hz。
  2. 驱动能力与消影:在实物电路中,当位选信号切换的瞬间,由于数码管LED的关断延迟和寄生电容,可能会产生“重影”(上一个数码管的残影)。为了避免这个,可以在段选信号变化前,先关闭所有数码管(使位选信号全为无效电平),稳定后再打开新的位选,这被称为“消影”处理。这可以在VHDL代码中增加一个短暂的状态来实现。
  3. 亮度均匀性:每个数码管点亮的时间占空比是相同的(1/8)。但如果显示的数字不同,点亮段的数量不同(比如数字“1”只点亮两段,数字“8”点亮七段),会导致平均电流不同,亮度有差异。更高级的驱动会采用电流控制或脉宽调制(PWM) 来调节每个段的亮度,但这通常需要更复杂的硬件驱动电路或IP核。

仿真波形是我们连接虚拟设计与物理世界最坚实的桥梁。当你把程序下载到板子,发现显示异常时,第一反应不应该是盲目修改代码,而是回头仔细对照仿真波形。用逻辑分析仪或示波器抓取实际的 sel 和 seg 信号,与ModelSim中的波形进行比对。如果两者在时钟沿上的跳变完全一致,那问题可能出在硬件连接(上拉/下拉电阻、共阳/共阴接法)、电源驱动能力上。如果实际信号与仿真不符,那就要深入检查代码的综合结果或时序约束了。养成这种“波形为证”的调试习惯,能让你在数字系统设计中少走很多弯路。

更多推荐