周立功教授新书《面向AMetal框架与接口的编程(上)》,对AMetal框架进行了详细介绍,通过阅读这本书,你可以学到高度复用的软件设计原则和面向接口编程的开发思想,聚焦自己的“核心域”,改变自己的编程思维,实现企业和个人的共同进步。
第四章为面向接口的编程,本文内容包括:4.4 事件驱动和4.5 键盘管理。
4.4 事件驱动
>>> 4.4.1 中断与事件驱动
1. 中断
到目前为止,几乎所有的程序都依赖轮询通信。那些代码只是一遍一遍地巡检外围功能部件,并在需要的时候为外围设备提供服务。可想而知,轮询访问不仅消耗了大量的 MCU资源,而且将导致非常不稳定的反应时间。
为了有效地解决上述可能导致整个系统瘫痪的问题,计算机专家提出了一种“实时”的解决方案,通过“中断”使可预见的反应时间维持在几微秒之内。所谓中断是指当 MCU 正在处理某件事情的时候,外部发生的某一“事件”请求 MCU 迅速去处理,于是 MCU 暂时中止当前的工作,转去处理所发生的事件。当中断服务处理完该事件以后,再回到原来被中止的地方继续原来的工作。
但也有可能突发事务请求中断时,可能出现在正常程序流程的任何地方,在正常程序流程中可以选择响应或不响应这个中断请求,突发事件的处理可能会改变整个程序的状态,从而也改变了后续的正常程序流程。
比如,在一次会议上你正在按照计划做报告,这时手机铃声响了,此时,你有两种选择,一是你觉得正在进行的报告更重要,你可以挂断电话或干脆关机,等会后再去处理这个来电;二是你认为这个电话很重要或很快就可处理完毕(不影响做报告),你可以暂停报告转而接听这个电话,当接听完毕后,你再继续做报告,前提是你必须记住接电话前讲到哪里了,当然如果你足够机敏的话,在这次通话中你所接收到的信息可能会改变你随后的报告内容。
由此可见,通过中断方式允许系统在执行主程序时可以响应并处理其它任务,进而中断驱动系统给人们一种假象,MCU 可以同时执行多个任务。而事实上 MCU 不能同时执行 1条以上的指令,它只是暂停主程序转去执行其它程序,完成后再返回继续执行主程序。
从这个角度来看,中断响应非常类似于函数的调用过程。它们两者之间的差别在于中断的响应是由“事件”发起的,而不像函数调用那样,它是在主程序流程中预先设定的,中断是系统响应一些和主程序异步事件,这些事件何时将主程序中断是预先未知的。有了中断就可以实现主机与外设并行工作,支持多程序并发运行,支持实时处理功能。
2. 事件驱动
在现实生活中,“发生的某件事情”就是事件,事实上很多程序都对“发生的事情”做出反应。比如,移动或点击鼠标、按键、或经过一定的时间都是基于事件的驱动程序。
事件驱动程序只是“原地不动”,什么也不做,等待有事件发生,一旦事件确实发生了,它们就会做出反应,完成所有必要的工作来处理这个事件。其实,Windows 操作系统就是事件驱动程序的一个很好的示例,当启动计算机运行 Windows 时,它只是“原地不动”,不会启动任何程序,你也不会看到鼠标光标在屏幕上移动。不过,如果你开始移动或点击鼠标,就会有情况发生。
为了让事件驱动程序“看到”有事件发生,它必须“寻找”这些事件,程序必须不断地扫描计算机内存中用于事件发生的部分,即只要程序在运行就会不断寻找事件。显然,只要移动或点击了鼠标或按下了按键,就会发生事件,这些事件在哪里呢?比如,在内存中存储事件的部分就是事件队列,事件队列就是发生的所有事件的列表,这些事件按它们发生的顺序排列。
如果需要编写一个游戏,则程序必须知道用户什么时候按下一个按键或移动了鼠标。而这些按键动作、点击或移动鼠标都是事件,而且程序必须知道如何应对这些事件,它必须处理事件,程序中处理某个事件的部分称为 事件处理器。而事实上并不是发生的每一个事件都要处理,比如,在桌面移动鼠标就会产生成百上千个事件,因为事件循环运行得非常快。每一个瞬间即使鼠标只是移动了一点点,也会生成一个新的事件。不过你的程序可能并不关心鼠标的每一个小小的移动,它可能只关心用户什么时候点击某个部分,因此你的程序可以忽略鼠标移动事件,只关注鼠标点击事件。
事件驱动程序中,对于所关心的各种事件会有相应的事件处理器。如果你有一个游戏使用键盘上的方向来控制一艘船的移动,可能要为 keyDown 事件写一个处理器;相反,如果使用鼠标控制这艘船,就可能为 mouseMove 事件写一个事件处理器。
另一种有用的事件是软件定时器事件,定时器会按设定的间隔生成事件,就像闹钟一样,如果设定好闹钟,并将闹钟打开,每天它都会在固定的时刻响起来。比如,(宏观上)同时处理两个事件。其中,一个为键盘输入事件,另一个为时间事件,用于显示运行的时间,每秒显示一次。
显然,可以在 main()函数设置一个循环,依次检查是否有键盘输入和时间是否到 1 秒?其实都可以直接调用固定的函数来实现“键盘输入处理代码”和“时间处理代码”,但这样不够灵活,此时可以用中断机制来实现,即由硬件来实现对事件的检测并调用指定的函数,这样一来使用注册回调函数机制也就成为了必然。而注册回调函数就是事先用一个函数指针变量保存指定的函数,然后在事件发生时,通过这个函数指针变量调用指定的函数。
>>>4.4.2 软件定时器
我们知道,数码管显示主要做两件事,其一,每隔 5ms 调用一次 digitron_disp_scan()动态扫描显示函数,其次,当需要改变显示内容时,则调用缓冲区操作接口,修改缓冲区中的内容。由于 MCU 设计了类似于闹钟那样的特定性的周期性的中断时钟节拍源,因此由时钟节拍源实现的定时器也是一个周期性的定时器,并产生周期性的中断,这个中断可以看做系统心脏的脉动。即当计数值等于定时时间时,则定时器立即触发中断,计数器重新开始计数,如此周而复始循环计数。
显然,可以使用定时器的周期性的中断实现自动扫描显示,即每隔 5ms 触发中断自动调用 digitron_disp_scan(),这样就可以将 MCU 解放出来执行其它的任务,从而得到更好的性能,其相应的接口函数详见表 4.3。程序员先调用软件定时器函数,然后等待操作完成。通常程序员提供一个由函数指针指定的回调函数,当操作完成后,中断系统会调用回调函数。
表 4.3 软件定时器接口函数
1. am_softimer_t 类型
从面向对象的角度来看,类相当于 C 语言的结构体,这里的 am_softimer_t 是用 typedef自定义的一个对用户隐藏的结构体类型。即:
在使用软件定时器时,需要使用该类型定义一个软件定时器实例(对象),实例的本质是定义一个结构体变量。比如:
显然,对象是类型的实例,即 timer 是 am_softimer_t 类型的一个实例。
2. 初始化软件定时器
事先将指定的函数保存在函数指针 p_func 中(注册),当定时时间到时,则通过 p_func调用指定的函数,即注册函数回调机制。
其中的 p_timer 为使用 am_softimer_t 类型定义的软件定时器实例,当定时时间到,则调用 p_func 指向的函数(注册回调函数),am_pfnvoid_t 是 AMetal 声明的函数指针类型,其定义(am_types.h)如下:
由此可见,p_func 指向的函数类型是无返回值,具有一个 void*型参数的函数。p_arg为用户自定义的参数,在定时时间到调用回调函数时,会将此处设置的 p_arg 作为作为参数传递给回调函数;如果不使用此参数,则设置为 NULL。如果返回 AM_OK,说明软件定时器初始化成功;如果返回-AM_EINVAL,说明由于参数错误导致初始化失败。初始化函数的使用范例详见程序清单 4.26。
程序清单 4.26 am_softimer_init ()函数范例程序
其中的 am_softtimer_init()函数(A)与用户自定义的任务函数(C)同属于上层模块的函数,timer_callback()函数(B)为下层模块的函数。由于事先已经将 timer_callback()的地址 time_callback 保存在 p_func 中了,因此,当 am_softtimer_init()调用 timer_callback()时,仅需将用户自定义的任务函数的入口地址作为实参传递给 timer_callback()的形参,即可通过函数指针变量 p_arg 在某个时刻回调用户自定义的任务函数,即在函数 A 调用函数 B 中直接调用回调函数 C。即只要在每次调用 timer_callback()时,给出不同的函数名作为实参,即可回调相应的函数,却不必修改 timer_callback()。
3. 启动软件定时器
启动定时器并设置定时时间(单位 ms),然后定时器开始计数。当计数值等于定时时间时,则定时器立即触发中断,计数器重新开始计数,如此周而复始循环计数。当定时器触发中断时,则程序跳转到调用 am_softimer_init()时 p_func 指向的函数,其函数原型为:
p_timer 为使用 am_softimer_t 类型定义的软件定时器实例,ms 为定时时间,单位 ms。如果返回 AM_OK,说明启动定时器成功;如果返回-AM_EINVAL,说明失败参数错误。设置定时器以实现数码管自动扫描显示的代码详见程序清单 4.27。
程序清单 4.27 自动扫描显示实现
程序中,digitron_softimer_set()函数初始化并启动了一个软件定时器,并在定时器回调函数中调用了数码管扫描函数,进而实现了数码管自动扫描。
为了更方便的使用自动扫描,可以将 digitron_softimer_set()合并到 digitron_init()中,形成一个新的 digitron_init_with_softimer(),当用户需要数码管初始化后自动扫描时,只需调用该带软件定时器的初始化函数即可,详见程序清单 4.28。
程序清单 4.28 digitron1.h 文件内容
如程序清单 4.29 所示为再次迭代的 0~59 秒循环显示程序。
程序清单 4.29 0~59 秒计数器范例程序(3)
既然程序是每隔 1s 计数器加 1 后更新缓冲区数据的,那么同样可以使用软件定时器实现每秒加 1 的操作,迭代后的代码详见程序清单 4.30。
程序清单 4.30 0~59 秒计数器范例程序(4)
当启动软件定时器后,秒计数器加1和更新缓冲区数据的工作自动在timer_sec_callback()函数中完成,不再需要主程序干预。现在 while(1)主循环什么事情都不用做,同样实现了 0~59的循环显示。这样一来,数码管就会独立地工作了,那么在 while(1)主循环中,就可以直接去做其它事情。以后遇到“每隔一定时间做某件事”的问题,均可使用软件定时器来实现。
虽然用软件定时器实现自动扫描显示的方法非常巧妙,流程也更加清晰,且程序还可以去做其它的事情,但却是以牺牲程序空间为代价的,即软件定时器要占用一个硬件定时器,以及 438 个字节的 Flash 和 12 个字节的 RAM。同时在使用软件定时器时,由于新建一个软件定时器必须定义一个定时器实例,每个定时器实例还要占用 24 字节,因此要根据硬件资源做出取舍。
4. 关闭软件定时器
当软件定时器关闭时,如果再次启动,则调用 am_softimer_start()重新启动。即:
其中的 p_timer 为使用 am_softimer_t 类型定义的软件定时器实例,如果返回 AM_OK,说明停止定时器;如果返回-AM_EINVAL,即参数错误导致关闭失败,详见程序清单 4.31。
程序清单 4.31 am_softimer_stop ()范例程序
现在不妨在程序清单 4.30 的基础上,再增加一个小功能,即每秒加一、蜂鸣器“嘀”一声,详见程序清单 4.32。
程序清单 4.32 0~59 秒计数器+蜂鸣器综合范例程序(1)
通过运行发现,虽然计数器在每秒加 1 时,蜂鸣器也会发出“嘀”的一声,但数码管的某位却会熄灭一下。如果觉得看起来还不够明显,不妨将蜂鸣器的鸣叫时间增加到 500ms。奇怪!为何连显示都不正常了呢?
虽然此前在 main()函数的 while(1)主循环中也使用了延时,但在主程序的延时期间,软件定时器定时时间到而产生的中断事件是可以抢占 MCU 的,所以不会影响其它事件的继续运行。如果在中断环境中调用 buzzer_beep(),程序必须等到蜂鸣器鸣叫结束后才会返回,这样一来就会使回调函数产生 100ms 的延时,从而导致 MCU 被完全占用,不仅 while(1)主循环无法执行,而且连其它的中断事件也无法执行。比如,另一个软件定时器中的数码管动态扫描也就无法执行了,所以在这 100ms 时间内,无法实现数码管动态扫描,于是只有一个数码管显示,另外一个数码管无法显示而处于熄灭的状态。
在这种情况下,应尽可能地将相应功能设计为异步模式,即启动软件定时器,设定蜂鸣器鸣叫时间,打开蜂鸣器,函数立即返回。待定时时间到,则自动调用回调函数,然后在回调函数中关闭蜂鸣器并停止定时器。这就是使用软件定时器实现 buzzer_beep_async()的由来,异步模式的优点是无需等待,函数立即返回,即可在任意地方调用该函数了,再也不会因为
延时而带来副作用,详见程序清单 4.33。
程序清单 4.33 实现蜂鸣器异步鸣叫函数
基于此,将 buzzer_beep_async()添加到 buzzer.h 以利于复用,详见程序清单 4.34。
程序清单 4.34 0~59 秒计数器+蜂鸣器综合范例程序(2)
4.5 键盘管理
>>> 4.5.1 独立按键
1. 消抖方法
对于质量不太好或者长期使用簧片氧化磨损的按键来说,常常会产生一种被称为“抖动”的现象。如图 4.12(a)所示为单触点按键的无消抖电路,当按键未按下时,则输出 Y 为高电平;当按下时,则输出 Y 为低电平。但由于按键的机械特性和人手指的不稳定性等综合因素,致使按键盘刚按下的瞬间,因接触不良而产生的反复跳动现象,即“抖动”,同样在按键释放的瞬间也可能产生“抖动”,结果输出 Y 在这一瞬间产生了多个窄脉冲干扰,这些脉冲信号的宽度一般可达毫秒,详见图 4.12 (b)。
图 4.12 无消抖按键电路及波形
“抖动”的脉冲宽度一般有几十到几百微秒,但也可能达到毫秒级,这对运行速度很快的数字电路会产生很大的影响。如果将发生“抖动”现象的按键连接到计数电路的时钟输入端,则检测到每按一次键都会产生一串极不稳定的脉冲。
对实际的产品来说,按键在长时间的使用中永不产生“抖动”是不可能的,但只要预防可能产生的“抖动”即可。抖动其实只持续了一小段时间,软件延时就是在按键产生“抖动”的这段时间里,用“拖延时间”的方法避开,从而消除因“抖动”而产生的错误信号,其示意图详见图 4.13。在按下键的瞬间启动定时器开始延时,延时 td 时间后再判断按键是否仍然按下,若仍按下则本次按键有效,否则本次按键无效。延时消抖由于过程比较复杂,比较适合用软件实现,因此称为软件消抖。
图 4.13 延时消抖
2. 电路原理
一般来说,在用法上按键可分为独立按键和矩阵键盘两大类。LPC824 的 P0_10、P0_11是标准的开漏结构,无内部上拉电阻,因此连接按键时必须加上拉电阻。其它的 14 个 GPIO口均有可编程使能的内部上拉电阻,虽然 MCU 内部有几十 KΩ以上的上拉电阻,但均属于弱上拉,所以在实际的应用中,一般都会外接一个阻值适中的上拉电阻,以提高可靠性。
对于独立按键来说,要求比较简单,既不考虑多个键同时按下,也不考虑长按的情况。仅识别是否有键按下的情况,即有键按下一次执行一次操作。如图 4.14 所示是一个独立按键电路图,只要将 AM824-Core的 J14_1 与 J14_2 短接,则 KEY 键接入 PIO0_1。
图 4.14 独立按键电路图
由于一次按键的时间通常都是上百毫秒,相对于 MCU 来说是很长的,因此不需要时时刻刻不断地检测按键,只需要每隔一定的时间(如 10ms)检测 GPIO 的电平即可。其检测方法如下(1 表示高电平、0 表示低电平):
(1)当无键按下时,由于 PIO0_1 内部自带弱上拉电阻,因此 PIO0_1 为 1;
(2)当 KEY 按下时,则 PIO0_1 为 0。在下一次扫描(延时 10ms 去抖动)后,如果PIO0_1 为 1,说明错误触发;如果 PIO0_1 还是 0,说明确实有键按下,执行相应的操作;
(3)当 KEY 释放时,则 PIO0_1 为 1,在下一次扫描(延时 10ms 去抖动)后,如果PIO0_1 为 0,说明错误触发;如果 PIO0_1 还是 1,说明按键已经释放,执行相应的操作。
3. Key 软件包
AMetal 提供了独立按键初始化和按键扫描函数接口(key1.h),详见程序清单 4.35。
程序清单 4.35 key1.h 接口
如程序清单 4.36 所示为独立按键的范例程序,如果有键按下,则蜂鸣器“嘀”一声;当按键释放后,则 LED0 翻转。
程序清单 4.36 独立按键范例程序
显然,每隔 10ms 调用一次 key1_scan(),即可根据 key_return 的值判断按键事件的产生,但这又是“每隔一段时间做某事”。如果使用软件定时器定时自动扫描,则无需在 while(1)中每隔 10ms 调用一次 key1_scan(),详见程序清单 4.37。
程序清单 4.37 添加软件定时器后的按键范例程序
程序中新增了一个初始化软件定时器 key1_softimer_set(),并启动软件定时器以 10ms的时间间隔,通过 key1_softimer_callback()回调 key1_scan()实现按键扫描。当按键事件发生(返回值不为 0xFF)时,则调用 key1_process()按键处理程序,根据扫描得到的返回值判断按键事件的发生。在 key1_process()按键处理程序中,当有键按下时,蜂鸣器“嘀”一声;当按键释放时,LED0 翻转。由于 key1_process()是在中断环境的回调函数中调用的,因此不能出现阻塞式语句,必须调用异步模式下的 buzzer_beep_async()。
在这里,与软件定时器相关的代码直接放在主程序中,而在实际使用时,更希望将实现和声明分别放在 key1.c 和 key1.h 中,因此需要增加一个接口函数:
虽然按键与数码管都可以使用软件定时器实现自动扫描,但它们之间却存在一定的差异,数码管只要自动扫描即可,但对于按键自动扫描,当扫描到按键事件发生时,还必须通知应用程序做相应的处理。而实际上在封装模块时,并不知道应用程序要做什么事,唯一的办法是采用注册回调机制。当按键事件发生时,调用相应的注册函数。如果需要使用软件定时器,则在初始化时注册一个函数,以便按键事件发生时调用。定义回调函数类型为:
重新定义带软件定时器的初始化函数类型为:
为了便于使用,将上述函数声明和回调函数类型定义添加到程序清单 4.38 所示的 key1.h中,其相关实现代码添加到程序清单 4.39 所示的 key1.c 中。
程序清单 4.38 key1.h 文件内容
程序清单 4.39 新增使用软件定时器自动扫描的程序(key1.c)
当有键按下时,则蜂鸣器“嘀”一声;当按键释放时,则 LED0 翻转,经过迭代后的代码详见程序清单 4.40。
程序清单 4.40 使用软件定时器自动进行按键扫描范例程序
>>> 4.5.2 矩阵键盘
独立按键必须占用一个 I/O 口,当按键数目较多时,这种每个按键占用一个口的方法就显得很浪费了。如何用尽可能少的 I/O 口去管理较多的按键呢?矩阵形式键盘电路就是使用最多的一种,如图 4.15 所示就是一种典型的矩阵式 2×2 键盘电路。采用矩阵键盘方式进行排列,其中 KR0、KR1 为行线,KL0、KL1 为列线。
图 4.15 2×2 矩阵键盘
该接法将口线分成行线(row)和列线(column),如果将它变成比较容易理解的拓扑结构,就是两组垂直交叉的平行线,每个交叉点就是一个按键位置,按键的两端分别接在行线和列线上。其最大优点是组合灵活,假如有16 个 I/O 可用于扩展做键盘电路,我们可以将它接成 6×10、5×11 或 8×8 等多种接法,当然,使用效率最高的是 8×8 的接法,它最多可实现 64 个按键。
MiniPort-Key 按键模块集成了 4 个按键,通过 MiniPort B(排母)与 AM824-Core 相连,同时引出其余不用的 I/O,实现模块的横向堆叠,其对应 AM824-Core 的 MiniPort 接口的 J4的功能定义详见图 4.16。
图 4.16 按键模块实物与接口定义图
2×2 的矩阵键盘共有 4 个按键,分别为 KEY0~KEY3。KR0、KR1 为行线(row),KL0、KL1 为列线(column)。假设选择 KL0、KL1 为输入,当无键按下时,由于内部弱上拉作用,此时读取电平为高电平。当 KEY0 按下时,KL1 依然为高电平,而 KL0 在 KR0 输出低电平时就会得到低电平。显然,只有 KR0、KR1 输出为低电平时,KL0、KL1 才能得到低电平,这就是逐行扫描键盘的方法,即行线为输出,列线为输入,每次扫描一行,扫描该行时,对应行线输出为低电平,其余行线输出为高电平,然后读取所有列线的电平,若有列线读到低电平,则表明该行与读到低电平的列对应的交叉点有按键按下。逐列扫描法恰好相反,其列线为输出,行线为输入,但基本原理还是一样的。AMetal 针对矩阵键盘提供了相应的 matrixkey.h 接口,详见程序清单 4.41。
程序清单 4.41 matrixkey.h 接口
如程序清单4.42所示是使用上述接口的范例程序,即当有键按下时,蜂鸣器在发出“嘀”的一声的同时,通过 LED0 和 LED1 的组合显示按键编号。比如,KEY0 键按下时,两个 LED灯均熄灭。KEY1 按下时显示 01,即 LED0 亮,LED1 熄灭,依此类推。
程序清单 4.42 矩阵键盘范例程序
为了节省引脚,还可以将数码管与矩阵键盘结合起来使用,如图4.17 所示的数码管的 2个 com 端与矩阵键盘的列线是复用的,PIO0_17与 PIO0_23 既是数码管的 com0、com1,又是矩阵键盘的列线 KL0、KL1这样设计反而节省了引脚。作为键盘扫描时需将列线配置为输入,作为数码管扫描时需将 com 端设置为输出。
图 4.17 LED 显示器电路图
为了不影响数码管的显示,在键盘扫描结束后,必须将管脚恢复为输出状态。这是由
函数实现的。键盘扫描只需要每隔 10ms 进行一次,而数码管扫描需要每隔 5ms 进行一次,当它们同时使用时,可以在按键扫描的 10ms 内进行 2 次数码管扫描。
利用 4 个按键和数码管,实现一个按键调节值的小应用,各个按键的功能定义如下:
-
KEY0:进入设置状态。点击后进入设置状态,默认个位不断闪烁,再次点击后回到正常运行状态;
-
KEY2:切换当前调节的位。当进入设置状态后,当前调节的位会不断地闪烁。点击该键可以切换当前调节的位,由个位切换到十位,或由十位切换到个位;
-
KEY1:也称为+1 键,将当前正在闪烁的位的值加 1;
-
KEY3:也称为-1 键,将当前正在闪烁的位的值减 1。
其相应的范例程序详见程序清单 4.43。
程序清单 4.43 矩阵键盘+数码管范例程序(2)
-
定时器
+关注
关注
23文章
3237浏览量
114467 -
周立功
+关注
关注
38文章
130浏览量
37583
原文标题:周立功:面向接口的编程——事件驱动和键盘管理
文章出处:【微信号:ZLG_zhiyuan,微信公众号:ZLG致远电子】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
相关推荐
评论