第9章综合练习
本章内容主要通过一些实践例程,来提高编程的熟练度,并且进行一些算法和技巧上的积累。介绍单片机IO口结构、上下拉电阻、蜂鸣器和继电器,数据类型转换、中断响应调整、位操作技巧、以及PWM等。在学习本章内容的时候,还是那句话,一定要能够达到不看教材,能够独立把程序做出来的效果,那样才能基本上掌握相关知识点和内容。
9.1单片机I/O口的结构
B站在线视频教程:9-1 单片机IO结构与上下拉电阻
上节课提到了单片机I/O口其中一种“准双向I/O”的内部结构,实际上单片机I/O口还有另外三种状态,分别是开漏、推挽、高阻态,通过图9-1来分析下另外这三种状态。
前边介绍“准双向I/O”的时候是用三极管来说明的,出于严谨的态度,这里按照实际用MOS管示意。实际上三极管是靠电流导通,而MOS管是靠电压导通的,具体缘由和它们的内部构造有关系。在单片机I/O口状态这一块内容上,可以把MOS管当三极管来理解。在图9-1中,T1相当于一个PNP三极管,T2相当于一个NPN三极管。
准双向I/O口原理已经讲过了,开漏输出和准双向I/O的唯一区别,就是开漏输出把内部的上拉电阻去掉了。开漏输出如果要输出高电平时,T2关断,I/O电平要靠外部的上拉电阻才能拉成高电平,如果没有外部上拉电阻I/O电平就是一个不确定态。标准51单片机的P0口默认就是开漏输出,如果要用的时候外部需要加上拉电阻。而强推挽输出就是有比较强的驱动能力,如图9-1中第三张小图,当内部输出一个高电平时,通过MOS管直接输出电流,没有电阻的限流,电流输出能力也比较大;如果内部输出一个低电平,那反向电流也可以很大,强推挽的一个特点就是驱动能力强。
单片机I/O还有一种状态叫高阻态。通常用来做输入引脚的时候,可以将I/O口设置成高阻态,高阻态引脚本身如果悬空,用万用表测量的时候可能是高可能是低,它的状态完全取决于外部输入信号的电平,高阻态引脚对GND的等效电阻很大(理论上相当于无穷大,但实际上总是有限值而非无穷大),所以称之为高阻。
9.2上下拉电阻
前边很多次提到了上拉电阻,下拉电阻,具体到底什么样的电阻算是上下拉电阻,上下拉电阻都有何作用呢?上拉电阻就是将不确定的信号通过一个电阻拉到高电平,同时此电阻也起到一个限流作用,下拉就是下拉到低电平。
比如I/O设置为开漏输出高电平或者是高阻态时,默认的电平就是不确定的,外部经一个电阻接到VCC,也就是上拉电阻,那么相应的引脚就是高电平;经一个电阻到GND,也就是下拉电阻,那么相应的引脚就是一个低电平。
上拉电阻应用很多,都可以起到什么作用呢?主要先了解最常用的以下4点。
- OC门要输出高电平,必须外部加上拉电阻才能正常使用,其实OC门就相当于单片机I/O的开漏输出,其原理可参照图9-1中的开漏电路。
- 加大普通I/O口的驱动能力。标准51单片机的内部I/O口的上拉电阻,一般都是在几十K欧,比如STC89C52内部是20K的上拉电阻,所以最大输出电流是250uA,因此外部加个上拉电阻,可以形成和内部上拉电阻的并联结构,增大高电平时电流的输出能力。
- 在电平转换电路中,比如前边讲的5V转12V的电路中,上拉电阻其实起到的是限流电阻的作用,可以回顾一下图3-8。
- 单片机中未使用的引脚,比如总线引脚,引脚悬空时,容易受到电磁干扰而处于紊乱状态,虽然不会对程序造成什么影响,但通常会增加单片机的功耗,加上一个对VCC的上拉电阻或者一个对GND的下拉电阻后,可以有效的抵抗电磁干扰。
那么在进行电路设计的时候,又该如何选择合适的上下拉电阻的阻值呢?
- 从降低功耗的方面考虑应当足够大,因为电阻越大,电流越小。
- 从确保足够的引脚驱动能力考虑应当足够小,电阻小了,电流才能大。
- 在开漏输出时,过大的上拉电阻会导致信号上升沿变缓。实际电平的变化都是需要时间的,虽然很小,但永远都达不到零,而开漏输出时上拉电阻的大小就直接影响了这个上升过程所需要的时间,如图9-2所示。想一下,如果电阻很大,而信号频率又很快的话,最终将导致信号还没等上升到高电平就又变为低了,于是信号就无法正确传送了。
综合考虑各种情况,常用的上下拉电阻值大多选取在1K到10K之间,具体到底多大通常要根据实际需求来选,通常情况下在标准范围内就可以,不一定是一个固定的值。
9.3蜂鸣器
B站在线视频教程:9-2 蜂鸣器
蜂鸣器从结构区分分为压电式蜂鸣器和电磁式蜂鸣器。压电式为压电陶瓷片发音,电流比较小一些,电磁式蜂鸣器为线圈通电震动发音,体积比较小。
按照驱动方式分为有源蜂鸣器和无源蜂鸣器。这里的有源和无源不是指电源,而是振荡源。有源蜂鸣器内部带了振荡源,如图9-3所示中,给了BUZZ引脚一个低电平,三极管导通蜂鸣器就会直接响。而无源蜂鸣器内部是不带振荡源的,要让它响必须给500Hz~4.5KHz之间的脉冲频率信号来驱动它才会响。有源蜂鸣器往往比无源蜂鸣器稍贵,因为里边多了振荡电路,驱动发音也简单,靠电平就可以驱动,而无源蜂鸣器价格比较便宜,但驱动起来比较麻烦。
如图9-3所示,Kingst51开发板采用的是有源蜂鸣器,由于蜂鸣器电流依然相对较大,因此需要用三极管驱动。蜂鸣器经常用于电脑、打印机、万用表这些设备上做提示音,提示音一般也很简单,发出个声音就可以了。
#include <reg52.h>
sbit BUZZ = P1^6; //蜂鸣器控制引脚
void main()
{
EA = 1; //使能总中断
TMOD = 0x01; //设置T0为模式1
TH0 = 0xFC; //为T0赋初值0xFC67,定时1ms
TL0 = 0x67;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
BUZZ = 0; //启动蜂鸣器鸣叫
while (1);
}
/* T0中断服务函数,用于控制蜂鸣器发声 */
void InterruptTimer0() interrupt 1
{
static unsigned int tmr = 0;
TH0 = 0xFC; //重新加载重载值
TL0 = 0x67;
tmr++;
if (BUZZ == 0)
{
if (tmr >= 500) //蜂鸣器鸣叫0.5s
{
BUZZ = 1;
tmr = 0;
}
}
else
{
if (tmr >= 1500) //蜂鸣器关闭1.5s
{
BUZZ = 0;
tmr = 0;
}
}
}
9.4继电器
B站在线视频教程:9-3 继电器
继电器是根据一定的信号来接通或者断开电流电路的控制元件,它具有控制系统和被控制系统。当控制系统达到一定条件时,继电器会动作,使被控制的输出电路导通或者断开。
继电器具有隔离、转换、放大和自动保护等多种功能。它能够将输入电路中的小电流、低电压信号转换为输出电路中的大电流、高电压信号,从而驱动各种负载设备。同时,继电器还能够实现电路之间的隔离,保护电路免受干扰和损坏。此外,继电器还具有灵敏度高、动作速度快、体积小、重量轻等优点。这使得继电器在电力系统、通信系统、自动控制系统等领域得到了广泛应用。
Kkingst51开发板所使用的继电器是一个5V控制系统,最大被控制电压250V的继电器。它一共有5个引脚,其中2个控制系统引脚,3个被控制系统引脚。3个被控制引脚为单刀双掷,分别为公共端,常开和常闭,如图9-4所示。
图9-4中,当单片机的RELAY引脚为高电平时,三极管截止,继电器的控制端没有电流通过,1脚和公共端2脚接到一起;当单片机的RELAY引脚为低电平时,三极管导通,这时候由于磁力的作用,3脚和公共端2脚吸合到一起。
继电器的用法分为常开(NO,Normally Open)和常闭(NC,Normally Closed)两种方式。常开指的是继电器线圈在未通电的状态下,其触点处于断开状态。常开方式常用于在特定条件下启动某个电器设备的场景,比如声控灯,电动门禁等。常闭指的是继电器线圈在未通电的状态下,其触点处于闭合状态。常闭常用于在特定条件下断开连接的场合,比如安全监测系统,当检测到气体泄漏时,继电器激活,断开常闭点。
图9-4中加了一个D4这样一个二极管,这个二极管称之为续流二极管。继电器内部有线圈,属于感性器件,当三极管导通给继电器供电时,就会有导通电流流过继电器,电感上也会储存有能量。但是当三极管从导通变截止时的瞬间,经“+5V-三极管-继电器-GND”这条回路就截断了,无法通过电流,而电感由于之前存储了电流这个时候无处可去了,就是要经过D4和继电器自身的环路来消耗,从而避免了关断时由于电感电流造成的反向冲击,接续关断时的电流,这就是续流二极管名称的由来。
继电器的控制程序比较简单,如下所示通过IO口即可控制继电器的开关。
#include <reg52.h>
sbit RELAY = P3^3; //继电器控制引脚
void main()
{
unsigned int i;
RELAY = 0; //继电器吸合
for (i=0; i<50000; i++); //延时一段时间
RELAY = 1; //继电器释放
while (1);
}
9.5不同数据类型间的相互转换
B站在线视频教程:9-4 编程实用技巧
在C语言中,不同数据类型之间是可以混合运算的。当表达式中的数据类型不一致时,首先转换为同一种类型,然后再进行计算。C语言有两种方法实现类型转换,一是自动类型转换,另外一种是强制类型转换。
不同数据类型之间转换的主要原则是:短字节的数据向长字节数据转换。
比如:unsigned char a;
unsigned int b;
unsigned int c;
c = a *b;
在运算的过程中,程序会自动全部按照unsigned int
型来计算。比如a=10
,b=200
,c的结果就是2000
。那当a=100
,b=700
,那c是70000
吗?新手最容易犯这种错误,要注意每个变量类型的取值范围,c的数据类型是unsigned int
型,取值范围是0~65535
,而70000
超过65535
了,其结果会溢出,最终c的结果是(70000 - 65536) = 4464
。
那要想让c正常获得70000
这个结果,需要把c定义成一个unsigned long
型。如果写成:unsigned char a=100;
unsigned int b=700;
unsigned long c=0;
c = a*b;
有做过实验的同学,会发现这个c的结果还是4464
,这个是个什么情况呢?
请注意,C语言不同类型运算的时候数值会转换同一类型运算,但是每一步运算都会进行识别判断,不会进行一个总的分析判断。比如这段代码中a和b相乘的时候,是按照unsigned int
类型运算的,运算的结果也是unsigned int
类型的4464
,只是最终把unsigned int
类型4464
赋值给了一个unsigned long
型的变量而已。在运算的时候如何避免这类问题的产生呢?可以采用强制类型转换的方法。
在一个变量前边加上一个数据类型名,并且这个类型名用小括号括起来,就表示把这个变量强制转换成括号里的类型。如 c = (unsigned long)a * b;
由于强制类型转换运算符优先级高于*,所以这个地方的运算是先把a转换成一个unsigned long
型的变量,而后与b相乘,根据C语言的规则b会自动转换成一个unsigned long
型的变量,而后运算完毕结果也是一个unsigned long
型的,最终赋值给了c。
不同类型变量之间的相互赋值,短字节类型变量向长字节类型变量赋值时,其值保持不变,比如unsigned char a=100;
unsigned int b=700;
b=a;
那么最终b的值就是100
了。但是如果程序是unsigned char a=100;
unsigned int b=700;
a=b;
那么a的值仅仅是取了b的低8位,首先要把700变成一个16位的二进制数据,然后取它的低8位出来,也就是188,这就是长字节类型给短字节类型赋值的结果,会从长字节类型的低位开始截取刚好等于短字节类型长度的位,然后赋给短字节类型。
在51单片机里边,有一种特殊情况,就是bit类型的变量,这个bit类型的强制类型转换,是不符合上边讲的这个原则的,比如bit a=0;
unsigned char b;
a=(bit)b;
使用bit
做强制类型转换,不是取b的最低位,而是它会判断b这个变量是0还是非0的值,如果b是0,那么a的结果就是0,如果b是任意非0的其它值,那么a的结果都是1。
9.6定时器中断响应调整
在6.5.2章节有一个数码管秒表显示程序,那个程序是1秒数码管加1,但是细心的读者做了实验,经过长时间运行会发现,和实际的时间有了较大误差了,那如何去调整这种误差呢?要解决问题,先找到问题是什么原因造成的。
先对前面讲过的中断内容做一个较深层次的补充。还是讲解中断的那个场景,当在看电视的时候,突然发生了水开的中断,必须去提水的时候,第一,从电视跟前跑到厨房需要一定的时间,第二,因为看的电视是智能数字电视,因此在去提水之前可以使用遥控器将电视进行暂停操作,方便回来后继续从刚才的剧情往下进行。暂停电视,跑到厨房提水,这一点点时间是很短的,在实际生活中可以忽略不计,但是在单片机秒表程序中,误差是会累计的,每1秒钟都差了几个微秒,时间一久,造成的累计误差就不可小觑了。
单片机系统里,硬件进入中断需要一定的时间,大概是几个机器周期,还要进行原始数据保护,就是把进中断之前程序运行的一些变量先保存起来,专业术语叫做中断压栈,进入中断后,重新给定时器TH和TL赋值,也需要几个机器周期,这样下来就会消耗一定的时间,得把这些时间补偿回来。
方法一,使用软件debug进行补偿。
前边讲过使用debug来观察程序运行时间,那可以把2次进入中断的时间间隔观察出来,看看和实际定时的时间相差了几个机器周期,然后在进行定时器初值赋值的时候,进行一个调整。Kingst51开发板用的是11.0592M的晶振,发现差了几个机器周期,就把定时器初值加上几个机器周期,这样就相当于进行了一个补偿。
方法二,使用累计误差计算出来。
有的时候,除了程序本身存在的误差外,硬件精度也可能会影响到时钟的精度,比如晶振,会随着温度变化出现温漂现象,就是实际值和标称值要差一点。那么还可以采取累计误差的方法来提高精度。让时钟运行半个小时或者一个小时,看看最终时间差了几秒,然后算算一共进了多少次中断,把差的几秒平均分配到每次中断中,就可以实现时钟的调整。这个世界上本就没有绝对的精确,只能在一定程度上提高精确度,但是永远都不会使误差为零。
9.7字节操作修改位的技巧
在编程时,有的情况下需要改变一个字节中的某一位或者几位,但是又不想改变其它位原有的值,该如何操作呢?
例如学习定时器的时候遇到一个寄存器TCON,这个寄存器是可以进行位操作的,可以直接写TR0=1;TR0是TCON的一个位。还有一个寄存器TMOD,这个寄存器是不支持位操作的,那如果要使用T0的模式1,希望达到的效果是TMOD的低4位是0b0001,但如果直接写成TMOD = 0x01的话,实际上已经同时操作到了高4位,即属于T1的部分,设置成了0b0000,如果T1定时器没有用到的话,那随便怎么样都行,但是如果程序中既用到了T0,又用到了T1,那设置T0的同时已经干扰到了T1的模式配置,这是不希望看到的结果。
在这种情况下,就可以用前边学过的“&”和“|”运算了。对于二进制位操作来说,不管该位原来的值是0还是1,它跟0进行&运算,得到的结果都是0,而跟1进行&运算,将保持原来的值不变;不管该位原来的值是0还是1,它跟1进行|运算,得到的结果都是1,而跟0进行|运算,将保持原来的值不变。
利用上述这个规律,就可以着手解决刚才的问题了。如果现在要设置TMOD使定时器0工作在模式1下,又不干扰定时器1的配置,可以进行这样的操作:TMOD = TMOD & 0xF0; TMOD = TMOD | 0x01;
第一步与0xF0做&运算后,TMOD的高4位不变,低4位清零,变成了0bxxxx0000;
然后再进行第二步与0x01进行|运算,那么高7位均不变,最低位变成1了,这样就完成了只将低4位的值修改位0b0001,而高4位保持原值不变的任务,即只设置了T0而不影响T1。
另外,在C语言中,a &= b;
等价于a = a&b;
同理,a |= b;
等价于a = a|b;
那么刚才的一段代码就可以写成TMOD &= 0xF0;
TMOD |= 0x01
这样的简写形式。这种写法可以一定程度上简化代码,是C语言常用的一种编程风格。
9.8数码管扫描函数算法改进
在学习数码管动态扫描时为了方便理解,程序写的细致一些,引入了switch的用法,随着编程能力与领悟能力的增强,对于74HC138这种非常有规律的数字器件,在编程上也可以改进一下逻辑算法,让程序变的更简洁。这种逻辑算法,通常不是靠学一下可以全部掌握的,而是通过不断的编写程序以及研究他人程序的过程中一点点积累起来的。
前边动态扫描刷新函数是这么写的:
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;
}
每一个case分支结构是相同的,即改变ADDR2~0、改变索引i、取数据写入P0,只要把case后的常量与ADDR2~0和LedBuff的下标对比,就可以发现它们其实是相等的,那么可以直接把常量值(实际上就是i在改变前的值)赋值给它们即可,而不必写上6遍。还剩下一个i的操作,它进行了5次相同的++与一次归0操作,那么很明显用++和if判断就可以替代这些操作。下面就是据此改进后的代码:
P0 = 0xFF;
P1 = (P1 & 0xF8) | i;
P0 = LedBuff[i];
if (i < 5)
i++;
else
i = 0;
P1 = (P1 & 0xF8) | i;
这行代码就利用了前面讲到的&和|运算来将i的低3位直接赋值到P1口的低3位上,而P0的赋值也只需要一行代码,i的处理也很简单。这样代码是不是要简洁的多,也巧妙的多,功能与前面的switch是一样的,同样可以实现动态显示刷新的功能。
9.8.1秒表程序
B站在线视频教程:9-5 实用秒表案例
设计一个秒表程序,程序中涉及到的知识点前边都讲过了,包括了定时器、数码管、中断、按键等多个知识点。多知识点同时应用到一个程序中的小综合。此程序是一个“真正的”并且“实用的”秒表程序,第一它有足够的分辨率,保留到小数点后两位,即每10ms计一次数,第二它也足够精确,因为补偿了定时器中断延时造成的误差,它完全可以为用来测量百米成绩。这种小综合也是将来做大项目程序的基础。
#include <reg52.h>
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
};
unsigned char LedBuff[6] = { //数码管显示缓冲区
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
};
unsigned char KeySta[4] = { //按键当前状态
1, 1, 1, 1
};
bit StopwatchRunning = 0; //秒表运行标志
bit StopwatchRefresh = 1; //秒表计数刷新标志
unsigned char DecimalPart = 0; //秒表的小数部分
unsigned int IntegerPart = 0; //秒表的整数部分
unsigned char T0RH = 0; //T0重载值的高字节
unsigned char T0RL = 0; //T0重载值的低字节
void ConfigTimer0(unsigned int ms);
void StopwatchDisplay();
void KeyDriver();
void main()
{
EA = 1; //开总中断
ENLED = 0; //使能选择数码管
ADDR3 = 1;
P2 = 0xFE; //P2.0置0,选择第4行按键作为独立按键
ConfigTimer0(2); //配置T0定时2ms
while (1)
{
if (StopwatchRefresh) //需要刷新秒表示数时调用显示函数
{
StopwatchRefresh = 0;
StopwatchDisplay();
}
KeyDriver(); //调用按键驱动函数
}
}
/* 配置并启动T0,ms-T0定时时间 */
void ConfigTimer0(unsigned int ms)
{
unsigned long tmp; //临时变量
tmp = 11059200 / 12; //定时器计数频率
tmp = (tmp * ms) / 1000; //计算所需的计数值
tmp = 65536 - tmp; //计算定时器重载值
tmp = tmp + 18; //补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp>>8); //定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; //清零T0的控制位
TMOD |= 0x01; //配置T0为模式1
TH0 = T0RH; //加载T0重载值
TL0 = T0RL;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
}
/* 秒表计数显示函数 */
void StopwatchDisplay()
{
signed char i;
unsigned char buf[4]; //数据转换的缓冲区
//小数部分转换到低2位
LedBuff[0] = LedChar[DecimalPart%10];
LedBuff[1] = LedChar[DecimalPart/10];
//整数部分转换到高4位
buf[0] = IntegerPart%10;
buf[1] = (IntegerPart/10)%10;
buf[2] = (IntegerPart/100)%10;
buf[3] = (IntegerPart/1000)%10;
for (i=3; i>=1; i--) //整数部分高位的0转换为空字符
{
if (buf[i] == 0)
LedBuff[i+2] = 0xFF;
else
break;
}
for ( ; i>=0; i--) //有效数字位转换为显示字符
{
LedBuff[i+2] = LedChar[buf[i]];
}
LedBuff[2] &= 0x7F; //点亮小数点
}
/* 秒表启停函数 */
void StopwatchAction()
{
if (StopwatchRunning) //已启动则停止
StopwatchRunning = 0;
else //未启动则启动
StopwatchRunning = 1;
}
/* 秒表复位函数 */
void StopwatchReset()
{
StopwatchRunning = 0; //停止秒表
DecimalPart = 0; //清零计数值
IntegerPart = 0;
StopwatchRefresh = 1; //置刷新标志
}
/* 按键驱动函数,检测按键动作,调度相应动作函数,需在主循环中调用 */
void KeyDriver()
{
unsigned char i;
static unsigned char backup[4] = {1,1,1,1};
for (i=0; i<4; i++) //循环检测4个按键
{
if (backup[i] != KeySta[i]) //检测按键动作
{
if (backup[i] != 0) //按键按下时执行动作
{
if (i == 1) //Esc键复位秒表
StopwatchReset();
else if (i == 2) //回车键启停秒表
StopwatchAction();
}
backup[i] = KeySta[i]; //刷新前一次的备份值
}
}
}
/* 按键扫描函数,需在定时中断中调用 */
void KeyScan()
{
unsigned char i;
static unsigned char keybuf[4] = { //按键扫描缓冲区
0xFF, 0xFF, 0xFF, 0xFF
};
//按键值移入缓冲区
keybuf[0] = (keybuf[0] << 1) | KEY1;
keybuf[1] = (keybuf[1] << 1) | KEY2;
keybuf[2] = (keybuf[2] << 1) | KEY3;
keybuf[3] = (keybuf[3] << 1) | KEY4;
//消抖后更新按键状态
for (i=0; i<4; i++)
{
if (keybuf[i] == 0x00)
{ //连续8次扫描值为0,即16ms内都是按下状态时,可认为按键已稳定的按下
KeySta[i] = 0;
}
else if (keybuf[i] == 0xFF)
{ //连续8次扫描值为1,即16ms内都是弹起状态时,可认为按键已稳定的弹起
KeySta[i] = 1;
}
}
}
/* 数码管动态扫描刷新函数,需在定时中断中调用 */
void LedScan()
{
static unsigned char i = 0; //动态扫描索引
P0 = 0xFF; //关闭所有段选位,显示消隐
P1 = (P1 & 0xF8) | i; //位选索引值赋值到P1口低3位
P0 = LedBuff[i]; //缓冲区中索引位置的数据送到P0口
if (i < 5) //索引递增循环,遍历整个缓冲区
i++;
else
i = 0;
}
/* 秒表计数函数,每隔10ms调用一次进行秒表计数累加 */
void StopwatchCount()
{
if (StopwatchRunning) //当处于运行状态时递增计数值
{
DecimalPart++; //小数部分+1
if (DecimalPart >= 100) //小数部分计到100时进位到整数部分
{
DecimalPart = 0;
IntegerPart++; //整数部分+1
if (IntegerPart >= 10000) //整数部分计到10000时归零
{
IntegerPart = 0;
}
}
StopwatchRefresh = 1; //设置秒表计数刷新标志
}
}
/* T0中断服务函数,完成数码管、按键扫描与秒表计数 */
void InterruptTimer0() interrupt 1
{
static unsigned char tmr10ms = 0;
TH0 = T0RH; //重新加载重载值
TL0 = T0RL;
LedScan(); //数码管扫描显示
KeyScan(); //按键扫描
//定时10ms进行一次秒表计数
tmr10ms++;
if (tmr10ms >= 5)
{
tmr10ms = 0;
StopwatchCount(); //调用秒表计数函数
}
}
9.9PWM知识与实例
B站在线视频教程:9-6 PWM初步认识
PWM在单片机中的应用是非常广泛的,它的基本原理很简单,但往往应用于不同场合上意义也不完全一样。
PWM是Pulse Width Modulation的缩写,中文名字是脉冲宽度调制,一种说法是它利用微处理器的数字输出对模拟电路进行控制的一种有效的技术,其实就是使用数字信号达到一个模拟信号的效果。这是个什么概念呢?
首先从名字来看,脉冲宽度调制,就是改变脉冲宽度来实现不同的效果。先来看三组不同的脉冲信号,如图9-5所示。
这是一个周期是10ms,即频率是100Hz的波形,但是每个周期内,高低电平脉冲宽度各不相同,这就是PWM的本质。引入一个概念--占空比。占空比是指高电平的时间占整个周期的比例。比如第一部分波形的占空比是40%,第二部分波形占空比是60%,第三部分波形占空比是80%,这就是PWM的解释。
为何它能对模拟电路进行控制呢?数字电路里,只有0和1两种状态,比如第2章学会的点亮LED小灯那个程序,写一个LED = 0;小灯就会长亮,写一个LED = 1;小灯就会灭掉。当让小灯亮和灭间隔运行的时候,小灯是闪烁。如果把这个间隔不断的减小,减小到肉眼分辨不出来,也就是100Hz以上的频率,这个时候小灯表现出来的现象就是既保持亮的状态,但亮度又没有LED = 0;时的亮度高。不断改变时间参数,让LED = 0;的时间大于或者小于LED = 1;的时间,会发现亮度都不一样,这就是模拟电路的感觉了,不再是纯粹的0和1,还有亮度不断变化。如果用100Hz的信号,如图9-5所示,假如高电平熄灭小灯,低电平点亮小灯的话,第一部分波形熄灭4ms,点亮6ms,亮度最高,第二部分熄灭6ms,点亮4ms,亮度次之,第三部分熄灭8ms,点亮2ms,亮度最低。那么用程序验证一下理论,用定时器T0定时改变P0.0的输出来实现PWM,与纯定时不同的是,这里每周期内都要重载两次定时器初值,即用两个不同的初值来控制高低电平的不同持续时间。为了使亮度的变化更加明显,程序中使用的占空比差距更大。
#include <reg52.h>
sbit PWMOUT = P0^0;
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned char HighRH = 0; //高电平重载值的高字节
unsigned char HighRL = 0; //高电平重载值的低字节
unsigned char LowRH = 0; //低电平重载值的高字节
unsigned char LowRL = 0; //低电平重载值的低字节
void ConfigPWM(unsigned int fr, unsigned char dc);
void ClosePWM();
void main()
{
unsigned int i;
EA = 1; //开总中断
ENLED = 0; //使能独立LED
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;
while (1)
{
ConfigPWM(100, 10); //频率100Hz,占空比10%
for (i=0; i<40000; i++);
ClosePWM();
ConfigPWM(100, 40); //频率100Hz,占空比40%
for (i=0; i<40000; i++);
ClosePWM();
ConfigPWM(100, 90); //频率100Hz,占空比90%
for (i=0; i<40000; i++);
ClosePWM(); //关闭PWM,相当于占空比100%
for (i=0; i<40000; i++);
}
}
/* 配置并启动PWM,fr-频率,dc-占空比 */
void ConfigPWM(unsigned int fr, unsigned char dc)
{
unsigned int high, low;
unsigned long tmp;
tmp = (11059200/12) / fr; //计算一个周期所需的计数值
high = (tmp*dc) / 100; //计算高电平所需的计数值
low = tmp - high; //计算低电平所需的计数值
high = 65536 - high + 12; //计算高电平的重载值并补偿中断延时
low = 65536 - low + 12; //计算低电平的重载值并补偿中断延时
HighRH = (unsigned char)(high>>8); //高电平重载值拆分为高低字节
HighRL = (unsigned char)high;
LowRH = (unsigned char)(low>>8); //低电平重载值拆分为高低字节
LowRL = (unsigned char)low;
TMOD &= 0xF0; //清零T0的控制位
TMOD |= 0x01; //配置T0为模式1
TH0 = HighRH; //加载T0重载值
TL0 = HighRL;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
PWMOUT = 1; //输出高电平
}
/* 关闭PWM */
void ClosePWM()
{
TR0 = 0; //停止定时器
ET0 = 0; //禁止中断
PWMOUT = 1; //输出高电平
}
/* T0中断服务函数,产生PWM输出 */
void InterruptTimer0() interrupt 1
{
if (PWMOUT == 1) //当前输出为高电平时,装载低电平值并输出低电平
{
TH0 = LowRH;
TL0 = LowRL;
PWMOUT = 0;
}
else //当前输出为低电平时,装载高电平值并输出高电平
{
TH0 = HighRH;
TL0 = HighRL;
PWMOUT = 1;
}
}
需要提醒的是,由于标准51单片机中没有专门的PWM模块,所以用定时器加中断的方式来产生PWM,而现在有很多的单片机都会集成硬件的PWM模块,这种情况下需要做的仅仅是计算一下周期计数值和占空比计数值然后配置到相关的SFR中即可,既使程序得到了简化又确保了PWM的输出品质(因为消除了中断延时的影响)。
如果想让亮度等级更多,并且让亮度等级连续起来,会产生一个小灯渐变的效果,与呼吸有点类似,习惯上称之为呼吸灯,程序代码如下,这个程序用了2个定时器2个中断。
B站在线视频教程:9-7 PWM呼气灯
#include <reg52.h>
sbit PWMOUT = P0^0;
sbit ADDR0 = P1^0;
sbit ADDR1 = P1^1;
sbit ADDR2 = P1^2;
sbit ADDR3 = P1^3;
sbit ENLED = P1^4;
unsigned long PeriodCnt = 0; //PWM周期计数值
unsigned char HighRH = 0; //高电平重载值的高字节
unsigned char HighRL = 0; //高电平重载值的低字节
unsigned char LowRH = 0; //低电平重载值的高字节
unsigned char LowRL = 0; //低电平重载值的低字节
unsigned char T1RH = 0; //T1重载值的高字节
unsigned char T1RL = 0; //T1重载值的低字节
void ConfigTimer1(unsigned int ms);
void ConfigPWM(unsigned int fr, unsigned char dc);
void main()
{
EA = 1; //开总中断
ENLED = 0; //使能独立LED
ADDR3 = 1;
ADDR2 = 1;
ADDR1 = 1;
ADDR0 = 0;
ConfigPWM(100, 10); //配置并启动PWM
ConfigTimer1(50); //用T1定时调整占空比
while (1);
}
/* 配置并启动T1,ms-定时时间 */
void ConfigTimer1(unsigned int ms)
{
unsigned long tmp; //临时变量
tmp = 11059200 / 12; //定时器计数频率
tmp = (tmp * ms) / 1000; //计算所需的计数值
tmp = 65536 - tmp; //计算定时器重载值
tmp = tmp + 12; //补偿中断响应延时造成的误差
T1RH = (unsigned char)(tmp>>8); //定时器重载值拆分为高低字节
T1RL = (unsigned char)tmp;
TMOD &= 0x0F; //清零T1的控制位
TMOD |= 0x10; //配置T1为模式1
TH1 = T1RH; //加载T1重载值
TL1 = T1RL;
ET1 = 1; //使能T1中断
TR1 = 1; //启动T1
}
/* 配置并启动PWM,fr-频率,dc-占空比 */
void ConfigPWM(unsigned int fr, unsigned char dc)
{
unsigned int high, low;
PeriodCnt = (11059200/12) / fr; //计算一个周期所需的计数值
high = (PeriodCnt*dc) / 100; //计算高电平所需的计数值
low = PeriodCnt - high; //计算低电平所需的计数值
high = 65536 - high + 12; //计算高电平的定时器重载值并补偿中断延时
low = 65536 - low + 12; //计算低电平的定时器重载值并补偿中断延时
HighRH = (unsigned char)(high>>8); //高电平重载值拆分为高低字节
HighRL = (unsigned char)high;
LowRH = (unsigned char)(low>>8); //低电平重载值拆分为高低字节
LowRL = (unsigned char)low;
TMOD &= 0xF0; //清零T0的控制位
TMOD |= 0x01; //配置T0为模式1
TH0 = HighRH; //加载T0重载值
TL0 = HighRL;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
PWMOUT = 1; //输出高电平
}
/* 占空比调整函数,频率不变只调整占空比 */
void AdjustDutyCycle(unsigned char dc)
{
unsigned int high, low;
high = (PeriodCnt*dc) / 100; //计算高电平所需的计数值
low = PeriodCnt - high; //计算低电平所需的计数值
high = 65536 - high + 12; //计算高电平的定时器重载值并补偿中断延时
low = 65536 - low + 12; //计算低电平的定时器重载值并补偿中断延时
HighRH = (unsigned char)(high>>8); //高电平重载值拆分为高低字节
HighRL = (unsigned char)high;
LowRH = (unsigned char)(low>>8); //低电平重载值拆分为高低字节
LowRL = (unsigned char)low;
}
/* T0中断服务函数,产生PWM输出 */
void InterruptTimer0() interrupt 1
{
if (PWMOUT == 1) //当前输出为高电平时,装载低电平值并输出低电平
{
TH0 = LowRH;
TL0 = LowRL;
PWMOUT = 0;
}
else //当前输出为低电平时,装载高电平值并输出高电平
{
TH0 = HighRH;
TL0 = HighRL;
PWMOUT = 1;
}
}
/* T1中断服务函数,定时动态调整占空比 */
void InterruptTimer1() interrupt 3
{
static bit dir = 0;
static unsigned char index = 0;
unsigned char code table[13] = { //占空比调整表
5, 18, 30, 41, 51, 60, 68, 75, 81, 86, 90, 93, 95
};
TH1 = T1RH; //重新加载T1重载值
TL1 = T1RL;
AdjustDutyCycle(table[index]); //调整PWM的占空比
if (dir == 0) //逐步增大占空比
{
index++;
if (index >= 12)
{
dir = 1;
}
}
else //逐步减小占空比
{
index--;
if (index == 0)
{
dir = 0;
}
}
}
9.10长短按键的应用
B站在线视频教程:9-8 长短按键
单片机系统中应用按键的时候,如果只需要按下一次按键加1或减1,用第8章学到的知识就可以完成,但如果想连续加很多数字的时候,要一次次按下按键确实有点不方便,这时会希望一直按住按键,数字就自动持续增加或减小,这就是所谓的长短按键应用。 当检测到一个按键产生按下动作后,马上执行一次相应的操作,同时在程序里记录按键按下的持续时间,该时间超过1秒后(主要是为了区别短按和长按这两个动作,因短按的时间通常都达到几百ms),每隔200ms(如果你需要更快那就用更短的时间,反之亦然)就自动再执行一次该按键对应的操作,这就是一个典型的长按键效果。 做一个模拟定时炸弹的功能。打开后,数码管显示数字0,按向上的按键数字加1,按向下的按键数字减1,长按向上按键1秒后,数字会持续增加,长按向下按键1秒后,数字会持续减小。设定好数字后,按下回车按键,时间就会进行倒计时,当倒计时到0实现爆炸。
#include <reg52.h>
sbit BUZZ = P1^6;
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[7] = { //数码管+独立LED显示缓冲区
0xFF, 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}
};
unsigned long xdata KeyDownTime[4][4] = { //每个按键按下的持续时间,单位ms
{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}
};
bit flag1s = 0; //1秒定时标志
bit flagStart = 0; //倒计时启动标志
unsigned char T0RH = 0; //T0重载值的高字节
unsigned char T0RL = 0; //T0重载值的低字节
unsigned int CountDown = 0; //倒计时计数器
void ConfigTimer0(unsigned int ms);
void ShowNumber(unsigned long num);
void KeyDriver();
void main()
{
EA = 1; //使能总中断
ENLED = 0; //选择数码管和独立LED
ADDR3 = 1;
ConfigTimer0(1); //配置T0定时1ms
ShowNumber(0); //上电显示0
while (1)
{
KeyDriver(); //调用按键驱动函数
if (flagStart && flag1s) //倒计时启动且1秒定时到达时,处理倒计时
{
flag1s = 0;
if (CountDown > 0) //倒计时未到0时,计数器递减
{
CountDown--;
ShowNumber(CountDown); //刷新倒计时数显示
if (CountDown == 0) //减到0时,执行声光报警
{
BUZZ = 0; //启动蜂鸣器发声
LedBuff[6] = 0x00; //点亮独立LED
}
}
}
}
}
/* 配置并启动T0,ms-T0定时时间 */
void ConfigTimer0(unsigned int ms)
{
unsigned long tmp; //临时变量
tmp = 11059200 / 12; //定时器计数频率
tmp = (tmp * ms) / 1000; //计算所需的计数值
tmp = 65536 - tmp; //计算定时器重载值
tmp = tmp + 28; //补偿中断响应延时造成的误差
T0RH = (unsigned char)(tmp>>8); //定时器重载值拆分为高低字节
T0RL = (unsigned char)tmp;
TMOD &= 0xF0; //清零T0的控制位
TMOD |= 0x01; //配置T0为模式1
TH0 = T0RH; //加载T0重载值
TL0 = T0RL;
ET0 = 1; //使能T0中断
TR0 = 1; //启动T0
}
/* 将一个无符号长整型的数字显示到数码管上,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) //按键动作函数,根据键码执行相应动作
{
if (keycode == 0x26) //向上键,倒计时设定值递增
{
if (CountDown < 9999) //最大计时9999秒
{
CountDown++;
ShowNumber(CountDown);
}
}
else if (keycode == 0x28) //向下键,倒计时设定值递减
{
if (CountDown > 1) //最小计时1秒
{
CountDown--;
ShowNumber(CountDown);
}
}
else if (keycode == 0x0D) //回车键,启动倒计时
{
flagStart = 1; //启动倒计时
}
else if (keycode == 0x1B) //Esc键,取消倒计时
{
BUZZ = 1; //关闭蜂鸣器
LedBuff[6] = 0xFF; //关闭独立LED
flagStart = 0; //停止倒计时
CountDown = 0; //倒计时数归零
ShowNumber(CountDown);
}
}
/* 按键驱动函数,检测按键动作,调度相应动作函数,需在主循环中调用 */
void KeyDriver()
{
unsigned char i, j;
static unsigned char xdata backup[4][4] = { //按键值备份,保存前一次的值
{1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}
};
static unsigned long xdata TimeThr[4][4] = { //快速输入执行的时间阈值
{1000, 1000, 1000, 1000}, {1000, 1000, 1000, 1000},
{1000, 1000, 1000, 1000}, {1000, 1000, 1000, 1000}
};
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]; //刷新前一次的备份值
}
if (KeyDownTime[i][j] > 0) //检测执行快速输入
{
if (KeyDownTime[i][j] >= TimeThr[i][j])
{ //达到阈值时执行一次动作
KeyAction(KeyCodeMap[i][j]); //调用按键动作函数
TimeThr[i][j] += 200; //时间阈值增加200ms,以准备下次执行
}
}
else //按键弹起时复位阈值时间
{
TimeThr[i][j] = 1000; //恢复1s的初始阈值时间
}
}
}
}
/* 按键扫描函数,需在定时中断中调用 */
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;
KeyDownTime[keyout][i] += 4; //按下的持续时间累加
}
else if ((keybuf[keyout][i] & 0x0F) == 0x0F)
{ //连续4次扫描值为1,即4*4ms内都是弹起状态时,可认为按键已稳定的弹起
KeySta[keyout][i] = 1;
KeyDownTime[keyout][i] = 0; //按下的持续时间清零
}
}
keyout++; //输出索引值递增
keyout &= 0x03; //索引值加到4即归零
//拉高全部输出引脚
KEY_OUT_1 = 1;
KEY_OUT_2 = 1;
KEY_OUT_3 = 1;
KEY_OUT_4 = 1;
}
/* LED动态扫描刷新函数,需在定时中断中调用 */
void LedScan()
{
static unsigned char i = 0; //动态扫描索引
P0 = 0xFF; //关闭所有段选位,显示消隐
P1 = (P1 & 0xF8) | i; //位选索引值赋值到P1口低3位
P0 = LedBuff[i]; //缓冲区中索引位置的数据送到P0口
if (i < 6) //索引递增循环,遍历整个缓冲区
i++;
else
i = 0;
}
/* T0中断服务函数,完成数码管、按键扫描与秒定时 */
void InterruptTimer0() interrupt 1
{
static unsigned int tmr1s = 0; //1秒定时器
TH0 = T0RH; //重新加载重载值
TL0 = T0RL;
LedScan(); //LED扫描显示
KeyScan(); //按键扫描
if (flagStart) //倒计时启动时处理1秒定时
{
tmr1s++;
if (tmr1s >= 1000)
{
tmr1s = 0;
flag1s = 1;
}
}
else //倒计时未启动时1秒定时器始终归零
{
tmr1s = 0;
}
}
9.11练习题
- 能够理解清楚单片机I/O口的结构。
- 能够看懂上下拉电阻的电路应用并且熟练使用上下拉电阻。
- 掌握不同类型变量转换的规则与字节操作进行位修改的技巧。
- 掌握蜂鸣器和继电器基本基本原理和驱动方式
- 理解PWM的实质,尝试控制LED小灯产生更多闪烁效果。
- 利用数码管和LED小灯实现一个交通灯程序。
- 掌握长短按键的用法。