FPGA实战:VGA贪吃蛇小游戏完整项目复盘 不少刚接触 FPGA 的朋友跑完流水灯和串口例程之后都会卡在一个问题上接下来做什么我在自己的学习路线里挑了一个看起来特别“不正经”的目标——在 FPGA 上做一个能玩的 VGA 贪吃蛇小游戏。真做下来才发现这个项目把数字电路里最核心的时钟分频、计数器、状态机、同步、存储、接口时序全部串起来了做完一遍再看别的项目很多思路会自动打通。这篇文章就是我那次 FPGA 小游戏项目的完整复盘包括整体架构、每段关键 Verilog 代码的意图、仿真怎么设计、上板调试踩了哪些坑希望能给正在找 FPGA 项目实战方向的朋友一个具体参考。1. 为什么拿小游戏当FPGA项目实战主线1.1 游戏背后藏着一整套“数字电路全家桶”先聊点实在的。大多数开发板例程是割裂的流水灯教你always块和计数器UART 回环教你接口时序按键实验教你消抖。但真实工程很少单独考某一个知识点而是要求你把它们组成一个能跑起来的系统。小游戏恰好就是一个完美的“系统集成场景”。拿最简单的 VGA 贪吃蛇来说它至少需要这些硬件电路像素时钟生成640x48060Hz 需要约 25.175MHz 的时钟。计数器和比较器产生 VGA 行同步、场同步信号以及像素坐标。状态机管理游戏的待机、运行、暂停、结束状态。存储器保存蛇身每一节的坐标数据。伪随机数发生器让食物随机出现在游戏区域。按键消抖和边沿检测把机械按键变成稳定的单脉冲信号。跨时钟域处理按键和游戏逻辑、VGA 扫描时钟之间需要同步。这些模块单独看都不难但把它们拼在一起你才会真正理解“时序”和“状态”这两个词在硬件里意味着什么。软件里你写一个if (x 640)代表的是判断硬件里同样的代码代表一堆比较器在跑组合逻辑电平稳定需要时间信号传递有先后。这种思维转变只有做整机项目才能体会到。1.2 开发板、VGA接口与工具链的选择我用的是一块国产 Artix-7 开发板主时钟 100MHz板载 VGA 接口和四个独立按键。其实 Cyclone IV、Ego1 或者任何带 VGA 输出的板子都可以不必为了做小游戏专门去买带 HDMI 的高端板。我特别建议入门阶段优先用VGA 而不是 HDMI做显示输出原因很直白VGA 的时序是公开标准行同步、场同步、RGB 三路信号都是低压逻辑示波器一看就知道对不对HDMI 涉及 TMDS 编码、高速差分对、电气特性调试难度完全是另一个量级。先用 VGA 把原理搞明白以后想升级再研究 HDMI 不迟。工具链我用的 Vivado 2021.2语言选 Verilog。对这种不到一千行的项目Verilog 足够而且网上参考代码多遇到问题容易搜到答案。如果你的开发板是 Altera 家的用 Quartus 也一样核心代码基本可以平移。1.3 项目技术指标我给自己定的目标是这样分辨率 640x48060HzVGA 输出。游戏区域覆盖整个屏幕蛇身每格 16x16 像素也就是 40x30 的网格。蛇默认长度 4 格每 250ms 自动前进一格。四个按键控制上、下、左、右支持暂停与重启。食物随机出现吃到后蛇长加一。撞墙、撞到自己都算游戏结束。整个 RTL 代码加上 Testbench 大概八百多行属于“周末可以动手假期能做完”的体量。下面从显示驱动开始按模块逐个拆。2. VGA显示驱动把像素放到正确位置2.1 VGA时序记住800和525这两个数字VGA 640x48060Hz 的时序本质上是一个不停循环的坐标计数器。屏幕上一行有 640 个可见像素但一行真正扫描的总周期是800个像素时钟多出来的 160 个周期用于右边界、行同步脉冲和左边界。同理垂直方向一帧有 480 行可见但总行数是525行多出来的 45 行给了场同步和上下边界。为什么要留这些多余周期因为老式 CRT 显示器扫描电子束从右边界回到左边界、从底边回到顶边需要时间。今天虽然是 LCD 显示器但 VGA 标准沿用下来我们照样要模拟这套时序。标准的行时序数字是这样的像素时钟为单位区域周期数可见区域640右边界16行同步脉冲96左边界48一行总计800垂直方向以行为单位可见 480 行后沿 33 行场同步 2 行前沿 10 行总计 525 行。同步脉冲都是低电平有效也就是扫描到那段时间里信号被拉低。用一个 10 位计数器就能生成 VGA 的扫描坐标reg [9:0] hcount; reg [9:0] vcount; always (posedge pix_clk or negedge rst_n) begin if (!rst_n) begin hcount 10d0; vcount 10d0; end else begin if (hcount 10d799) begin hcount 10d0; if (vcount 10d524) vcount 10d0; else vcount vcount 1; end else begin hcount hcount 1; end end end assign hsync (hcount 10d656 hcount 10d752) ? 1b0 : 1b1; assign vsync (vcount 10d490 vcount 10d492) ? 1b0 : 1b1;我把 hsync 在 656 到 751 这段拉低正好是 96 个周期vsync 在 490 到 491 拉低正好 2 行。这样显示器就能正确锁定帧率和行频。2.2 从计数器到可见区域的像素点亮逻辑有了 hcount 和 vcount判定“当前像素是否是有效显示区域”很简单wire active (hcount 10d640) (vcount 10d480);在 active 为高时hcount 就是当前像素的 X 坐标vcount 就是 Y 坐标。接下来要画什么颜色完全取决于这个坐标落入哪个游戏元素。比如蛇头当前位于网格坐标 (20, 15)映射到像素坐标就是 X 从 2016 到 201615Y 从 1516 到 151615。只要当前扫描像素落在这个矩形内RGB 输出绿色落在食物矩形内输出红色否则输出黑色。wire on_snake (hcount snake_head_px_x hcount snake_head_px_x 16) (vcount snake_head_px_y vcount snake_head_px_y 16); wire on_food (hcount food_px_x hcount food_px_x 16) (vcount food_px_y vcount food_px_y 16); always (*) begin if (!active) pixel_data 12h000; else if (on_snake) pixel_data 12h0F0; else if (on_food) pixel_data 12hF00; else pixel_data 12h000; end这种“每个像素都主动赋值”的方式有一个好处即使游戏元素移动了位置被移走区域的像素也会自然恢复成背景色不会残留上一帧的图案。我见过有人用“先清屏再画图”的思路在 FPGA 上很容易造成闪烁和残影后面会详细说。2.3 时钟生成别用“反相分频”糊弄显示器VGA 像素时钟是 25.175MHz但大多数开发板只有 50MHz 或 100MHz 的晶振。一些入门教程会教你直接用寄存器分频reg pix_clk; always (posedge clk_100m) pix_clk ~pix_clk;这样做出来的时钟其实“抖动”很厉害占空比也未必是 50%而且它是通过逻辑翻转生成的不是专门的时钟资源到后级触发器时偏斜和抖动都会放大。低分辨率下也许能显示但长期稳定性差。我用的做法是在 Vivado 里调用Clocking Wizard生成一个真正的 MMCM/PLL 时钟输入 100MHz输出 25.175MHzPLL 会自动帮你做频率综合和相位对齐。在 Quartus 里对应的是 ALTPLL。如果开发板的晶振刚好是 25MHz直接用原时钟也行那就更省事了。另外提醒一点如果项目里同时有 100MHz 系统时钟和 25.175MHz 像素时钟游戏逻辑完全可以跑在 100MHz 上只有 VGA 扫描计数器必须用像素时钟。两个时钟域之间的数据交互要用同步器或跨时钟域握手来处理。贪吃蛇这种慢速游戏最简单的做法是只在 VGA 的非显示区更新游戏变量见后面的“帧同步更新”技巧。3. 游戏逻辑状态机贪吃蛇的“大脑”怎么设计3.1 游戏状态机三种主状态与一条控制主线游戏逻辑的核心是一个有限状态机。我定义四个状态IDLE等待开始。RUN正常游戏。PAUSE暂停画面保持。OVER撞墙或撞自己游戏结束。控制按下开始进入 RUN暂停键在 RUN 和 PAUSE 之间切换游戏结束时按重启回到 IDLE。这些状态控制着游戏 tick 是否推进、按键是否响应、碰撞检测是否执行。localparam STATE_IDLE 2d0; localparam STATE_RUN 2d1; localparam STATE_PAUSE 2d2; localparam STATE_OVER 2d3; reg [1:0] state; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) begin state STATE_IDLE; end else begin case (state) STATE_IDLE : if (btn_start_pulse) state STATE_RUN; STATE_RUN : if (collision) state STATE_OVER; else if (btn_pause_pulse) state STATE_PAUSE; STATE_PAUSE: if (btn_pause_pulse) state STATE_RUN; STATE_OVER : if (btn_restart_pulse) state STATE_IDLE; default : state STATE_IDLE; endcase end end关键点是状态切换只发生在单脉冲信号上不能直接用按键电平。这个习惯非常重要否则按住按键时状态机会连续跳变。按键消抖和单脉冲生成放在第 4 节我先讲游戏逻辑。3.2 移动节拍从100MHz时钟到“每250ms走一格”蛇的移动需要一个慢速节拍。在 100MHz 下250ms 是 2500 万个时钟周期用 32 位计数器来数localparam TICK_LIMIT 32d25_000_000; // 100MHz * 0.25s reg [31:0] tick_cnt; reg tick_pulse; always (posedge clk_100m) begin if (!rst_n) begin tick_cnt 0; tick_pulse 0; end else begin if (tick_cnt TICK_LIMIT - 1) begin tick_cnt 0; tick_pulse 1b1; end else begin tick_cnt tick_cnt 1; tick_pulse 1b0; end end endtick_pulse只在达到计数值的那一个周期拉高。所有的蛇身移动逻辑都检测这个脉冲保证蛇是一格一格地走而不是每时钟周期都跑。想加快游戏速度就减小TICK_LIMIT想做“吃到食物加速”把 TICK_LIMIT 改成可配置寄存器就行。这里有个重要的设计原则把“节拍生成”和“节拍执行”分开。节拍只是一个脉冲信号具体格子怎么走、蛇身怎么移放在独立的 always 块里。这样调试时可以用仿真把节拍时间缩短验证执行逻辑不必真的等 250ms。3.3 蛇身存储循环队列的读写逻辑蛇身的运动规律是头部前进一格尾部前进一格相当于删掉旧尾巴中间身体所有部分跟随。用软件写就是数组平移但在 FPGA 里给几十段蛇身做整体平移成本不小而且会占用大量寄存器。更优雅的做法是环形队列只更新头尾两个指针蛇身实际坐标存放在一段内存里。parameter MAX_SNAKE_LEN 128; reg [6:0] head_ptr; reg [6:0] tail_ptr; reg [6:0] snake_len; reg [9:0] snake_x_mem [0:MAX_SNAKE_LEN-1]; reg [9:0] snake_y_mem [0:MAX_SNAKE_LEN-1];假设蛇向右移动新头部坐标是(head_x 1, head_y)。每个 tick 做这样几件事// 保存新头 snake_x_mem[head_ptr] new_head_x; snake_y_mem[head_ptr] new_head_y; if (eat_flag) begin snake_len snake_len 1; // 尾巴不移动所以 tail_ptr 保持不变 end else begin tail_ptr tail_ptr 1; // 尾巴前进一格相当于删除 end head_ptr head_ptr 1;这个队列能容纳 128 个坐标点。当前蛇身的有效坐标就是tail_ptr到head_ptr之间按环形方式递增的所有条目。实际显示时逐个读取队列里的坐标并画 16x16 的方块即可。用环形队列还有个附带好处碰撞检测非常直接。只要遍历队列里除头部以外的所有坐标看有没有和头部重合就行。如果不用队列每次移动都做数组平移综合出的寄存器阵列和布线面积都会明显增加。3.4 碰撞检测与食物生成含LFSR伪随机碰撞检测有三种边界撞墙head_x 0 || head_x 40 || head_y 0 || head_y 30。撞自己头部网格坐标等于蛇身上任何一节的网格坐标。吃食物头部等于食物坐标此时置eat_flag。我实际给蛇身比较的坐标用“网格坐标”而不是像素坐标。每格固定 16x16 像素所以头部像素坐标除以 16 就得到网格坐标wire [5:0] head_grid_x head_px_x[9:4]; wire [5:0] head_grid_y head_px_y[9:4];向右移动 16 像素网格坐标正好加一用px 4比做除法节省大量资源。这是 FPGA 里常见的技巧只要网格是 2 的整数次幂除法就是移位一次都不用调除法器 IP。食物随机生成我用了一个 16 位 LFSR线性反馈移位寄存器reg [15:0] lfsr; always (posedge clk_100m) begin if (!rst_n) lfsr 16hACE1; else lfsr {lfsr[14:0], lfsr[15] ^ lfsr[13]}; // 一个本原多项式 end wire [5:0] food_x lfsr[5:0] % 40; // 0~39格 wire [5:0] food_y lfsr[11:6] % 30; // 0~29格LFSR 不是真正均匀的随机数但对游戏来说足够。唯一要注意的是LFSR 初值不能为 0否则永远出不来随机序列另外食物生成后要避免落在蛇身体上简单做法是先判断是否和蛇身重合重合就再加一个固定偏移。建议把 LFSR 的取值和蛇身坐标比较如果碰撞就跳过这次生成下一次 tick 再重新取。因为生成食物本来就是在吃食物那个时刻做一次频率很低多判断几次不影响性能。4. 按键输入别让机械抖动偷走你的操作4.1 按键为什么会“飘”机械抖动与亚稳态按键输入看着简单实际上最容易踩坑。机械按键按下和释放时金属触点会在一两毫秒内反复弹跳直接拿按键电平去做状态机或方向控制结果就是一次按键触发三四个动作方向还可能乱跳。另外还有亚稳态如果按键信号在读它的时钟沿附近发生变化触发器采样出来的值可能是不稳定的中间态出现在后续逻辑里就是莫名其妙的“飘”。处理方法是三级递进两级同步器去掉亚稳态 → 计数器消抖 → 边沿检测生成单脉冲。4.2 推荐按键处理代码我常用的模板是先把按键输入同步到系统时钟域reg key_sync0, key_sync1; always (posedge clk_100m) begin key_sync0 key_in; key_sync1 key_sync0; end然后做消抖。假设按键按下为低电平100MHz 时钟下 20ms 等于 200 万周期用 21 位计数器reg [20:0] debounce_cnt; reg key_filtered; always (posedge clk_100m or negedge rst_n) begin if (!rst_n) begin debounce_cnt 0; key_filtered 1b1; end else begin if (key_sync1 ! key_filtered) begin debounce_cnt 0; end else if (debounce_cnt 21d2_000_000) begin debounce_cnt debounce_cnt 1; if (debounce_cnt 21d2_000_000 - 1) key_filtered key_sync1; end end end这个逻辑的巧妙之处在于按键信号一变计数器立刻清零只有新电平稳定了 20mskey_filtered才更新。抖动的电平变化会被不断重置到不了计数上限自然被滤掉。最后是边沿检测。我们通常只需要“按下”这个动作不需要持续电平。把key_filtered打一拍再比较前后值reg key_filtered_d; always (posedge clk_100m) key_filtered_d key_filtered; // 按键按下是低电平所以下降沿表示按下 wire key_press ~key_filtered key_filtered_d;要注意按键极性。有些开发板按键按下是高电平有些是低电平务必看原理图。如果极性反了整个逻辑输出会完全错误而且很难通过看代码发现。4.3 方向锁存防止“回头撞死自己”蛇类游戏有一个经典的坑方向键的即时响应。如果当前方向是向右玩家快速按了“上”又按“左”理想情况是先向上走一格再向左但如果按键在同一个 tick 周期里被连续接受逻辑可能先把方向改成上下一秒又改成左结果蛇从左边的身体穿过去瞬间判定撞死。解决方法是引入方向待定锁存按键只写入pending_dir真正生效要等下一个 tick 执行时并且要检查新方向和当前方向是否相反reg [1:0] cur_dir; // 0上 1下 2左 3右 reg [1:0] pending_dir; wire [1:0] opposite_dir cur_dir ^ 2b10; // 上下互为反左右互为反 always (posedge clk_100m) begin if (key_press key_up) pending_dir 2b00; ... if (tick_pulse state STATE_RUN) begin if (pending_dir ! opposite_dir) cur_dir pending_dir; end endcur_dir ^ 2b10这个写法很省事00 与 10 互为反向01 与 11 互为反向。如果玩家按了相反方向就用异或检测出来忽略这次按键。实际操作中方向键还会遇到“连续变向”的问题比如从右改成上还没走一格又改成左。这时候同样会被pending_dir opposite_dir挡住因为当前方向还是右反向是左左键依旧被忽略。这需要玩家先向右走一格方向真正变成“上”之后再按左才会生效符合真实蛇类游戏的体验。5. 仿真验证与上板调试把看不见的逻辑“逼出水面”5.1 Testbench设计先仿真后上板FPGA 调试最大的难点是“看不见信号”。上板看现象往往只知道结果不对但不知道是哪一级逻辑算错了。所以 Testbench 写得越细上板越省心。我建了三个层次的仿真用例第一层仿真 VGA 控制器。用一段仿真代码跑 800*525 个像素时钟检查 hsync 低电平宽度是不是 96 个周期、vsync 低电平是不是 2 行、hcount/vcount 的翻转边界对不对。这些数值错了屏幕会黑屏或者滚动是最基础的检查。initial begin clk 0; forever #9.93 clk ~clk; // 25.175MHz 近似 end initial begin rst_n 0; #20 rst_n 1; // 跑一帧 repeat (2) begin (negedge vsync); $display(one frame done); end end第二层仿真游戏节拍和蛇移动。这个要注意真实 TICK_LIMIT 是 2500 万仿真跑太久。我会把TICK_LIMIT定义成localparam在 Testbench 里用defparam或者直接把参数改成小值比如 10 个时钟周期验证逻辑正确后再还原。这种“参数化节拍”的方法能让游戏逻辑仿真时间缩短几十万倍。第三层仿真完整场景。构造几个典型事件蛇向右移动三格、按方向键向上、吃到食物、撞墙、撞自己。每件事给一个阶段性的$display输出观察蛇身队列里坐标的变化是否符合预期。一个经常被忽略的细节是Testbench 里也要做按键抖动模拟。不要在 testbench 里给一个干净的跳变那不是真实按键我自己会在仿真里加一段 30ms 的交变电平验证消抖模块确实能滤掉抖动并只输出一个脉冲。5.2 闪烁、残影、花屏三个高频故障上板之后最常见的是三个现象我把根因和对策列出来。闪烁。大概率是游戏状态在 VGA 扫描的中途被修改。比如蛇的坐标在某一帧的一半位置突然跳到下一格显示器上半帧和下半帧画面不一致就会感到闪烁或撕裂。对策是把游戏逻辑更新锁到 VGA 的“非显示窗口”里通常是场同步结束后的前几行wire vga_vblank (vcount 480); // 垂直消隐区 wire update_lock vga_vblank; always (posedge clk_100m) begin if (update_lock tick_pulse) // 执行蛇移动、碰撞检测 end这样做虽然画面更新会延迟几个微秒但保证一帧内看到的是完整一致的画面。对于 60Hz 显示这个延迟完全不影响手感。残影或拖尾。根本原因是“只画新像素不清旧像素”。如果颜色输出是用if (on_snake) pixel_data green; else pencil_data 背景那旧位置自然会被背景覆盖不会有残影。但如果有人把显示逻辑写成了“检测到命中就拉高颜色否则保持上一个颜色”就会出现拖尾。你的颜色组合逻辑必须覆盖每个像素不在蛇身上、不在食物上就给背景色不要“默认保持”。花屏或画面斜切。多半是 VGA 同步信号极性反了或者像素时钟差得比较多。我遇到过把非活动区域也当成可显示区域把同步脉冲期间的颜色也输出结果屏幕出现斜向条纹。检查 hsync/vsync 极性、检查 active 信号是否只在 hcount640 且 vcount480 时有效基本能解决。5.3 用逻辑分析仪和“减速观察法”辅助调试如果现象依然难定位可以用两个手段。一是用 Vivado 自带的 ILA集成逻辑分析仪抓内部信号。设置触发器当collision拉高时抓取head_ptr、tail_ptr、head_grid_x、head_grid_y、cur_dir。这样碰撞的那一刻蛇身队列里到底是什么状态一目了然。ILA 不需要额外硬件原理图里例化一个即可调试效率提升非常大。二是“减速观察法”。把 TICK_LIMIT 调大让蛇几秒钟才走一格这样人眼能看清每一步的坐标变化配合板载 LED 把蛇头的网格坐标低几位显示出来就能判断方向更新是否正确。这个方法土但很有效我调试碰撞误判时就是靠它确认“蛇头明明没有撞到身体逻辑却认为撞了”后来发现问题出在环形队列的指针回绕计算上。最后提一个习惯问题每次修改 RTL 后先跑一遍原有仿真再上板。对比重复很多次消耗最多时间的往往不是逻辑本身而是“改一处、上板看一眼、再改”的无效循环。仿真通过不一定上板成功但仿真失败上板几乎必挂别省这一步。我个人做完这个项目最大的体会是FPGA 小游戏不是一个“玩具”它是在用真实工程的手法做一套完整的数字系统。显示、输入、逻辑、状态、存储、跨时钟域哪一个环节没想清楚屏幕都会诚实地告诉你——而且从不撒谎。如果看完这篇你也想动手我的建议是先从 VGA 显示一个静止方块开始跑通了再往里加蛇身和按键这样每一步都有明确的验证目标。