简介:面向单片机课程设计的贪吃蛇游戏完整方案,适合本科阶段学习51单片机、Proteus仿真及C语言开发的读者。资源包共包含18个文件,整体大小约102.42MB,主要文件有Proteus仿真工程、Keil源码工程、课程设计报告Word文档和答辩PPT,工程配置齐全,下载即可打开并二次修改。游戏采用经典贪吃蛇规则,用方向键控制蛇移动吃豆子,每吃一个豆子蛇身加长、速度提升,撞墙或撞到自己则游戏结束;源码中包含C语言主程序、A51启动文件以及烧录使用的HEX文件,便于实际运行验证。目前已有2435人学习下载。从内容预览来看,压缩包内还带有课程报告、PPT展示和演示视频,覆盖从需求分析、代码编写到仿真调试、答辩展示的完整过程。对于需要完成课设、熟悉定时器与按键扫描或理解Proteus仿真的同学,这份配套资料具有直接参考价值。
1. 项目概述:为什么51单片机还能做贪吃蛇
先聊点实在的。我见过不少人对51单片机有个刻板印象——觉得这芯片又老又慢,Flash才几KB,RAM更是可怜巴巴的几百字节,能干点啥?但贪吃蛇这个项目恰恰就是把51的潜力榨干的一个绝佳练手题。你想想,一个游戏需要的核心要素是什么:输入处理、逻辑更新、画面渲染、状态管理,这些在51上全都能跑,而且跑起来还挺有模有样。
这个项目的价值在哪里?它不像流水灯、蜂鸣器那样只是“点一下亮一下”,贪吃蛇是一个完整的实时交互系统。它需要你处理动态数据结构(蛇的身体就是一块连续的内存空间)、需要你管理游戏状态机(运行、暂停、死亡、胜利)、需要你搞定定时器中断(蛇的移动节奏)、还需要你设计合理的按键扫描逻辑。换句话说,它覆盖了嵌入式开发的大部分基本功。适合什么人做?如果你刚学完51的基础外设,想找个综合项目把知识串起来;或者你在准备课程设计、电子设计竞赛的入门练手,这个项目都相当合适。
我最初做这个项目的时候,用的是STC89C52RC这颗经典芯片,12MHz晶振,显示屏方案试过8x8点阵,也试过LCD1602,后面会详细对比两者差异。整个项目从画原理图到PCB打样再到调通游戏,前后花了大概两周业余时间,中间踩的坑不少,这篇文章会把关键的设计思路、代码结构和调试心得全部拆开来讲。
2. 硬件设计与方案选型
2.1 显示方案:点阵屏还是LCD1602
这是做贪吃蛇第一个要拍板的事情。先说8x8点阵方案。网上能搜到很多视频,用两个8x8点阵拼成16x8的屏幕,蛇和食物都用点亮的小方块来表现,视觉效果非常直观,特别有街机味。硬件上需要两块8x8共阴点阵模块,用74HC595或者直接IO口驱动都行。但这里有个关键问题:8x8点阵的驱动本质是动态扫描,你得在一毫秒级别内反复刷新行和列,靠人眼余晖形成稳定画面。如果刷新率不够,画面会闪烁得让你怀疑人生;如果主循环里还要处理游戏逻辑,两者就会互相打架。
LCD1602方案则走的是另一条思路。它本质是个字符屏,不能画点,只能显示ASCII字符。所以游戏画面就是用一个字符代表蛇身,比如用星号(*)表示蛇,用数字0或者特殊符号表示食物。1602是两行,每行16个字符,整个游戏区域就是16x2的网格。听起来比点阵小不少,但实际玩起来完全够用,而且1602自带控制器,内部有DDRAM让你写入显示数据,刷新压力比点阵小得多。
我个人的建议是:如果你是初学者,想降低显示驱动的难度,只管把游戏逻辑写好,那就选LCD1602。如果你想挑战一下,或者手头正好有点阵模块,想做出更炫酷的像素风效果,那就选8x8点阵。两种方案我都会在代码设计里给出思路,核心区别只在于显示接口函数,游戏逻辑层完全共用。
2.2 按键交互:独立按键与P3口的搭配
贪吃蛇需要四个方向控制,最简单粗暴的做法就是用四个独立按键接P3.0到P3.3,分别代表上、下、左、右。按键另一端接地,按下时IO口读到低电平。原理图非常简单,但实际使用中有几个细节需要注意。
第一是按键去抖。机械按键按下和松开的瞬间会有大约10ms到20ms的抖动,如果不做处理,一次按下很可能被程序识别成多次触发,方向就会乱跳。硬件上可以加RC滤波,软件上更常用的是延时去抖或者定时器扫描去抖。考虑到游戏需要实时响应,我建议在主循环里做10ms级别的软件延时去抖,这样代码更直观,也不容易出错。
第二是按键方向状态位。四路按键分别检测,每次检测到有效的按下事件后,就把对应的方向值写入一个全局方向变量。这个变量是游戏逻辑的核心输入,蛇的移动方向每帧都从这里面读。
第三是暂停功能的按键。我额外加了一个独立按键接P3.4作为暂停/继续键。玩到一半有事离开,这功能很实用,而且实现起来也简单,就是一个状态标志位翻转的事。
2.3 最小系统与供电设计
单片机最小系统这块,STC89C52RC的精简配置就是:复位电路(10uF电容加10K电阻)、晶振电路(12MHz晶振加两个30pF负载电容)、电源去耦(VCC和GND之间并联一个10uF电解电容和一个104瓷片电容)。STC系列单片机支持ISP下载,所以板上只需要留一个串口下载接口,我用的是四针排针,把TXD、RXD、VCC、GND引出来,用USB转TTL模块下载程序。
电源部分要特别提醒一点:如果你的仿真或者实物使用USB供电,一定要注意共地。下载器和开发板必须共地,否则下载大概率失败。另外LCD1602的背光电流大概20mA左右,加上单片机本身,整板功耗不到100mA,USB口供电完全没问题。但如果你用的是点阵屏加动态扫描,8个LED同时点亮的情况也不少见,瞬间电流会到几十毫安,USB还是扛得住,不过建议在电源入口处加一个100uF左右的电解电容做稳压缓冲。
3. 核心代码架构与逻辑拆解
3.1 数据结构设计:蛇身数组与方向标志
贪吃蛇游戏的核心数据结构其实很简单,就是存储蛇身每一节坐标的数组。我定义一个结构体来管理:
#define MAX_SNAKE_LEN 32 typedef struct { unsigned char x; unsigned char y; } Point; Point snake[MAX_SNAKE_LEN]; // snake[0] 为蛇头 unsigned char snakeLen; unsigned char direction; // 当前方向: 0上 1下 2左 3右 unsigned char foodExist; // 食物是否存在 Point food; unsigned char gameState; // 0运行 1暂停 2结束这里有几个设计细节值得展开讲。首先是蛇身数组的长度上限。在1602上,游戏区域是16x2,总共32个单元格,所以蛇的最大长度就是32;在16x8点阵上则是128。我把上限定义为32,如果后续换屏,直接改宏即可。
其次是方向变量的取值范围。很多初学者会直接用按键值写入方向,但这样做会有隐患:按键按下的是“上”,可如果蛇目前正在向上走,你再按“上”当然没问题,但按“下”呢?如果直接让蛇掉头180度,蛇头会直接撞向自己第二节身体,这显然是游戏bug。所以方向控制必须加上“不能反向”的判断,这在后面移动逻辑里会说。
3.2 蛇的移动算法:数组整体搬移的本质
蛇移动的实现方式,我在第一次写的时候走了弯路,那时候用链表,每个节点动态分配内存,结果51的RAM太小,玩一会儿就耗尽。后来想通了,用数组加整体搬移的做法最省心。
每到一个移动周期,我做的就是下面这件事:
void moveSnake(void) { unsigned char i; Point newHead = snake[0]; // 根据方向计算新蛇头坐标 switch(direction) { case 0: if(newHead.y > 0) newHead.y--; break; // 上 case 1: if(newHead.y < 1) newHead.y++; break; // 下 case 2: if(newHead.x > 0) newHead.x--; break; // 左 case 3: if(newHead.x < 15) newHead.x++; break; // 右 } // 把原来蛇身从尾部开始依次后移一位 for(i = snakeLen; i > 0; i--) { snake[i] = snake[i-1]; } snake[0] = newHead; // 如果新蛇头坐标等于食物坐标,则吃到了,蛇长+1,尾巴不用砍 if(isEqual(snake[0], food)) { snakeLen++; foodExist = 0; if(snakeLen >= MAX_SNAKE_LEN) gameState = 2; // 胜利 } else { // 没吃到食物,砍掉尾巴(即长度不变) // 因为上面整体后移了一位,末尾已经是重复值,无需额外操作 } }整体后移这段代码是核心。它的思路是:把蛇身体数组从尾部开始,每一节往后挪一格,空出头部的位置,然后把新头放进去。这个过程看起来就像蛇整体向前蠕动了一格。如果吃到了食物,就不砍尾巴;没吃到,保持长度不变。这个技巧在嵌入式开发里非常经典,叫“滑动窗口”,用空间换时间,逻辑简单执行速度快,适合51这种资源紧张的环境。
碰撞检测分两类。第一类是撞墙:在1602方案里,游戏区域就是16x2,蛇头坐标一旦超出边界就算死亡。我在计算新蛇头的时候已经加了边界判断,如果越界就直接置游戏结束标志。第二类是撞自己:遍历蛇身数组,如果新蛇头坐标和身体某一节坐标相同,算死亡。
3.3 游戏循环与定时器:异步驱动不卡屏
游戏要流畅,最关键的是不能让主循环死等延时。如果我在moveSnake前面加一个delay(500)来控速,整个系统在等待期间是无法响应按键的,玩起来会感觉很“顿”。正确做法是用定时器0产生一个固定时基的中断,在中断服务函数里做计时累加,主循环里判断是否到了更新游戏逻辑的时间点。
volatile unsigned int tick = 0; #define MOVE_INTERVAL 300 // 300ms 移动一次 void timer0_isr(void) interrupt 1 { TH0 = 0xDC; TL0 = 0x00; // 12MHz, 模式1, 50ms中断一次 tick++; } void main(void) { // 初始化... while(1) { if(gameState == 0) { if(tick >= MOVE_INTERVAL / 50) { tick = 0; readKey(); moveSnake(); updateDisplay(); } } // 按键扫描也不能在主循环里阻塞太久,使用非阻塞方式 } }这个设计的好处是:无论主循环里做了什么操作,中断都在后台默默计时,一到时间就触发移动。按键扫描做在移动之前,保证方向能及时响应。而更新显示放在移动之后,画面和逻辑是同步的。
关于定时器初值的计算,这里多说一句。定时器0工作在方式1(16位)时,最大定时65536个机器周期。12MHz晶振下每个机器周期是1us,所以最大约65.5ms。我选了50ms,初值就是65536 - 50000 = 15536,也就是0x3CB0。初始化代码里写的TH0=0xDC,TL0=0x00,算出来是56320,对应的是56320us约56ms,这是我调试时为了手头板子方便调整过的值,你需要根据自己板子的晶振重新计算。
4. 显示驱动设计与画面刷新
4.1 LCD1602驱动封装
如果走LCD1602方案,显示驱动的重点是把游戏网格映射到字符坐标。我的做法是给LCD驱动封装三个底层函数:写命令、写数据、初始化,然后在上层维护一个16字节的显存数组,每次更新画面时先把整个数组清成空格,再把蛇身和食物填进去,最后一次性刷到LCD上。
unsigned char dispBuf[2][16]; void syncDisplay(void) { unsigned char i; for(i = 0; i < 16; i++) { setCursor(i, 0); writeData(dispBuf[0][i]); } for(i = 0; i < 16; i++) { setCursor(i, 1); writeData(dispBuf[1][i]); } }这种“先改显存,再整体刷新”的模式要养成习惯。如果你每移动一次蛇就直接往LCD上写,画面会出现比较明显的闪动,因为LCD的写入是按字节来的,你写一个字节显示一个字节,中间过程肉眼可见。显存缓冲的方式可以保证同一时间只有一帧完整画面出现在屏幕上,感官上会舒服很多。
4.2 点阵屏动态扫描的优化思路
如果你选8x8点阵方案,那显示驱动又是另一套打法。16x8的屏幕需要扫描8行(或者8列),每次只点亮一行,利用人眼视觉暂留合成完整画面。核心代码如下:
void scanDisplay(void) { unsigned char row; for(row = 0; row < 8; row++) { P0 = rowSelect[row]; // 行选通 P1 = colData[row]; // 该行各列的亮灭状态 delayShort(500); // 约0.5ms } }这套显示代码必须在主循环里高频执行,理想情况是每毫秒扫完一帧(8行x0.5ms再加切换时间,大约4ms一帧),这样人眼看起来比较稳定。问题在于,游戏逻辑和按键检测如果插在主循环里,会打断扫描节奏,导致闪烁。解决思路有两个:把扫描放进定时器中断,主循环专心做逻辑;或者主循环里交替执行扫描和逻辑,每次扫描一帧再跑逻辑,保证刷新连续。我推荐把扫描放进中断,用定时器1做一个1ms中断,中断里切换一行,主循环只负责逻辑更新和缓冲区的写入。
这个方案的细节很多,行选线的接法、列数据的取反问题、共阴共阳的差异,都可能让你调半天。如果你第一次做,建议先点亮一行做测试,用固定图案验证行和列方向都对了,再切到游戏模式。
4.3 食物生成规则与碰撞容错
食物的生成逻辑看似简单,其实藏着坑。最笨的办法是每次随机生成一个坐标,然后检查是否和蛇身重叠。但在51上做真随机数是比较麻烦的,常用的做法是用定时器累加值的低几位当作随机种子。我的做法是:
void generateFood(void) { unsigned char x, y, i, overlaps; do { x = 0; y = 0; // 用定时器低字节做伪随机,简单但够用 x = (unsigned char)(TL0 % 16); y = (unsigned char)((TL0 >> 4) % 2); overlaps = 0; for(i = 0; i < snakeLen; i++) { if(snake[i].x == x && snake[i].y == y) { overlaps = 1; break; } } } while(overlaps); food.x = x; food.y = y; foodExist = 1; }这里用TL0的当前值做随机源,TL0一直在跑,所以食物位置每次生成都会不同。不过要注意,如果蛇身已经很长,几乎占满屏幕,这个do-while循环可能会长时间找不到空位,所以游戏胜利条件是在蛇长达到最大值时立即触发,而不是生成食物时才判断。
另外一个容易被忽略的细节是食物的碰撞判定。我检查新蛇头坐标时用的是精确相等,也就是新头坐标和食物坐标完全相同才算吃到。这个判定在1602的网格里没问题,但在点阵屏方案里,如果蛇一次移动多格(加速),就可能导致跨过食物却没检测到。解决方式是让蛇每步只移动一格,不要用“加速跳格”的做法,要加速就缩短移动间隔时间,而不是加大移动步长。
5. 完整流程:从仿真到实物
5.1 Proteus仿真搭建的要点
如果你手头暂时没有实物,先用Proteus仿真跑通整个逻辑是完全可行的,这也是很多学校的课程设计标配。Proteus里搭建这个电路,需要添加STC89C52RC、LCD1602或者点阵屏幕、四个按键、以及必要的电阻电容。连线的时候要注意:
- LCD1602的RS、RW、EN引脚分别接单片机的P2.0、P2.1、P2.2,数据口D0-D7接P0口。P0口是开漏输出,必须接上拉电阻,否则LCD根本没法正常工作,这是仿真和实物里最常见的坑。
- 四个方向按键接P3.0-P3.3,按键另一端接GND。
- 晶振在仿真里经常被忽略,但程序里如果用到定时器,就要保证单片机属性里的晶振频率和自己代码计算的延时一致。我默认用12MHz,如果你在Proteus里忘了设置,默认是1MHz,时间会差很多。
Proteus仿真对比实物的最大优势是调试方便。你可以在代码里给蛇移动加打印信息,通过虚拟终端实时看数据;也可以直接改坐标数组的值,快速验证某种边界情况。但仿真也有它的局限:它不会模拟按键抖动,你的去抖逻辑在仿真里可能测不出来,所以代码里一定要保留软件去抖,不能因为仿真正常就删掉。
5.2 下载与实机调试的完整步骤
硬件下载这块,STC89C52RC用的是STC-ISP这个软件。连接好USB转TTL模块后,注意几个关键点:第一,下载时单片机的P3.0和P3.1作为串口使用,不要接其他影响电平的外设;第二,STC系列是冷启动下载,也就是先点下载按钮,再给单片机上电,顺序反了会导致下载失败;第三,串口波特率选115200就行,晶体误差在允许范围内。
下载完成后,第一步先跑一个LED闪烁的测试程序,确认最小系统和下载链路是通的。不要一上来就跑贪吃蛇,万一屏幕不亮,你没法判断是程序问题还是接线问题。测试序没问题后,再烧录游戏程序,如果正常运行,LCD背光亮起,按下启动键应该能看到蛇出现在初始位置。
5.3 实物接线与电源检查清单
实物接线的检查清单,我建议按这个顺序排查:
- 电源:VCC和GND之间电压是不是5V,有没有反接。用万用表测一下单片机电源脚和地脚之间的电阻,排除短路。
- 晶振:用示波器(或者逻辑分析仪)看晶振脚是否有振荡信号。没示波器的话,最笨的办法是碰一下晶振脚看LED亮度变化,但不太精确。有条件还是上示波器靠谱。
- 复位:RESET引脚电压在正常运行时应该为低电平,按下复位键会变高。如果你程序完全不跑,先检查这个。
- LCD对比度:1602的V0脚通常接一个10K电位器调节对比度,如果屏上有字但看不清,先调电位器。
- 按键IO:按住按键,用万用表测量对应IO引脚的电平,应该从高变低。
这些做完还是不行,就检查程序编译有没有警告,比如数组越界、隐式转换这类问题,虽然编译器只给警告不报错,但运行起来就会出诡异问题。
6. 常见问题与优化方向
6.1 按键方向失灵或反向的坑
这是初学者遇到最多的问题。症状一般是:按“上”蛇往上走,再按“左”却变成往右;或者按“下”蛇直接反向把自己撞死了。根因通常是方向判断逻辑把方向值直接写给了direction,没有检查当前方向。
正确的逻辑是:比如蛇当前方向是右(3),按键输入左(2),这个更新应该被忽略。同理,当前向上(0)时输入下(1)也要忽略。代码实现就是加一个if判断:
void updateDirection(unsigned char newDir) { if(gameState != 0) return; // 非运行状态不响应方向 if((direction == 0 && newDir == 1) || (direction == 1 && newDir == 0) || (direction == 2 && newDir == 3) || (direction == 3 && newDir == 2)) { return; // 反向,忽略 } direction = newDir; }这个判断是游戏的手感命脉,忘掉它的后果就是蛇会“自尽”,而且看起来特别弱智。我第一次调试时没加这个,还以为是蛇跑太快自己撞到了,排查了半天才发现是逻辑bug。
6.2 画面闪烁的改进办法
闪烁问题在点阵方案上更明显。常见的表现是:蛇运动的时候画面呈波浪状抖动,停下来就稳定。这通常说明显示扫描和游戏逻辑更新抢占同一个CPU时间片,且游戏逻辑的耗时比较大。
我实测下来的解决方案是:把扫描放进定时器中断里,中断频率提到1kHz,主循环只做逻辑,两者数据通过全局缓冲区交换。这样即使游戏逻辑跑得慢,屏幕每一行依然能维持稳定的刷新节奏,不会闪。测量刷新率的方法很简单:接一个IO口,在扫描函数入口置高、出口置低,用示波器看波形的周期,按1/周期算刷新率。只要每帧刷新率高于60Hz,人眼就很难察觉闪烁了。
6.3 速度档位与游戏体验调优
默认300ms移动一次,玩起来比较轻松。但如果你想把游戏做得更有意思,可以做速度递增机制:分数每增加5分,移动间隔缩短20ms,最低到100ms为止。这个改动很小,只要在吃到食物时修改MOVE_INTERVAL对应的比较值就行。代码上注意把MOVE_INTERVAL改成全局变量,定时器里比较时读这个变量的值。
另一个体验优化是“死亡后按键重开”。游戏结束后,按任意方向键或者专门的重置键,把蛇长、方向、位置都恢复初始值,再生成第一个食物。这块逻辑虽然简单,但写起来容易漏初始化项,建议用一个resetGame()函数把全局变量统一复位,比在main里散落初始化代码更清晰。
6.4 代码空间优化:压缩Hex体积的小技巧
如果编译出来的Hex文件太大,烧录时占用空间多,运行时也可能因为代码跳转而变慢。几个实用的优化方法:
- 使用unsigned char而不是int存坐标,省一半内存和时间。
- 把频繁调用的函数声明为small模式,或者用code关键字把常量表放进程序存储器。
- 避免在中断函数里做复杂运算。中断里只做标志位设置,具体逻辑放主循环。
- 编译优化开level 2,Keil的优化选项里选择“Focus on Speed”或“Size”,按照你的实际需要选。我实测过,速度优先模式下游戏运行明显更流畅。
说了这么多,我觉得这个项目最迷人之处在于:它把一个看似只有8位CPU、128字节RAM的芯片,变成了一个能玩游戏的“游戏机”。它逼着你把每一字节的资源都用到位、把每一个逻辑都理顺,这种绷着弦做开发的感觉,恰恰是后续做更复杂嵌入式系统时最需要练的基本功。如果你做完这个还想继续进阶,可以试试在同样的硬件上加计分显示、最高分记录、难度选择菜单,或者把显示换成一款彩色LCD,游戏立马就有种“进化”的既视感。反正硬件就在那里,能榨出多少花样,全看你的想象力了。
本文还有配套的精品资源,点击获取