定时器的初步认识
时钟周期
时钟周期$T$是时序中最小的时间单位,具体的计算方法是
$$ T=\frac{1}{时钟源频率} $$
机器周期
即单片机完成一个操作的最短时间。
机器周期主要针对汇编语言而言,在汇编语言下程序的每一条语句执行所用的时间都是机器周期的整数倍。
初步认识
顾名思义,定时器就是用来计时的。
定时器内部有一个寄存器,让它开始计数后,这个寄存器的值每经过一个机器周期就会自动加一。
定时器有很多工作模式,分别使用不同的位宽(二进制位),假如是16位的定时器,也就是两个byte,最大值就是65535,那么加到65535后,再加1就会溢出。
对于单片机来说,溢出后,这个值会变成0。寄存器从某一个初始值开始,经过确定的时间后溢出,这个过程就是定时。
定时器的寄存器
标准的51单片机内部有T0和T1这两个计时器,T就是Timer的缩写,我们这里只讲标准的51单片机。
下面是几个与定时器有关的SFR。
现在稍微说明一下。先看TR1,当程序中写TR1=1
后,定时器1就会启动工作,当程序写TR1=0
时,反之。
当定时器1设置为16位模式时,那么每经过一个机器周期,TL1加1一次,当TL1加到255后,再加1,TL1变成0,TH1会加1一次,如此一直加到两者都是255以后,再加1一次,就会溢出了。这时,两者都会变成0,TF1自动置1。
之前说过,定时器有多种工作模式,工作模式的选择由TMOD(如下图所示)来控制。
可位寻址:意味着可以直接对单个位进行读写操作,而不是只能对整个字节进行操作。
不可位寻址:意味着不能直接对单个位进行操作,只能对整个字节进行读取或写入。
重点看表5-6中的模式1和模式2。
我们这里先看模式1,下面是模式1的电路示意。
OSC代表时钟频率("振荡器"-Oscillator),因为一个机器周期等于12个时钟周期,所以d=12。
定时器的应用
下面来做一个定时器的程序(基于普中51开发板)。
使用定时器0的步骤如下:
- 设置特殊功能寄存器TMOD,配置好工作模式。
- 设置计数寄存器TH0和TL0的初值。
- 设置TCON,通过TR0置1来让计时器开始计数。
- 判断TCON寄存器的TF0位,检测定时器溢出的情况。
初值如何计算?
晶振是$11.0592$MHz,时钟频率就是$T=\frac{1}{11059200}$,机器周期是$12T$,假如要定时$20ms$,就是$0.02s$,要经过x个机器周期得到$0.02s$,下面来算一下$x\times\frac{12}{11059200}=0.02$,得到$x=18432$。16位定时器的溢出值是65536,于是就可以这样操作:先给TH0和TL0一个初始值,让它们经过18432个机器周期后刚好达到65536,也就是溢出,溢出后可以通过检测TF0的值得知,就刚好是$0.02s$。那么初值$y=65536-18432=47104$,转成二进制就是0xB800,也就是TH0=0xB8,TL0=0x00。
程序实现
#include <reg52.h>
sbit LED = P2 ^ 0;
void main()
{
unsigned char cnt = 0; // 记录T0的溢出次数
TMOD = 0x01; // 将T0设置为模式1
TH0 = 0xB8; // 为T0设置初值0xB800
TL0 = 0x00;
TR0 = 1; // 启动T0
while (1)
{
if (TF0 == 1)
{
TF0 = 0;
TH0 = 0xB8;
TL0 = 0x00;
cnt++;
if (cnt >= 50)
{ // 溢出50次即是1秒
cnt = 0;
LED = ~LED;
}
}
}
}
数码管动态显示
数码管真值表
unsigned char code LedChar[] = {
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};
数码管的静态显示
功能:令普中51开发板的最右侧数码管进行16进制定时。
#include <reg52.h>
unsigned char code LedChar[] = {
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};
unsigned char cnt = 0; // 记录T0中断次数
unsigned char sec = 0; // 记录经过的秒数
sbit addr_1 = P2 ^ 2;
sbit addr_2 = P2 ^ 3;
sbit addr_3 = P2 ^ 4;
void main()
{
addr_1 = 0;
addr_2 = 0;
addr_3 = 0;
TMOD = 0x01;
TH0 = 0xB8;
TL0 = 0x00;
TR0 = 1;
while (1)
{
if (TF0 == 1)
{
TF0 = 0;
TH0 = 0xB8;
TL0 = 0x00;
P0 = ~LedChar[sec];
cnt++;
if (cnt >= 50)
{
cnt = 0;
sec++;
if (sec >= 16)
{
sec = 0;
}
}
}
}
return;
}
动态显示的基本原理
多个数码管显示数字的时候,实际上是轮流点亮数码管(一个时刻内只有一个数码管是亮的),利用人眼的视觉暂留效应,就可以做到看起来是所有数码管都同时亮了,这就是动态显示,也叫动态扫描。
那么一个数码管需要点亮多长时间呢?也就是说要多长时间完成一次全部数码管的扫描呢(很明显:整体扫描时间=单个数码管点亮时间*数码管个数)?答案是:10ms 以内。
只要刷新率大于100Hz,即刷新时间小于10ms,就可以做到无闪烁,这也是动态扫描的硬性指标。
下面来在普中51开发板上实现一个8位十进制秒表功能。
实现动态显示(秒表)
#include <REG52.H>
unsigned char code LedChar[] = {
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};
unsigned char ledBuff[8] = { // 数码管显示缓冲区,初值保证启动时都不亮
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
// 74HC138 的地址位
sbit addr_1 = P2 ^ 2;
sbit addr_2 = P2 ^ 3;
sbit addr_3 = P2 ^ 4;
void main()
{
unsigned char i = 0; // 动态扫描的索引
unsigned int cnt = 0; // 记录T0溢出的次数
unsigned long sec = 0; // 经过的秒数
TMOD = 0x01;
TH0 = 0xFC; // 定时1ms
TL0 = 0x67;
TR0 = 1;
while (1)
{
if (TF0)
{
TF0 = 0;
TH0 = 0xFC;
TL0 = 0x67;
cnt++;
if (cnt >= 1000)
{ // 1s
cnt = 0;
sec++;
// 将sec按十进制从低到高依次提取并转为数码管显示字符
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];
ledBuff[6] = LedChar[sec / 1000000 % 10];
ledBuff[7] = LedChar[sec / 10000000 % 10];
}
switch (i) // 完成数码管动态扫描刷新
{
case 0:
addr_1 = 0;
addr_2 = 0;
addr_3 = 0;
i++;
P0 = ~ledBuff[0];
break;
case 1:
addr_1 = 1;
addr_2 = 0;
addr_3 = 0;
i++;
P0 = ~ledBuff[1];
break;
case 2:
addr_1 = 0;
addr_2 = 1;
addr_3 = 0;
i++;
P0 = ~ledBuff[2];
break;
case 3:
addr_1 = 1;
addr_2 = 1;
addr_3 = 0;
i++;
P0 = ~ledBuff[3];
break;
case 4:
addr_1 = 0;
addr_2 = 0;
addr_3 = 1;
i++;
P0 = ~ledBuff[4];
break;
case 5:
addr_1 = 1;
addr_2 = 0;
addr_3 = 1;
i++;
P0 = ~ledBuff[5];
break;
case 6:
addr_1 = 0;
addr_2 = 1;
addr_3 = 1;
i++;
P0 = ~ledBuff[6];
break;
case 7:
addr_1 = 1;
addr_2 = 1;
addr_3 = 1;
i = 0;
P0 = ~ledBuff[7];
break;
default:
break;
}
}
}
return;
}
上面代码出现的问题
这个代码已经完成我们的功能了(秒表),但是还存在一定的问题。
请看下图:
可以发现这份代码的运行效果有两个小问题。
- 数码管不应该亮的段,似乎正在微微发亮。(鬼影)
- 不应该变化的数码管在不断抖动。(数码管抖动)
下面我们来解释一下。
第一个问题(鬼影)是来自switch
语句(扫描)。比如,当我们从第八个数码管case 7:
切换到第一个数码管的时候case 0:
,有短暂的时间里,addr1~3变成了011
和001
,也就是第4和第2个数码管,而且这时P0还没有更新。
第二个问题(数码管抖动)来自那一大坨显示缓存区的更新,每次秒数更新时都要运行那一大坨再运行扫描,导致某个数码管的点亮时间会变长,其他的数码管点亮时间会变短。(这里比较难理解,可以仔细想一下程序)
完善(主要讲中断)
下面来解决这两个问题。
第一个问题,我们在switch前将P0统一关闭,比较简单。
第二个问题的解决我们要引入一个很重要的概念——中断。
中断
介绍这里我懒得写了,就让gpt用通俗的语言介绍一下中断吧:
51单片机的中断可以理解为一个“紧急呼叫”,它让单片机可以暂停当前的工作去处理更重要的事情。就像你在做家务时,如果有人敲门,你可能会暂停手头的活去开门。在单片机里,这个“敲门”的动作就是一个中断信号,提示单片机有更紧急的任务需要立即处理。
51单片机中的中断系统可以分为两大类:外部中断和内部中断。
- 外部中断:来自单片机外部的中断信号,比如外部设备的信号或者外部事件,如按钮按下。在51单片机里,通常有两个外部中断源,分别是INT0和INT1。
- 内部中断:由单片机内部的某些事件触发的中断,比如定时器溢出(计时器计到了设定的时间)、串行通信完成等。这些中断来源于单片机内部的特定功能模块。
当中断发生时,单片机会完成当前指令的执行,然后跳转到一个特定的地址执行中断服务程序(也就是处理中断的代码),这个特定的地址就是中断向量地址。每种中断源都有它自己的中断向量地址。在51单片机中,中断向量表通常是固定的。
中断处理过程大致如下:
- 中断请求:当中断条件满足时,比如外部信号到来或定时器溢出,中断请求被发出。
- 中断响应:单片机完成当前正在执行的指令后,检查中断系统,看看是否有中断请求。
- 执行中断服务程序:如果中断被允许(也就是说中断使能位被置位),单片机会跳转到对应的中断向量地址去执行预设的中断服务程序。
- 返回正常执行流程:中断服务程序执行完毕后,单片机通过一个特殊的返回指令(如RET或RETI),返回到被中断的地方继续执行之前的任务。
在51单片机中,中断还有优先级之分,即当多个中断同时到来时,根据优先级决定哪个中断先被处理。这样的设计可以让一些更重要的任务得到及时处理。
理解51单片机的中断机制,对于编写能够有效响应外部事件和内部状态变化的程序非常重要。这样的程序可以更加快速和高效地响应外部事件,提高系统的实时性和可靠性。
标准51单片机中控制中断的寄存器有两个:一个是中断使能寄存器;另一个是中断优先级寄存器,这里先介绍中断使能寄存器,如下图所示。
现在展示一下完善后的代码,然后通过代码来作解释:
#include <REG52.H>
// 74HC138 的地址位
sbit addr_1 = P2 ^ 2;
sbit addr_2 = P2 ^ 3;
sbit addr_3 = P2 ^ 4;
unsigned char code LedChar[] = {
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8,
0x80, 0x90, 0x88, 0x83, 0xC6, 0xA1, 0x86, 0x8E};
unsigned char ledBuff[8] = { // 数码管显示缓冲区,初值保证启动时都不亮
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
unsigned char i = 0; // 扫描索引
unsigned int cnt = 0; // 记录中断次数
unsigned char flag_1s = 0; // 1s定时标志
void main()
{
unsigned long sec = 0;
EA = 1; // 使能总中断
TMOD = 0x01;
TH0 = 0xFC; // 1ms
TL0 = 0x67;
ET0 = 1; // 使能T0中断
TR0 = 1; // T0~~~启动!
while (1)
{
if (flag_1s)
{
flag_1s = 0;
sec++;
// 将sec按十进制从低到高依次提取并转为数码管显示字符
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];
ledBuff[6] = LedChar[sec / 1000000 % 10];
ledBuff[7] = LedChar[sec / 10000000 % 10];
}
}
return;
}
//中断服务函数 重点哦~
void interruptTimer0() interrupt 1
{
TH0 = 0xFC; // 1ms
TL0 = 0x67;
cnt++;
if (cnt >= 1000)
{ // 1s
cnt = 0;
flag_1s = 1;
}
P0 = 0x00; // 消去鬼影
switch (i) // 完成数码管动态扫描刷新
{
case 0:
addr_1 = 0;
addr_2 = 0;
addr_3 = 0;
i++;
P0 = ~ledBuff[0];
break;
case 1:
addr_1 = 1;
addr_2 = 0;
addr_3 = 0;
i++;
P0 = ~ledBuff[1];
break;
case 2:
addr_1 = 0;
addr_2 = 1;
addr_3 = 0;
i++;
P0 = ~ledBuff[2];
break;
case 3:
addr_1 = 1;
addr_2 = 1;
addr_3 = 0;
i++;
P0 = ~ledBuff[3];
break;
case 4:
addr_1 = 0;
addr_2 = 0;
addr_3 = 1;
i++;
P0 = ~ledBuff[4];
break;
case 5:
addr_1 = 1;
addr_2 = 0;
addr_3 = 1;
i++;
P0 = ~ledBuff[5];
break;
case 6:
addr_1 = 0;
addr_2 = 1;
addr_3 = 1;
i++;
P0 = ~ledBuff[6];
break;
case 7:
addr_1 = 1;
addr_2 = 1;
addr_3 = 1;
i = 0;
P0 = ~ledBuff[7];
break;
default:
break;
}
}
可以先把代码复制下来看看效果。
这里重点解释一下中断服务函数,它的书写格式是固定的,尤其是这个interrupt
,是中断特有的关键字,一定不能错。那后面的1
是怎么来的呢?请看下表。
现在看第二行的T0中断,可以看到,interrupt后面的数字即中断函数编号。
至此,中断函数的命名规则还算是比较清楚了。
现在解释一下这个中断函数的作用。
中断函数写好后,每当满足中断条件(T0中断的触发条件即中断标志位TF0=1)而触发中断后,系统就会自动来调用中断函数。比如我们上面这个程序,平时一直在主程序 while(1)的循环中执行,假如程序有 100 行,当执行到 50 行时,定时器溢出了,那么单片机就会立刻跑到中断函数中执行中断程序,中断程序执行完毕后再自动返回到刚才的第 50 行处继续执行下面的程序,这样就保证了动态显示间隔是固定的 1ms,不会因为程序执行时间不一致的原因导致数码管显示的抖动了。
中断的优先级
中断的优先级就像是一个人在处理多个需要立刻做出反应的任务时所遵循的处理顺序。举个例子,假设你正在玩手机,这时候有两件事同时发生了:一是你闻到了煤气味,二是手机响了,是你的朋友打来的电话。这两件事情都需要你立即做出反应,但是它们的重要性显然不同。显然,煤气泄漏是更紧急的,需要你先处理,接电话则可以稍后。
在单片机的世界里,也会有许多不同的中断请求同时到来,这些中断请求可能来自不同的外部设备或内部事件。比如,可能同时有信号告诉单片机:“我接收到了一个新的数据”和“用户按下了一个按钮”。这时候,中断优先级就起作用了。
中断优先级决定了单片机在多个中断同时请求服务时,应该先响应哪个中断。在51单片机中,中断优先级是可以编程设置的。你可以根据应用的需求,设定某个中断比其他中断有更高的优先级。这样,当多个中断同时发生时,单片机会根据设定的优先级顺序,先处理优先级高的中断。
例如,如果你设置了定时器溢出中断的优先级高于串行通信完成中断的优先级,那么当这两个中断同时发生时,单片机会先去处理定时器的中断,再处理串行通信的中断。
总之,中断的优先级就是在多个需要立即处理的任务中,事先决定了哪些任务更重要,应该先被处理的一个规则。这样可以保证在多任务环境下,最关键的任务能够得到及时的响应。
这里涉及到中断优先级和中断嵌套的概念,我们这里先简单介绍一下相关寄存器。
中断优先级有两种:一种是抢占优先级,一种是固有优先级。
先介绍抢占优先级,请看下图。
IP 这个寄存器的每一位,表示对应中断的抢占优先级,每一位的复位值都是 0,当我们把某一位设置为 1 的时候,这一位的优先级就比其它位的优先级高了。比如我们设置了 PT0位为 1 后,当单片机在主循环或者任何其它中断程序中执行时,一旦定时器 T0 发生中断,作为更高的优先级,程序马上就会跑到 T0 的中断程序中来执行。反过来,当单片机正在 T0中断程序中执行时,如果有其它中断发生了,还是会继续执行 T0 中断程序,直到把 T0 中的中断程序执行完毕以后,才会去执行其它中断程序。
当进入低优先级中断中执行时,如又发生了高优先级的中断,则立刻进入高优先级中断执行,处理完高优先级中断后,再返回处理低优先级中断,这个过程就叫做中断嵌套,也称为抢占。所以抢占优先级的概念就是,优先级高的中断可以打断优先级低的中断的执行,从而形成嵌套。当然反过来,优先级低的中断是不能打断优先级高的中断的。
那么既然有抢占优先级,自然就也有非抢占优先级了,也称为固有优先级。在中断查询序列表中的最后一列给出的就是固有优先级,请注意,在中断优先级的编号中,一般都是数字越小优先级越高。从表中可以看到一共有 1~6 共 6 级的优先级,这里的优先级与抢占优先级的一个不同点就是,它不具有抢占的特性,也就是说即使在低优先级中断执行过程中又发生了高优先级的中断,那么这个高优先级的中断也只能等到低优先级中断执行完后才能得到响应。既然不能抢占,那么这个优先级有什么用呢?
答案是多个中断同时存在时的仲裁。比如说有多个中断同时发生了,当然实际上发生这种情况的概率很低,但另外一种情况就常见的多了,那就是出于某种原因我们暂时关闭了总中断,即 EA=0,执行完一段代码后又重新使能了总中断,即 EA=1,那么在这段时间里就很可能有多个中断都发生了,但因为总中断是关闭的,所以它们当时都得不到响应,而当总中断再次使能后,它们就会在同时请求响应了,很明显,这时也必需有个先后顺序才行,这就是非抢占优先级的作用了,谁优先级最高先响应谁,然后按编号排队,依次得到响应。