本帖最后由 laocuo1142 于 2023-11-23 10:58 编辑
状态机是一种思想,事件驱动也是一种思想。
事件驱动的概念
生活中有很多事件驱动的例子,上自习瞒着老师偷睡觉就是很生动的一个。
我们都是从高中时代走过来的,高中的学生苦啊,觉得睡觉是世界上最奢侈的东西, 有时候站着都能睡着啊!老师看的严,上课睡觉不允许 啊,要挨批啊!有木有!相比而言,晚自习是比较宽松的,老师只是不定时来巡视,还是有机会偷偷睡一会儿的。
现在的问题是,怎么睡才能既睡得好又不会让老师发现呢? 晚自习是比较宽松的,老师只是不定时来巡视,还是有机会偷偷睡一会儿的。现在的问题是,怎么睡才能既睡得好又不会让老师发现呢?
我们现在有三种睡觉方案:
方案 A:倒头就睡,管你三七二十一,睡够了再说,要知道有时候老师可能一整晚上都不来的。
方案 B:间歇着睡,先定上闹钟, 5 分钟响一次,响了就醒,看看老师来没来,没来的话定上闹钟再睡,如此往复。
方案 C:睡之前让同桌给放哨,然后自己睡觉,什么也不用管,什么时候老师来了,就让同桌戳醒你。
不管你们选择的是哪种方案,我高中那会儿用的可是方案 C,安全又舒服。
方案 C 是很有特点的:本来自习课偷睡觉是你自己的事儿, 有没有被老师抓着也是你自己的事儿,这些和同桌是毫无利害关系的,但是同桌这个环节对方案 C 的重要性是不言而喻的,他肩负着监控老师巡视和叫醒睡觉者两项重要任务,是事件驱动机制实现的重要组成部分 。
在事件驱动机制中,对象对于外部事件总是处于“休眠” 状态的,而把对外部事件的检测和监控交给了第三方组件。
一旦第三方检测到外部事件发生, 它就会启动某种机制, 将对象从“休眠” 状态中唤醒, 并将事件告知对象。对象接到通知后, 做出一系列动作, 完成对本次事件响应,然后再次进入“休眠” 状态,如此周而复始。
有没有发现,事件驱动机制和单片机的中断原理上很相似 。
事件驱动与单片机编程
在我们再回到单片机系统中来,看看事件驱动思想在单片机程序设计中的应用。当我还是一个单片机菜鸟的时候(当然,我至今也没有成为单片机高手),网络上的大虾们就谆谆教导:一个好的单片机程序是要分层的。曾经很长一段时间, 我对分层这个概念完全没有感觉。
什么是程序分层?
程序为什么要分层?
应该怎么给程序分层?
随着手里的代码量越来越多,实现的功能也越来越多,软件分层这个概念在我脑子里逐渐地清晰起来,我越来越佩服大虾们的高瞻远瞩。
单片机的软件确实要分层的,最起码要分两层:驱动层和应用层。应用是单片机系统的核心,与应用相关的代码担负着系统关键的逻辑和运算功能,是单片机程序的灵魂。
硬件是程序感知外界并与外界打交道的物质基础,硬件的种类是多种多样的,各类硬件的操作方式也各不相同,这些操作要求严格、精确、琐细、繁杂。
与硬件打交道的代码只钟情于时序和寄存器,我们可以称之为驱动相关代码;与应用相关的代码却只专注于逻辑和运算, 我们可称之为应用相关代码。
这种客观存在的情况是单片机软件分层最直接的依据,所以说,将软件划分为驱动层和应用层是程序功能分工的结果。那么驱动层和应用层之间是如何衔接的呢?
在单片机系统中,信息的流动是双向的,由内向外是应用层代码主动发起的,实现信息向外流动很简单, 应用层代码只需要调用驱动层代码提供的 API 接口函数即可, 而由外向内则是外界主动发起的, 这时候应用层代码对于外界输入需要被动的接收, 这里就涉及到一个接收机制的问题,事件驱动机制足可胜任这个接收机制。
外界输入可以理解为发生了事件,在单片机内部直接的表现就是硬件生成了新的数据,这些数据包含了事件的全部信息, 事件驱动机制的任务就是将这些数据初步处理(也可能不处理),然后告知应用层代码, 应用代码接到通知后把这些数据取走, 做最终的处理, 这样一次事件的响应就完成了。
说到这里,可能很多人突然会发现,这种处理方法自己编程的时候早就用过了,只不过没有使用“事件驱动” 这个文绉绉的名词罢了。其实事件驱动机制本来就不神秘, 生活中数不胜数的例子足以说明它应用的普遍性。下面的这个小例子是事件驱动机制在单片机程序中最常见的实现方法,假设某单片机系统用到了以下资源:
一个串口外设 Uart0,用来接收串口数据;
一个定时器外设 Tmr0,用来提供周期性定时中断;
一个外部中断管脚 Exi0,用来检测某种外部突发事件;
一个 I/O 端口 Port0,连接独立式键盘,管理方式为定时扫描法,挂载到 Tmr0 的 ISR;
这样,系统中可以提取出 4 类事件,分别是 UART、 TMR、 EXI、 KEY ,其中 UART 和KEY 事件发生后必须开辟缓存存储事件相关的数据。所有事件的检测都在各自的 ISR 中完成,然后 ISR 再通过事件驱动机制通知主函数处理。
为了实现 ISR 和主函数通信, 我们定义一个数据类型为INT8U的全局变量 g_u8Evnt**Grp,称为事件标志组,里面的每一个 bit 位代表一类事件,如果该 bit 值为 0,表示此类事件没有发生,如果该 bit 值为 1,则表示发生了此类事件,主函数必须及时地处理该事件。图 5 所示为g_u8Evnt**Grp 各个 bit 位的作用 。
程序清单 List9 所示就是按上面的规划写成的示例性代码 。
程序清单List9:
#define **_UART 0x01
#define **_TMR 0x02
#define **_EXI 0x04
#define **_KEY 0x08
volatile INT8U g_u8Evnt**Grp = 0; /*事件标志组*/
INT8U read_envt_**_grp(void);
/***************************************
*FuncName : main
*Description : 主函数
*Arguments : void
*Return : void
*****************************************/
void main(void)
{
INT8U u8**Tmp = 0;
sys_init();
while(1)
{
u8**Tmp = read_envt_**_grp(); /*读取事件标志组*/
if(u8**Tmp ) /*是否有事件发生? */
{
if(u8**Tmp & **_UART)
{
action_uart(); /*处理串口事件*/
}
if(u8**Tmp & **_TMR)
{
action_tmr(); /*处理定时中断事件*/
}
if(u8**Tmp & **_EXI)
{
action_exi(); /*处理外部中断事件*/
}
if(u8**Tmp & **_KEY)
{
action_key(); /*处理击键事件*/
}
}
else
{
;/*idle code*/
}
}
}
/*********************************************
*FuncName : read_envt_**_grp
*Description : 读取事件标志组 g_u8Evnt**Grp ,
* 读取完毕后将其清零。
*Arguments : void
*Return : void
*********************************************/
INT8U read_envt_**_grp(void)
{
INT8U u8**Tmp = 0;
gbl_int_disable();
u8**Tmp = g_u8Evnt**Grp; /*读取标志组*/
g_u8Evnt**Grp = 0; /*清零标志组*/
gbl_int_enable();
return u8**Tmp;
}
/*********************************************
*FuncName : uart0_isr
*Description : uart0 中断服务函数
*Arguments : void
*Return : void
*********************************************/
void uart0_isr(void)
{
......
push_uart_rcv_buf(new_rcvd_byte); /*新接收的字节存入缓冲区*/
gbl_int_disable();
g_u8Evnt**Grp |= **_UART; /*设置 UART 事件标志*/
gbl_int_enable();
......
}
/*********************************************
*FuncName : tmr0_isr
*Description : timer0 中断服务函数
*Arguments : void
*Return : void
*********************************************/
void tmr0_isr(void)
{
INT8U u8KeyCode = 0;
......
gbl_int_disable();
g_u8Evnt**Grp |= **_TMR; /*设置 TMR 事件标志*/
gbl_int_enable();
......
u8KeyCode = read_key(); /*读键盘*/
if(u8KeyCode) /*有击键操作? */
{
push_key_buf(u8KeyCode); /*新键值存入缓冲区*/
gbl_int_disable();
g_u8Evnt**Grp |= **_KEY; /*设置 TMR 事件标志*/
gbl_int_enable();
}
......
}
/*********************************************
*FuncName : exit0_isr
*Description : exit0 中断服务函数
*Arguments : void
*Return : void
*********************************************/
void exit0_isr(void)
{
......
gbl_int_disable();
g_u8Evnt**Grp |= **_EXI; /*设置 EXI 事件标志*/
gbl_int_enable();
......
}
看一下程序清单 List9 这样的程序结构,是不是和自己写过的某些程序相似?对于事件驱动机制的这种实现方式, 我们还可以做得更绝一些, 形成一个标准的代码模板,做一个包含位段和函数指针数组的结构体,位段里的每一个元素作为图 5 那样的事件标志位,然后在函数指针数组中放置各个事件处理函数的函数地址, 每个处理函数对应位段里的每个标志位。
这样, main()函数中的事件处理代码就可以做成标准的框架代码。应该说,这样的实现方式是很好的,足以轻松地应对实际应用中绝大多数的情况。但是,事件驱动机制用这样的方式实现真的是完美的么?在我看来,这种实现方式至少存在两个问题:
不同事件集中爆发时,无法记录事件发生的前后顺序。
同一事件集中爆发时,容易遗漏后面发生的那次事件。
图 6 所示为某一时段单片机程序的执行情况,某些特殊情况下,会出现上面提到的两个问题。
图中, f1 为某事件的处理函数, f2 为另一事件的处理函数, I1、 I2、 I3 为 3 个不同事件触发的 ISR,假定 I1、 I2、 I3 分别对应事件 E1、 E2、 E3。从图中可以看出,主函数在调用事件处理函数 f1 的时候,发生了 2 次事件,主函数被 I1和 I2 中断了 2 次, I1 和 I2 执行的时候各自置位了相应的事件标志位。
函数 f1 返回后, 主函数又调用了另一个事件处理函数 f2, f2 执行期间先后发生了 2 次同样的事件, f2 被 I3 中断了 2次,对应的事件标志位被连续置位了 2 次。
在图 6 中我们当然可以看出 I1 先于 I2 执行,即事件 E1 发生在事件 E2 之前,但是主函数再次读取事件标志组 g_u8Evnt**Grp 的时候, 看到的是两个“同时” 被置位的标志位, 无法判断出事件 E1 和 E2 发生的先后顺序, 也就是说有关事件发生先后顺序的信息丢失了, 这就是前面说的第 1 个问题:不同事件集中爆发时,无法记录事件发生的前后顺序。
在程序清单 List9 中, 主函数在处理事件时, 按照程序预先设定好的顺序, 一个一个地处理发生的事件, 如果不同事件某时段集中爆发, 可能会出现事件的发生顺序和事件的处理顺序不一致的情况。倘若系统功能对事件的发生顺序敏感,那么程序清单 List9 中的程序就不能满足要求了。
同样的道理,如果 I3 对应的事件 E3 是程序清单 List9 中 EXI 那样的事件(这种事件没有缓冲机制), 事件 E3 第 2 次的发生就被遗漏了, 这就是前面所说的第 2 个问题:同一事件集中爆发时,容易遗漏后后面发生的事件。
如果系统功能对事件 E3 的发生次数敏感,程序清单 List9 中的程序也是不能满足要求的。既然事件驱动机制这样的实现方式存在缺陷, 那么有没有一种更好的实现方式呢?当然有!把事件转换成消息存入消息队列就能完美解决这个问题, 只不过大家不要对我这种自导自演的行文方式产生反感就好 。 |