51单片机学习笔记——OLED贪吃蛇


一、成果展示

功能
1.贪吃蛇的基本游戏规则
2.有开始和结束界面
3.实现计分功能
4.游戏有无墙和有墙两种模式
5.游戏有简单和困难两种难度
6.在开始和结束时有声音提示
开始与结束界面
游戏画面
硬件焊接部分

源码链接,提取码:o93u

二、软件部分

1.OLED模块与SSD1306使用

  • OLED引脚图
    OLED的引脚与SSD1306和单片机的通信方式有关。常见的有SPI,I2C串行通信和并行通信等。博主购买的是7针的OLED模块,引脚详情如下。
引脚 名称 解释
1 GND 接地端
2 VCC 电源端
3 D0 在 SPI 和 I2C 通信中为时钟管脚(SCLK)
4 D1 在 SPI 和 I2C 通信中为数据管脚(MOSI)
5 RES 复位管脚,低电平有效
6 DC 数据和命令控制管脚,0写命令,1写数据
7 CS 片选信号输入端,当输入低电平,表明OLED被选中,若只有OLED通信可直接接地
  • OLED指令
    与LCD1602类似,DC管脚置0之后,向OLED中写入命令,下表为部分指令。
指令 解释
00H-0FH 页地址模式下设置起始地址低位
10H-1FH 页地址模式下设置起始地址高位
20H 设置寻址模式,00H/01H/02H为水平/垂直/页地址模式
26H/27H 设置水平滚动的起始页,终止页和滚动速度
29H/2AH 设置垂直和水平滚动的起始页,终止页,滚动速度,垂直滚动偏移
2EH 禁用滚动,调用后RAM数据需要重写
2FH 启用滚动,在26H/27H/29H/2AH设置好后调用
40H-7FH 设置屏幕(地址)起始行,取值范围为[0,63],一般从头显示
81H 设置对比度,共有256级对比度
A0H/A1H 设置段重映射,A0H左右反置,A1H正常
A3H 设置滚动垂直区
A4H/A5H 设置全屏点亮,A5H无视GDDRAM点亮全屏,A4H正常
A6H/A7H 设置反转显示,A7H反转(0表示点亮),A6H正常(1表示点亮)
A8H 设置复用率,默认63
AEH/AFH 设置屏幕开启/关闭,AEH关闭屏幕,AFH开启屏幕
B0H-B7H 页地址模式下设置目标显示位置起始地址
C0H/C8H 设置列输出扫描方向,C0H左右反置,C8H正常
D3H 设置显示偏移
D5H 设置显示时钟震荡频率
D9H 设置预充电周期
DAH 设置列引脚硬件配置
DBH 设置VCOMH反压值
E3H 空指令,不产生作用
  • OLED与贪吃蛇

第一,由于OLED模块不能发送数据,所以不能读取OLED显存(GDDRAM)中的值。第二,OLED是对页地址进行整体赋值,贪吃蛇在显示蛇,食物等方面都要求对像素点进行操作。所以在OLED上显示像素点就会有困难。比如说某一页上的数据是 (0100 0000) 。如果想让其他像素点显示,会对这一页重新赋值,比如(0010 0000)。赋值过后,先前的数据就会被覆盖。也就是说一页只能打一个点,因此为了避免这种情况,要记录之前显示过的值,必须要在单片机内部申请一块内存区域充当OLED的显示缓存区,每次打点的时候读取先前的数据进行运算后再给OLED传送数据。

  • 在OLED上打印像素点

博主购买的OLED附带中景园电子的部分源码,商家可能考虑到内存问题,就给打点函数删除掉了,博主花了好几天时间,查阅了相关资料,以及学长的帮助下,才完成了下面打印像素点的两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*****************************************************************/
unsigned char OLED_GRAM[64][8] = {0};   //申请一个二维数组作为显存
/*****************************************************************/
//显示地图上的点
//我设置的是y轴向下的坐标系
void OLED_Write_GRAM(u8 x,u8 y,bit value)
{
    u8 OLED_page = y/8;
    u8 OLED_page_value = 1 << y%8;
    if(x>=64)
            return;
    if(value)OLED_GRAM[x][OLED_page]|=OLED_page_value;
    else OLED_GRAM[x][OLED_page]&=~OLED_page_value;
}
/*****************************************************************/
//向OLED传输显存数据
//更新显存到OLED  
void OLED_Refresh(void)
{
    unsigned char i,n;
    for(i=0;i<8;i++)
    {
       OLED_WR_Byte(0xb0+i,OLED_CMD); //设置行起始地址
       OLED_WR_Byte(0x00,OLED_CMD);   //设置低列起始地址
       OLED_WR_Byte(0x10,OLED_CMD);   //设置高列起始地址
       for(n=0;n<64;n++)
        {
            OLED_WR_Byte(OLED_GRAM[n][i],OLED_DATA);
            //delay_ms(1);
        }
    }
    for(i=0;i<64;i++)
        OLED_WR_Byte(0x00,OLED_DATA);
}
/*****************************************************************/

以上代码需要注意的几个地方:

1
 unsigned char OLED_GRAM[64][8] = {0};

在查阅相关资料发现,博主购买的单片机是STC公司的89C54RD+和12C5A60S2,其内存大小为均1280字节。OLED显示屏共128列8页,若在单片机中申请内存则是128×8×1=1024个字节。博主试过一次,IO口电平全部被拉低了…也就说申请的内存占用了IO口的寄存器。所以申请64×8的内存,节省一点,让蛇在左半屏活动就好了~

1
2
OLED_Write_GRAM()函数中有一句 if(x>=64)return;
OLED_Refresh()函数中有一句 for(i=0;i<64;i++)  OLED_WR_Byte(0x00,OLED_DATA);

没有这两句,OLED的显示过程中会出现这种诡异的情况,最后一页乱码了。博主猜测是由于使用的12单片机,送数据和送命令之间切换过快导致的。
最后一页乱码

  • 在OLED上打印汉字和数字

在中景园电子提供的代码中,下面两个函数用于实现汉字的打印和数字的打印,非常方便。用于打印菜单和得分。

1
2
void OLED_ShowNum(u8 x,u8 y,u16 num,u8 len,u8 size2);//x,y坐标,num打印的数字,len数字的位数,size字体大小
void OLED_ShowCHinese(u8 x,u8 y,u8 no);//x,y坐标,no汉字的序号

2.贪吃蛇的基本算法

  • 打印
1
2
3
4
void Print_Map();               //打印地图
void Print_Snake();             //打印蛇的坐标
void Print_Food();              //打印食物
void Print_Clear();             //清屏方法

以上是部分打印函数。这几个函数都使用了打点函数,也就是左半屏,用于显示游戏画面。博主采用的方式是使用了一个定时器0。在定时器中断内进行显存的更新,以此来控制贪食蛇运动速度。在每次打印蛇和食物坐标之前,会使用一次清屏函数,清除上一帧蛇,否则会留下长长的尾巴。以下是部分定时器0中断内的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void time0() interrupt 1
{
    TH0 = (65535-10000)/256;    //10ms初值
    TL0 = (65535-10000)%256;
    time_count++;               //计时
    if(time_count >= mode)      //mode为难度
    {
        Food_Is_Eaten();        //判断食物是否被吃了
        Print_Clear();          //清屏函数
        Print_Snake();          //打印蛇的坐标   
        Print_Food();           //打印食物
        OLED_Refresh();         //把显存中的值赋给OLED
        time_count = 0;         //重新计时
    }  
}

蛇类是贪吃蛇的核心算法。包括蛇身体坐标的储存,蛇的初始化,蛇的运动,蛇是否撞墙,是否吃到自己。

1
2
3
4
5
6
7
8
9
10
11
typedef struct Snake//蛇结构体,用于储存蛇的身体和食物的坐标
{
    u8 x;           //x为横坐标
    u8 y;           //y为纵坐标
}Snake;
Snake snake[MAX];               //蛇身体结构体,食物,尾巴
u8 length = 1;                  //储存蛇的长度
void Snake_Init();              //初始化蛇
void Snake_Move();              //操控蛇移动 后一节给前一节赋值 如果无墙模式 撞墙之后要在另一侧打印
bit  Snake_eatself();           //判断是否吃到自己
bit  Snake_HitWall();           //判断蛇是否撞墙

1.蛇结构体:蛇身体坐标的储存采用了一个结构体数组,仅有x,y两个坐标的成员变量,同时也能储存食物的坐标。

2.蛇的初始化:首先要给蛇身体的结构体数组开辟一块内存空间,并用length变量储存蛇的长度。蛇头坐标为snake[0].x和snake[0].y;然后依次向后储存到第length个。C语言基础较好的朋友可能会用链表,malloc()函数去动态分配内存。在查阅相关资料发现由于51单片机内存太小,malloc()函数申请的内存很容易申请不到,返回NULL,也是因为博主学艺不精,放弃了这个想法。妥协之下,在蛇到达最大长度的时候直接退出游戏。最大长度为MAX,这里我设置的是30。(在控制台写贪吃蛇的时候博主很暴力的把max设置成地图长乘地图宽)第二,要给蛇头赋初值,博主在初始化函数中初始化了两节坐标。

3.蛇的移动:蛇的移动其实很好理解,蛇头先上下左右移动,然后把前一节的坐标赋给后一节。如果是无墙模式,先把头坐标直接赋值到另一侧,再进行后一节赋给前一节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void Snake_Move()               //操控蛇移动 后一节给前一节赋值
{
    u8 i;
    tail.x = snake[length].x;   //保留上次尾巴的坐标
    tail.y = snake[length].y;   //当吃到食物的时候将该坐标赋给蛇结构第length个的坐标
    for(i = length;i > 0 ; i--)
    {
        snake[i].x = snake[i - 1].x;
        snake[i].y = snake[i - 1].y;
    }
    if(wall == 0)
    {
        if(snake[0].x == 63)    //如果与右墙相撞
        {
            snake[0].x = 1;         //到最左侧
        }
        else if(snake[0].x == 0)//如果与左墙相撞
        {
            snake[0].x = 62;            //到最右侧
        }
        else if(snake[0].y == 0)//如果与上墙相撞
        {
            snake[0].y = 62;            //到最下侧
        }
        else if(snake[0].y == 63)//如果与下墙相撞
        {
            snake[0].y = 0;         //到最上侧
        }
    }
}

但如果按照上述算法,有一个很致命的问题,在头坐标赋给第二节身体的时候,第二节身体再赋值给第三节,那第三节的元素不也是第一节的吗?因此采用的是从后向前赋值。从把倒数第二节坐标赋给尾巴,倒数第三节坐标赋给倒数第二节,以此类推。最后再根据蛇的运动方向使蛇头移动。至于为什么保留尾巴的坐标,与吃食物有关,后面再解释。

4.是否撞墙,是否吃到自己
撞墙和吃到自己的函数返回值都是bit类型(0和1),类似bool类型,如果撞墙/吃到自己返回1,否则返回0,表示无事发生。算法也很简单,撞墙就判断蛇头坐标是否和墙重合,吃自己函数就遍历所有身体坐标,看是否与头重合。

  • 食物

食物类分为蛇的坐标,食物的刷新,食物被吃的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Snake food;                         //食物结构,储存x,y坐标
void Food_Init()                    //随机生成食物 清除原先食物的坐标 写入新的坐标
{
    //食物生成的算法是利用定时器生成两个62范围内的数,如果与蛇重合重新生成
    while(1)
    {
        u8 i;
        food.x = TL1 % 61+1;//加1是防止和地图边缘重合
        food.y = TL0 % 61+1;
        for(i = 0; i < length ; i++)
        {
            if(food.x == snake[i].x && food.y == snake[i].y)
                continue;   //如果生成的食物与蛇身体重合,进行下一次循环
        }
        break;              //如果没有和身体重合,退出循环,退出函数
    }
}
void Food_Is_Eaten()                //判断食物是否被吃了
{
    if(snake[0].x == food.x && snake[0].y == food.y)
    {
        length++;
        snake[length].x = tail.x;
        snake[length].y = tail.y;
        Food_Init();    //重新生成一次食物
        Print_Score();  //更新一次分数
    }  
}

1.食物生成/更新:在游戏开始和食物被吃的时候,才调用这个函数,重新生成食物坐标。采用了定时器0和定时器1的TL值取模来生成x和y坐标(1-62之内的随机数)。如果和蛇身体有重合,则重新生成。
2.判断食物是否被吃:算法也很简单,先判断蛇头坐标是否和食物坐标重合。如果重合则蛇变长一节,把之前储存的蛇尾后面一节坐标赋给最新一节身体。然后重新生成一次食物。

3.按键检测

除正常的独立按键检测以外,还要储存前一次按键的值,用于蛇每次自动移动,用于表示蛇移动的方向;其次,由于贪吃蛇特性,譬如在向上走的同时,只能向左向右拐。因此每次给方向赋值的时候要先判断一次,防止其转向冲突。

4.开始界面

开始界面的实现首先要感谢好朋友的指点,给了我思路,虽然有些简陋,但基本功能可以实现。
UI界面
代码部分较为冗长,这里只介绍一下基本思路。
首先要定义两个变量,一个是行数line,用于记录选中的行,一个是按键key,用于记录输入的按键。玩家只有上、下、确定键可以按。在按上下键的时候更改选中的行数,并重新打印小箭头;在按确定键的时候根据选中的行数去实现相应需求。开始游戏则跳出循环,更改难度和模式则更改相应变量的值,并把UI中的汉字更改掉。

5.得分面板,结束界面,无源蜂鸣器驱动函数

这部分就比较好实现了,得分为蛇长度-1;结束界面在相应位置打印汉字,关闭定时器0停止显存刷新;蜂鸣器使用不同频率的脉冲给蜂鸣器的IO口送电就好。如果想实现音调do re mi,由于Hz是每秒钟周期的次数,而我们的计时器和延时函数是按毫秒统计,所以需要进行频率的换算,比如C4是261Hz。

1261=x1000×2 \frac{1}{261}=\frac{x}{1000×2}

2611?=1000×2x?
可以得到x=7.66,也就是约每7.66毫秒电平变化一次,可以发出C4(中央C)的音调。延时函数并不会很准确,如果想精确实现可以使用定时器。

三、硬件部分

1.元件清单

以下元件是博主自己使用到的,仅供参考。

元件名 数量 注释
洞洞板8*8 1个 也可以采用7×9,6×8,40pin锁紧座6.5cm左右
40针IC锁紧座 1个 也可以40针IC座,价格便宜但拔插困难
10K电阻 3个 P0口使用了1个,蜂鸣器和复位电路1个,P0也可以买排阻
471(470Ω)电阻 1个 电源指示灯电路会用到
2K电阻 1个 蜂鸣器电路会用到
15Ω电阻 1个 蜂鸣器电路会用到
33μF电容 2个 晶振两端的负载电容
10μF电容 1个 复位电路会用到
104(0.1μF)电容 2个 电源的滤波电容和蜂鸣器旁路电容
独立按键 6个 上下左右确定和复位电路按键
排针、排母 若干 用于IO口和OLED的连接
杜邦线 若干 用于IO口和OLED的连接
OLED模块 1个 显示屏幕
STC89C54RD+ 1个 单片机
12MHz晶振 1个 12MHz便于定时器定时
无源蜂鸣器 1个 发出提示音
三极管8550 1个 蜂鸣器电路使用,放大信号
CH340模块 1个 用于供电,下载程序

2.最小系统的焊接

  • 晶振电路
    采用普中开发板的接法,如图所示。
    晶振电路
  • 复位电路
    采用普中开发板的接法,如图所示。
    复位电路
  • 电源指示灯
    电源指示灯电路
    IO口给低电平二极管发光,用于测试单片机是否正常运行。

3.蜂鸣器电路的焊接

蜂鸣器电路采用这篇博客的接法,这里就不搬运了。区别是三极管集电极C端并联的33R电阻替换成了15Ω。(因为板子空间不是很够且没有买到33R)

4.独立按键的焊接

博主购买的是4脚的微动按键开关。
独立按键
四脚分别两两导通。不想检测可以焊接对角线,对角线必然不导通。如果想检测可以使用万用表的二极管挡,如果万用表蜂鸣器响则代表导通(短路)。独立按键的焊接非常简单,一端连接IO口一端共地即可。

四、错误示范汇总

1.软件部分

  • 关于51单片机选择问题
    博主考虑到内存大小和flash空间问题,购买了STC89C54RD+和STC12C560S2,事实证明,12单片机总是会出现一些无法理解的错误,最后程序是在89C54上完成的。而常用的52单片机8K字节flash,512字节RAM更加捉襟见肘。
  • 关于IIC和SPI通信选择问题
    IIC通信速度较SPI通信速度慢,因此不适合使用IIC通信。
  • 关于片选CS的问题
    在阅读oled.c源码时,由于只有oled与单片机通信,CS端接地就万事大吉,以为CS没什么用,就手贱删掉了CS拉低的代码,事后想想也好蠢…后来又不接CS管脚,OLED也不会亮,还是老老实实的接在IO口上吧…
  • 关于地图大小的选择问题
    之前非常天真的把地图设置成60×60,但是在给显存赋值,让蛇穿墙的时候,生成食物坐标时会造成麻烦,遂改成64×64。
  • 定时器0与定时器1中断冲突问题
    定时器1用于随机生成食物,但是千万不要开定时器1的中断!!!不要手欠写ET1=1;两个中断会冲突,这个错误排查了好久。

2.硬件部分

  • 洞洞板的选择问题
    为了图省事在线下购买的洞洞板,他下面都是连着的!!!表示第一次见这种洞洞板~第一次焊的时候,焊好之后才发现IO口都并上了…白闻了一下午焊锡…但是合理利用的话会省很多事。
    第一次焊错的板子
    (错误示范图)
  • 关于31管脚的上拉电阻问题
    在普中科技的单片机开发板原理图中,31管脚有接一个4.7k的上拉电阻,但STC公司的单片机内部上拉,也可以不接。
    31管脚
  • 元件引脚焊接问题
    在第一块板子焊费之后,40脚的IC紧锁座根本拿不下来。(我的2块钱!!!)出于保险,在第二块板子上,我直接把IC座卡在板子上,没有点焊锡,结果电路连接的不是很顺畅,又检查了好几天。

3.排查电路问题的方法分享

  • 检查电路是否导通
    万用表真的很好用。调到二极管挡,测试有没有虚焊,哪里有没有导通,如果导通万用表蜂鸣器会响。就是用这个方法测出来IC座虚焊的。
  • 检查晶振是否工作
    晶振正常工作的时候,程序会执行,如果晶振不工作,程序会卡在初始状态。这个时候的现象一般是单片机管脚有电压,但是程序不运行。
  • 检查单片机是否正常运行
    可以通过电源指示灯来判断。我写了一个很简单的闪烁的灯的程序,如果灯正常闪烁,代表单片机正常运行。

部分图片和资料源自网络,如有侵权请联系删除。