单片机的非OS的事件驱动
0 2023-01-29

很多单片机项目恐怕都是没有操作系统的前后台结构,就是main函数里用while无限循环各种任务,中断处理紧急任务。这种结构最简单,上手很容易,可是当项目比较大时,这种结构就不那么适合了,编写代码前你必须非常小心的设计各个模块和全局变量,否则最终会使整个代码结构杂乱无序,不利于维护,而且往往会因为修改了某部分代码而莫名其妙的影响到其他功能,而使调试陷入困境。

改变其中局面的最有效措施当然是引入嵌入式操作系统,但是大多数的操作系统都是付费的(特别是商业项目)。我们熟悉的uc-os/II如果你应用于非商业项目它是免费的,而应用于商业项目的话则要付费,而且价格不菲。

我们也可以自己编写一套嵌入式OS,这当然最好了。可要编写一套完整的OS并非易事,而且当项目并不是非常复杂的话也不需要一个完整的os支持。我们只要用到OS最基本的任务调度和上下文切换就够了。正是基于这样的想法,最近的一个项目中我就尝试采用事件驱动的思想重新构建了代码架构,实际使用的效果还不错,在这里做个总结。

本质上新架构仍然是前后台结构,只不过原来的函数直接调用改成通过指向函数的指针来调用。实际上这也是嵌入式OS任务调度的一个核心。

C语言中可以定义指向函数的指针:
void (*handle)(void);

这里的handle就是一个指向函数的指针,我们只要将某函数的函数名赋给该指针,就能通过实现函数的调用了:

void func1(void)
{
// Code
}
handle = func1;
(*handle)(); // 实现func1的调用

有了这个函数调用新方法,我们就可以想办法将某个事件与某个函数关联,实现所谓的事件驱动。例如,按键1按下就是一个事件,func1响应按键1按下事件。但是,如果是单纯的调用方法替代又有什么意义呢?这又怎么会是事件驱动呢?关键就在于使用函数指针调用方法可以使模块和模块之间的耦合度将到最低。一个例子来说明这个问题,一个按键检测模块用于检测按键,一个电源模块处理按键1动作。

传统的前后台处理方法:
main.c

void main()
{
...
while(1)
{
...
keyScan();
if(flagKeyPress)
{
keyHandle(); // 检测到按键就设置flagKeyPress标志,进入处理函数
}
}
}

key.c

void keyHandle(void)
{
switch (_keyName) // 存放按键值的全局变量
{
...
case KEY1: pwrOpen(); break;
case KEY2: pwrClose(); break;
}
}

power.c

void pwrOpen(void)
{
...
}
void pwrClose(void)
{
...
}

这样的结构的缺点在哪里呢?

1. key代码中直接涉及到power代码的函数,如果power代码里的函数变更,将引起key代码的变更

2. 一个按键值对应一个处理函数,如果要增加响应处理函数就要再次修改key代码

3. 当项目越来越大时,引入的全局变量会越来越多,占用过多的内存

很显然key模块与其他模块的耦合程度太高了,修改其他模块的代码都势必去修改key代码。理想的状态是key模块只负责检测按键,并触发一个按键事件,至于这个按键被哪个模块处理,它压根不需要知道,大大减少模块之间的耦合度,也减少出错的几率。这不正好是事件驱动的思想吗?

接下来,该如何实现呢?

事件驱动的实现

需要一个事件队列:

u16 _event[MAX_EVENT_QUEUE];

它是一个循环队列,保存事件编号,我们可以用一个16位数为各种事件编号,可以定义65535个事件足够使用了。

一个处理函数队列:

typedef struct
{
u16 event; // 事件编号
void (*handle)(void); // 处理函数
}handleType;

handleType _handle[MAX_HANDLE_QUEUE];

它实际是一个数组,每个元素保存事件编号和对应的处理函数指针。

一个驱动函数:

void eventProc(void)
{
u16 event;
u8 i;
if((_eventHead!=_eventTail) || _eventFull) // 事件队列里有事件时才处理
{
event = _eq[_eventHead];
_event [_eventHead++] = 0; // 清除事件

if(_eventHead>= MAX_EVENT_QUEUE)
{
_eventHead= 0; // 循环队列
}
// 依次比较,执行与事件编号相对应的函数
for(i=0; i<_handleTail; i++)
{
if(_handle[i].event == event)
{
(*_handle[i].handle)();
}
}
}
}

main函数可以精简成这样:

void main(void)
{
...
while(1)
{
eventProc();
}
}

这样代码的缺陷是需要为处理函数队列分配一个非常大的内存空间,项目越复杂分配的空间越大,显然不可取。需要做个变通,减少内存空间的使用量。

改进与变通

这样代码的缺陷是需要为处理函数队列分配一个非常大的内存空间,项目越复杂分配的空间越大,显然不可取。需要做个变通,减少内存空间的使用量。

typedef struct
{
void (*handle)(u16 event); // 仅保存模块总的散转函数
}handleType;
handleType _handle[MAX_HANDLE_QUEUE];

修改驱动函数:

void eventProc(void)
{
u16 event;
u8 i;
if((_eventHead!=_eventTail) || _eventFull) // 事件队列里有事件时才处理
{
...
for(i=0; i<_handleTail; i++)
{
(*_handle[i].handle)(event); // 将事件编号传递给模块散转函数
}
}
}

把散转处理交回给各模块,例如power模块的散转函数:

void pwrEventHandle(u16 event)
{
switch (event)
{
...
case EVENT_KEY1_PRESS: pwrOpen(); break;
...
}
}

在power模块的初始化函数中,将该散转函数加入到处理函数队列中:
// 该函数在系统初始化时调用
void pwrInit(void)
{
...
addEventListener(pwrEventHandle);
...
}

addEventListener定义如下:
void addEventListener(void (*pFunc)(u16 event))
{
if(!_handleFull)
{
_handle[_handleTail].handle = pFunc;
_handleTail++;

if(_handleTail>= MAX_HANDLE_QUEUE)
{
_handleFull= TRUE;
}
}
}

每个模块都定义各自的散转处理,然后在初始化的时候将该函数存入处理事件队列中,即能实现事件处理又不会占用很多的内存空间。

加入到事件队列需要封装成统一的函数dispatchEven,由各模块直接调用。例如,key模块就可以dispatchEvent(EVENT_KEY1_PRESS)来触发一个事件

void dispatchEvent(u16 event)
{
u8 i;
bool canDispatch;
canDispatch = TRUE;
if(!_eventFull)
{
// 为了避免同一事件被多次加入到事件队列中
for(i=_eventHead; i!=_eventTail;)
{
if(_event[i] == event)
{
canDispatch = FALSE;
break;
}
i++;
if(i >= MAX_EVENT_QUEUE)
{
i = 0;
}
}
if(canDispatch)
{
_event[_eventTail++] = event;
if(_eventTail>= MAX_EVENT_QUEUE)
{
_eventTail= 0;
}
if(_eventTail== _eventHead)
{
_eventFull = TRUE;
}
}
}
}

深一步:针对与时间相关的事件

对于与时间相关的事件(循环事件和延时处理事件)需要做进一步处理。

首先要设定系统Tick,可以用一个定时器来生成,例如配置一个定时器,每10ms中断一次。
注:tick一般指os的kernel计时单位,用于处理定时、延时事件之类。一般使用硬件定时器中断处理tick事件

定义一个时间事件队列:

typedef struct
{
u8 type; // 事件类别,循环事件还是延时事件
u16 event; // 触发的事件编号
u16 timer; // 延时或周期时间计数器
u16 timerBackup; // 用于周期事件的时间计数备份
}timerEventType;
timerEventType _timerEvent[MAX_TIMER_EVENT_Q];

在定时器Tick中断中将时间事件转换成系统事件:

void SysTickHandler(void)
{
...
for(i=0; i<_timerEventTail; i++)
{
_timerEvent[i].timer--;
if(_timerEvent[i].timer == 0)
{
dispatchEvent(_timerEvent[i].event);// 事件触发器

if(_timerEvent[i].type == CYCLE_EVENT)
{
// 循环事件,重新计数
_timerEvent[i].timer = _timerEvent[i].timerBackup;
}
else
{
// 延时事件,触发后删除
delTimerEvent(_timerEvent[i].event);
}
}
}
}

将增加和删除时间事件封装成函数,便以调用:

void addTimerEvent(u8 type, u16 event, u16 timer)
{
_timerEvent[_timerEventTail].type = type;
_timerEvent[_timerEventTail].event = event;
_timerEvent[_timerEventTail].timer = timer; // 时间单位是系统Tick间隔时间
_timerEvent[_timerEventTail].timerBackup = timer; // 延时事件并不使用
_timerEventTail++;
}

void delTimerEvent(u16 event)
{
...
for(i=0; i<_timerEventTail; i++)
{
if(_timerEvent[i].event == event)
{
for(j=i; j<_timerEventTail; j++)
{
_timerEvent[j] = _timerEvent[j+1];
}
_timerEventFull= FALSE;
_timerEventTail--;
}
}
}

对于延时处理,用事件驱动的方法并不理想,因为这可能需要将一段完整的代码拆成两个函数,破坏了代码的完整性。解决的方法需要采用OS的上下文切换,这就涉及到程序堆栈问题,用纯C代码不容易实现。 

声明: 本文转载自其它媒体或授权刊载,目的在于信息传递,并不代表本站赞同其观点和对其真实性负责,如有新闻稿件和图片作品的内容、版权以及其它问题的,请联系我们及时删除。(联系我们,邮箱:evan.li@aspencore.com )
0
评论
  • 相关技术文库
  • 单片机
  • 嵌入式
  • MCU
  • STM
  • PS/2接口协议的的嵌入式软件编程及应用分析

    1、引言随着计算机工业的发展,作为计算机最常用输入设备的键盘也日新月异。1981年IBM推出了IBMpc/XT键盘及其接口标准。该标准定义了83键,采用5脚DI

    6小时前
  • 基于通用型单片机和以太网控制器实现嵌入式以太网接口的设计

    由于嵌入式技术和网络技术的迅速发展,以太网接口在嵌入式系统中的应用越来越广泛,以太网接口不仅通信速度快,传输可靠,使用和配置方便,而且不受地域限制(广域网和局域

    6小时前
  • ARM TrustZone的简介概念

    TrustZone是ARM对ARM6的扩展,其实只是增加了一条指令,一个配置状态位,以及一个新的有别于核心态和用户态的安全态。ARM并没有把TrustZone设

    12小时前
  • 主流ARM的分类介绍

    ARMCPU现在分为3种型号:A系列:主要是用在智能手机上,代表的系统是Andrios和IOS。R系列:和M系列一样,更注重实时功能,军工和航天的实时嵌入式设备

    12小时前
  • 如何用51单片机进行遥控解码

    遥控器使用方便,功能多.目前已广泛应用在电视机、VCD、DVD、空调等各种家用电器中,且价格便宜,市场上非常容易买到。如果能将遥控器上许多的按键解码出来.用作单

    12小时前
  • Cortex-M3寄存器的11个知识点

    1.寄存器CM3拥有R0~R15通用寄存器和一些特殊功能寄存器R0~R12这些通用寄存器,复位初始值都是不可预料的2.CM3有R0到R15的通用寄存器组注:绝大

    12小时前
  • MM32F0/L0/W0系列MCU之EXTI

    一、MM32嵌套向量中断控制器本文针对MM32F0/L0/W0系列MCU产品。特征 ○中断都可屏蔽(除了NMI) ○16个可编程的优先等级(使用了4位中断优先级

    12小时前
  • 用库函数的方法来设置STM32 DAC

    STM32的DAC模块(数字/模拟转换模块)是12位数字输入,电压输出型的DAC。DAC可以配置为8位或12位模式,也可以与DMA控制器配合使用。DAC工作在1

    12小时前
  • PIC单片机设计引脚中断程序

    所有的中档系列PIC单片机,PORTB端口最高的4个引脚(RB7~RB4)在设为输入模式时,当输入电平由高到低或由低到高发生变化时,可以让单片机产生中断。这就是

    12小时前
  • EEPROM和FLASH的区别

    1、EEPROM介绍ElectricallyErasableProgrammableReadOnlyMemory电气可拭除可编程只读存储器发展过程:ROM–&g

    12小时前
  • stm32之CMSIS标准、库目录、GPIO

    一、CMSIS标准ST公司的stm32采用的是cortex-m3内核,内核是整个微处理器的CPU。该内核是ARM公司设计的一种处理器体系架构。内核与外设的关系就

    12小时前
  • uCOS-II下编写中断服务程序

    在uCOS-II实时内核下,对外设的访问接口没有统一完善,有很多工作需要用户自己去完成。串口通信是单片机测控系统的重要组成部分,异步串行口是一个比较简单又很具代

    12小时前
下载排行榜
更多
广告