基于51单片机与74HC595的8x8点阵贪吃蛇游戏设计与实现 1. 项目缘起与整体设计思路1.1 为什么选择8x8点阵做小游戏8x8 LED点阵在单片机圈子里算是个老演员了几乎每个玩过51单片机的人都拿它练过手。但大多数人只是用它滚动显示几个汉字或者做个心形动画就收工了真正把它做成一个完整可玩的小游戏的人并不多。PointGame这个项目的出发点很简单用最少的硬件资源做出一个真正能玩、有交互、有反馈的小游戏。为什么是8x8而不是更大的点阵这里有个很实际的考量。8x8点阵一共64个像素点听起来很少但对于一个贪吃蛇或者类似的小游戏来说刚好够用。屏幕太大反而会导致游戏节奏拖沓64个格子意味着蛇最多只能占十几个格子游戏节奏天然就快玩起来紧张感十足。而且8x8点阵的驱动电路简单用两片74HC595就能搞定列驱动行驱动直接用P0口整个系统不需要额外的锁存器或者译码器硬件成本压到最低。另一个关键原因是显示刷新。8x8点阵做动态扫描8行轮流点亮每行点亮时间大约1到2毫秒整体刷新率能轻松做到100Hz以上肉眼完全看不到闪烁。如果换成16x16的点阵扫描行数翻倍每行可用时间减半要么降低刷新率导致闪烁要么提高单片机主频增加功耗和复杂度。对于C51这种资源有限的平台来说8x8是最佳平衡点。这个项目适合谁如果你已经点亮过单个LED知道什么是限流电阻会用Keil建工程、会烧录程序那就可以直接上手。如果你还停留在点亮一个LED的阶段建议先把GPIO输出和延时函数搞明白再来。整个项目不需要任何额外的传感器或者复杂外设核心就是单片机、点阵、两片595和几个按键。1.2 硬件方案选型与背后逻辑整个系统的硬件架构可以拆成三块主控、显示驱动、输入。主控选的是STC89C52或者类似的51内核单片机。为什么不用STM32因为没必要。这个游戏的逻辑复杂度很低蛇的移动、食物的生成、碰撞检测这些用8位机跑绰绰有余。而且51单片机的GPIO操作非常直接没有HAL库那一层抽象写起来反而更贴近硬件对学习者来说更直观。STC89C52有8KB的Flash和512字节的RAM对于这个项目来说完全够用代码量大概在4到6KB左右RAM占用不到100字节。显示驱动部分行驱动直接用P0口。P0口是开漏输出驱动能力有限所以每行需要加一个PNP三极管或者直接用ULN2003这种达林顿管来增强驱动能力。列驱动用两片74HC595级联第一片的Q7接到第二片的DS这样两片595串起来正好16位对应8列数据加上一些控制位。74HC595的好处是只需要3根线数据、移位时钟、锁存时钟就能控制16个输出大大节省了IO口。这里有个细节值得展开说。很多人第一次用595驱动点阵的时候会直接把595的输出接到LED的阳极或者阴极然后发现亮度不够或者完全点不亮。问题出在595的驱动电流上。74HC595每个输出引脚的最大持续电流大概是35mA但整个芯片的总电流有限制如果8个输出同时点亮每个LED分到的电流可能只有几毫安亮度会明显下降。解决办法有两个一是用595控制三极管再驱动LED二是采用动态扫描每次只点亮一行这样同一时刻只有8个LED在亮每个LED可以分到足够的电流。PointGame采用的是第二种方案行扫描、列数据每行点亮时间约1.5ms8行扫完一轮约12ms刷新率约83Hz完全够用。输入部分用了4个独立按键接在P3口上。为什么是4个而不是更多因为游戏操作只需要四个方向。按键采用独立按键而不是矩阵键盘是因为独立按键的扫描代码更简单不需要处理行列交叉的复杂逻辑而且4个按键占用的IO口数量也可以接受。按键需要加上拉电阻51单片机的P3口内部有弱上拉但为了稳定起见外部再加10K的上拉电阻会更可靠。1.3 软件架构与状态机设计PointGame的软件架构采用了一个非常经典的前后台系统主循环负责游戏逻辑和按键扫描定时器中断负责显示刷新。这种架构的好处是显示刷新不受游戏逻辑执行时间的影响即使某一帧游戏逻辑计算稍微慢了一点显示也不会闪烁。定时器中断的频率设置为1kHz也就是每1ms进一次中断。中断服务函数里做两件事一是维护一个显示扫描计数器每进一次中断计数器加一根据计数器的值决定当前点亮哪一行二是维护一个游戏时钟计数器每进多少次中断触发一次游戏逻辑更新。游戏逻辑更新的频率不需要太高大概20Hz就够了也就是每50ms更新一次蛇的位置。这样蛇的移动速度适中玩家有足够的时间反应。状态机是这个项目的核心设计模式。整个游戏有四个状态待机状态、运行状态、暂停状态、结束状态。待机状态下点阵显示一个闪烁的标题或者动画运行状态下蛇在移动、食物在闪烁暂停状态下屏幕冻结显示暂停标志结束状态下显示得分和闪烁的Game Over。状态之间的切换由按键触发比如待机状态下按任意方向键进入运行状态运行状态下按暂停键进入暂停状态蛇撞墙或者撞到自己进入结束状态。用状态机的好处是逻辑清晰每个状态下的行为都是独立的不会出现在待机状态下蛇还在移动这种bug。而且状态机的扩展性很好以后想加一个设置难度的状态只需要增加一个状态和相应的切换条件就行不需要改动已有的代码逻辑。2. 核心细节解析与实操要点2.1 74HC595的驱动时序与代码实现74HC595的时序其实很简单但第一次接触的人容易被数据手册上的时序图吓到。说白了就三根线DS是数据线SHCP是移位时钟STCP是锁存时钟。数据在SHCP的上升沿被移入移位寄存器当16位数据全部移完后给STCP一个上升沿移位寄存器里的数据就被锁存到输出寄存器引脚电平同时更新。这里有个关键点数据是高位先移入还是低位先移入74HC595是高位先移入。也就是说如果你要发送一个字节0x80二进制10000000第一个时钟脉冲移入的是最高位1最后一个时钟脉冲移入的是最低位0。发送完8个时钟后这个字节就在移位寄存器里了。如果级联了两片595先发送的字节会最终停留在第二片595的输出寄存器里后发送的字节停留在第一片。这个顺序在写代码的时候一定要搞清楚否则点阵列数据会左右颠倒。代码实现上我习惯用位操作来写这样效率最高void HC595_SendByte(unsigned char dat) { unsigned char i; for(i 0; i 8; i) { DS (dat 0x80) ? 1 : 0; // 取最高位 dat 1; // 左移一位 SHCP 0; SHCP 1; // 上升沿移位 } } void HC595_SendWord(unsigned int dat) { HC595_SendByte(dat 8); // 先发高字节 HC595_SendByte(dat 0xFF); // 再发低字节 STCP 0; STCP 1; // 锁存输出 }这段代码里有个细节SHCP先拉低再拉高产生一个上升沿。为什么要先拉低因为如果SHCP之前是高电平直接拉高不会产生上升沿数据不会被移入。所以每次移位前先把时钟拉低确保能产生一个干净的上升沿。注意74HC595的OE输出使能引脚必须接低电平否则输出是高阻态点阵不会亮。MR主复位引脚接高电平否则移位寄存器会被清零。2.2 点阵扫描与消影处理动态扫描的原理很简单每次只点亮一行快速轮流点亮8行利用人眼的视觉暂留效应形成一幅完整的图像。但这里有个坑如果不做消影处理你会看到上一行的残影串到下一行显示效果很脏。消影的原理是这样的在切换到下一行之前先把列数据全部清零也就是595输出全0然后再切换行选最后再发送新的列数据。这样在行切换的瞬间所有LED都是灭的不会出现残影。具体到代码里扫描流程是这样的void Timer0_ISR() interrupt 1 { TH0 (65536 - 1000) / 256; // 1ms定时 TL0 (65536 - 1000) % 256; // 消影先关闭所有列 HC595_SendWord(0x0000); // 切换行选 P0 ~(1 scan_row); // 低电平选中当前行 // 发送当前行的列数据 HC595_SendWord(display_buffer[scan_row]); // 行计数递增 scan_row; if(scan_row 8) scan_row 0; }这里P0口用的是低电平选中行因为行驱动用的是PNP三极管基极低电平导通。如果你用的是NPN三极管或者ULN2003逻辑就要反过来P0口输出高电平选中行。display_buffer是一个8字节的数组每个字节对应一行的8个列数据。游戏逻辑只需要修改这个数组的内容显示刷新完全由中断负责两者互不干扰。这种双缓冲的思想在嵌入式显示里非常常见可以避免画面撕裂。实操心得扫描频率不要设得太高。有些教程说刷新率要几百Hz其实对于8x8点阵来说100Hz左右完全够用。频率太高会导致每次点亮的时间太短LED亮度下降。我实测下来每行1.5ms8行12ms刷新率约83Hz亮度和稳定性都很好。2.3 按键扫描与消抖逻辑按键扫描看起来简单但要做好并不容易。机械按键在按下和释放的瞬间会产生抖动抖动时间通常在5到20ms之间。如果不消抖按一次键可能会被识别成多次。PointGame的按键处理放在主循环里每20ms扫描一次。消抖策略是连续两次扫描到同一个按键按下才确认按键有效。同时记录按键的释放状态只有释放后才能再次触发防止长按重复触发。unsigned char Key_Scan() { static unsigned char key_up 1; unsigned char key_val 0; if(key_up (KEY_UP 0 || KEY_DOWN 0 || KEY_LEFT 0 || KEY_RIGHT 0)) { Delay_ms(10); // 延时消抖 key_up 0; if(KEY_UP 0) key_val 1; else if(KEY_DOWN 0) key_val 2; else if(KEY_LEFT 0) key_val 3; else if(KEY_RIGHT 0) key_val 4; } else if(KEY_UP 1 KEY_DOWN 1 KEY_LEFT 1 KEY_RIGHT 1) { key_up 1; } return key_val; }这段代码里有个细节key_up变量是静态的用来记录按键是否已经释放。如果按键一直按着不放key_up保持为0不会重复返回值。只有所有按键都释放后key_up才恢复为1下一次按下才会被识别。注意延时消抖的10ms会阻塞主循环如果主循环里还有其他实时性要求高的任务这种阻塞式消抖就不合适了。更好的做法是用定时器中断来扫描按键把消抖逻辑放在中断里主循环只读取按键状态。不过对于这个游戏来说主循环的实时性要求不高阻塞式消抖完全够用。3. 实操过程与核心环节实现3.1 硬件搭建与接线检查硬件搭建是整个项目的第一步也是最容易出问题的一步。我建议先在面包板上搭电路确认所有功能正常后再焊接到洞洞板上。接线清单如下模块引脚连接到说明点阵行选8行P0.0-P0.7经三极管驱动595数据DSP2.0第一片595595移位时钟SHCPP2.1两片并联595锁存时钟STCPP2.2两片并联按键上KEY_UPP3.0带上拉按键下KEY_DOWNP3.1带上拉按键左KEY_LEFTP3.2带上拉按键右KEY_RIGHTP3.3带上拉点阵的引脚排列需要特别注意。8x8点阵通常有16个引脚分两排每排8个。但不同型号的点阵引脚定义可能不同有的行和列是交叉排列的。最可靠的方法是查数据手册或者用万用表的二极管档逐个测试。具体做法是红表笔接一个引脚黑表笔依次碰其他引脚如果某个LED亮了说明这两个引脚分别是这个LED的阳极和阴极。把所有LED的对应关系测出来就能确定行和列的引脚。实操心得点阵的引脚顺序很容易搞错我建议先用一个简单的测试程序让每一行依次点亮观察实际效果。如果点亮的顺序和预期不符就调整行选数组的顺序。列数据也一样如果显示的内容左右颠倒就把列数据的位顺序反过来。3.2 游戏逻辑的核心实现游戏逻辑的核心是蛇的移动和碰撞检测。蛇用一个数组来存储每一节身体的坐标数组的第0个元素是蛇头最后一个元素是蛇尾。每次移动时从蛇尾开始把每一节的坐标替换成前一节的坐标最后更新蛇头的坐标。typedef struct { unsigned char x; unsigned char y; } Point; Point snake[64]; // 蛇身坐标数组 unsigned char snake_len; // 蛇的长度 unsigned char direction; // 当前方向 void Snake_Move() { // 从尾部开始每节身体移动到前一节的位置 for(int i snake_len - 1; i 0; i--) { snake[i] snake[i-1]; } // 根据方向更新蛇头 switch(direction) { case DIR_UP: snake[0].y--; break; case DIR_DOWN: snake[0].y; break; case DIR_LEFT: snake[0].x--; break; case DIR_RIGHT: snake[0].x; break; } }碰撞检测分两种撞墙和撞自己。撞墙的判断很简单蛇头的x或y超出0到7的范围就是撞墙。撞自己的判断是遍历蛇身数组如果蛇头的坐标和某一节身体的坐标重合就是撞自己。unsigned char Check_Collision() { // 撞墙检测 if(snake[0].x 7 || snake[0].y 7) return 1; // 撞自己检测 for(int i 1; i snake_len; i) { if(snake[0].x snake[i].x snake[0].y snake[i].y) return 1; } return 0; }食物的生成需要保证食物不出现在蛇身上。做法是随机生成一个坐标然后检查这个坐标是否在蛇身上如果是就重新生成直到找到一个空位。void Generate_Food() { unsigned char valid 0; while(!valid) { food.x rand() % 8; food.y rand() % 8; valid 1; for(int i 0; i snake_len; i) { if(food.x snake[i].x food.y snake[i].y) { valid 0; break; } } } }注意rand()函数需要先调用srand()设置种子否则每次上电后生成的随机序列是一样的。可以用定时器计数值作为种子这样每次上电的种子都不同。3.3 显示缓冲区的更新与映射游戏逻辑计算出来的蛇身坐标和食物坐标需要映射到display_buffer数组里才能显示。映射的规则是display_buffer的每个字节对应一行字节的每一位对应一列。如果某一位是1对应的LED就亮。void Update_Display() { // 清空显示缓冲区 for(int i 0; i 8; i) { display_buffer[i] 0x00; } // 绘制蛇身 for(int i 0; i snake_len; i) { display_buffer[snake[i].y] | (0x80 snake[i].x); } // 绘制食物 display_buffer[food.y] | (0x80 food.x); }这里0x80 snake[i].x的意思是如果蛇的x坐标是0对应最高位x坐标是7对应最低位。这个映射关系取决于595的接线顺序如果实际显示左右颠倒就把0x80改成0x01然后左移。实操心得显示缓冲区的更新频率不需要和游戏逻辑更新频率一致。游戏逻辑每50ms更新一次显示缓冲区也每50ms更新一次就够了。中断里的扫描函数只负责把display_buffer的内容刷到点阵上不关心内容是什么。这种分离设计让代码的耦合度降到最低。4. 常见问题与排查技巧实录4.1 点阵显示异常问题速查点阵显示异常是最常见的问题我整理了一个速查表覆盖了大部分情况现象可能原因排查方法解决方案完全不亮电源未接或595 OE引脚为高万用表测595输出引脚电压检查电源OE接低电平只有一行亮行选信号未变化示波器测P0口波形检查扫描计数器逻辑显示内容左右颠倒列数据位顺序反了显示一个不对称图案测试调整位映射顺序显示内容上下颠倒行选顺序反了逐行点亮测试调整行选数组顺序有残影未做消影处理观察行切换瞬间切换行前先清零列数据亮度不均匀扫描时间分配不均检查中断服务函数执行时间优化中断代码减少耗时操作闪烁严重刷新率太低计算扫描一轮的时间减小每行点亮时间这个表里的每一行都是我实际踩过的坑。特别是有残影这个问题我一开始以为是595的响应速度不够换了更快的595还是不行后来才发现是消影没做。消影的原理虽然简单但在代码里很容易漏掉。4.2 按键响应异常与排查按键问题通常表现为按了没反应、按一次触发多次、按键反应迟钝。按了没反应首先检查硬件。用万用表测按键两端的电压按下时应该从高电平变成低电平。如果电压不变说明按键坏了或者接线错了。如果硬件没问题检查代码里的按键扫描函数是否被正确调用以及按键端口的方向寄存器是否配置为输入。按一次触发多次这是典型的消抖没做好。机械按键的抖动时间可能长达20ms如果消抖延时只有5ms就可能出现多次触发。解决办法是增加消抖延时到10到20ms或者采用多次采样确认的方式。按键反应迟钝通常是因为主循环里还有其他耗时操作导致按键扫描的间隔太长。比如如果主循环里有一个100ms的延时按键扫描每100ms才执行一次按一下可能就错过了。解决办法是把按键扫描放到定时器中断里保证扫描间隔固定。实操心得我习惯在按键扫描函数里加一个计数器记录每个按键连续按下的次数。如果某个按键连续按下超过一定次数比如50次对应1秒就认为是长按触发特殊功能。这个技巧在需要长按操作的游戏里很有用。4.3 程序烧录与Keil配置问题Keil的配置问题困扰过很多人特别是Keil 5同时装了C51和MDK的时候经常出现this uvision project is not a c51 project的错误。这个问题的根源是Keil 5默认用MDK打开工程而C51工程需要C51的编译器。解决办法是在Keil 5的安装目录下找到TOOLS.INI文件确保里面同时包含了C51和MDK的配置段。C51的配置段大概长这样[C51] PATHC:\Keil_v5\C51\ VERSIONV9.60 BOOK0HLP\RELEASE_NOTES.HTM(Release Notes,GEN)如果TOOLS.INI里没有C51段说明C51编译器没有正确安装。需要重新运行C51的安装程序安装到Keil 5的目录下。安装完成后打开Keil 5在Project菜单里应该能看到Open Project下面有C51的选项。烧录失败也是常见问题。STC单片机用STC-ISP软件烧录时需要先断电点击下载按钮后再上电。如果一直提示正在检测单片机检查串口线是否接好串口号是否选对波特率是否太高建议先用最低波特率试。注意STC-ISP软件里的下次冷启动后使用内部IRC选项不要随便勾选否则可能导致单片机跑飞。如果勾选了需要断电重新上电才能恢复。4.4 游戏体验优化与扩展思路游戏能跑起来之后可以做一些体验优化。比如蛇的移动速度可以随着得分增加而加快增加难度曲线。实现方法是维护一个速度变量每吃到一个食物速度变量减一游戏逻辑更新的间隔就缩短一点。还可以增加音效。用一个蜂鸣器接在某个IO口上吃到食物时响一声撞墙时响一声长音。蜂鸣器的控制很简单就是GPIO翻转产生方波频率决定音调。显示效果也可以优化。比如蛇头用闪烁的方式显示食物用呼吸灯效果这些都可以通过修改display_buffer的更新逻辑来实现。呼吸灯效果需要PWM可以用定时器产生不同占空比的方波来控制LED亮度不过对于8x8点阵来说实现起来稍微复杂一些需要额外的PWM通道。最后再分享一个小技巧如果觉得8x8点阵太小可以级联多个点阵模块。比如用4个8x8点阵拼成一个16x16的屏幕显示面积翻四倍。级联的方法是把第一个点阵的列数据输出接到第二个点阵的列数据输入行选信号并联。这样只需要增加595的数量单片机的IO口占用不变。不过16x16的扫描行数变成16行每行点亮时间需要缩短到0.8ms左右才能维持相同的刷新率对中断服务函数的执行时间要求更高。