在前两章的内容里都没有考虑中断,本章将引入定时器。引入定时器的原因一些功能需要计时,最常见的就是电子钟了。

电子钟

  这是博主2012年的作品,本章将以这个电子钟为背景介绍用定时器分配任务的程序结构,源代码也会在附件中给出。

1. 用界面函数构成的基础框架

  这个电子钟的大体硬件租成有:按键、1602显示屏、18b20温度传感器、电源管理模块(两个AD转换和一路PWM输出),由于没用时钟芯片,采用的是8位自动填装定时器每隔200us一次中断来计时的(很准哦)。
  任务处理方面,除计时和日期计算以外,要处理的任务还有:1602显示、按键扫描、温度采集和电源管理(控制电池充放电)。
  需要显示界面有时间显示界面、时间设置界面、电源管理界面和logo界面。每个界面完成不同的功能,时间显示界面就是上图所示的样子,时间设置界面用来完成时间的设置,电源管理界面用来查看当前电池状态以及设置一些充放电参数,logo界面显示3秒后自动跳转到时间显示界面。
  这些界面是如何完成的呢?没错!它就是在主进程里用界面函数完成的。每个界面函数先初始化本界面显示,然后在实现本界面具体功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Page1()
{
	/*显示初始化*/
	/*其他初始化*/

	while(1)
	{
		/*本界面任务*/

		if(FlagPage!=1)
			return;
	}
}

  具体而言,例举logo界面的代码如下(因为logo界面代码最短):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void logopage()	//logo界面,定时器还在工作,只是停留在logo界面s中后返回
{
	/*下面是显示初始化*/
	unsigned char i;
	code char logo0[]="hello world!    ";
	code char logo1[]="    hello nicek!";
	wcom(0x80);
	for(i=0;logo0[i]!='\0';i++)
		wdat(logo0[i]);
	wcom(0x80+0x40);
	for(i=0;logo1[i]!='\0';i++)
		wdat(logo1[i]);

	timenum=15000;	//准备延时3s,这个变量会在定时器里每200us减1
	while(timenum)	//等待延时结束,等待过程中仍然对一些按键进行相应
	{
		switch(keynum)
		{
		case 5:	//按下返回键
			flagpage=0;	//返回后跳转到界面0
			return;	//返回
		case 6:	//按下灯光键
			led=0;	//点亮背光灯
			timenumlight=5;	//点亮5s,时间到了之后会在定时器中自动关闭灯光
			keynum=7;	//按键响应结束,标志没有按键按下
			break;
		}
	}
	flagpage=0;	//返回后跳转到界面0
}

  有了这些界面函数,也就构成了整个系统的基本框架,但是到目前为止还是没有用到定时器。下面就以“时间显示”界面为例,分析在这个界面中如何结合定时器完成相应功能。

2. 结合定时器编程分析

  先来讨论一下在时间显示界面里需要做的这么几个任务:
  1602需要500ms刷新一次。小时和分钟之间有一个冒号“:”需要500ms闪动一次,因为不显示秒,所以当分钟发生变化时整屏也要刷新一次;
  按键扫描5ms扫描一次。如果放在时间界面的主循环里进行按键扫描的话就不用考虑这么多,但是那样做有很多缺点,我这里是把按键扫描当做一个固定的进程,放在定时器中断处理函数里,其他所有界面都能用利用这个扫描结果;
  电源管理500ms检测一次;
  温度采集500ms一次,但比较复杂,涉及到所谓“任务分割”问题,由于比较复杂,会后面单独讨论。

  很自然地想到利用定时器计时来进行,那么定时器会以什么样的工作方式来调配整个系统呢?

  首先,定时器200us一次中断,需要有一个变量累加,当累加到5ms时,进行一次按键扫描;
  然后继续累加,每累加到一个5ms都要进行一次按键扫描……当累加到500ms时进行一次按键扫描、1602刷新显示、电源管理,(温度采集暂且忽略)。

  那么,我们可以直接把这些代码写入中断处理程序吗?比如按键扫描我可以把这段代码写入中断处理程序吗:

1
2
3
4
5
6
7
8
9
10
11
12
for(i=0;i<=6;i++)	//总共7个独立按键
{
	if(P1&pow2[i]==0)	//pow2[i]就是2的i次方
	{
		delay5(1);	//延时5ms,以确认是否真的按下
		if(P1&pow2[i]==0)
		{
			keynum=i;
			break;
		}
	}
}

  这是显然是不行的,执行完这段代码中的延时函数就有5ms了,而定时器是200us一次中断,一个中断周期里根本执行不完。
  如果把这段代码放到时间界面主循环里是可以的,但是这样的话在其他界面就不能使用(除非也加入相同的代码),正如我上面所说:那样有很多缺点,我这里是把按键扫描当做一个固定的进程,其他所有任务都能用利用这个扫描结果。

  所以这里要提出一个定时器分配任务的程序结构原则一:定时器中断里的代码执行长度一定不能超过定时器中断时间。

  所以我们要想想办法,把按键扫描程序改成了如下,以下代码由中断处理函数调用,每5ms执行一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
static unsigned char reslast;	//保存上次扫描结果,0-没按下,1-按下
unsigned char res;
unsigned char i;
numforkey=0;
res=P1;
for(i=0;i<=6;i++)
{
	if(((res&pow2[i])==0)&&((reslast&pow2[i])!=0))	//这次按下,上次断开
		break;
}
//从这里出来,如果i==7则表示没有按键按下,i<=6的任意一个值表示那个键被按下了
keynum=i;
reslast=res;

  这段代码里面没有延时,执行一次是很快的,而且也可以很好地完成按键扫描,比上面的那种延时扫描更有优势(不占用资源,而且稍加改造可以识别同时按下多个按键)。

  所以,把原则一加上一句,定时器分配任务的程序结构原则一:定时器中断里的代码执行长度一定不能超过定时器中断时间,要想办法把任务改成不占用定时器时间的结构,给主进程让出更多的时间。关于把“任务改成不占用定时器时间”这个问题会在后面第4章“占用式与非占用式程序结构分析”里详细讨论。


  按键扫描在中断处理程序里已经安置妥当了,但请注意当500ms来临时,需要完成的任务有 按键扫描、1602刷新显示、电源管理,(温度采集暂且忽略),定时器中断周期只有200us,现在一下子来了这么多任务,一个中断周期内可以处理完吗?

  为了避免这种风险,我们把定时器中断里的一些任务转移到主进程中(还有另一种方法将在后面“定时器执行任务”的章节中介绍),这就是定时器分配任务的程序结构原则二:当节拍时间到来时,要处理的任务真的很多,可以通过标志变量通知主进程执行。 但通知让主进程做的事对实时性要求不能太高。

  实际上我在电子钟里面把1602刷新就没有放在定时器中断里,而是放到了主进程。在程序里设置了一个flag500ms标志变量,定时器中断处理函数标记变量为1表示到了500ms,时间界面的主循环检测这个变量,当发现这个变量为1时就执行1602刷新。而按键扫描和电源管理由于在任何界面都会用到,并且代码执行速度快,所以把它们放在定时器中断里进行,1602刷新对实时性要求不高,所以可以用定时器通知主进程执行。


  下面要说的就是温度采集了,上面为什么没讨论它,就是因为它比较复杂。它既不能满足原则一(在一个定时器中断时间内完成)也不能满足原则二(对实时性要求不高)。

  温度传感器用的是18b20,由单总线协议决定了对它进行一次读写大约需要18ms,而且对实时性要求也很高。

  这里隆重推出一个自己起名的概念——“任务分割”。顺便引出定时器分配任务的程序结构原则三:当既不满足原则一又不满足原则二,即既不能在一个定时器中断时间里完成又对实时性要求很高的任务,对它进行任务分割。

  下一节将详细介绍任务分割的概念,以及如何对任务进行“分割”。

3. 任务分割

  所谓任务分割就是把不能在一个定时器中断时间里完成的任务分割成多个可以在一个定时器中断时间里完成的任务。在这里,把分割完成之后的任务函数仍然放在主进程里。
  要完成任务分割,首先需要让定时器的计时功能可以被外部使用,设一个全局变量TimeNum,然后在定时器中断处理程序里让TimeNum自减:

1
2
3
4
5
6
7
8
unsigned char TimeNum=0;
void t0_int() interrupt 1
{
	/*Do Something*/

	if(TimeNum!=0)
		TimeNum--;
}

  这样,外部就可以通过TimeNum变量使用定时器的计时资源了。在主进程里只要这么做就行:

1
2
3
4
5
6
7
/*Do Something*/
TimeNum=100;	//准备等待个定时器中断周期
while(TimeNum)	//等待
{
	/*Do Something*/
}
/*继续 Do Something*/

  可以利用定时器延时之后就可以对主进程里的长任务进行分割了,分割方法就是把原来顺序执行的任务函数,找到合适的节点,在节点处进行合适时间的延时。
  这个步骤做起来是比较麻烦的,因为要进行任务分割的任务一般对时间精确性要求是比较高的(否则直接放到主进程就可以了),需要充分了解运行过程。既要保证每个节点之间的运行时间小于一个定时器中断周期,又要保证任务时序的正确性。

  需要强调一下,任务分割这个方法是个下策,实在没办法的情况下才进行这种处理,我也只在这个电子钟工程的温度测量用过。
  这种情况在操作系统中也是不好处理的,当某个不能被中断的函数的运行时间确实长于时钟节拍周期的话,也只有先关闭中断,等该函数运行结束后再打开中断。操作系统中把这种函数叫做“临界段”代码。而在电子钟这个例子中,由于要进行精确的计时,是不可以关闭中断的,只有进行任务分割(当然也有其他的处理方式,加个协处理器什么的)。

  下面截取18b20的一段代码,举例说明一下修改的原理。
  修改前代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bit ds18b20_init()
{
	bit dat=0;
	dq=1;
	delayusx2(5);
	dq=0;
	delayusx2(200);
	delayusx2(200);
	dq=1;
	delayusx2(50);
	dat=dq;
	delayusx2(25);
	return dat;
}

  修改后代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bit ds18b20_init()
{
	bit dat=0;
	dq=1;
	delayusx2(5);    //较短的延时不需要修改,可以在一个中断周期内完成
	dq=0;
	timenum=4;while(timenum);  //这里原来的800ms延时换成了结合定时器的等待
	dq=1;
	delayusx2(50);    //延时时间短,不需要分割
	dat=dq;
	delayusx2(25);    //上次分割到这里大约100us,本次延时时间50us,仍然不需要分割
	//到这里实际上init部分的协议已经完成,下面仍然加了一个分割是为了和定时器同步,在这里等待一次定时器中断,保证等待之后有充足的时间执行后续代码
	timenum=1;while(timenum);
	return dat;
}

4. 定时器分配任务程序结构总结

  1、整个系统有一个主进程:main函数的主循环及其调用的所用任务函数,以及所有任务函数调用的子任务函数。
  这个主进程的特点是所有函数都在一个函数调用链里,运行时精力只能放在一处;优先级低,任何中断所调用的任务都会使其停止工作。

  2、定时器也可开辟一道进程,所有由定时器直接调用的任务都属于这个进程。
  定时器进程可以通过一些标志变量通知主进程进行某种动作,最常用的控制方法是用定时器产生节拍信号,通知主进程进行相应动作;
  同时,定时器也可以直接调用一些函数,在定时器中断处理程序里完成任务。所有由定时器直接调用的程序都属于定时器进程,优先级高于主进程;
  用定时器分配任务有一下三点原则:
  定时器分配任务的程序结构原则一:定时器中断里的代码执行长度一定不能超过定时器中断时间,要想办法把任务改成不占用定时器时间的结构,给主进程让出更多的时间。
  定时器分配任务的程序结构原则二:当节拍时间到来时,要处理的任务真的很多,可以通过标志变量通知主进程执行。 但通知让主进程做的事对实时性要求不能太高。
  定时器分配任务的程序结构原则三:当既不满足原则一又不满足原则二,即既不能在一个定时器中断时间里完成又对实时性要求很高的任务,对它进行任务分割。

  3、整个系统来看有两个并行的进程——主进程和定时器进程。主进程一次只能执行一个任务,而定时器进程由于任务一般比较小(如按键扫描、计时、数码管扫描等),所以认为定时器进程的任务也一并完成了。
  看上去就像是多个进程在同时运行,这些进程之间可以通过公共变量进行通信,比如节拍时间的标识变量、计时产生的时间、按键扫描结果变量keynum等,所有其他进程可以有选择地对这些标识变量进行响应。类似于进程间通信。


  用定时器命令主函数执行任务的原因有两点:1、利用定时器的时钟节拍使主函数也可以节拍性地执行任务。2、利用主函数构建的逻辑结构。
  对于第1点,该任务的实时性确实会受影响,因为毕竟主函数是用查询方式查询标识变量的。
  但是第2点带来的好处也是非常大的。还拿电子钟举例来说吧,电子钟里面的各个界面之间的逻辑是通过主函数构建出来的,定时器在任何界面都会中断,并且在定时器中执行的任务会通过标识变量向主函数发送信息(比如当前时间、按键扫描结果(我的按键扫描是放在定时器里执行的)等),虽然在各个界面向主函数发送的信息是一样的,但是主函数中的各个界面对这些信息的反应却是不同的(比如各个界面对同一按键的反应是不同的,对定时器提供的时间所做的的反应也是不同的)。   这些都依赖于主函数所构建出的逻辑结构。


  关于这种结构的介绍暂且到此吧,下面将引出一个新的问题。
  注意在3.2节的分析中,有些任务是直接放在定时器里执行的,这些任务都有一些共同的特点——执行时间短。
  执行时间短带来了什么?在1.2节有这么一句话:“任务执行的并行与否是相对而言的。”,在本节的总结中还有这句话:“定时器进程由于任务一般比较小(如按键扫描、计时、数码管扫描等),所以认为定时器进程的任务也一并完成了。”
  定时器的中断处理函数的执行肯定不会被主进程阻挡,这里面的任务全都可以看成是并行的多个进程,它们各自完成不同的功能,把自己的运行结果作为资源供其他进程使用。
  在第5章中将详细分析这种结构,在那一章,我们将会明确我们到底在追求什么,我们要追求的结构到底是什么样的?

  不过在此之前,先要补充一点理论知识,这就是下一章的内容。
  下一章将会分析和明确那些“完成某个任务的函数”到底应该做些什么,哪些东西是有用的。并对这些函数进行改造,分析改造成不同形式的函数有什么样的特点。

Comments