事件驱动与单片机编程
在我们再回到单片机系统中来,看看事件驱动思想在单片机程序设计中的应用。当我还是一个单片机菜鸟的时候(当然,我至今也没有成为单片机高手),网络上的大虾们就谆谆教导:一个好的单片机程序是要分层的。曾经很长一段时间, 我对分层这个概念完全没有感觉。
什么是程序分层?
程序为什么要分层?
应该怎么给程序分层?
随着手里的代码量越来越多,实现的功能也越来越多,软件分层这个概念在我脑子里逐渐地清晰起来,我越来越佩服大虾们的高瞻远瞩。
单片机的软件确实要分层的,最起码要分两层:驱动层和应用层。应用是单片机系统的核心,与应用相关的代码担负着系统关键的逻辑和运算功能,是单片机程序的灵魂。
硬件是程序感知外界并与外界打交道的物质基础,硬件的种类是多种多样的,各类硬件的操作方式也各不相同,这些操作要求严格、精确、琐细、繁杂。
与硬件打交道的代码只钟情于时序和寄存器,我们可以称之为驱动相关代码;与应用相关的代码却只专注于逻辑和运算, 我们可称之为应用相关代码。
这种客观存在的情况是单片机软件分层最直接的依据,所以说,将软件划分为驱动层和应用层是程序功能分工的结果。那么驱动层和应用层之间是如何衔接的呢?
在单片机系统中,信息的流动是双向的,由内向外是应用层代码主动发起的,实现信息向外流动很简单, 应用层代码只需要调用驱动层代码提供的 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 中的程序也是不能满足要求的。既然事件驱动机制这样的实现方式存在缺陷, 那么有没有一种更好的实现方式呢?当然有!把事件转换成消息存入消息队列就能完美解决这个问题, 只不过大家不要对我这种自导自演的行文方式产生反感就好 。
事件驱动与消息 什么是消息?消息是数据信息的一种存储形式。从程序的角度看,消息就是一段存储着特定数据的内存块, 数据的存储格式是设计者预先约定好的, 只要按照约定的格式读取这段内存, 就能获得消息所承载的有用信息。
消息是有时效性的。任何一个消息实体都是有生命周期的,它从诞生到消亡先后经历了生成、 存储、 派发、 消费共 4 个阶段:消息实体由生产者生成, 由管理者负责存储和派发, 最后由消费者消费。
被消费者消费之后, 这个消息就算消亡了, 虽然存储消息实体的内存中可能还残留着原来的数据, 但是这些数据对于系统来讲已经没有任何意义了, 这也就是消息的时效性。说到这里,大家有没有发现,这里的“消息” 和前面一直在说的“事件” 是不是很相似?把“消息” 的这些特点套用在“事件” 身上是非常合适的, 在我看来, 消息无非是事件的一个马甲而已。
我们在设计单片机程序的时候,都怀着一个梦想,即让程序对事件的响应尽可能的快,理想的情况下,程序对事件要立即响应,不能有任何延迟。这当然是不可能的,当事件发生时,程序总会因为这样那样的原因不能立即响应事件。
为了不至于丢失事件,我们可以先在事件相关的 ISR 中把事件加工成消息并把它存储在消息缓冲区里, ISR 做完这些后立即退出。主程序忙完了别的事情之后,去查看消息缓冲区,把刚才 ISR 存储的消息读出来, 分析出事件的有关信息, 再转去执行相应的响应代码, 最终完成对本次事件的响应。
只要整个过程的时间延迟在系统功能容许的范围之内, 这样处理就没有问题。将事件转化为消息,体现了以空间换时间的思想。再插一句,虽然事件发生后对应的 ISR 立即被触发,但是这不是严格意义上的“响应”, 顶多算是对事件的“记录”, “记录” 和“响应” 是不一样的。事件是一种客观的存在,而消息则是对这种客观存在的记录。
对于系统输入而言,事件是其在时间维度上的描述;消息是其在空间维度上的描述,所以,在描述系统输入这一功能上,事件和消息是等价的。对比一下程序清单 List9 中的实现方式, 那里是用全局标志位的方式记录事件, 对于某些特殊的事件还配备了专门的缓冲区, 用来存储事件的额外信息, 而这些额外信息单靠全局标志位是无法记录的。
现在我们用消息+消息缓冲区的方式来记录事件,消息缓冲区就成了所有事件共用的缓冲区,无论发生的事件有没有额外的信息,一律以消息的形式存入缓冲区 。
为了记录事件发生的先后顺序,消息缓冲区应该做成以“先入先出” 的方式管理的环形缓冲队列。事件生成的消息总是从队尾入队,管理程序读取消息的时候总是从队头读取,这样,消息在缓冲区中存储的顺序就是事件在时间上发生的顺序,先发生的事件总是能先得到响应。
一条消息被读取之后, 管理程序回收存储这个消息的内存, 将其作为空闲节点再插入缓冲队列的队尾,用以存储将来新生成的消息。图 7 所示为使用了消息功能的事件驱动机制示意图。不知道有没有人对图中的“消费者”有疑问, 这个“消费者” 在程序中指的是什么呢?
既然这个事件/消息驱动机制是为系统应用服务的, 消费者当然就是指应用层的代码了, 更明确一点儿的话, 消费者就是应用代码中的状态机 。
|