
简介基于FPGA开发平台的按键控制弹球小游戏完整工程面向数字逻辑课程设计、EDA实验及FPGA入门学习者。工程通过按键操控弹球运动并输出VGA信号至显示器实时显示涉及按键消抖、时钟分频、VGA时序控制等典型模块可作为综合实训项目直接参考。压缩包共115个文件、3.79MB以Quartus工程文件为主含qpf/qsf/sof与大量编译中间文件cdb/hdb/ddb等同时提供VGA.v、Key_Set.v、Div.v等Verilog源码及readme说明便于对照工程结构理解各模块划分。已有1232人学习浏览资源完整度较高。下载后可获得可编译的工程目录和编译报告能帮助排查引脚分配、时序约束等问题适合需要动手实践VGA显示与游戏逻辑的爱好者快速上手。1. 按键弹球与 VGA 显示在 FPGA 上从零搭一个小游戏系统把玩具级弹球游戏跑在 FPGA 开发板上是一件看起来简单、做起来全是细节的事。标题里最容易被低估的不是“按键控制弹球”的玩法逻辑而是“输出 VGA 信号输送到显示器”这半句——你需要在一个像素时钟周期里完成同步信号、坐标映射和颜色判定。很多第一次做的开发者Verilog 里游戏逻辑写得顺顺利利一上板子就是黑屏或画面撕裂最后绕在时序上出不来。这篇笔记想帮你把整条链路打通从按键消抖到游戏状态更新再到 VGA 控制器的行场时序和像素输出每一步都给出可复现的代码和参数并把最容易翻车的几个点单独拎出来讲。适合手里有一块入门级 FPGA 开发板、想做一个完整小项目的学习者也适合想快速整理一遍 VGA 时序的从业者。2. 先拆架构再写代码四个模块与 640×480 的 VGA 时序参数弹球游戏听起来是个整体实际拆开后就是一个典型的 FPGA 小系统输入、处理、输出三部分清晰分开。我习惯把它拆成四个模块加一层顶层按键消抖负责把机械开关的毛刺整理成干净的单周期脉冲游戏逻辑维护球和挡板的坐标处理碰撞和得分VGA 时序控制器专职产生行场同步信号和当前像素坐标显示组合模块根据坐标判定当前像素属于球、挡板、边界还是背景。顶层模块只做一件事例化这些子模块把信号连起来再把像素时钟分配出去。模块拆分的边界为什么这样定直接决定后面调试的体验。游戏逻辑里不要出现任何 VGA 时序代码显示模块里也不要碰按键状态——各模块只通过几个信号交互出问题时用仿真就能定位到具体模块不用对着几百行代码找半天。这是我做这类练手项目时最看重的一点宁可多写一层接线也不要把所有逻辑塞进一个 always 块。各模块间的信号关系如下表照着这个接口去写代码后面接线基本不会乱模块职责关键输入关键输出按键消抖消除机械抖动生成单周期脉冲key_in、时钟key_pulse游戏逻辑球运动、碰撞、挡板移动frame_tick、左右脉冲ball_x/y、paddle_x/y、scoreVGA 时序生成同步信号与像素坐标pclk、rst_nhsync、vsync、pixel_x/y、blank显示组合按坐标映射颜色可见区坐标、球/挡板坐标vga_r/g/b顶层例化、接线、分频板载时钟、按键、引脚VGA 引脚信号2.1 VGA 信号不是 SPI同步脉冲、消隐期和像素时钟的关系VGA 接口输出的核心是三个同步信号加三路模拟颜色数字逻辑里我们只关心前两者行同步 HSYNC 和场同步 VSYNC。显示器靠这两路信号确定每一行、每一帧从哪里开始靠像素时钟逐点采样数据线上的 RGB 值。换句话说FPGA 端必须严格按照显示器期望的时序把数据推出去早一拍晚一拍都会出现画面偏移或黑屏。最常用的分辨率和刷新率组合是 640×480 60Hz它的完整时序参数非常规整特别适合 FPGA 这类按周期工作的设计。行方向一周期共 800 个像素时钟其中 640 个是可见像素其余 160 个是前肩、同步脉冲和后肩。场方向一帧共 525 行480 行可见其余 45 行属于消隐区域。像素时钟取 25MHz配合 800×525 的总周期帧率约 59.5Hz显示器都能正常锁定。参数行方向场方向可见区域640 像素480 行前肩1610同步脉冲962后肩4833总周期800525同步脉冲是低电平有效也就是说每个行周期里 hsync 要拉低 96 个像素时钟每个场周期里 vsync 要拉低 2 行。注意这里说的是“低有效”有些开发者习惯性写成高有效结果显示器直接无信号。2.2 像素时钟从哪里来PLL 优先计数器分频只做备选640×48060Hz 的标准像素时钟是 25.175MHz实际工程里几乎没有开发板自带这么偏的频率。我通常的做法是如果板上有 50MHz 晶振直接配置 PLL 核输出 25MHz 或 25.175MHz 供 VGA 使用。PLL 的好处是输出时钟相位稳定和输入时钟同源综合工具能正确处理跨时钟关系。有些入门级开发板没有提供可用的 PLL IP或者学习者想先跑通逻辑再优化时序这时可以退一步用计数器分频。50MHz 时钟每隔一个周期翻转一次电平就得到 25MHz。这个做法能运行但有几个隐患翻转得到的时钟在 FPGA 内部是普通寄存器输出没有走全局时钟网络时会有明显 skew综合工具也容易把它当作普通数据信号处理时序分析结果不准确。所以我只建议在验证功能阶段临时用正式布局布线前还是要切到 PLL。reg clk_25m; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) clk_25m 1b0; else clk_25m ~clk_25m; // 50MHz 二分频得到 25MHz end这段代码里clk_50m 是开发板晶振输入rst_n 是异步复位。每次 clk_50m 上升沿到来时 clk_25m 翻转一次得到频率减半的像素时钟。逻辑上没有难点但要注意这个时钟不能直接用于触发游戏逻辑模块的 always 块之外的其他时钟域逻辑否则容易出现时序不稳定。如果开发板支持 PLL我更推荐例化时钟 IP。配置的时候直接把输入频率填成板载晶振频率输出填 25MHz生成后顶层模块里像例化普通模块一样调用即可后面所有模块统一用这个输出时钟避免跨时钟域问题。3. 按键消抖与游戏逻辑球反弹、挡板碰撞的状态机实现按键弹球游戏里物理按键是最容易被忽略的“元件”。机械开关在按下和松开的瞬间触点会来回弹跳约 5 到 20 毫秒直接把按键信号送给 FPGA你会看到球在挡板还没动的时候就出现了跳变位置或者一次按键被识别成多次。VGA 显示是逐帧刷新的按键状态不干净游戏体验会非常明显。3.1 按键消抖10ms 延时采样的实现与参数选择常见的消抖方案有三种RC 硬件滤波、延时计数器采样、状态机消抖。FPGA 里最稳妥的是延时计数器采样按键输入电平变化后启动一个计数器统计电平稳定持续时间只有稳定超过约 10ms 才认为按键状态真正改变。10ms 这个值是有依据的——机械开关的抖动最坏情况一般不超过 20ms多数在 5~10ms 范围取 10ms 能在响应速度和可靠性之间取得平衡。module debounce ( input wire clk, // 25MHz 像素时钟 input wire rst_n, // 异步复位低有效 input wire key_in, // 原始按键电平 output reg key_clean, // 消抖后的稳定电平 output reg key_pulse // 按下瞬间的单周期脉冲 ); reg [19:0] cnt; reg key_sync; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 20d0; key_clean 1b1; // 按键默认高电平 key_sync 1b1; key_pulse 1b0; end else begin // 第一拍同步降低亚稳态概率 key_sync key_in; // 输入电平与当前稳定电平不同启动计数 if (key_sync ! key_clean) begin cnt cnt 1b1; // 25MHz 下计数到 499999 约 20ms留足余量 if (cnt 20d499_999) begin key_clean key_sync; // 电平翻转消抖完成 key_pulse 1b1; // 产生单周期脉冲 end end else begin cnt 20d0; key_pulse 1b0; end end end endmodule这段代码的核心是两个输出key_clean 是消抖后的电平信号key_pulse 是按下瞬间产生的单周期高电平。游戏逻辑里只应该使用 key_pulse原因是它只在按键状态稳定后给一拍不会出现长按导致的重复触发。如果把 key_clean 直接接到游戏逻辑按住按键不放时挡板会持续移动具体行为取决于你自己的设计需求——但一般弹球游戏的挡板是持续移动的那就应该用 key_clean 控制方向用 key_pulse 做菜单确认这类一次性动作。cnt 计数器的位宽和阈值需要匹配时钟频率。上例中 clk 是 25MHz周期 40ns计数到 499999 需要 20ms比理论 10ms 多留了一倍余量能适应手感较差的廉价按键。如果系统时钟换成 50MHz阈值要改成 999999否则实际延时只有 10ms 的一半可能在抖动未结束时就采到不稳定电平。3.2 球的位置更新坐标寄存器要有符号位弹球游戏的逻辑核心是两件事维护球的位置把运动方向用有符号数表示维护挡板的位置用按键改变它的坐标。位置坐标直接用无符号数也可以但碰撞反弹时如果只靠方向标志位控制加减代码会变得啰嗦——不如把速度定义成有符号数正数代表向右下负数代表向左上每次帧更新直接做加法。坐标寄存器的位宽有一个容易踩的坑ball_x 和 vx 相加时无符号数加有符号数中间结果必须能表达负数否则球越界后坐标会回绕成巨大正值。我一般把球的坐标定义成 11 位有符号寄存器而显示接口兼容的范围是 0~639这样无论加法中间出现什么值逻辑上都是安全的。3.3 帧同步更新游戏状态只在消隐期改变这里要强调一个影响画面稳定性的关键设计球的坐标更新必须只在帧消隐期发生一次而不是每像素时钟都执行。VGA 显示是一行行扫描的如果球坐标在扫描过程中被修改同一帧画面里球会被画在两个不同位置人眼看到的就是撕裂或鬼影。解决方法是利用 VGA 时序控制器产生一个 frame_tick 信号——它发生在垂直同步脉冲开始处此刻显示器正处于消隐期没有任何像素在传输游戏逻辑在这个时刻更新坐标最安全。module pong_game ( input wire clk, // 25MHz input wire rst_n, input wire frame_tick, // 垂直同步起始处的脉冲 input wire left_hold, // 左移按键电平 input wire right_hold, // 右移按键电平 output reg [10:0] ball_x, output reg [10:0] ball_y, output reg [9:0] paddle_x, output reg [9:0] paddle_y, output reg [10:0] score ); localparam [10:0] BALL_SIZE 11d8; localparam [9:0] PADDLE_W 10d64; localparam [9:0] PADDLE_H 10d8; localparam [10:0] X_MIN 11d4; localparam [10:0] X_MAX 11d628; // 640 - 8 - 4 localparam [10:0] Y_MIN 11d4; localparam [10:0] PADDLE_Y 11d452; // 距底 20 像素 reg signed [10:0] vx; reg signed [10:0] vy; always (posedge clk or negedge rst_n) begin if (!rst_n) begin ball_x 11d316; ball_y 11d240; paddle_x 10d288; paddle_y PADDLE_Y[9:0]; vx 11sd1; vy 11sd1; score 11d0; end else if (frame_tick) begin // 挡板移动每帧最多移动 4 像素 if (left_hold paddle_x 10d8) paddle_x paddle_x - 10d4; else if (right_hold paddle_x 10d568) // 640-64-8 paddle_x paddle_x 10d4; // 球位置累加方向由有符号速度决定 ball_x ball_x vx; ball_y ball_y vy; // 左右边界碰撞用旧坐标判断避免越界 if (ball_x X_MIN) vx 11sd1; else if (ball_x BALL_SIZE X_MAX) vx -11sd1; // 上边界碰撞 if (ball_y Y_MIN) vy 11sd1; // 挡板碰撞底边进入挡板区域且 x 范围重合 if (ball_y BALL_SIZE PADDLE_Y ball_y BALL_SIZE PADDLE_Y PADDLE_H ball_x BALL_SIZE paddle_x ball_x paddle_x PADDLE_W) begin vy -11sd1; score score 11d1; end // 球落出下边界重新开始 if (ball_y 11d480) begin ball_x 11d316; ball_y 11d240; vx 11sd1; vy 11sd1; end end end endmodule这段代码里要特别留意非阻塞赋值的语义。ball_x ball_x vx 使用的是上一帧的 ball_x 值同一个 always 块里后续的 if 判断也是基于上一帧坐标。以球速为 1 像素/帧为例球永远不会踏进边界内部因为判断和更新是交错但有序的先看旧位置是否越界决定是否改速度方向再累加新位置。这个顺序在低速下表现完美但一旦把速度提高到 2 像素/帧以上球就可能“跳过”边界或挡板缝出现穿墙这种让人抓狂的玄学问题。处理办法是提前预判下一帧位置把下一步的坐标算出来再做边界判定然后统一赋值。参数上PADDLE_Y 取 452 意味着挡板底边在 460距离屏幕底边 20 像素给玩家留出了反应空间。挡板移动步长 4 像素/帧在 60fps 下手感大约是每秒 240 像素的横移速度挡板宽 64 像素这个参数适合大多数人的反应能力。若觉得太灵敏把步长改成 2 或 3 再看手感。4. VGA 显示控制器行场计数器、同步脉冲与像素坐标映射VGA 控制器是整个项目里最需要耐心调试的部分。它的任务不是“画画面”而是精确地把画面数据按照显示器的扫描顺序送出去。显示器希望先收到一行同步脉冲再收到 640 个像素的颜色数据如此反复 480 行后收到一个场同步脉冲完成一帧。FPGA 端要做的事就是根据像素时钟驱动两个计数器一个数行内像素一个数行数在合适的位置拉低同步信号在可见区给出当前坐标对应的 RGB 值。4.1 行场计数器代码用 800 和 525 两个模值驱动行计数器 h_cnt 在 0 到 799 之间循环场计数器 v_cnt 在 0 到 524 之间循环。v_cnt 的递增条件是 h_cnt 刚好回到 0也就是说场周期是行周期的 525 倍两层计数嵌套构成了完整的帧扫描。module vga_ctrl ( input wire pclk, // 25MHz 像素时钟 input wire rst_n, output reg hsync, output reg vsync, output reg [9:0] h_cnt, output reg [9:0] v_cnt, output wire [9:0] pixel_x, output wire [9:0] pixel_y, output wire blank ); localparam H_TOTAL 800; localparam H_SYNC 96; localparam H_BACK 48; localparam V_TOTAL 525; localparam V_SYNC 2; localparam V_BACK 33; // 行计数器0~799 always (posedge pclk or negedge rst_n) begin if (!rst_n) h_cnt 10d0; else if (h_cnt H_TOTAL - 1) h_cnt 10d0; else h_cnt h_cnt 1b1; end // 场计数器每行结束时判断是否加一 always (posedge pclk or negedge rst_n) begin if (!rst_n) v_cnt 10d0; else if (h_cnt H_TOTAL - 1) begin if (v_cnt V_TOTAL - 1) v_cnt 10d0; else v_cnt v_cnt 1b1; end end // 同步脉冲低有效处于同步计数值内时拉低 always (posedge pclk or negedge rst_n) begin if (!rst_n) begin hsync 1b1; vsync 1b1; end else begin hsync ~(h_cnt H_SYNC); vsync ~(v_cnt V_SYNC); end end // 可见区域坐标减去同步和后肩得到屏幕标系坐标 assign pixel_x (h_cnt H_SYNC H_BACK) ? h_cnt - H_SYNC - H_BACK : 10d0; assign pixel_y (v_cnt V_SYNC V_BACK) ? v_cnt - V_SYNC - V_BACK : 10d0; // blank位于可见区域时置高 assign blank (h_cnt H_SYNC H_BACK) (h_cnt H_SYNC H_BACK 640) (v_cnt V_SYNC V_BACK) (v_cnt V_SYNC V_BACK 480); // 帧开始脉冲消隐期起点用于游戏逻辑更新 wire frame_tick (v_cnt 0) (h_cnt 0); endmodulepixel_x 和 pixel_y 的计算比较关键。h_cnt 从 0 开始前 96 个时钟是同步脉冲接着 48 个时钟是后肩所以第 144 个时钟才进入可见区此时屏幕坐标应该是 0。用 h_cnt 减去 144 得到的就是当前像素在 640 像素行内的横坐标。场方向同理v_cnt 从 0 开始同步 2 行加后肩 33 行第 35 行进入可见区v_cnt 减去 35 得到屏幕纵坐标。这个坐标系的起点在屏幕左上角向下向右为正方向。游戏逻辑里的球坐标也是这个坐标系两个模块对接时不需要做坐标变换。4.2 显示组合模块把坐标变成颜色有了 pixel_x 和 pixel_y显示模块的工作就是比较坐标范围。球是一个 8×8 的方块挡板是 64×8 的长条边界是顶部和左右两侧的 4 像素宽亮线。比较逻辑组合成三个标志位再按优先级输出颜色。球在最上方挡板其次边界再次最后是背景。module vga_display ( input wire clk, input wire rst_n, input wire blank, input wire [9:0] pixel_x, input wire [9:0] pixel_y, input wire [10:0] ball_x, input wire [10:0] ball_y, input wire [9:0] paddle_x, input wire [9:0] paddle_y, output reg [3:0] vga_r, output reg [3:0] vga_g, output reg [3:0] vga_b ); localparam [9:0] BALL_SIZE 10d8; localparam [9:0] PADDLE_W 10d64; localparam [9:0] PADDLE_H 10d8; localparam [9:0] WALL_SIZE 10d4; reg in_ball; reg in_paddle; reg in_wall; // 比较像素坐标与对象范围生成标志位 always (posedge clk or negedge rst_n) begin if (!rst_n) begin in_ball 1b0; in_paddle 1b0; in_wall 1b0; end else if (blank) begin in_ball 1b0; in_paddle 1b0; in_wall 1b0; end else begin in_ball (pixel_x ball_x[9:0]) (pixel_x ball_x[9:0] BALL_SIZE) (pixel_y ball_y[9:0]) (pixel_y ball_y[9:0] BALL_SIZE); in_paddle (pixel_x paddle_x) (pixel_x paddle_x PADDLE_W) (pixel_y paddle_y) (pixel_y paddle_y PADDLE_H); in_wall (pixel_x WALL_SIZE) || (pixel_x 640 - WALL_SIZE) || (pixel_y WALL_SIZE); end end // 输出颜色blank 期间一律输出黑色 always (posedge clk or negedge rst_n) begin if (!rst_n) begin vga_r 4h0; vga_g 4h0; vga_b 4h0; end else if (blank) begin vga_r 4h0; vga_g 4h0; vga_b 4h0; end else if (in_ball) begin vga_r 4hF; // 球是红色 vga_g 4h0; vga_b 4h0; end else if (in_paddle) begin vga_r 4h0; // 挡板是绿色 vga_g 4hF; vga_b 4h0; end else if (in_wall) begin vga_r 4hF; // 边界是白色 vga_g 4hF; vga_b 4hF; end else begin vga_r 4h2; // 背景是深蓝 vga_g 4h2; vga_b 4h5; end end endmodule注意比较条件里pixel_x ball_x[9:0]这里把 11 位的球坐标截成低 10 位使用。正常游戏过程中球的坐标不会超过 640直接截位不会出错但代码语义上不够严谨。更稳的做法是把 pixel_x 扩展为 11 位比较省去取位操作。显示模块里的另一层流水线是必要的比较逻辑从组合电路算出来到寄存器输出需要一拍画面整体会向右下方偏移一个像素肉眼看不出来但如果之后要做精细的碰撞判定心里要有这个偏移量。4.3 顶层模块与引脚约束最后一公里的接线顶层模块把前面三个模块串起来同时处理开发板的引脚约束。这部分代码没有太多技术含量但最容易出低级错误——信号名拼错、位宽不匹配、引脚约束文件漏了某个信号。我习惯把 VGA 的 RGB 信号和按键、时钟分成三组约束每组单独加注释这样报错时能快速定位是哪个环节。VGA 接口常见的电阻网络是 R、G、B 各 4 位对应 4 根信号线接到一个电阻网络后驱动模拟信号。如果你的开发板是 5 位或 3 位色深顶层信号位宽同步修改即可逻辑不需要动。引脚约束里还要确认 VGA 的 hsync、vsync 对应 FPGA 的具体管脚编号不同开发板差异很大要逐个核对板卡原理图这个环节不能凭经验猜。综合后的布局布线报告也要看一眼重点确认没有未连接的 VGA 引脚。很多新手在这里栽过跟头功能仿真一切正常下载到板子上就是黑屏结果查到是约束文件里少写了一个引脚。5. 常见问题排查黑屏、按键失灵、画面撕裂的根因与修复做到这里整套代码已经可以综合下载了但在显示器上真正亮起来之前几乎注定要经历几轮排查。我把最常见的问题按视频信号、输入信号和逻辑三条线分开讲。这些问题我每一类都实际遇到过有的排查了整整一个晚上最后发现原因简单得让人哭笑不得。5.1 黑屏且显示器提示“无信号”同步极性或时钟没跑现象上电后显示器直接进入省电模式提示无信号输入。开发板上的程序明明已经烧录进去了。原因最常见的是 HSYNC 和 VSYNC 极性写反。前面提过 VGA 的同步脉冲是低电平有效如果你在代码里把 hsync 在同步期拉高、其他时间拉低显示器无法识别到正常的同步时序就直接放弃锁定了。第二个高频原因是像素时钟没到达 VGA 控制器——如果顶层模块里分的时钟信号忘了连到 vga_ctrl 的 pclk综合工具也不会报错因为它默认这是一个未用信号直接优化掉了。解决先用示波器或逻辑分析仪看 hsync 和 vsync 是否有波形没有波形说明时钟或复位有问题。有波形但显示器无信号重点检查同步极性把 hsync 拉低的那个条件改成计数小于 96。再检查开发板上 VGA 接口的每个引脚是否都绑到了正确的 FPGA 管脚。5.2 按键偶尔无效或一次触发多次抖动处理不彻底现象快速连按时球和挡板的响应时灵时不灵慢慢按键时有时一次按键球却跳了两下。原因一是消抖计数器的阈值设得太短比如只有 1ms机械抖动还没结束电平就被判定为稳定按键状态多次翻转产生多个脉冲。二是按键信号穿过 FPGA 管脚后没有做同步处理直接进入游戏逻辑触发器采样到亚稳态出现不可预测的结果。解决消抖模块里先加两级寄存器同步再进计数器采样时钟用 25MHz。计数阈值不要低于 5ms我建议直接取 20ms既能完全覆盖抖动窗口又不会对游戏操作造成可感知的延迟。确认用的是 key_pulse 而不是 key_clean 做一次性动作触发的判定。5.3 画面撕裂或球的轨迹出现残影状态更新时机不对现象球移动速度快时屏幕上能看到球分成了上下两个半块或者挡板边缘有横条状闪烁。刷新率越低越明显。原因游戏逻辑里球的坐标在每一帧的任意时钟周期都可能更新而 VGA 控制器把一帧 480 行全部点完需要约 16ms。如果球的坐标刚更新完就被显示器采到整帧画面里球只出现在新位置如果扫描到一半位置变了后面几百行的球位置就和前面几百行不一致看起来就是撕裂。解决球的坐标更新必须限制在消隐期严格来说只有一个时机——垂直同步脉冲开始处。把 vga_ctrl 里生成的 frame_tick 接到游戏逻辑的使能端确保一帧内坐标只改变一次。这个做法成本最低效果也最彻底。5.4 球穿墙或直接穿过挡板速度超过 1 像素/帧后的边界漏洞现象球速从 1 提到 2 之后偶尔会从边界直接穿出去或者从挡板侧面穿过去但没反弹。用单步仿真能复现但每次穿的位置还不固定。原因代码里的碰撞判定用的是当前帧坐标如果上一帧球还在边界内侧这一帧坐标加了 2 像素球的左边缘跳到了边界外侧被判定为“已经在界内”或“在界外的另一侧”老逻辑里只有到达边界那一帧才会反转方向于是直接漏判。解决在更新坐标之前先根据当前坐标和速度推算下一帧位置用预判坐标做碰撞判定。边界反弹时还要做钳位更新后的坐标不能越过边界否则球会花一帧嵌在墙里显示上出现“半个球钻进墙”的视觉问题。速度越高这类判定越需要严谨不建议用加大边界厚度来掩盖问题那会让球的反弹位置和视觉位置越偏越多。5.5 RGB 颜色偏色或画面右移引脚位序和 blank 钳制现象球的颜色在屏幕上偏红或偏黄画面整体向右偏移了 8 像素左右右侧有竖条鬼影。原因颜色偏色一般是 RGB 各位和开发板电阻网络的连接顺序接反了。FPGA 输出的 vga_r[3:0] 连接到电阻网络时MSB 和 LSB 的顺序有严格要求接反了绿色数据可能串到红色通道颜色自然不对。画面偏移则多半是 blank 信号没参与颜色钳制——消隐期内 RGB 必须输出黑色否则前肩后肩区域残留上一帧的像素数据显示器把它当有效像素显示整幅画面就被“挤”向右了。解决比对板卡原理图确认 VGA 座子上的 R、G、B 引脚分别连到 FPGA 的哪几个管脚按位序重新约束。颜色输出逻辑里把 blank 作为最高优先级条件保证消隐区绝对是 0。6. 进阶玩法与验证用仿真看时序、加分数显示和难度曲线如果你已经把这套小游戏跑上板子并玩熟练了下一步值得做的事是加分数显示和难度递增但这之前先补上仿真验证。我第一次做这个项目时直接上板黑屏后花了很长时间才定位到时序问题。后来养成了习惯任何 VGA 相关模块先写 testbench 跑仿真确认波形正确再下载。testbench 里主要观察三点hsync 的周期是否正好 800 个 pixel_clkvsync 的周期是否正好 525 个 hsync以及 blank 出现的区间和 640×480 是否吻合。仿真时给像素时钟 25MHz模拟一帧约需要 16.6ms 的仿真时间跑完整帧会有点久。我一般只跑到第 600 行左右确认前 30 行的同步和消隐波形正确后就可以停。如果要在仿真里看到球移动需要给 frame_tick 足够的周期数让游戏逻辑更新几次坐标再检查 vga_display 输出的颜色是否跟着变化。这一层检查能筛掉八成以上逻辑错误。分数显示是最值得做的增强。VGA 上显示文本常见做法是字符点阵把 0~9 十个数字做成 8×16 的像素点阵存在 ROM 里扫描到分数区域时按行和列索引读出对应像素。实现不复杂但对 VGA 时序的理解要求更高正好检验学到的知识能不能举一反三。难度递增方面最简单的方案是每得 5 分把球速提高 1 像素/帧。但要先解决第 5.4 节提到的边界预判问题否则速度一提上去球就开始穿墙。另一个更容易实现的思路是增加挡板的移动灵敏度惩罚也就是分数越高每次按键挡板移动步长越小这样不用改球速也能提升难度。最后分享一个我自己的习惯每次改完代码先在仿真里跑一遍再上板验证不要直接拿板子试。VGA 这种同步敏感的接口一个参数错了就要花一轮烧录下载的时间仿真能把这些成本降到最低。希望这篇笔记能帮你少踩一些坑把弹球游戏顺利点亮在显示器上。本文还有配套的精品资源点击获取