news 2026/10/26 2:24:56

51单片机贪吃蛇:8×8点阵扫描与定时器时间片调度实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
51单片机贪吃蛇:8×8点阵扫描与定时器时间片调度实战

简介:围绕51单片机贪吃蛇游戏课程设计,这套资料面向高校本科电子信息、自动化等专业学生及单片机初学者,提供从Proteus仿真、Keil工程源码到课程报告与答辩PPT的完整方案。压缩包共18个文件,约102.42MB,包含C源程序、HEX烧录文件、pdsprj仿真工程、uvproj/uvopt工程配置、docx设计报告和pptx展示文稿等,既有可编译代码也有仿真与文档材料。资源描述中的演示视频和报告可用于对照游戏规则理解蛇身移动、吃豆加速、撞墙或撞自身结束等逻辑。目前已有2443人学习,适合需要完成课程设计、复现实验或参考单片机小项目开发流程的读者,可据此梳理硬件仿真、程序结构与报告撰写思路。

1. 一块51、一个8×8点阵:贪吃蛇项目到底在练什么

基于51单片机的贪吃蛇游戏设计,真正难的不是把蛇画在8×8点阵上,而是让51这种资源很紧的8位机在2ms显示扫描、10ms按键扫描、200ms步进之间不互相踩脚。很多人第一次做,逻辑在电脑上跑得通,焊到板上就出现拖影、按键反向、分数掉电清零。这个项目适合刚学完定时器和C语言数组、想做一个能拿得出手的完整小系统的人;也适合已经做过流水灯、数码管,想补上状态机、点阵扫描和I2C存储的熟手。下面按硬件、调度、逻辑、交互、排查的顺序,把能复现的细节拆开。

2. 从需求倒推选型:51单片机贪吃蛇的硬件最小系统怎么搭

2.1 主控、显示与按键的三条选型线

做51单片机贪吃蛇,最容易犯的错是先画板再想资源。8×8点阵要16根IO,四个方向键要4根,24C02要2根,再加晶振、复位,32根IO的8051内核刚好够,但P0口通常要外接上拉排阻,否则列数据驱动无力,显示会发暗。显示选8×8单色点阵而不是LCD1602,理由很直接:贪吃蛇的网格是8×8,点阵一个像素对应一个格子,取模和坐标映射简单,游戏感也强;LCD1602只能显示字符,做蛇身要自己造字模,调试反而绕。

主控不追求具体品牌,选一款兼容8051、5V供电、12T或1T的8位单片机即可。11.0592MHz晶振的好处是定时器初值好算,12MHz也能用,但2ms和10ms的初值要重算。显示驱动可以用两片74HC595级联省IO,也可以像我一样直接用P0送列、P2送行,省掉移位寄存器时序,代价是占用16根IO。按键用四个轻触开关,接P3.2到P3.5,按下低电平,外部10k上拉。分数存储用24C02,I2C两根线上拉4.7k,WP接地。蜂鸣器可选,接P1.0经NPN三极管驱动,不要直接灌电流。

选型时先算电流:8×8点阵全亮,如果每列瞬时电流10mA,8列同时亮就是80mA,行扫描占空比1/8,平均电流小,但瞬时电流会拉低电源。5V/1A电源足够,每个芯片电源脚并0.1uF去耦,点阵列线串220Ω到330Ω限流。不要为了亮度把限流电阻降到100Ω以下,51端口总电流有限,长时间跑容易发热。

2.2 最小系统接线表与供电去耦

接线表比口头描述可靠,焊接前先对着表查一遍。下面这张表是常见做法,具体引脚可以按PCB布线调整,但P0上拉、行驱动三极管、I2C上拉这三处不要省。

信号51引脚外设电平与备注
行选H0-H7P2.0-P2.78×8点阵行线经1k基极电阻接NPN三极管,高有效
列数据C0-C7P0.0-P0.78×8点阵列线低有效,P0加10k排阻上拉
上/下/左/右P3.2-P3.5轻触开关按下低,10k上拉
SDA/SCLP3.6/P3.724C024.7k上拉,WP接地
晶振XTAL1/XTAL211.0592MHz两只22pF到地
复位RST10uF+10k高电平复位,电容正极接RST
蜂鸣器P1.0NPN三极管可选,基极1k

P0口是开漏结构,做输出时必须外接上拉,否则高电平出不来。点阵列线低有效时,P0写0点亮,写1熄灭,但写1要靠上拉把电平拉高。行线如果直接由P2灌电流,8行全亮时P2总电流可能超标,所以行线经NPN三极管扩流,P2输出高电平时三极管导通,行选有效。注意三极管是NPN还是PNP,取决于点阵是共阴还是共阳,接反了会出现整行常亮或全灭。

供电去耦别只看电源板,点阵旁边也要放0.1uF。复位电容用10uF电解加10k电阻,电容正极接RST,负极接地,上电时RST短暂高电平,单片机复位。晶振尽量靠近芯片,负载电容22pF左右,走线不要绕远。按键上拉电阻可以统一用一个10k排阻,省地方。

注意:P0口上拉排阻不要用太小阻值,4.7k虽然驱动强,但会增加端口低电平时的灌电流;10k是折中值,显示亮度不够时先查扫描占空比和限流电阻,不要一味减小上拉。

2.3 用定时器切时间片:2ms扫描和10ms按键的初值

51单片机没有操作系统,贪吃蛇的实时性全靠定时器。常见做法是Timer0每2ms中断一次做点阵行扫描,Timer1每10ms中断一次做按键扫描和游戏节拍计数。这样显示刷新和游戏逻辑分开,主循环只处理状态变化和食物生成,不会因为一个长循环把显示拖出拖影。

以11.0592MHz、12T模式为例,机器周期是12/11.0592MHz≈1.085us,2ms需要1843个计数,16位定时器初值65536-1843=63693,即0xF8CD;10ms需要9216个计数,初值65536-9216=56320,即0xDC00。

#include <reg52.h> // 11.0592 MHz、12T模式:Timer0=2ms,Timer1=10ms void timer_init(void) { TMOD &= 0x00; TMOD |= 0x11; // T0、T1都工作在模式1,16位定时器 TH0 = 0xF8; // 2ms: 65536 - 1843 = 63693 = 0xF8CD TL0 = 0xCD; TH1 = 0xDC; // 10ms: 65536 - 9216 = 56320 = 0xDC00 TL1 = 0x00; ET0 = 1; // 允许T0中断 ET1 = 1; // 允许T1中断 EA = 1; TR0 = 1; TR1 = 1; }

这段代码只做初始化,中断服务函数里要记得重装TH0/TL0和TH1/TL1,否则下一次中断时间会漂。Timer0中断里只做显示扫描,不调用延时、不做浮点、不写EEPROM;Timer1中断里可以置按键扫描标志和游戏tick标志,具体逻辑放主循环,减少中断嵌套带来的抖动。

如果你的8051内核是1T模式,机器周期不是12个时钟,初值必须按数据手册重算。直接抄0xF8CD会出现扫描频率翻几倍或慢几倍,表现是点阵闪烁或按键迟钝。12MHz、12T时2ms初值是0xF830,10ms初值是0xD8F0,也别混用。定时器初值算错是51贪吃蛇最隐蔽的翻车点之一,示波器看一眼P2行选波形,周期不对就先回到这里。

3. 贪吃蛇状态机与8×8点阵显示:把游戏循环拆成可调度的任务

3.1 蛇身循环队列与方向缓存的数据结构

8×8网格最多64格,蛇身用两个64字节数组存x、y就够,不需要链表。关键是索引用循环队列:head指向蛇头,tail指向蛇尾,len记录当前长度。移动时新头写到head+1的位置,没吃到食物就tail+1,长度不变;吃到食物长度加一,tail不动。这样避免每次移动搬整个数组,51的RAM和CPU都省。

方向不能直接改snake.dx/dy,否则快速连按左右会让蛇瞬间掉头撞自己。常见做法是加一个pending方向缓存,按键只写pending,snake_step每次只消费一次,并且丢弃与当前方向相反的输入。这样即使用户手快,蛇也只会按步进节奏转向。

#define MAX_LEN 64 typedef struct { unsigned char x[MAX_LEN]; unsigned char y[MAX_LEN]; unsigned char head; unsigned char tail; unsigned char len; signed char dx, dy; } Snake; Snake snake; unsigned char pending_dx, pending_dy, has_pending; unsigned char food_x, food_y; bit game_over, game_win; bit snake_occupied(unsigned char x, unsigned char y) { unsigned char i, idx = snake.tail; for (i = 0; i < snake.len; i++) { if (snake.x[idx] == x && snake.y[idx] == y) return 1; idx++; if (idx >= MAX_LEN) idx = 0; } return 0; } void snake_step(void) { signed char ndx = snake.dx, ndy = snake.dy; unsigned char nx, ny, new_head, i, idx; bit will_eat; if (has_pending) { // 禁止180度回头:与当前方向相反的输入直接丢弃 if (!(pending_dx == -snake.dx && pending_dy == -snake.dy)) { ndx = pending_dx; ndy = pending_dy; } has_pending = 0; } nx = snake.x[snake.head] + ndx; ny = snake.y[snake.head] + ndy; if (nx > 7 || ny > 7) { game_over = 1; return; } will_eat = (nx == food_x && ny == food_y); idx = snake.tail; for (i = 0; i < snake.len; i++) { if (snake.x[idx] == nx && snake.y[idx] == ny) { // 没吃到食物时,尾巴这一格会移走,允许新头进入 if (!(!will_eat && idx == snake.tail)) { game_over = 1; return; } } idx++; if (idx >= MAX_LEN) idx = 0; } new_head = snake.head + 1; if (new_head >= MAX_LEN) new_head = 0; snake.x[new_head] = nx; snake.y[new_head] = ny; snake.head = new_head; snake.dx = ndx; snake.dy = ndy; if (will_eat) { snake.len++; if (snake.len >= MAX_LEN) { game_win = 1; return; } place_food(); } else { snake.tail++; if (snake.tail >= MAX_LEN) snake.tail = 0; } }

这段代码里最值得看的是撞自己判断。没吃到食物时,尾巴会在这一步移走,所以新头可以进入尾巴原来的格子;如果简单遍历整个身体判撞,蛇在贴近尾巴转向时会被误判死亡。will_eat先算出来,再决定是否排除tail,逻辑就对了。len达到64时直接判胜,不再生成食物,避免数组越界。

食物生成不要用rand() % 64再查重,8×8小,直接收集所有空格再随机选一个更稳。随机种子不要固定,否则每次上电食物位置一样,玩家会觉得“玄学”。可以用定时器计数值异或一个非零常量做LFSR种子,LFSR不要选0。

unsigned char lfsr = 0xA5; unsigned char rand_u8(void) { unsigned char b = ((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1; lfsr = (lfsr >> 1) | (b << 7); return lfsr; } void place_food(void) { unsigned char empty_x[64], empty_y[64], n = 0, i, j, k; for (j = 0; j < 8; j++) { for (i = 0; i < 8; i++) { if (!snake_occupied(i, j)) { empty_x[n] = i; empty_y[n] = j; n++; } } } if (n == 0) { game_win = 1; return; } k = rand_u8() % n; food_x = empty_x[k]; food_y = empty_y[k]; }

place_food遍历64格,在51上耗时可控,但不要放在2ms中断里调用。参数上,LFSR种子可以在上电时读Timer1计数值低字节,再或上0x5A,保证非零。食物生成最多64次循环,不会阻塞显示,因为显示在Timer0中断里独立跑。

3.2 8×8点阵的行扫描与取模:先关行再送列

8×8点阵显示的核心是“先关行、再送列、最后开行”。顺序反了会出现鬼影:上一行的列数据还没撤掉,下一行已经打开,肉眼看到一条拖影。行扫描频率每行2ms,8行16ms一帧,约62.5Hz,人眼不闪。如果每行1ms,125Hz更稳,但中断更频繁,51在11.0592MHz下也能承受,看你的其他中断负担。

显示缓冲用一个8字节数组frame,frame[y]的bit0到bit7对应x=0到7。蛇身和食物都写进这个数组。Timer0中断里只做扫描,不重建frame。重建frame放主循环,避免中断里遍历蛇身。

volatile unsigned char frame[8]; void build_frame(void) { unsigned char i, idx; for (i = 0; i < 8; i++) frame[i] = 0; idx = snake.tail; for (i = 0; i < snake.len; i++) { frame[snake.y[idx]] |= (1 << snake.x[idx]); idx++; if (idx >= MAX_LEN) idx = 0; } frame[food_y] |= (1 << food_x); } void timer0_isr(void) interrupt 1 { static unsigned char row = 0; TH0 = 0xF8; TL0 = 0xCD; P2 = 0x00; // 先关行,消隐 P0 = ~frame[row]; // 列低有效;共阳点阵去掉取反 P2 = (1 << row); // 行高有效 row++; if (row >= 8) row = 0; }

这段扫描代码假设列低有效、行高有效。如果你的点阵是共阳,列可能高有效,P0输出不要取反;如果行低有效,P2输出要取反。接反的现象是显示全亮或全灭,不要先怀疑程序,用万用表量一下行线电平。P0送列数据前要先关行,P2=0x00就是消隐;如果省略这一句,换行瞬间上一行列数据会串到下一行。

frame被主循环写、中断读,存在撕裂风险:主循环刚清完frame还没写完蛇身,中断进来扫到一半,屏幕会闪一下。8×8小,肉眼不一定明显,但最后一章会讲双缓冲改法。现在先保证扫描频率和消隐顺序正确。

3.3 主循环时间片:10ms按键、2ms显示、200ms步进

主循环不做死延时,用标志位驱动。Timer1每10ms置key_tick和game_tick标志,主循环看到key_tick就调key_scan_10ms,看到game_tick就累加步进计时。步进间隔由速度表决定,初始300ms,每吃4节升一级,最低120ms。这样蛇的速度变化是离散的,不会因为浮点运算或长循环突然跳帧。

volatile unsigned char key_tick = 0; volatile unsigned int game_tick = 0; unsigned char level = 0; const unsigned char speed_tab[10] = {30,28,26,24,22,20,18,16,14,12}; // 单位10ms void timer1_isr(void) interrupt 3 { TH1 = 0xDC; TL1 = 0x00; key_tick = 1; game_tick++; } void main(void) { timer_init(); snake_init(); while (1) { if (key_tick) { key_tick = 0; key_scan_10ms(); } if (game_tick >= speed_tab[level]) { game_tick = 0; if (!game_over && !game_win) { snake_step(); build_frame(); update_level(); } } } }

speed_tab单位是10ms,30对应300ms,12对应120ms。level由snake.len/4推导,最多截到9。game_tick在Timer1里累加,主循环比较后清零。注意build_frame放在snake_step之后,保证显示和逻辑同步;如果先build再step,屏幕会慢一帧,快速转向时看起来像按键延迟。update_level只改level,不清game_tick,最多晚一个tick生效,影响不大。

主循环里不要放delay_ms,按键消抖和EEPROM写延时都在各自函数里短延时,不能阻塞显示。EEPROM写6ms会掉几帧显示,但写最高分只在游戏结束时发生,可以接受。如果游戏过程中频繁写EEPROM,屏幕会明显闪,这是设计问题,不是显示问题。

4. 按键消抖、速度曲线与分数存储:三个容易被忽略的交互细节

4.1 方向键消抖与禁止180度回头的实现

轻触开关按下时会有几毫秒到几十毫秒的抖动,直接读P3会一次按下触发多次转向。常见做法是10ms扫描一次,连续3次读到相同电平才认,也就是30ms消抖。消抖状态机比delay debounce可靠,因为不阻塞主循环。四个按键分别计数,按下沿触发方向缓存,释放沿不触发。

#define KEY_UP 0x04 // P3.2 #define KEY_DOWN 0x08 // P3.3 #define KEY_LEFT 0x10 // P3.4 #define KEY_RIGHT 0x20 // P3.5 void key_scan_10ms(void) { static unsigned char cnt[4] = {0,0,0,0}; static unsigned char stable[4] = {1,1,1,1}; unsigned char raw[4], i; raw[0] = (P3 & KEY_UP) ? 1 : 0; raw[1] = (P3 & KEY_DOWN) ? 1 : 0; raw[2] = (P3 & KEY_LEFT) ? 1 : 0; raw[3] = (P3 & KEY_RIGHT) ? 1 : 0; for (i = 0; i < 4; i++) { if (raw[i] != stable[i]) { if (++cnt[i] >= 3) { // 连续3次10ms一致才认 stable[i] = raw[i]; cnt[i] = 0; if (stable[i] == 0) { // 按下沿 if (i == 0) { pending_dx = 0; pending_dy = -1; has_pending = 1; } if (i == 1) { pending_dx = 0; pending_dy = 1; has_pending = 1; } if (i == 2) { pending_dx = -1; pending_dy = 0; has_pending = 1; } if (i == 3) { pending_dx = 1; pending_dy = 0; has_pending = 1; } } } } else { cnt[i] = 0; } } }

raw[i]为0表示按下,stable初始为1。连续3次读到0才把stable改成0并触发一次方向缓存;释放时连续3次读到1才改回,不触发。30ms消抖对贪吃蛇够用,如果按键手感差,可以改成5次50ms,但转向会显得迟滞。方向缓存只写pending,不直接改snake.dx/dy,禁止180度回头的逻辑在snake_step里做,这样按键扫描和游戏步进解耦。

注意按键扫描不要在Timer1中断里直接改pending,因为snake_step可能在主循环里同时读pending。简单做法是Timer1只置key_tick,主循环调key_scan_10ms;如果非要在中断里扫,pending要用volatile并关中断保护,51上不划算。

4.2 用查表做速度曲线:别让蛇一加速就撞墙

贪吃蛇的速度曲线不是越线性越好。初始300ms给玩家反应时间,每吃4节升一级,间隔减20ms,最低120ms。120ms以下,按键从按下到下一次步进可能只有一两帧,新手很容易撞墙。用查表比用公式省CPU,也避免浮点。表里存10ms为单位的间隔,主循环直接比较game_tick。

unsigned char update_level(void) { unsigned char new_level = snake.len / 4; if (new_level > 9) new_level = 9; level = new_level; return level; }

假设蛇初始长度3,吃第一个食物后len=4,level=1,间隔从300ms变280ms;吃到len=40时level=9,间隔120ms。len=64时level被截到9,不会越界。参数上,每4节升一级是折中,想更平滑可以每3节升一级,但最高级提前到来;想更硬核可以每5节升一级,后期节奏慢。速度表不要写成speed_tab[level]越界,level先截断再查表。

如果游戏结束后重新开始,记得把level和game_tick清零,否则新一局直接继承上一局速度。这个坑很常见:玩家死了重新开始,蛇一出来就飞快,以为是随机数,其实是level没复位。

4.3 24C02存最高分:擦写次数与掉电保护

51芯片内部通常没有可反复擦写的EEPROM,分数存储常见做法是外挂24C02。I2C两根线接P3.6/P3.7,4.7k上拉到5V,WP接地允许写。上电先读地址0x00的两个字节,如果读到0xFFFF就当作0分,避免第一次上电显示255。游戏结束且分数超过最高分才写,不要每吃一个食物写一次,24C02擦写次数有限,写周期最大5ms,频繁写还会闪屏。

#define EEP_ADDR 0xA0 void eeprom_write_u16(unsigned char addr, unsigned int v) { i2c_start(); i2c_write(EEP_ADDR); // 写设备地址 i2c_write(addr); // 字地址 i2c_write(v >> 8); i2c_write(v & 0xFF); i2c_stop(); delay_ms(6); // 24C02写周期最大5ms,留余量 } unsigned int eeprom_read_u16(unsigned char addr) { unsigned int v; i2c_start(); i2c_write(EEP_ADDR); i2c_write(addr); i2c_start(); // 重复起始 i2c_write(EEP_ADDR | 1); v = i2c_read_ack() << 8; v |= i2c_read_nack(); i2c_stop(); return v; }

i2c_start、i2c_write、i2c_read_ack、i2c_read_nack是软件模拟函数,按24C02时序写,SCL高电平期间SDA不能跳变。写完之后延时6ms再访问,否则24C02内部写周期没结束,下一次读会返回旧数据或0xFF。上电初始化时读一次最高分,如果等于0xFFFF就写0并更新显示。WP引脚不要悬空,悬空可能被干扰成写保护,表现为分数永远存不进去。

分数变量用unsigned int,8×8最多64分,unsigned char也够,但用int方便以后扩展。写最高分前先关全局中断?不需要,但写6ms会掉显示,最好在游戏结束画面

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 2:24:43

Kubernetes服务网格选型与生产实践指南

1. 为什么Kubernetes需要服务网格在云原生架构中&#xff0c;服务网格&#xff08;Service Mesh&#xff09;已经成为微服务通信的基础设施层。当你的Kubernetes集群中运行的服务数量超过两位数时&#xff0c;传统的服务间通信方式就会暴露出诸多问题。我曾在一次生产环境迁移中…

作者头像 李华
网站建设 2026/10/26 2:24:39

思科7960/7940 SIP固件8.6:TLS 1.2与SRTP加密配置指南

简介&#xff1a;本资源为思科IP电话7960/7940系列适配SIP协议的8.6版本完整固件包&#xff0c;专为网络通信工程师、UC系统运维人员及VoIP设备升级实践者设计&#xff0c;解决从旧版8.3.1S直接升级至9.x时因缺失中间版本导致的自动重启、升级失败等典型问题。压缩包共6个核心文…

作者头像 李华
网站建设 2026/10/26 2:24:14

MASM for Windows集成实验环境:32位汇编教学闭环沙盒

简介&#xff1a;本资源是一套开箱即用的Windows平台汇编语言集成实验环境&#xff0c;专为高校计算机专业学生、汇编初学者及低层开发爱好者设计&#xff0c;有效解决传统DOS环境下MASM开发配置复杂、界面不友好、调试困难等痛点。压缩包共25个文件&#xff0c;含10个可执行程…

作者头像 李华
网站建设 2026/10/26 2:22:53

STM32H743+TouchGFX图形驱动实战:LTDC/SDRAM/DMA2D协同调试指南

简介&#xff1a;本资源是一套基于STM32H743IIT6微控制器的完整TouchGFX嵌入式GUI开发工程&#xff0c;面向嵌入式中级开发者及GUI界面进阶学习者&#xff0c;解决高性能Cortex-M7平台下图形界面移植、触摸驱动适配与CubeMXHAL协同开发等核心难点。压缩包共1172个文件&#xff…

作者头像 李华
网站建设 2026/10/26 2:22:20

2026寒假牛客2.5:计算机专业高效学习计划解析

1. 项目背景解析"2026寒假牛客2.5"这个看似简单的标题背后&#xff0c;实际上隐藏着一个面向计算机专业学生的系统性学习计划。作为在编程教育领域深耕多年的从业者&#xff0c;我见过太多学生在寒假期间要么彻底放松&#xff0c;要么盲目学习导致效率低下。这个编号…

作者头像 李华
网站建设 2026/10/26 2:21:49

Redis延迟双删策略:解决高并发缓存一致性问题

1. 延迟双删的本质与核心价值Redis作为现代分布式系统中最常用的缓存组件&#xff0c;其数据一致性问题是每个开发者必须直面的挑战。延迟双删&#xff08;Delayed Double Delete&#xff09;策略本质上是一种权衡方案——它不像强一致性方案那样带来巨大性能损耗&#xff0c;又…

作者头像 李华