我们和单片机之间进行信息交互,主要包含两大类,输入设备和输出设备。前边讲的LED小灯、数码管、点阵都是输出设备,这节课我们学习一下最常用的输入设备——按键。在本节课的学习过程中我们还会穿插介绍一点硬件设计的基础知识。
8.1 单片机最小系统电路解析
8.1.1 电源
我们在学习过程中,很多指标都是直接用的概念指标,比如我们说+5V代表1,GND代表0等等这些。但在实际电路中是没有这么精准的,那这些指标允许范围是什么呢?随着我们所学的内容不断增多,大家要慢慢培养一种阅读手册的能力。
比如我们使用STC89C52RC单片机的时候,我们找到他的手册的11页,第二个选项,工作电压:5.5V-3.4V(5V单片机),这个地方就说明我们这个单片机正常的工作电压是个范围值,只要电源VCC在5.5V到3.4V之间都可以正常工作,电压超过5.5V是绝对不允许的,会烧坏单片机,电压如果低于3.4V,单片机不会损坏,但是也不能正常工作。而在这个范围内,最典型、最常用的电压值就是5V,这就是后面括号里“5V单片机”这个名称的由来。除此之外,还有一种常用的工作电压范围是2.7V-3.6V、典型值是3.3V的单片机,也就是所谓的“3.3V单片机”了。日后随着大家接触的东西慢慢增多,对这点会有更深刻的理解。
现在我们再顺便多了解一点,大家打开74HC138的数据手册,会发现74HC138手册的第二页也有一个表格,上边写了74HC138的工作电压范围,最小值是4.75V,额定值是5V,最大值是5.25V,可以得知它的工作电压范围是4.75V-5.25V。这个地方讲这些目的是让大家清楚的了解,我们获取器件工作参数的一个最重要,也是最权威的途径,就是通过器件的数据手册。
8.1.2 晶振
晶振通常分为无源晶振和有源晶振两种类型,无源晶振一般称之为crystal(晶体),而有源晶振则叫做oscillator(振荡器)。
有源晶振是一个完整的谐振振荡器,他是利用石英晶体的压电效应来起振,所以有源晶振需要供电,当我们把有源晶振电路做好后,不需要外接电路,它就可以主动产生振荡频率,并且可以提供高精度的频率基准,信号质量比无源信号好。
而无源晶振自身无法振荡起来,它需要芯片内部的振荡电路一起工作才能振荡,它允许不同的电压,但是信号质量和精度较有源晶振差一些。相对价格来说,无源晶振要比有源晶振价格便宜很多。无源晶振两侧通常都会有两个电容,一般其容值都选在10pF~40pF之间,如果手册中有具体电容大小的要求则要根据要求来选电容,如果手册没有要求,我们用20pF就是比较好的选择,这是一个长久以来的经验值,具有极其普遍的适用性。
我们来认识下比较常用的两种晶振的样貌,如图8-1和图8-2所示。
 
图8-1 27Mhz有源晶振 图8-2 11.0592M无源晶振
有源晶振通常有4个引脚,VCC,GND,晶振输出引脚和一个没有用到的悬空引脚。无源晶振有2个或3个引脚,如果是3个引脚的话,中间引脚是晶振的外壳,使用时要接到GND,两侧的引脚就是晶体的2个引出脚了,这两个引脚作用是等同的,就像是电阻的2个引脚一样,没有正负之分。对于无源晶振,就是用我们的单片机上的两个晶振引脚接上去即可,而有源晶振,只接到单片机的晶振的输入引脚上,输出引脚上不需要接,如图8-3和图8-4所示。关于晶振的更多资料可参考:http://www.51hei.com/dianzi/300.html 上面有更深层的原理剖析与详细的分类.
 
图8-3 无源晶振接法 图8-4 有源晶振接法
8.1.3 复位电路
我们先来分析一下我们的复位电路,如图8-5所示。

图8-5 单片机复位电路
当这个电路处于稳态时,电容起到隔离直流的作用,隔离了+5V,而左侧的复位按键是弹起状态,下边部分电路就没有电压差的产生,所以按键和电容C11以下部分的电位都是和GND相等的,也就是0V电压。我们这个单片机是高电平复位,低电平正常工作,所以正常工作的电压是0V电压,完全OK,没有问题。
我们再来分析从没有电到上电的瞬间,电容C11上方是5V电压,下方是0V电压,根据我们初中所学的知识,这个时候电容C11要进行充电,正离子从上往下充电,负电子从GND往上充电,这个时候电容对电路来说相当于一根导线,全部电压都加在了R31这个电阻上,那么RST端口位置是+5V电压,随着电容充电越来越多,即将充满的时候,电流会越来越小,那RST端口上的电压值等于电流乘以R31的阻值,也就会越来越小,一直到电容完全充满后,线路上不再有电流,这个时候RST和GND的电位就相等了也就是0V了。
从这个过程上来看,我们加上这个电路,单片机系统上电后,RST引脚会先保持一小段时间的高电平而后变成低电平,这个过程就是上电复位的过程。那这个“一小段时间”到底是多少才合适呢?每种单片机不完全一样,51单片机手册里写的是持续时间不少于2个机器周期的时间。复位电压值,每种单片机不完全一样,我们按照通常值0.7Vcc作为复位电压值,复位时间的计算过程比较复杂,我这里只给大家一个结论,时间t=1.2RC,我们用的R是4700,C是0.0000001,那计算得知t是564us,远远大于2个机器周期(2us),在电路设计的时候一般留够余量就行。
按键复位(即手动复位)有2个过程,按下按键之前,RST的电压值是0V,当按下按键后电路导通,同时电容也会在瞬间进行放电,RST电压值变化为4700Vcc/(4700+18),会处于高电平复位状态。当松开按键后就和上电复位类似了,先是电容充电,后电流逐渐减小直到RST电压变0V的过程。我们按下按键的时间通常都会有上百毫秒,这个时间足够复位了。按下按键的瞬间,电容两端的5V电压(注意不是电源的5V和GND之间)会被直接接通,此刻会有一个瞬间的大电流冲击,会在局部范围内产生电磁干扰,为了抑制这个大电流所引起的干扰,我们这里在电容放电回路中串入一个18欧的电阻来限流。
如果有的同学已经开始DIY设计自己的电路板的时候,那单片机最小系统的设计现在已经有了足够的理论依据了,可以考虑尝试了。如在制作过程有有问题可到:单片机论坛http://www.51hei.com/bbs/ 求助作者会不定期回复的,基础比较薄弱的同学先不要着急,继续跟着往下学,把课程都学完了再动手操作也不迟,磨刀不误砍柴工。
8.2 函数的调用
随着我们编程的程序量的增多,如果把所有的语句都写到main函数中,一方面程序会写的比较乱,另外一个方面,当我们一个功能需要多次执行的时候,我们就得不断重复写语句,这个时候,就引入了函数调用的概念。
一个程序一般由若干个子程序模块组成,一个模块实现一个特定的功能,在C语言中,这个模块就用函数来表示。一个C程序一般由一个主函数和若干个其他函数构成。主函数可以调用其他函数,其他函数也可以相互调用,但其它函数不能调用主函数。在我们的51单片机程序中,还有中断服务函数,是当相应的中断到来后自动调用执行的,不需要也不能由其他函数调用。
函数调用的一般形式是:
函数名(实参列表)
函数名就是需要调用的函数的名称,实参列表就是根据实际调用函数要传递给被调用函数的参数列表,不需要传递参数的只加括号就可以,传递多个参数时要用逗号隔开。在这里我以上节课的点阵I❤U的纵向移动的程序改动一下,大家先了解一下基本的函数调用。另外,大家不要偷懒,一定把这个程序抄下来做一下实验加深一下自己的印象。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char code graph[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xC3,0xE7,0xE7,0xE7,0xE7,0xE7,0xC3,0xFF,
0x99,0x00,0x00,0x00,0x81,0xC3,0xE7,0xFF,
0x99,0x99,0x99,0x99,0x99,0x81,0xC3,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF
};
unsigned char index = 0; //图片刷新索引
void refresh(); //函数声明
void main()
{
P0 = 0xFF; //P0口初始化
ADDR3 = 0; //选择LED点阵
ENLED = 0; //LED显示总使能
TMOD = 0x01; //设置定时器0为模式1
TH0 = 0xFC; //定时器初值,定时1ms
TL0 = 0x67;
TR0 = 1; //打开定时器0
ET0 = 1; //使能定时器0中断
EA = 1; //打开总中断开关
while(1);
}
void refresh()
{
static unsigned char j = 0;
P0 = 0xFF; //LED点阵动态刷新
switch (j)
{
case 0: ADDR0=0; ADDR1=0; ADDR2=0; break;
case 1: ADDR0=1; ADDR1=0; ADDR2=0; break;
case 2: ADDR0=0; ADDR1=1; ADDR2=0; break;
case 3: ADDR0=1; ADDR1=1; ADDR2=0; break;
case 4: ADDR0=0; ADDR1=0; ADDR2=1; break;
case 5: ADDR0=1; ADDR1=0; ADDR2=1; break;
case 6: ADDR0=0; ADDR1=1; ADDR2=1; break;
case 7: ADDR0=1; ADDR1=1; ADDR2=1; break;
default: break;
}
P0 = graph[index+j];
j++;
if (j >= 8)
{
j = 0;
}
}
void InterruptTimer0() interrupt 1
{
static unsigned char tmr = 0;
TH0 = 0xFC; //溢出后进入中断重新赋值
TL0 = 0x67;
refresh(); //函数调用
tmr++; //图片刷新频率控制
if (tmr >= 250) //每隔250ms刷新一帧
{
tmr = 0;
index++;
if (index >= 32)
{
index = 0;
}
}
}
这个程序是对函数的简单调用,但是有以下三个细节需要大家注意一下:
1、函数调用的时候,不需要加函数类型。在中断函数内调用刷新函数的时候我们只写了refresh(); 而没有加void。
2、调用函数与被调用函数的位置关系,C语言规定:函数在被调用之前,必须先被定时或声明。意思就是说:在一个文件中,一个函数应该先定义,然后才能被调用,也就是调用函数应位于被调用函数的下方。但是作为一种通常的编程规范,我们推荐main函数写在最前面(因为它起到提纲挈领的作用),其后再定义各个子函数,而中断函数则写在文件的最后。这时候,我们就在文件开头,所有函数定义之前,开辟一块区域,叫做函数声明区,用来把被调用的子函数声明一下,如此,该函数就可以被随意调用了。如上述例程所示。
3、函数声明的时候必须加函数类型,函数的形式参数,最后加上一个分号表示结束。这点请尤其注意,因为函数定义时最后是不能有分号的,初学者很容易因粗心大意搞错,导致程序编译不过。
4、函数自身的类型、声明的类型以及调用的类型必须一致。我们这个例子里refresh函数的类型是void。
8.3 函数的形式参数和实际参数
上一个程序在进行函数调用的时候,我们不需要任何参数传递,所以函数定义和调用时refresh()括号里是空的,但是更多的时候我们调用函数,主调函数和被调用函数之间是要有参数传递关系的。在调用一个有参数的函数时,函数名后边括号里中的参数叫做实际参数,简称实参。而被调用的函数在进行定义的时候,括号里的参数就叫做形式参数,简称形参,我们找个简单程序例子做说明。
unsigned char add(unsigned char x, unsigned char y);
void main()
{
unsigned char a = 1;
unsigned char b = 2;
unsigned char c = 0;
c = add(a, b); //调用时,a和b就是实参,把函数的返回值赋给c
//运算完后,c的值就是3
while(1);
}
unsigned char add(unsigned char x, unsigned char y) //x和y就是形参
{
unsigned char z = 0;
z = x + y;
return z; //返回值z的类型就是函数add的类型
}
这个演示程序虽然很简单,但是形参和实参以及函数返回值等全部内容都囊括在内了。主调函数main和被调函数add之间的数据通过形参和实参发生了传递关系,而函数运算完了也把值传递给了变量c,函数只要不是void类型的函数,都会有返回值,返回值类型就是函数的类型。关于形参和实参,还有以下几点需要注意。
1、函数定义中指定的形参,在未发生函数调用时不占内存,只有函数调用时,函数add中的形参才被分配内存单元。在调用结束后,形参所占的内存单元也被释放,这个前边讲过了,形参是局部变量。
2、实参可以是常量,也可以是简单或者复杂的表达式,但是要求他们必须有确定的值,在调用发生时将实参的值传递给形参。
如上边这个程序也可以写成: c = add(1, a+b);
3、形参必须要指定数据类型,和定义变量一样。
4、实参和形参的数据类型应该相同或者赋值兼容。和变量赋值一样,当形参和实参出现不同类型时,则按照不同类型数值的赋值规则进行转换。
5、主调函数在调用函数之前,应对被调函数做原型声明。
6、实参向形参的数据传递是单向传递,不能有形参再回传给实参。也就是说,实参值传递给形参后,调用结束,形参单元被释放,而实参单元仍保留并且维持原值。
8.4 独立按键
通常的按键分为独立式按键和矩阵式按键两种,独立式按键比较简单,并且与独立的输入线相连接,如图8-6所示
图8-6 独立式按键电路图
4条输入线接到单片机的IO口上,当按键K1按下时,+5V通过电阻R1然后再通过按键K1最终进入GND形成一条通路,那么这条线路的全部电压都加到了R1这个电阻上,KeyIn1这个引脚就是个低电平。当松开按键后,线路断开,就不会有电流通过,那么KeyIn1和+5V就应该是等电位,是一个高电平。我们就可以通过KeyIn1这个IO口的高低电平来判断是否有按键按下。
这个电路中按键的原理我们清楚了,但是实际上在我们的单片机IO口内部,也有一个上拉电阻的存在。我们的按键是接到了P2口上,P2口上电默认是准双向IO口,我们来简单了解一下这个准双向IO口的电路,如图8-7所示。

图8-7 准双向IO口结构图
首先说明一点,就是我们现在绝大多数单片机的IO口都是使用MOS管而非三极管,但用在这里的MOS管其原理和三极管是一样的,因此在这里我用三极管替代它来进行原理讲解,把前面讲过的三极管的知识搬过来,一切都是适用的,有助于理解。
图8-7方框内的电路都是指单片机内部部分,方框外的就是我们外接的上拉电阻。这个地方大家要注意一下,就是当我们要读取外部按键信号的时候,首先单片机必须得给个‘1’,也就是高电平,这样我们才能正常的读取外部的按键信号,我们来分析一下缘由。
当内部输出是高电平,经过一个反向器变成低电平,NPN三极管不会导通,那么单片机IO口从内部来看,由于上拉电阻R的存在,所以是一个高电平。当外部没有按键按下将电平拉低的话,VCC也是+5V,他们之间虽然有2个电阻,但是没有压差,就不会有电流,线上所有的位置都是高电平,这个时候我们就可以正常读取到按键的状态了。
当内部输出是个低电平,经过一个反相器变成高电平,NPN三极管导通,那么单片机的内部IO口就是个低电平,这个时候,外部虽然也有上拉电阻的存在,但是两个电阻是并联关系,不管按键是否按下,单片机的IO口上输入到单片机内部的状态都是低电平,我们就无法正常读取到按键的状态了。
这个和水流其实很类似的。内部和外部,只要有一边是低电位,那么电流就会顺流而下,由于只有上拉电阻,下边没有电阻分压,直接到GND上了,所以不管另外一边是高还是低,那电位肯定就是低电位了。
这里得到一个结论,这种具有上拉的准双向IO口,如果要正常读取外部信号的状态,必须首先得保证自己输出的电平是‘1’,如果输出‘0’,则无论外部信号是高是低,这个引脚读进来的都是低。
8.5 矩阵按键
8.5.1 矩阵按键和独立按键的关系
我们在使用按键的时候有这样一种使用经验,当需要多个按键的时候,如果做成独立按键会大量占用IO口,因此我们引入了矩阵按键,如图8-8所示,使用了8个IO口来实现16个按键。

图8-8 矩阵按键
其实独立按键理解了,矩阵按键也简单,我们来分析一下。图8-8中,一共有4组按键,我们只看其中一组,如图8-9所示。大家认真看一下,当KeyOut1输出一个低电平,KeyOut2、KeyOut3、KeyOut4这三个输出高电平时,是否相当于4个独立按键呢。

图8-9 矩阵按键变独立按键
我们先用一个简单的程序来实现这4个独立按键的使用。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit LED9 = P0^7;
sbit LED8 = P0^6;
sbit LED7 = P0^5;
sbit LED6 = P0^4;
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
void main(void)
{
//选择独立LED进行显示
P0 = 0xFF; //初始化P0
ADDR0 = 0;
ADDR1 = 1;
ADDR2 = 1;
ADDR3 = 1;
ENLED = 0;
P2 = 0xF7; //选中第一行按键以进行扫描
while(1)
{
//将按键扫描引脚的值传递到LED上
LED9 = KEY1; //按下时为0,对应的LED点亮
LED8 = KEY2;
LED7 = KEY3;
LED6 = KEY4;
}
}
这个程序可以实现当按下K1、K2、K3或者K4任何一个按键或者多个按键的时候,我们对应赋值的小灯就会点亮,松开按键的时候,小灯就熄灭。这里提醒一句,原理图K1到K4是竖着画的,但是走线布局的时候是横向排布的,注意一下。
从这里可以看出来,其实独立按键本身就是矩阵按键中的一种情况而已,那这样看来我们板子上就有4组每组4个独立共16个独立按键。
8.5.2 按键消抖
绝大多数情况下,我们按按键是不能一直按住的,所以我们通常是判断按键从按下到弹起两种状态发生变化了,就认为是有按键按下。
程序上,我们可以把每次按键状态都存储起来,当下一次按键状态读进来的时候,与当前按键状态做比较,如果发现这两次按键状态不一致,就说明按键发生动作了,当上一次的状态是未按下、现在是按下,此时的按键动作就是“按下”;当上一次的状态是按下、现在是未按下,此时的按键动作就是“弹起”。显然,每次按键动作都会包含一次“按下”动作和一次“弹起”动作,我们可以任选一个动作来执行程序,或者两个都用以执行不同的程序也是可以的。下面还是用程序来直观的看一下。
#include <reg52.h>
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char code LedChar[] = {
0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8e
}; //数码管真值表
void main(void)
{
bit backup = 1; //按键值备份,保存前一次的扫描值
unsigned char counter = 0; //计数器记录按键按下的次数
//选择最右边的数码管进行显示
P0 = LedChar[counter];
ADDR0 = 0;
ADDR1 = 0;
ADDR2 = 0;
ADDR3 = 1;
ENLED = 0;
//选中第一行按键以进行扫描
P2 = 0xF7;
while(1)
{
if (KEY4 != backup) //只取KEY4为例,当前值与前一次值不相等时,说明按键有动作
{
if (backup == 0) //如果前一次的值为0,则说明当前状态是由0变为1,即按键弹起
{
counter++; //计数器+1
if (counter >= 10)
{ //只用1个数码管显示,所以记到10就清零重新开始
counter = 0;
}
P0 = LedChar[counter]; //计数值显示到数码管上
}
backup = KEY4; //更新备份为当前值,以备进行下次比较
}
}
}
在这个程序中,我们以K4为例,按一次按键,就会产生“按下”和“弹起”两个动态的动作,我们选择在“弹起”时对数码管进行加1操作。理论是如此,大家可以在板子上用K4按键做做实验试试,多按几次,是不是会发生这样一种现象:有的时候我明明只按了一下按键,但数字却加了不止1,而是2或者更多?但是我们的程序并没有任何逻辑上的错误,这是怎么回事呢?于是我们就得来说说按键抖动和消抖了。
通常按键所用的开关都是机械弹性开关,当机械触点断开、闭合时,由于机械触点的弹性作用,一个按键开关在闭合时不会马上就稳定的接通,在断开时也不会一下子彻底断开,而是在闭合和断开的瞬间伴随了一连串的抖动,如图8-10所示。

图8-10 按键抖动状态图
按键稳定闭合时间长短是由操作人员决定的,通常都会在100ms以上,刻意快速按的话能达到40-50ms左右,很难再低了。抖动时间是由按键的机械特性决定的,一般是都会在10ms以下,为了确保程序对按键的一次闭合或者一次断开只响应一次,必须进行按键的消抖处理。当检测到按键状态变化时,不是立即去响应动作,而是先等待闭合或断开稳定后再进行处理。按键消抖可分为硬件消抖和软件消抖。
硬件消抖就是在按键上并联一个电容,如图8-11所示,利用电容的充放电特性来对抖动过程中产生的电压毛刺进行平滑处理,从而实现消抖。但实际应用中,这种方式的效果往往不是很好,而且还增加了成本和电路复杂度。所以实际中使用的并不多。

图8-11 电容消抖
在绝大多数情况下,我们是用软件即程序来实现消抖的。最简单的消抖原理,就是当检测到按键状态变化后,先等待一个10ms左右的延时瞬间,让抖动消失后再进行一次按键状态检测,如果与刚才检测到的状态相同,就刻意确认按键已经稳定的动作了。将上边的程序稍加改动,如下所示。
#include <reg52.h>
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char code LedChar[] = {
0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8e
}; //数码管真值表
void delay(void); //延时函数声明
void main(void)
{
bit keybuf = 1; //按键值暂存,临时保存按键的扫描值
bit backup = 1; //按键值备份,保存前一次的扫描值
unsigned char counter = 0; //计数器记录按键按下的次数
//选择最右边的数码管进行显示
P0 = LedChar[counter];
ADDR0 = 0;
ADDR1 = 0;
ADDR2 = 0;
ADDR3 = 1;
ENLED = 0;
//选中第一行按键以进行扫描
P2 = 0xF7;
while(1)
{
keybuf = KEY4; //只取KEY4为例,把当前扫描值暂存
if (keybuf != backup) //当前值与前一次值不相等说明此时按键有动作
{
delay(); //延时大约10ms
if (keybuf == KEY4) //判断扫描值有没有发生改变,即按键抖动
{
if (backup == 0) //如果前一次的值为0,则说明当前状态是由0变为1,即按键弹起
{
counter++; //计数器+1
if (counter >= 10)
{ //只用1个数码管显示,所以记到10就清零重新开始
counter = 0;
}
P0 = LedChar[counter]; //计数值显示到数码管上
}
backup = keybuf; //更新备份为当前值,以备进行下次比较
}
}
}
}
void delay(void)
{
unsigned int i = 1000;
while (i--); //通过debug的KEIL软件延时方式计算得出大概是10ms
}
大家把这个程序下载到板子上再进行试验试试,按一下按键而数字加了多次的问题是不是就这样解决了?把问题解决掉的感觉是不是很爽呢?
这个程序用了一个简单的算法实现了按键的消抖。作为这种很简单的演示程序,我们可以这样来写,但是实际工程开发的时候,我们的程序量很大,各种状态值也很多,我们while(1)的这个主循环要不停的扫描各种状态值是否有发生变化的,如果程序中间加了这种delay延时操作后,很可能某一事件发生了,但是我们程序还在进行delay延时操作中,当这个事件发生完了,我们还在delay操作中,当我们delay完事再去检查的时候,已经晚了,已经检测不到那个事件了。为了避免这种情况的发生,我们要尽量缩短while(1)循环一次所用的事件,而需要进行长时间延时的操作,必须想其它的办法来处理。
那么我们如何处理这种延时问题呢?其实除了这种简单的延时,我们还有更优异的方法来处理按键抖动问题。举个例子:我们启用一个定时中断,每2ms进一次中断,扫描一次按键状态并且存储起来,连续扫描8次后,看看这连续8次的按键状态是否是一致的。8次按键的时间大概是16ms,这16ms内如果按键状态一直保持一种状态,那就可以确定现在按键是稳定的阶段,并非处于抖动的阶段,如图8-12

图8-12 按键连续判断
假如左边时间是起始0时刻,每经过2ms左移一次,每移动一次,判断当前连续的8次按键状态是不是全1或者全0,如果是全1则判定为弹起,如果是全0则判定为按下,如果0和1交错,就认为是抖动,不做任何判定。想一下,这样是不是比简单的延时更加可靠?
利用这种方法,就可以避免通过直接延时按键消抖占用CPU时间,而是转化成了一种按键状态判定而非按键过程判断,我们只对当前按键的连续16ms的8次状态进行判断,而不再关心它在这16ms内都做了什么事情,我们来看看这个程序怎么写。
#include <reg52.h>
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char code LedChar[] = {
0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8e
};
bit KeySta = 1; //当前按键状态
void main(void)
{
bit backup = 1; //按键值备份,保存前一次的值
unsigned char counter = 0; //计数器记录按键按下的次数
//选择最右边的数码管进行显示
P0 = LedChar[counter];
ADDR0 = 0;
ADDR1 = 0;
ADDR2 = 0;
ADDR3 = 1;
ENLED = 0;
//选中第一行按键以进行扫描
P2 = 0xF7;
//配置T0工作在模式1,定时2ms
TMOD = 0x01;
TH0 = 0xF8;
TL0 = 0xCD;
TR0 = 1;
ET0 = 1;
EA = 1;
while(1)
{
if (KeySta != backup) //当前值与前一次值不相等说明此时按键有动作
{
if (backup == 0) //如果前一次的值为0,则说明当前状态是由0变为1,即按键弹起
{
counter++; //计数器+1
if (counter >= 10)
{ //只用1个数码管显示,所以记到10就清零重新开始
counter = 0;
}
P0 = LedChar[counter]; //计数值显示到数码管上
}
backup = KeySta; //更新备份为当前值,以备进行下次比较
}
}
}
void InterruptTimer0() interrupt 1
{
static unsigned char keybuf = 0xFF; //按键扫描缓冲区,保存一段时间内的扫描值
TH0 = 0xF8; //溢出后进入中断重新赋值
TL0 = 0xCD;
keybuf = (keybuf << 1) | KEY4; //只取KEY4为例,缓冲区左移一位,并将当前扫描值移入最低位
if (keybuf == 0x00)
{ //当连续8次扫描值都为0,即16ms内都只检测到按下状态时,可认为按键已按下
KeySta = 0; //按键状态值为按下
}
else if (keybuf == 0xFF)
{ //当连续8次扫描值都为1,即16ms内都只检测到弹起状态时,可认为按键已弹起
KeySta = 1; //按键状态值为弹起
}
else
{} //其它情况下则说明按键状态尚未稳定,则不对KeySta变量值进行更新
}
这个算法是我们在工程中经常使用按键所总结的一个比较好的方法,介绍给大家,今后都可以用这种方法消抖了。当然,按键消抖也还有其它的方法,程序实现更是多种多样,大家也可以再多考虑下其它的算法,拓展下思路。这个程序有一个新知识点,就是bit类型的变量,这个在标准C语言里边是没有的。51单片机有一种特殊的变量类型就是bit型,比如unsigned char型是定义了一个无符号的8位的数据,它占用一个字节(Byte)的内存,而bit型是1位数据,只占用1个位(bit)的内存,用法和标准C中其他的基本数据类型是一致的。它的优点就是节省内存空间,8个bit型变量才相当于1个char型变量所占用的空间。虽然它只有0和1两个值,但也已经可以表示很多东西了,比如:按键的按下和弹起、LED灯的亮和灭、三极管的导通与关断、开关的闭合与断开,联想一下已经学过的内容,它是不是能用最小的内存代价来完成很多工作呢?上面是c语言版的,汇编语言的键盘解说可参考:http://www.51hei.com/mcuteach/227.html 里面也讲得比较详细.
8.5.3 矩阵按键
我们讲独立按键的时候,大家已经简单认识了矩阵按键是什么样子了。矩阵按键相当于4组每组各4个独立按键,一共是16个按键。那我们如何区分这些按键呢?想一下我们生活所在的地球,要想确定我们所在的位置,就要借助经纬线,而矩阵按键就是通过行线和列线来确定哪个按键被按下。在程序中我们是如何进行的呢?
前边讲过,我们的按键按下通常都会保持100ms以上的,那我们程序上就每次快速的让矩阵按键的KeyOut其中一个输出低电平,其他三个输出高电平,判断当前列的按键的状态,下次再让另外一个KeyOut输出低电平,另外三个高电平,再次判断列,通过程序快速执行不断的循环判断,就可以最终确定有哪个按键按下,这个是不是和我们动态刷新数码管有点类似?数码管我们在动态赋值,而按键这里我们在动态读取状态。消抖方式依然采取检测连续状态的方式,只是我们现在连续检测4次就可以了。看下我们的程序,这个程序是按下我们的16个按键K1~K16,对应在最右边的数码管显示0~F,大家学一下矩阵按键的基本用法和矩阵按键消抖的方法。
#include <reg52.h>
sbit KEY_IN_1 = P2^4; //矩阵按键的扫描输入引脚1
sbit KEY_IN_2 = P2^5; //矩阵按键的扫描输入引脚2
sbit KEY_IN_3 = P2^6; //矩阵按键的扫描输入引脚3
sbit KEY_IN_4 = P2^7; //矩阵按键的扫描输入引脚4
sbit KEY_OUT_1 = P2^3; //矩阵按键的扫描输出引脚1
sbit KEY_OUT_2 = P2^2; //矩阵按键的扫描输出引脚2
sbit KEY_OUT_3 = P2^1; //矩阵按键的扫描输出引脚3
sbit KEY_OUT_4 = P2^0; //矩阵按键的扫描输出引脚4
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char code LedChar[] = {
0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8e
}; //数码管真值表
unsigned char KeySta[4][4] = { //全部矩阵按键的当前状态,默认都未按下
{1, 1, 1, 1}, //bit类型不能定义数组,因此定义成unsigned char
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1}
};
void main(void)
{
unsigned char i, j;
unsigned char backup[4][4] = { //按键值备份,保存前一次的值
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1}
};
//选择最右边的数码管进行显示
P0 = 0xFF;
ADDR0 = 0;
ADDR1 = 0;
ADDR2 = 0;
ADDR3 = 1;
ENLED = 0;
//配置T0工作在模式1,定时1ms
TMOD = 0x01;
TH0 = 0xFC;
TL0 = 0x67;
TR0 = 1;
ET0 = 1;
EA = 1;
while(1)
{
//检索按键状态的变化
for (i=0; i<4; i++) //i作为行循环变量
{
for (j=0; j<4; j++) //j作为列循环变量
{
if (backup[i][j] != KeySta[i][j]) //判断按键动作
{
if (backup[i][j] == 0) //判断按键弹起
{
P0 = LedChar[i*4+j]; //执行按键动作
}
backup[i][j] = KeySta[i][j]; //更新前一次的值
}
}
}
}
}
void InterruptTimer0() interrupt 1
{
unsigned char i;
static unsigned char keyout = 0; //矩阵按键扫描输出计数器
static unsigned char keybuf[4][4] = { //按键扫描缓冲区,保存一段时间内的扫描值
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF}
};
TH0 = 0xFC; //溢出后进入中断重新赋值
TL0 = 0x67;
//将一行的4个按键值移入缓冲区
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;
//消抖后更新按键状态
for (i=0; i<4; i++) //每行4个按键,所以循环4次
{
if ((keybuf[keyout][i] & 0x0F) == 0x00)
{ //连续4次扫描值为0,即16ms(4*4ms)内都只检测到按下状态时,可认为按键已按下
KeySta[keyout][i] = 0;
}
else if ((keybuf[keyout][i] & 0x0F) == 0x0F)
{ //连续4次扫描值为1,即16ms(4*4ms)内都只检测到弹起状态时,可认为按键已弹起
KeySta[keyout][i] = 1;
}
}
//执行下一次的扫描输出
keyout++;
keyout &= 0x03; //用跟0x03做“与”的方式,实现加到4即归零,是不是很巧妙,学会它吧
switch (keyout)
{
case 0:
KEY_OUT_4 = 1;
KEY_OUT_1 = 0;
break;
case 1:
KEY_OUT_1 = 1;
KEY_OUT_2 = 0;
break;
case 2:
KEY_OUT_2 = 1;
KEY_OUT_3 = 0;
break;
case 3:
KEY_OUT_3 = 1;
KEY_OUT_4 = 0;
break;
default:
break;
}
}
这个程序是一个比较简单的按键程序,但是大家要把按键消抖和矩阵按键检测机制充分理解透彻,这块内容今后就是你的一个技术积累了。
8.5.4 按键、数码管简单加法运算
这一小节内容只有一个程序,使用我们的矩阵按键实现计算器中简单的整数加法运算,大家可以先把程序复制到Keil中编译下载到板子上试试效果。这是我们第一次做一个算的上的综合性程序,实现了按键和数码管以及C语言灵活运用的一个例程。作为初学者针对这种程序的学习方式是,先从头到尾读一到三遍,边读边理解,然后边抄边理解,彻底理解透彻后,自己尝试独立写出来。完全采用记忆模式来学习这种例程,一两个例程你感觉不到什么提高,当这种例程背过上百八十个的时候,厚积薄发的感觉就会体现出来了。同时,在抄读的过程中注意学习我们程序的编程规范,尽量规整一些。
#include <reg52.h>
sbit KEY_IN_1 = P2^4; //矩阵按键的扫描输入引脚1
sbit KEY_IN_2 = P2^5; //矩阵按键的扫描输入引脚2
sbit KEY_IN_3 = P2^6; //矩阵按键的扫描输入引脚3
sbit KEY_IN_4 = P2^7; //矩阵按键的扫描输入引脚4
sbit KEY_OUT_1 = P2^3; //矩阵按键的扫描输出引脚1
sbit KEY_OUT_2 = P2^2; //矩阵按键的扫描输出引脚2
sbit KEY_OUT_3 = P2^1; //矩阵按键的扫描输出引脚3
sbit KEY_OUT_4 = P2^0; //矩阵按键的扫描输出引脚4
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char code LedChar[] = {
0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8e
}; //数码管真值表
const unsigned char code KeyCodeMap[4][4] = { //矩阵按键编号到PC标准键盘键码的映射表
{ '1', '2', '3', 0x26 }, //数字键1、数字键2、数字键3、向上键
{ '4', '5', '6', 0x25 }, //数字键4、数字键5、数字键6、向左键
{ '7', '8', '9', 0x28 }, //数字键7、数字键8、数字键9、向下键
{ '0', 0x1B, 0x0D, 0x27 } //数字键0、ESC键、 回车键、 向右键
};
unsigned char KeySta[4][4] = { //全部矩阵按键的当前状态
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1}
}; //由于数组不能定义成bit型,这里定义成unsigned char型
unsigned char LedBuf[6] = { //数码管动态扫描显示缓冲区
0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
};
void DisplayNum(unsigned long num);
void KeyAction(unsigned char keycode);
void main(void)
{
unsigned char i, j;
unsigned char backup[4][4] = { //按键值备份,保存前一次的值
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1},
{1, 1, 1, 1}
};
//选择数码管进行显示
P0 = 0xFF;
ADDR3 = 1;
ENLED = 0;
//配置T0工作在模式1,定时1ms
TMOD = 0x01;
TH0 = 0xFC;
TL0 = 0x67;
TR0 = 1;
ET0 = 1;
EA = 1;
while(1)
{
//检索按键状态的变化
for (i=0; i<4; i++)
{
for (j=0; j<4; j++)
{
if (backup[i][j] != KeySta[i][j])
{
if (backup[i][j] == 0) //按键弹起时执行动作
{
KeyAction(KeyCodeMap[i][j]);
}
backup[i][j] = KeySta[i][j];
}
}
}
}
}
void KeyAction(unsigned char keycode)
{
static unsigned long result = 0; //用于保存运算结果
static unsigned long addend = 0; //用于保存输入的加数
if ((keycode>='0') && (keycode<='9')) //输入0-9的数字
{
addend = (addend*10) + (keycode-'0'); //原数据扩大10倍,由新输入的数字填充其个位
DisplayNum(addend); //运算结果显示到数码管
}
else if (keycode == 0x26) //向上键用作加号,执行加法或连加运算
{
result += addend; //进行加法运算
addend = 0;
DisplayNum(result); //运算结果显示到数码管
}
else if (keycode == 0x0D) //回车键,执行加法运算(实际效果与加号并无区别)
{
result += addend; //进行加法运算
addend = 0;
DisplayNum(result); //运算结果显示到数码管
}
else if (keycode == 0x1B) //Esc键,清零结果
{
addend = 0;
result = 0;
DisplayNum(addend); //清零后的加数显示到数码管
}
}
void DisplayNum(unsigned long num)
{
signed char i;
unsigned char buf[6];
for (i=0; i<6; i++) //把长整型数转换为6位十进制的数组
{
buf[i] = num % 10;
num /= 10;
}
for (i=5; i>=1; i--) //从最高位起,遇到0即转换为空格,遇到非0即退出
{
if (buf[i] == 0)
{
LedBuf[i] = 0xFF;
}
else
{
break;
}
}
for ( ; i>=0; i--) //剩余低位都如实转换为数字
{
LedBuf[i] = LedChar[buf[i]];
}
}
void InterruptTimer0() interrupt 1
{
unsigned char i;
static unsigned char ledcnt = 0; //数码管扫描计数器
static unsigned char keyout = 0; //矩阵按键扫描输出计数器
static unsigned char keybuf[4][4] = { //按键扫描缓冲区,保存一段时间内的扫描值
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF},
{0xFF, 0xFF, 0xFF, 0xFF}
};
TH0 = 0xFC; //溢出后进入中断重新赋值
TL0 = 0x67;
//将一行的4个按键值移入缓冲区
keybuf[keyout][0] = (keybuf[keyout][0] << 1) | KEY_IN_1;
keybuf[keyout][1] = (keybuf[keyout][1] << 1) | KEY_IN_2;
keybuf[keyout][2] = (keybuf[keyout][2] << 1) | KEY_IN_3;
keybuf[keyout][3] = (keybuf[keyout][3] << 1) | KEY_IN_4;
//消抖后更新按键状态
for (i=0; i<4; i++) //每行4个按键,所以循环4次
{
if ((keybuf[keyout][i] & 0x0F) == 0x00)
{ //连续4次扫描值为0,即16ms(4*4ms)内都只检测到按下状态时,可认为按键已按下
KeySta[keyout][i] = 0;
}
else if ((keybuf[keyout][i] & 0x0F) == 0x0F)
{ //连续4次扫描值为1,即16ms(4*4ms)内都只检测到弹起状态时,可认为按键已弹起
KeySta[keyout][i] = 1;
}
}
//执行下一次的扫描输出
keyout++;
keyout &= 0x03;
switch (keyout)
{
case 0:
KEY_OUT_4 = 1;
KEY_OUT_1 = 0;
break;
case 1:
KEY_OUT_1 = 1;
KEY_OUT_2 = 0;
break;
case 2:
KEY_OUT_2 = 1;
KEY_OUT_3 = 0;
break;
case 3:
KEY_OUT_3 = 1;
KEY_OUT_4 = 0;
break;
default:
break;
}
//执行数码管动态扫描显示
P0 = 0xFF;
switch (ledcnt)
{
case 0: ADDR0=0; ADDR1=0; ADDR2=0; break;
case 1: ADDR0=1; ADDR1=0; ADDR2=0; break;
case 2: ADDR0=0; ADDR1=1; ADDR2=0; break;
case 3: ADDR0=1; ADDR1=1; ADDR2=0; break;
case 4: ADDR0=0; ADDR1=0; ADDR2=1; break;
case 5: ADDR0=1; ADDR1=0; ADDR2=1; break;
default: break;
}
P0 = LedBuf[ledcnt];
ledcnt++;
if (ledcnt >= 6)
{
ledcnt = 0;
}
}
8.6 作业
1、理解单片机最小系统三要素电路设计规则。
2、掌握函数间相互调用的方法和规则。
3、学会独立按键和矩阵按键的电路设计方法和软件编程思路。
4、用一个按键实现一个数码管数字从F~0递减的变化程序。
5、用矩阵按键做一个减法运算
上一课:第七章 点阵LED的学习
下一课:第九章 步进电机和蜂鸣器 |