1. 为什么选“以撒的结合”做数字逻辑大程1.1 这个选题最吸引我的一点先说实话我一开始根本没想到数字逻辑设计课程的大作业能跟游戏扯上关系。常见的选题无非是交通灯、电子钟、自动售货机顶多加个密码锁。不是说这些不好而是这些题目太“标准”了——标准到大家交上去的代码长得都差不多老师看一眼就知道谁抄谁。“以撒的结合”这个游戏本身有个非常适合硬件实现的特质地图是房间制。整个游戏被拆成一个个独立的房间玩家在房间之间移动通过清空房间里的怪物来获取道具、推进流程。这种离散化的结构天然就跟数字逻辑的“状态”概念对上了。换句话说这个选题不是硬把游戏往 FPGA 上塞而是这个游戏本来就是用状态机思维设计的。另一个原因是视觉效果。Verilog 写的课程设计最容易翻车的地方就是“跑通了但看不出效果”。如果你做个电子钟那基本就是数码管上跳数字但如果你做个“以撒的房间探索”你可以用 VGA 输出一个像模像样的像素风画面角色在地图上走动、开门、打怪视觉效果非常直观。答辩的时候往屏幕前一站评委老师一眼就能看懂你做了什么甚至还能勾起一波回忆。1.2 课程大程的现实约束当然选这个题之前得先想清楚几个硬约束平台我们实验室用的是 Altera 的 DE2-115 开发板核心芯片是 Cyclone IV EP4CE115。这决定了你写的 Verilog 风格要贴合 Altera 的时序模型也决定了你能用的资源上限。外设板载有 VGA 接口、PS/2 接口、16x2 LCD、LED、拨码开关、按键还有 50MHz 的板载晶振。这些东西是现成的不需要额外搭电路。时间整个大程周期大概 6 周期间还要上课、做实验。前两周我基本都在搭架构和写状态机第三周才真正开始写 VGA 显示逻辑最后两周在调 bug。如果你打算认真做至少要给自己留 4 周以上的时间。6 周听起来挺充裕但实际上大部分人都在最后十天拼命。我的建议是前两周哪怕啥都没写出来也要把状态图、模块划分、接口定义画清楚。这个前期工作做得好后面写代码几乎就是照着翻译。1.3 我能从这门大程里得到什么从学习角度说这个选题覆盖了数字逻辑课程里几乎所有核心知识点状态机设计有限状态机的状态编码、状态跳转、输出逻辑时序逻辑与组合逻辑的划分计数器、分频器、定时器同步与异步信号处理、亚稳态问题VGA 时序协议行同步、场同步、有效像素区间输入消抖与边沿检测模块化设计思想顶层模块、子模块、信号连接比做一个电子钟收获大得多因为它在单个工程里逼迫你把多个子系统整合到一起这跟课程实验里那种“一次只做一个模块”是完全不同的体验。硬件调试的痛苦也在这一个模块单独测是好的连在一起就出问题你根本没有断点可打只能靠$display和 SignalTap 一点一点找。2. 整体架构先画状态图再写代码2.1 游戏逻辑到底可以被简化成什么“以撒的结合”本身是个很复杂的游戏——有射击、有道具组合、有掉落物、有 BOSS AI。这些全都做不现实也没必要。课程大程的核心是体现数字逻辑设计的能力而不是复刻一个完整的游戏。所以我在动笔之前先把游戏逻辑简化成了这样几个基本要素房间地图整个游戏世界由若干个房间组成每个房间是一个有限的空间有 4 个可能的出口上下左右出口可能是门可能是墙。玩家角色在房间内移动移动到门的位置时进入相邻房间。敌人每个房间里预设若干怪物怪物在房间内按固定模式移动或静止。战斗玩家可以发射子弹子弹命中敌人后敌人血量减少清空所有敌人后解锁通往下个房间的门。道具/血量拾取道具可以恢复血量或增强能力这个是可选的加分项。这个抽象过程其实就是做硬件设计最核心的能力把复杂问题分解成状态、事件、数据通路然后在硬件层面复用同一套控制逻辑。2.2 模块划分我的顶层模块长这样模块名职责top顶层模块例化所有子模块连接信号clk_div将 50MHz 板载时钟分频成 VGA 像素时钟25MHz、游戏逻辑时钟50Hzgame_state_machine游戏主状态机管理当前房间、玩家位置、敌人生成、门锁状态player_ctrl玩家控制读取按键输入输出玩家坐标bullet_ctrl子弹管理发射、移动、碰撞检测enemy_ctrl敌人管理初始化、移动、被击中、死亡collision_check碰撞检测模块玩家与墙、子弹与敌人、玩家与子弹vga_ctrlVGA 时序控制器生成 hsync、vsync、像素坐标通过调色板输出 RGBsprite_rom角色和物体的像素数据存储只读存储器key_debounce按键消抖与边沿检测你可以看到数字逻辑设计里最重要的模块化思维在这里体现得淋漓尽致每个模块只干一件事模块与模块之间的通信通过明确的输入输出端口完成。这样在调试阶段可以单独验证某个模块不用一上来就面对整个工程。2.3 真正把游戏逻辑压缩到状态机里游戏主状态机是整个设计的灵魂。我在顶层用一个有限状态机来描述玩家在房间内的行为流转IDLE - IN_ROOM - FIGHTING - ROOM_CLEAR - TRANSITION - (进入下一房间)实际编码时用的是三段式状态机。为什么用三段式不用两段式或者一段式一段式把状态转移和输出逻辑全部写在一个always块里代码短但可读性差而且组合逻辑和时序逻辑混在一起很容易产生毛刺。对于这种多状态的游戏控制逻辑一段式几乎不可维护。两段式状态转移一段输出逻辑一段。可读性好了但组合逻辑输出容易产生竞争跨时钟域处理也麻烦。三段式状态转移、状态判断、输出赋值分开写代码清晰时序收敛容易是 FPGA 工程里的标准做法。给你看一眼核心结构不是完整代码但思路就是这个// 状态编码 localparam IDLE 3d0; localparam IN_ROOM 3d1; localparam FIGHTING 3d2; localparam ROOM_CLEAR 3d3; localparam TRANSITION 3d4; reg [2:0] current_state; reg [2:0] next_state; // 状态转移 always (posedge clk_game or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end // 状态判断 always (*) begin case (current_state) IDLE: next_state start_button ? IN_ROOM : IDLE; IN_ROOM: next_state enemy_count 0 ? FIGHTING : ROOM_CLEAR; FIGHTING: next_state enemy_count 0 ? ROOM_CLEAR : FIGHTING; ROOM_CLEAR: next_state door_touched ? TRANSITION : ROOM_CLEAR; TRANSITION: next_state transition_timer_done ? IN_ROOM : TRANSITION; default: next_state IDLE; endcase end这个状态机的关键在于它把整个游戏的宏观流转浓缩成了几个离散状态而玩家的像素级移动、子弹发射这些高频操作则放到另一个低频时钟域里去处理。你千万别把游戏逻辑全部塞进一个巨大无比的状态机里那会导致状态爆炸后面调试起来想死。3. VGA 显示最容易翻车的地方提前说3.1 显示方案的两种主流做法VGA 显示方案上我见到过两种做法方案 A随机存取存储器VRAM 像素坐标索引申请一块BRAM大小等于屏幕实际显示区域的像素数。每个像素点存一个颜色索引值比如 8 位代表调色板中的某种颜色。游戏逻辑每帧更新 VRAM 中的数据VGA 控制器每帧按行列地址顺序读出整数个像素然后映射到 RGB 输出。方案 B精灵Sprite 背景拼接把画面拆成“背景 若干前景元素”。背景是纯色或者简单的网格图案前景是玩家、敌人、子弹每个元素用一块小的只读存储器存储像素数据。VGA 控制器扫描到某个坐标时判断该坐标落在哪个精灵的区域内然后从对应的 ROM 里取出该像素的颜色。我最终选的是方案 B。原因是房间地图相对固定背景只需要一块小的 ROM 或者直接用组合逻辑生成玩家和敌人都是小尺寸精灵16x16、32x32用 ROM 存像素数据很自然;BRAM 资源有限VRAM 方案在 DE2-115 上也能做但帧刷新和游戏逻辑的读写冲突处理起来复杂得多。3.2 VGA 时序的“坑”详解如果你是第一次接触 VGA最容易犯的错误就是以为只要把 RGB 信号拉高、拉低就完事了。但 VGA 协议有三个信号必须严格满足时序行同步HSync、场同步VSync、以及有效显示区间Blank。以 640x480 60Hz 为例这是 DE2-115 上最省资源的模式参数数值像素时钟频率25.175 MHz每行总像素数800 个像素时钟行同步信号宽度96 个像素时钟行有效数据起始从第 144 个像素时钟开始每帧总行数525 行场同步信号宽度2 行场有效数据起始从第 35 行开始这里有个新手非常容易忽略的问题你不能直接把 50MHz 的板载时钟接到 VGA 控制器上必须分频到 25.175MHz 左右。如果直接用 50MHz 干活那画面右边的内容会跑到屏幕外面去——因为你一行的总时钟数从 800 变成了 1600显示器的时序检测逻辑就直接崩了。我当时没买有源晶振就用 PLL 分频。DE2-115 上有现成的 PLL IP 核可以调用输出 25MHz 的像素时钟就够用了实际上 25MHz 和 25.175MHz 的差异在肉眼观察下几乎看不出来。核心的 VGA 控制器代码骨架如下module vga_ctrl ( input wire clk_pixel, // 25MHz input wire rst_n, input wire [7:0] pixel_rgb, // 当前坐标下的颜色值 output reg [3:0] vga_r, output reg [4:0] vga_g, // DE2-115 绿色是5位 output reg [3:0] vga_b, output reg vga_hsync, output reg vga_vsync, output reg [9:0] vga_x, // 当前扫描像素坐标 output reg [9:0] vga_y // 当前扫描行坐标 ); reg [9:0] h_count; reg [9:0] v_count; always (posedge clk_pixel or negedge rst_n) begin if (!rst_n) begin h_count 0; v_count 0; end else begin if (h_count 799) begin h_count 0; if (v_count 524) v_count 0; else v_count v_count 1; end else begin h_count h_count 1; end end end always (*) begin vga_hsync (h_count 96) ? 1b1 : 1b0; vga_vsync (v_count 2) ? 1b1 : 1b0; end // 只有落在有效区间的坐标才允许画东西 wire active (h_count 144 h_count 784) (v_count 35 v_count 515); assign vga_x active ? (h_count - 144) : 10d0; assign vga_y active ? (v_count - 35) : 10d0; always (*) begin if (active) begin vga_r pixel_rgb[7:4]; vga_g pixel_rgb[3:0]; // 这里仅示意实际要做位宽匹配 vga_b 4b0000; end else begin vga_r 4b0000; vga_g 5b00000; vga_b 4b0000; end end endmodule注意 DE2-115 的 VGA 接口是 4 位红色、5 位绿色、4 位蓝色也就是说你只有 13 位颜色深度并没有 8 位 RGB 输出。这意味着你需要把内部处理的 8 位颜色索引映射到 4/5/4 的 VGA 物理接口上。比如黑色是 0000/00000/0000白色是 1111/11111/1111纯红是 1111/00000/0000。3.3 精灵绘制的核心判断逻辑当 VGA 控制器的扫描坐标(vga_x, vga_y)出来之后你要做的就是在每个像素位置判断“该画什么”。我的做法是always (*) begin pixel_color 8h00; // 默认黑色背景 // 画墙体 if (is_wall(vga_x, vga_y)) pixel_color BROWN; // 画门 if (is_door(vga_x, vga_y)) pixel_color DOOR_COLOR; // 画玩家精灵 if (vga_x player_x vga_x player_x 16 vga_y player_y vga_y player_y 16) begin pixel_color sprite_rom_player[ (vga_y - player_y) * 16 (vga_x - player_x) ]; end // 画敌人精灵 // 画子弹 // ... end优先级顺序很重要必须先判断背景再判断道具最后判断玩家、子弹、敌人这类动态元素。因为同一时刻一个坐标只能输出一个颜色后面的判断会覆盖前面的值。实际中子弹和玩家可能重叠我让子弹优先显示这样玩家被击中时比较容易看到弹道轨迹。这种“从前往后覆盖”的思想在硬件里叫“优先级编码”跟软件里画图层是一个道理但 Verilog 里你必须显式写出优先级顺序没有透明度和混合算法可用。4. 键盘输入处理消抖和边沿检测不能偷懒4.1 按键读取的基本方案DE2-115 板子上有 4 个按键KEY0~KEY3按下为低电平。我的方案是KEY0开始游戏 / 重新开始KEY1向左移动KEY2向右移动KEY3向上移动电梯不就只是向上走向下移动用 SW0 开关模拟不行太蠢了。后来我改用 PS/2 键盘的 WASD 方向键。其实我更推荐直接使用 PS/2 接口接一个标准键盘这样控制更自然也能展示你对 PS/2 协议的处理能力。但如果你时间紧板载按键也可以实现基本移动只是上下左右需要 4 个键而板载只有 4 个你总不能用拨码开关走路吧。我最后用了板载 KEY 实现上下左右把 KEY0 留作暂停/开始因为 PS/2 协议接收模块虽然不难但要处理帧格式、奇偶校验、键盘通码断码会额外多花 2-3 天。如果你想拿高分我会建议加 PS/2 支持毕竟“键盘控制角色移动”比“按按钮移动”演示效果好很多。4.2 消抖到底在消除什么东西机械按键按下和释放的瞬间金属触点会弹跳电平会在 0 和 1 之间快速抖动几次持续大约 5~20ms。如果你不做处理状态机会把这些抖动当成多次有效按键事件表现出来就是角色“走一步抖三下”。最简单可靠的消抖方法是计数法检测到按键变化后启动一个 10ms 左右的定时器等定时结束再去采一次电平如果稳定不变就认为这是有效按键输入。module key_debounce ( input wire clk, // 50MHz input wire key_in, // 原始按键输入低有效 output reg key_pulse // 每按一次输出一个高电平脉冲 ); reg [19:0] cnt; reg key_sync; reg key_prev; // 第一级同步器防止亚稳态传入逻辑 reg key_sync0; reg key_sync1; always (posedge clk) begin key_sync0 key_in; key_sync1 key_sync0; end always (posedge clk) begin if (key_sync1 ! key_sync) begin cnt 0; key_sync key_sync1; end else if (cnt 20d500000) begin key_pulse (key_sync 1b0) ? 1b1 : 1b0; end else begin cnt cnt 1; key_pulse 1b0; end end endmodule这个模块里有三个关键设计两级同步器key_sync0和key_sync1这是跨时钟域处理的基本功防止亚稳态。很多人写按键输入直接一步采在仿真里完全没问题一旦上板子就可能出现诡异的随机触发。变化沿检测 计数窗口不是持续计数然后输出而是检测到变化才重新计数这样能避免重复触发。输出为脉冲信号而不是电平信号。脉冲的好处是游戏状态机每按一次只处理一个事件松开不会额外触发。4.3 边沿检测的核心理解很多同学在搞不清“边沿检测”和“电平检测”的区别。看这条always (posedge clk) begin key_reg key_in; end wire key_edge key_in ~key_reg; // 检测上升沿这叫“打一拍再比较”——如果当前输入是高电平而上一拍是低电平说明信号刚才从低变高了这就是一个上升沿。在游戏里多用于处理“按下发射键的那一瞬间发射一颗子弹”而不是按住期间每帧都发射否则就会变成机枪连发。实际写的时候移动用“持续按住有效电平”射击用“单次脉冲边沿”这两者在设计时就要分开。如果你统一用边沿触发角色会走走停停如果统一用电平触发按一下会连续发射好几颗子弹。5. 碰撞检测从像素级到状态级的抽象5.1 为什么不能用像素级碰撞一开始我天真地想玩家和敌人的碰撞不就是检查两个精灵的像素范围是否重叠吗这确实是最直观的思路if ((player_x enemy_x player_x enemy_x ENEMY_WIDTH) (player_y enemy_y player_y enemy_y ENEMY_HEIGHT)) begin // collide end这在 Verilog 里是能写但问题在于游戏逻辑时钟频率只有 50Hz 的话玩家坐标每 20ms 才更新一次帧与帧之间的位移量很容易超过精灵本身的尺寸。比如你按了一下方向键20ms 内玩家坐标能跳 4 个像素——看起来没多少但如果敌人宽度也是 4 像素那就可能上一帧没碰到、下一帧直接穿模了。这在硬件游戏里非常常见。所以我的做法是把碰撞检测做成“区域级”而非“像素级”每个物体玩家、敌人、子弹、墙、门用一个抽象的矩形区域表示碰撞检测只比较两个矩形的“边界是否相交”而不是逐个像素比较对于高速移动的子弹额外做了“上一帧坐标 运动向量”的连续扫掠检测。具体到碰撞检测的 Verilog 实现我写了一个组合逻辑模块module collision_check ( input wire [9:0] x1, y1, input wire [3:0] w1, h1, input wire [9:0] x2, y2, input wire [3:0] w2, h2, output reg hit ); always (*) begin if (x1 x2 w2 x2 x1 w1 y1 y2 h2 y2 y1 h1) hit 1b1; else hit 1b0; end endmodule这四个比较条件缺一不可少了任何一个都会产生虚假碰撞。注意判定条件是严格小于如果在边缘上碰撞一般不计否则会出现玩家卡在墙角的抖动问题。5.2 玩家与墙、门、敌人的碰撞优先级在一个房间里墙、门、敌人、道具、玩家可能同时出现碰撞检测必须有一个优先级。我设计的优先级从高到低是子弹 vs 敌人优先检测因为这是游戏性的核心——打中敌人才算数子弹 vs 墙子弹碰到墙就消失但其实是在第 1 条之前检测的因为子弹先撞墙就没了不需要再判断是否打到敌人玩家 vs 敌人如果碰到敌人玩家扣血掉心同时做一次击退位移玩家 vs 墙玩家不能穿过墙体如果坐标超出边界就回退到上一拍的位置玩家 vs 门门开着且玩家走到门的位置触发房间切换状态。这里有个很经典的 Bug 案例玩家贴着墙移动时如果先检测玩家碰到敌人再检测碰墙可能出现“玩家被敌人击退、弹射进墙里”的情况。所以我在击退逻辑里额外判断了一次目标位置是否合法不合法就直接不执行击退。5.3 如何用碰撞结果驱动游戏状态变化光检测到碰撞还不够你还需要把“碰撞”翻译成“游戏事件”。我在游戏状态机里用几个标志位来接收碰撞结果wire bullet_hit_enemy; wire player_hit_enemy; wire player_at_door; wire player_out_of_bounds; always (posedge clk_game) begin if (bullet_hit_enemy) begin enemy_hp enemy_hp - 1; if (enemy_hp 0) enemy_dead 1b1; end if (player_hit_enemy) begin player_hp player_hp - 1; if (player_hp 0) game_over 1b1; end end这里我给每个敌人安排了 1~3 点血量打中一下掉 1 点血量为 0 时置enemy_dead信号。所有敌人死亡后状态机进入ROOM_CLEAR解锁门开关。这个“打完怪解锁门”的规则是整个游戏循环的核心反馈机制。6. 敌人 AI不用多高端但一定要让人觉得“有反应”6.1 最简敌人 AI巡逻 追踪 攻击很多同学会忽略敌人逻辑觉得“敌人能站着挨打就行”。其实敌人 AI 是游戏手感的分水岭。一个会追着玩家跑、会主动攻击的敌人和一群木桩敌人演出的观感天差地别。我实现的敌人 AI 思路是这样的每 0.2 秒更新一次移动方向默认状态原地徘徊或者沿房间内四条边循环移动追踪状态如果玩家进入敌人的视野范围我用的是简单的“X 或 Y 轴距离小于 100 像素”敌人切换为追踪模式朝玩家方向移动攻击状态如果玩家进入更近的距离比如 30 像素内敌人停止移动延迟 0.5 秒后对玩家所在方向发动一次“冲锋”。实现控制逻辑时我用了第二个状态机主状态机管“房间流转”敌人状态机管“单房间内战斗”两者互不干扰只在“房间清空”这个信号上做同步。6.2 敌人分布与房间数据结构为了支撑多个房间的地图我在 ROM 里预定义了一张房间地图// room_map: 每个房间存 5 个敌人0表示无敌人 // 房间编号0 是起始房间1-3 是普通房间4 是 BOSS 房间 reg [7:0] enemy_data [0:4][0:4]; initial begin // 房间02个敌人 enemy_data[0][0] 8h11; // 敌人类型1位置(8,4) enemy_data[0][1] 8h12; // 敌人类型1位置(8,8) // 房间13个敌人 enemy_data[1][0] 8h21; enemy_data[1][1] 8h23; enemy_data[1][2] 8h25; // ... end有人会问用 Veriloginitial块初始化 ROM 里的数据这不是仿真语法吗其实对于 BRAM 的初始化Altera 支持initial加$readmemh的方式上板也能生效只要你的初始化块是硬化的。但要注意如果你的初始化数据小于 ROM 容量没赋值的地址默认是未知x必须在initial里提前清零。6.3 敌人刷新时机的控制敌人不是永生的。玩家清空一个房间后进入新房间才需要重新加载敌人。这个“加载”动作发生在房间切换状态TRANSITION中always (posedge clk_game) begin if (current_state TRANSITION) begin // 重新初始化敌人数量和位置 enemy_count enemy_num[current_room]; enemy_hp INIT_HP; enemy_pos_x enemy_start_x; enemy_pos_y enemy_start_y; end end这里有个细节TRANSITION至少持续 2 秒我是用一个定时器实现的而不是直接一帧切完。否则“门碰到就切房间”会导致同一帧内玩家刚进入新房间碰到门又切回去形成无限穿梭。2 秒过渡时间既是一个动画掩护又给状态机提供了稳定窗口。7. 道具、血量与音效风险与收益并存的加分项7.1 道具系统的最小实现“以撒的结合”最大的魅力是道具组合。但在 Verilog 里做组合系统收益很低复杂度却高得离谱。我的做法是做了三个最简单的道具道具效果红心恢复 1 点血量子弹强化子弹发射间隔减半速度药水玩家的移动速度提高 1 倍道具的生成规则每个房间里随机生成 0~1 个道具用 LFSR线性反馈移位寄存器生成伪随机数决定道具类型和位置。LFSR 是数字逻辑设计里非常经典的一个电路一组寄存器在时钟驱动下按固定多项式移位生成看起来随机、其实完全确定的序列。它的好处是完全由组合逻辑和触发器实现不需要任何软件参与。我用的 16 位 LFSR 特征多项式是x^16 x^14 x^13 x^11 1每帧移一位取低 4 位作为随机数。因为道具生成时游戏逻辑在IN_ROOM状态所以同一盘游戏在不同房间的随机结果不同增加了重玩性。7.2 血量显示与外设映射血量我用 5 颗红心来展示。显示方案有两种VGA 画面左上角画 5 颗小红心好看但需要额外占用精灵 ROM 空间和绘制逻辑。开发板上的 17 位 LED 灯简单但观感差。16x2 LCD可以在 LCD 上打印字符串HP: ***但需要学 LCD 控制器时序多花一天。我选的是 LCD 打印文字。理由是LCD 控制器是课程实验中做过的基础模块可以直接复用而且 LCD 上同时还能显示“当前房间号”“金币数如果有”“游戏时间”比分红心更有信息量。LCD 显示其实不复杂核心就是把 ASCII 码写入 LCD 的 DDRAM 地址。我之前写过一套lcd_driver喂给它[7:0] char_index和[6:0] position信号就行。在这里不要贪多显示两行就够了Isaac Game Room:02 HP: ***** Score:01237.3 音效附赠的彩蛋DE2-115 上没有现成的音频解码芯片但有一个板载蜂鸣器其实是无源蜂鸣器。你可以通过输出方波信号控制频率实现简单的“嘀嘀”声。玩家射出一发子弹输出 1kHz 方波 20ms敌人被击中输出 500Hz 方波 40ms拾取道具输出 1.5kHz 方波 100ms音效说白了就是一个“频率可控的分频器 使能定时器”代码量不大加上去以后整体演示效果立竿见影。实际写的时候我用了一个 16 位的寄存器作为分频计数值不同音效对应不同的计数值。module beep_ctrl ( input wire clk_50m, input wire [15:0] freq_div, input wire beep_en, output reg beep_out ); reg [15:0] cnt; always (posedge clk_50m) begin if (beep_en) begin cnt cnt 1; if (cnt freq_div) begin cnt 0; beep_out ~beep_out; end end else begin cnt 0; beep_out 1b0; end end endmodule注意beep_out输出到物理引脚时要看蜂鸣器的驱动方式有的是直接方波驱动有的需要经过三极管这个查一下板卡原理图就好。8. 让人崩溃的三个 Bug 与排查过程8.1 按键漂移为什么按一下走了两步这是一个非常典型的时序 Bug我排查了很久。现象按下“向右移动”按键角色经常移动两个格子有时甚至连续移动三四次。排查链路怀疑消抖模块我把按键消抖延时从 10ms 加到 100ms现象依旧。说明不是机械抖动的问题。怀疑状态机我在按键脉冲信号上接了计数器用 SignalTap 抓波形发现按键脉冲确实出现了多次。这就不对了。发现问题核心按键脉冲是key_pulse由 50MHz 时钟产生而游戏逻辑用的是 50Hz 时钟。key_pulse的宽度是 20ns游戏逻辑时钟周期是 20ms。一个 20ns 的脉冲传入一个 20ms 的时钟域理论上绝大多数情况下游戏逻辑根本采不到这个脉冲。但关键问题出在按键保持期间消抖模块会重复输出多次key_pulse因为按键一直被按住每 10ms 重新触发一次计数结束就输出一个脉冲。这个 Bug 的本质是消抖模块输出的是“在按键按住时周期性重复的脉冲”而不是“按下那一下的单次脉冲”。我修改了消抖代码只在检测到按键从高电平到低电平的“下降沿”那一瞬间允许输出一次脉冲always (posedge clk) begin key_prev key_sync1; end wire key_press_event key_sync1 1b0 key_prev 1b1;按下沿触发 消抖计数一次按键只产生一个脉冲。这就解决了。8.2 VGA 画面整体左偏但有一部分黑屏现象VGA 画面能显示但明显偏左右侧黑屏画面显示不完整。这个 Bug 的根子在于 VGA 时序的行同步参数设错了。我一开始参考网上代码用的参数是h_front_porch 16 h_sync_time 96 h_back_porch 48看起来没啥问题。但我的像素时钟是 25MHz水平总周期应该是 800 个像素时钟。我算了一下行同步96前置沿16后沿48有效区域640合计96 16 48 640 800看起来对问题出在“有效区域从第几个像素开始”的计算。有的参考代码把有效区间定义成if (h_count 0 h_count 640) // 错误这就把行同步区域、前后沿都当成有效像素输出了。正确做法是if (h_count 144 h_count 784) // 正确其中144 96 48同步信号 后沿784 144 640。你把有效起点设成 0等于把前面 144 个像素的位置白白错位了画面自然偏左。这种问题在仿真里几乎看不出来因为 ModelSim 不关心显示器怎么解析只有上板子才暴露。这也是为什么 VGA 调试一定要接真显示器。8.3 敌人被打死后仍然显示现象子弹明明命中了敌人敌人血条也扣了但敌人还在画面上。排查中发现问题出在 VGA 的绘制逻辑和游戏逻辑两个模块对敌人坐标的更新不同步。敌人死了游戏状态机把enemy_dead信号置为高但 VGA 那边还在用enemy_x、enemy_y的旧值绘制。说白了就是VGA 模块在画精灵时没有检查敌人是否活着。我只在游戏状态机里更新敌人的存在标志却忘了把enemy_dead传给vga_ctrl的绘制逻辑。修改方案很简单绘制敌人前加条件if (enemy_alive vga_x enemy_x ...) begin pixel_color enemy_sprite[...]; end这个 Bug 带给我的教训比 Bug 本身更有价值模块之间的共享信号必须有清晰的“有效性”定义。enemy_x和enemy_y只管位置enemy_alive管存在性这两类信号要分开管理不能混在一起。9. 调试工具与方法没有 SignalTap 我可能交不了作业9.1 ModelSim 仿真调试的局限很多课设教程第一步就是让你写 Testbench 仿真但说实话纯仿真的覆盖度非常有限。因为它只能验证你给的激励序列下的行为而实际上板子上的输入是随机的、带有大量毛刺的。VGA 时序、按键消抖、PLL 上电启动这类问题仿真是很难覆盖到的。我带了个教训仿真通过不等于上板通过。我在 ModelSim 里跑通了游戏逻辑结果上板后画面直接花屏最后发现是 PLL 的复位时序不对——PLL 锁定需要时间而我一开始就发了复位信号导致内部寄存器状态乱掉。这种问题在仿真里如果用“理想时钟模型”根本看不到。9.2 SignalTap 让我看到了波形里的真相Quartus 自带的 SignalTap II Logic Analyzer 是 FPGA 调试的神器。它可以把内部信号实时的抓取下来在 PC 上显示波形相当于芯片里的示波器。我排查 VGA 画面偏左时就用 SignalTap 抓了h_count、vga_x、vga_hsync三个信号。波形一出来就明白了vga_x在h_count等于 144 之前就已经有非零值等于有效区间起点算错了。用法要点添加探针信号时必须选那些内部连线的 net 名不能选定义的寄存器名有的信号会被综合优化掉。采样时钟选像素时钟 25MHz采样深度 1K 就够了太多会占用 BRAM。抓取信号越多占用的资源越大不要一口气抓 32 路信号。9.3 用 DEBUG 状态开关快速定位除了信号抓取我在游戏状态机里加了一个“调试模式”assign debug_mode sw[9]; // SW9 拨上去进入调试模式 always (*) begin if (debug_mode) begin // 固定房间、强制生成敌人、关闭碰撞伤害 // 这样可以快速测试特定场景 end end调试模式配合板载 LCD可以实时显示当前房间号、玩家坐标、敌人数量这样你不用盯着 VGA 画面猜自己在哪里。这个“内部状态可视化”的思维不仅在课程设计里重要以后做工程调试也是核心技能。10. 我能给后来者的几条具体建议10.1 时间规划前松后紧是大忌我的建议是第 1 周画状态图、模块图、端口定义。别写代码就在纸上画。第 2 周写 VGA 控制器、按键消抖、玩家移动模块单独写、单独仿真、单独上板验证。第 3 周实现房间地图和门锁逻辑把玩家和房间串起来。第 4 周实现敌人 AI、战斗、碰撞检测。这是工作量最大的阶段。第 5 周做道具、音效、LCD 状态显示打磨细节。第 6 周整体联调、录制演示视频、写报告、准备答辩。如果第 3 周结束 VGA 还没亮后面就很危险了。VGA 是整个项目的视觉基础它出不来其他所有内容都白搭。10.2 代码风格的自我约束课程设计代码也许不用考虑可维护性但严谨风格一旦养成将会非常受益。我有几条自我要求每条always块的敏感列表要写全组合逻辑用(*)时序逻辑用(posedge clk)不要混用。状态机的三个块必须严格分离状态转移、状态判断、输出赋值各司其职。信号命名要有前缀player_、enemy_、room_、vga_、beep_看到名字就知道属于哪个模块。每个模块最关键注释写清楚特别是那些容易被忽略的时序细节比如“这里硬件上需要用 2 个周期完成数据同步否则会亚稳态”。10.3 一个你绝对应该提前准备的作业演示视频大程答辩时间通常只有 5~10 分钟评委不可能从头到尾看你试玩。我强烈建议你提前录一个 3 分钟的演示视频内容包括启动游戏并展示 VGA 画面展示房间地图和门的切换打一个房间的怪展示射击和敌人死亡展示道具拾取和血量变化按一下复位键重启游戏录完视频以后你甚至可以一边播放视频一边口头讲设计思路这样既省时间又能展示你做了完整的闭环。很多老师最后给的评语里会写“演示条理清晰”这往往不是因为项目做得多么完美而是因为视频讲得清楚。11. 回过头看这次大程真正教会我的东西说句心里话做完这个“以撒的结合”Verilog 大程之后我对数字逻辑设计的理解比整个学期上课还要深。上课的时候每个知识点都是孤立的状态机是状态机计数器是计数器VGA 是外设实验。但当你把这些东西揉进一个完整项目里你就不得不面对它们之间的耦合、时序冲突、资源竞争——这些才是硬件设计真正实际工作的样子。有一个体会我印象特别深在软件里你写错了一个变量名编译器会告诉你但在硬件里你写错一个时序约束整个板子就可能乱成一团而且不会给你任何报错信息。你需要自己用 SignalTap 去看波形用眼睛去“看”电路内部到底发生了什么。这种“靠波形说话的调试思维”我觉得是这门课带给我的最大财富。如果让我再选一次我还是会选这个题目。不是因为游戏有多好玩而是因为它逼着我从“四个 KEY 加四个 LED 的实验”跳到了“在一个屏幕上呈现一个可以玩的小世界”的层面。那份成就感是做一个交通灯替代不了的。后面如果你想在这个基础上继续扩展可以考虑几个方向加一个 PS/2 键盘处理模块替换板载按键、写一个可以切换的关卡地图编辑器、把敌人 AI 改成更复杂的寻路算法、或者用 SDRAM 做更大画面的帧缓冲。每一个方向都能让你的设计能力和对 FPGA 的理解再上一个台阶。