☰
C51单片机贪吃蛇小游戏开发实战:从硬件到代码全解析
2026/9/26 7:19:15 网站建设 项目流程

简介:基于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 最小系统与外设清单

先把硬件清单列出来,这些都是我自己实际用过的,兼容性没问题:

器件型号/规格数量用途
单片机STC89C52RC1主控
晶振12MHz1时钟源
电容30pF2晶振负载电容
电解电容10uF1复位电路
电阻10K1复位上拉
LCDLCD16021显示
按键轻触按键4上下左右
电位器10K1LCD对比度调节
排阻10K 排阻1P0上拉
蜂鸣器5V有源1音效(可选)
面包板830孔1搭建电路

注意STC89C52RC的P0口是开漏输出,内部没有上拉电阻,驱动LCD数据线时必须外接10K排阻上拉,否则1602显示会花屏或者完全不显示。这是新手最容易踩的一个坑。

2.2 引脚分配与电路接线

我的接线方案是这样的:

模块引脚单片机引脚
LCD1602RSP2.0
LCD1602RWP2.1
LCD1602ENP2.2
LCD1602D4-D7P0.0-P0.3(4线模式)
按键上—P1.0
按键下—P1.1
按键左—P1.2
按键右—P1.3
蜂鸣器I/OP3.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。这个“由简到繁”的调试顺序会帮你把问题隔离得很干净,不至于硬件和软件问题混在一起的时候一头雾水。祝你的蛇跑得顺畅。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询