嵌入式设计模式书籍 (嵌入式软件开发技术难点)

思从深而行从简,思想有多远,我们就能走多远。

1、嵌入式软件与设计模式

软件开发,难的不是编写软件,而是编写功能正常的软件。软件工程化才能保证软件质量和项目进度,而设计模式使代码开发真正工程化,设计模式是软件工程的基石。

所谓设计模式就是对常见问题的通解,合理地运用设计模式可以很好地解决很多问题,每种模式针对一个通用问题,以及该问题的核心解决方案,这也是设计模式能被广泛应用的原因。真正的高手能云淡风轻地用最简单的方法解决最复杂的问题,这也是高级程序员与新手的本质区别之一。

一般常见的是*人帮四**模式即GOF的23种设计模式,是偏向于可复用的面向对象的软件,并不能很完美的契合嵌入式软件,因为嵌入式C语言是结构化的语言,与硬件关联。虽然也可强制封装结构体实现类似效果(复杂的嵌入式应用软件也可使用,但对于通用PC的高级语言存在差距)。

基于嵌入式系统的工作流,选择合适的设计模式或代码框架,将复杂软件解耦或者分层,提高代码复用度和可扩展性具有一定意义,当然,代价是对资源和实时性的损耗。

微信公众号【嵌入式系统】专门针对嵌入式系统软件,提供四大类设计模式。

微信公众号【 嵌入式系统 】嵌入式系统软件设计模式 :

1、硬件访问类

2、并发同步类

3、状态与工作流类

4、安全性与可靠性类

设计模式是在已具备一定开发基础的前提下,对软件架构的优化,因此部分章节需要熟悉数字电路、RTOS实时操作系统等,才能更好的理解。更多理论信息可关注微信公众号【 嵌入式系统 】的其他文章。

2、硬件访问类设计模式

2.1 硬件访问的概念

嵌入式系统最明显的特性是可直接访问硬件,硬件操作通常会包括初始化、配置、控制等步骤,嵌入式软件管理硬件,给硬件提供命令或数据,或者从其获取信息,这块即是常说的硬件驱动代码。这类软件设计主要是考虑硬件器件的更换与兼容,对业务层的封装和隔离。

2.2 硬件代理模式

硬件代理模式(Hardware Proxy Pattem)使用结构体封装所有硬件设备访问,无论其硬件接口是怎样的,代理为客户提供与硬件形态无关的接口。

如果应用层直接访问硬件设备,硬件的变化所导致的问题会加剧;一个细节的改变,应用层均需要重新调整,通过提供介于应用层和硬件之间的代理,就极大地限制了硬件改变的影响,从而减少这样的修改。类似常说的代理律师,就是即使不懂法律,但可以请律师,由代理律师去活动。

硬件代理接口可以形如 init、open、close、read、write、control,其内部实现无需开放。函数名称或者结构体内函数指针名只是参考,也可以自定义诸如config等,这里只是说明,对不同的硬件设备统一访问接口,封闭细节。

但是也有一定缺点,硬件细节已经在硬件代理内部完全封装,对运行实时性有不良的影响。其实, 所有的设计模式都是利于软件维护,而不利于实时性 。代理模式只是简单封装接口,不能实现线程安全,除非在封装时额外增加加临界区或者队列保护。

例如开启加速度传感器,最初需求只有一颗如BMA425,其开启接口为 gsensor_bma425_open;但后期硬件替换为SCA720,如果是直接操作则需重写驱动,将原开启接口全部替换为gsensor_sc7a20_open。这种方式固然效率高,但也导致代码维护困难,从驱动层到业务层都需要替换接口。而且不能实现软件自动识别其型号,自动匹配驱动,对项目维护也是较大工作量。

如果改为硬件代理,则可以先封装函数指针结构体,伪代码形如

typedef struct
{
   *init;
   *open;
   *close;
}gsensor_ops_t;

gsensor_ops_t  bma425;
gsensor_ops_t  sc7a20;
gsensor_ops_t* p_gsensor_ops;

所有业务层访问p_gsensor_ops,至于其究竟是哪一颗传感器则无需关注,扩展或者更换硬件无需修改业务层。最佳的方案是驱动层可以根据硬件固有差异,如芯片ID或者I2C从机地址,识别出具体硬件型号,自动将p_gsensor_ops指向具体的型号的驱动接口,对软件版本和生产维护更加友好,不管硬件如何变化,软件一版即可。

2.3 硬件适配器模式

硬件适配器模式 (Hardware Adapter Patterm) 在两个接口之间进行转换,使已经存在硬件接口能适应新的期望。

适配器模式的最直观应用是手机充电器,市电220V的交流电,但手机只支持5V的直流电,要确保手机正常充电,就是在充电链路中增加适配器,将220v的交流转换为5v的直流。

一般来说具有同一个功能的硬件器件,其接口往往相似,比如前面提到的加速度传感器,不管是哪个厂商,都是提供I2C或者SPI接口。硬件适配器模式则是在业务层和硬件层之间加入相互转换,创建适配器提供客户期望的接口,而不是重写硬件设备的接口,最少化返工代码。

在软件开发中经常有同个物理量,不同硬件的表示数值不同,前面提到的加速度传感器,因为量程和精度的差异,加速度大小1g的表示值,不同的传感器xyz三轴数值不同,有的是512表示1g,有的是256表示1g;这样对业务层的逻辑算法就难以统一标准。所以可以在业务算法和硬件驱动之间,增加适配转换,统一1g的表示值,这样才能保证硬件的变化不影响算法。

再比如简单的例子,将第三方库移植到不同的平台,因为参数等信息不完全相同,原来是传入1-100表示百分比,但新接口是以0.01~1.00表示,则需要在调用接口前转换两者关系。所以,硬件适配器模式一般用在硬件器件更换,或者软件跨平台移植,它并没具体的接口套路,是因地制宜,按接口形式转换。

2.4 中介者模式

中介者模式(Mediator Pattern)是用来降低多个元素之间的通信复杂性,提供一个中介类,协调处理不同类之间的通信,各子类之间不直接通信,松耦合,使代码易于维护。

比如而二手房交易,有10个买家与10个卖家,如果都直接去沟通对接,每人需要和10人对接,而且信息没经过过滤,沟通效率低;如果有个中介,所有人都只与中介对接,中介再按买家和卖家意愿,转达有效意见,买家与卖家无需直接交流,就能促成满意的交易。每添加新元素就需更新中介者,最终可能导致中介者越发难以维护;如果中介者出现问题,则中介功能包括相关元素程序崩溃,这和房产交易中介卷钱跑路一样。

嵌入式软件开发技术难点,嵌入式编程设计模式学习笔记

软件中定义两种角色分别为合作者和中介者,合作者 (Collaborator) 指所有可能被中介者调用的具体对象,中介者 (Mediaior) 协调多个具体合作者。中介者需要明确每个合作者交互的消息,从哪来,到哪去。当感兴趣的事件发生时,合作者可以给中介者发送消息,中介者提供协调逻辑,或者与消息关联的合作者通信。

中介者与每个具体合作者一般通过多个指针连接,如果具体的合作者的接口一致,指针数组是最好的。两种角色的结构体定义伪代码形如下,针对的场景是根据汽车引擎点火acc状态,加速度传感器gsensor监测的震动信息,GPS卫星定位获取运行速度,三组数据组合判断当前车辆是处于什么状态。

//中介者管理3个关联合作者
typedef struct mediator_t
{
    colleague_t *acc;
    colleague_t *gsensor;
    colleague_t *gps;
    mediator_relay relay;
}mediator_t;

//每个合作者提供2个接口,向中介者发消息,和接收处理中介者的消息
typedef struct colleague_t
{
    mediator_t *m_mediator;
    colleague_send send;
    colleague_receive receive;
}colleague_t;

合作者发送消息时不明确是谁执行,只管发送;中介者需要识别类型,按既定规则转发给对应的合作者执行。这也是中介者模式的缺点,中介者代码庞大,随着合作者数量的增加会变得复杂难以维护。完整的演示代码如下。

//微信公众号:嵌入式系统
#include <stdio.h>

typedef enum
{
    EVENT_1,
    EVENT_2,
    EVENT_3,
    EVENT_MAX
}event_t;//模拟测试消息

struct mediator_t;
typedef int (*mediator_relay)(event_t id,void *data,int len);

struct colleague_t;
typedef int (*colleague_send)(event_t id,void *data,int len);
typedef int (*colleague_receive)(event_t id,void *data,int len);

typedef struct mediator_t
{
    colleague_t *acc;
    colleague_t *gsensor;
    colleague_t *gps;
    mediator_relay relay;
}mediator_t;

typedef struct colleague_t
{
    mediator_t *m_mediator;
    colleague_send send;
    colleague_receive receive;
}colleague_t;

/*******************************************************/
//合作者接口
static colleague_t colleague_acc={0};
static colleague_t colleague_gsensor={0};
static colleague_t colleague_gps={0};
static int colleague_acc_send(event_t id,void *data,int len)
{
    colleague_t *handle=&colleague_acc;
    handle->m_mediator->relay(id,data,len);
}

static int colleague_acc_receive(event_t id,void *data,int len)
{
    printf("ACC recv id=%d,%s\r\n",id,data);
}

static int colleague_gsensor_send(event_t id,void *data,int len)
{
    colleague_t *handle=&colleague_gsensor;
    handle->m_mediator->relay(id,data,len);
}

static int colleague_gsensor_receive(event_t id,void *data,int len)
{
    printf("gSensor recv id=%d,%s\r\n",id,data);
}

static int colleague_gps_send(event_t id,void *data,int len)
{
    colleague_t *handle=&colleague_gps;
    handle->m_mediator->relay(id,data,len);
}

static int colleague_gps_receive(event_t id,void *data,int len)
{
    printf("GPS recv id=%d,%s\r\n",id,data);
}

/*******************************************************/
//中介者接口
static mediator_t mediator_manager={0};

//中介者协调全局,将对应的事件转发给有需要的合作者,范例只是说明用法,随意定义的关系
//这个函数中介者模式维护的重点,也是它的缺点
static int mediator_msg_relay(event_t id,void *data,int len)
{
    mediator_t *handle=&mediator_manager;

    switch(id)
    {
    case EVENT_1:
        handle->gsensor->receive(id,data,len);
        break;
    case EVENT_2:
        handle->gps->receive(id,data,len);
        break;
    case EVENT_3:
        handle->acc->receive(id,data,len);
        break;
    default:
        break;
    }
}

/*******************************************************/
//测试接口
//如果觉得这样有一定耦合度,可以由中介者提供注册API给合作者调用,传入自身地址给中介者
static void init_member(void)
{
    colleague_acc.m_mediator=&mediator_manager;
    colleague_acc.send=colleague_acc_send;
    colleague_acc.receive=colleague_acc_receive;

    colleague_gsensor.m_mediator=&mediator_manager;
    colleague_gsensor.send=colleague_gsensor_send;
    colleague_gsensor.receive=colleague_gsensor_receive;

    colleague_gps.m_mediator=&mediator_manager;
    colleague_gps.send=colleague_gps_send;
    colleague_gps.receive=colleague_gps_receive;

    mediator_manager.acc=&colleague_acc;
    mediator_manager.gsensor=&colleague_gsensor;
    mediator_manager.gps=&colleague_gps;
    mediator_manager.relay=mediator_msg_relay;
}

//微信公众号:嵌入式系统
int main(void)
{
    printf("embedded-system\r\n");
    init_member();
    colleague_acc.send(EVENT_1,(void*)"from acc",0);
    colleague_gsensor.send(EVENT_2,(void*)"from gsensor",0);
    colleague_gps.send(EVENT_3,(void*)"from gps",0);
    return 0;
}


看懂范例才能更好的理解中介者模式的价值。

//微信公众号:嵌入式系统
//运行结果:
embedded-system
gSensor recv id=0,from acc
GPS recv id=1,from gsensor
ACC recv id=2,from gps

三个关联合作者互相交互,只处理与自己关联的事件,对外或者app有问题就找中介,不与具体的合作者通信。

2.5 观察者模式

观察者模式提供一种方法来使对象“监听”其他对象,而不需要修改任何数据服务器。在嵌入式领域,适合传感器采样或者某些周期更新的数据,转发给关注它的元素,比较类似发布--订阅模式。

好比在红绿灯路口,交通信号灯由绿变红,整条车道的车都会收到该信息并进行制动处理。信号灯本身不关注外界,只是按自己的节奏控制灯的变化;而众多车主观察到红灯,都进行停车动作。

一个目标对象的状态发生改变,所有的依赖对象(观察者对象)都将得到通知。在嵌入式软件的实现上,观察者模式通过在数据生产服务器添加订阅和取消订阅,服务器端不需要任何客户的先验信息。数据服务器按一定的更新策略,通知对其感兴趣的客户。

软件上,通知列表最简单的方式是定义一个足够大的数组来包含所有潜在的客户,在有很多客户的高度动态的系统中这会浪费内存,另一种方案是用链表构建一个系统。数据产生服务提供众多函数指针,有需求的客户提供自身句柄,一般是回调函数指针;当响应数据变化按既定策略执行回调,实现数据源的变化信息广播到所有观察者的效果。

例如设备支持GPS卫星定位,在驱动上报GPS信息的接口,底层提供一个函数指针数组,上层用自身的回调函数填充数组。底层获取到GPS信息后,查询数组,若回调函数非空则执行。每个回调函数由各模块分别实现,代码整洁,耦合性低。

//微信公众号:嵌入式系统
#include <string.h>
#include <stdio.h>

#define PAL_GNSS_SUBSCRIPTIONS_MAX 5

typedef unsigned char uint8_t;
typedef void (*gnss_info_callback)(void* data);

typedef struct
{
    gnss_info_callback m_cb;
} pal_gnss_subscription_info;

//订阅池
static pal_gnss_subscription_info g_gnss_subscription_pool[PAL_GNSS_SUBSCRIPTIONS_MAX] = {0};

//订阅
uint8_t pal_gnss_subscribe(gnss_info_callback callback)
{
    uint8_t i;
    uint8_t ret=0;

    if(callback != NULL)
    {
        for(i = 0; i < PAL_GNSS_SUBSCRIPTIONS_MAX; i++)
        {
            if(g_gnss_subscription_pool[i].m_cb == NULL)
            {
                //RTOS注意竞争
                g_gnss_subscription_pool[i].m_cb = callback;
                ret = 1;
                break;
            }
        }
    }
    else
    {
        ret = 0;
    }
    return ret;
}

//取消订阅
void pal_gnss_unsubscribe(gnss_info_callback callback)
{
    uint8_t i;

    for(i = 0; i < PAL_GNSS_SUBSCRIPTIONS_MAX; i++)
    {
        if(g_gnss_subscription_pool[i].m_cb == callback)
        {
            //RTOS注意竞争
            g_gnss_subscription_pool[i].m_cb = NULL;
            break;
        }
    }
}

//广播给观察者,执行回调
void pal_gnss_info_update(void)
{
    uint8_t i;
    uint8_t data=1;//test

    for(i = 0; i < PAL_GNSS_SUBSCRIPTIONS_MAX; i++)
    {
        if(g_gnss_subscription_pool[i].m_cb != NULL)
        {
            //RTOS中使用消息队列更好,这里只是演示效果
            g_gnss_subscription_pool[i].m_cb((void*)&data);
        }
    }
}

//微信公众号:嵌入式系统
int main(void)
{
    printf("embedded-system\r\n");
    return 0;
}

观察者模式,订阅-发布机制尤其传感器采集数据,分发给不同模块,各模块收到广播后按自身需求处理数据的场景。

2.6 消抖过滤模式

这个简单的模式用于消除来自于金属表面间歇性连接引起的多个假事件。

按钮、拨动开关和机电式继电器等机械式输入设备,它们都有一个共同的问题,即接触金属产生连接,金属变形或“弹性”在开关转换时产生间歇连接。从而导致控制系统中有多个电子信号。消抖过滤模式通过在首次检测到异常信号后,等待一段时间将多个信号减少到一个信号。简单且常见的场景就是按键消抖,这也是嵌入式入门的基础。

嵌入式系统软件检测到首次跳变事件,设置延迟定时器 〈如果需要关闭设备中断) ,随后检查设备状态。一定时间后(去抖动时间),如果状态不同,则事件一定是真实的,则发送相应的信息给应用层。因为按键防抖属于嵌入式软件入门基础,这里不做详细描述,重点关注定时器,可以采用CPU延时等待,也可以采用硬件定时实现,后者更合理。

关于按键检测,底层区分按键码和按键事件类型(短按、长按、连续按),可以参考 《按键检测》,结合观察者模式,使用二维数据管理按键回调函数,可以实现按键检测驱动与业务的隔离。

2.7 中断模式

物理世界从根本上来说是并发与异步的,事情该发生时它就会发生,如果嵌入式系统不加以注意,这些事件可能丢失。为了及时监测感兴趣的事件,硬件中断模式,即中断中断服务程序是最有效的方法。

中断模式是一种构造系统的方式,用于对传入事件作出适当的反应。在嵌入式系统中,事件分为不同等级的紧急度,即使在系统非常繁忙地处理其它事件时也必须处理。在本章其他内容中讨论的轮询模式中,在系统方便的时候查询感兴趣的事件;虽然这是有好处的,不中断当前正在执行的过程,但它的缺点是高紧急度和高频率的事件可能得不到及时处理。中断模式通过立即停止当前的过程,处理传入事件来解决这个问题,并且随后返回原来的流程。

通常情况下,当中断服务程序ISR执行时,关闭中断,这意味着中断服务程序必须快速执行以确保不会丢失其他中断。因为中断服务程序必须很短,当它们调用其他的系统服务时必须非常小心。如果ISR 处理占用太长时间,在共享资源上出现竞争条件或发生死锁,问题很难跟踪。

中断模式是嵌入式软件特有的,硬件中断的特点是响应及时,处理要简短,尤其是在RTOS中。

2.8 轮询模式

另一种从硬件获取传感器数据或信号的常用模式是定期检查,称为轮询过程。当数据或信号不是非常紧急到不能等待到下一个轮询时段来收取,或当数据或信号可用时,硬件没有能力生成中断(或缺乏中断检测口),这时轮询非常有用。

轮询分定期或者不定期进行,定期轮询使用定时器按固定间隔查询,不定期即机会轮询是当系统方便的时候才轮询,没有固定间隔。

定期轮询主要用于周期性的变化,或者变化很缓慢的状态,按合适的间隔定时查询设备状态,如果数据或信号轮询时间加上反应处理时间,比数据更新间隔长,那就必须引起注意,否则数据将会丢失。因为定期轮流,其本身检查绑定一个定时器中断,因此定期轮询模式其实是中断模式的特殊情况。

不定期轮询是当系统方便的时候才轮询,如在主系统功能或在重复执行的周期点之间,在低端单片机上比较常见,对其他系统从事的活动的及时性影响也小。非定期的模式如果时间非常短,也可以嵌套在其它驱动中,诸如死循环循环等待某个状态,比如查询UART发送完成,IIC的ACK响应,但是这种循环体一定要注意,必须留有一定会退出循环的条件。

2.9 小结

硬件代理模式关注指定硬件细节的封装,解决硬件元件更新迭代和多个同类器件的兼容。

而硬件适配器模式为适应不同但是相似的硬件,也解决跨平台移植。

硬件器件组合工作,或者软件需求的复杂交互则适合中介者模式。

观察者模式为硬件数据支持动态添加和删除客户,适合多个模块共享传感器数据的场景。

消抖过滤模式、中断模式、轮询模式用于解决与硬件交互的低层次问题。

软件模式的选择,与硬件框架、资源和软件需求、应用场景相关,合适的才是最好的。

3、并发同步类设计模式

3.1 并发和RTOS概念

基于RTOS的软件,宏观上多个任务并行,实际是多任务的分时调度,对应着硬件资源可能就是前任务还未完成访问,后任务要抢占使用,这切换过程中就存在竞争。若没有RTOS相关基础,可以先参考基于《RTOS的软件开发理论》 和 《FreeRTOS及其应用,万字长文,基础入门》 ,否则本章信息可能无法理解。当任务调度启动后,所有的任务独立运行,如何设计避免一个资源被多个任务抢占使用,按串行访问共享资源?

3.2 临界区模式

临界区模式是与任务协调相关的最简单粗暴的模式。禁止任务在区域内转换,通过禁用任务转换甚至禁止中断来处理竞争关系,保证当前任务不间断的执行,直到完成相关操作退出临界区。

临界区模式结构简单,受保护的元素是资源而不是任务,在临界区开始之前禁止任务切换,并在服务结束后重新可用。RTOS提供临界区进出时的任务切换使能处理,或者直接在硬件级别配置中断等方式开关中断处理。

临界区模式关闭任务调度或者中断响应,实际会影响其他任务的时序。因此要求临界区持续时间很短,一般是在同一个任务内使用互斥锁或者临界区接口,快速完成相应操作,否则可能导致系统异常。例如有2个任务模块共享读写某一块内存数据,或者操作某个寄存器,就比较适合临界区。

3.3 守卫调用模式

守卫调用模式,通过提供的锁定机制串行访问,以阻止锁定后其他线程的调用服务,简单描述就是A任务占用某个资源后,将其锁定;优先级高于A的B任务想要使用它,得先咨询能否使用,如果资源处于锁定状态则延时等待(相应的任务阻塞,即使优先级更高),等到前一个任务使用结束,解锁释放资源,B任务才能执行。

这里面存在一定问题,如果还有任务C,其优先级介于A和B之间,表面上C会先执行,导致优先级低的C竟然比任务B先执行,即优先级反转,实际不会这样。

一般RTOS信号量支持优先级继承,即任务B使用某个信号量等待A任务时,临时会将A任务的优先级提高,和B相等,实际执行顺序是B-A-C。

通过信号量的获取和释放,来独占的访问某个硬件或者软件资源,其对时间没有太严格要求,这种在业务层开发更常见。一般在两个任务或者任务与中断间,进行锁定,确保共享资源按顺序使用,也可用于同步交互。

两个任务同步处理的场景,AT指令的发送任务和接收解析任务适合信号量,发送任务必须等前一AT回复,发出AT后阻塞等待信号量;接收解析任务确认接收完成,释放信号量;这时发送任务才能退出阻塞,发送下一个AT。

3.3 队列模式

队列模式是任务间异步通信最常见的实现,在非耦合的任务间及时通信,通过队列先进先出的数据结构,发送者将消息存入队列,接收者从队列中取出消息。它也提供一种简单方法串行访问共享资源,将访问消息排队,并且在稍后的时间中处理,这就避免了共享资源常见的相互排斥的问题。

消息队列使用异步通信,并且不会遇到互斥问题,这是因为没有引用共享资源。消息队列以单一的形式避免并发系统中通过传递引用共享信息产生的资源损坏的问题。在传值共享中,制作一个信息的副本,并且发送给接收线程进行处理。接收线程完全拥有收到的数据,并且因此能够自由修改,而不需要考虑由于多个写者,或者在一个写者和多个读者*共中**享它们造成信息损坏。其缺点之一是发送者传递消息后不能立即处理,需要进程等待,直到接收者任务运行,并且能够处理正等待的消息。

队列模式通过对数据和命令排队串行访问数据,允许接收者每次处理一个。由于它是异步完成,所以消息发送和处理之间的时间是非耦合的。这可能不满足系统的性能需求。守卫调用模式也串行访问,但是同步执行,以便数据和命令传输发生在时间上更加接近。但关于信号量的释放使用出现问题,会导致较严重的错误。

队列的最简单的实现策略是消息元素数组。这具有简单性的优点,但是缺乏链表的灵活性队列是很容易实现的,但是有很多可能的变体。有时一些消息比其他的更加紧急或者重要,并且应该在等待的低优先级消息之前处理。扩展添加多个缓冲区 (每个优先级一个) 实现优先级修改,或者基于消息优先级 ,但是这样会很麻烦。

一般还是固定长度的数组队列,但是可以向队列头或者尾插入新数据,这种满足绝大部分需求,这种情况下需要注意的是接收处理要及时,避免队列溢出。

例如只有1个UART,通过开关切换分时复用接2个外设,就比较适合队列模式,读写请求缓存到队列,按序取出执行,避免出现收发数据不完整的问题。

3.4 汇合模式

任务同步发生在简单的函数调用、共享单一资源或者传递数据,可用队列模式或守卫调用模式,但如果同步需要的条件更加复杂,涉及多个任务间的同步,汇合模式更适合解决任务间复杂形式同步的问题。

在这个模式中,两个或更多的任务通过操作类似全局变量的位,按变量相应位的状态执行不同动作。在RTOS内核中,如freeRTOS,这个全局变量叫事件组,其读写操作也有相应的API。

例如3个子任务各自监测不同外设,主任务收到3个子任务反馈的外设连接正常的事件,主任务才在UI界面提示所有外设连接正常。

3.5 小结

并发才是嵌入式软件开发的常态,事情并行发生必须预防竞争与冲突,这也是实时操作系统 (RTOS) 的基本要求。

临界区模式、守卫调用模式和队列模式解决在多任务环境下串行访问资源的问题。临界区模式在资源访问期间关闭任务转换,因此防止可能的资源数据损坏,但是阻塞了更高优先级任务,使它们永远不能访问资源。

守卫调用模式通过信号量完成相同的资源保护目标,该模式可能能够导致优先级倒置,所以该模式需要使用支持优先级继承的方式。

队列模式串行访问资源,通过将请求放在队列中,并且按照先进先出 (FIFO) 的顺序从队列取出,队列模式概念上非常简单,但是导致资源的请求响应延迟,影响时效性。

看起来这些模式很高级,其实主流的实时操作系统,其内核都支持这些模式相关的接口。内核开发的大佬们,早就洞悉了并发的风险与模式,使用内核的互斥锁、信号量、队列和事件组,可以很容易的实现这些模式。

如果基于裸机开发,主程序和中断程序也近似存在共享冲突,可以自定义全局变量、循环数组实现守卫调用模式或队列模式;其他模式,裸机基本上也用不上。

4、状态与工作流类设计模式

4.1 状态与事件

行为随条件变化而改变,这里状态切换的模式也称为状态机。有限状态机 (Finite State Machine,FSM) 是由3 个主要元素组成的有向图: 状态、转换和动作。

状态是系统或者元素的状态;转换是从一个状态到另一个状态的路径,通常通过感兴趣的事件初始化,当元素处在前驱状态中,并且收到触发事件,它将连接前驱状态与后续状态。如果事件发生,然而状态中的元素没有对这个特定的事件做出响应,事件就“静静丢弃”没有任何效果,也就是状态机仅做肯定的陈述 (“当我在这个状态中并且这件事发生,我才会做” ) 。

状态机本身不关心事件如何到达,但是必须避免竞争条件。同步状态机必须阻塞调用者 (守卫调用模式或者临界区模式) ,异步状态机必须使用排队(队列模式)来存储它们的事件,直到它们得到处理。如果状态机正在执行动作的过程中产生新事件, 状态机会立即停止去处理事件? 答案当然是否定的。 状态机在处理一个新事件之前,必须确保前一状态的处理完成 。后续几个状态-事件模式都需要遵循这个原则,而且具体模式实现中不再赘述。

4.2 单事件接收器模式

单事件接收器状态机 (以下简称 SERSM) 简单说就是触发状态切换的事件接收入口只有一个,单事件接收器模式依赖于单一事件接收器在客户与状态机间提供接口。在内部,这个单一事件接收器必须接收事件数据类型,不仅识别哪个事件已经发生,并且识别任意伴随事件的数据。

事件接收器模式必然有事件,事件类型event_type和事件携带的数据event_data,前者可以是个枚举值,后者是一个结构体struct,为了识别不同的事件数据属性,可以选择union共用体更贴切。

如果是同步处理,状态执行比较简单,可以直接调用event_receptor,按传入的事件类型和事件数据执行并切状态;异步版本则建议组合使用队列模式,将事件按FIFO的方式入队,事件执行函数被动触发从队列中取出事件,用轮询的机制寻找新的事件和单事件接收器再执行,最终也是调用event_receptor。具体实现有两种方式:

1、分支逻辑法

利用 if-else 或者 switch-case 分支逻辑,按状态转移图,将每一个状态转移原模原样地直译成代码,对于简单的状态机来说,这种实现方式最简单、直接,是首选;缺点所有的状态逻辑封装在单一事件接收器内(一个大型 if-else 或 switch-case 结构),限制了状态变更和扩展性。

//微信公众号:嵌入式系统
//代码只是表意,无法编译
//enum s1 s2 s3  状态枚举值
//state 表示当前状态
void event_receptor(event_type,event_data)
{
    switch(state)
    {
    case s1:
        state_handle1(event_type,event_data);
        break;
    case s2:
        state_handle2(event_type,event_data);
        break;
    case s3:
        state_handle3(event_type,event_data);
    default:
        break;
    }
}

state_handle1/state_handle2/state_handle3执行后,按执行情况切换状态到新的state。下次即使同样的事件,因为state不同,也会产生不同的效果。

2、查表法

对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适,也称表驱动法。通过二维数组来表示状态转移图,能极大地提高代码的可读性和可维护性,扩展状态只需增加表即可。

//微信公众号:嵌入式系统
//代码只是表意,无法编译
typedef struct
{
    int state;   //状态 enum
    pFun state_handle; //对应状态下的函数指针
}state_event_table_struct;

state_event_table_struct table[]={
    {s1,state_handle1},
    {s2,state_handle2},
    {s3,state_handle3}
};

void event_receptor(event_type,event_data)
{
    for(i=0;i<(sizeof(table)/sizeof(state_event_table_struct));i++)
    {
        //根据当前状态state查表选择对应的事件接收器,并进行状态切换
        if(current_state==table[i].state) 
        {
            table[i].state_handle(event_type,event_data);
            //current_state内部更新
        }
    }
}

4.3 多事件接收器模式

多事件接收器有限状态机 (MERSM) 通常仅用于同步状态机,这是因为业务层通常关心状态机的事件集合。在这个模式中,每个事件都有一个单一的事件接收器,每个事件接收器本身仅考虑处理单一事件以及执行相关动作。可以理解为单事件接收器是多个事件进入一个固定的接收器处理,而多事件接收器是多个事件组分配给多个接收器处理。

前者可以比喻为多个领导给一个员工安排任务,后者是多个领导同时给多个员工安排任务。在事件处理上可以复用处理机制,假设员工employee有A/B/C三人,任务task集有t1/t2/t3三种,其处理接口有多种实现方式。

1、拆分为单事件接收器模式内再嵌套一个单事件接收器

//微信公众号:嵌入式系统
//代码只是表意,无法编译
void employee_task_handle(employee, task)
{
    switch(employee)
    {
        case A:
            {
                switch(task)
                {
                    case t1:
      //do something
                        break;
                    case t2:
      //do something
                        break;
                    case t3:
      //do something
                        break;
                    default:
                        break;
                }
            }
            break;

        case B:
   //同上
            break;
        case C:
   //同上
            break;
        default:
            break;
    }
}

对于两层switch-case,也可以先按task分,再嵌套按employee分类执行。

也可以直接将employee_task_handle 函数的参数拆分,例如 employee_A_handle/ employee_B_handle /employee_C_handle 三个函数体,可调用的事件接收入口就有3个,需要调用者自行区分,选择合适的事件接收接口,这也是多事件接收器字面意思的效果。

2、组合事件再按单事件接收器模式

employee三人与task三种,有9种组合方式,直接将有限的9种方式定为枚举值,就是个简单的单事件接收器模式了,只是代码处理上,每个case内重复的代码比较多而已。这只是适合组合类型比较少的情形,将多事件接收器模式降维度,简化为前一节的单事件接收器模式。

3、建立二维表

这种实现方式就是下一节的状态表模式。通过将状态逻辑分组,降为单事件接收器模式处理。例如MTK方案的锂电池脉冲充电管理,因为充电状态有很多状态,每个状态下充电是间歇性脉冲充电,充或者不充再分2个子状态,采用的正是这种方案。

本质上多事件接收器模式,可以采用单事件接收器模式或者状态表模式实现,这样的前提是事件参数类似,结构相同。如果不同事件传入的参数格式差异很大,很难统一;例如事件1需要2个int参数,事件2需要3个char数组为参数,直接按参数类型划分多个接口,不必强行参数封装统一。不同的事件处理分不同的接收器接口,也就是多事件接收器模式的特点。拆分为多个事件接收有利于传参,但要求使用者从多个接口中选择正确的。

4.4 状态表模式

状态表模式是没有嵌套状态机创建的模式,其效果类似表驱动法,状态表模式使用二维数组来存储状态转换信息,通常用状态--事件构建表格。状态表模式的状态间不存在逻辑关系,属于并行的扁平化状态,也就是任意状态在任意时刻的表现一致,状态切换与当前状态无关。

状态表模式的执行,直接通过当前的状态和事件组合来索引,调用前必须先初始化状态表。它也比其他模式更易于扩展,因为扩展只是按规则添加新元素到状态表。毕竟嵌入式设备,状态的类型必然是有限的,状态表可以使用静态方式存储,虽然浪费但实现简单。

//微信公众号:嵌入式系统
//代码只是表意,无法编译
typedef void (* pfun_task_handle)(void);

pfun_task_handle employee_task_table[3][3]={
    {employeeA_task1,employeeA_task2,employeeA_task3},
    {employeeB_task1,employeeB_task2,employeeB_task3},
    {employeeC_task1,employeeC_task2,employeeC_task3},
};

//employee和task定为枚举值
//做好函数指针非空校验
void task_allocation(employee,task)
{
 employee_task_table[employee][task]();
}

这种模式非常适合同步状态机切换,如果是异步场景,最好使用队列模式组合处理。

4.5 分解与状态模式

前面的状态机中,元素始终正好处于某个状态之中,而且是一个确切的状态,这样的状态称为或状态。但实际场景也存在复杂的,例如交通信号灯,它有两个独立的属性:

颜色 :红Red、黄Yellow、绿Green 三种

显示样式 :熄灭off 、长亮Steady、快闪Flashing quickly 、慢闪Flashing slowly 四种。

这个灯有10种(10=3x3+1)状态,因为off关闭状态,灯的颜色属性已经没必要参考了。这种独立状态乘到一起,形成一个可能巨大的状态集合很常见,解决这类问题的方法一一与状态。这里仅仅作阉割版介绍,状态机的状态,并不一定只是一个属性或枚举值解决,也可以是多个正交属性组合,这种状态需要多个参数来描述,可以在单事件接收器模式上增加状态参数。

理论与实际的差距,一直做GPS卫星*位器定**,设备含RGB三颗LED,3颗灯除独立工作指示特定状态外,还有组合状态,例三颗共同闪亮多次后恢复先前独立的闪亮状态,代码维护其实很复杂,扩展性并不好。关于状态机,如果状态之间有关联,存在优先级或者组合,不管什么模式代码都会比较难维护;只能说结合产品通用需求做个伪标准化接口。可见理论和实际还是存在较大差距的。

4.7 小结

状态机实现的模式,每一个都有优点和缺点,使用哪一个完全依赖于需求。

单事件接收器模式使用单一事件接收器,并且内部用大的 switch - case 语句实现状态行为。它需要创建并传递给接收器事件相关联的类型,最简单易用。

多事件接收器模式为每个事件使用单独的事件处理程序,以便事件类型不用明确指出,适合不同的参数类型匹配不同的事件接收器。

状态表模式可以扩展到大型的状态空间,并且提供与状态空间大小独立的性能,对较大状态空间支持较好。

分解与状态模式提供简单的实现与状态的方法(难通用待学习)。

5、安全性与可靠性类设计模式

安全性是指不会引起人或者设备危险的系统,即危险的严重性后果,和发生的可能性;可靠性用于衡量系统的“可服务时间”或者“可用性”。没有所谓的“安全软件”,因为嵌入式系统是电子、机械、软件在不同操作下的复合体 ,安全、稳定只是特定场合的运行结果。

嵌入式系统的安全性和可靠性,除去硬件防护方案外,软件上也可以采用一些防御性编程,实现系统的安全可靠以及异常恢复。主要从数据校验、备份两方面来入手。这里的解决方案其实也算是软件开发技巧,不是严格意义上的设计模式。

5.1 二进制反码模式

二进制反码模式在检测由于外界影响或者硬件故障内存损坏时很有用。

可能由 EMI (Electro-magnetic interference ,电磁干扰) 、热量、硬件故障、软件故障或者其他外部原因引发内存位损坏。这个模式将重要存储两份,一份以正常形式,而另一份以二进制反码〈~ 操作符计算位反转,逐位取反) 形式。读取数据时,二进制反码格式再次取反,并且与正常形式值比较。如果值完全相同则返回那个值,否则随之处理错误。

该模式提供可靠的方式识别影响单一内存分配的故障,非常适合数据量小但非常重要的数据存储,但对于非常大的数据结构,复制两份数据浪费硬件资源。在这种情况下使用数据流校验数据的正确性更合适。

一些非常关键的信息,如使用uint8_t变量表示某个状态,一般可设0和非0两种状态,假设原本的非0为1,但因为异常被改为2,软件是无能为力的。但如果使用二进制反码模式,使用0x55和0xAA为两种正常状态,其他为异常状态,这样软件的处理上就更加安全健壮。

5.2 数据流校验模式

数据流校验模式解决各种原因导致的变量损坏的问题,如环境因素 (EMI、热量、辐射) 、硬件因素(电源波动、内存单元故障、地址线短流路) 或者是软件故障 〈其他修改内存的软件错误) ,针对大型数据集合中的数据损坏问题。

简单说就是对数据进行校验,计算其和、CRC、MD5或者SHA哈希值等,如果数据中间出现异常被篡改,校验值可以发现错误,但是不能解决错误。考虑到硬件资源限制,一般用CRC16校验。将原始数据和其CRC16值一并存储。使用前通过校验值确认数据是否被篡改。

5.3 魔数标记模式

如果前面两种方式适合数据存储,如果只是单纯的内存数据块校验,可以简单粗暴的增加魔数标记。例如一个大结构体,首尾增加字段,正常情况下将其赋一个特殊值,如果使用中存在内存覆盖或者操作越界,导致首尾标记的数据出现变化,则表示内存出现严重问题。

//微信公众号:嵌入式系统
typedef struct
{
    uint16_t magic_head;
    int32_t  importance1;
    uint8_t  importance2[5];
    uint16_t magic_tail;
} cutomer_data_struct;

//magic_head或magic_tail发生变化说明内存操作出现问题

对于动态内存申请也可以采用这种方式,期望申请N字节时多申请6个字节(举例而已),

magic_head申请长度有效堆区magic_tail 0x1234N实际可用区域0x1234

如果magic_head和magic_tail不是0x1234,说明动态申请的区域使用越界。可以参考动态内存管理及防御性编程。

有些芯片SDK代码,对flash的写保护,或者看门狗喂狗接口,其写法也类似,将一个特殊的值写给寄存器才算正常,也是基于这类考虑。魔法数在应用开发中尽量使用枚举值来替代,但也因为魔法数的特殊性,在安全方面可以避免误操作。

5.4 智能数据模式

软件为了正确执行功能都有前置条件,但是这些功能并没有明确地检查条件实际上是否满足,在合适的位置使用主动防卫的方式来检查参数,智能数据模式即为标量数据元素编写这种范例。

嵌入式C在函数层面,运行时对参数的范围不会检查,这是其固有的不安全性,需要使用者主动去对传入的参数进行范围检查,对函数的返回值进行结果判断。

智能数据模式简化就是对数据前置条件和规则的自检,属于习惯 (小模式)范围,创建或者启动时对参数自检,对传入的参数值进行范围检查,以及多个参数间的组合合理性检查,对运行的返回值进行错误处理。所有错误码以枚举类型展示,或者直接字符描述以使意图理解更加清晰。

智能数据模式优点是数据能自我保护,缺点是执行操作的性能开销 。一般只在针对核心功能、人机交互等,引入的错误容易产生严重后果的地方处理。

最典型最简单的应用场景就是对传入的指针参数进行非空判断,这里推荐合理的使用const限定参数。

5.5 单通道模式

通道模式使用中等规模或者大型的冗余来帮助识别何时发生运行时故障,并且可能 (依赖于怎样实现) 在故障存在时持续提供服务。

通道是体系结构,包含执行端到端处理的软件(可能有硬件),也就是说通过一系列的数据处理步骤,将关键部分独立化。例如数据流可以定为数据采集-处理-执行一条龙服务,基于事件的驱动。安全性和可靠性通过在通道的关键点增加检查得到增强,可能需要一些额外的硬件。由于仅有单一通道,因此该模式将在出现持续故障时,不能继续完成功能,但是它可检测并可能处理临时故障。

这里不提供代码范例,只是提供一种思路,关键部分单独处理,分步执行,不要耦合其他逻辑,对各步骤中的中间信息增加范围校验,识别异常并尽可能进行自我恢复处理。

5.6 双通道模式

双通道模式是一种通过提供多个通道提高稳定性的主要模式,从而在架构层解决冗余问题。比如体温计检测与显示,单通道到数据采集-处理-显示,双通道就可能是2颗传感器各自独立采集,再合并处理后显示。

如果通道是相同的(叫做同构冗余通道),能够解决随机故障 (偶尔失效) ,但是不能解决系统故障 (错误) 。如果通道使用不同的设计或者实现,称为异构冗余模式(也称为多样设计模式),能够解决随机和系统故障。

双通道模式对单个点 (失效或者是失效与错误,这依赖于选择的具体子模式) 故障提供保护。系统可能通过与另一个通道比较来检测一个通道中的错误,然后转换故障到安全状态,或者它可能使用其他的手段检测一个通道的故障,并且 当故障发生时,转换到另外一个。

这种双备份机制,通过复制通道以解决与安全性和可靠性相关的故障,通常也需要大量的硬件复制,以致非常高硬件成本。如果通道是相同的,则所有的复制品包含相同的错误,因此将在相同的环境下出现错误。一般是实施策略是两个通道的管理实现也不相同。两个通道能够同时运行,并且互相检查,如果在临界值上输出不同,系统则转换到故障安全状态。另外,一个通道可以运行直到检测到错误,并且开启另外一个通道,允许故障出现时持续提供服务。不同模式的变体,有不同的硬件成本和效果。

  1. 同构冗余模式 不同的通道使用相同的设计和实现,可以有效地解决单一点的错误,该模式变体有相对较高的生产成本 ,但是有相对低的设计成本 〈因为两个通道仅需设计一次,复制一次) 。
  2. 异构冗余模式 使用不同设计或者不同实现的双通道来解决随机和系统故障,系统能够在出现故障时持续提供服务。该模式变体不仅有相对较高的生产成本,而且有相对高的设计成本(因为两个个通道需设计两套方案) 。硬件、软件方案都不同,但独立且不同的方案解决随机和系统错误。
  3. 三模块冗余 (TMR ) 模式 使用相同设计的 3 个通道来解决故障,应用的理论是,如果有单一点故障,则通道中的一个将与另外两个解约,并且丢弃异常值。系统可以在出现故障时持续提供服务,提供故障在单一通道内适当的隔离。该模式有很高的生产成本,因为通道必须复制 3 次。如果所有的通道有相同的设计 (很常见) ,则设计成本相对低,如果通道的设计不同,则该模式变体的成本非常高,因为每个通道必须设计 3 次。这种在航空、军工电子学领域很常见的。
  4. 完整性检查模式 使用两个同构通道,一个是主执行通道,另一个使用低精确计算的轻量级通道,如果低精确检查通道检测到主通道有故障,则系统进入故障安全状态。该模式有低生产成本并且中等的设计成本,因为它需要额外的设计工作,但是有较低的精确冗余,当出现单一故障时不能继续提供服务。
  5. 监视器-执行器模式 使用两个额外同构通道,第一个就如在完整性检查模式那样,是一个执行通道,这个通道提供系统服务;第二个通道使用一个或多个独立的传感器监视执行通道的物理结果。如果执行通道有故障,并且执行不正确,则监视器通道识别它,并能够命令系统进入故障安全状态。如果监视器通道有故障,则执行器通道仍然执行正确行为。

多通道模式是个系统工程,不能仅依靠软件就实现,它是嵌入式设备安全和可靠性风险的最佳解决方案,但是成本也相应增加。一般的民用消费电子不会采纳。但是了解这些模式,也可能从软件方面、需求角度进行一定的优化。例如一般车载*位器定**检查汽车是否有行驶,可以依靠ACC点火,加速度传感器信息,GPS定位信息,虽然没有很明显的交代这是异构三通道,但设备本身支持这些信息的采集,软件层面上就可以组合这三种信息,合并分析得出汽车的状态。

5.7 小结

前面的模式在应用范围上 ,通常称为“设计定式”而不是“设计模式”,但合理的应用可提高设备在操作环境中的安全性和可靠性。

6、总结

天下武功,唯快不破。嵌入式设备因为其特殊性,物料更换、市场先机、订单交期、需求变更,都与软件开发存在关联,一般情况下,凡是软件能勉强解决的就不算增加成本,这种思路下软件开发就处于试验性开发、混乱下迭代的恶性循环,最终导致产品功能看起来都正常,而源码惨不忍睹。

实际上一个产品系列,开发很少奇技淫巧,更多的是修修补补、维护迭代,原创性开发不多;可阅读性和扩展性才是重点。而设计模式,就是在尽可能在局部采用特定的思路,去兼容不同的需求,让代码更好阅读,让下一个接手的人可以很容易的去支持更多奇葩需求。

更多信息请微信扫描关注

嵌入式软件开发技术难点,嵌入式编程设计模式学习笔记