简介:基于C51单片机的贪吃蛇小游戏项目资料包,定位于单片机初学者与嵌入式入门者,以经典游戏为载体,帮助读者掌握C51语法、定时器中断、IO控制、内存管理及基础算法设计,是一份完整的课程设计或综合实训参考。压缩包共30个文件,整体仅66KB,主要包含C源码文件、头文件、Keil工程文件、hex烧录文件以及汇编启动文件,辅以列表与目标文件,便于查看编译与链接过程。项目内代码模块清晰,涵盖蛇身移动、食物随机生成、碰撞检测、按键扫描等逻辑,并附有工程配置与烧录镜像,可直接导入Keil使用或烧录到硬件验证。通过源码可学习结构化软件设计、模块化编程与底层硬件驱动的协同方式,适合快速建立嵌入式游戏开发的整体认知。目前已有1027人学习下载,是轻量但完整的单片机实践案例。 这段时间一直在折腾C51单片机的小项目,前前后后也做过电子时钟、温度报警器这类玩意,但要说哪个项目最适合把单片机核心知识点串起来,我第一个推“基于C51单片机的贪吃蛇小游戏”。别小看这条蛇,它正好踩在定时器、中断、按键扫描、LCD显示、状态机设计这些关键交叉点上,做一遍下来,你对单片机的理解会清晰一大截。这篇文章就围绕这个项目,把硬件搭建、软件架构、核心代码、调试心得全部摊开讲,适合刚学完51基础语法、想通过一个完整项目练手的同学,也适合做课设和毕设的朋友参考。
1. 整体设计思路与方案选型
1.1 为什么选C51 + LCD1602这套组合
先聊聊方案选型。市面上能做贪吃蛇的硬件平台很多,STM32配OLED、ESP32配屏幕做网络版,看着都挺香,但论学习价值和上手难度,C51依然是最合适的入门平台。原因很简单:C51的寄存器操作极度透明,定时器初值要自己算,IO口要自己配置,所有细节都摊在明面上,做完这个项目你再去看ARM内核的单片机,底层逻辑基本能直接迁移。
屏幕方面我推荐LCD1602。虽然它只有两行,每行16个字符,但用字符码0xFF显示实心方块完全够用,蛇身、食物、边界都能用方块表示,而且1602的驱动时序比LCD12864简单太多,新手不容易卡在显示驱动的坑里。如果你想追求更好的体验,也可以换成LCD12864图形屏,但代码量和调试成本都会上涨,不建议第一次做就上。
CPU我建议用STC89C52RC或者AT89S52,12MHz晶振最省事,因为定时器初值好算,串口波特率也标准。STC系列还支持ISP下载,不需要专门的烧录器,一个CH340串口模块就能搞定,对做课设的人特别友好。
1.2 游戏逻辑状态机设计
写游戏逻辑之前,先想清楚一个问题:单片机是单线程的,它不可能像电脑上那样开个线程循环去刷新游戏,所以必须用状态机把整个游戏流程拆开。
贪吃蛇看起来简单,实际状态不少:开机后停在开始界面、按开始键进入游戏、游戏中有暂停、蛇撞墙或撞自己后进入结束状态、结束后按复位回到开始界面。我把这些状态定义成枚举变量:
typedef enum { STATE_MENU, STATE_RUNNING, STATE_PAUSED, STATE_GAMEOVER } GameState;主循环里用switch判断当前状态,不同状态执行不同逻辑。这样就避免了用一堆if嵌套,程序结构清晰,后期加功能(比如加暂停提示、加计分刷新)都很好扩充。
速度控制也是个关键点。很多人喜欢用delay函数卡时间,但delay会阻塞整个程序,按键响应会变得迟钝,游戏体验很差。正确的做法是用定时器中断产生时间基准,主循环里每秒刷新指定次数。我采用的是定时器0产生5ms中断,在中断里累加计数,累加到设定值才触发一次游戏刷新。
2. 硬件搭建与核心器件连接
2.1 最小系统与外设清单
先把硬件清单列出来,这些都是我自己实际用过的,兼容性没问题:
| 器件 | 型号/规格 | 数量 | 用途 |
|---|---|---|---|
| 单片机 | STC89C52RC | 1 | 主控 |
| 晶振 | 12MHz | 1 | 时钟源 |
| 电容 | 30pF | 2 | 晶振负载电容 |
| 电解电容 | 10uF | 1 | 复位电路 |
| 电阻 | 10K | 1 | 复位上拉 |
| LCD | LCD1602 | 1 | 显示 |
| 按键 | 轻触按键 | 4 | 上下左右 |
| 电位器 | 10K | 1 | LCD对比度调节 |
| 排阻 | 10K 排阻 | 1 | P0上拉 |
| 蜂鸣器 | 5V有源 | 1 | 音效(可选) |
| 面包板 | 830孔 | 1 | 搭建电路 |
注意STC89C52RC的P0口是开漏输出,内部没有上拉电阻,驱动LCD数据线时必须外接10K排阻上拉,否则1602显示会花屏或者完全不显示。这是新手最容易踩的一个坑。
2.2 引脚分配与电路接线
我的接线方案是这样的:
| 模块 | 引脚 | 单片机引脚 |
|---|---|---|
| LCD1602 | RS | P2.0 |
| LCD1602 | RW | P2.1 |
| LCD1602 | EN | P2.2 |
| LCD1602 | D4-D7 | P0.0-P0.3(4线模式) |
| 按键上 | — | P1.0 |
| 按键下 | — | P1.1 |
| 按键左 | — | P1.2 |
| 按键右 | — | P1.3 |
| 蜂鸣器 | I/O | P3.5 |
LCD1602推荐使用4线模式,只接D4-D7,能省4个IO口。所有按键一端接IO口,另一端接地,IO口内部需要接上拉或者开启内部上拉功能。STC89C52RC的P1口内部有上拉,但为了稳定,我习惯外部再加10K上拉电阻。
2.3 Proteus仿真注意事项
如果你先拿Proteus做仿真,注意一个细节:Proteus里的STC单片机默认没有外部扩展RAM,如果程序里定义了过大的数组,编译器可能会分配到xdata段,仿真时会报错或者运行异常。解决方法是把所有大的数组定义到code区(用code关键字)或者data区,Keil里将存储模式设为Small。这也提醒了一个硬件设计原则:51单片机内部RAM只有128字节(增强型有256字节),程序里别随便定义大数组。
3. 软件代码实现与调试过程
3.1 数据结构与变量定义
贪吃蛇的核心数据结构是蛇身坐标。我做的时候一开始想用链表,每个节点动态分配内存,后来觉得在51上搞动态内存风险太大,容易内存碎片,而且浪费资源,所以改用定长数组。
我的定义方式是:
#define MAP_WIDTH 16 #define MAP_HEIGHT 2 struct Snake { unsigned char x[64]; unsigned char y[64]; unsigned char length; unsigned char direction; // 0上 1下 2左 3右 }; struct Food { unsigned char x; unsigned char y; }; struct Snake snake; struct Food food; unsigned short timerCount = 0; unsigned char gameSpeed = 20; // 每20个5ms刷新一次,即100ms一格 unsigned int score = 0;蛇长最大64是因为LCD1602一共只有32个字符位置,实际根本不可能超过32,我预留64是为了防止边界判断出错时越界。数组长度为64,对应每个位置存x和y坐标。蛇头必须是数组第0个元素,这样移动时只需要把坐标往后挪一位就行。
3.2 核心逻辑:蛇移动与食物生成
贪吃蛇的移动逻辑是整个项目最核心的部分。我先计算新蛇头位置,然后把原来蛇身的坐标依次往后挪,最后把新蛇头放到第0位:
void snakeMove(void) { unsigned char i; unsigned char newX = snake.x[0]; unsigned char newY = snake.y[0]; switch(snake.direction) { case 0: newY--; break; // 上 case 1: newY++; break; // 下 case 2: newX--; break; // 左 case 3: newX++; break; // 右 } // 边界检测 if(newX >= 16 || newY >= 2) { gameState = STATE_GAMEOVER; return; } // 自撞检测 for(i = 0; i < snake.length; i++) { if(snake.x[i] == newX && snake.y[i] == newY) { gameState = STATE_GAMEOVER; return; } } // 移动:从尾部开始往后覆盖 for(i = snake.length; i > 0; i--) { snake.x[i] = snake.x[i-1]; snake.y[i] = snake.y[i-1]; } snake.x[0] = newX; snake.y[0] = newY; // 判断吃到食物 if(newX == food.x && newY == food.y) { snake.length++; score += 10; generateFood(); } }这里有个细节:移动时要从蛇尾开始往前覆盖,不能从蛇头开始,否则会把整条蛇身变成同一个坐标。我一开始就是写反了,结果蛇一移动就缩成一个点。
食物生成不能用库函数rand(),因为51的rand()种子不可控,同一温度下每次运行可能产生相同序列。我的办法是用定时器低字节当随机数种子,再加上一个简单的伪随机递推:
void generateFood(void) { unsigned char valid = 0; while(!valid) { food.x = (unsigned char)(timerCount * 7 + 3) % 16; food.y = (unsigned char)(timerCount * 13 + 1) % 2; valid = 1; for(unsigned char i = 0; i < snake.length; i++) { if(snake.x[i] == food.x && snake.y[i] == food.y) { valid = 0; break; } } } }因为timerCount一直在变化,所以产生的伪随机数每次都不一样,而且避免食物生成在蛇身上。
3.3 定时器与游戏速度控制
游戏速度控制我用的是定时器0,方式1,16位计数模式。12MHz晶振下,机器周期是1us,要产生5ms定时,需要计数5000个周期,但16位定时器最大计数65535,所以初值计算:
TH0 = (65536 - 5000) / 256 = 60536 / 256 = 236 = 0xEC
TL0 = (65536 - 5000) % 256 = 60536 % 256 = 0x78
中断服务函数里,每次5ms累加一次timerCount,当timerCount达到gameSpeed时,执行一次游戏刷新并清零timerCount:
void timer0_isr(void) interrupt 1 { TH0 = 0xEC; TL0 = 0x78; timerCount++; if(timerCount >= gameSpeed) { timerCount = 0; gameTick = 1; // 主循环检测这个标志位 } }主循环里检测gameTick标志位,为1时就执行移动、碰撞检测和刷新显示。这样把游戏刷新和按键扫描分开,互不阻塞,按键响应会很灵敏。
我实测下来,gameSpeed设为20(即100ms一格)玩起来最舒服,不会太快也不会太慢。后面还可以通过减小gameSpeed值实现难度递增,比如每吃到5个食物就加速一次。
3.4 按键扫描与LCD显示的关键代码
按键扫描必须防抖。我用的方案是:主循环每20ms扫描一次按键,检测到按下时置一个标志,等下一个周期再确认一次,连续两次为低才判定按下。这样能滤掉大部分机械抖动。
方向控制还有一个关键限制:不能允许蛇直接掉头,否则会瞬间撞到自己。比如当前方向是左,你不能让它直接变右:
void keyProcess(void) { if(P1_0 == 0 && snake.direction != 1) { snake.direction = 0; } if(P1_1 == 0 && snake.direction != 0) { snake.direction = 1; } if(P1_2 == 0 && snake.direction != 3) { snake.direction = 2; } if(P1_3 == 0 && snake.direction != 2) { snake.direction = 3; } }LCD显示部分,我还用了覆盖法代替清屏。直接调用LCD_Clear()会整屏闪一下,观感特别差。我改成在旧蛇尾位置写空格,在新蛇头位置写方块,这样不用清屏,画面非常稳定:
void lcdDrawSnake(void) { unsigned char i; // 清理旧尾部(移动前调用) lcdSetCursor(snake.x[snake.length], snake.y[snake.length]); lcdWriteData(' '); // 画新蛇头(移动后) lcdSetCursor(snake.x[0], snake.y[0]); lcdWriteData(0xFF); // 画食物 lcdSetCursor(food.x, food.y); lcdWriteData('*'); }注意LCD1602第一行地址是0x00-0x0F,第二行地址是0x40-0x4F,写地址时如果直接按字符位置算,第二行会出错。我的lcdSetCursor函数里做了地址偏移。
4. 常见问题与排查技巧实录
做这个项目过程中,我把能踩的坑基本都踩了一遍,整理成速查表,你如果遇到同样问题可以直接对号入座:
| 现象 | 原因 | 排查方法 |
|---|---|---|
| LCD1602完全不显示 | P0口没加上拉电阻 | 检查排阻焊接,测量P0口电压 |
| LCD显示乱码/花屏 | 对比度电位器没调好 | 调整10K电位器到屏幕刚出现黑色方块的临界点 |
| 第一行显示正常,第二行错位 | 地址计算错误 | 检查lcdSetCursor第二行的地址偏移 |
| 按键按下无反应 | 消抖时间不够或IO口没配置输入 | 用万用表量按键按下时的电平变化 |
| 蛇移动速度越来越快或忽快忽慢 | 定时器初值计算错误或中断里改变量冲突 | 用示波器/逻辑分析仪量IO翻转频率 |
| 蛇一移动就缩成一团 | 蛇身坐标更新方向写反 | 检查for循环是从尾部往前覆盖 |
| 食物偶尔生成在蛇身上 | 随机数生成没做冲突检测 | 检查generateFood里的valid循环 |
| Keil编译报错DATA段溢出 | 数组定义过大或存储模式设置不对 | 把存储模式设为Small,大数组写进code区 |
| Proteus仿真时程序不运行 | 晶振频率设置不对或复位电容不合适 | 双晶振频率改为12MHz,复位电容改为10uF |
这里特别想分享一个调式技巧:写代码时在关键位置加一个LED指示翻转,比如按下按键时翻转P3.6的电平,这样不用接仿真器也能肉眼判断程序有没有跑到对应位置。我在调试LCD时序时就是靠这个定位问题的,比盲目看代码猜快很多。
另一个容易忽略的细节是定时器中断和主循环共享变量的问题。我的timerCount和gameTick是中断里修改、主循环里读取的,虽然中断不会被主循环打断,但为了防止编译器优化导致读取不到最新值,建议在变量声明前加上volatile关键字:
volatile unsigned char gameTick = 0; volatile unsigned short timerCount = 0;不加volatile的话,优化等级开高后,主循环可能一直读到旧值,表现就是游戏卡死或者速度不对。
还有一个存储资源的问题。STC89C52的RAM只有256字节,里面还要给堆栈留空间。我初始定义蛇身长度最大64,两个坐标数组加起来已经占128字节,再加其他变量,RAM就很紧张了。后来我把最大长度改成32,因为LCD1602总共才32个字符位置,蛇不可能超过32,这样占64字节,刚好够用。另外LCD显示缓冲区也不要随便定义大的数组,能直接写就写。
5. 扩展思路与实际体验
做完了基础版本以后,我觉得还能加一些功能让项目更完整。最简单的是加计分,用LCD第二行末尾显示数字;然后可以加难度递增,每吃5个食物把gameSpeed减2,游戏会越玩越刺激;还可以加一个最高分保存,把分数写入单片机内部EEPROM,掉电不丢失——STC89C52内部自带EEPROM,用STC下载软件就能读出数据。
如果想做得更花哨一点,可以接一个有源蜂鸣器,吃到食物响一声,撞墙响三声,用P3.5驱动,音效让游戏体验提升一个档次。
我个人在实际操作中最大的体会是:这个项目虽然叫“贪吃蛇小游戏”,但它的工程量恰好卡的很好——复杂度不会低到让人无聊,也不会高到让人崩溃。它逼着你去梳理清楚定时器、中断、状态机、数组操作这四个核心点,而这些正是后面学STM32、学RTOS都绕不开的底层能力。
如果你已经在Keil里写完了代码但还没下载到板子上,我最后再分享一个经验:先不要急着接LCD,先把LED流水灯跑起来确认最小系统工作正常,再一步一步接按键、接LCD。这个“由简到繁”的调试顺序会帮你把问题隔离得很干净,不至于硬件和软件问题混在一起的时候一头雾水。祝你的蛇跑得顺畅。
本文还有配套的精品资源,点击获取