FPGA贪吃蛇设计核心解析

AI助手已提取文章相关产品:

贪吃蛇源码(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),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值