
简介贪吃蛇源码是基于Verilog硬件描述语言实现的经典小游戏工程面向FPGA学习者和数字电路设计爱好者目标是演示如何用硬件逻辑完成交互游戏。工程在VGA显示器上呈现完整游戏流程涵盖状态机控制、蛇身位置存储、键盘方向输入、像素刷新与时钟同步等核心模块适合用于理解Verilog语法和实际FPGA开发流程。资源包共196个文件大小约5.38MB以.v源码文件为主并包含Quartus工程配置qpf/qsf、编译下载文件sof/pof、RAM初始化文件mif、仿真报告、备份文件和说明文档可直接打开工程进行仿真、综合与上板验证。目前已吸引1168人学习下载。通过学习本套代码可以掌握用硬件描述语言实现游戏逻辑的方法还能了解VGA时序驱动、状态机设计、存储器使用及FPGA工具链的综合流程是一份兼顾趣味性与知识性的综合实践案例。 在FPGA开发板上把贪吃蛇跑起来是我见过最适合当“第二块跳板”的练手项目——太简单则无聊太难则劝退。写完一版能在VGA显示器上稳定运行、用四向按键操控的Verilog代码你会发现这个看起来只是“小游戏”的东西恰好把状态机、存储结构、伪随机数、视频时序、按键消抖这些硬件开发的核心问题挨个敲了一遍。和单片机版贪吃蛇完全是两回事C语言里蛇身就是一个数组移动就是改几个下标Verilog里没有“数组指针”这种抽象你得想清楚蛇身坐标存在哪里、每个时钟沿怎么更新、碰到墙和碰到自己分别由什么组合逻辑判断。如果你正在学Verilog语法但一直没做过综合项目或者手里有块带VGA接口的FPGA开发板不知道能做什么我建议直接用这个项目当入口。它不需要SDRAM控制器不需要操作系统也不涉及复杂协议但做完之后你对FPGA工程的认知是完整的从时钟分频、按键同步消抖到状态机设计、片上RAM使用再到调试手段全都会用一遍。1. 这个项目练的不是游戏是硬件设计的思维方式1.1 为什么不用单片机或上位机的思路来理解在软件里游戏循环是“读输入→更新数据→画帧”CPU逐个执行。硬件里没有“循环”只有“每个时钟沿都在做同样事情的并行电路”。所以贪吃蛇移植到FPGA真正的难点不是游戏规则本身而是规则怎么映射成“每个时钟沿做什么”一个定时器产生移动使能游戏状态机根据使能推进蛇身坐标用移位寄存器组保存碰撞检测变成若干个比较器的并联随机食物由LFSR产生。这些思路如果继续沿用软件写法比如用几百行C语言逻辑硬套综合出来一堆状态混乱的组合逻辑板子上去必坑。1.2 这个项目会逼你搞懂的五个知识点有限状态机游戏状态待开始/运行/结束怎么迁移复位和按键事件怎么触发迁移。存储结构选型坐标用什么寄存器或内存保存为什么寄存器堆适合这个场景。异步输入处理按键信号怎么识别怎么消抖怎么进入同一个时钟域的逻辑。视频时序VGA行场同步脉冲、有效区、消隐区怎么精确生成像素时钟如何与分辨率匹配。组合逻辑与时序逻辑的分工哪些判断必须在时序逻辑里锁存哪些比较可以放在组合逻辑实时算出来。2. 系统拆解40x30网格快照让逻辑和显示彻底分开2.1 能跑通的整体模块划分我的顶层结构是这样的top例化所有子模块统一时钟和复位。key_input按键同步消抖输出开始/方向脉冲。game_ctrl游戏核心状态机维护蛇身数组、食物坐标、分数。vga_ctrl产生水平和垂直同步信号以及行列计数。draw_ctrl接收行列计数换算格子坐标读取网格状态输出RGB。score_disp分数BCD转换后送到4位数码管动态扫描。我强烈建议在顶层下面用体积小、职责单一的模块去拆不要一个文件写几千行。原因很简单仿真的时候你可以单独给game_ctrl喂测试激励不用每次都跑完整VGA时钟出问题时也能很快定位到具体模块。2.2 为什么先把屏幕映射成40x30网格这是整个项目最值得想清楚的设计决策。如果不对画面做网格抽象直接对640x480个像素逐个判断组合逻辑会变得特别大而且蛇身位置和像素的换算关系在代码里完全是一团浆糊。640x480是307200个像素点如果用RGB565存一整帧需要614400字节内部Block RAM基本被吃掉。而把画面映射成40x30个格子每格16x16像素总共1200个格子每格用2bit编码背景/蛇身/食物/墙一共才2400bit分布式RAM就能放下。这个收益非常明显画面也不会太糙——16像素一格在普通显示器上有明显像素格但整体观感完全可以接受。更核心的是逻辑和显示解耦游戏状态只在移动使能到来时更新网格VGA则每帧60Hz反复扫描这个网格生成画面。两者互不干扰也不会因为某一帧游戏逻辑没算完导致画面闪烁。2.3 网格RAM的读写分工网格存储在二维寄存器数组里reg [1:0] grid [0:39][0:29];写入方是game_ctrl读取方是draw_ctrl。写的时候必须在游戏逻辑的时间节拍上// 清除旧尾 grid[old_tail_x][old_tail_y] 2d0; // 写入新头 grid[next_head_x][next_head_y] 2d1;读取则在VGA扫描的每个像素时钟用组合逻辑实时查wire [1:0] grid_type grid[grid_x][grid_y];这里要注意多维数组的索引如果都用变量综合工具会把它变成大量选择器。但因为只有1200个槽位、2bit位宽速度完全不是瓶颈。如果你比较在意资源也可以把二维展开成一维地址比如grid_addr grid_y * 40 grid_x用一个真正的RAM块更省寄存器但代码可读性会稍差。我自己的版本就是二维数组直接写的简单粗暴实测在XC6SLX9、Cyclone IV这种老器件上都没问题。边界一圈要填成墙。初始化时把所有格子设置成背景然后给第0行、第29行、第0列、第39列赋值为墙。蛇的活动空间实际上就是38x28碰撞检测也会因此简单很多。3. 游戏核心状态机移动、进食、碰撞判定的Verilog实现3.1 状态机定义和状态迁移游戏状态我定义为4个localparam IDLE 2d0, RUN 2d1, PAUSE 2d2, DEAD 2d3;上电进入IDLE显示初始蛇检测到开始按键进入RUNRUN下持续计时每隔MOVE_INTERVAL个时钟执行一步移动碰到墙或蛇身进入DEADDEAD下再按开始键回到IDLE并复位所有变量。这里有一个很多新手容易忽略的点PAUSE状态不是必须的但没有暂停功能调试时你会非常痛苦。我加暂停主要是为了上板后排问题游戏逻辑走到哪一步按一下暂停能看得清清楚楚。3.2 蛇身存储与移位更新蛇身我用了64组6bit坐标寄存器。6bit足够表示0~63网格宽度40、高度30完全够用reg [5:0] snake_x [0:63]; reg [5:0] snake_y [0:63]; reg [6:0] snake_len;为什么不用RAM因为每一帧所有节都要并行移位寄存器实现最简单、可以同一时钟沿完成。RAM要写多个字节反而绕。初始化时蛇身放在网格中央附近长度设为3头朝右未被占用的坐标全部初始化成6h3F这相当于一个“无效坐标”标记后面碰撞检测会用到。每次移动使能到来时从尾部到头部整体后移一位integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 初始化各节坐标 end else if (state RUN move_en) begin for (i snake_len - 1; i 0; i i - 1) begin snake_x[i] snake_x[i-1]; snake_y[i] snake_y[i-1]; end snake_x[0] next_head_x; snake_y[0] next_head_y; end end这里的for循环综合器会并行展开不是C语言里的顺序执行。新头坐标用组合逻辑根据当前方向实时算出来wire [5:0] next_head_x (dir RIGHT) ? snake_x[0] 1 : (dir LEFT) ? snake_x[0] - 1 : snake_x[0]; wire [5:0] next_head_y (dir DOWN) ? snake_y[0] 1 : (dir UP) ? snake_y[0] - 1 : snake_y[0];3.3 碰撞检测为什么判定尾部要特殊处理这是很多初版代码会写错的地方。很多人扫描全部蛇身一旦重合就判死亡。结果会出现蛇头追尾快撞上的一瞬间被判定死亡但按经典规则这个情况不算死因为尾巴在新头到达的那一拍恰好会移走。正确做法是扫描从旧头到倒数第二个节点也就是索引从0到snake_len-2尾部索引snake_len-1这一拍会挪走不用判reg hit; integer k; always (*) begin hit 0; if (next_head_x GRID_COLS-1 || next_head_x 0 || next_head_y GRID_ROWS-1 || next_head_y 0) begin hit 1; end else begin for (k 0; k 64; k k 1) begin if (k snake_len - 1 next_head_x snake_x[k] next_head_y snake_y[k]) hit 1; end end end写成固定64次循环是因为如果写成k snake_len - 1作为循环上界某些综合器对变量动态边界的展开并不友好。配合初始化时把所有无效坐标设成6h3F即使多扫几个也不会误判。3.4 食物生成LFSR伪随机数定位食物坐标由16bit LFSR生成。LFSR是多项式反馈移位寄存器综合面积很小种子不能为0否则会锁死在0序列reg [15:0] lfsr_reg 16hACE1; always (posedge clk) begin lfsr_reg[15:1] lfsr_reg[14:0]; lfsr_reg[0] lfsr_reg[15] ^ lfsr_reg[14] ^ lfsr_reg[12] ^ lfsr_reg[3]; end assign food_x lfsr_reg[5:0] % (GRID_COLS - 2) 1; assign food_y lfsr_reg[11:6] % (GRID_ROWS - 2) 1;取模后加1保证食物不会生成在墙的那一圈。但随机出来的食物位置可能落在蛇身上所以要加一层校验如果与蛇身任意一节重叠就再取下一个LFSR值最多尝试64次。为了防止极端情况死循环我加了一个兜底逻辑64次还不合法就按顺序扫描网格找一个空格子。这个兜底非常重要否则蛇很长时食物压在蛇身正中间游戏会直接卡死。方向输入的锁存也有讲究。当前方向向右时玩家按左键应该被忽略否则蛇头会直接掉头撞到第二节。经典版本有两种处理忽略反向输入或者允许反向并交给碰撞检测判死。我采用忽略玩家体验好很多always (posedge clk) begin if (key_left dir ! RIGHT) dir LEFT; if (key_right dir ! LEFT) dir RIGHT; if (key_up dir ! DOWN) dir UP; if (key_down dir ! UP) dir DOWN; end4. VGA显示通路把网格状态映射到屏幕像素4.1 VGA时序参数表640x48060Hz是最常见的VGA模式时序参数表如下参数数值像素时钟25.175 MHz实际用25MHz也能跑行周期800 px行同步起点656 px行同步宽度96 px行有效区640 px行前肩16 px行后肩48 px场周期525 lines场同步起点490 lines场同步宽度2 lines场有效区480 lines场前肩10 lines场后肩33 lines标准模式下行同步、场同步都是负极性。很多开发板原理图上已经做了反向所以输出极性要根据具体板子确认这是后面排坑的重点之一。vga_ctrl核心逻辑就是用两个计数器产生行列坐标always (posedge clk) begin if (hcount 799) begin hcount 0; vcount (vcount 524) ? 0 : vcount 1; end else begin hcount hcount 1; end end assign hsync (hcount 656 hcount 752) ? 1b0 : 1b1; assign vsync (vcount 490 vcount 492) ? 1b0 : 1b1;4.2 从像素坐标到网格坐标的实时换算hcount和vcount在有效区内递增到640和480网格坐标直接右移4位得到因为每格16x16像素wire active (hcount 640) (vcount 480); wire [5:0] grid_x hcount[9:4]; // 0~39 wire [5:0] grid_y vcount[9:4]; // 0~29 wire [1:0] t grid[grid_x][grid_y]; always (*) begin if (!active) begin rgb 12h000; end else begin case (t) 2d0: rgb 12h000; // 背景黑色 2d1: rgb 12h0F0; // 蛇身绿色 2d2: rgb 12hF00; // 食物红色 2d3: rgb 12h00F; // 边框蓝色 default: rgb 12h000; endcase end end消隐区必须输出黑色否则屏幕边缘会出现彩色噪点。4.3 读时序带来的画面偏移问题这里有一个非常隐蔽的坑。如果你用的是寄存器二维数组组合逻辑读取基本没延迟RGB直接输出没问题。但如果你把网格存进了真正的同步RAM块地址给到数据出来会滞后一个时钟周期RGB就必须同步打一拍再送出去否则画面会整体右移一个像素而且最右侧出现拖影或错位。我推荐的方案是小规模网格就用寄存器堆省掉这个麻烦如果以后项目变大、网格数暴涨再考虑换同步RAM并处理读延迟。这个取舍对初学者来说能少排很多莫名其妙的问题。4.4 分数显示数码管动态扫描分数用4位数码管显示吃的食物数用BCD编码。最简单的做法是每次吃完把分数寄存器加1然后转BCD。动态扫描就是20ms内快速轮流点亮4位数码管每位置亮5ms左右人眼看起来是同时亮的case (sel_cnt) 2d0: begin an_mask 4b1110; seg_data bcd_score[3:0]; end 2d1: begin an_mask 4b1101; seg_data bcd_score[7:4]; end 2d2: begin an_mask 4b1011; seg_data bcd_score[11:8]; end 2d3: begin an_mask 4b0111; seg_data bcd_score[15:12]; end endcase如果你的板子有VGA但没数码管也可以用UART把分数打到上位机不过数码管直观多了。5. 仿真验证和上板调试我踩过的四个坑5.1 仿真到底要验证哪些行为不要等整个工程写完再仿真应该是每个核心模块写完就单独验。我的testbench至少覆盖这几类场景上电复位后蛇头坐标、方向、状态正确。开始键后进入RUN定时器到点蛇头位置正常递增。把食物放在蛇头前方模拟吃到食物长度和分数都加1。按反向键验证输入被忽略。设置撞墙条件验证进入DEAD。仿真时MOVE_INTERVAL不能还是25_000_000这种真实值否则跑半天也看不到一步移动。我把这个参数放在顶层模块带parameter默认值仿真时直接传一个小值比如parameter MOVE_INTERVAL 25_000_000在测试平台里改成#(.MOVE_INTERVAL(5))几步就看清楚移动轨迹。5.2 坑一方向跳变和蛇反向自杀现象慢速走的时候按一下本来方向蛇直接掉头撞到第二节游戏莫名其妙结束。排查链路先在key_input模块里抓消抖后按键信号看是不是一个单周期脉冲。我遇到的情况是按键消抖计数阈值太小按一次被判定成好几次“左键”方向寄存器连续改最终和当前方向冲突。另一个原因就是前面说的没做反向判定。确认这两点都处理了问题基本消失。5.3 坑二VGA画面整体偏移或抖动现象显示器能出画面但整体右移或顶部错位有时候还闪。排查链路先看显示器的OSD信息确认输入分辨率是不是640x48060Hz然后用逻辑分析仪抓hsync和vsync对照时序表看同步脉冲起点。我踩过的坑是把行同步起点算成了656但实际计数器从0开始数应该是第656个时钟输出同步脉冲——这中间差了一个时钟周期。另一个坑是开发板的VGA接口已经反相了信号软件里再写负极性反而成了正极性。这两种情况的表现都是画面偏移或强制不显示拿示波器量一下输出引脚就很清楚。5.4 坑三仿真一切正常板子没反应现象Modelsim仿真头坐标变化都没问题下载到板子按开始没反应。排查链路先怀疑复位。外部按键复位是异步信号直接进状态机会产生亚稳态。我加了两级同步器问题少了一半。接着怀疑LFSR种子。如果种子是0随机序列全为0食物坐标固定且可能和蛇头重叠游戏逻辑在吃到第一口后直接卡死。最后用ILA核抓内部信号把state、move_en、dir拉出来看才发现是综合工具把某个时序逻辑优化成了常量因为那个寄存器根本没有被外部端口观察又是固定值。解决方法是把关键状态信号引到测试引脚或者直接用片上逻辑分析仪观测。5.5 坑四蛇变长后格子状态错乱现象吃到食物后蛇身长度确实加了但画面上的蛇身残影很多或者尾巴位置颜色不对。排查链路问题几乎都出在网格写入顺序上。如果先画新头再清旧尾当蛇长只有1或2时清尾操作可能会把刚写进去的新头状态覆盖成背景。我后来固定用这个顺序先清除旧尾格子→再移位蛇身数组→再写入新头格子→最后更新食物格子。顺序不乱网格状态就永远不会出现自相矛盾。这个项目做完最大的收获不是能跟人吹牛说“我用FPGA写了个游戏”而是你会开始习惯用数据流和时钟节拍的视角看硬件。以前看别人代码里到处是状态机觉得很玄自己把贪吃蛇的移动、进食、死亡转换理清之后再看UART、SPI这类协议的状态机整个思路就顺了。后续想进阶的话可以加障碍物、让蛇随长度加速、把按键换成PS/2键盘输入这些都是在现有框架上很小的增量。祝你的蛇跑起来不撞墙。本文还有配套的精品资源点击获取