1. 项目概述与设计思路

还记得在游戏厅里看到的老虎机吗?那些闪烁的灯光和转动的轮盘总是让人心动。现在,我们可以用FPGA技术来自己实现一个数字版本的老虎机游戏!这个项目特别适合刚开始接触数字逻辑设计的爱好者,不需要复杂的硬件,只需要一块FPGA开发板和Quartus软件就能搞定。

这个老虎机游戏的核心是用三个数码管来模拟老虎机的三个转轮,每个数码管显示0-7之间的数字,相当于传统老虎机上的不同图案。游戏玩法很简单:按一下按键,三个数码管开始循环显示随机数字;再按一次按键,数码管停止显示。如果最后三个数字相同,你就赢了!

我选择用VHDL来实现这个设计,主要是因为VHDL在描述硬件行为方面特别强大,而且Quartus平台对VHDL的支持非常完善。整个设计采用了模块化的思路,分成几个关键部分:数据控制模块负责处理按键输入和状态转换,显示模块控制数码管的输出,胜利判断模块则检测是否中奖。

在实际动手之前,我建议先理清状态机的设计思路。老虎机的工作流程可以分解为几个明确的状态:初始空闲状态、三个转轮分别减速停止的状态、以及最终判断胜负的状态。这种状态机的思路不仅适用于老虎机,以后做其他数字逻辑设计也会经常用到。

2. 开发环境搭建与工程创建

开始之前,我们需要准备好开发环境。我使用的是Quartus Prime Lite Edition,这个版本完全免费,功能也足够我们完成这个项目。去Intel官网下载安装包,选择适合你操作系统的版本。安装过程比较简单,基本上就是一路点击"Next",但要注意安装路径最好不要有中文和空格,避免后续出现奇怪的问题。

安装完成后,打开Quartus,我们来创建一个新工程。点击File > New Project Wizard,然后按照向导一步步操作。第一步是设置工程目录和名称,我建议单独创建一个文件夹来存放这个项目,名字就取为"slot_machine"吧。第二步是选择项目类型,这里我们选择"Empty project"。

接下来要添加已有的设计文件,因为我们是从头开始,所以直接点击Next跳过。然后选择目标器件,这个根据你实际使用的FPGA开发板来决定。我用的是Cyclone IV系列的EP4CE6E22C8,如果你的板子不一样,记得在这里选择对应的型号。后面的EDA工具设置保持默认就行,最后确认一下总结信息,点击Finish完成工程创建。

工程创建好后,我们需要添加VHDL文件。右键点击工程名,选择New > VHDL File,命名为data_ctrl.vhd,这将是我们的主控制模块。用同样的方法再创建两个文件:display.vhd用于显示控制,win_detect.vhd用于胜利判断。这种分模块的设计方式会让代码更清晰,也更容易调试。

3. 核心模块设计与实现

3.1 数据控制模块

数据控制模块是整个系统的大脑,负责处理按键输入、状态转换和数字生成。先来看看实体声明部分:

ENTITY data_ctrl IS
PORT(
    clk_in     : IN  STD_LOGIC;  -- 50MHz时钟
    rst_n      : IN  STD_LOGIC;  -- 复位信号
    key        : IN  STD_LOGIC;  -- 按键输入
    current_state : OUT STD_LOGIC_VECTOR(2 DOWNTO 0); -- 当前状态
    data_1     : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);  -- 第一个数字
    data_2     : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);  -- 第二个数字  
    data_3     : OUT STD_LOGIC_VECTOR(3 DOWNTO 0)   -- 第三个数字
);
END data_ctrl;

这个模块有几个关键输入输出:50MHz的系统时钟、低电平有效的复位信号、按键输入,以及输出的当前状态和三个数字信号。我特意加了一个current_state输出,这样在调试的时候可以方便地知道状态机当前处于什么状态。

状态机的设计是这个模块的核心。我定义了6个状态:

  • 空闲状态(s_idle):等待按键按下
  • 计数状态(s_count):三个数字都在快速变化
  • 第一个数字停止(s_cnt23):第一个数字已停止,后两个还在变化
  • 第二个数字停止(s_cnt3):前两个数字已停止,最后一个还在变化
  • 结束状态(s_end):所有数字都停止

状态转换是通过检测按键事件来触发的。每次按键按下,状态就向前推进一步。为了消除按键抖动,我添加了一个简单的延时计数器:

PROCESS(clk_in, rst_n)
BEGIN
    IF rst_n = '0' THEN
        wait_1 <= (OTHERS => '0');
    ELSIF rising_edge(clk_in) THEN
        IF key = '0' THEN  -- 按键按下
            wait_1 <= wait_1 + 1;
        ELSE
            wait_1 <= (OTHERS => '0');
        END IF;
    END IF;
END PROCESS;

数字生成部分使用了三个独立的计数器,每个计数器用不同的分频系数来产生不同速度的数字变化效果。这样设计的好处是三个转轮看起来是在独立运转,更接近真实老虎机的效果。

3.2 显示控制模块

显示控制模块负责将数字信号转换为数码管能够显示的段码。虽然听起来简单,但这里面有些细节需要特别注意:

ENTITY display IS
PORT(
    digit_in   : IN  STD_LOGIC_VECTOR(3 DOWNTO 0);
    seg_out    : OUT STD_LOGIC_VECTOR(6 DOWNTO 0)
);
END display;

数码管通常有7个段(a-g),每个段对应一个LED。我们需要根据输入的数字值,点亮相应的段来形成数字形状。比如数字0需要点亮除了g段之外的所有段,数字1只需要点亮b和c段。

我用了查找表的方式来实现这个转换:

ARCHITECTURE behavior OF display IS
BEGIN
    WITH digit_in SELECT
        seg_out <= "1000000" WHEN "0000",  -- 0
                   "1111001" WHEN "0001",  -- 1
                   "0100100" WHEN "0010",  -- 2
                   "0110000" WHEN "0011",  -- 3
                   "0011001" WHEN "0100",  -- 4
                   "0010010" WHEN "0101",  -- 5
                   "0000010" WHEN "0110",  -- 6
                   "1111000" WHEN "0111",  -- 7
                   "1111111" WHEN OTHERS;   -- 默认全灭
END behavior;

这种方法的优点是代码清晰易懂,而且执行效率高。不过要注意数码管是共阳极还是共阴极,这决定了段码是高电平有效还是低电平有效。我的代码是基于共阳极数码管编写的,如果是共阴极,需要将所有段码取反。

在实际测试中,我发现如果直接驱动数码管,有时候会出现亮度不均匀的问题。后来我添加了一个简单的PWM调光功能,通过调节占空比来控制亮度,效果就好多了。

3.3 胜利判断模块

胜利判断模块的逻辑相对简单,就是比较三个数字是否相等:

ENTITY win_detect IS
PORT(
    data1 : IN  STD_LOGIC_VECTOR(3 DOWNTO 0);
    data2 : IN  STD_LOGIC_VECTOR(3 DOWNTO 0);
    data3 : IN  STD_LOGIC_VECTOR(3 DOWNTO 0);
    win   : OUT STD_LOGIC
);
END win_detect;

实现起来也很直接:

ARCHITECTURE behavior OF win_detect IS
BEGIN
    win <= '1' WHEN (data1 = data2) AND (data2 = data3) ELSE '0';
END behavior;

虽然代码简单,但我在实际测试中发现了一个问题:如果比较的时候数字还在变化,可能会产生误判。所以我添加了一个状态检测,只有在状态机处于结束状态时才进行胜利判断,这样就避免了错误的中奖信号。

为了增加游戏体验,我还添加了一个简单的胜利提示功能:当检测到胜利时,会让三个数码管一起闪烁几次。这个功能虽然简单,但确实让游戏体验提升了不少。

4. 时钟分频与按键消抖

4.1 时钟分频设计

FPGA板上的时钟通常是50MHz,这个频率对我们来说太高了。我们需要通过时钟分频来得到适合各种功能的时钟信号。比如数码管的刷新频率最好在100Hz-1kHz之间,而转轮的变化频率则需要更低一些。

我设计了三个独立的分频器,分别用于控制三个转轮的变化速度:

PROCESS(clk_in, rst_n)
BEGIN
    IF rst_n = '0' THEN
        div_cnt_1 <= (OTHERS => '0');
    ELSIF rising_edge(clk_in) THEN
        IF div_cnt_1 >= "00000000000000000000000000010000" THEN  -- 5Hz
            div_cnt_1 <= (OTHERS => '0');
            cnt1 <= cnt1 + 1;  -- 数字变化
        ELSE
            div_cnt_1 <= div_cnt_1 + 1;
        END IF;
    END IF;
END PROCESS;

第一个转轮使用5Hz的更新频率,第二个用3Hz,第三个用2Hz。这样设计是为了让三个转轮看起来是在以不同的速度运转,更接近真实老虎机的效果。当按键按下时,转轮不是立即停止,而是依次减速停止,这也增加了游戏的趣味性。

在实际调试中,我发现分频系数的设置很重要。如果频率太高,数字变化太快,根本看不清;如果频率太低,又显得游戏反应迟钝。经过几次试验,我找到了一个比较合适的范围:5Hz-2Hz的渐变频率效果最好。

4.2 按键消抖处理

机械按键在按下和释放时会产生抖动,这会导致单次按键被误识别为多次按键。为了解决这个问题,我实现了一个简单的按键消抖逻辑:

PROCESS(clk_in, rst_n)
BEGIN
    IF rst_n = '0' THEN
        key_debounced <= '1';
        debounce_cnt <= (OTHERS => '0');
    ELSIF rising_edge(clk_in) THEN
        IF key /= key_debounced THEN
            debounce_cnt <= debounce_cnt + 1;
            IF debounce_cnt = "1111111111111111" THEN  -- 约1.3ms
                key_debounced <= key;
                debounce_cnt <= (OTHERS => '0');
            END IF;
        ELSE
            debounce_cnt <= (OTHERS => '0');
        END IF;
    END IF;
END PROCESS;

这个消抖逻辑的原理是:当检测到按键状态变化时,开始计数,只有当按键状态保持一定时间(约1.3ms)后才确认状态变化。这样可以有效过滤掉机械抖动产生的毛刺。

在实际测试中,我发现这个消抖时间对于大多数按键来说已经足够了。如果你的按键抖动特别严重,可以适当增加计数器的位宽,延长消抖时间。不过要注意,消抖时间太长会影响按键的响应速度,需要根据实际情况权衡。

5. 顶层设计与模块连接

顶层设计就像是在画一张电路连接图,把各个模块按照设计思路连接起来。我习惯先用框图画出模块之间的连接关系,然后再用VHDL代码实现。

ENTITY slot_machine_top IS
PORT(
    clk     : IN  STD_LOGIC;  -- 50MHz时钟
    rst_n   : IN  STD_LOGIC;  -- 复位按钮
    key     : IN  STD_LOGIC;  -- 游戏控制按键
    seg1    : OUT STD_LOGIC_VECTOR(6 DOWNTO 0);  -- 数码管1段码
    seg2    : OUT STD_LOGIC_VECTOR(6 DOWNTO 0);  -- 数码管2段码  
    seg3    : OUT STD_LOGIC_VECTOR(6 DOWNTO 0);  -- 数码管3段码
    led     : OUT STD_LOGIC   -- 胜利指示灯
);
END slot_machine_top;

在架构体中,我们需要实例化所有模块并正确连接信号:

ARCHITECTURE structure OF slot_machine_top IS
    -- 内部信号声明
    SIGNAL data1, data2, data3 : STD_LOGIC_VECTOR(3 DOWNTO 0);
    SIGNAL win_flag : STD_LOGIC;
    
    -- 组件声明
    COMPONENT data_ctrl
    PORT(
        clk_in : IN STD_LOGIC;
        rst_n : IN STD_LOGIC;
        key : IN STD_LOGIC;
        data_1 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
        data_2 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0);
        data_3 : OUT STD_LOGIC_VECTOR(3 DOWNTO 0)
    );
    END COMPONENT;
    
    -- 其他组件声明...
BEGIN
    -- 实例化数据控制模块
    inst_data_ctrl: data_ctrl
    PORT MAP(
        clk_in => clk,
        rst_n => rst_n,
        key => key,
        data_1 => data1,
        data_2 => data2, 
        data_3 => data3
    );
    
    -- 其他模块实例化...
END structure;

这种层次化的设计方法让代码结构清晰,也便于调试。如果某个模块有问题,可以单独测试这个模块,而不需要牵扯整个系统。

在信号命名方面,我建议采用一致的命名规范。比如输入信号加"_in"后缀,输出信号加"_out"后缀,内部信号使用描述性的名字。这样在阅读代码时就能快速理解信号的用途和方向。

6. 管脚分配与硬件连接

管脚分配是FPGA设计中很重要的一步,如果分配错误,整个系统就无法正常工作。在Quartus中,我们可以通过Assignment Editor来分配管脚。

首先需要知道FPGA开发板的原理图,了解各个外设连接到了哪个管脚。比如数码管的段码通常连接到了FPGA的通用IO管脚,按键连接到了特定的输入管脚。

在Assignment Editor中,我们需要为每个端口指定具体的管脚编号。比如:

端口管脚编号说明
clkPIN_2350MHz时钟输入
rst_nPIN_24复位按钮
keyPIN_25游戏控制按键
seg1[0]PIN_40数码管1段a
seg1[1]PIN_41数码管1段b

管脚分配完成后,记得检查一下是否有冲突。有些管脚有特殊功能(如时钟输入、配置管脚等),不能用作普通IO。Quartus会在编译时检查管脚分配是否合法,如果有问题会给出警告或错误信息。

硬件连接方面,除了FPGA开发板本身,我们可能还需要一些外部元件。比如如果开发板上的数码管不够,可以外接数码管模块。连接时要注意电压匹配,大多数FPGA开发板使用3.3V电平,而一些外设可能需要5V电平,这时候就需要电平转换电路。

在实际操作中,我发现排线连接很容易出错。建议先用万用表检查连接是否正确,特别是电源和地线不要接反。有一次我就因为地线接触不良,调试了半天才发现问题。

7. 功能仿真与测试

功能仿真是验证设计正确性的重要手段。在Quartus中,我们可以使用ModelSim来进行仿真。首先需要编写测试脚本(Testbench),给设计提供激励信号并检查输出结果。

ENTITY tb_slot_machine IS
END tb_slot_machine;

ARCHITECTURE behavior OF tb_slot_machine IS 
    -- 组件声明和信号定义
    COMPONENT slot_machine_top
    PORT(
        clk : IN STD_LOGIC;
        rst_n : IN STD_LOGIC;
        key : IN STD_LOGIC;
        seg1 : OUT STD_LOGIC_VECTOR(6 DOWNTO 0);
        seg2 : OUT STD_LOGIC_VECTOR(6 DOWNTO 0);
        seg3 : OUT STD_LOGIC_VECTOR(6 DOWNTO 0);
        led : OUT STD_LOGIC
    );
    END COMPONENT;
    
    -- 测试信号
    signal clk : STD_LOGIC := '0';
    signal rst_n : STD_LOGIC := '0';
    signal key : STD_LOGIC := '1';
    signal seg1, seg2, seg3 : STD_LOGIC_VECTOR(6 DOWNTO 0);
    signal led : STD_LOGIC;
    
    -- 时钟周期定义
    constant clk_period : time := 20 ns;
BEGIN
    -- 实例化被测单元
    uut: slot_machine_top PORT MAP (
        clk => clk,
        rst_n => rst_n,
        key => key,
        seg1 => seg1,
        seg2 => seg2,
        seg3 => seg3,
        led => led
    );
    
    -- 时钟生成
    clk_process :process
    begin
        clk <= '0';
        wait for clk_period/2;
        clk <= '1';
        wait for clk_period/2;
    end process;
    
    -- 测试激励
    stim_proc: process
    begin
        -- 复位
        rst_n <= '0';
        wait for 100 ns;
        rst_n <= '1';
        wait for 20 ns;
        
        -- 模拟按键按下
        key <= '0';
        wait for 100 ns;
        key <= '1';
        
        -- 等待一段时间后再按一次
        wait for 1000 ns;
        key <= '0';
        wait for 100 ns;
        key <= '1';
        
        wait;
    end process;
END;

在测试脚本中,我们模拟了完整的游戏流程:先复位系统,然后模拟按键按下开始游戏,等待一段时间后再模拟按键按下停止转轮。通过观察波形图,我们可以验证状态机的转换是否正确,数字生成是否正常,胜利判断是否准确。

仿真时要注意时间尺度的选择。如果只看功能逻辑,可以把时间尺度设大一些;如果需要观察时钟边沿等细节,就需要放大时间尺度。我一般会先进行大时间尺度的功能仿真,确保基本逻辑正确后,再对关键部分进行详细时序分析。

除了功能仿真,最好还要进行时序仿真,检查设计是否满足时序要求。特别是当系统时钟频率较高时,组合逻辑的延迟可能会导致建立时间或保持时间 violation。

8. 常见问题与调试技巧

在做这个项目的过程中,我遇到了不少问题,也积累了一些调试经验。这里分享几个常见问题和解决方法:

第一个问题是状态机无法正确转换状态。这通常是因为状态编码或状态转换条件有错误。我的解决方法是添加一些调试信号,把状态机的当前状态输出到LED或数码管上,这样就能直观地看到状态机的运行情况。

第二个问题是数码管显示乱码。这可能是段码映射错误,或者是扫描频率不合适。我用示波器检查了段码输出信号,发现确实是段码表定义有误。修改后显示就正常了。

第三个问题是按键响应不灵敏。这可能是消抖参数设置不合理,或者是按键检测逻辑有缺陷。我调整了消抖计数器的位宽,找到了一个既能有效消抖又不会影响响应速度的参数。

调试FPGA设计时,Quartus的Signal Tap逻辑分析仪是个很有用的工具。它可以实时捕获FPGA内部的信号变化,帮助我们分析问题。使用方法是先定义要观察的信号,设置触发条件,然后编译下载到FPGA中运行。

另一个有用的技巧是使用增量编译。当只修改了部分代码时,使用增量编译可以大大缩短编译时间,提高调试效率。特别是在反复调试某个模块时,这个功能特别实用。

如果遇到时序问题,可以尝试添加流水线寄存器来改善时序。或者使用Quartus的时序分析工具,找出关键路径,针对性地进行优化。

最后建议保存多个版本的工程文件。每完成一个功能模块或者解决一个重要问题,就保存一个版本。这样如果后续修改引入了新问题,可以快速回退到之前的稳定版本。

更多推荐