第8章 函数进阶与按键
用户与单片机之间的信息交互需要依赖于两类设备:输入设备和输出设备。前边讲的LED小灯、数码管、点阵都是输出设备,本章就来学习一下最常用的输入设备——按键,同时还会学到一些硬件电路的基础知识与C语言函数的一些进阶知识。
8.1单片机最小系统解析
B站在线视频教程: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所示。
有源晶振通常有4个引脚,VCC,GND,晶振输出引脚和一个没有用到的悬空引脚(有些晶振也把该引脚作为使能引脚)。无源晶振有2个或3个引脚,如果是3个引脚的话,中间引脚接是晶振的外壳,使用时要接到GND,两侧的引脚就是晶体的2个引出脚了,这两个引脚作用是等同的,就像是电阻的2个引脚一样,没有正负之分。对于无源晶振,单片机上的两个晶振引脚接上去即可;而有源晶振,只接到单片机的晶振的输入引脚上,输出引脚上不需要接,如图8-3和图8-4所示。
8.1.3复位电路
分析一下Kingst51开发板上的复位电路,如图8-5所示。
当这个电路处于稳态时,电容起到隔离直流的作用,隔离了+5V,而左侧的复位按键是弹起状态,下方电路就没有电压差的产生,所以按键和电容C11以下部分的电位都是和GND相等的,即0V。单片机是高电平复位,低电平正常工作,正常工作的电压是0V。
从没有电到上电的瞬间,电容C11上方电压是5V,下方是0V,根据初中所学的知识,电容C11要进行充电,正离子从上往下充电,负电子从GND往上充电,这个时候电容对电路来说相当于一根导线,全部电压都加在了R31这个电阻上,那么RST端口位置的电压就是5V,随着电容充电越来越多,即将充满的时候,电流会越来越小,那RST端口上的电压值等于电流乘以R31的阻值,也就会越来越小,一直到电容完全充满后,线路上不再有电流,这个时候RST和GND的电位就相等了也就是0V了。
8.2函数的调用
B站在线视频教程:8-2 函数的调用
在一个程序的编写过程中,随着代码量的增加,如果把所有的语句都写到main函数中,一方面程序会显得的比较乱;另一方面,当同一个功能需要在不同地方执行时,就得再重复写一遍相同的语句。此时,如果把一些零碎的功能单独写成一个函数,在需要它们时只需进行一些简单的函数调用,这样既有助于程序结构的清晰条理,又可以避免大块的代码重复。
在实际工程项目中,一个程序通常都是由很多个子程序模块组成的,一个模块实现一个特定的功能,在C语言中,这个模块就用函数来表示。一个C程序一般由一个主函数和若干个其他函数构成。主函数可以调用其它函数,其它函数也可以相互调用,但其它函数不能调用主函数。在51单片机程序中,还有中断服务函数,是当相应的中断到来后自动调用的,不需要也不能由其它函数来调用。
函数调用的一般形式是:
函数名 (实参列表)
函数名就是需要调用的函数名称,实参列表是根据实际需求调用函数要传递给被调用函数的参数列表,不需要传递参数时只保留括号,传递多个参数时参数之间要用逗号隔开。
那么先举例看一下函数调用使程序结构更加条理清晰方面的作用。回顾一下图6-1所示的程序流程图和为实现它而编写的程序代码,相对来说这个主函数的结构就比较复杂了,很难一眼看清楚它的执行流程。那么如果把其中最重要的两件事——秒计数和数码管动态扫描功能都用单独的函数来实现会怎样呢?来看以下程序。
#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 LedChar[] = { //数码管显示字符转换表
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E
};
unsigned char LedBuff[6] = { //数码管显示缓冲区,初值0xFF确保启动时都不亮
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
};
void SecondCount();
void LedRefresh();
void main()
{
ENLED = 0; //使能U3,选择控制数码管
ADDR3 = 1; //因为需要动态改变ADDR0-2的值,所以不需要再初始化了
TMOD = 0x01; //设置T0为模式1
TH0 = 0xFC; //为T0赋初值0xFC67,定时1ms
TL0 = 0x67;
TR0 = 1; //启动T0
while (1)
{
if (TF0 == 1) //判断T0是否溢出
{
TF0 = 0; //T0溢出后,清零中断标志
TH0 = 0xFC; //并重新赋初值
TL0 = 0x67;
SecondCount(); //调用秒计数函数
LedRefresh(); //调用显示刷新函数
}
}
}
/* 秒计数函数,每秒进行一次秒数+1,并转换为数码管显示字符 */
void SecondCount()
{
static unsigned int cnt = 0; //记录T0中断次数
static unsigned long sec = 0; //记录经过的秒数
cnt++; //计数值自加1
if (cnt >= 1000) //判断T0溢出是否达到1000次
{
cnt = 0; //达到1000次后计数值清零
sec++; //秒计数自加1
LedBuff[0] = LedChar[sec%10];
LedBuff[1] = LedChar[sec/10%10];
LedBuff[2] = LedChar[sec/100%10];
LedBuff[3] = LedChar[sec/1000%10];
LedBuff[4] = LedChar[sec/10000%10];
LedBuff[5] = LedChar[sec/100000%10];
}
}
/* 数码管动态扫描刷新函数 */
void LedRefresh()
{
static unsigned char i = 0; //动态扫描的索引
switch (i)
{
case 0: ADDR2=0; ADDR1=0; ADDR0=0; i++; P0=LedBuff[0]; break;
case 1: ADDR2=0; ADDR1=0; ADDR0=1; i++; P0=LedBuff[1]; break;
case 2: ADDR2=0; ADDR1=1; ADDR0=0; i++; P0=LedBuff[2]; break;
case 3: ADDR2=0; ADDR1=1; ADDR0=1; i++; P0=LedBuff[3]; break;
case 4: ADDR2=1; ADDR1=0; ADDR0=0; i++; P0=LedBuff[4]; break;
case 5: ADDR2=1; ADDR1=0; ADDR0=1; i=0; P0=LedBuff[5]; break;
default: break;
}
}
看一下,主函数的结构是不是清晰的多了——每隔1ms就去干两件事,至于这两件事是什么交由各自的函数去实现。还请大家注意一点:原来程序中的i、cnt、sec这三个变量在放到单独的函数中后,都加了static关键字而变成了静态变量。
当然,这里刻意把程序功能做了这样的划分,主要目的还是来讲解函数的调用,对于这个程序即使不划分函数也复杂不到哪里去,但继续学下去就能领会到划分功能函数的必要了。现在还是把注意力放在学习函数调用上,有以下几点需要注意:
1、函数调用的时候,不需要加函数类型。在主函数内调用SecondCount()和LedRefresh()时都没有加void。
2、调用函数与被调用函数的位置关系,C语言规定:函数在被调用之前,必须先被定义或声明。意思就是说:在一个文件中,一个函数应该先定义,然后才能被调用,也就是调用函数应位于被调用函数的下方。但是作为一种通常的编程规范,推荐main函数写在最前面(因为它起到提纲挈领的作用),其后再定义各个功能函数,而中断函数则写在最后。那么主函数要调用定义在它之后的函数怎么办呢?就在文件开头,所有函数定义之前,开辟一块区域,叫做函数声明区,用来把被调用的函数声明一下,该函数就可以被随意调用了。如上述例程所示。
3、函数声明的时候必须加函数类型,函数的形式参数,最后加上一个分号表示结束。函数声明行与函数定义行的唯一区别就是最后的分号,其它的都必须保持一致。这点请尤其注意,初学者很容易因粗心大意而搞错分号或是修改了定义行中的形参却忘了修改声明行中的形参,导致程序编译不过。
8.3函数的形式参数和实际参数
上一个例程中在进行函数调用的时候,不需要任何参数传递,所以函数定义和调用时括号内都是空的,但是更多的时候需要在主调函数和被调用函数之间传递参数。在调用一个有参数的函数时,函数名后边括号中的参数叫做实际参数,简称实参。而被调用的函数在进行定义时,括号里的参数叫做形式参数,简称形参,用一个简单程序例子做说明。
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类型,就都会有返回值,返回值类型就是函数的类型。关于形参和实参,还有以下几点需要注意。
- 函数定义中指定的形参,在未发生函数调用时不占内存,只有函数调用时,函数add中的形参才被分配内存单元。在调用结束后,形参所占的内存单元也被释放,这个前边讲过了,形参是局部变量。
- 实参可以是常量,也可以是简单或者复杂的表达式,但是要求必须有确定的值,在调用发生时将实参的值传递给形参。如上边这个程序也可以写成:c = add(1, a+b);
- 形参必须要指定数据类型,和定义变量一样,因为它本来就是局部变量。
- 实参和形参的数据类型应该相同或者赋值兼容。和变量赋值一样,当形参和实参出现不同类型时,则按照不同类型数值的赋值规则进行转换。
- 主调函数在调用函数之前,应对被调函数做原型声明。
- 实参向形参的数据传递是单向传递,不能由形参再回传给实参。也就是说,实参值传递给形参后,调用结束,形参单元被释放,而实参单元仍保留并且维持原值。
8.4按键
8.4.1独立按键
B站在线视频教程:8-3 按键基本原理
常用的按键电路有两种形式,独立式按键和矩阵式按键,独立式按键比较简单,它们各自与独立的输入线相连接,如图8-6所示。
4条输入线接到单片机的I/O口,当按键K1按下时,+5V通过电阻R1再通过按键K1最终进入GND形成一条通路,这条线路的电压都加到R1这个电阻上,KeyIn1这个引脚就是个低电平。当松开按键,线路断开,就不会有电流通过,KeyIn1和+5V就应该是等电位,是一个高电平。就可以通过KeyIn1这个I/O口的高低电平来判断是否有按键按下。
实际上在单片机I/O口内部,也有一个上拉电阻的存在。Kingst51开发板的按键接到P2口上,P2口上电默认是准双向I/O口,来了解一下这个准双向I/O口的电路,如图8-7所示。
首先说明一点,现在绝大多数单片机的I/O口都是使用MOS管而非三极管,但用在这里的MOS管其原理和三极管是一样的,因此在这里用三极管来进行原理讲解,把前面讲过的三极管的知识搬过来,一切都是适用的,有助于理解。
图8-7方框内的电路都是指单片机内部部分,方框外的就是外接的上拉电阻和按键。这个地方要注意一下,就是当单片机要读取外部按键信号的时候,必须先给该引脚写“1”,也就是高电平,这样才能正确读取到外部按键信号。
当“内部输出”是高电平,经过一个反向器变成低电平,NPN三极管不会导通,那么单片机I/O口从内部来看,由于上拉电阻R的存在,所以是一个高电平。当外部没有按键按下将电平拉低的话,VCC也是+5V,它们之间虽然有2个电阻,但是没有压差,就不会有电流,线上所有的位置都是高电平,这个时候就可以正常读取到按键的状态了。
当“内部输出”是个低电平,经过一个反相器变成高电平,NPN三极管导通,那么单片机的内部I/O口就是个低电平,这个时候,外部虽然也有上拉电阻的存在,但是两个电阻是并联关系,不管按键是否按下,单片机的I/O口上输入到单片机内部的状态都是低电平,就无法正常读取到按键的状态了。
和水流类似,内部和外部,只要有一个点是低电位,电流就会顺流而下,由于只有上拉电阻没有下拉电阻分压,直接到GND上,线路上就是低电平了。
可以得出一个结论,这种具有上拉的准双向I/O口,如果要正常读取外部信号的状态,得保证内部输出1,如果内部输出0,无论外部信号是1还是0,这个引脚读进来的都是0。
8.4.2矩阵按键
在某一个系统设计中,如果需要使用很多的按键时,做成独立按键会大量占用I/O口,因此引入了矩阵按键的设计。如图8-8所示,是Kingst51开发板上的矩阵按键电路原理图,使用8个I/O口来实现了16个按键。
独立按键理解了,矩阵按键也不难理解。图8-8中,一共有4组按键,如果只看其中一组,如图8-9所示。KeyOut1输出一个低电平,KeyOut1就相当于是GND,是否相当于4个独立按键呢。当然这时候KeyOut2、KeyOut3、KeyOut4都必须输出高电平,才能保证与它们相连的三路按键不会对这一路产生干扰,可以对照两张原理图分析一下。
8.4.3独立按键的扫描
B站在线视频教程:8-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()
{
ENLED = 0; //选择独立LED进行显示
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;
P2 = 0xF7; //P2.3置0,即KeyOut1输出低电平
while(1)
{
//将按键扫描引脚的值传递到LED上
LED9 = KEY1; //按下时为0,对应的LED点亮
LED8 = KEY2;
LED7 = KEY3;
LED6 = KEY4;
}
}
本程序在KeyOut1上输出低电平,而KeyOut2~4保持高电平,就相当于是把矩阵按键的第一行,即K1~K4作为4个独立按键来处理,然后把这4个按键的状态直接送给LED9~6这4个LED小灯。当按键按下时,对应按键的输入引脚是0,对应小灯控制信号是低电平,于是灯就亮了,这说明上述关于按键检测的理论都是可实现的。
绝大多数情况下,按键是不会一直按住的,所以通常检测按键的动作并不是检测一个固定的电平值,而是检测电平值的变化,即按键在按下和弹起这两种状态之间的变化,只要发生了这种变化就说明现在按键产生动作了。如何判断按键被按下事件发生?
假设代表按键的IO口的状态x,x为0代表按下,x为1代表弹起。某一t时刻读取一次按键IO口的状态,x为1;而t+1时刻再次读取一次按键IO口状态,x为0,通过x的变化得知按键状态发生了变化,即按键被按下的事件发生。同理如果t时刻读到的x为0,而t+1时刻读取到IO口状态x为1,则按键弹起的事件发生。
每次按键动作都会包含一次“按下”和一次“弹起”,可以任选其一来执行程序,或者两个都用,以执行不同的功能程序。把每次t时刻扫描到的按键状态都保存起来,当t+1时刻按键状态扫描进来的时候,与前一次t时刻扫描的状态进行比较,如果这两次按键状态不一致,就说明按键产生动作了。下面用程序实现这个功能,程序只取按键K4为例。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
unsigned char code LedChar[] = { //数码管显示字符转换表
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E
};
void main()
{
bit backup = 1; //定义位变量,保存t时刻扫描的按键值,默认按键状态为弹起
unsigned char cnt = 0; //定义一个计数变量,记录按键按下的次数
ENLED = 0; //选择数码管DS1进行显示
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;
P2 = 0xF7; //P2.3置0,即KeyOut1输出低电平
P0 = LedChar[cnt]; //显示按键次数初值
while (1)
{
if (KEY4 != backup) //t+1时刻KEY4与t时刻不相等说明此时按键有动作
{
if (backup == 0) //如果t时刻值为0,则说明当前是由0变1,即按键弹起
{
cnt++; //按键次数+1
if (cnt >= 10)
{ //只用1个数码管显示,所以加到10就清零重新开始
cnt = 0;
}
P0 = LedChar[cnt]; //计数值显示到数码管上
}
backup = KEY4; //更新备份按键状态,以备进行下次比较
}
}
}
先来介绍出现在程序中的一个新知识点,变量类型——bit,这个在标准C语言是没有的。51单片机有一种特殊的变量类型就是bit型。unsigned char型是定义了一个无符号的8位的数据,它占用一个字节(Byte)的内存,而bit型是1位数据,只占用1个位(bit)的内存,用法和标准C中其他的基本数据类型是一致的。它的优点就是节省内存空间,8个bit型变量才相当于1个char型变量所占用的空间。虽然它只有0和1两个值,但也已经可以表示很多东西了,比如:按键的按下和弹起、LED灯的亮和灭、三极管的导通与关断等等。
在这个程序中,以K4为例,按一次按键,就会产生“按下”和“弹起”两个动态的动作,程序选择在“弹起”时对数码管进行加1操作。理论虽是如此,但是经过多次实验是否发现了这样一种现象:有的时候明明只按了一下按键,但数字却加了不止1,而是2或者更多?但是程序并没有逻辑上的错误,这是怎么回事呢?这是一个按键抖动和消抖的问题。
8.4.4按键消抖
B站在线视频教程:8-5按键消抖
通常按键所用的开关都是机械弹性开关,当机械触点断开、闭合时,由于机械触点的弹性作用,一个按键在闭合时不会马上就稳定的接通,在断开时也不会一下子彻底断开,而是在闭合和断开的瞬间伴随了一连串的抖动,如图8-10所示。
按键稳定闭合时间长短是由操作人员决定的,通常都会在100ms以上,刻意快速按的话能达到40-50ms左右,很难再低了。抖动时间是由按键的机械特性决定的,一般都会在10ms以内,为了确保程序对按键的一次闭合或者一次断开只响应一次,必须进行按键的消抖处理。当检测到按键状态变化时,不是立即去响应动作,而是先等待闭合或断开稳定后再进行处理。
最简单的消抖,就是当检测到按键状态变化后,先等待一个10ms左右的延时时间,让抖动消失后再进行一次按键状态检测,如果与刚才检测到的状态相同,就可以确认按键已经稳定的动作了。将上一个的程序稍加改动,得到新的带消抖功能的程序如下。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
unsigned char code LedChar[] = { //数码管显示字符转换表
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E
};
void delay();
void main()
{
bit keybuf = 1; //按键值暂存,临时保存按键的扫描值
bit backup = 1; //按键值备份,保存前一次的扫描值
unsigned char cnt = 0; //按键计数,记录按键按下的次数
ENLED = 0; //选择数码管DS1进行显示
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;
P2 = 0xF7; //P2.3置0,即KeyOut1输出低电平
P0 = LedChar[cnt]; //显示按键次数初值
while (1)
{
keybuf = KEY4; //把当前扫描值暂存
if (keybuf != backup) //当前值与前次值不相等说明此时按键有动作
{
delay(); //延时大约10ms
if (keybuf == KEY4) //判断扫描值有没有发生改变,即按键抖动
{
if (backup == 0) //如果前次值为0,则说明当前是弹起动作
{
cnt++; //按键次数+1
if (cnt >= 10)
{ //只用1个数码管显示,所以加到10就清零重新开始
cnt = 0;
}
P0 = LedChar[cnt]; //计数值显示到数码管上
}
backup = keybuf; //更新备份为当前值,以备进行下次比较
}
}
}
}
/* 软件延时函数,延时约10ms */
void delay()
{
unsigned int i = 1000;
while (i--);
}
作为消抖的功能演示程序可以采用延时的办法,但是实际做开发的时候,程序量往往很大,各种状态值也很多,while(1)这个主循环要不停的扫描各种状态值是否有发生变化,及时的进行任务调度,如果程序中间加了这种delay延时操作后,很可能某一事件发生了,但是程序还在进行delay延时操作中,当这个事件发生完了,程序还在delay操作中,当delay完事再去检查的时候,已经检测不到那个事件了。为了避免这种情况的发生,要尽量缩短while(1)循环一次所用的时间,而需要进行长时间延时的操作,必须想其它的办法来处理。
那么消抖该采用什么办法呢?介绍一种作者在实际工程中常常采用的一种办法:启用一个定时中断,每2ms进一次中断,扫描一次按键状态并且存储起来,连续扫描8次后,看看这连续8次的按键状态是否是一致的。8次按键的时间大概是16ms,这16ms内如果按键状态一直保持一致,那就可以确定现在按键处于稳定的阶段,而非处于抖动的阶段,如图8-12。
假如8-12图中t时刻检测到了某一个按键[1,2,3,4,5,6,7,8]这8个状态,t+1(t+2ms)时刻检测[2,3,4,5,6,7,8,9]这8个状态,t+2(t+4ms)时刻检测[3,4,5,6,7,8,9,10]这8个状态... ...,随着时间的推移,检测按键也不断更新连续的8次按键状态。按键状态分为弹起、抖动和按下三种状态,当程序检测到连续8次按键状态全为1时,则代表按键状态为弹起;当程序检测到连续8次按键状态全为0时,则代表按键状态为按下;当检测连续8次按键状态是0和1交错,则代表按键为抖动。
利用这种方法,就可以避免通过延时消抖占用单片机执行时间,而是转化成了一种按键状态判定而非按键过程判定,只对当前按键的连续16ms的8次状态进行判断,而不再关心它在这16ms内都做了什么,下面就按照这种思路用程序实现出来,同样只以K4为例。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit KEY1 = P2^4;
sbit KEY2 = P2^5;
sbit KEY3 = P2^6;
sbit KEY4 = P2^7;
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()
{
bit backup = 1; //按键值备份,保存前一次的扫描值,默认弹起
unsigned char cnt = 0; //按键计数,记录按键按下的次数
EA = 1; //使能总中断
ENLED = 0; //选择数码管DS1进行显示
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;
TMOD = 0x01; //设置T0为模式1
TH0 = 0xF8; //为T0赋初值0xF8CD,定时2ms
TL0 = 0xCD;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
P2 = 0xF7; //P2.3置0,即KeyOut1输出低电平
P0 = LedChar[cnt]; //显示按键次数初值
while (1)
{
if (KeySta != backup) //当前值与前次值不相等说明此时按键有动作
{
if (backup == 0) //如果前次值为0,则说明当前是弹起动作
{
cnt++; //按键次数+1
if (cnt >= 10)
{ //只用1个数码管显示,所以加到10就清零重新开始
cnt = 0;
}
P0 = LedChar[cnt]; //计数值显示到数码管上
}
backup = KeySta; //更新备份为当前值,以备进行下次比较
}
}
}
/* T0中断服务函数,用于按键状态的扫描并消抖 */
void InterruptTimer0() interrupt 1
{
static unsigned char keybuf = 0xFF; //扫描缓冲区,保存一段时间内的扫描值,默认按键弹起为全‘1’
TH0 = 0xF8; //重新加载初值,定时2ms
TL0 = 0xCD;
keybuf = (keybuf<<1) | KEY4; //缓冲区左移一位,将当前按键状态更新至最低位
if (keybuf == 0x00)
{ //连续8次扫描值都为0,即16ms内都只检测到按下状态时,可认为按键已按下
KeySta = 0;
}
else if (keybuf == 0xFF)
{ //连续8次扫描值都为1,即16ms内都只检测到弹起状态时,可认为按键已弹起
KeySta = 1;
}
else
{} //其它情况则说明按键状态尚未稳定,则不对KeySta变量值进行更新
}
8.4.5矩阵按键的扫描
B站在线视频教程:8-6 矩阵按键扫描
介绍独立按键扫描的时候,已经简单认识了矩阵按键是什么样子了。矩阵按键相当于4组每组各4个独立按键,一共是16个按键。那如何区分这些按键呢?
前边讲过,按键按下通常都会保持100ms以上。如果在按键扫描中断程序中,每次让矩阵按键的一个KeyOut输出低电平,其它三个输出高电平,判断当前4个KeyIn的状态,下次中断时再让下一个KeyOut输出低电平,其它三个输出高电平,再次判断所有KeyIn,通过快速的中断不停的循环进行判断,就可以最终确定哪个按键按下了,扫描原理是不是跟数码管动态扫描有点类似?数码管在动态赋值,而按键这里在动态读取状态。至于扫描间隔时间和消抖时间,由于现在有4个KeyOut输出,要中断4次才能完成一次全部按键的扫描,显然再采用2ms中断判断8次扫描值的方式时间就太长了(248=64ms),那么就改用1ms中断判断4次采样值,这样消抖时间还是16ms(144)。下面就用程序实现程序循环扫描板子上的K1~K16这16个矩阵按键,在按键按下时把当前按键的编号显示在一位数码管上(用0~F表示,显示值=按键编号-1)。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit KEY_IN_1 = P2^4;
sbit KEY_IN_2 = P2^5;
sbit KEY_IN_3 = P2^6;
sbit KEY_IN_4 = P2^7;
sbit KEY_OUT_1 = P2^3;
sbit KEY_OUT_2 = P2^2;
sbit KEY_OUT_3 = P2^1;
sbit KEY_OUT_4 = P2^0;
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代表弹起,0表示按下
{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}
};
void main()
{
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}
};
EA = 1; //使能总中断
ENLED = 0; //选择数码管DS1进行显示
ADDR3 = 1;
ADDR2 = 0;
ADDR1 = 0;
ADDR0 = 0;
TMOD = 0x01; //设置T0为模式1
TH0 = 0xFC; //为T0赋初值0xFC67,定时1ms
TL0 = 0x67;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
P0 = LedChar[0]; //默认显示0
while (1)
{
for (i=0; i<4; i++) //循环检测4*4的矩阵按键,比较16个按键状态是否发生变化
{
for (j=0; j<4; j++)
{
if (backup[i][j] != KeySta[i][j]) //检测按键动作
{
if (backup[i][j] != 0) //按键按下时执行动作
{
P0 = LedChar[i*4+j]; //将编号显示到数码管
}
backup[i][j] = KeySta[i][j]; //更新前一次的备份值
}
}
}
}
}
/* T0中断服务函数,扫描矩阵按键状态并消抖 */
void InterruptTimer0() interrupt 1
{
unsigned char i = 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;
switch (keyout) //根据输出索引值,拉低相应输出引脚
{
case 0: KEY_OUT_1 = 0; break;
case 1: KEY_OUT_2 = 0; break;
case 2: KEY_OUT_3 = 0; break;
case 3: KEY_OUT_4 = 0; break;
default: break;
}
//将keyout = x(x为1,2,3,4中其中一个)时对应一行的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,即4*4ms内都是按下状态时,可认为按键已稳定的按下
KeySta[keyout][i] = 0;
}
else if ((keybuf[keyout][i] & 0x0F) == 0x0F)
{ //连续4次扫描值为1,即4*4ms内都是弹起状态时,可认为按键已稳定的弹起
KeySta[keyout][i] = 1;
}
}
keyout++; //输出索引值递增
keyout = keyout & 0x03; //索引值加到4即归零
//拉高全部输出引脚
KEY_OUT_1 = 1;
KEY_OUT_2 = 1;
KEY_OUT_3 = 1;
KEY_OUT_4 = 1;
}
这个程序完成了矩阵按键的扫描、消抖、动作分离的全部内容,还有两点值得说明。
首先,中断函数中扫描KeyIn输入和切换KeyOut输出的顺序与前面提到的不同,程序中先对所有的KeyIn输入做了扫描、消抖,然后才切换到了下一次的KeyOut输出,也就是说中断每次扫描的实际是上一次KeyOut输出选择的那行按键,这是为什么呢?任何信号从输出到稳定都需要时间,有时它足够快而有时却不够快,这取决于具体的电路设计,如果keyout为0时立即读取与其对应的四个按键的KeyIn值,虽然程序上已经让keyout为0了,但是电路反应并没有那么快,电路中对应的keyout引脚还没有完全拉成低电平的话,读取到的值就有可能出现错误。这里的输入输出顺序的颠倒就是为了让输出信号有足够的时间(一次中断间隔)来稳定,并有足够的时间来完成它对输入的影响,虽然这样使得程序理解起来有点绕,但它的适应性是最好的,换个说法就是,这段程序足够“健壮”,足以应对各种恶劣情况。
其次,是一点小小的编程技巧。注意看keyout = keyout & 0x03;这一行,这里是要让keyout在0~3之间变化,加到4就自动归零,按照常规可以用前面讲过的if语句轻松实现,但是现在看一下这样程序是不是同样可以做到这一点呢?因为0、1、2、3这四个数值正好占用两个二进制的位,所以把一个字节的高6位一直清零的话,这个字节的值自然就是一种到4归零的效果了。看一下,这样一句代码比if语句要更为简洁吧,而效果完全一样。
8.5简易加法计算器
B站在线视频教程:8-7 简易加法计算器
学到这里,已经掌握了一种显示设备和一种输入设备的使用,那么是不是可以来做点综合性的实验了。现在就来做一个简易的加法计算器,用程序实现从板子上标有0~9数字的按键输入相应数字,该数字要实时显示到数码管上,用标有向上箭头的按键代替加号,按下加号后可以再输入一串数字,然后回车键计算加法结果,并同时显示到数码管上。虽然这远不是一个完善的计算器程序,但作为初学者也可以巩固一下前边学习到的所有内容。
首先,本程序相对于之前的例程要复杂得多,需要完成的工作也多得多,所以把各个子功能都做成独立的函数,以使程序便于编写和维护。分析程序的时候就从主函数和中断函数入手,随着程序的流程进行就可以了。
其次,可以看到再把矩阵按键扫描分离出动作以后,并没有直接使用行列数所组成的数值作为分支判断执行动作的依据,而是把抽象的行列数转换为了一种叫做标准键盘键码(就是电脑键盘的编码)的数据,然后用得到的这个数据作为下一步分支判断执行动作的依据,为什么多此一举呢?有两层含义:第一,尽量让自己设计的东西(包括硬件和软件)向已有的行业规范或标准看齐,这样有助于别人理解认可设计,也有助于设计与别人的设计相对接,毕竟标准就是为此而生的。第二,有助于程序的层次化而方便维护与移植,比如现在用的按键是44的,但如果后续又增加了一行成了45的,那么由行列数组成的编号可能就变了,就要在程序的各个分支中查找修改,稍不留神就会出错,而采用这种转换后,则只需要维护KeyCodeMap这样一个数组表格就行了,看上去就像是把程序的底层驱动与应用层的功能实现函数分离开了,应用层不用关心底层的实现细节,底层改变后也无需在应用层中做相应修改,两层程序之间是一种标准化的接口。这就是程序的层次化,而层次化是构建复杂系统的必备条件,那么现在就先通过简单的示例来学习一下吧。
#include <reg52.h>
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
sbit KEY_IN_1 = P2^4;
sbit KEY_IN_2 = P2^5;
sbit KEY_IN_3 = P2^6;
sbit KEY_IN_4 = P2^7;
sbit KEY_OUT_1 = P2^3;
sbit KEY_OUT_2 = P2^2;
sbit KEY_OUT_3 = P2^1;
sbit KEY_OUT_4 = P2^0;
unsigned char code LedChar[] = { //数码管显示字符转换表
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E
};
unsigned char LedBuff[6] = { //数码管显示缓冲区
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
};
unsigned char code KeyCodeMap[4][4] = { //矩阵按键编号到标准键盘键码的映射表
{ 0x31, 0x32, 0x33, 0x26 }, //数字键1、数字键2、数字键3、向上键
{ 0x34, 0x35, 0x36, 0x25 }, //数字键4、数字键5、数字键6、向左键
{ 0x37, 0x38, 0x39, 0x28 }, //数字键7、数字键8、数字键9、向下键
{ 0x30, 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}
};
void KeyDriver();
void main()
{
EA = 1; //使能总中断
ENLED = 0; //选择数码管进行显示
ADDR3 = 1;
TMOD = 0x01; //设置T0为模式1
TH0 = 0xFC; //为T0赋初值0xFC67,定时1ms
TL0 = 0x67;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
LedBuff[0] = LedChar[0]; //上电显示0
while (1)
{
KeyDriver(); //调用按键驱动函数
}
}
/* 将一个无符号长整型的数字显示到数码管上,num-待显示数字 */
void ShowNumber(unsigned long num)
{
signed char i;
unsigned char buf[6];
for (i=0; i<6; i++) //把长整型数转换为6位十进制的数组
{
buf[i] = num % 10;
num = num / 10;
}
for (i=5; i>=1; i--) //从最高位起,遇到0转换为空格,遇到非0则退出循环
{
if (buf[i] == 0)
LedBuff[i] = 0xFF;
else
break;
}
for ( ; i>=0; i--) //剩余低位都如实转换为数码管显示字符
{
LedBuff[i] = LedChar[buf[i]];
}
}
/* 按键动作函数,根据键码执行相应的操作,keycode-按键键码 */
void KeyAction(unsigned char keycode)
{
static unsigned long result = 0; //用于保存运算结果
static unsigned long addend = 0; //用于保存输入的加数
if ((keycode>=0x30) && (keycode<=0x39)) //输入0-9的数字
{
addend = (addend*10)+(keycode-0x30); //整体十进制左移,新数字进入个位
ShowNumber(addend); //运算结果显示到数码管
}
else if (keycode == 0x26) //向上键用作加号,执行加法或连加运算
{
result += addend; //进行加法运算
addend = 0;
ShowNumber(result); //运算结果显示到数码管
}
else if (keycode == 0x0D) //回车键,执行加法运算(实际效果与加号相同)
{
result += addend; //进行加法运算
addend = 0;
ShowNumber(result); //运算结果显示到数码管
}
else if (keycode == 0x1B) //Esc键,清零结果
{
addend = 0;
result = 0;
ShowNumber(addend); //清零后的加数显示到数码管
}
}
/* 按键驱动函数,检测按键动作,调度相应动作函数,需在主循环中调用 */
void KeyDriver()
{
unsigned char i, j;
static unsigned char backup[4][4] = { //按键值备份,保存前一次的值
{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}
};
for (i=0; i<4; i++) //循环检测4*4的矩阵按键
{
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]; //刷新前一次的备份值
}
}
}
}
/* 按键扫描函数,需在定时中断中调用,推荐调用间隔1ms */
void KeyScan()
{
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}
};
switch (keyout) //根据输出索引值,拉低相应输出引脚
{
case 0: KEY_OUT_1 = 0; break;
case 1: KEY_OUT_2 = 0; break;
case 2: KEY_OUT_3 = 0; break;
case 3: KEY_OUT_4 = 0; break;
default: break;
}
//将一行的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,即4*4ms内都是按下状态时,可认为按键已稳定的按下
KeySta[keyout][i] = 0;
}
else if ((keybuf[keyout][i] & 0x0F) == 0x0F)
{ //连续4次扫描值为1,即4*4ms内都是弹起状态时,可认为按键已稳定的弹起
KeySta[keyout][i] = 1;
}
}
keyout++; //输出索引值递增
keyout = keyout & 0x03; //索引值加到4即归零
//拉高全部输出引脚
KEY_OUT_1 = 1;
KEY_OUT_2 = 1;
KEY_OUT_3 = 1;
KEY_OUT_4 = 1;
}
/* 数码管动态扫描刷新函数,需在定时中断中调用 */
void LedScan()
{
static unsigned char i = 0; //动态扫描的索引
P0 = 0xFF; //显示消隐
switch (i)
{
case 0: ADDR2=0; ADDR1=0; ADDR0=0; i++; P0=LedBuff[0]; break;
case 1: ADDR2=0; ADDR1=0; ADDR0=1; i++; P0=LedBuff[1]; break;
case 2: ADDR2=0; ADDR1=1; ADDR0=0; i++; P0=LedBuff[2]; break;
case 3: ADDR2=0; ADDR1=1; ADDR0=1; i++; P0=LedBuff[3]; break;
case 4: ADDR2=1; ADDR1=0; ADDR0=0; i++; P0=LedBuff[4]; break;
case 5: ADDR2=1; ADDR1=0; ADDR0=1; i=0; P0=LedBuff[5]; break;
default: break;
}
}
/* T0中断服务函数,用于数码管显示扫描与按键扫描 */
void InterruptTimer0() interrupt 1
{
TH0 = 0xFC; //重新加载初值
TL0 = 0x67;
LedScan(); //调用数码管显示扫描函数
KeyScan(); //调用按键扫描函数
}
8.6练习题
- 理解单片机最小系统三要素电路设计规则。
- 掌握函数间相互调用的方法和规则。
- 学会独立按键和矩阵按键的电路设计方法和软件编程思路。
- 用一个按键实现一个数码管数字从F~0递减的变化程序。
- 用矩阵按键做一个简易减法计算器。