看到“数字逻辑设计大程——以撒的结合(Verilog语言)”这个题目,我第一反应是:这个选题的人胆子不小。数字逻辑课的大作业,常规操作是数码管时钟、跑马灯、频率计、电子琴这类“标准答案”遍地都是的项目,而《以撒的结合》是什么概念?随机地图、飞行道具、弹幕、血量、道具组合,这些在软件里写都是一摊子事,要压到一块 FPGA 上用 Verilog 实现,还得保证上板能跑、时序收敛、不爆资源——这活儿确实有挑战性。
但话说回来,这类大作业恰恰是数字逻辑课程里最能拉开差距的项目。一个游戏项目,尤其是有动作逻辑的游戏,几乎把数字电路的核心知识点全串起来了:时序逻辑、状态机、计数器分频、模块化设计、跨时钟域处理、仿真与上板调试。做完这一整个流程,你对 Verilog 的理解深度会远超那些只写跑马灯的同学。这篇就来拆解一下,用 Verilog 在 FPGA 上实现《以撒的结合》这类游戏,整体架构怎么搭、核心模块怎么写、哪些坑必须提前避,以及我实测下来的调试经验。
1. 整体架构:先把游戏拆成能综合的模块
1.1 为什么数字逻辑课会有人选“以撒”当大程
先说下项目背景。数字逻辑设计课程的考核点,落在“时序逻辑电路设计”和“有限状态机”这两块上。多数大作业停留在按键控制 LED、数码管显示计数器这类基础应用,最多加个 VGA 显示字符。但游戏不一样,一个最简单的动作游戏,至少要包含:角色位置寄存器、按键输入逻辑、移动速度控制、子弹坐标管理、碰撞比较器、敌人状态机、显存或像素渲染逻辑、VGA 时序生成。
这些东西综合在一起,几乎就是一个完整的数字系统设计缩影。以撒本身的玩法结构其实很“模块友好”:角色在房间中移动、射击泪弹、敌人死亡掉道具、拾取道具改变属性、走到门的位置进入下一个房间。每一个玩法节点都能对应到一个独立的硬件模块,非常适合用 Verilog 的模块化思路去实现。这也是我最终选这个题目的原因——不是因为它有多酷,而是它的游戏结构天生就是一张数字电路模块图。
1.2 顶层模块划分与时钟规划
对于这种规模的项目,动手写代码前必须先定好顶层架构,否则代码写一半你就会发现模块之间信号又多又乱,根本连不到一起。我实际采用的模块划分如下:
top顶层模块:负责例化所有子模块、分配引脚、跨模块连线pll_clk时钟模块:用板载 50MHz 晶振生成 VGA 需要的 25MHz 像素时钟,以及游戏逻辑使用的时钟vga_controller:核心的 VGA 时序生成模块,输出 hsync、vsync、像素坐标、有效显示标志game_logic:角色移动、子弹发射、敌人 AI、碰撞检测、道具拾取等一系列游戏逻辑render:像素渲染模块,根据游戏逻辑中的对象位置和 VGA 坐标,决定当前像素的颜色keyboard_input:键盘扫描与按键映射,输出角色移动方向和射击指令
时钟规划上,我的方案是统一采用 25MHz 作为游戏逻辑的主时钟。为什么不用 50MHz?因为 VGA 显示 640x480@60Hz 需要 25MHz 像素时钟,如果游戏逻辑用自己的独立时钟,后面做同步会多一档跨时钟域处理,对新手来说是额外的复杂度。统一用 25MHz,所有逻辑都在同一时钟沿下工作,省去大量跨时钟域调试的麻烦。
经验心得:如果板子上有 PLL 硬核,用 PLL 分频生成 25MHz;如果是基础教学板没有 PLL,直接用计数器对 50MHz 二分频即可。分频器是数字逻辑最基础的考点,这里能现学现用。
2. 核心游戏逻辑的 Verilog 实现细节
2.1 角色移动、计数器分频与按键扫描
游戏逻辑的核心是角色控制。在《以撒》里,角色要能八方向移动,还要朝最近的敌人方向射击泪弹。映射到硬件上,就是两个位置寄存器pos_x和pos_y,一个方向寄存器facing_dir,以及一个按键状态寄存器key_state的维护。
移动的逻辑很直接:按 W/A/S/D 时改变位置寄存器。但这里有个容易被忽略的问题——直接每个时钟周期都加一,角色会以光速飞出去。25MHz 下每个像素周期移动一步,一秒就是两千五百万像素,这显然不行。解决办法是用计数器分频,把移动频率降下来。
// 基于计数器分频的移动节拍生成 reg [4:0] move_cnt; reg move_tick; always @(posedge clk or posedge rst) begin if (rst) begin move_cnt <= 0; move_tick <= 0; end else begin // 每 25 个周期产生一个 tick,即每 1us 移动一次 if (move_cnt == 5'd24) begin move_cnt <= 0; move_tick <= 1'b1; end else begin move_cnt <= move_cnt + 1'b1; move_tick <= 1'b0; end end end这个设计里,move_tick每个微秒拉高一个周期。角色每次移动 1 像素,一秒就是 1000 像素,配合调整初值可以设定理想的移动手感。不过直接这样写有个问题——如果按键一直按着,角色每微秒都动一格,但游戏里通常希望按住时连续移动。加一个方向键的持续判断即可:检测到 W 且move_tick为高时,pos_y减一;检测到 S 时加一,A/D 同理。
按键模块这里多说一句。如果用 PS/2 键盘,不能直接在游戏逻辑里读键盘信号,因为 PS/2 是串行数据,还有毛刺和时序要求。建议拆一个独立的键盘扫描模块,内部用移位寄存器接收串行数据,解析出扫描码,再映射成 W/A/S/D 对应的key_r、key_l、key_u、key_d信号。这一步做完,游戏逻辑里就不用关心键盘协议了,只管读这四个布尔值。
2.2 碰撞检测:比较器的艺术
碰撞检测是游戏项目里最容易写崩的模块,也是在 FPGA 上用 Verilog 写和软件差异最明显的地方。软件里可以写双循环遍历所有对象做相交判断,但硬件里每个比较器都是真实逻辑门消耗,循环展开后资源会很紧张。
我的做法是分两类对待。第一类是角色与房间墙壁/障碍物的碰撞,这一类对象是静态的,直接在移动时做坐标边界比较即可。举个例子,角色位置在(pos_x, pos_y),移到新位置前先判断新坐标是否落在墙壁矩形内,如果会撞上就把对应坐标限制在边界上,而不更新移动量。这种判断只需要几个比较器,用if语句实现,非常直观。
// 角色与边界碰撞:限制角色坐标范围 always @(posedge clk or posedge rst) begin if (rst) begin pos_x <= 10'd100; pos_y <= 10'd200; end else if (move_tick) begin if (key_u) begin if (pos_y > WALL_TOP) pos_y <= pos_y - 1'b1; end // 其余方向类似,只更新不越界的坐标 end end第二类碰撞是子弹与敌人、角色与敌人的动态碰撞。这里要注意框体尺寸。贴图和逻辑框不是一回事,碰撞检测用的矩形要比贴图小一圈,否则视觉上角色明明没碰到子弹却死了,体验很差。实际硬件里,子弹中心坐标和敌人中心坐标之间的距离判断,可以用两个减法器和两个比较器实现:两个坐标分别相减,取绝对值,各跟一个阈值比较,都低于阈值就判定碰撞。绝对值的硬件实现也简单,最高位为 1 就取反加一。
2.3 子弹与敌人:寄存器数组的“内存管理”
以撒的泪弹系统在软件里是一个数组,一个vector就能搞定。硬件里我用了寄存器数组:预先分配 16 个子弹槽位,每个槽位包含active标志、坐标x、坐标y、飞行方向dx、dy。每次按下发射键,就扫描数组找第一个active == 0的槽位,写入初始值并置位;每帧更新时,对所有active == 1的槽位做坐标位移。子弹超出房间边界后,active清零,槽位释放。
// 子弹数组更新示意 always @(posedge clk or posedge rst) begin for (int i = 0; i < 16; i = i + 1) begin if (bullet[i].active && bullet_tick) begin bullet[i].x <= bullet[i].x + bullet[i].dx; bullet[i].y <= bullet[i].y + bullet[i].dy; // 超出边界时释放槽位 if (bullet[i].x < 10 || bullet[i].x > 630 || bullet[i].y < 10 || bullet[i].y > 470) begin bullet[i].active <= 1'b0; end end end end敌人管理也是类似的思路,因为同时存在的敌人数量是有限的,分配 8 个敌人槽位就足够应付一个小房间。关键在于敌人 AI 不能用太复杂的算法——要知道这是数字逻辑,范围有限。敌人要么朝角色方向直线移动,要么做固定轨迹的往返移动,要么周期性朝角色当前位置发射子弹。这三种模式用有限状态机(IDLE -> MOVE -> ATTACK -> BACK)加几个计数器来实现就足够了,如果想加点区别,可以给不同敌人类型分配不同的移动速度和攻击间隔。
注意:在 Verilog 里定义这种数组结构,要确认你的综合工具支持 SystemVerilog 的
typedef struct语法。如果只能用传统 Verilog-2001,建议用多个位宽的数组平铺:reg active[15:0]、reg [9:0] bx[15:0]、reg [9:0] by[15:0],这样最稳妥,兼容所有工具链。
3. VGA 显示与输入模块:看得见才算数
3.1 VGA 控制器与像素渲染
游戏逻辑写得再好,显示不出来就等于零。VGA 控制器的核心是产生精确的时序信号。以 640x480@60Hz 为例,行周期 800 拍,其中有效显示 640 拍,还有同步脉冲、后沿、前沿;场周期 525 行,有效 480 行。具体参数网上很多,这里不贴完整表了。
实现方式就是两个计数器加一个状态判断。一个h_count计数器从 0 数到 799,一个v_count计数器在h_count每次回卷时加一,从 0 数到 524。当h_count落在 656 到 751 之间时,hsync置低;当v_count落在 490 到 491 之间时,vsync置低。有效区域是h_count < 640 && v_count < 480,这个区域里每个像素都要给出 RGB 值。
关键的一步是像素渲染。渲染模块拿到h_count和v_count,把它当成当前扫描到的屏幕坐标,然后跟游戏逻辑里的各个对象矩形做包含判断:先判断是否在角色矩形框内,是就给角色颜色;否则遍历敌人、子弹、墙壁、地板,逐层判断。这个逻辑用嵌套if-else实现,速度很快,因为这个判断是一根时钟周期内通过组合逻辑就能完成的。
// 像素渲染核心逻辑(简化示意) always @(*) begin if (!visible) begin pixel_rgb = 12'b0000_0000_0000; end else if (in_player) begin pixel_rgb = PLAYER_COLOR; end else if (in_enemy) begin pixel_rgb = ENEMY_COLOR; end else if (in_bullet) begin pixel_rgb = BULLET_COLOR; end else begin pixel_rgb = FLOOR_COLOR; // 默认地板色 end end这里有一个很重要的性能认知:游戏逻辑模块通常用计数器分频后的节拍信号驱动,但渲染模块必须每个像素时钟都要跑。如果让渲染逻辑也跟随游戏逻辑的节拍走,画面会变成一顿一顿的慢动作。所以架构上一定是游戏逻辑和渲染逻辑跑在同一个 25MHz 时钟下,用move_tick约束游戏状态的更新频率,而渲染组合逻辑始终在逐像素输出。
3.2 键盘输入与按键消抖的工程细节
使用 PS/2 键盘时,除了前面提到的移位寄存器接收串行数据,还需要处理按键重复的问题。键盘按住 W 时,PS/2 协议会以约 10ms 的间隔重复发送同一个扫描码,但在游戏里,按住 W 就应该一直移动,不需要等键盘重复。所以键盘模块的输出层要设计一个“锁定”逻辑:检测到某个键的按下码后输出 1,检测到该键的释放码后再输出 0,这样按住键期间持续产生有效信号。
如果不用键盘,只用板载按键,那消抖就是一个必修课。机械开关按下瞬间会产生约 10~20ms 的抖动,如果不处理,一按会触发好几次跳跃/射击。最稳的方案是在按键模块里做一个20ms的计数器,检测到按键电平变化后开始计数,计数期间忽略所有电平跳变,计数结束再采样稳定电平。这类消抖代码不复杂,但必须放在独立模块里,不要在游戏主逻辑中插入延迟计数的代码,否则后续状态机写起来会很别扭。
实操心得:上板调试时一定先测试 VGA 时序和键盘映射,再调游戏逻辑。我习惯在顶层里加一个“测试模式”的拨码开关,拨到测试档时像素渲染输出一个三色条,用来快速确认 VGA 是否正常;拨回运行档才走游戏逻辑。这个习惯帮我省了大量来回折腾的时间。
4. 三段式状态机把游戏流程“立起来”
4.1 主状态机:菜单、游戏、死亡与胜利
一个完整的以撒房间流程,至少要有这几个主状态:INIT(初始化)、PLAYING(游戏中)、DYING(死亡动画)、GAMEOVER(结算)、CLEAR(通关进入下一房间)。用三段式状态机来实现是非常标准且稳妥的做法。
三段式状态机指的是:第一段用时序逻辑做状态跳转state <= next_state,第二段用组合逻辑根据当前状态和输入计算next_state,第三段用时序逻辑根据当前状态输出控制信号。这样做的优势是状态跳转和输出逻辑分离,仿真好查,综合后的时序也更好收敛。
拿最简单的PLAYING -> DYING跳转来说:第二段组合逻辑里写if (player_hp == 0) next_state = DYING;,第三段时序逻辑里检测state == PLAYING时角色可以移动,进入DYING后停止接收移动指令、播放一段简单的倒计时或者闪烁效果。这里的关键是不要把控制信号写在状态跳转逻辑里,否则会出现一拍延时不一致的问题。
// 三段式状态机结构示意 // 第一段:状态寄存器 always @(posedge clk or posedge rst) begin if (rst) state <= INIT; else state <= next_state; end // 第二段:次态组合逻辑 always @(*) begin next_state = state; // 默认保持 case (state) INIT: if (start_en) next_state = PLAYING; PLAYING: if (player_hp == 0) next_state = DYING; else if (all_enemy_dead) next_state = CLEAR; DYING: if (death_cnt >= 5'd20) next_state = GAMEOVER; CLEAR: if (door_touched) next_state = INIT; ... endcase end // 第三段:输出逻辑 always @(posedge clk or posedge rst) begin if (rst) begin move_enable <= 1'b0; end else begin case (state) PLAYING: move_enable <= 1'b1; default: move_enable <= 1'b0; endcase end end初学状态机时经常犯的一个错误是漏写第二段的默认赋值next_state = state;。没有默认赋值,组合逻辑会生成锁存器,导致综合时一堆警告,时序行为完全不可预测。这种问题在仿真里很难发现,因为仿真模型和综合后的硬件锁存行为会有差异。
4.2 敌人 AI 与道具拾取的状态控制
敌人的行为状态机可以单独设计。我的一个房间敌人enemy_sm有三个状态:PATROL(巡逻)、ATTACK(攻击瞄准)、KO(死亡清空)。巡逻状态用计数器控制移动方向,每 N 个节拍切换一次;当角色进入一定范围后切到攻击状态,朝角色方向发射子弹;被打中后血量归零进入KO,槽位释放。
这里想提醒一个地方:所有敌人共享同一套状态机参数是可以的,但每个敌人的状态变量必须独立存储。也就是如果设计一个enemy_type状态机逻辑,不要用单个state寄存器给所有敌人共用,而要把state作为数组的一部分,每个敌人有自己的状态位。错误做法是写一个state控制所有敌人,那样所有敌人会同步动作,看起来像矩阵方阵一样滑稽。
道具拾取可以用一个独立的碰撞检测模块实现。核心思路是:道具槽位(大概 4 个)每个有pos_x、pos_y、type、active四个寄存器,每帧检测角色矩形与道具矩形的重合,重合时置active = 0并给角色加上对应的属性加成。以撒里的道具效果,硬件实现最方便的是三类:增加移速(修改移动节拍的计数器阈值)、增加射速(缩短发射周期计数器的阈值)、增加血量(递增hp寄存器)。其余花哨组合效果不建议做,只挑能在数字逻辑课上讲清楚的属性做即可。
5. 上板调试与常见问题实录
5.1 仿真与上板的典型差异
我实际调试中碰到的第一个坑是:ModelSim 仿真一切正常,一上板画面完全乱掉。为什么?因为仿真环境默认是理想时序,所有组合逻辑延时都为零;而实际 FPGA 上,组合逻辑路径有真实门延迟,如果组合逻辑链太长,下一拍采样时就可能采到中间值。游戏里最典型的位置就是“像素渲染的整条if-else判断链”。当角色坐标、子弹坐标、敌人坐标全都参与像素颜色的判断时,这条组合路径可能长达十几级 LUT,容易成为时序瓶颈。
解决办法是在渲染管线的最后一级加一个寄存器:先用组合逻辑算出pixel_rgb,再用一个寄存器在像素时钟沿打一拍输出到 VGA 引脚。这一个流水线寄存器能把组合路径切断,时序问题立刻缓解。代价是画面整体延后一个时钟周期,对于 VGA 输出完全感知不到。
第二个坑是:上板后角色移动特别卡,一帧一帧跳。这多半是因为把游戏逻辑的更新写在了 VGA 计数的非有效区内。VGA 一行只有 640 拍是有效的,剩下 160 拍是消隐区,如果你把移动判断放在h_count < 640的块里,那一行其实只执行了可用时间的一小部分,但次数不定导致节奏不均。正确做法是把游戏逻辑的更新独立出来,只跟move_tick挂钩,不要跟 VGA 的行场计数绑定。
5.2 常见报错、资源问题与工具链经验
资源爆掉也是一个高频问题。如果选的是 Cyclone IV 这类入门板,逻辑单元总共才几千个到一万多个。初始化就把每个子弹的坐标、方向、状态全部分配,再加上敌人、地图、VGA、键盘,逻辑单元很容易飙到 90% 以上。出现这种情况,优先优化的是寄存器位宽和数组深度。把坐标从 32 位降到 10 位(640 和 480 只需要 10 位二进制),把子弹数组从 32 槽降到 12 槽,把多余的状态位删掉,资源通常能降下来 30% 到 50%。
还有一个工具链相关的细节。用 Quartus 综合时,我遇到过Error (10170): Verilog HDL syntax error,查了半天发现是代码里用了integer在always块里声明变量,但那个块是组合逻辑。Quartus 对某些语法约束比仿真相当严格,必须写成reg [31:0] i;然后用for循环。另外,Quartus 17.1 版本如果 ModelSim 没有配置好,运行仿真会报failure to obtain a Verilog simulation license,这是因为 ModelSim-Altera 版本没有正确激活。解决办法是到 Quartus 工具菜单里重新指定 ModelSim 可执行文件路径,或者直接用 Icarus Verilog 做仿真——后者免费的,而且支持 SystemVerilog 语法,适合快速验证状态机。
| 常见问题 | 现象 | 排查方向 |
|---|---|---|
| VGA 无画面 | 屏幕黑屏或花屏 | 先用测试彩条确认时序,再检查 PLL 锁相是否稳定 |
| 角色移动卡顿 | 画面一帧一帧跳 | 检查游戏逻辑更新是否与move_tick挂钩,是否误绑到 VGA 计数 |
| 按键无反应 | 键盘输入不生效 | 确认键盘模块有没有输出释放码后的锁定逻辑,用 LED 指示按键状态 |
| 碰撞不灵敏 | 角色明明躲开却受伤 | 检查碰撞矩形是否比贴图大,适当缩小逻辑框 |
| 综合资源溢出 | 逻辑单元使用率超过 90% | 缩小数组深度,压缩寄存器位宽,优化状态机寄存器数量 |
调试顺序上,我建议遵循“由硬到软”的原则:先解决 VGA 彩条和键盘扫描,再测试单角色移动,接着加墙壁碰撞,然后加敌人和子弹,最后再串状态机和道具。每加一层就上板验证一次,别等全部写完再调,不然问题叠问题,根本无从下手。
这个项目做完之后,我对 Verilog 的体会是:它跟高级语言最大的区别不是语法,而是思维方式。写软件时你下意识会把数据放进数组里、把逻辑写进函数里,但硬件里每一个数组都是一组寄存器,每一个函数都是一段组合逻辑,多一个信号就多一份资源消耗。把一个游戏项目用 Verilog 完整做下来,真正锻炼的恰恰是这种“用硬件资源换行为复杂度”的权衡能力。如果你也在做这个题目,记住架构先行、模块化测试、逐层上板,这套流程走通了,不光是大程能拿高分,后续做任何数字系统设计都会顺畅很多。