购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第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,则相应的初始值应为2 16 -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 所起的作用是:如果当前标识符没有定义过,就定义它并编译该头文件,如果同一个项目中的另一个文件中也包含了相同的头文件,它会首先判断标识符是否定义过,由于刚才已经定义过,所以就会略过相同的头文件。

很明显,模块化后的源代码更多且显得更烦琐了,简单的源代码当然没有这样做的必要,但是项目越复杂,进行模块划分后会更容易管理。本书为了使代码更简洁,不使用这种模块划分方式,这里读者主要了解一下设计思想即可。 PaghcocB3n5z6BFp4YHbcc5/4L0d6pBujtXgEseG7lbUSifU2aRlBIDkSuY57Yrs

点击中间区域
呼出菜单
上一章
目录
下一章
×