第3章 中断编程与代码管理
虽然目前已经顺利实现LED闪烁的功能,但是在实际产品中,一块单片机通常总是会同时实现多个功能,最常见的应用就是:在控制显示的同时还要检测按键(以处理用户的输入交互信息)。由于LED闪烁是由延时函数实现的,它就是一系列(除延时外无实际意义的)累加操作,也就是说,单片机在延时期间无法处理其他事情。如果恰好在1s的延时期间按下按键(这是非常可能的),单片机自然检测不到按下状态,因为它在任一时刻只能处理一件事情(顺序执行)。反过来,当单片机在执行其他非常耗时的任务时,LED闪烁行为也就会被迫改变(例如,闪烁速度变慢了),对不对?
为了凸显延时函数带来的困扰,我们稍微更改一下任务:按键第一次按下可以使LED闪烁,第二次按下将停止闪烁,第三次按一下LED继续闪烁,依此类推。下面看一下相应的源代码,如清单3.1所示。
清单3.1 源代码
在源代码的开始,我们使用关键字sbit分别给控制LED与读取按键状态的引脚都定义了一个别名。在main函数内,我们使用关键字bit定义了一个位变量flicker_flag标记LED是否执行闪烁功能(与关键字sbit不同,bit定义的是变量,而不是别名),为0则停止,1则运行。flicker_flag初始状态为0表示默认不闪烁。
然后检测按键是否被按下,即读取引脚P3.2(KEY)的电平是否等于0。如果答案是肯定的,就把flicker_flag取反。这里特别要注意语句while(!KEY),它用来检测按键是否松开,如果省略这条语句,LED闪烁电路也可以正常启动运行,但要使它停下来几乎不可能。因为判断按键是否被按下的语句也就只有几微秒,一旦LED闪烁开始,执行闪烁功能的时间会比判断按键是否按下的时间要长得多,换句话说,当按键被按下时,单片机有很大的概率还在延时函数里执行,所以按键按下的状态也就没有被检测到。
那么,长时间按下按键总可以吧?然而一旦按键被检测到按下了(KEY为低电平),假设flicker_flag现在被设置为0,理论上LED闪烁确实会停止(后面执行LED闪烁的if语句不会执行),但是这下好了,程序又会重新检测,以迅雷不及掩耳之势将flicker_flag又设置为1了,LED闪烁功能又打开了,如图3.1所示。
图3.1 没有按键松开检测时的执行流程
也就是说,要想使LED闪烁功能停止,只有一种可能:在检测到按键后几微秒内准确停止,也就是第1步开始后在第2步马上松开按键。但是,按键动作持续的时间一般都是毫秒级的,这意味着不太可能做到如此短而准确地按键操作。while(!KEY)就表示当按键被按下后,如果一直按着按键,它就总会执行这条while循环语句(感叹号为“逻辑非”运算,0为假,非0为真),直到松开按键之后(KEY为高电平),单片机才会跳出while循环语句执行后面的if语句,这样可以防止单片机检测到一次按键被按下状态后,就立即重复检测相同的一次按键状态。
实际的按键读取代码通常还会进行消抖(消除抖动)操作,它在检测到按键按下后延时一段时间(通常是十几毫秒左右)再读取按键是否仍然被按下。因为按键按下的那一瞬间,单片机读到的电平状态并不是稳定的,按键消抖可以防止按键被误触发。当然,这已经不是我们关注的主要内容,大家了解一下即可。
尽管如此,清单3.1所示代码还是有点小问题,大多数时候必须长时间按下按键才能使LED闪烁停止,因为前面已经提过,单片机大部分时间还是在运行LED闪烁代码,必须按下按键直到它运行到按键检测部分才能再次修改flicker_flag标记,继而达到停止LED闪烁的目的。而我们却希望在任意时刻只要按一下按键就会立刻停止LED闪烁,该怎么办呢?比较好的解决方案就是使用中断(Interrupt)编程。
什么是中断呢?咱们举个例子,假如我正在教室讲课,门外有人敲门:外面有个陌生人找。我当然会气定神闲地慢悠悠回复道:你让他在会议室等一下,下课后我再过去找他。然后继续上课,外面又有人敲门说:校长有事找!事关薪资福利职称,必须马上得走一趟,刻不容缓!这时我会急匆匆地对学生们说:(你们先自己)预习(一下),(处理完事我再过来)。随即快速夺门绝尘而去。
我这个老师的形象实在是不好,开个玩笑,大家不要学习。在这个例子中,“我正在教室讲课”相当于单片机在顺序执行语句,当陌生人到来时,我对此处理的方式是押后(先把手头的事干完再处理其他事情),在单片机编程中称为顺序编程。当校长有事找时,我马上出去应答(得先去见校长再回来讲课,决计拖延不得呀),在单片机编程中称为中断编程。也就是说,“校长有事找”相当于一个中断信号,它中断了我正在进行的讲课动作。很明显,中断编程一般应用在对实时性要求比较高的场合,如果不马上处理就会后悔莫及。
在按键控制LED闪烁的简单任务中,可以把按键按下事件作为中断信号,这样无论延时代码是否正在执行,单片机都可以实时响应,对不对?还有一种思路就是:影响按键无法实时检测的根本原因在于延时语句的执行时间太长了,只要我们能缩短其执行的时间,一样可以让单片机实时响应按键状态。
由于使用循环语句延时1s的代码实在太缺乏效率了,所以我们决定使用中断编程来优化它,具体的思路是:使用一个定时器设置定时时长为1s,每当1s时间到来时就发送一个中断信号,单片机根据中断信号进行LED状态转换的控制,而在1s内(中断信号未到来之前),我们不需要再做LED闪烁功能相关的延时控制,也就可以把更多的时间用于检测按键,相应的代码执行流程对比如图3.2所示。
图3.2 顺序与中断编程执行流程对比
中断编程的关键在于中断信号的产生,这可以通过定时器来实现。定时器是个什么东西呢?看过警匪片的读者都会知道,有些反派会使用定时炸弹,先设置一个时间,开启计时后数字就会不断地减小,数字减小到了全0就会爆炸。定时炸弹就是定时器的一个典型应用。一般单片机内部都有定时器,根据型号的不同,可以是加法或减法类型。由于定时器是一个硬件电路,它与软件指令是同时运行的(并行),我们只需要控制它何时产生中断信号即可,具体来说包括设置定时时长(初始值)、使能中断(允许计数器产生中断信号)、开启计数,这样当计数完成后就会产生一个中断信号。
我们使用中断方式来实现LED闪烁功能,相应的源代码如清单3.2所示。
清单3.2 中断编程源代码
首先定义了一个全局变量count并初始化为0,因为51单片机定时器的定时时长达不到1s,所以只能借助另一个变量来实现。例如,把定时时长设置为20ms,定时器每中断一次就将count加1,当count为50时,就意味着1s的时间到了。所以在main函数中,我们使用了“判断count是否大于49”的if语句。在if语句中先将count清零,这样就可以开始下一个50次20ms的计数,然后将LED的状态取反即可。
注意中断服务函数(Interrupt Service Routine,ISR)timer0_isr的形式,它使用了关键字interrupt,后面跟了一个中断号1,这是51单片机中断服务函数的固定模式,虽然它看起来像一个函数,但却不能由其他函数调用(main函数也不可以),只能在指定的中断产生时自动调用,这是中断服务函数与一般函数的主要区别。
另外需要特别注意的是,我们没有在中断服务函数中编写过多代码。事实上,也不应该在中断服务函数中编写过多代码,因为中断的最大特点就是实时性。如果在其中编写大量代码,单片机在运行中断服务函数时又产生了另一个中断怎么办呢?除非新产生的中断优先级更高,否则它就无法及时得到运行,也就失去了中断实时性的特点。
请务必牢记:中断服务函数中只做必要操作!我们只是把TH0与TL0设置初始值后,再将count累加就退出了。虽然将LED电平取反的功能放到中断服务函数中也可以实现相同的功能,但这种编程方式是不妥当的(通俗来讲,这不是单片机工程师的专业编程)。
TH0与TL0是什么东西呢?main函数中赋值的TMOD、ET0、EA、ETR又是什么呢?这涉及51单片机的定时器结构,如图3.3所示。
图3.3 51单片机的定时器0结构(工作模式1)
从图可以看到,定时器结构中有很多网络或模块被取了名称(例如C/T、TR0、GATE、TL0、TH0、TF0),其实它们都是图2.4所示TMOD与TCON寄存器中的某些位,并且在reg51.h头文件中进行了标识符定义,我们直接通过它们即可控制定时器的运行状态,如图3.4所示。
图3.4 定时器0相关的控制位
51单片机中的两个定时器被命名为T0(Timer0)与T1,图3.4中我们仅标记了与T0相关的控制位,因为AT89C1051仅有这一个定时器。每个定时器都有4种工作模式,为简化讨论过程,我们使用比较常用的模式1(M1M0=01)。
定时器有两种工作方式,即定时与计数(本质上都是计数),它们的唯一区别是:定时是对单片机内部固定频率(对于图2.3所示电路就是12MHz)时钟进行计数,而计数是对外部引脚T0(P3.4)的脉冲进行计数。例如,我们要实现测量引脚P3.4的脉冲个数,此时应该使用计数而不是定时。这两种工作方式的切换由C/T位来决定,我们当然使用定时工作方式(C/T=0)。
接下来确定是否开始启动定时器计数,它由一个与门的输出电平控制(为1则开始计数,为0则停止计数)。为了启动计数过程,我们首先应该将TR0(Timer0 Run)置1,同时还应该使或门的输出也为1。或门用来选择引脚(P3.2)的电平是否也参与启动计数的控制,我们只需要软件控制计数,将GATE置0即可(注意GATE输入有一个非门),所以将TMOD寄存器初始化为0x1(高4位无效)。
在工作模式1下,TH0与TL0组成了一个16位加法计数器。当开启计数后,计数器就会从设置的初始值开始累加,一旦累加到最大值就会产生一个溢出标志位TF0(Timer0 overflow,可以理解为进位),此时就可以产生中断信号。所以现在关键的问题在于:我们应该将定时器初始值设置多少呢?假设我们需要定时20ms,则相应的初始值应为216-20ms×12MHz/12=45536(0xB1E0)。也就是说,我们需要将TH0与TL0分别初始化为0xB1与0xE0。(计算时已经将12MHz除以12,是因为从图3.3可以看到,振荡时钟经过了12分频)
计数器溢出后是否产生中断还取决于单片机是否允许中断。为了允许定时器0产生中断信号,我们首先应该开启总中断允许标记位EA(Enable All),它是单片机中所有中断允许的总控制位,然后再开启定时器0的专用允许标记位ET0(Enable timer0)即可。
最后请注意:当定时器0产生中断后,计数器的初始值是不会重载的(计数器不会继续累加),所以在进入中断服务函数后,我们对TH0与TL0重新设置了初始值。
定时器需要配置的位比较多,对于51单片机不熟悉的读者可能会觉得有些烦琐,当然,我们讨论定时器中断编程的主要目标是为了理解这种编程思想,对于具体编程不感兴趣的读者可以跳过。在实际产品开发过程中,中断是一种非常重要的处理方式。换句话说,可以不去了解51单片机内部定时器具体是如何控制的,因为不同厂家的单片机操作方式并不一样(当然,原理相通),本书其他地方也并未涉及具体的中断编程,但是不能不理解中断编程思路。
在稍微复杂点的项目中,通常会将代码进行分块管理,例如,我们可以将清单2.4所示源代码分解为六个文件,它们之间的关系如图3.5所示。
图3.5 分块管理的代码包含关系
图3.5中的箭头表示文件包含关系,例如simple_led_driver.c包含了led.h,led.c包含了led.h、consts.h、delay.h。各文件相应的源代码如清单3.3~3.8所示。
清单3.3 源文件simple_led_key_driver.c
清单3.4 头文件led.h
清单3.5 源文件led.c
清单3.6 头文件delay.h
清单3.7 源文件delay.c
清单3.8 头文件consts.h
扩展名为.c的文件称为源文件(Souce File),扩展名为.h的文件称为头文件(Header File)。头文件通常仅包含变量或函数的声明(不是定义)、宏定义、特殊功能寄存器定义,它通常被源文件使用#include预处理器指令包含。
可以看到,我们把源代码划分后放在不同的文件中,每一个文件只包含与某一项功能相关的代码。例如,led.c仅包含与LED控制相关的代码,delay.c仅包含时间延时相关的代码。后续如果有更多的功能,则可以添加到对应的文件当中。如果项目中添加了全新的功能模块,则可以再新建源文件与头文件负责处理。
除main函数所在的源文件simple_led_key_driver.c外,其他源文件都包含了同名的头文件。头文件只做了函数原型的声明,而函数的具体定义则放在同名源文件中。每一个头文件都会首先使用条件编译指令#ifndef定义一个唯一的标识符,它的命名规则一般由前后加下划线的头文件名全大写(这只是惯例,并非必须这么做)组成,这样可以防止头文件被重复包含,在大型工程中可以提升编译效率。
条件编译指令#ifndef所起的作用是:如果当前标识符没有定义过,就定义它并编译该头文件,如果同一个项目中的另一个文件中也包含了相同的头文件,它会首先判断标识符是否定义过,由于刚才已经定义过,所以就会略过相同的头文件。
很明显,模块化后的源代码更多且显得更烦琐了,简单的源代码当然没有这样做的必要,但是项目越复杂,进行模块划分后会更容易管理。本书为了使代码更简洁,不使用这种模块划分方式,这里读者主要了解一下设计思想即可。