FPGA数字逻辑设计实战:从零实现经典乒乓球游戏
2026/9/4 15:02:20 网站建设 项目流程

简介:这是一份面向FPGA初学者与数字电路课程设计者的完整乒乓球游戏机实现方案,基于Verilog HDL在Altera Cyclone II EP2C5T144C8芯片上完成硬件逻辑设计,解决从算法建模、时序控制到外设驱动的全流程开发问题。压缩包共251个文件,包含69个.cdb(编译数据库)、67个.hdb(仿真波形数据)、21个.tdf(时序分析文件)、7个.v(核心Verilog源码)及.qpf/.sof/.pof等工程配置与烧录文件,辅以详细设计报告(.doc)和readme说明,总大小仅1.61MB,结构紧凑、模块清晰,便于理解乒乓运动逻辑、球拍控制、碰撞检测与LED/数码管显示驱动等关键模块。已有2148人学习下载,资源提供可直接编译下载的Quartus II工程、带注释的源码、仿真测试激励及完整设计报告,特别适合数字系统课程实践、FPGA入门项目复现与竞赛备赛参考。

1. 项目概述:从零到一,用FPGA复刻经典乒乓

如果你对数字电路设计感兴趣,或者正在学习FPGA和Verilog,那么“用FPGA实现一个乒乓球游戏机”绝对是一个经典且极具价值的练手项目。这不仅仅是一个简单的游戏,它几乎涵盖了数字逻辑设计的核心要素:时钟分频、状态机、计数器、显示驱动、人机交互。当你亲手在开发板上看到两个光点来回跳动,并通过按键控制“球拍”时,那种成就感是看多少遍理论都无法比拟的。这个项目非常适合电子工程、计算机科学相关专业的学生,以及希望从理论转向实践的FPGA初学者。通过它,你能深刻理解硬件描述语言如何转化为实际的硬件行为,掌握从设计、仿真到上板调试的完整流程。

2. 核心设计思路与架构拆解

2.1 需求分析与功能定义

一个基础的乒乓球游戏机,其核心功能可以拆解为以下几个部分:

  1. 球体运动:一个光点(代表球)在屏幕上沿对角线运动,遇到边界或球拍时反弹。
  2. 球拍控制:两个可由玩家控制的“球拍”(通常是一列或几列LED灯),通过按键上下移动。
  3. 碰撞检测:实时判断球是否与球拍或屏幕边界发生碰撞,并改变其运动方向。
  4. 计分系统:当一方未能接到球时,对方得分,并在数码管或LED上显示当前比分。
  5. 游戏状态控制:包括游戏开始、暂停、复位、球速调节等。

基于FPGA的实现,意味着我们需要用Verilog代码来描述上述所有逻辑,并将其映射到开发板上的具体资源:按键作为输入,LED点阵或VGA接口作为显示输出,数码管或LED灯显示分数。

2.2 系统顶层模块划分

一个清晰、模块化的顶层设计是项目成功的关键。通常,我们可以将整个系统划分为以下几个主要模块:

Top_Pong_Game ├── clk_gen (时钟分频与生成模块) ├── debounce (按键消抖模块) ├── game_control (游戏核心控制与状态机模块) ├── ball_movement (球体运动逻辑模块) ├── paddle_control (球拍控制模块) ├── collision_detect (碰撞检测模块) ├── score_counter (计分模块) └── display_driver (显示驱动模块,如LED矩阵驱动或VGA驱动)

为什么这样划分?

  • 解耦与复用:每个模块功能单一。例如,debounce模块可以复用于所有机械按键,clk_gen可以为不同速度需求的模块提供时钟。
  • 便于调试:在仿真时,可以单独测试每个模块的功能,例如单独验证碰撞检测逻辑是否正确,再集成到顶层。
  • 可扩展性:如果想增加功能,如增加音效、改变游戏模式,只需修改或增加对应模块,而不必重写整个工程。

2.3 核心器件选型与接口定义

对于这个项目,一块基础的FPGA开发板(如Xilinx的Basys3、Artix-7系列,或Altera/Intel的Cyclone IV/V系列)就足够了。我们需要确认开发板上的资源:

  • 输入:至少4个独立按键(两个玩家上下控制,一个复位,一个开始/暂停)。如果资源允许,可以增加拨码开关调节球速。
  • 输出
    • 方案A(LED点阵):使用8x8或16x16的LED点阵屏来显示球和球拍。优点是直观、驱动相对简单,缺点是分辨率低,显示效果较粗糙。
    • 方案B(VGA显示):通过VGA接口连接显示器。优点是显示效果极佳,可以绘制更精细的图形和文字,缺点是驱动逻辑更复杂,需要生成符合VGA时序的同步信号和RGB数据。
    • 计分显示:通常使用板载的数码管或额外的LED灯来表示分数。

我的选择与考量:对于初学者,我强烈建议先从LED点阵方案入手。它的驱动核心是一个行列扫描电路,逻辑清晰,能让你快速聚焦于游戏逻辑本身,而不是复杂的视频时序。当你成功在点阵上跑通游戏后,再挑战VGA版本,会更有成就感,也能更深刻地理解时序电路设计。

3. 关键模块的Verilog实现详解

3.1 时钟管理与按键消抖

这是所有FPGA数字系统稳定运行的基石,却最容易被新手忽略。

时钟分频模块 (clk_gen): FPGA外部晶振通常提供很高的频率(如100MHz)。我们的游戏逻辑(如球每0.2秒移动一格)和扫描显示(如LED点阵的刷新率需要60Hz以上)需要更低的时钟。这就需要通过计数器进行分频。

module clk_gen ( input wire clk_100m, // 100MHz主时钟 input wire rst_n, // 低电平复位 output reg clk_game, // 游戏逻辑时钟,如1Hz output reg clk_scan // 显示扫描时钟,如1kHz ); // 分频计数器参数 parameter GAME_DIV = 100_000_000; // 100MHz / 1Hz = 100,000,000 parameter SCAN_DIV = 100_000; // 100MHz / 1kHz = 100,000 reg [31:0] cnt_game, cnt_scan; always @(posedge clk_100m or negedge rst_n) begin if (!rst_n) begin cnt_game <= 0; cnt_scan <= 0; clk_game <= 0; clk_scan <= 0; end else begin // 游戏时钟分频 if (cnt_game >= GAME_DIV/2 -1) begin cnt_game <= 0; clk_game <= ~clk_game; end else begin cnt_game <= cnt_game + 1; end // 扫描时钟分频 if (cnt_scan >= SCAN_DIV/2 -1) begin cnt_scan <= 0; clk_scan <= ~clk_scan; end else begin cnt_scan <= cnt_scan + 1; end end end endmodule

注意:这里使用了clk_game <= ~clk_game;来生成占空比50%的时钟。对于简单的使能信号,更常见的做法是生成一个周期性的高脉冲(使能信号),而不是生成一个新的时钟域,以避免跨时钟域问题。但对于这个入门项目,直接分频时钟更直观。

按键消抖模块 (debounce): 机械按键在按下和弹起时,会产生持续数毫秒的抖动,直接读取会导致多次误触发。消抖逻辑的核心是:检测到按键状态变化后,等待一段时间(如20ms),如果状态保持稳定,则输出新的状态。

module debounce ( input wire clk, // 扫描时钟,如1kHz input wire button_in, // 原始按键输入 output reg button_out // 消抖后稳定输出 ); parameter DEBOUNCE_MS = 20; // 消抖时间 parameter CLK_FREQ_HZ = 1000; // 时钟频率1kHz localparam COUNTER_MAX = DEBOUNCE_MS * CLK_FREQ_HZ / 1000; reg [15:0] counter; reg button_reg; always @(posedge clk) begin button_reg <= button_in; // 同步打拍 if (button_reg ^ button_out) begin // 检测到输入与输出不同(有变化) if (counter == COUNTER_MAX) begin button_out <= button_reg; // 稳定后更新输出 counter <= 0; end else begin counter <= counter + 1; end end else begin counter <= 0; // 状态稳定,计数器清零 end end endmodule

实操心得:消抖时间DEBOUNCE_MS通常取10~20ms。时钟clk不宜直接用高速主时钟,否则计数器会非常大。用一个1kHz(1ms周期)的分频时钟来驱动消抖模块,计数器只需计数20左右,逻辑更简洁。

3.2 游戏核心:状态机与运动逻辑

这是整个游戏的“大脑”,我习惯用一个顶层状态机来协调所有行为。

游戏控制状态机 (game_control)

module game_control ( input wire clk_game, input wire rst_n, input wire start_btn, // 开始/暂停 input wire miss_left, // 左方失球 input wire miss_right,// 右方失球 output reg game_active, // 游戏进行中标志 output reg [1:0] state // 状态输出,可用于调试 ); // 定义游戏状态 localparam IDLE = 2'b00; // 待机 localparam PLAY = 2'b01; // 进行中 localparam PAUSE = 2'b10; // 暂停 localparam GOAL = 2'b11; // 得分状态(短暂显示) reg [1:0] current_state, next_state; reg [19:0] goal_timer; // 得分显示计时器 // 状态寄存器更新 always @(posedge clk_game or negedge rst_n) begin if (!rst_n) current_state <= IDLE; else current_state <= next_state; end // 下一状态逻辑 always @(*) begin next_state = current_state; case (current_state) IDLE: if (start_btn) next_state = PLAY; PLAY: begin if (start_btn) next_state = PAUSE; else if (miss_left || miss_right) next_state = GOAL; end PAUSE: if (start_btn) next_state = PLAY; GOAL: if (goal_timer == 20'd999_999) next_state = PLAY; // 显示约1秒后回到PLAY default: next_state = IDLE; endcase end // 输出逻辑与计时器 always @(posedge clk_game or negedge rst_n) begin if (!rst_n) begin game_active <= 1'b0; goal_timer <= 0; end else begin case (current_state) PLAY: game_active <= 1'b1; GOAL: begin game_active <= 1'b0; if (goal_timer < 20'd999_999) goal_timer <= goal_timer + 1; else goal_timer <= 0; end default: game_active <= 1'b0; endcase end end assign state = current_state; endmodule

球体运动模块 (ball_movement): 球的位置用二维坐标(ball_x, ball_y)表示,方向用两个标志位dir_x, dir_y表示(1为增,0为减)。在game_active有效且每个clk_game上升沿,球根据方向移动一格。碰撞检测模块会修改dir_xdir_y

module ball_movement ( input wire clk_game, input wire rst_n, input wire game_active, input wire collide_x, // X方向碰撞(左右边界或球拍) input wire collide_y, // Y方向碰撞(上下边界) output reg [3:0] ball_x, // 假设X坐标范围0-15 output reg [3:0] ball_y // 假设Y坐标范围0-7 ); reg dir_x; // 1:向右,0:向左 reg dir_y; // 1:向下,0:向上 always @(posedge clk_game or negedge rst_n) begin if (!rst_n) begin ball_x <= 4'd8; // 初始位置居中 ball_y <= 4'd4; dir_x <= 1'b1; // 初始向右 dir_y <= 1'b1; // 初始向下 end else if (game_active) begin // 处理碰撞,改变方向 if (collide_x) dir_x <= ~dir_x; if (collide_y) dir_y <= ~dir_y; // 根据方向更新位置 ball_x <= dir_x ? ball_x + 1 : ball_x - 1; ball_y <= dir_y ? ball_y + 1 : ball_y - 1; end end endmodule

3.3 碰撞检测与球拍控制

碰撞检测模块 (collision_detect): 这是游戏逻辑的精度所在。需要检测四种碰撞:

  1. 上下边界ball_y到达0或最大值。
  2. 左右球拍ball_x到达最左或最右,同时ball_y在对方球拍的高度范围内。
module collision_detect ( input wire [3:0] ball_x, ball_y, input wire [3:0] paddle_left_y, // 左球拍中心Y坐标 input wire [3:0] paddle_right_y,// 右球拍中心Y坐标 input wire [2:0] paddle_height, // 球拍高度,如3表示3格 output reg collide_x, collide_y, output reg miss_left, miss_right ); parameter X_MAX = 15; parameter Y_MAX = 7; // 计算球拍上下边界 wire [3:0] left_paddle_top = paddle_left_y + paddle_height/2; wire [3:0] left_paddle_bot = paddle_left_y - paddle_height/2; wire [3:0] right_paddle_top = paddle_right_y + paddle_height/2; wire [3:0] right_paddle_bot = paddle_right_y - paddle_height/2; always @(*) begin collide_y = 1'b0; collide_x = 1'b0; miss_left = 1'b0; miss_right = 1'b0; // 上下边界碰撞 if (ball_y == 0 || ball_y == Y_MAX) collide_y = 1'b1; // 左右边界及球拍碰撞 if (ball_x == 0) begin // 到达左边界 // 判断是否在左球拍范围内 if (ball_y <= left_paddle_top && ball_y >= left_paddle_bot) begin collide_x = 1'b1; // 击中球拍,反弹 end else begin miss_right = 1'b1; // 左方未击中,右方得分 end end else if (ball_x == X_MAX) begin // 到达右边界 // 判断是否在右球拍范围内 if (ball_y <= right_paddle_top && ball_y >= right_paddle_bot) begin collide_x = 1'b1; end else begin miss_left = 1'b1; // 右方未击中,左方得分 end end end endmodule

关键细节:这里用组合逻辑(always @(*))实现碰撞检测,确保响应是即时性的。球拍位置paddle_left_y等需要由球拍控制模块根据按键输入实时更新。

球拍控制模块 (paddle_control): 该模块读取消抖后的按键信号,在游戏进行状态(game_active)下,控制球拍位置的上下移动,并确保球拍不超出屏幕范围。

module paddle_control ( input wire clk_game, input wire rst_n, input wire game_active, input wire btn_up_left, // 左方上移 input wire btn_down_left, // 左方下移 input wire btn_up_right, // 右方上移 input wire btn_down_right,// 右方下移 output reg [3:0] paddle_left_y, output reg [3:0] paddle_right_y ); parameter Y_MAX = 7; parameter PADDLE_SPEED = 1; // 每次移动的格数 always @(posedge clk_game or negedge rst_n) begin if (!rst_n) begin paddle_left_y <= 4'd4; paddle_right_y <= 4'd4; end else if (game_active) begin // 控制左球拍 if (btn_up_left && paddle_left_y < Y_MAX) begin paddle_left_y <= paddle_left_y + PADDLE_SPEED; end else if (btn_down_left && paddle_left_y > 0) begin paddle_left_y <= paddle_left_y - PADDLE_SPEED; end // 控制右球拍 if (btn_up_right && paddle_right_y < Y_MAX) begin paddle_right_y <= paddle_right_y + PADDLE_SPEED; end else if (btn_down_right && paddle_right_y > 0) begin paddle_right_y <= paddle_right_y - PADDLE_SPEED; end end end endmodule

3.4 显示驱动与系统集成

LED点阵驱动模块 (led_matrix_driver): 这是将游戏逻辑状态“可视化”的关键。我们以8x8点阵为例,采用经典的“行列扫描”法来驱动。

module led_matrix_driver ( input wire clk_scan, // 扫描时钟,如1kHz input wire rst_n, input wire [3:0] ball_x, ball_y, input wire [3:0] paddle_left_y, paddle_right_y, input wire [2:0] paddle_height, output reg [7:0] row_sel, // 行选信号,低电平有效 output reg [7:0] col_data // 列数据,高电平点亮 ); reg [2:0] scan_cnt; // 扫描计数器,0-7循环 wire [7:0] row_mask; // 当前扫描行的掩码 // 根据扫描计数生成行选信号(例如共阴极,低电平选中一行) always @(posedge clk_scan or negedge rst_n) begin if (!rst_n) scan_cnt <= 0; else scan_cnt <= scan_cnt + 1; end always @(*) begin row_sel = 8'b1111_1111; row_sel[scan_cnt] = 1'b0; // 选中当前行 end // 生成当前行需要点亮的列数据 always @(*) begin col_data = 8'b0000_0000; // 点亮球 if (ball_y == scan_cnt) begin col_data[ball_x] = 1'b1; end // 点亮左球拍(位于X=0列) if (scan_cnt >= paddle_left_y - paddle_height/2 && scan_cnt <= paddle_left_y + paddle_height/2) begin col_data[0] = 1'b1; end // 点亮右球拍(位于X=7列) if (scan_cnt >= paddle_right_y - paddle_height/2 && scan_cnt <= paddle_right_y + paddle_height/2) begin col_data[7] = 1'b1; end end endmodule

重要提示:实际硬件连接时,务必查阅开发板原理图,确认LED点阵是共阴极还是共阳极,以及行列信号是高电平有效还是低电平有效。上述代码以共阴极、行选低有效、列数据高有效为例。驱动能力不足时可能需要三极管扩流。

顶层模块集成 (top_pong_game): 最后,我们将所有模块像搭积木一样连接起来。

module top_pong_game ( input wire clk_100m, input wire rst_n_in, // 外部复位按键 input wire btn_start, input wire btn_up_l, btn_down_l, input wire btn_up_r, btn_down_r, output wire [7:0] led_row, output wire [7:0] led_col, output wire [3:0] score_left, // 左方分数,接数码管译码器 output wire [3:0] score_right // 右方分数 ); // 内部信号声明 wire clk_game, clk_scan; wire btn_start_db, btn_up_l_db, btn_down_l_db, btn_up_r_db, btn_down_r_db; wire game_active; wire [3:0] ball_x, ball_y; wire [3:0] paddle_left_y, paddle_right_y; wire collide_x, collide_y, miss_left, miss_right; // 模块实例化 clk_gen u_clk_gen (.clk_100m(clk_100m), .rst_n(rst_n), ...); debounce u_deb_start (.clk(clk_scan), .button_in(btn_start), .button_out(btn_start_db)); // ... 实例化其他按键的消抖模块 game_control u_game_ctrl (.clk_game(clk_game), .rst_n(rst_n), .start_btn(btn_start_db), ...); paddle_control u_paddle_ctrl (.clk_game(clk_game), .rst_n(rst_n), .game_active(game_active), ...); ball_movement u_ball (.clk_game(clk_game), .rst_n(rst_n), .game_active(game_active), ...); collision_detect u_collide (.ball_x(ball_x), .ball_y(ball_y), ...); score_counter u_score (.clk_game(clk_game), .rst_n(rst_n), .miss_left(miss_left), .miss_right(miss_right), ...); led_matrix_driver u_display (.clk_scan(clk_scan), .rst_n(rst_n), .ball_x(ball_x), ...); endmodule

4. 仿真、调试与上板实战全记录

4.1 测试平台的搭建与关键仿真

写Verilog代码,不仿真就等于闭着眼睛开车。我们需要一个测试平台(Testbench)来验证每个模块乃至整个系统的行为。以game_control状态机为例:

`timescale 1ns / 1ps module tb_game_control(); reg clk, rst_n, start_btn, miss_left, miss_right; wire game_active; wire [1:0] state; game_control uut (.*); // 实例化被测模块 // 生成时钟(周期20ns,对应50MHz,这里简化) always #10 clk = ~clk; initial begin // 初始化 clk = 0; rst_n = 0; start_btn = 0; miss_left = 0; miss_right = 0; #100 rst_n = 1; // 释放复位 // 测试:按下开始键,进入PLAY状态 #20 start_btn = 1; #40 start_btn = 0; // 观察game_active是否变高,state是否变为PLAY // 测试:在PLAY状态下,模拟右方得分 #200 miss_right = 1; #40 miss_right = 0; // 观察是否进入GOAL状态,game_active变低,约1秒后回到PLAY // 测试:在PLAY状态下暂停 #200 start_btn = 1; #40 start_btn = 0; // 观察是否进入PAUSE状态,game_active变低 #1000 $finish; end endmodule

在Modelsim或Vivado Simulator中运行,观察波形图,确认状态转换、game_active信号和计时器逻辑是否符合预期。

仿真要点

  • 分模块仿真:先单独仿真debouncecollision_detect等关键模块。
  • 关键场景覆盖:仿真要覆盖边界情况,如球刚好擦着球拍边沿通过、球拍移动到屏幕最顶端时按下上键等。
  • 查看中间信号:把模块内部的重要寄存器(如球坐标、方向、计数器)也拉到波形图中观察。

4.2 上板调试的“血泪”经验

仿真通过,只是万里长征第一步。上板调试才是真正的挑战。以下是我踩过的一些坑:

  1. 现象:LED点阵显示闪烁、重影或亮度不均。

    • 排查:首先检查clk_scan频率。刷新率太低(如低于60Hz)会看到闪烁;太高可能超过LED的响应速度或扫描逻辑的稳定时间。1kHz左右是个不错的起点。其次,检查行列信号的驱动能力,如果电流不够,LED会暗淡。最后,仔细核对原理图,确认你的代码中的行列有效电平与硬件匹配。共阴/共阳弄反,整个显示就是反的。
  2. 现象:球或球拍移动不流畅,有跳帧或卡顿。

    • 排查:检查clk_game的频率。这是游戏的主时钟,决定了球移动的速度。如果频率太低,球移动慢;如果太高,球速过快难以操作。可以通过一个拨码开关连接分频计数器的参数,实现球速可调。另外,确保game_active信号在正确的时间被置位/清零。
  3. 现象:按键响应迟钝或连击。

    • 排查:99%的问题出在消抖。检查消抖模块的时钟clk是否确实连接到了1kHz左右的时钟,而不是100MHz主时钟。检查消抖时间常数是否合理(20ms)。可以在代码里添加一个LED,用消抖后的信号去点亮它,直观测试按键响应。
  4. 现象:碰撞检测不准,球有时会穿墙或穿拍。

    • 排查:这是逻辑错误。回到collision_detect模块,打印(或通过LED指示)球坐标和球拍边界坐标,在极限位置(如ball_y=0, ball_x=0)单步调试。特别注意比较运算符是>>=<还是<=,一个等号的差别会导致完全不同的行为。确保你的碰撞判断条件覆盖了所有像素点。
  5. 综合与实现警告:工具(如Vivado)可能会报告“时序未满足”、“ latch推断”等警告。

    • latch推断:通常是因为在组合逻辑always @(*)块中,if或case语句没有写全所有分支,导致电路在某种条件下需要“保持”之前的值,从而生成了锁存器。在FPGA设计中,应避免非预期的锁存器。务必为所有输出信号在所有分支下都分配一个值。
    • 时序违规:如果系统时钟很高,而组合逻辑路径太长(比如碰撞检测逻辑非常复杂),可能导致建立/保持时间违例。可以通过打拍(插入寄存器)来分割组合逻辑路径。

4.3 功能扩展与优化思路

当基础版本稳定运行后,你可以尝试以下扩展,让项目更出彩:

  • VGA显示:学习VGA时序,用FPGA生成RGB信号,在显示器上绘制更精美的球拍、球、边界线和分数。这会涉及帧缓冲、图形生成等更复杂的概念。
  • 音效输出:利用PWM(脉宽调制)驱动一个无源蜂鸣器,在击球、得分时发出不同频率的声音。
  • 智能对手:实现一个简单的AI,控制右方球拍自动追踪球的Y坐标,做成单人游戏模式。
  • 多球模式:增加一个球,并处理球与球之间的碰撞(可选)。
  • 七段数码管显示分数:编写一个二进制到BCD码转换器,再连接数码管动态扫描驱动模块,专业地显示两位分数。

5. 设计报告撰写核心要点

一份好的设计报告不仅是代码的说明,更是你设计思路的体现。它应该包含:

  1. 设计目标与要求:清晰描述你要实现的功能和性能指标。
  2. 系统总体设计:用框图展示顶层模块划分和信号连接关系。
  3. 分模块详细设计:对每个模块(如消抖、状态机、运动、碰撞、显示)进行说明,包括端口定义功能描述状态转移图(如有)、关键代码片段及注释
  4. 仿真测试与分析:展示关键模块和顶层系统的仿真波形图,并解释波形如何验证了功能。
  5. 硬件测试与结果:描述使用的硬件平台、引脚分配表(Pin Assignment),并附上实际上板运行的照片或视频。
  6. 总结与心得体会:回顾整个项目过程,遇到了哪些问题,如何解决的,有什么收获。这部分最能体现你的个人思考。

从构思到实现一个完整的FPGA乒乓球游戏,你会经历从模块设计、代码编写、功能仿真、综合实现到上板调试的全过程。每一个环节的坑踩过去,你对数字系统设计的理解就会深一层。这个项目麻雀虽小,五脏俱全,它带给你的绝不仅仅是一个会动的游戏,更是解决复杂工程问题的思维方式和动手能力。当你看到自己设计的游戏在开发板上流畅运行时,那种硬件被你的代码赋予生命的喜悦,是软件编程难以比拟的。动手去做吧,从第一个分频器开始。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询