简介:这是一份基于C51单片机(8051系列)的贪吃蛇小游戏完整工程资源,面向单片机初学者和嵌入式硬件爱好者,可作为课程设计或大作业参考,也能帮助理解从C51编程到硬件控制的完整流程。包内共30个文件,压缩包仅66KB,含6个C源码、头文件、启动文件、Keil工程文件、hex烧录文件及obj/lst/m51等中间文件,结构清晰,方便直接编译和烧录。已有1027人浏览学习,适合想快速上手51单片机游戏项目、学习定时器、中断、IO操作与算法设计的开发者。通过该工程可掌握C51语法、定时器控制游戏帧率、按键检测、蛇的移动与碰撞判定、食物随机生成,以及Keil编译与在线调试的流程,是理解嵌入式系统开发的良好实践。 把 C51 单片机从“点亮一颗 LED”带到“能完整玩一局贪吃蛇”,中间隔的其实不是多少行代码,而是有没有一套完整的设计思路。我是在实验室跟一个学弟聊毕设选题时重新做了一遍这个项目的,起因很简单——他流水灯、数码管、按键中断都练过,但真让他独立做一个带完整闭环的小项目,他完全不知道从哪里下手。贪吃蛇恰好是这样一个项目:硬件上只需要单片机、8×8 点阵、四个按键和几个电阻,软件上却能覆盖定时器、中断、动态扫描、状态机、坐标换算、随机数、碰撞检测这些入门阶段最该吃透的东西。这篇就按我自己从零搭的过程来写,适合学完基础例程、准备做第一个完整项目的朋友,也适合蓝桥杯国赛前拿它熟悉整套流程的人。
1. 为什么用 C51 做贪吃蛇:一个“小游戏”背后的需求拆解
先说明一个很多人忽略的事实:贪吃蛇在电脑上写,用链表加图形库也就是一两百行的事,为什么非要拿到 8 位单片机上折腾?因为在电脑上写,重点是“游戏”;在 C51 上写,重点是“系统”。这颗芯片的 CPU 在 12MHz 晶振下,实际指令周期只有 1MHz,内部 RAM 只有 128 字节,没有操作系统,没有现成的图形库,所有任务都要自己排时间表——这个约束才是项目的核心价值。
1.1 把游戏需求翻译成单片机任务
贪吃蛇表面上有三件事:显示画面、接收方向、更新蛇身。落到单片机上,对应关系是这样的:
- 显示画面 → 8×8 LED 点阵的动态扫描,需要定时刷新行数据;
- 接收方向 → 独立按键的扫描与防抖,需要周期性读取 IO;
- 更新蛇身 → 游戏逻辑和碰撞检测,需要按固定节拍推进。
这三件事不能全挤在死循环里顺序执行,也不能互相用 delay 阻塞,否则会出现按键没响应、画面闪烁这类问题。所以项目的第一步就是拆时间片,把“游戏逻辑”“按键扫描”“显示刷新”安排成三个并行的小任务。
1.2 一份可以直接照抄的硬件清单
我用的主控是 STC89C52RC,兼容标准 8051 指令集,Keil 里选 AT89S52 或 STC89C52 都能编译。硬件清单如下:
- 主控:STC89C52RC 或 AT89S52,DIP40 封装;
- 显示:8×8 单色点阵模块(共阴或共阳均可,代码里留极性宏);
- 输入:4 个独立按键,分别控制上、下、左、右,再加 1 个复位键;
- 晶振:12MHz 陶瓷晶振或直插晶振,配两个 30pF 电容;
- 辅助:10kΩ 按键上拉排阻、1 个 10μF 电解电容做复位、若干跳线。
需要说明的是,如果用 P0 口驱动点阵的列线,务必外接 4.7kΩ~10kΩ 上拉电阻。标准 51 的 P0 口是开漏输出,不加上拉,高电平驱动能力非常弱,点阵亮度会明显发暗,Proteus 仿真里还不太容易看出来,实物的坑尤其明显。
2. 硬件选型:为什么选 8×8 点阵加独立按键,而不是 LCD1602
做显示方案时,网上大量例程用 LCD1602 显示贪吃蛇,比如把蛇身画成自定义字符。这个方案能做,但我劝你第一个版本别这么做。LCD1602 本身是字符屏,一行显示 16 个字符,一个字符要占 5×8 的点阵,想画一个连续的、会移动的蛇身,你得不断刷新整个字符缓冲区,代码复杂度并不低,而且画面视觉上非常“块状”,谈不上游戏感。
8×8 点阵就不一样:它本身就是像素矩阵,蛇身每格对应一个 LED,移动就是改一个坐标点的亮灭,思维模型和游戏逻辑完全一致。缺点是 I/O 占用偏多——16 个引脚,初学者直接用两个 8 位口驱动是最容易理解的方案,后面熟练了再换成 74HC595 串转并来省引脚。
2.1 点阵动态扫描的驱动逻辑
8×8 点阵内部是 8 行 8 列共 64 个 LED。以共阴模块为例,给某一行接高电平、某一列接低电平,交叉点的 LED 就会点亮。如果你一个个地点,64 个点根本忙不过来;实际做法是“逐行扫描”:每次只点亮一行,这一行里需要亮的列就拉低,其他行全部关断,然后切到下一行。只要每秒刷新的次数超过 50 次,人眼因为视觉暂留会看到一整幅完整的画面。
我用定时器中断做扫描,每 2ms 切换一行,8 行一共 16ms 扫完一轮,刷新率约 62Hz,画面稳定不闪烁。如果扫得太快,比如低于 50Hz,画面会明显闪;扫得太快又没有意义,因为每行点亮时间缩短,亮度反而下降。
2.2 引脚分配与最小系统电路
我这次的引脚分配很简单,适合直接照抄:
| 功能 | 引脚 | 说明 |
|---|---|---|
| 点阵行选 | P2.0~P2.7 | 逐行输出高电平 |
| 点阵列数据 | P0.0~P0.7 | 低电平点亮,P0 需上拉 |
| 方向键上 | P3.0 | 独立按键,低电平有效 |
| 方向键下 | P3.1 | 同上 |
| 方向键左 | P3.2 | 同上 |
| 方向键右 | P3.3 | 同上 |
| 蜂鸣器 | P1.0 | 可选,用于吃食音效 |
按键接法是最常见的:一端接 GND,另一端接 IO 口并接上拉到 VCC,按下时 IO 变成低电平。这样在程序里读引脚时,默认读到高电平,按键触发读低电平,判断逻辑简单。晶振选择 12MHz,是因为 12MHz 下机器周期刚好是 1μs,定时器初值计算非常方便。
3. 软件骨架:时间片轮询如何同时管住刷新、按键和游戏逻辑
很多初学者的第一个完整项目,通常是在 main 函数里写一个大死循环,每步都 delay 一下。比如游戏逻辑处理完 delay 200ms,再扫描按键。这个写法在贪吃蛇项目里会立刻暴露问题:你按方向键时,主循环可能正卡在 delay 里,按键要等几百毫秒才被响应,游戏体验非常差。
3.1 前后台结构与时基的产生
我用的是典型的前后台结构:定时器中断作为“前台”,负责产生稳定的时间片;main 死循环作为“后台”,只在标志位置位的时候执行对应任务。这样按键扫描每隔固定时间执行一次,游戏逻辑每隔固定节拍执行一次,显示扫描用最高的频率刷新,三者互不阻塞。
定时器 0 配置为方式 1,也就是 16 位定时器。12MHz 晶振下机器周期 1μs,我要 2ms 中断一次,初值计算如下:
// 2ms = 2000 个机器周期 // 初值 = 65536 - 2000 = 63536 = 0xF830 TMOD = 0x01; // 定时器0,方式1 TH0 = 0xF8; TL0 = 0x30; ET0 = 1; // 开定时器0中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器中断里除了切换点阵扫描行,还放了两个计数器:累计 10 次中断(20ms)置位“按键扫描”标志;累计 250 次中断(500ms)置位“游戏移动”标志。移动节拍后面要做加速,所以这个 500ms 可以改成全局变量,每吃到一个食物减少一点。
void t0_isr(void) interrupt 1 { static unsigned char row = 0; unsigned char tick; TH0 = 0xF8; TL0 = 0x30; // 重装初值 // 点阵下一行扫描 row = (row + 1) & 0x07; set_row(row); write_col(map_buffer[row]); // 时间片计数 if (++tick_scan >= 10) { tick_scan = 0; key_flag = 1; } if (++tick_move >= move_speed) { tick_move = 0; move_flag = 1; } }3.2 主循环里只做慢任务
主循环看起来非常干净,所有快速操作都在中断里完成了,main 里只处理事件:
void main(void) { sys_init(); game_init(); while (1) { if (key_flag) { key_flag = 0; key_proc(); } if (move_flag) { move_flag = 0; game_update(); } } }这种结构的最大好处是:按键扫描和游戏逻辑永远以固定的时间周期执行,不会因为某次游戏逻辑运算较长而影响下一帧的响应。这也是之后学 RTOS 前最好的一课——时间片轮询本身就是最轻量的多任务思想。后面你换 STM32、用定时器中断做调度,思路完全一样。
4. 贪吃蛇核心算法:蛇身存储、食物生成与碰撞检测的 C51 实现
硬件和骨架搭好后,真正有趣的游戏逻辑就来了。C51 的资源有限,算法必须为单片机“定制”,不能直接把电脑上的写法搬过来。
4.1 蛇身存储:静态数组,不用链表
电脑上做贪吃蛇,很多人会用链表,因为蛇身长度动态变化,增删节点方便。但在 51 单片机上,我明确建议用静态数组,原因有两个:一是 128 字节的 RAM 很小,链表每个节点要存数据加指针,开销明显;二是 C51 的 malloc 支持并不方便,而且动态内存容易产生碎片,在一个游戏项目里完全没有必要。
我的数据结构是这样:
#define MAP_W 8 #define MAP_H 8 #define MAX_SNAKE_LEN 32 unsigned char snake_x[MAX_SNAKE_LEN]; unsigned char snake_y[MAX_SNAKE_LEN]; unsigned char snake_len; unsigned char food_x, food_y; unsigned char g_dir; // 0上 1下 2左 3右坐标用 unsigned char,正好装下 0~7 的范围。蛇头在数组下标 0,蛇尾在 snake_len-1。移动时整体向前挪一位,把新蛇头填入数组头部,如果没吃到食物,就直接把最后一个元素丢弃。
4.2 移动、食物生成与碰撞检测的代码
移动函数的核心逻辑如下:
void game_update(void) { unsigned char i; signed char dx, dy; switch (g_dir) { case 0: dx = 0; dy = -1; break; case 1: dx = 0; dy = 1; break; case 2: dx = -1; dy = 0; break; default: dx = 1; dy = 0; break; } // 计算新蛇头坐标 char new_x = (signed char)snake_x[0] + dx; char new_y = (signed char)snake_y[0] + dy; // 撞墙检测 if (new_x < 0 || new_x >= MAP_W || new_y < 0 || new_y >= MAP_H) { game_over(); return; } // 吃食物判断 unsigned char eat = (new_x == food_x && new_y == food_y) ? 1 : 0; // 蛇身前移 if (eat) { for (i = snake_len; i > 0; i--) { snake_x[i] = snake_x[i - 1]; snake_y[i] = snake_y[i - 1]; } snake_len++; } else { for (i = snake_len - 1; i > 0; i--) { snake_x[i] = snake_x[i - 1]; snake_y[i] = snake_y[i - 1]; } } snake_x[0] = new_x; snake_y[0] = new_y; // 撞自身检测:绕过蛇头本身,从下标1开始 for (i = 1; i < snake_len; i++) { if (snake_x[i] == snake_x[0] && snake_y[i] == snake_y[0]) { game_over(); return; } } if (eat) { gen_food(); if (move_speed > 20) move_speed -= 2; // 加速 } update_buffer(); }食物生成这里有个坑:C51 标准库自带 rand(),但每次上电默认种子都是一样的,导致第一局食物位置固定。我在游戏初始化时用定时器的计数值当种子:
void gen_food(void) { do { food_x = rand() % MAP_W; food_y = rand() % MAP_H; // 利用 TL0 的低位打乱 rand 的低位,避免规律性明显 if (TL0 & 0x01) food_x = MAP_W - 1 - food_x; } while (cell_on_snake(food_x, food_y)); }4.3 方向键的防反逻辑
还有一个小细节:游戏进行中如果蛇正在向上走,你按“下”,蛇会直接掉头撞上自己。处理办法很简单,按键扫描时对比当前方向和欲设置方向是否为相反方向,如果是就丢弃这次按键:
void key_proc(void) { if (key_up_pressed && g_dir != 1) g_dir = 0; if (key_down_pressed && g_dir != 0) g_dir = 1; if (key_left_pressed && g_dir != 0 && g_dir != 1) g_dir = 2; if (key_right_pressed && g_dir != 0 && g_dir != 1) g_dir = 3; }这样蛇只能向左转或向右转,不能 180 度调头,游戏逻辑才符合直觉。
5. 开发调试中的五个坑:从 Keil C51 支持包到 Proteus 仿真
这部分是我最想写的,因为这些坑浪费了我大量时间,而且网上资料往往只讲一半。
5.1 Keil5 装 C51 支持包:MDK 和 C51 不是同一个东西
很多新手电脑上装的是 Keil5,默认装的是 MDK-ARM,用来编 STM32 的。打开 Keil 后发现找不到 51 单片机型号,以为软件坏了。其实 Keil5 的 C51 支持是要单独安装的,装完 Pack Installer 里选 Legacy Device 或对应 51 型号即可。MDK-ARM 和 C51 可以共存,不用卸载重装。
另外注意:同一台电脑装 Keil4 和 Keil5 也能共存,但 Keil4 创建的工程被 Keil5 打开后,工程文件格式会升级,再拿回 Keil4 打开可能报错。建议一步到位用 Keil5 + C51 支持包。官方评估版有 2KB 代码限制,贪吃蛇这种小项目完全够用,学习阶段不需要额外折腾授权。
5.2 调试 C51 的时候,System Viewer 空白是正常的
网上有人问“8051 单片机在 Keil 仿真时 System Viewer 是空白的”,我一开始也以为仿真坏了。后来才明白,System Viewer 主要给 ARM 内核用,C51 工程里它就是一片空白。要看 51 的寄存器、IO 状态,应该打开菜单栏的 Peripherals,在里面选 Timer、Parallel Ports、Serial 等窗口,P0/P1/P2 的高低电平、定时器计数值都能实时看。仿真时配合这些窗口调点阵扫描顺序,比盲调快得多。
5.3 Proteus 仿真 STC 单片机时,xdata 是个坑
用 Proteus 仿真 STC 增强型单片机时,如果代码里声明了大数组,比如 unsigned char xdata buf[512],会出现数据不刷新、甚至仿真直接卡死的情况。这属于仿真模型对 xdata 内存支持不完整,不是真实芯片的问题。贪吃蛇的地图只有 8×8,坐标数组放内部 data 区完全够用,没必要碰 xdata。如果以后要做 16×16 的大地图,再考虑外扩 RAM 或者换仿真模型。
5.4 按键防抖不能 delay,点阵要防“鬼影”
C51 例程里最常见的按键防抖写法是 delay(20),这在流水灯项目里没问题,但在贪吃蛇这种持续刷新的项目里是灾难:按下按键的瞬间,主循环被 delay 卡住,点阵扫描中断虽然还在走,但屏幕会明显闪一下,游戏移动节拍也会被干扰。正确做法是每 20ms 扫描一次按键,连续两次读到同一键按下,才认为有效。这样既不阻塞,又很稳。
点阵动态扫描还有一个经典问题叫“鬼影”:上一行的 LED 还没完全熄灭,下一行的数据就送进来了,画面会出现残影。解决方法是切换行时先把显示关掉,更新行选和列数据,再开显示。简单说就是“先灭、再换行、再亮”,顺序不能反。
5.5 P0 口一定要接上拉电阻
前面提过,这里再强调一次:P0 口内部没有上拉电阻,驱动点阵列线时必须外接上拉。漏接的话,Proteus 仿真可能一切正常,实物上点阵亮度极低、甚至高电平拉不起来。我见过不少人调了半天以为是代码问题,最后发现是电路漏了 8 个电阻。
6. 还能怎么玩:难度分级、分数显示与双人模式的扩展思路
做完基础版之后,这个项目还有很多可以延展的地方,我列几个我觉得性价比比较高的方向。
6.1 难度分级与最高分记录
基础版的移动节拍是固定 500ms,我把它做成全局变量后,可以很自然地分出三档难度:简单 500ms、普通 300ms、困难 150ms。开机时按一次按键选择难度即可。再进一步,把“最高分”存到单片机内部 EEPROM 或外部 AT24C02 里,掉电不丢失。STC89C52 内部自带 EEPROM,不用额外接芯片,这个功能直接让项目从“课程设计”变成“能展示的完整作品”。
6.2 加一块 LCD1602 做信息面板
点阵主屏负责游戏画面,分数、难度、游戏状态这些文字信息,可以放到一块 LCD1602 上显示。这样两个模块各司其职,成绩展示也更直观。注意 LCD1602 和点阵都用 P0/P2 口的话,需要加锁存器或者分时复用总线,建议给 LCD1602 单独分配一组 IO,比如 P1 口,代码逻辑会简单很多。
6.3 音效、双人对战和大地图
蜂鸣器接到 P1.0,吃到食物时响一声短音,游戏结束时响一段长音,成本不到两块钱,体验提升非常明显。双人模式则是把地图从 8×8 扩展到 16×16、用两块点阵拼接,两个人各控制一条蛇,比赛谁先撞死。这个版本我建议放到基础版跑通之后再做,因为 16×16 的地图意味着坐标计算、点阵扫描、食物生成逻辑都要重写一遍,工作量并不小,但对提升 C51 的熟练度非常有效。
做完这个项目,我最直观的感受是:很多在电脑上顺理成章的事,在只有 128 字节 RAM 的芯片上都要重新设计。比如蛇身用链表当然优雅,但静态数组更省;比如游戏逻辑用大循环自然,但要学会和 2ms 的中断抢时间片。正是这些约束逼着你去理解状态机和时间片轮询,而不是写完就跑。如果你也想做一个真正拿得出手的单片机入门项目,别急着上 32 位平台,先用 C51 把这套东西走通,后面换 STM32 也只是 API 不同、思路相通。个人建议先 Proteus 仿真验证逻辑,再焊最小系统接实物点阵,两个阶段踩到的坑会完全不一样,但都值得踩一遍。
本文还有配套的精品资源,点击获取