贪吃蛇源码(Verilog)技术分析
在FPGA开发板上跑一个贪吃蛇游戏,听起来像极客的玩具,但背后其实是一套完整的数字系统设计实战。它不依赖操作系统、没有现成的图形库,所有逻辑从零构建——从VGA信号生成到按键响应,再到蛇身移动与碰撞检测,每一个环节都必须精确控制时序和资源。这类项目之所以常出现在高校课程设计或入门训练中,正是因为它把状态机、同步逻辑、存储管理、人机交互等核心概念揉进了一个看得见、玩得着的应用里。
我们今天就来拆解这样一个用Verilog实现的贪吃蛇系统,重点不是贴代码,而是讲清楚: 硬件如何“思考”?为什么这样设计?以及那些只在实践中才会踩的坑。
VGA驱动:让屏幕动起来的第一步
任何可视化系统的起点都是显示输出。在FPGA上最常见的方案是通过VGA接口输出640×480@60Hz的模拟信号。这看似简单,实则对时序要求极为严格——哪怕差几个像素周期,显示器也可能直接无信号。
要生成图像,就得模拟CRT时代的扫描过程:一行一行地“画”。每一行分为四个阶段——有效显示区、前肩、同步脉冲、后肩。垂直方向也类似,一帧由480行组成,总高度525行,中间穿插场同步时间。控制器靠两个计数器
h_count
和
v_count
实时追踪当前光栅位置。
下面这段代码就是典型的VGA时序发生器:
module vga_controller(
input clk,
input rst_n,
output reg hsync,
output reg vsync,
output reg [9:0] x,
output reg [9:0] y,
output reg de
);
parameter H_FRONT = 16;
parameter H_SYNC = 96;
parameter H_BACK = 48;
parameter H_ACT = 640;
parameter V_FRONT = 11;
parameter V_SYNC = 2;
parameter V_BACK = 31;
parameter V_ACT = 480;
localparam H_TOTAL = H_FRONT + H_SYNC + H_BACK + H_ACT; // 800
localparam V_TOTAL = V_FRONT + V_SYNC + V_BACK + V_ACT; // 525
reg [10:0] h_count = 0;
reg [10:0] v_count = 0;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
h_count <= 0;
v_count <= 0;
end else begin
h_count <= h_count + 1;
if (h_count >= H_TOTAL - 1) begin
h_count <= 0;
v_count <= (v_count >= V_TOTAL - 1) ? 0 : v_count + 1;
end
end
end
always @(posedge clk) begin
hsync <= !(h_count >= H_ACT && h_count < H_ACT + H_SYNC);
vsync <= !(v_count >= V_ACT && v_count < V_ACT + V_SYNC);
de <= (h_count < H_ACT) && (v_count < V_ACT);
x <= de ? h_count : 0;
y <= de ? v_count : 0;
end
endmodule
这个模块输出三个关键信息:水平/垂直同步信号、当前坐标
(x, y)
,以及
de
(Data Enable),表示当前是否处于可视区域。有了这些,后续就可以根据
(x,y)
决定某个点该不该亮、是什么颜色。
值得一提的是,虽然现在很多开发板已经转向HDMI,但VGA因其纯逻辑可实现性,仍是教学首选。而且它的“逐像素判断”机制,天然适合贪吃蛇这种基于网格的游戏渲染。
游戏逻辑核心:蛇是怎么“活”起来的?
如果说VGA是舞台,那
snake_core
就是主角的大脑。它要解决几个问题:
- 蛇头往哪走?
- 吃到食物了吗?
- 碰墙或者咬自己没?
- 怎么让蛇“动”而不是瞬间传送?
这些问题的答案全靠一个 有限状态机(FSM)+ 定时更新机制 来完成。
状态机驱动流程
游戏状态通常划分为三种:
-
IDLE
:等待开始
-
RUNNING
:正常运行
-
GAME_OVER
:结束
状态切换由外部事件触发,比如复位、碰撞、用户操作等。这里的状态机并不复杂,但它确保了行为的有序性和可预测性。
移动机制的关键:节拍控制
FPGA没有“延时函数”,不能写
delay(200ms)
。为了让蛇每200毫秒移动一格,我们需要一个独立的定时器模块,产生一个脉冲信号
move_en
。只有当这个信号拉高时,蛇才允许移动一步。
这就实现了“软实时”效果:无论按键多快,蛇的速度由系统节拍决定,避免失控。
蛇身怎么存?寄存器数组的取舍
由于FPGA没有动态内存,最直接的办法是用固定长度的寄存器数组存储每一段的身体坐标:
logic [9:0] body_x[MAX_LEN];
logic [9:0] body_y[MAX_LEN];
每次移动时,把整个数组向前搬一格,新蛇头插入第一位。这种做法直观,但也带来一个问题:资源消耗大。假设最大长度32,每个坐标10bit,一共就是 32×2×10 = 640 个寄存器。对于小型FPGA来说,压力不小。
更优的做法可以考虑使用片上RAM模拟环形缓冲,或者只记录头尾+运动轨迹链表结构(如每段继承前一段的方向)。但在教学场景下,寄存器数组更容易理解,调试也方便。
防止反向掉头的小细节
你有没有注意到,贪吃蛇不能立刻180度转身?这是为了避免自杀式操作。在代码中可以通过限制方向输入来实现:
if (dir_in != 2'bxx && dir_in != (~direction[1:0]))
direction <= dir_in;
意思是:如果新方向不是原方向的反方向,才接受更新。例如当前向上(3),就不允许立刻向下(1)。这是一个虽小却必要的用户体验优化。
关于
$random
的现实问题
很多示例代码里会看到:
food_x <= $random % (GRID_W-1) * 10;
这句话看着很爽,但在综合阶段会被工具报错——因为
$random
是不可综合的系统任务,只能用于仿真。
实际设计中需要用伪随机数生成器,比如基于LFSR(线性反馈移位寄存器)构造一个周期足够长的序列,再映射到坐标范围。或者干脆预设几组固定位置轮流出现,也能达到类似效果。
按键处理:别小看那一下“抖”
用户通过按键控制方向,但机械按键按下瞬间会产生几十毫秒的电平抖动。如果不处理,可能一次按压被识别成五六次方向变化,结果就是蛇乱转弯甚至自杀。
所以必须加 消抖电路 。常用方法是:检测到下降沿后,启动一个计数器,延迟约15–20ms后再读一次电平。如果仍然是低电平,则确认为有效按键。
下面是典型实现:
module debounce (
input clk,
input btn,
output reg valid_edge
);
reg [15:0] counter;
reg btn_sync1, btn_sync2;
wire btn_negedge;
always @(posedge clk) begin
btn_sync1 <= btn;
btn_sync2 <= btn_sync1;
end
assign btn_negedge = ~btn_sync1 & btn_sync2;
always @(posedge clk) begin
if (btn_negedge)
counter <= 0;
else if (counter < 16'd19999)
counter <= counter + 1;
else
counter <= counter;
end
always @(posedge clk) begin
if (counter == 16'd19999 && btn_sync1 == 0)
valid_edge <= 1;
else
valid_edg <= 0;
end
endmodule
这里用了两级同步寄存器防亚稳态,然后利用计数器延时采样。输出
valid_edge
是一个单周期脉冲,可用于边沿触发方向更新。
四个方向键需要四个实例化。也可以进一步封装成四合一模块,统一管理。
整体架构与协同工作
整个系统并不是孤立模块的堆砌,而是一个紧密协作的流水线:
[按键] → [消抖] → [方向编码] → [Snake Core]
↓
[Grid Renderer] ← [VGA Controller]
↓
[RGB 输出]
顶层模块负责协调:
- 接收消抖后的方向信号;
- 提供
move_en
给蛇核;
- 将
(x,y)
坐标送入渲染器;
- 渲染器查询当前位置是否属于蛇身或食物,决定RGB输出。
渲染逻辑一般放在组合逻辑中,例如:
assign is_snake = (pixel_x >= body_x[i] && pixel_x < body_x[i]+10 &&
pixel_y >= body_y[i] && pixel_y < body_y[i]+10);
每个像素都会遍历蛇身数组做比较,虽然听起来效率低,但由于分辨率不高(640×480)、蛇身最多三十余节,现代FPGA完全能胜任。
工程实践中的权衡与建议
如何实现“动画”?
本质上没有动画,只有“重绘”。VGA每帧刷新一次画面,只要蛇的位置变了,下一帧就会在新位置重新绘制。视觉暂留效应让我们觉得它是连续移动的。
这也是为什么移动频率不能太高——否则看起来像滑行;也不能太低——否则卡顿明显。经验值一般是100~300ms/步。
边界与自碰检测
边界检测很简单:判断蛇头是否超出
(0~640, 0~480)
范围即可。
自撞检测稍微耗资源:需要循环比对新蛇头是否与任意身体坐标重合。可以用展开循环(unroll loop)提高速度,但会增加面积。折中方案是只检查靠近头部的几节。
分数显示怎么做?
可以在屏幕一角叠加字符。方法有两种:
1. 预定义字模ROM,按坐标查表输出;
2. 使用现成IP核(如Xilinx的Text Display Controller)。
前者轻量,后者功能强但占用资源多。
可扩展性展望
一旦基础框架搭好,扩展功能非常自然:
- 加分机制:每吃一粒食物加10分;
- 加速模式:每增长一定长度提升移动频率;
- 障碍物地图:预设静态障碍,增加难度;
- 多关卡支持:通过SW拨码选择难度;
- 存档功能:配合EEPROM保存最高分。
甚至可以做成简易掌机原型,加上OLED或LCD屏,就成了真正可玩的产品级演示系统。
调试技巧:别等到烧板子才发现问题
FPGA开发最怕“黑箱运行”。推荐几个实用手段:
-
ILA抓信号
:把蛇头坐标、方向、
move_en
等关键信号接入Integrated Logic Analyzer,在线观测波形;
-
状态指示灯
:用LED显示当前游戏状态(绿=运行,红=结束);
-
仿真验证
:用Testbench模拟按键序列,验证蛇是否会正确转弯或死亡;
-
简化测试
:先关闭自撞检测,观察移动是否流畅;再逐步开启各项功能。
还有一个隐藏陷阱:初始值赋值。像
initial begin food_x = 320; end
这种写法在FPGA综合中可能无效。正确的做法是在复位分支中初始化:
if (!rst_n) begin
food_x <= 10'd320;
food_y <= 10'd240;
...
end
这样才能保证上电后状态确定。
结语
贪吃蛇不只是童年回忆,更是嵌入式系统设计的一块“试金石”。它逼迫开发者直面硬件的本质:并行、时序、资源约束。你在PC上几行Python就能写出的游戏,在FPGA上却要亲手搭建每一个环节——从同步信号到坐标映射,从状态流转到人机交互。
这个过程教会我们的,不仅是Verilog语法,更是一种思维方式: 如何把抽象算法转化为物理世界中的稳定行为 。而这,正是数字系统工程师的核心能力。
当你第一次看到那只由逻辑门构成的小蛇在屏幕上缓缓爬行时,那种成就感,远超任何Hello World。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
5541

被折叠的 条评论
为什么被折叠?



