51单片机学习(上)

最近收拾东西时居然发现了之前买的STM32单片机(普中STM32-F1),之前差点挂闲鱼卖了,很好奇是什么勇气让我直接买了STM32的板子?哈哈哈,买回来直接吃灰,连光盘都在。现在闲暇之余准备系统地学习一下单片机子,没准还能造些有意思的小玩意,那还是从89C51开始吧,哈哈。

单片机(Micro Controller Unit),简称MCU。内部集成了CPU、RAM、ROM、定时器、中断系统、通讯接口等一系列电脑的常用硬件功能。单片机的任务是信息采集(依靠传感器)、处理(依靠CPU)和硬件设备(例如电机,LED等)的控制。单片机跟计算机相比,单片机算是一个袖珍版计算机,一个芯片就能构成完整的计算机系统。但在性能上,与计算机相差甚远,但单片机成本低、体积小、结构简单,在生活和工业控制领域大有所用,而且学习使用单片机是了解计算机原理与结构的最佳选择。

我目前使用的是STC89C52单片机,这种类型的单片机资料也比较多。

STC89C52简介

STC(南通国芯微电子)公司的51单片机系列是8位单片机,所以RAM为512字节,ROM:8K(Flash),工作频率:11.0592MHz(有些是12MHz),命名规则如下:

STC89C52结构

STC89C52系列单片机的内部结构框图如下图所示。STC89C52单片机中包含中央处理器(CPU)、程序存储器(Flash)、数据存储器(SRAM)、定时/计数器、UART串口、I/O接口、EEPROM、看门狗等模块。STC89C52系列单片机几乎包含了数据采集和控制中所需的所有单元模块,可称得上一个微机片上系统。内部结构图:

GPIO与管脚图

GPIO(General Purpose Intput Output)是通用输入输出端口的简称,可以通过软件来控制其输入和输出。51 单片机芯片的 GPIO 引脚与外部设备连接起来,从而实现与外部通讯、控制以及数据采集的功能。不过GPIO 最简单的应用还属点亮LED 灯了,只需通过软件控制GPIO 输出高低电平即可。当然 GPIO 还可以作为输入控制,比如在引脚上接入一个按键,通过电平的高低判断按键是否按下等操作。

这些管脚只需要结合开发板原理图就可以确定,哪些管脚和哪些外部设备相连接:

(1)电源引脚:引脚图中的VCC、GND 都属于电源引脚。 (2)晶振引脚:引脚图中的XTAL1、XTAL2 都属于晶振引脚。 (3)复位引脚:引脚图中的RST/VPD 属于复位引脚,不做其他功能使用。 (4)下载引脚:51 单片机的串口功能引脚(TXD、RXD)可以作为下载引脚使用。 (5)GPIO 引脚:引脚图中带有Px.x 等字样的均属于GPIO 引脚。GPIO 占用了芯片大部分的引脚,共达32个,分为了4组,P0、P1、P2、P3,每组为8个IO,而且在P3组中每个IO都具备额外功能,只要通过相应的寄存器设置即可配置对应的附加功能,同一时刻,每个引脚只能使用该引脚的一个功能。

① 51单片机所有IO口都是双向的,即可以作为输入也可以作为输出使用。

② 由于P0口是漏极开路的,所以要操作P0 口必须外接上拉电阻,其他P1、P2、P3 口都内部自带上拉电阻,可以不加,如果要增强IO口驱动能力,可以外接上拉电阻。

开发环境搭建

1、安装Keil5(汇编器、编译器 + 依赖库 + 头文件),Keil5也是一个IDE,可以直接在里面写代码。

2、Keil5也可以用SDCC替代,http://sdcc.sourceforge.net/,SDCC 是一个可重定向、优化的标准 C(ANSI C89、ISO C99、ISO C11)编译器套件,它针对基于 Intel MCS51 的微处理器(8032、8051、8052 等)。

3、安装STC-ISP (烧录器),也可以使用普中ISP,STC-ISP功能会多一些。

4、但是最好还是用VSCode方便一些,快捷键用着也方便,安装 Embedded IDE(https://docs.em-ide.com)插件即可,Embedded IDE会提供8051、STM8、 Cortex-M、RISC-V… 项目的开发、编译、烧录等功能。安装好Embedded IDE后,直接可以Ctrl点进去就能查看源代码,比较方便。

注意:SDCC和Keil5的头文件是不一样的,SDCC用的是 8052.h,Keil5用的是 REGX52.H

1、GPIO之LED流水灯

通过LED流水灯实验,认识GPIO。通过STC-ISP软件生成延时代码,完成LED流水灯的功能:

//此文件中定义了单片机的一些特殊功能寄存器
#include <REGX52.H> 
#include <INTRINS.H>

// 睡眠x毫秒
void DelayXms(unsigned int x){
	unsigned char i, j;
	while(x--){
		_nop_();
		i = 2;
		j = 199;
		do
		{
			while (--j);
		} while (--i);
	}
}

void main(){
	while(1){
		P2 = 0xFE; //1111 1110
		DelayXms(500);
		
		P2 = 0xFD; //1111 1101
		DelayXms(500);
		
		P2 = 0xFB; //1111 1011
		DelayXms(500);
		
		P2 = 0xF7; //1111 0111
		DelayXms(500);
		
		P2 = 0xEF; //1110 1111
		DelayXms(500);
		
		P2 = 0xDF; //1101 1111
		DelayXms(500);
		
		P2 = 0xBF; //1011 1111
		DelayXms(500);
		
		P2 = 0x7F; //0111 1111
		DelayXms(500);
	}
}

2、按键抖动与消抖

对于机械开关,当机械触点断开、闭合时,由于机械触点的弹性作用,一个开关在闭合时不会马上稳定地接通,在断开时也不会一下子断开,所以在开关闭合及断开的瞬间会伴随一连串的抖动。

#include <REGX52.H>

void DelayXms(unsigned int x)
{
	unsigned char i, j;
    while (x--)
    {
        i = 2;
        j = 199;
        do
        {
            while (--j);
        } while (--i);
    }
}

void main(){
    while (1)
    {
        P2_1 = 1;
        if(P3_1 == 0){
            DelayXms(20); // 消除按下抖动
            while (P3_1 == 0);
            DelayXms(20); // 消除松手抖动
            P2_0 = ~P2_0; // 取反就是开和关
        }
    }
}

3、数码管MCU扫描

LED数码管:数码管是一种简单、廉价的显示器,是由多个发光二极管封装在一起组成8字型的器件。数码管一般有两种驱动方式:

① 单片机直接扫描:硬件设备简单,但会耗费大量的单片机CPU时间

② 专用驱动芯片:内部自带显存、扫描电路,单片机只需告诉它显示什么即可

下面是通过单片机直接扫描的方式来完成的数据显示示例:

#include <REG52.H>
#include <INTRINS.H>
#include <stdlib.h>

typedef unsigned char uint8;

uint8 hexArr[] = {0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F,
                  0x80, // -> 10 小数点
                  0x40, // -> 11 负 号
            }; 

void DelayXms(unsigned int x) //@11.0592MHz
{
    unsigned char i, j;
    while (x--)
    {
        _nop_(); // 空语句
        i = 2;
        j = 199;
        do
        {
            while (--j)
                ;
        } while (--i);
    }
}


/**
 * x: LED 0-7
 * num: 0-9,10为小数点, 11为负号
 **/
void showNumLed(uint8 x, uint8 num)
{
    switch (x)
    {
    case 0: P2_2 = 1; P2_3 = 1; P2_4 = 1; break;
    case 1: P2_2 = 0; P2_3 = 1; P2_4 = 1; break;
    case 2: P2_2 = 1; P2_3 = 0; P2_4 = 1; break;
    case 3: P2_2 = 0; P2_3 = 0; P2_4 = 1; break;
    case 4: P2_2 = 1; P2_3 = 1; P2_4 = 0; break;
    case 5: P2_2 = 0; P2_3 = 1; P2_4 = 0; break;
    case 6: P2_2 = 1; P2_3 = 0; P2_4 = 0; break;
    case 7: P2_2 = 0; P2_3 = 0; P2_4 = 0; break;
    default:
        break;
    }

    // P0 = 0x7D; -> 6
    P0 = hexArr[num];
}

void numToCharArray(char arr[], long num){ // 123456
    int i;
    if(num < 0) num = -num;
    for (i = 7; i >= 0; i--)
    {
       if(num == 0) break;
       arr[i] = num % 10;
       num /= 10;
    }
}

void showLongByLED(long num){
    int i;
    int tmp;
    // 如果是负数
    if(num < 0){
        //showNumLed(1, 10);
        if(num < -9999999L){
            // 溢出
            while (1)
            {
                for (i = 0; i < 8; i++)
                {
                    showNumLed(i, 11);
                    DelayXms(2);
                    P0 = 0x00; // 消影
                }
            }
        }else {
            char ch[8] = {0};
            numToCharArray(ch, num);
            for(i = 0; i < 8; i++){
                if(ch[i] != 0){ // 0390 9999
                    tmp = i;
                    while(1){
                        showNumLed(tmp - 1, 11);
                        DelayXms(2);
                        P0 = 0x00;
                        for (i = tmp; i < 8; i++)
                        {
                            showNumLed(i, ch[i]);
                            DelayXms(2);
                            P0 = 0x00; // 消影
                        }
                    }
                }
            }
        }
    }else if(num == 0){
        showNumLed(8, 0);
    }else {
        char ch[8] = {0};
        numToCharArray(ch, num);
        for(i = 0; i < 8; i++){
            if(ch[i] != 0){
                tmp = i;
                while(1){
                    for (i = tmp; i < 8; i++)
                    {
                        showNumLed(i, ch[i]);
                        DelayXms(2);
                        P0 = 0x00; // 消影
                    }
                }
            }
        }
    }
}


void showLongByLEDAuto(long num){
    int i;
    int tmp;
    // 如果是负数
    if(num < 0){
        //showNumLed(1, 10);
        if(num < -9999999L){
            // 溢出
            for (i = 1; i <= 8; i++)
            {
                showNumLed(i, 11);
                DelayXms(2);
                P0 = 0x00; // 消影
            }
        }else {
            
        }
    }else if(num == 0){
        showNumLed(8, 0);
    }else {
        char ch[8] = {0};
        numToCharArray(ch, num);
        for(i = 0; i < 8; i++){
            if(ch[i] != 0){
                tmp = i;
                for (i = tmp; i < 8; i++)
                {
                    showNumLed(i, ch[i]);
                    DelayXms(2);
                    P0 = 0x00; // 消影
                }
            }
        }
    }
}

void main()
{
    int i;
    int flag = 0;
    for (i = 0; i < 10; i++)
    {
         showNumLed(0, i);
         DelayXms(100);
    }

    showLongByLED(-8900999L);
    while(1);
}

这种通过单片机扫描的方式还是过于复杂,而且比较浪费单片机的计算机资源,推荐还是用专用驱动芯片,内部自带显存、扫描电路,单片机只需告诉它显示什么即可,TM1640就是这样一种芯片:

TM1640是一种LED(发光二极管显示器)驱动控制专用电路,内部集成有MCU数字接口、数据锁存器LED驱动等电路。本产品性能优良,质量可靠。主要应用于电子产品LED显示屏驱动。采用SOP28、SSOP28的封装形式。

4、LCD1602显示字符

直接使用下面封装好的函数即可,以后需要模块化的地方一定要模块化,形成代码复用:

LCD1602.h:

#ifndef __LCD1602_H__
#define __LCD1602_H__

//用户调用函数:
void LCD_Init();
void LCD_ShowChar(unsigned char Line,unsigned char Column,char Char);
void LCD_ShowString(unsigned char Line,unsigned char Column,char *String);
void LCD_ShowNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length);
void LCD_ShowSignedNum(unsigned char Line,unsigned char Column,int Number,unsigned char Length);
void LCD_ShowHexNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length);
void LCD_ShowBinNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length);

#endif //!__LCD1602_H__

LCD1602.c:

#include <REG52.H>

//引脚配置:
sbit LCD_RS=P2^6;
sbit LCD_RW=P2^5;
sbit LCD_EN=P2^7;
#define LCD_DataPort P0

//函数定义:
/**
  * @brief  LCD1602延时函数,12MHz调用可延时1ms
  * @param  无
  * @retval 无
  */
void LCD_Delay()
{
	unsigned char i, j;

	i = 2;
	j = 239;
	do
	{
		while (--j);
	} while (--i);
}

/**
  * @brief  LCD1602写命令
  * @param  Command 要写入的命令
  * @retval 无
  */
void LCD_WriteCommand(unsigned char Command)
{
	LCD_RS=0;
	LCD_RW=0;
	LCD_DataPort=Command;
	LCD_EN=1;
	LCD_Delay();
	LCD_EN=0;
	LCD_Delay();
}

/**
  * @brief  LCD1602写数据
  * @param  Data 要写入的数据
  * @retval 无
  */
void LCD_WriteData(unsigned char Data)
{
	LCD_RS=1;
	LCD_RW=0;
	LCD_DataPort=Data;
	LCD_EN=1;
	LCD_Delay();
	LCD_EN=0;
	LCD_Delay();
}

/**
  * @brief  LCD1602设置光标位置
  * @param  Line 行位置,范围:1~2
  * @param  Column 列位置,范围:1~16
  * @retval 无
  */
void LCD_SetCursor(unsigned char Line,unsigned char Column)
{
	if(Line==1)
	{
		LCD_WriteCommand(0x80|(Column-1));
	}
	else if(Line==2)
	{
		LCD_WriteCommand(0x80|(Column-1+0x40));
	}
}

/**
  * @brief  LCD1602初始化函数
  * @param  无
  * @retval 无
  */
void LCD_Init()
{
	LCD_WriteCommand(0x38);//八位数据接口,两行显示,5*7点阵
	LCD_WriteCommand(0x0c);//显示开,光标关,闪烁关
	LCD_WriteCommand(0x06);//数据读写操作后,光标自动加一,画面不动
	LCD_WriteCommand(0x01);//光标复位,清屏
}

/**
  * @brief  在LCD1602指定位置上显示一个字符
  * @param  Line 行位置,范围:1~2
  * @param  Column 列位置,范围:1~16
  * @param  Char 要显示的字符
  * @retval 无
  */
void LCD_ShowChar(unsigned char Line,unsigned char Column,char Char)
{
	LCD_SetCursor(Line,Column);
	LCD_WriteData(Char);
}

/**
  * @brief  在LCD1602指定位置开始显示所给字符串
  * @param  Line 起始行位置,范围:1~2
  * @param  Column 起始列位置,范围:1~16
  * @param  String 要显示的字符串
  * @retval 无
  */
void LCD_ShowString(unsigned char Line,unsigned char Column,char *String)
{
	unsigned char i;
	LCD_SetCursor(Line,Column);
	for(i=0;String[i]!='\0';i++)
	{
		LCD_WriteData(String[i]);
	}
}

/**
  * @brief  返回值=X的Y次方
  */
int LCD_Pow(int X, int Y)
{
	unsigned char i;
	int Result=1;
	for(i=0;i<Y;i++)
	{
		Result*=X;
	}
	return Result;
}

/**
  * @brief  在LCD1602指定位置开始显示所给数字
  * @param  Line 起始行位置,范围:1~2
  * @param  Column 起始列位置,范围:1~16
  * @param  Number 要显示的数字,范围:0~65535
  * @param  Length 要显示数字的长度,范围:1~5
  * @retval 无
  */
void LCD_ShowNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length)
{
	unsigned char i;
	LCD_SetCursor(Line,Column);
	for(i=Length; i>0; i--)
	{
		LCD_WriteData(Number/LCD_Pow(10,i-1)%10+'0');
	}
}

/**
  * @brief  在LCD1602指定位置开始以有符号十进制显示所给数字
  * @param  Line 起始行位置,范围:1~2
  * @param  Column 起始列位置,范围:1~16
  * @param  Number 要显示的数字,范围:-32768~32767
  * @param  Length 要显示数字的长度,范围:1~5
  * @retval 无
  */
void LCD_ShowSignedNum(unsigned char Line,unsigned char Column,int Number,unsigned char Length)
{
	unsigned char i;
	unsigned int Number1;
	LCD_SetCursor(Line,Column);
	if(Number >= 0)
	{
		LCD_WriteData('+');
		Number1=Number;
	}
	else
	{
		LCD_WriteData('-');
		Number1=-Number;
	}
	for(i=Length; i>0; i--)
	{
		LCD_WriteData(Number1/LCD_Pow(10,i-1)%10+'0');
	}
}

/**
  * @brief  在LCD1602指定位置开始以十六进制显示所给数字
  * @param  Line 起始行位置,范围:1~2
  * @param  Column 起始列位置,范围:1~16
  * @param  Number 要显示的数字,范围:0~0xFFFF
  * @param  Length 要显示数字的长度,范围:1~4
  * @retval 无
  */
void LCD_ShowHexNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length)
{
	unsigned char i,SingleNumber;
	LCD_SetCursor(Line,Column);
	for(i=Length; i>0; i--)
	{
		SingleNumber = Number/LCD_Pow(16,i-1)%16;
		if(SingleNumber < 10)
		{
			LCD_WriteData(SingleNumber+'0');
		}
		else
		{
			LCD_WriteData(SingleNumber-10+'A');
		}
	}
}

/**
  * @brief  在LCD1602指定位置开始以二进制显示所给数字
  * @param  Line 起始行位置,范围:1~2
  * @param  Column 起始列位置,范围:1~16
  * @param  Number 要显示的数字,范围:0~1111 1111 1111 1111
  * @param  Length 要显示数字的长度,范围:1~16
  * @retval 无
  */
void LCD_ShowBinNum(unsigned char Line,unsigned char Column,unsigned int Number,unsigned char Length)
{
	unsigned char i;
	LCD_SetCursor(Line,Column);
	for(i=Length; i>0; i--)
	{
		LCD_WriteData(Number/LCD_Pow(2,i-1)%2+'0');
	}
}

5、定时器与中断系统

定时器

51单片机的定时器属于单片机的内部资源,其电路的连接和运转均在单片机内部完成。

1、定时器用于计时系统,可实现软件计时,或者使程序每隔一固定时间完成一项操作。 2、替代长时间的Delay,提高CPU的运行效率和处理速度。

51系列单片机一定有基本的2个定时器,但是STC89C52具有3个定时器(T0、T1、T2),T0和T1与传统的51单片机兼容,T2是此型号单片机增加的资源。需要注意的是:定时器的资源和单片机的型号是关联在一起的,不同的型号可能会有不同的定时器个数和操作方式,但一般来说,T0和T1的操作方式是所有51单片机所共有的。而且通常我们使用的都是基本的2个定时器:T0与T1。

定时器在单片机内部就像一个小闹钟一样,根据时钟的输出信号,每隔一段时间,计数单元的数值就增加一,当计数单元数值增加到"设定的闹钟提醒时间"时,计数单元就会向中断系统发出中断申请,产生"响铃提醒",使程序跳转到中断服务函数中执行。

STC89C52的T0和T1均有四种工作模式:

13位定时器/计数器、16位定时器/计数器(常用)、8位自动重装模式、两个8位计数器

以定时器T0为例,通过定时器中断控制D1指示灯间隔1秒闪烁。

中断系统

中断系统是为使CPU具有对外界紧急事件的实时处理能力而设置的。

当中央处理机CPU正在处理某件事的时候外界发生了紧急事件请求,要求CPU暂停当前的工作,转而去处理这个紧急事件,处理完以后,再回到原来被中断的地方,继续原来的工作,这样的过程称为中断。实现这种功能的部件称为中断系统,请示CPU中断的请求源称为中断源。微型机的中断系统一般允许多个中断源,当儿个中断源同时向CPU请求中断,要求为它服务的时候,这就存在CPU优先响应哪一个中断源请求的问题。通常根据中断源的轻重缓急排队,优先处理最紧急事件的中断请求源,即规定每一个中断源有一个优先级别。CPU总是先响应优先级别最高的中断请求。

当CPU正在处理一个中断源请求的时候(执行相应的中断服务程序),发生了另外一个优先级比它还高的中断源请求。如果CPU能够暂停对原来中断源的服务程序,转而去处理优先级更高的中断请求源,处理完以后,再回到原低级中断服务程序,这样的过程称为中断嵌套。这样的中断系统称为多级中断系统,没有中断嵌套功能的中断系统称为单级中断系统。

STC89C52系列单片机提供了8个中断请求源,它们分别是:外部中断O(INTO)、定时器0中断、外部中断1(INT1)、定时器1中断、串口(UART)中断、定时器2中断、外部中断2(INT2)、外部中断3(INT3)。所有的中断都具有4个中断优先级。中断的资源和单片机的型号是关联在一起的,不同的型号可能会有不同的中断资源,例如中断源个数不同、中断优先级个数不同等等,下面是STC89C51的中断号:

寄存器是连接软硬件的媒介。在单片机中寄存器就是一段特殊的RAM存储器,一方面,寄存器可以存储和读取数据,另一方面,就像每一个寄存器背后都连接了一根导线,控制着电路的连接方式。寄存器相当于一个复杂机器的"操作按钮" 。

通过配置中断部分的寄存器就可以理解为什么需要做这样的配置:

时钟Demo

#include <REGX51.H>
#include "LCD1602.h"

unsigned int s, m, h;

void Timer0Init(void)		//1毫秒@11.0592MHz
{
	// 配置定时器 -----------------------
	TMOD &= 0xF0;		// 设置定时器模式
	TMOD |= 0x01;		// 设置定时器模式
	TL0 = 0x66;		// 设置定时初始值
	TH0 = 0xFC;		// 设置定时初始值
	TF0 = 0;		// 清除TF0标志
	TR0 = 1;		// 定时器0开始计时

	// 配置中断 -------------------------
	ET0 = 1;
	EA = 1;
	PT0 = 0;
}


void main()
{
		LCD_Init();
		Timer0Init();
	
		LCD_ShowString(1, 1, "Clock:");
		LCD_ShowString(2, 3, ":");
		LCD_ShowString(2, 6, ":");
    while (1){
			// LCD显示属于耗时操作,不要放在定时器中断中完成
			LCD_ShowNum(2, 1, h, 2);
			LCD_ShowNum(2, 4, m, 2);
			LCD_ShowNum(2, 7, s, 2);
		}
}

 // 中断函数
void Timer0_Routine() interrupt 1 
{
	static unsigned int T0Count = 0;
	TL0 = 0x66;		// 设置定时初始值
	TH0 = 0xFC;		// 设置定时初始值
	T0Count++;
	
	if(T0Count > 1000){	
		T0Count = 0;
		s++;
		// 时、分、秒
		if(s >= 60){
			s = 0;
			m++;
		}
		
		if(m >= 60){
			m = 0;
			h++;
		}
		
		if(h >= 24){
			h = 0;
		}
	}
}

最终效果如下所示: