嵌入式消息订阅发布模式软件框架

文章目录

  • 一、总体框架
  • 二、基于RT-Thread的SoftBus
    • 2.1 SoftBus的由来
    • 2.2 消息订阅者模式
    • 2.3 静态订阅关系与动态订阅关系
    • 2.4 C/S模式
    • 2.5 消息订阅者模式与C/S模式的区别
    • 2.6 Env配置SoftBus
    • 2.7 SoftBus API
    • 2.8 SoftBus的实现
      • 2.8.1 消息的定义
      • 2.8.2 服务的定义
      • 2.8.3 服务初始化与运行
      • 2.8.4 消息的创建与销毁
      • 2.8.5 发送事件消息
      • 2.8.6 发送异步请求消息
      • 2.8.7发送同步请求消息
      • 2.8.8发送应答消息
  • 三、基于SoftBus的菜单框架
    • 3.1 菜单核心思想
    • 3.2 菜单服务初始化
    • 3.3 菜单数据结构
    • 3.4 菜单初始化和切换
    • 3.5 菜单超时
    • 3.6 菜单自动注册
    • 3.7 菜单公共订阅
    • 3.8 菜单API
  • 四、RT-Thread设备IO框架
    • 4.1 设备IO框架介绍
    • 4.2 设备IO框架API
    • 4.3 串口框架与使用
    • 4.4 从0开始编写一个设备驱动
      • 4.4.1 定义设备数据类型
      • 4.4.2 实现设备驱动框架
      • 4.4.3 注册设备驱动框架
      • 4.4.4 底层注册设备驱动
      • 4.4.5 修改KConfig和SConscript
  • 五、RT-Thread组件与软件包
    • 5.1 FinSH
      • 5.1.1FinSH简介
      • 5.1.2FinSH内置命令
      • 5.1.3 FinSH配置
      • 5.1.4 FinSH导出命令
      • 5.1.5 FinSH的应用
        • 5.1.5.1 查看线程运行情况
        • 5.1.5.2 动态调试功能
    • 5.2 SFUD
      • 5.2.1 SFUD简介
      • 5.2.1 SFUD的原理
      • 5.2.3 SFUD的使用
    • 5.3 EasyFlash
      • 5.3.1 EasyFlash简介
      • 5.3.2 EasyFlash配置
      • 5.3.3 EasyFlash移植
      • 5.3.4 EasyFlash的应用
    • 5.4 FlexibleButton
      • 5.4.1 FlexibleButton简介
      • 5.4.2 FlexibleButton配置
      • 5.4.3 FlexibleButton的应用
    • 5.5 PM
      • 5.5.1 PM简介
      • 5.5.2 PM工作原理
      • 5.5.3 PM移植
      • 5.5.4 PM组件应用
  • 六、业务实现
    • 6.1 指纹检测
    • 6.2 密码输入
    • 6.3 用户鉴权
  • 七、总结
    • 7.1 与原框架的区别
      • 7.1.1 底层驱动
      • 7.1.2 外设与组件
      • 7.1.3 菜单框架
      • 7.1.4 业务逻辑
      • 7.1.5 工程配置
    • 7.2 现存问题及对策
      • 7.2.1 服务主动性问题
      • 7.2.2 中断与发送消息
      • 7.2.3 菜单问题
      • 7.2.4 登记键问题
      • 7.2.5 组合开门问题
      • 7.2.6 设备匹配问题
    • 7.3 开发原则
      • 7.3.1 开闭原则
      • 7.3.2 单一职责原则
      • 7.3.3 里氏替换原则
      • 7.3.4 依赖倒置原则
      • 7.3.5 接口隔离原则
      • 7.3.6 迪米特原则

一、总体框架

总体框架如图所示,大体分为4层:

  1. 服务:菜单服务用于捕获外部输入,例如触摸按键、指纹、刷卡等,并根据事件访问数据库、LED等其他服务。

  2. 设备与组件:包含了我们开发的一些模块的驱动以及RT-Thread官方提供的一些组件或软件包,例如:

    SoftBus则用于服务与服务之间的通信。

    FinSH可用于串口调试。

    FAL用于管理FLASH。

    SFUD用于驱动SPI FLASH 。

    easyFlash提供了键值对、日志记录等功能。

    设备框架用于解耦设备的驱动。

    低功耗管理用于管理芯片及外设的功耗。

  3. 实时内核:RT-Thread内核层,其中BSP和平台相关,我们为FM33LC平台编写了BSP,其余芯片(如STM32等)则由RT-Thread官方提供。

  4. CPU架构:与使用的芯片相关,我们用的最多的就是ARM架构和RISC-V架构。

嵌入式消息订阅发布模式软件框架_第1张图片

二、基于RT-Thread的SoftBus

2.1 SoftBus的由来

传统的函数调用方式都是直接调用,这样非常容易引起代码耦合。由此引入消息-订阅者等模式来达到解耦的目的,SoftBus就应用了类似的机制。

SoftBus主要分为两大部分:消息-订阅者模式、C/S模式。

2.2 消息订阅者模式

消息-订阅者模式中有三个角色:消息发送方、订阅方、中间者。消息与订阅者的关系是一对多的关系。这种模式是异步的,适用于模块自身状态改变时向外发出广播消息,只有关心这些消息的其他模块才能收到并处理。

  • 消息发送方:在发生某一事件时将事件消息广播出去,消息发送方并不关心有哪些订阅者关心这些消息。

  • 订阅方:提前订阅了某些消息,当这些消息被发送方广播时,订阅这些消息的订阅方都会受到这些消息。

  • 中间者:中间者相当于一个邮递员,准确地将消息送给订阅方。

嵌入式消息订阅发布模式软件框架_第2张图片

2.3 静态订阅关系与动态订阅关系

所谓静态订阅关系,就是指订阅关系是在程序编译阶段就已经确定好了;动态订阅关系则是指程序可以在运行过程中动态地订阅或取消订阅。

静态订阅的实现方式其实与请求应答模式是非常类似的。订阅关系也是通过固定的map表来记录的。静态订阅与请求应答的区别如下:

  1. 事件对于订阅者可能是一对多的关系,因此事件在发送时SoftBus可能会复制多条消息分别投放到订阅者服务中等待处理;请求应答则是一对一的关系,不存在消息需要复制的情况。
  2. 虽然静态订阅关系和请求应答一样是通过map表来记录,但是在事件的处理函数中,是不允许发送应答消息的,即事件消息的链路是单向的。但可以在事件消息处理函数中去做请求别的服务等操作。

动态订阅是通过双向链表来记录订阅关系的,双向链表的优点是插入和删除都非常方便。每个服务都有一条记录动态订阅关系的链表,可通过相关API函数来订阅或取消订阅感兴趣的消息。当订阅的消息发布时,订阅这些消息的服务都会收到该消息。

2.4 C/S模式

由于编程时经常会遇到A模块请求B模块的数据,然后根据请求的数据再做进一步处理,这种情景用异步的消息-订阅者模式来实现是比较麻烦的,因此有了C/S模式。C/S模式即服务器客户端模式,服务器提供资源供客户端来访问并应答客户端。常见地应用如:A模块请求B模块去做某一件事,并在B完成后应答给A。

嵌入式消息订阅发布模式软件框架_第3张图片

这种模式可以是非阻塞(异步)的,也可以是阻塞(同步)的。在异步模式下,请求方发送完请求消息后就会继续执行完函数,应答方发送应答消息后,请求方才处理应答消息;在同步模式下,请求方发送完请求后会阻塞地进行等待(有超时),直到应答方发送了应答消息或者等待超时后,请求方才会继续执行。

嵌入式消息订阅发布模式软件框架_第4张图片

2.5 消息订阅者模式与C/S模式的区别

  • 消息订阅者模式中,消息的发送方并不知道订阅者的情况,因此常用于外部事件输入,由发布者发布消息,订阅者捕获并处理;而在C/S模式常用于客户端对服务器进行控制。
  • 消息订阅者模式是纯异步的,;C/S模式既可以是同步的,也可以是异步的。

2.6 Env配置SoftBus

在Env工具中使能SoftBus组件,使能SoftBus后进入下一级菜单可配置SoftBus的具体功能,例如是否开启动态订阅功能等。
嵌入式消息订阅发布模式软件框架_第5张图片

然后使用Scons工具构建工程,工程中就会多出一个SoftBus分组。
嵌入式消息订阅发布模式软件框架_第6张图片

2.7 SoftBus API

/* 创建一条消息 */
rt_msg_t rt_msg_create(rt_uint32_t msg_id,void *data, rt_uint32_t data_size);

/* 销毁一条消息 */
void rt_msg_delete(rt_msg_t msg);

/* 非阻塞地发送一条请求消息,对应C/S模式中的客户端请求 */
rt_err_t rt_msg_send_req(rt_uint32_t des_server_id, rt_msg_t req_msg, 
                         msg_proc msg_res_proc);

/* 阻塞地发送一条请求消息,对应C/S模式中的客户端请求 */
rt_err_t rt_msg_send_req_block(rt_uint32_t des_server_id, rt_msg_t req_msg, 
                               rt_msg_t res_msg, rt_uint32_t timeout);

/* 发送一条应答消息,对应C/S模式中的服务器的应答 */
rt_err_t rt_msg_send_res(rt_msg_t req_msg,  rt_msg_t res_msg);

/* 发送一条事件消息,,对应消息-订阅者模式中的发布消息 */
rt_err_t rt_msg_send_event(rt_msg_t event_msg);

/* 动态订阅一条消息 */
rt_err_t rt_server_subscribe(rt_uint32_t server_id, rt_uint32_t msg_id, msg_proc proc);

/* 动态取消订阅一条消息 */
rt_err_t rt_server_unsubscribe(rt_uint32_t server_id, rt_uint32_t msg_id, msg_proc proc);

/* 设置服务的消息处理表 */
rt_err_t rt_server_set(rt_uint32_t server_id, msg_map_t map, rt_uint16_t map_size);

/* 创建一个服务 */
rt_err_t rt_server_create(rt_server_t server);

2.8 SoftBus的实现

为了简化消息-订阅者模式与C/S模式的实现,**在SoftBus中,发布者发布的消息、客户端发送的请求、服务器的应答统称为消息;订阅者和服务器统称为服务(server)。每个server都有一个唯一的server ID,每个消息都有一个唯一的msg ID,**并且server可以绑定一个消息处理表,消息的处理表实际就是一组msg ID及对应的处理函数。只要server的消息处理表中支持某一msg的处理,那么该msg即可以是客户端发送的请求,也可以是发布者发布的消息,即订阅关系在直接体现在这里。

2.8.1 消息的定义

消息的定义如下,一条消息由消息ID和携带的数据构成,如果消息很简单没有数据,那么数据可以为空。

typedef struct
{
    rt_uint32_t             msg_id;
    void                    *data;
    rt_uint32_t             data_size;    
}rt_msg, *rt_msg_t;

rt_msg仅仅是对针对用户开放的一个定义,实际上SoftBus.c中还定义了**_rt_msg**,_rt_msg是在rt_msg的基础上进行了扩展,可以认为他们之间存在一种继承关系。

struct _rt_msg
{
    rt_msg                  msg;                    /* 消息实体 */
    rt_uint32_t             type;                   /* 消息类型 */
    rt_uint32_t             src_server_id;          /* 源服务ID */
    msg_proc                msg_proc;               /* 发送方的应答处理函数或动态订阅处理函数 */
    rt_msg_t                sync_msg;               /* 同步消息指针,用于接收同步消息 */
    struct _rt_msg          *next;                  /* 指向下一_rt_msg节点 */
};
  • msg包含了消息的基本信息,如消息ID、数据。
  • type指定了消息的类型,请求、应答或事件,用户在发送消息时无需关心,只需调用相应的API。
  • src_server_id记录了消息的发送源,即消息是哪个服务发出的,如果是事件消息,那么该字段无效。
  • msg_proc用于处理异步消息,异步消息可能是事件消息,也可能是异步的应答消息。
  • sync_msg是一个消息指针,用于请求方在请求同步消息时接收结果。
  • next是消息指针,指向下一消息节点,用于构成单向链表。

消息处理的定义如下,可以看出消息处理就是对每个消息ID都定义一个相应的处理函数。

typedef void(*msg_proc)(const rt_msg_t pMsg) ;

typedef struct 
{
    rt_uint32_t             msg_id;
    msg_proc                proc;
}msg_map, *msg_map_t;

2.8.2 服务的定义

一个服务需要具备处理多种消息的能力,因此服务包含了一个消息处理表,可见以下代码。由于服务的本质是线程,因此还需要设置服务的栈大小、优先级,这实际上就是线程的栈大小、优先级。

typedef struct 
{
    rt_uint32_t             server_id;      /* 服务ID */
    char                    *name;          /* 服务名字 */
    msg_map_t               map;            /* 消息处理表 */
    rt_uint16_t             map_size;       /* 消息处理表的项数 */
    rt_uint16_t             stack_size;     /* 服务的栈大小 */
    rt_uint8_t              prio;           /* 服务的优先级 */
}rt_server, *rt_server_t;

rt_server仅仅是对外开放的服务数据定义,实际上服务还需要包含更多成员,例如待处理的消息缓存等。在Softbus.c中定义了**_rt_server**,这也意味着对外部可见的是rt_server,而SoftBus运作时真正需要的是包含了更多信息的**_rt_server**,他们之间存在一种”继承“关系。_rt_server定义如下。

struct _rt_server
{
    rt_server               server;                 /* 用户定义的sever参数 */
    struct rt_thread        thread;                 /* 服务线程 */
    struct rt_mutex         mutex;                  /* 消息队列互斥量 */
    struct rt_semaphore     msg_sem;                /* 消息信号量,唤醒对应服务 */
    struct rt_semaphore     sync_sem;               /* 同步信号量,用于阻塞请求 */
    _rt_msg_t               msg_node;               /* 待处理的消息链表 */
    rt_uint32_t             msg_count;              /* 待处理的消息个数 */    
    rt_base_t               flag;                   /* 服务标志位 */
#ifdef RT_USING_SOFTBUS_DYNAMIC_SUBSCRIBE
    rt_list_t               subcribe_list;          /* 动态订阅链表 */
#endif
    struct _rt_server       *next;                  /* 指向下一_rt_server节点 */
};
  • server成员包含了服务的一些基本信息,thread即服务所依赖的线程。
  • mutex用于提供操作服务数据时互斥保护。
  • msg_sem用于当服务收到消息时唤醒服务(无消息时服务是休眠的)。
  • sync_sem用于发送同步请求时,应答方唤醒请求方。即发送同步请求时,请求方会休眠,直到超时或被应答方及时唤醒。
  • msg_node是一个消息的指针,指向下一节点,用于构成单向链表。

2.8.3 服务初始化与运行

初始化服务只需调用rt_server_create函数即可,该函数会申请所需的内存并赋值,最后启动线程。下面是一个初始化服务的例子。

    rt_server server;

	server.prio = 5;
    server.server_id = LED_SERVER_ID;
    server.stack_size = 800;
    server.name = "led_sever";
    server.map = (msg_map_t)ledMsgMap;
    server.map_size = ARRAY_SIZE(ledMsgMap);
    rt_server_create(&server);

上面提到了服务的本质是一个带有消息处理器的线程,下面的代码是所有服务的线程入口函数。可以看到,服务会死循环等待它的唤醒信号,然后处理它消息处理链表中的消息。没有消息时,线程将会一直休眠。

/* 服务线程入口函数 */
static void _rt_server_entry(void *p)
{
    _rt_server_t server = (_rt_server_t)(p);

    for (;;)
    {
        rt_sem_take(&server->msg_sem, RT_WAITING_FOREVER);
        _msg_proc(server);
    }
}

消息的处理函数如下,该函数会遍历服务的消息链表并依次处理,不同的类型的消息类型有不同的处理函数。请求消息和静态订阅消息被统一处理;应答消息和动态订阅消息被统一处理;MSG_TYPE_SET是内部的一种消息,用于切换静态订阅表,菜单在切换时,就会发生菜单服务的静态订阅表切换。

/* 消息处理 */
static void _msg_proc(_rt_server_t server)
{
    while (server->msg_node != RT_NULL)
    {
        _rt_msg_t curr_msg = server->msg_node;

        switch (curr_msg->type)
        {
        case MSG_TYPE_REQ:
        case MSG_TYPE_EVENT_STATIC:
            _req_static_event_msg_handle(server, curr_msg);
            break;

        case MSG_TYPE_RES:
        case MSG_TYPE_EVENT_DYNAMIC:
            _res_dynamic_event__msg_handle(server, curr_msg);
            break;

        case MSG_TYPE_SET:
            _set_msg_handle(server, curr_msg);
            break;

        default:
            LOG_E("msg proc err,invalid msg type");
            break;
        }

        rt_mutex_take(&server->mutex, RT_WAITING_FOREVER);
        server->msg_node = curr_msg->next;
        server->msg_count--;
        rt_mutex_release(&server->mutex);

        RT_KERNEL_FREE(curr_msg);
    }
}

2.8.4 消息的创建与销毁

/* 动态创建一条消息,仅在发送消息后返回-RT_ERROR需要手动销毁 */
rt_msg_t rt_msg_create(rt_uint32_t msg_id, void *data, rt_uint32_t data_size)
{
    _rt_msg_t _msg = RT_NULL;

    RT_ASSERT(!(data == RT_NULL && data_size));

    _msg = RT_KERNEL_MALLOC(RT_ALIGN(sizeof(struct _rt_msg) + data_size, RT_ALIGN_SIZE));

    if (_msg == RT_NULL)
    {
        LOG_I("aeda new msg failed,not enough memory");
        return RT_NULL;
    }

    _msg->msg.msg_id = msg_id;
    _msg->next = RT_NULL;

    if (data && data_size)
    {
        _msg->msg.data = (rt_uint8_t *)(_msg + 1);
        _msg->msg.data_size = data_size;
        rt_memcpy(_msg->msg.data, data, data_size);
    }
    else
    {
        _msg->msg.data = RT_NULL;
        _msg->msg.data_size = 0;
    }

    return &(_msg->msg);
}

创建消息的代码如上所示,创建消息就是申请内存并赋值的一个过程。

创建消息时,使用了一个小技巧,即消息本身的空间和消息所携带的数据空间同时申请内存,然后将消息的数据空间指针直接指向对应的内存。

_msg->msg.data = (rt_uint8_t *)(_msg + 1);

因为消息本身的空间大小是固定的,而对结构体指针地址+1会直接让地址跳过该结构体所占内存大小的空间,那么自增后地址即为消息的数据空间地址。如果分两次申请内存,会导致容易生成内存碎片,同时释放内存时还需要判断消息是否携带了额外的数据。

消息的销毁就是是否内存的过程。一般情况下,在消息发送失败等情况下,SoftBus会自动销毁消息,因此该

void rt_msg_delete(rt_msg_t msg)
{
    _rt_msg_t _msg = RT_NULL;

    RT_ASSERT(msg != RT_NULL);

    _msg = to_rt_msg(msg);
    RT_KERNEL_FREE(_msg);
}

2.8.5 发送事件消息

事件消息对应的是消息-订阅者模型,这是在智能锁上使用的最广泛的一种消息,也是最简单的一种。以下是发送事件消息的函数定义,为了节省篇幅,省去了动态订阅部分的代码。

server_node是一个全局的服务链表。在服务初始化时,就会链接在这条链表上,通过这条链表,可以访问所有的服务。发送事件消息时,需要遍历所有的服务,查看这个服务是否订阅了当前事件消息。2.2小节中提到了静态订阅和动态订阅,静态订阅的实现也就是遍历服务的消息处理表;动态订阅是在服务中内置了一条订阅链表,每个链表节点都记录了一个订阅关系(消息ID和消息处理函数)。当前默认是会优先查找静态订阅关系,找到订阅订阅关系后不会再去找动态订阅关系了。

消息和订阅者关系可能是一对多的关系,因此在有多个订阅者的情况下,需要复制消息并投放每个订阅者的消息处理链表中,并唤醒他们。

rt_err_t rt_msg_send_event(rt_msg_t event_msg)
{
    _rt_server_t server_node = _server_list;
    _rt_msg_t _msg = RT_NULL;
    _rt_msg_t _msg_copy = RT_NULL;
    rt_uint32_t count = 0;

    RT_ASSERT(event_msg != RT_NULL);

    if (server_node == RT_NULL)
    {
        goto exit;
    }

    _msg = to_rt_msg(event_msg);
    _msg->src_server_id = INVALID_SERVER_ID;

    /* 遍历所有服务 */
    while (server_node)
    {
        if (_find_msg_proc(server_node, event_msg->msg_id))
        {
            if (count == 0)
            {
                _msg_copy = _msg;
            }
            else
            {
                /* 复制一份_msg再放到服务的消息链表 */
                _msg_copy = (_rt_msg_t)RT_KERNEL_MALLOC(RT_ALIGN(sizeof(struct _rt_msg) + _msg->msg.data_size, RT_ALIGN_SIZE));
                if (_msg_copy == RT_NULL)
                {
                    return -RT_ENOMEM;
                }
                rt_memcpy(_msg_copy, _msg, RT_ALIGN(sizeof(struct _rt_msg) + _msg->msg.data_size, RT_ALIGN_SIZE));
            }
            count++;
            _msg_copy->type = MSG_TYPE_EVENT_STATIC;
            rt_mutex_take(&server_node->mutex, RT_WAITING_FOREVER);
            _msg_push(server_node, _msg_copy);
            server_node->msg_count++;
            rt_mutex_release(&server_node->mutex);
            rt_sem_release(&server_node->msg_sem);
        }
        server_node = server_node->next;
    }
    if (count)
    {
        return RT_EOK;
    }

exit :
    rt_msg_delete(event_msg);
    return -RT_ERROR;
}

2.8.6 发送异步请求消息

由于是异步的,因此发送异步请求消息时需要指定应答消息的处理函数。在发送请求时,需要确认目标服务是否支持该请求(在目标服务的消息处理表中查找是否存在对该消息的处理),若目标服务不支持则发送失败。

请求的一个特殊用法是在非服务中使用,即允许在一个非服务的线程中使用,但消息的接收方必须是服务,而且这种情况是收不到应答消息的,因为消息的接收必须依赖服务。

rt_err_t rt_msg_send_req(rt_uint32_t des_server_id, rt_msg_t req_msg, msg_proc msg_proc)
{
    _rt_server_t _server = _find_server_by_server_id(des_server_id);
    _rt_msg_t _msg = to_rt_msg(req_msg);

    if (_server == RT_NULL)
    {
        LOG_I("aeda send req faile,can't find server");
        goto exit;
    }

    if (_find_msg_proc(_server, _msg->msg.msg_id) == RT_NULL)
    {
        LOG_I("aeda send req faile,can't find msg proc");
        goto exit;
    }

    /* 在非服务中发送请求,消息源ID要置成INVALID_SERVER_ID,禁止应答方发送应答消息 */
    if (_is_server_thread(rt_thread_self()))
    {
        _msg->src_server_id = _curr_server_get()->server.server_id;
    }
    else
    {
        _msg->src_server_id = INVALID_SERVER_ID;
    }

    _msg->type = MSG_TYPE_REQ;
    _msg->sync_msg = RT_NULL;
    _msg->msg_proc = msg_proc;

    /* 消息放入接收方消息链表,并唤醒 */
    rt_mutex_take(&_server->mutex, RT_WAITING_FOREVER);

    _msg_push(_server, _msg);
    _server->msg_count++;

    rt_mutex_release(&_server->mutex);

    rt_sem_release(&_server->msg_sem);

    return RT_EOK;

exit:
    rt_msg_delete(req_msg);
    return -RT_ERROR;
}

2.8.7发送同步请求消息

同步消息与异步消息实现的方式大同小异。在发送完请求后,会阻塞在sync_sem信号量上,直到应答方释放该信号量唤醒发送方。与异步消息的另一区别为入参,用应答消息指针取代了应答消息的处理函数。

2.8.8发送应答消息

发送应答消息时,会判断应答的是同步请求消息还是异步请求消息,如果是同步请求消息,将应答消息直接复制到请求消息的接收指针上,并唤醒请求方;如果是异步请求消息,那么将应答消息放入请求方的消息链表中等待异步处理。

rt_err_t rt_msg_send_res(rt_msg_t req_msg, rt_msg_t res_msg)
{
    _rt_server_t _server = RT_NULL;
    _rt_msg_t _req_msg = RT_NULL;
    _rt_msg_t _res_msg = RT_NULL;

    RT_ASSERT(req_msg != RT_NULL);
    RT_ASSERT(res_msg != RT_NULL);

    _req_msg = to_rt_msg(req_msg);

    /* 如果不是其他服务发送的消息,不能发送应答 */
    if (_req_msg->src_server_id == INVALID_SERVER_ID)
    {
        LOG_E("invalid server id,send res msg failed");
        goto exit;
    }

    _server = _find_server_by_server_id(_req_msg->src_server_id);

    if (!_server)
    {
        LOG_E("invalid server id,send res msg failed");
        goto exit;
    }

    _res_msg = to_rt_msg(res_msg);
    _res_msg->type = MSG_TYPE_RES;

    rt_mutex_take(&_server->mutex, RT_WAITING_FOREVER);

    /* 如果是收到的是同步请求消息,那么直接返回应答消息 */
    if (_server->flag & SERVER_FLAG_SYNC)
    {
        if (_req_msg->sync_msg)
        {
            _req_msg->sync_msg->msg_id = res_msg->msg_id;
            if(_req_msg->sync_msg->data && res_msg->data)
            {
                rt_memcpy(_req_msg->sync_msg->data, res_msg->data, 
                          MIN(_req_msg>sync_msg->data_size, res_msg->data_size));
            }
        }

        rt_mutex_release(&_server->mutex);

        rt_sem_release(&_server->sync_sem);

        RT_KERNEL_FREE(_res_msg);

        return RT_EOK;
    }

    /* 收到的是异步请求消息,将应答消息放入请求服务的消息链表并唤醒 */
    _res_msg->msg_proc = _req_msg->msg_proc;

    _msg_push(_server, _res_msg);
    _server->msg_count++;

    rt_mutex_release(&_server->mutex);

    rt_sem_release(&_server->msg_sem);

    return RT_EOK;
exit:
    rt_msg_delete(res_msg);
    return -RT_ERROR;
}

三、基于SoftBus的菜单框架

3.1 菜单核心思想

基于事件驱动:将菜单整个作为服务(订阅者),并让其订阅指纹、刷卡、触摸按键等消息,并根据这些消息处理来访问其他服务,如密码鉴权时需访问数据库服务、控制LED时需要访问LED服务。在菜单需要跳转时,可以跳转到指定的菜单。每个菜单的服务表都是不一样的,即每个菜单订阅的消息不同,例如管理员密码验证时就无须关心指纹输入。

然而有些消息却是每个菜单都需要订阅的,例如菜单超时消息、按键消息、强制锁定消息、语音播放完成消息等,如果每个菜单的map表都包含这些消息,无疑会产生很多重复的代码(重复的订阅关系)。出于该问题,我们用动态订阅+静态订阅的方式解决该问题,即公共消息通过动态订阅来完成,菜单相关的特有消息通过静态订阅来完成。动态订阅在菜单初始化时就已经确定好;切换菜单时,菜单服务将会切换静态订阅表,这样做还有一个好处:SoftBus中会优先处理静态订阅关系,因此静态订阅的优先级高于动态订阅。这样,一些特殊菜单可以“复写”消息处理。例如空闲菜单下语音播放完成不需要更新菜单超时,而其他菜单则需要,这种情况下,虽然每个菜单都订阅了语音播放完成消息,而且消息处理使用的是同一个函数,但我们可以让空闲菜单再静态订阅一条语音播放完成消息,并且处理函数为空,这样当空闲菜单下有语音播放完成时,空闲菜单就调用了一个空函数,而不是公共的语音播放完成处理函数。

3.2 菜单服务初始化

下面的代码是菜单服务初始化函数,默认绑定的是menuDefultMsgMap服务表,这实际是一张无效的表,因为此时菜单还未初始化,也就没有服务。为了实现菜单超时功能,启动了一个软件定时器,软件定时器会在超时后广播一条菜单超时的消息,这是一条公共消息,广播的优势就在于此,谁关心就谁订阅,非常灵活且低耦合。

/* 初始化菜单服务 */
static int MenuSeverInit(void)
{
    rt_server server;
     
    rt_err_t err = RT_EOK;
    
    server.prio = 5;
    server.server_id = MENU_SERVER_ID;
    server.stack_size = 2048;
    server.name = "Menu";
    server.map = menuDefultMsgMap;
    server.map_size = ARRAY_SIZE(menuDefultMsgMap);

    err = rt_server_create(&server);

    if(err != RT_EOK)
    {
        LOG_E("menu sever init failed");
        return -RT_ERROR;
    }

    /* 初始化菜单超时定时器,超时后会广播一条菜单超时消息 */
    rt_timer_init(&s_stTimer,
                  "menu_timer",
                  Menu_TimeoutCallback,
                  RT_NULL,
                  0,
                  RT_TIMER_FLAG_ONE_SHOT | RT_TIMER_FLAG_SOFT_TIMER);

    return RT_EOK;
}
INIT_PREV_EXPORT(MenuSeverInit);

3.3 菜单数据结构

菜单管理层用于管理各个菜单。菜单的数据类型定义如下。

struct Menu
{
    MenuId          eMenuId;            /* 菜单ID */
    rt_uint32_t     orignalTimeout;     /* 菜单原始超时时间 */
    rt_uint32_t     currTimeout;        /* 菜单当前超时时间 */
    MenuEnterCb     MenuEnter;          /* 菜单进入回调 */
    MenuExitCb      MenuExit;           /* 菜单退出回调 */
    msg_map_t       pMap;               /* 消息处理表 */
    rt_uint16_t     mapSize;         	/* 消息处理表项数 */
    struct Menu     *pNext;             /* 指向下一级菜单 */
};
typedef struct Menu *pMenu;
  • eMenuId:菜单的唯一ID。
  • orignalTimeout:每个菜单都有一个默认的超时时间orignalTimeout,默认为10秒。
  • currTimeout:当前超时时间,默认等于orignalTimeout,在运行过程中可以动态修改。
  • MenuEnter:在进入菜单时,回调MenuEnter,类似于构造函数。
  • MenuExit:在退出菜单时,回调MenuExit,类似于析构函数。
  • pMap:菜单的消息处理表,即静态的消息订阅表,每个菜单都有各自的消息订阅表。
  • mapSize:消息处理表的项数。pNext用于指向下一菜单,构成单向链表。

3.4 菜单初始化和切换

下面的代码功能是初始化一个菜单,将菜单插入到菜单链表中。s_pMenuList是全局的链表头,记录了系统中已经初始化的菜单。

/* 初始化一个菜单 */
rt_err_t Menu_Init(pMenu pstMenu)
{
    RT_ASSERT(pstMenu != RT_NULL);
    RT_ASSERT(pstMenu->eMenuId < Menu_Max);
    RT_ASSERT(pstMenu->pMap != RT_NULL);
    RT_ASSERT(pstMenu->u16MapSize != RT_NULL);

    if(Menu_Find(pstMenu->eMenuId) != RT_NULL)
    {
        LOG_E("exist menu");
        return -RT_ERROR;
    }
    if(s_pMenuList == RT_NULL)
    {
        s_pMenuList = pstMenu;
    }
    else
    {
        pstMenu->pNext = s_pMenuList;
        s_pMenuList = pstMenu;
    }
    
    return RT_EOK;
}

Menu_Change实现了菜单的切换,核心就是根据新传入的菜单ID去s_pMenuList中查找,如果查找到了就更换静态订阅表。s_pCurrMenu是一个全局的指针,指向当前正在的运行的菜单。

rt_err_t Menu_Change(MenuId eMenuId)
{
    pMenu pMenu = RT_NULL;
    MenuId lastMenuId = Menu_None;

    /* 当前菜单正在运行,先停止定时器 */
    if(s_pCurrMenu && s_pCurrMenu->currTimeout)
    {
        rt_timer_stop(&s_stTimer);
    }
    
    pMenu = Menu_Find(eMenuId);
    
    /* 查找不到对应的菜单,退出 */
    if(pMenu == RT_NULL)
    {
        if(s_pCurrMenu && s_pCurrMenu->currTimeout)
        {
            rt_timer_start(&s_stTimer);
        }
        return -RT_ERROR;
    }

    /* 设置新的静态订阅表 */
    if(rt_server_set(MENU_SERVER_ID, pMenu->pMap, pMenu->u16MapSize) != RT_EOK)
    {
        return -RT_ERROR;
    }

    /* 调用当前菜单的MenuExit */
    if(s_pCurrMenu && s_pCurrMenu->MenuExit)
    {
        s_pCurrMenu->MenuExit(pMenu->eMenuId);
    }

    LOG_I("menu change to %d",pMenu->eMenuId);
    
    /* 临时记录上一个菜单ID */
    lastMenuId = s_pCurrMenu ? s_pCurrMenu->eMenuId : Menu_None;

    /* s_pCurrMenu指向新的菜单*/
    s_pCurrMenu = pMenu;

    /* 重置currTimeout为orignalTimeout */
    pMenu->currTimeout = pMenu->orignalTimeout;

    /* 跳转到新菜单前启动定时器 */
    Menu_UpdateTimer();

    /* 调用新菜单的MenuEnter */
    if(pMenu->MenuEnter)
    {
        pMenu->MenuEnter(lastMenuId);
    }

    return RT_EOK;
}

3.5 菜单超时

在初始化菜单服务时(MenuSeverInit),超时定时器就被创建好了,这是一个单次的软件定时器。在切换菜单时,菜单便会以orignalTimeout作为当前超时时间启动该软件定时器。下面的代码是定时器超时回调,在该回调中会广播一条菜单超时消息。

/* 定时器超时回调,广播超时消息 */
static void Menu_TimeoutCallback(void *p)
{
    rt_msg_t pMsg = RT_NULL;

    pMsg = rt_msg_create(MENU_TIMEOUT_EVENT_MSG_ID, RT_NULL, 0);
    if(pMsg)
    {
        rt_msg_send_event(pMsg);
    }   
}

在一些事件来临的时候,例如触摸按键事件、语音完成事件等,菜单可以调用Menu_UpdateTimer函数主动重置该定时器重新计时。

void Menu_UpdateTimer(void)
{
    if(s_pCurrMenu)
    {
        rt_uint32_t u32TimeoutTick = 0;

        if(s_pCurrMenu->currTimeout == 0)
        {
            rt_timer_stop(&s_stTimer);
            return;
        }
        
        u32TimeoutTick = rt_tick_from_millisecond(s_pCurrMenu->currTimeout);
        rt_timer_control(&s_stTimer, RT_TIMER_CTRL_SET_TIME, &u32TimeoutTick);
        rt_timer_start(&s_stTimer);
    }   
}

3.6 菜单自动注册

在当前框架下,菜单的个数并不是由数组来实现的,而是由链表来实现的。链表相对于数组的好处就是可以无限扩展,因此菜单是通过注册的方式加入到菜单框架中的。具体的注册方法如下。

static msg_map msgMap[] =
{
    MENU_TIMEOUT_EVENT_MSG_ID,  Menu_TimeoutHandle,          /* 菜单超时事件 */
    TOUCH_EVENT_MSG_ID,         InputTouchEventHandle,       /* 触摸按键事件 */
    FP_EVENT_MSG_ID,            FpDetectHandle,              /* 指纹事件 */
    CARD_EVENT_MSG_ID,          CardDetectHandle,            /* 刷卡事件 */
};

/* 注册输入菜单 */
MENU_INIT(Menu_Input, MENU_TIMEOUT_MS, InputMenuEnter, RT_NULL, msgMap);

MENU_INIT是一个宏,用于定义一个菜单并自动初始化。

#define MENU_INIT(menuId,timeout,enter,exit,map) 		\
static struct Menu Menu##menuId =                       \
{                                                       \
    .eMenuId        =   menuId,                         \
    .orignalTimeout =   timeout,                        \
    .MenuEnter      =   enter,                          \
    .MenuExit       =   exit,                           \
    .pMap           =   map,                            \
    .u16MapSize     =   ARRAY_SIZE(map),                \
};                                                      \
static int Menu##menuId##Init(void)                     \
{                                                       \
    return Menu_Init(&Menu##menuId);                    \
}                                                       \
INIT_APP_EXPORT(Menu##menuId##Init)

3.7 菜单公共订阅

有一些时事件几乎所有的菜单都会关心,例如菜单超时事件、语音播放完成事件、强制锁定事件等,需要让所有菜单都订阅这些事件。方法有两种:一是让所有菜单的都静态订阅这些事件;而是让菜单动态订阅这些事件。因为每个菜单都有个静态订阅表,显然若用静态订阅的方式,将会有很多重复代码。而动态订阅面向的是服务,所有的菜单的服务ID是一样的,因此使用动态订阅的方式仅需要订阅一次,如下函数所示。

/* 订阅菜单超时事件 */
rt_server_subscribe(MENU_SERVER_ID,
                    MENU_TIMEOUT_EVENT_MSG_ID,
                    MenuTimeoutHandle);    
                    
/* 订阅语音播放完成事件 */  
rt_server_subscribe(MENU_SERVER_ID, 
                    VOICE_PLAY_FINISH_EVENT_ID, 
                    VoicePlayFinishEventHandle);     

/* 订阅防拆报警事件 */
rt_server_subscribe(MENU_SERVER_ID, 
					MONITOR_BREAKOUT_START_EVENT_ID, 
					BreakoutStartHandle);      

/* 订阅强制锁定开始事件 */
rt_server_subscribe(MENU_SERVER_ID, 
                    MONITOR_FORCE_LOCK_START_EVENT_ID,
                    ForceLockStartHandle); 

/* 订阅机械按键事件 */
rt_server_subscribe(MENU_SERVER_ID, 
                    BUTTON_MECH_EVENT_MSG_ID, 
                    MechButtonEventHandle);

还有一个好处是静态订阅的优先级高于动态订阅,利用这一特性,我们可以让菜单实现函数“覆写”功能。例如菜单虽然动态订阅了菜单超时消息,但对于空闲菜单,它是不需要这个消息的,那么我们可以让空闲菜单静态订阅菜单超时消息,对应的消息处理函数为空。

3.8 菜单API

/* 初始化一个菜单 */
extern rt_err_t Menu_Init(pMenu pstMenu);

/* 更新菜单超时时间 */
extern void Menu_UpdateTimer(void);

/* 修改菜单的超时时间 */
extern void Menu_SetTimeout(rt_uint32_t milliSecond);

/* 菜单切换 */
extern rt_err_t Menu_Change(MenuId eMenuId);

四、RT-Thread设备IO框架

4.1 设备IO框架介绍

SoftBus仅用于上层业务模块之间的通信,这是一个“左右分离”的概念,但对于上层控制底层设备而言,这里还需要一个”上下分层“的概念。RT-Thread的设备IO框架便是这样的一个上下分层的设备框架。

假如上下不分层,会发生什么?例如当前使用的是汇顶指纹模组,上层代码里调用的全是汇顶指纹模组相关的代码,如果一个新的项目上使用的是其他厂商的指纹模组,这时候必然要进行大量的移植修改工作。鉴于此,我们可以根据指纹模组的共性抽象出一个指纹模组的框架,这个框架对于任何指纹模组都达到通用性,这样上层调用就会是一个标准的接口。

RT-Thread对诸如上述指纹框架进一步抽象,高度抽象为设备。下图是RTT官网的设备IO框架图,可见,上下分层是十分明显的。在实现上,使用了面向对象的编写方式,I/O设备管理层作为基类被设备驱动框架层继承,因此可以可以通过标准的device接口来访问设备,有点类似于多态。

  • I/O设备管理层:高度抽象了各个不同种类的设备,使之可以通过标准的device接口来访问设备。
  • 设备驱动框架层:这是与硬件无关的一层,例如上文提到的指纹模组驱动框架,就处于这一层。
  • 设备驱动层:与硬件相关,实现对应上一层的设备驱动框架层,例如上文提到的汇顶指纹模组驱动。
    嵌入式消息订阅发布模式软件框架_第7张图片

4.2 设备IO框架API

设备IO框架的API如下面的代码所示。在这点上,RTT与Linux驱动中的file_operation十分相似,对设备的控制都是read、write、control等。

/* 初始化设备 */
rt_err_t rt_device_init(rt_device_t dev);

/* 打开设备 */
rt_err_t rt_device_open(rt_device_t dev, rt_uint16_t oflag);

/* 关闭设备 */
rt_err_t rt_device_close(rt_device_t dev);

/* 读设备 */
rt_size_t rt_device_read(rt_device_t dev,
                         rt_off_t    pos,
                         void       *buffer,
                         rt_size_t   size);

/* 写设备 */
rt_size_t rt_device_write(rt_device_t dev,
                          rt_off_t    pos,
                          const void *buffer,
                          rt_size_t   size);
/* 控制设备 */
rt_err_t rt_device_control(rt_device_t dev, int cmd, void *arg);

/* 设置设备的接收完成回调,一般配合rt_device_read使用 */
rt_err_t rt_device_set_rx_indicate(rt_device_t dev,
                          rt_err_t (*rx_ind)(rt_device_t dev, rt_size_t size));

/* 设置设备的发送完成回调,一般配合rt_device_write使用 */
rt_err_t rt_device_set_tx_complete(rt_device_t dev,
                          rt_err_t (*tx_done)(rt_device_t dev, void *buffer));

4.3 串口框架与使用

以串口设备举例,I/O设备管理层对应device.c,串口设备驱动框架对应serial.c,串口驱动对应drv_usart.c。只有drv_usart.c是与芯片相关的,替换芯片时,只需替换drv_usart.c即可。三者的关系如下图所示,图中分别描述了串口设备注册、打开、发送数据、接收数据的过程。在打开设备时,需要指定设备的打开方式,主要是分别指定了发送和接收的方式(是否通过中断、DMA等方式)。
嵌入式消息订阅发布模式软件框架_第8张图片
RT-Thread的FinSH组件就是一个典型的串口设备使用例子。其基本逻辑是设置串口异步通知回调rx_indicate并打开串口,当键盘上有数据输入时,串口中断会回调rx_indicate,在rx_indicate释放信号量来唤醒FinSH线程,FinSH再通过read接口读取一个字符并处理。

4.4 从0开始编写一个设备驱动

虽然RT-Thread官方提供了很多组件和在线软件包,但是仍然无法满足我们的业务需求,因此我们需要学会自己去编写一些设备的驱动,例如指纹模组、FM17550等。

RT-Thread的在线软件包里有很多设备的驱动,但代码质量良莠不齐,有些根本无法使用,例如at24cxx在线包,仅提供了简单的读写,甚至页写入功能都没有。另外,由于电脑上网功能受限,在env中无法下载在线软件包,只能在GitHub上找到对应的包手动下载,比较麻烦。因此我们自己编写的设备驱动是直接放到了component下面的device driver中。

接下来我们从0开始写一个AT24CXX的驱动,以熟悉整个设备驱动的开发流程及RT-Thread的设备IO框架思想。

4.4.1 定义设备数据类型

设备驱动向下提供一个注册函数,向上则是标准的设备IO操作函数。注册函数需要视具体的硬件依赖关系而定。所谓硬件依赖关系是指设备所依赖的硬件资源,确定了硬件依赖关系,也就确定了注册函数的入参。

AT24CXX在硬件上依赖于I2C总线,另外,由于AT24CXX地址取决于外部电路、该系列下有多种容量,不同容量对应的页大小也是不一样的,因此可得出如下设备结构体定义:

struct rt_at24cxx_device
{
    /* inherit from ethernet device */
    struct rt_device                parent;
    struct rt_i2c_bus_device        *i2c_bus;
    rt_uint16_t                     addr;
    rt_uint8_t					  	page_size;
    rt_uint32_t                     capacity;
};
typedef struct rt_at24cxx_device *rt_at24cxx_device_t;

4.4.2 实现设备驱动框架

设备驱动主要是init、open、close、read、write、control这6个函数,这些函数要根据实际情况去完成,有些函数是可以为空的。对于AT24CXX而言,不需要init、open、close、control,只需实现read和write。但RT-Thread设备驱动框架里面限制了设备读写前必须是打开状态,因此这里open函数还是需要的,只是直接返回了打开成功。

具体的函数实现如下,由于篇幅限制,这里就省略了很多具体功能实现的细节,只列出大致的功能步骤。

static rt_err_t at24cxx_open(rt_device_t dev, rt_uint16_t oflag)
{
    return RT_EOK;
}

static rt_size_t at24cxx_write_reg(rt_uint32_t addr, rt_uint8_t *data, rt_uint32_t size)
{
    struct rt_i2c_msg msgs[2];
    rt_uint8_t write_addr[2];

    write_addr[0] = (addr & 0xFF00) >> 8;
    write_addr[1] = (addr & 0x00FF);

    msgs[0].addr = at24cxx_device.addr;
    msgs[1].addr = at24cxx_device.addr;

    msgs[0].flags = RT_I2C_WR ;
    msgs[0].buf = write_addr;
    msgs[0].len = 2;

    msgs[1].flags = RT_I2C_WR | RT_I2C_NO_START;
    msgs[1].buf = (rt_uint8_t*)data;
    msgs[1].len = size;

    if(rt_i2c_transfer(at24cxx_device.i2c_bus, &msgs[0], 2) == 2)
    {
        return size;
    }

    return 0;
}

static rt_size_t at24cxx_read(rt_device_t dev, rt_off_t pos, void *buffer, rt_size_t size)
{
	struct rt_i2c_msg msgs[2];
    rt_uint8_t read_addr[2];

    if((pos + size) > at24cxx_device.capacity)
    {
        return 0;
    }

    read_addr[0] = (pos & 0xFF00) >> 8;
    read_addr[1] = (pos & 0x00FF);

    msgs[0].addr = at24cxx_device.addr;
    msgs[0].flags = RT_I2C_WR ;
    msgs[0].buf = read_addr;
    msgs[0].len = 2;
	
    msgs[1].addr = at24cxx_device.addr;
    msgs[1].flags = RT_I2C_RD;
    msgs[1].buf = (rt_uint8_t*)buffer;
    msgs[1].len = size;

    if(rt_i2c_transfer(at24cxx_device.i2c_bus, &msgs[0], 2) == 2)
    {
        return size;
    }

    return  0;
}

static rt_size_t at24cxx_write (rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size)
{
    if((pos + size) > at24cxx_device.capacity)
    {
        return 0;
    }

	/* 判断是否跨页写入、实现页写入等功能 */

    return size ;
}

4.4.3 注册设备驱动框架

一般在注册函数中需要动态地申请设备结构体内存 ,然后将传入的参数保存在该结构体中。由于AT24CXX一般在一个系统上是单一的设备,这里为了简单,直接定义了一个静态全局设备结构体,注册函数的实现如下。

static struct rt_at24cxx_device at24cxx_device;

#ifdef RT_USING_DEVICE_OPS
const static struct rt_device_ops at24cxx_ops =
{
    RT_NULL,
    at24cxx_open,
    RT_NULL,
    at24cxx_read,
    at24cxx_write,
    RT_NULL,
};
#endif

rt_err_t rt_hw_at24cxx_attach(const char *i2c_device_name, rt_uint16_t i2c_addr,
                              rt_uint32_t capacity, rt_uint8_t page_size)
{
    rt_err_t result = RT_EOK;

    RT_ASSERT(i2c_device_name != RT_NULL);
    RT_ASSERT((i2c_addr != 0) && (i2c_addr != 0xFF))
    RT_ASSERT(capacity);
	RT_ASSERT(page_size);

    at24cxx_device.i2c_bus = (struct rt_i2c_bus_device*)
        					 rt_i2c_bus_device_find(i2c_device_name);

    if (at24cxx_device.i2c_bus == RT_NULL)
    {
        return -RT_ENOSYS;
    }

    at24cxx_device.addr = i2c_addr;
    at24cxx_device.capacity = capacity;
	at24cxx_device.page_size = page_size;

    at24cxx_device.parent.type = RT_Device_Class_Char;
    at24cxx_device.parent.rx_indicate = RT_NULL;
    at24cxx_device.parent.tx_complete = RT_NULL;

#ifdef RT_USING_DEVICE_OPS
    at24cxx_device.parent.ops         = &at24cxx_ops;
#else
    at24cxx_device.parent.init        = RT_NULL;
    at24cxx_device.parent.open        = at24cxx_open;
    at24cxx_device.parent.close       = RT_NULL;
    at24cxx_device.parent.read        = at24cxx_read;
    at24cxx_device.parent.write       = at24cxx_write;
    at24cxx_device.parent.control     = RT_NULL;
#endif
    at24cxx_device.parent.user_data = RT_NULL;

    result = rt_device_register(&at24cxx_device.parent, AT24CXX_DEVICE_NAME, 				 							RT_DEVICE_FLAG_RDWR | RT_DEVICE_FLAG_STANDALONE | 										RT_DEVICE_FLAG_ACTIVATED);

    rt_device_open(&at24cxx_device.parent, RT_DEVICE_OFLAG_RDWR);

    return result;   
}

RT_USING_DEVICE_OPS是RT-Thread设备驱动框架相关的一个宏定义,打开这个宏之后,rt_device基类里面的ops成员就被定义了,后续操作设备是通过ops指向的驱动来调用的,否则就是直接通过rt_device记录的操作函数来调用。

struct rt_device
{
    struct rt_object          parent;                   /**< inherit from rt_object */

    enum rt_device_class_type type;                     /**< device type */
    rt_uint16_t               flag;                     /**< device flag */
    rt_uint16_t               open_flag;                /**< device open flag */

    rt_uint8_t                ref_count;                /**< reference count */
    rt_uint8_t                device_id;                /**< 0 - 255 */

    /* device call back */
    rt_err_t (*rx_indicate)(rt_device_t dev, rt_size_t size);
    rt_err_t (*tx_complete)(rt_device_t dev, void *buffer);

#ifdef RT_USING_DEVICE_OPS
    const struct rt_device_ops *ops;
#else
    /* common device interface */
    rt_err_t  (*init)   (rt_device_t dev);
    rt_err_t  (*open)   (rt_device_t dev, rt_uint16_t oflag);
    rt_err_t  (*close)  (rt_device_t dev);
    rt_size_t (*read)   (rt_device_t dev, rt_off_t pos, void *buffer, rt_size_t size);
    rt_size_t (*write)  (rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size);
    rt_err_t  (*control)(rt_device_t dev, int cmd, void *args);
#endif

#if defined(RT_USING_POSIX)
    const struct dfs_file_ops *fops;
    struct rt_wqueue wait_queue;
#endif

    void                     *user_data;                /**< device private data */
};

特别要注意的是rt_device的flag等成员,例如flag成员的每个bit位都表示了不同的属性,在调用rt_device_open时需要传入设备打开方式,例如接收中断方式打开、DMA接收中断方式打开等,相关的宏定义如下。

#define RT_DEVICE_FLAG_DEACTIVATE       0x000           /**< device is not not initialized */

#define RT_DEVICE_FLAG_RDONLY           0x001           /**< read only */
#define RT_DEVICE_FLAG_WRONLY           0x002           /**< write only */
#define RT_DEVICE_FLAG_RDWR             0x003           /**< read and write */

#define RT_DEVICE_FLAG_REMOVABLE        0x004           /**< removable device */
#define RT_DEVICE_FLAG_STANDALONE       0x008           /**< standalone device */
#define RT_DEVICE_FLAG_ACTIVATED        0x010           /**< device is activated */
#define RT_DEVICE_FLAG_SUSPENDED        0x020           /**< device is suspended */
#define RT_DEVICE_FLAG_STREAM           0x040           /**< stream mode */

#define RT_DEVICE_FLAG_INT_RX           0x100           /**< INT mode on Rx */
#define RT_DEVICE_FLAG_DMA_RX           0x200           /**< DMA mode on Rx */
#define RT_DEVICE_FLAG_INT_TX           0x400           /**< INT mode on Tx */
#define RT_DEVICE_FLAG_DMA_TX           0x800           /**< DMA mode on Tx */

#define RT_DEVICE_OFLAG_CLOSE           0x000           /**< device is closed */
#define RT_DEVICE_OFLAG_RDONLY          0x001           /**< read only access */
#define RT_DEVICE_OFLAG_WRONLY          0x002           /**< write only access */
#define RT_DEVICE_OFLAG_RDWR            0x003           /**< read and write */
#define RT_DEVICE_OFLAG_OPEN            0x008           /**< device is opened */
#define RT_DEVICE_OFLAG_MASK            0xf0f           /**< mask of open flag */

4.4.4 底层注册设备驱动

到了这里,AT24CXX的设备驱动实际已经完成了,但还没有注册到系统中,因为rt_hw_at24cxx_attach函数还没有被调用。这个注册函数需要在图4-1所示的设备驱动层中调用,即drv_at24cxx.c中完成。

#include 

int rt_hw_at24cxx_init(void)
{
    return rt_hw_at24cxx_attach(BSP_AT24CXX_I2C_NAME,
                                BSP_AT24CXX_I2C_ADDR,
                                BSP_AT24CXX_CAPACITY,
                                BSP_AT24CXX_PAGE_SIZE);
}
INIT_DEVICE_EXPORT(rt_hw_at24cxx_init);

drv_at24cxx.c的代码非常简单,就是调用了rt_hw_at24cxx_attach函数向设备管理层注册一个at24cxx设备,并用INIT_DEVICE_EXPORT宏来实现自动初始化。

对于rt_hw_at24cxx_attach函数所需的参数,我们可以在env中通过menuconfig进行配置,这些配置最终会定义在rtconfig.h中。如此一来,当我们更换硬件管脚、更改器件地址等操作时,就不用手动去修改这些宏了。

4.4.5 修改KConfig和SConscript

打开board目录下的Kconfig文件,在下添加如下代码。

	menuconfig BSP_USING_AT24CXX
		bool "Enable at24cxx driver"
		select RT_USING_I2C
		select RT_USING_AT24CXX
		default n
		if BSP_USING_AT24CXX
			config BSP_AT24CXX_I2C_NAME
				string "at24cxx i2c name"
				default "i2c1"
			
			config BSP_AT24CXX_I2C_ADDR
				hex  "at24cxx i2c 7 bit addr"
				range 0x1 0xFF
				default 0x50

			config BSP_AT24CXX_CAPACITY
				hex "at24cxx capacity(hex)"
				default 0x8000
				
			config BSP_AT24CXX_PAGE_SIZE
				int "for at24c256,page size is 64.for at24c16,page size is 16"
				default 64
		
		endif

打开libraries\HAL_Drivers目录下的SConscript文件,添加如下代码。

if GetDepend('BSP_USING_AT24CXX'):
    src += ['drv_at24cxx.c']

打开rt-thread\components\drivers\i2c下的SConscript文件,添加如下代码。

if GetDepend('RT_USING_AT24CXX'):
    src += ['at24cxx.c']

最后打开env工具,在menuconfig中进行配置后重新生成工程( scons --target=mdk5)后,相应的宏被开启,相应的文件被自动添加到工程中。
嵌入式消息订阅发布模式软件框架_第9张图片

五、RT-Thread组件与软件包

RT-Thread有非常丰富的组件与在线软件包,这一章主要介绍一下我们常用的几个组件或软件包。

5.1 FinSH

5.1.1FinSH简介

FinSH 是 RT-Thread 的命令行组件,提供一套供用户在命令行调用的操作接口,主要用于调试或查看系统信息。它可以使用串口 / 以太网 / USB 等与 PC 机进行通信。

用户在控制终端输入命令,控制终端通过串口、USB、网络等方式将命令传给设备里的 FinSH,FinSH 会读取设备输入命令,解析并自动扫描内部函数表,寻找对应函数名,执行函数后输出回应,回应通过原路返回,将结果显示在控制终端上。

当使用串口连接设备与控制终端时,FinSH 命令的执行流程,如下图所示:
嵌入式消息订阅发布模式软件框架_第10张图片
FinSH 支持权限验证功能,系统在启动后会进行权限验证,只有权限验证通过,才会开启 FinSH 功能,提升系统输入的安全性。

FinSH 支持自动补全、查看历史命令等功能,通过键盘上的按键可以很方便的使用这些功能,FinSH 支持的按键如下表所示:

按键 功能描述
Tab 键 当没有输入任何字符时按下 Tab 键将会打印当前系统支持的所有命令。若已经输入部分字符时按下 Tab 键,将会查找匹配的命令,也会按照文件系统的当前目录下的文件名进行补全,并可以继续输入,多次补全
↑↓键 上下翻阅最近输入的历史命令
退格键 删除符
←→键 向左或向右移动标

5.1.2FinSH内置命令

在 RT-Thread 中默认内置了一些 FinSH 命令,在 FinSH 中输入 help 后回车或者直接按下 Tab 键,就可以打印当前系统支持的所有命令。以下为按下 Tab 键后打印出来的当前支持的所有显示 RT-Thread 内核状态信息的命令,左边是命令名称,右边是关于命令的描述:

RT-Thread shell commands:
version         - show RT-Thread version information
list_thread     - list thread
list_sem        - list semaphore in system
list_event      - list event in system
list_mutex      - list mutex in system
list_mailbox    - list mail box in system
list_msgqueue   - list message queue in system
list_timer      - list timer in system
list_device     - list device in system
exit            - return to RT-Thread shell mode.
help            - RT-Thread shell help.
ps              - List threads in the system.
time            - Execute command with time.
free            - Show the memory usage in the system.

5.1.3 FinSH配置

下图是是env中FinSH组件的默认配置,可以根据实际需要进行配置。
嵌入式消息订阅发布模式软件框架_第11张图片

5.1.4 FinSH导出命令

#define MSH_CMD_EXPORT(command, desc)
#define MSH_CMD_EXPORT_ALIAS(command, alias, desc)
  • command:函数名。
  • alias:函数的别名。
  • desc:命令行的描述信息。

使用MSH_CMD_EXPORT导出的命令,命令名即函数名,即command;使用MSH_CMD_EXPORT_ALIAS导出的命令,命令名为alias,alias相当于给命令起了个别名,当函数名很长的时候,就可以用这个宏来起个别名,让导出的命令名简洁一些。

可以使用上面两个宏来导出自定义的函数,例如:

static int hello_test(int argc, const char **argv)
{
    rt_kprintf("hello_test\n");
    return 0;
}
/* 使用以下两个命令导出,任选一个即可 */
MSH_CMD_EXPORT(hello_test, hello test function);
MSH_CMD_EXPORT_ALIAS(hello_test, hello, hello test function)

5.1.5 FinSH的应用

5.1.5.1 查看线程运行情况

使用ps命令可以查看所有线程的运行情况,其中最重要的信息就是线程栈的历史最大使用量。在调试前期,我们难以给线程定义线程所需的栈大小,可以先给一个稍大的值,运行一段时间后使用ps命令查看线程栈使用情况,并据此调整线程栈大小。

msh />list_thread
thread   pri  status      sp     stack size max used left tick  error
-------- ---  ------- ---------- ----------  ------  ---------- ---
tshell    20  ready   0x00000118 0x00001000    29%   0x00000009 000
tidle     31  ready   0x0000005c 0x00000200    28%   0x00000005 000
timer      4  suspend 0x00000078 0x00000400    11%   0x00000009 000
5.1.5.2 动态调试功能
  • 调试电机有时要确定电机该旋转的时间,可以将电机运行时间作为命令行的第二个参数。
  • 我们在强制锁定的时候,可以通过命令行直接解除锁定。
  • 在需要的时候打印核心变量值。
  • 通过命令行直接登记密码,方便测试满负荷的情况。

5.2 SFUD

你是否因为搞不定 SPI Flash 而掉了好多头发?

你是否因为手撸 SPI Flash 驱动而浪费了大量开发时间?

你是否因为突然之间更换 SPI Flash 型号而去找产品打架?

5.2.1 SFUD简介

SFUD 全称 Serial Flash Universal Driver,是一款开源的串行 SPI Flash 通用驱动库。SFUD 开源项目由 armink 大神发起,遵循 MIT 开源协议,代码在 Github 上的托管仓库如下:https://github.com/armink/SFUD。

SFUD主要特点有:

  1. 支持 SPI/QSPI 接口
  2. 面向对象思想编写(同时支持多个 Flash 对象)
  3. 可灵活裁剪、扩展性强
  4. SFUD的资源占用情况非常小:
  • 标准占用:RAM:0.2KB ROM:5.5KB
  • 最小占用:RAM:0.1KB ROM:3.6KB

5.2.1 SFUD的原理

首先需要了解一个概念叫SFDP,支持SFDP标准的Flash芯片会Flash的参数放到一个特定区域内,这些参数包括芯片容量、擦写粒度、擦除命令、地址模式等 。

因此,SFUD的核心就在于通过读取SFDP来获取Flash的具体信息,这些信息包括了Flash的容量、擦写粒度、擦写命令等,由此来达到万能通用的效果。

图5-3是W25Q128数据手册中关于0x5A命令的描述,图5-4则是相关的时序图。想知道自己的FLASH是否支持,在数据手册里搜0x5A即可,一般支持该命令的Flash,都可以用SFUD库来驱动。

读取SFDP参数是先发送0x5A命令,再发0地址,读8个字节,前4个字节是‘S’、‘F’、‘D’、‘P’这4个字符,后面2个字节是SFDP的版本号。读出来如果是SFDP+版本号,会进一步发5A命令,并读08地址,读8个字节,这8个字节会包含FLASH的ID等信息,主要是要获取到PTP,再次发送5A命令,以PTP为地址读取36字节,这36字节包含了FALSH更具体的信息,例如最小擦除颗粒度、擦出命令、容量、写入方式等信息。

SFUD在初始化时会优先读取 SFDP 表参数,如果该 Flash 不支持 SFDP,则查询配置文件 sfud_flash_def.h中提供的 Flash 参数信息表中是否支持该款 Flash。这个表相当于是用户代替了SFDP将Flash参数手动传给SFUD库,从而也能达到让SFUD支持该Flash的效果。

更具体的解析,可参考该文档:https://blog.csdn.net/qq_27575841/article/details/105106753。

嵌入式消息订阅发布模式软件框架_第12张图片

​ 图5-3 W25Q128关于0x5A命令的描述

嵌入式消息订阅发布模式软件框架_第13张图片

​ 图5-3 0x5A命令时序图

5.2.3 SFUD的使用

图5-4是在env中使能SFUD,从图中的结构也可以看出,SFUD是依赖于于SPI或者QSPI接口的。
嵌入式消息订阅发布模式软件框架_第14张图片
虽然SFUD提供了标准的操作Flash的API接口,但实际使用中,我们一般会结合其他组件进行使用,例如结合FAL组件(Flash Abstraction Layer,用于管理Flash设备与分区)、EasyFlash组件来使用。

5.3 EasyFlash

5.3.1 EasyFlash简介

EasyFlash是一款开源的轻量级嵌入式Flash存储器库,主要为MCU提供便捷、通用的上层应用接口,使得开发者更加高效实现基于的Flash存储器常见应用开发,例如:KV数据库、在线升级、日志保存等。

目前EasyFlash已经到达V4.0.0版本,支持以下三大功能:

  1. ENV:小型KV数据库,支持写平衡(磨损平衡)及掉电保护

    EasyFlash不仅能够实现对产品的设定参数或运行日志等信息的掉电保存功能,还封装了简洁的增加、删除、修改及查询方法, 降低了开发者对产品参数的处理难度,也保证了产品在后期升级时拥有更好的扩展性。让Flash变为小型键值(Key-Value)存储数据库。

  2. IAP:在线升级功能

    该库封装了IAP(In-Application Programming)功能常用的接口,支持CRC32校验,同时支持Bootloader及Application的升级。

  3. LOG:无需文件系统,日志可直接存储在Flash上

    非常适合应用在小型的不带文件系统的产品中,方便开发人员快速定位、查找系统发生崩溃或死机的原因。同时配合EasyLogger(我开源的超轻量级、高性能C日志库,它提供与EasyFlash的无缝接口)一起使用,轻松实现C日志的Flash存储功能。

5.3.2 EasyFlash配置

EasyFlash在env中的位置如下:

RT-Thread online packages
	tools packages
		EasyFlash

嵌入式消息订阅发布模式软件框架_第15张图片
EasyFlash的配置主要包括一些功能选择(键值对功能、日志功能、在线升级功能)、Flash擦除粒度、EasyFlash管理的Flash地址等。

5.3.3 EasyFlash移植

首先要在env中开启EasyFlash,如5.3.2小节所示。然后需要完成ef_port.c所需的Flash擦除、写入、读取这三个函数。一般情况下,ef_port.c可以结合FAL组件或SFUD提供的Flash操作接口来使用,在使用内部Flash且没有使用FAL组件的情况下,可以直接使用驱动层的内部Flash操作来实现。

EfErrCode ef_port_erase(uint32_t addr, size_t size);
EfErrCode ef_port_write(uint32_t addr, const uint32_t *buf, size_t size)
EfErrCode ef_port_read(uint32_t addr, uint32_t *buf, size_t size);

5.3.4 EasyFlash的应用

在我们实际业务中,主要用到了EasyFlash的两大功能:ENV和LOG。EasyFlash还导出了一些命令,可以结合FinSH进行使用。

ENV即环境变量,我们可以通过环境变量来设置和读取一些业务上的配置或状态,例如智能锁当前模式、人脸识别功能使能、徘徊报警使能、强制锁定标志等。ENV功能非常灵活和方便,可以动态地新增、修改、删除环境变量,无需再通过新增函数接口操作EEPROM来实现。

LOG功能可以直接将日志按字符串格式记录在Flash中,写入和导出都非常方便,有利于排查一些偶现的疑难问题。

5.4 FlexibleButton

5.4.1 FlexibleButton简介

FlexibleButton 是一个基于 C 语言的小巧灵活的按键处理库。该按键库解耦了具体的按键硬件结构,理论上支持轻触按键与自锁按键,并可以无限扩展按键数量。另外,FlexibleButton 使用扫描的方式一次性读取所有所有的按键状态,然后通过事件回调机制上报按键事件。

该按键库使用 C 语言编写,驱动与应用程序解耦,便于灵活应用,比如用户可以方便地在应用层增加按键中断、处理按键功耗、定义按键事件处理方式,而无需修改 FlexibleButton 库中的代码。

5.4.2 FlexibleButton配置

FlexibleButton在env中的位置如下:

RT-Thread online packages
	miscellaneous packages
		FlexibleButton

嵌入式消息订阅发布模式软件框架_第16张图片
env中FlexibleButton并没有什么配置项,只需要使能即可。

5.4.3 FlexibleButton的应用

FlexibleButton提供了以下API,我们基本上只用到其中的flex_button_register和flex_button_scan。只需注册按键所需的一些信息(例如按键的时间设置、读按键IO、按键触发回调等),然后周期性地调用scan函数进行扫描即可。

int8_t flex_button_register(flex_button_t *button);
flex_button_event_t flex_button_event_read(flex_button_t* button);
void flex_button_scan(void);

在智能锁按键的实际使用中,我们可以在程序中枚举所有可能使用的按键,并支持通过env来配置,在按键的事件回调中利用SoftBus广播按键事件,这样整个按键模块就完全解耦了,并且即使整个文件被添加或删除,编译也不会报错。以下是上述例子的代码。

#include "Button.h"

#ifdef BSP_USING_BUTTON

#define DRV_DEBUG      
#define LOG_TAG "m_button"
#include 

#define BUTTON_SCAN_TIME (20)	/* 20ms扫描一次 */

typedef enum
{
#ifdef BSP_USING_BUTTON_REGISTER	
    BUTTON_REGISTER,
#endif

#ifdef BSP_USING_BUTTON_RESTORE	
    BUTTON_RESTORE,
#endif

#ifdef BSP_USING_BUTTON_TAMP
    BUTTON_TAMP,
#endif

#ifdef BSP_USING_BUTTON_INDOORLOCK	
    BUTTON_INDOORLOCK,
#endif
    BUTTON_MAX,
}BUTTON_E;

typedef struct 
{
	rt_base_t		pin;
    flex_button_t   felxButton;
    KEY_VALUE_E     keyValue;
}Button, *pBuuton;

static Button button[BUTTON_MAX];

/* 按键扫描线程 */
ALIGN(RT_ALIGN_SIZE)
static rt_uint8_t threadStack[BUTTON_THREAD_STACK_SIZE];
static struct rt_thread buttonScanThread;

/* 按键唤醒事件集 */
static struct rt_event buttonEvent;

static rt_uint32_t buttonTick = 0;

#ifdef BSP_USING_BUTTON_REGISTER	
static rt_uint8_t _button_register_read(void)
{
    return (rt_uint8_t)rt_pin_read(BSP_BUTTON_REGISTER_PIN);
}
#endif

#ifdef BSP_USING_BUTTON_RESTORE	
static rt_uint8_t _button_restore_read(void)
{
    return (rt_uint8_t)rt_pin_read(BSP_BUTTON_RESTORE_PIN);
}
#endif

#ifdef BSP_USING_BUTTON_TAMP	
static rt_uint8_t _button_tamp_read(void)
{
    return (rt_uint8_t)rt_pin_read(BSP_BUTTON_TAMP_PIN);
}
#endif

#ifdef BSP_USING_BUTTON_INDOORLOCK	
static rt_uint8_t _button_indoorlock_read(void)
{
    return (rt_uint8_t)rt_pin_read(BSP_BUTTON_INDOORLOCK_PIN);
}
#endif


static void Button_Callback(flex_button_t *button)
{
    pBuuton it = (pBuuton)button->user_data;
    rt_msg_t pMsg = RT_NULL;
    ButtonEventMsg ButtonEventMsg;

	buttonTick = rt_tick_get();

    ButtonEventMsg.value = it->keyValue;
    ButtonEventMsg.event = (button_event_t)button->event;

    pMsg = rt_msg_create(BUTTON_MECH_EVENT_MSG_ID, &ButtonEventMsg, 		
                         sizeof(ButtonEventMsg));
    if(pMsg)
    {
        rt_msg_send_event(pMsg);
    }
}

static void Button_IrqHandle(void *parameter)
{
	buttonTick = rt_tick_get();
	rt_event_send(&buttonEvent, 0x01);
}


static void Button_ThreadEntry(void *parameter)
{
	buttonTick = rt_tick_get();
    while (1)
    {
		if((rt_tick_get() - buttonTick) < rt_tick_from_millisecond(3500))
		{
			flex_button_scan();
			rt_thread_mdelay(BUTTON_SCAN_TIME);  
		}
		else
		{
			LOG_I("wait button irq");
			rt_event_recv(&buttonEvent, 
							0x01, 
							RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR, 
							RT_WAITING_FOREVER,
							RT_NULL);
			LOG_I("button irq");
			buttonTick = rt_tick_get();
		}
    }
}


static int Button_Init(void)
{
	rt_uint8_t i = 0, err = 0;
#ifdef BSP_USING_BUTTON_REGISTER	
	rt_pin_mode(BSP_BUTTON_REGISTER_PIN, PIN_MODE_INPUT_PULLUP);
	button[BUTTON_REGISTER].pin = BSP_BUTTON_REGISTER_PIN;
	button[BUTTON_REGISTER].felxButton.usr_button_read = _button_register_read;
	button[BUTTON_REGISTER].keyValue = KEY_REG;
#endif	

#ifdef BSP_USING_BUTTON_RESTORE		
	rt_pin_mode(BSP_BUTTON_RESTORE_PIN, PIN_MODE_INPUT_PULLUP);
	button[BUTTON_RESTORE].pin = BSP_BUTTON_RESTORE_PIN;
	button[BUTTON_RESTORE].felxButton.usr_button_read = _button_restore_read;
	button[BUTTON_RESTORE].keyValue = KEY_RESTORE;
#endif

#ifdef BSP_USING_BUTTON_TAMP		
	rt_pin_mode(BSP_BUTTON_TAMP_PIN, PIN_MODE_INPUT);
	button[BUTTON_TAMP].pin = BSP_BUTTON_TAMP_PIN;
	button[BUTTON_TAMP].felxButton.usr_button_read = _button_tamp_read;
	button[BUTTON_TAMP].keyValue = KEY_TAMP;
#endif

#ifdef BSP_USING_BUTTON_INDOORLOCK	
	rt_pin_mode(BUTTON_INDOORLOCK, PIN_MODE_INPUT_PULLUP);
	button[BUTTON_INDOORLOCK].pin = BSP_BUTTON_INDOORLOCK_PIN;
	button[BUTTON_INDOORLOCK].felxButton.usr_button_read = _button_indoorlock_read;
	button[BUTTON_INDOORLOCK].keyValue = KEY_INDOORLOCK;
#endif
	
	for (i = 0; i < BUTTON_MAX; i++)
	{
		rt_pin_attach_irq(button[i].pin, PIN_IRQ_MODE_RISING_FALLING, Button_IrqHandle, RT_NULL);
		
		button[i].felxButton.pressed_logic_level = 0;
        button[i].felxButton.user_data = &button[i];
		button[i].felxButton.cb = (flex_button_response_callback)Button_Callback;
        button[i].felxButton.click_start_tick = 100 / BUTTON_SCAN_TIME;
        button[i].felxButton.short_press_start_tick = 500 / BUTTON_SCAN_TIME;
        button[i].felxButton.long_press_start_tick = 3000 / BUTTON_SCAN_TIME;
		button[i].felxButton.long_hold_start_tick = 6000 / BUTTON_SCAN_TIME;

		if (flex_button_register(&button[i].felxButton) == -1)
		{
			LOG_E("flex_button_register err\n");
			return -1;
		}
	}

	rt_event_init(&buttonEvent, "btn_wake", RT_IPC_FLAG_FIFO);

	for (i = 0; i < BUTTON_MAX; i++)
	{
		rt_pin_irq_enable(button[i].pin, PIN_IRQ_ENABLE);
	}

	err = rt_thread_init(&buttonScanThread,
						 "m_button",
						 Button_ThreadEntry,
						 RT_NULL,
						 threadStack,
						 BUTTON_THREAD_STACK_SIZE,
						 BUTTON_THREAD_PRIO,
						 10);
	if (err != RT_EOK)
	{
		LOG_E("button thread init err\n");
		return -1;
	}

	rt_thread_startup(&buttonScanThread);

	return 0;
}
INIT_APP_EXPORT(Button_Init);

#endif

5.5 PM

5.5.1 PM简介

PM,即Power Manage,电源管理组件。RT-Thread 的 PM 组件采用分层设计思想,分离架构和芯片相关的部分,提取公共部分作为核心。在对上层提供通用的接口同时,也让底层驱动对组件的适配变得更加简单。
嵌入式消息订阅发布模式软件框架_第17张图片
主要特点:

  • 基于模式来管理功耗,空闲时动态调整工作模式,支持多个等级的休眠。

  • 对应用透明,组件在底层自动完成电源管理。

  • 支持运行模式下动态变频,根据模式自动更新设备的频率配置,确保在不同的运行模式都可以正常工作。

  • 支持设备电源管理,根据模式自动管理设备的挂起和恢复,确保在不同的休眠模式下可以正确的挂起和恢复。

  • 支持可选的休眠时间补偿,让依赖 OS Tick 的应用可以透明使用。

  • 向上层提供设备接口,如果打开了 devfs 组件,那么也可以通过文件系统接口访问。

当前PM组件提供了以下几种模式,对于我们而言,使用的最多的就是PM_SLEEP_MODE_NONE和PM_SLEEP_MODE_DEEP。此外,PM组件还支持CPU变频,但在我们的应用中并没有用到。

模式 级别 描述
PM_SLEEP_MODE_NONE 0 系统处于活跃状态,未采取任何的降低功耗状态
PM_SLEEP_MODE_IDLE 1 空闲模式,该模式在系统空闲时停止 CPU 和部分时钟,任意事件或中断均可以唤醒
PM_SLEEP_MODE_LIGHT 2 轻度睡眠模式,CPU 停止,多数时钟和外设停止,唤醒后需要进行时间补偿
PM_SLEEP_MODE_DEEP 3 深度睡眠模式,CPU 停止,仅少数低功耗外设工作,可被特殊中断唤醒
PM_SLEEP_MODE_STANDBY 4 待机模式,CPU 停止,设备上下文丢失(可保存至特殊外设),唤醒后通常复位
PM_SLEEP_MODE_SHUTDOWN 5 关断模式,比 Standby 模式功耗更低, 上下文通常不可恢复, 唤醒后复位

5.5.2 PM工作原理

低功耗的本质是系统空闲时 CPU 停止工作,中断或事件唤醒后继续工作。在 RTOS 中,通常包含一个 IDLE 任务,该任务的优先级最低且一直保持就绪状态,当高优先级任务未就绪时,OS 执行 IDLE 任务。一般地,未进行低功耗处理时,CPU 在 IDLE 任务中循环执行空指令。RT-Thread 的电源管理组件在 IDLE 任务中,通过对 CPU 、时钟和设备等进行管理,从而有效降低系统的功耗。

在PM组件中,分别会对以上六种模式进行计数,请求一种模式会使该模式的计数值加1,释放一种模式会使该模式的计数值减1。但具体进入哪种模式还要看优先级,例如当系统同时请求了PM_SLEEP_MODE_NONE模式和PM_SLEEP_MODE_DEEP时,两个模式的计数值都为1,但PM_SLEEP_MODE_NONE优先级更高,所以此时不会休眠,当释放一次PM_SLEEP_MODE_NONE模式时,PM_SLEEP_MODE_NONE对应的计数值为0,PM_SLEEP_MODE_DEEP模式对应的计数值为1,此时允许进入深度休眠模式。

OS Tick补偿机制:在系统空闲时,空闲线程被执行,在空闲线程中系统将会计算下一个线程的唤醒时间,并计算出与当前系统时间的时间差,以此时间差作为LPTIM的定时时间并开启LPTIM,如果在此时间内系统没有被外部唤醒,那么定时时间到之后,系统被LPTIM唤醒,PM组件会将该时间差累加到系统时间上来补偿系统时间;如果在系统休眠期内,系统被外部唤醒,那么LPTIM将立即被停止并计算出LPTIM已经运行的时间,并将这段时间补偿到系统时间上。因此,系统的休眠不会引起系统时间的偏差。
嵌入式消息订阅发布模式软件框架_第18张图片

5.5.3 PM移植

PM主要依赖MCU的两大部分:低功耗定时器和休眠唤醒。如果不需要补偿OS Tick,那么低功耗定时器部分不需要移植。

低功耗定时器驱动在drv_lptim.c中,需要实现以下接口:

  • 启动低功耗定时器

  • 停止低功耗定时器

  • 获取低功耗定时器频率(固定值)

  • 获取低功耗定时器最大Tick值(固定值)

  • 获取低功耗定时器当前Tick值

低功耗定时器的移植部分是比较简单的,本文不详细描述移植过程。需要注意的是,低功耗定时器一般采用内部低速时钟作为时钟源,精度比较低,我们可以利用高速时钟去计算低功耗定时器的实际频率来提高OS Tick补偿的精度。

休眠唤醒驱动在drv_pm.c中,主要需要实现休眠和唤醒之后设置时钟这两大功能,还有一些对drv_lptim.c的封装。以下是FM33平台下的休眠唤醒实现。

static void sleep(struct rt_pm *pm, uint8_t mode)
{
    switch (mode)
    {
    case PM_SLEEP_MODE_NONE:
        break;

    case PM_SLEEP_MODE_IDLE:
        break;

    case PM_SLEEP_MODE_LIGHT:
        break;

    case PM_SLEEP_MODE_DEEP:
        {
            LL_PMU_SleepInitTypeDef LPM_InitStruct;
			
			/* 休眠关闭RCLP */
            LL_RCC_SetSleepModeRCLPWorkMode(LL_RCC_RCLP_UNDER_SLEEP_CLOSE); 
            
            /* 打开PDR */
            LL_RMU_EnablePowerDownReset(RMU); 
            
            /* 关闭BOR 2uA */
            LL_RMU_DisableBORPowerDownReset(RMU);                           
            
            /* 关闭ADC */ 
            LL_ADC_Disable(ADC);         
            
            /* 关闭VREF1p2 */
            LL_VREF_DisableVREF(VREF); 
            
            /* 关闭全部VREFbuf */
            WRITE_REG(VREF->BUFCR, 0);                                      
            
            /* 进入休眠 */
            LPM_InitStruct.DeepSleep = LL_PMU_SLEEP_MODE_DEEP;
            LPM_InitStruct.PowerMode = LL_PMU_POWER_MODE_SLEEP_AND_DEEPSLEEP;
            LPM_InitStruct.WakeupFrequency = LL_PMU_SLEEP_WAKEUP_FREQ_RCHF_8MHZ;
            LPM_InitStruct.WakeupDelay = LL_PMU_WAKEUP_DELAY_TIME_2US;
            LPM_InitStruct.CoreVoltageScaling = DISABLE; 
            LL_PMU_Sleep_Init(PMU, &LPM_InitStruct);

            /* 唤醒后立即配置时钟 */
            SystemClock_Config();
        }
        break;

    case PM_SLEEP_MODE_STANDBY:
        /* Enter STANDBY mode */
        break;

    case PM_SLEEP_MODE_SHUTDOWN:
        /* Enter SHUTDOWNN mode */
        break;

    default:
        RT_ASSERT(0);
        break;
    }
}

需要注意的是,上述代码中,在PM_SLEEP_MODE_DEEP分支中,最好不要加串口打印。因为这个函数在被调用前,已经关闭了中断,但此时systick还在运行,如果打印的内容比较多,systick会发生pending(pending就是中断已经发生了,但还没跳转到中断处理函数),后面一进休眠就会立即退出,因为休眠下被唤醒的原因并不是因为进入了中断服务函数,还是产生了中断pending。从表面上看,会误认为是休眠驱动有问题,但实际上是systick的pending造成的假象。在drv_lptim.c中放了一个低功耗定时器中断服务函数,这个函数是个空函数,也是这个道理,即pending退出休眠,而不是中断服务函数本身。

5.5.4 PM组件应用

在当前架构中,低功耗外设大致可分为两大类:依赖型外设和非依赖型外设。依赖型外设指的是一些外设模块在工作时,必须要求MCU处于正常运行模式下,例如指纹模块等,因为指纹模块与MCU之间可能存在异步通信,MCU无法预知指纹模块何时会有数据发送(低功耗串口除外),因此在指纹模块工作时,MCU也必须保持唤醒状态,同理,Hi3516等主控对于单片机而言也是。非依赖型外设指的是LED、刷卡外设等,因为LED不论亮还是灭,MCU都可以休眠(因为休眠状态下IO电平可以保持不变),刷卡虽然可能用到了SPI或者I2C,但都是MCU做主机外设做从机,不存在异步通信的可能性,即MCU本身只可能在非休眠的情况下操作外设。

对于依赖型外设而言,我们可以使用PM组件来管理外设对MCU的这层依赖关系,例如对于指纹模块,我们可以在对指纹模块上电的同时,请求PM不进休眠,在对指纹模块掉电的同时,释放之前的请求。如下面的代码所示。由于系统在初始化时就请求了DEEP模式,因此只要NONE模式的计数为0,MCU就可以进入休眠状态,当指纹模块上电时,请求了一次NONE模式,由于NONE模式优先级比DEEP模式高,所以MCU不会休眠,直到NONE模式的计数被清零。

static void FpPowerUpCallback(rt_device_t device)
{
	/* 请求进入NONE模式 */
    rt_pm_request(PM_SLEEP_MODE_NONE);
}

static void FpPowerDownCallback(rt_device_t device)
{
	/* 释放NONE模式 */
    rt_pm_release(PM_SLEEP_MODE_NONE);
}

static int FpInit(void)
{
	...

	/* 设置指纹模组上下电回调函数 */
	rt_device_control(fpDev, RT_FP_CMD_SET_POWER_UP_CALLBACK, FpPowerUpCallback);
    rt_device_control(fpDev, RT_FP_CMD_SET_POWER_DOWN_CALLBACK, FpPowerDownCallback);
    
    ...
}
    

对于非依赖型外设,最好的办法就是让外设自身去实现外设自身的低功耗。下面以机械按键举例,例子中使用了5.4小节所述的FlexibleButton。在非低功耗场景下,按键扫描线程可以按照下面这样写,即线程不断地调用flex_button_scan进行按键扫描。

/* 非低功耗场景下的按键扫描线程 */
static void Button_ThreadEntry(void *parameter)
{
    while (1)
    {
        flex_button_scan();
        rt_thread_mdelay(BUTTON_SCAN_TIME);  
    }
}

但有了低功耗需求之后,我们不可能让按键线程一直扫描。由于按键的扫描需要依赖MCU,但按键的检测并不需要依赖MCU是否处于低功耗状态(休眠模式下也支持外部中断),利用这一特性,我们可以让按键线程配合PM组件来实现低功耗,具体代码和分析如下。

首先要知道的是,在PM组件中,有一个宏RT_PM_TICKLESS_THRESH,当系统进入空闲线程后,如果下一线程的唤醒时间与当前时间的时间差小于RT_PM_TICKLESS_THRESH,那么系统将不会进入休眠。 示例中,BUTTON_SCAN_TIME为20,RT_PM_TICKLESS_THRESH是50,因此在按键扫描延时,系统即使进入空闲线程也不会休眠。

基本思路:当有按键按下时才开始扫描,一段时间没有按键按下时,休眠。基于这个思路,我们可以维护一个buttonTick变量,在按键中断和按键事件回调中更新此变量,那么当按键被按下弹起或触发了按键事件后,按键线程就会不停地更新buttonTick,从而避免此时进入休眠。当buttonTick一段时间维持不变之后,不再扫描按键,而是等待按键事件,按键中断中会发送按键事件来唤醒按键扫描线程。这种方法虽然没有调用PM组件的API,但为PM组件的运行提供了便利。

需要注意的是,该例子中按键事件回调的的最大时间差不能大于3500毫秒(可按需调整),例如长按为3秒触发,长按保持为6秒触发,6 - 3 < 3.5,符合要求。如果长按保持改为7秒触发,那么这两个事件的时间差为4秒,可能在长按触发的第3.5秒时,单片机就休眠了,无法触发长按保持回调。

/* 低功耗场景下的按键扫描线程 */
static void Button_ThreadEntry(void *parameter)
{
	buttonTick = rt_tick_get();
    while (1)
    {
		if(past_tick(buttonTick) < rt_tick_from_millisecond(3500))
		{
			flex_button_scan();
			rt_thread_mdelay(BUTTON_SCAN_TIME);  
		}
		else
		{
            /* 死等按键中断释放事件 */
			rt_event_recv(&buttonEvent, 
							0x01, 
							RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR, 
							RT_WAITING_FOREVER,
							RT_NULL);
			buttonTick = rt_tick_get();
		}
    }
}

类似的一些非依赖型的外设还包括触摸按键、FM17550模块、锁体、语音模块等。模块自身实现低功耗,不仅可以减小MCU对功耗管理的难度,还可以减小代码之间的耦合。

六、业务实现

6.1 指纹检测

下图是指纹模组在智能锁上的识别流程。蓝色部分表示指纹模组的上电检测流程,红色部分表示指纹识别的流程。

自检流程:

  1. 首先是drv_offp调用offp提供的注册接口,将底层硬件的参数注册进去,如串口、唤醒IO引脚。
  2. offp属于指纹框架层,会进一步上IO管理器注册设备。
  3. Input线程通过名字可获得指纹模块的设备句柄,设置接收回调,并发送自检命令。
  4. offp给指纹模块发送自检命令,等待应答。
  5. offp收到自检命令应答后将结果保存,并通知上层取数据。
  6. Input线程读取offp保存的数据(自检结果),完成初始化。

识别流程:

  1. 指纹模块检测到中断后,上电、开启串口,并发送识别命令,获取识别结果。
  2. 将识别结果放入缓存,通过rx_indicate回调函数通知上层读取数据。
  3. 上层读取数据后通过SoftBus进行消息广播。
  4. 菜单收到指纹消息后对指纹模块掉电并使能中断,随后发起鉴权等操作,可见5.3小节。
    嵌入式消息订阅发布模式软件框架_第19张图片

6.2 密码输入

密码输入功能主要是靠触摸按键模块、菜单模块、LED模块三者之间的数据通信完成的,密码的鉴权可见5.3小节。

5.1小节中的指纹模块以及包含了对设备层的讲解,因此密码输入涉及的触摸按键模块不再赘述设备层相关的内容。下图是密码输入的流程框架。从图中可以看出,密码输入的流程是单向的,触摸按键模块与菜单之间没有耦合。

触摸按键的休眠和唤醒是在触摸按键模块里的线程直接实现的:当持续三秒没有触摸事件时,直接让触摸按键休眠,当有外部中断时,主动唤醒触摸按键。即触摸按键的低功耗是自身模块维护的,不需要其他模块的干涉。
嵌入式消息订阅发布模式软件框架_第20张图片

6.3 用户鉴权

卡片的检测和密码比较相似,这里就不再赘述的。用户鉴权的流程是密码、卡片、指纹通用的。下图是用户鉴权相关的流程图,其中还包括了强制锁定的检测逻辑。只有错误次数达到上限时,监控服务才会广播强制锁定的消息,并且会启动一个3分钟的定时器,定时器超时后会广播强制锁定解除消息。如果鉴权时发生强制锁定,菜单服务在等待鉴权结果时就会收到强制锁定的消息,但此时还在处理鉴权,因此是处理完鉴权事件后才去处理强制锁定事件。
嵌入式消息订阅发布模式软件框架_第21张图片

七、总结

7.1 与原框架的区别

7.1.1 底层驱动

  1. RT-Thread设备IO框架融合了片内外设(GPIO、串口、SPI等)与片外外设(指纹模块、EEPROM等);HALIF层只覆盖了片内外设,片外外设在SYS层。
  2. 设备IO框架对片内外设的支持程度更高,例如lptimer、低功耗框架等;而HALIF或者SYS当前不支持。
  3. 设备IO框架对配置更灵活,对同一资源可以有不同的配置,例如某引脚可以配置为输出,在低功耗状态下可以配置为输入;HALIF不支持资源的动态配置,如要实现上述功能,需要定义两个引脚资源来实现,比较浪费。
  4. 设备IO框架的上下分层更明显,且功能更强大,例如串口支持了轮询、中断、DMA三种方式,且自带接收buffer,开发业务时无需关心串口接收是如何完成的;HALIF仅是对底层接口的一层封装,功能比较低级,如需实现串口DMA接收功能,还需要对芯片的DMA、接收流程等细节进行编程。
  5. RT-Thread的BSP包目前支持的芯片非常多;HALIF目前只支持STM32。

7.1.2 外设与组件

RTT支持丰富的组件以及Pack包。例如:

FinSh:串口命令行,用于调试程序非常方便,已使用。

CmBacktrace:查找硬件Hardfault原因的利器,必要时使用。

SFUD:万能通用串行FLASH驱动库,已使用。

easyFlash:提供了键值对数据库、日志记录等功能,已使用。

Flexible Button:提供机械按键的短按、双击、长按、弹起等检测,已使用。
嵌入式消息订阅发布模式软件框架_第22张图片
嵌入式消息订阅发布模式软件框架_第23张图片
嵌入式消息订阅发布模式软件框架_第24张图片

另外,RTT支持6个等级的自动初始化,因此片内外设可使用INIT_BOARD_EXPORT宏自动初始化,片外外设(设备)可使用INIT_DEVICE_EXPORT宏自动初始化,这样在跳转到main函数之前,就已经”万事俱备只欠东风“了。

而SYS层没有自动初始化机制,需要在应用层对相应的外设驱动以及底层驱动进行初始化。

7.1.3 菜单框架

  1. 原菜单框架是基于数组的,如下所示,数组的特点是查找快,但扩展性差,因此新的菜单框架是基于单向链表的,方便动态管理。

    static TMenu SystemMenu[] = 
    {
    
        //1、菜单编号      2、超时检测函数     3、事件处理函数         4、菜单初始化函数
      
    	// 待机菜单
    	{rmiIdle,        IdleMenuRun,       IdleEventRun,       IdleMenuShow},									
    	// 密码输入菜单
    	{rmiInput,       InputMenuRun,      InputEventRun,      InputMenuShow}, 
    
    	// 登录选择菜单
    	{rmiMngChoose,	 MngChooseMenuRun,	MngChooseEventRun,  MngChooseMenuShow},
    	/* ... */
    };
    
  2. 原菜单框架在代码上非常密集,几乎所有的菜单事件处理都写到了同一个文件里,编写和查阅代码十分难受;新框架在文件分布上是独立的,每个菜单都是单独的一个c文件,每个菜单都可以使用MENU_INIT宏自动初始化并注册到菜单链表中。
    嵌入式消息订阅发布模式软件框架_第25张图片

  3. 调整了菜单个数,免扰模式和通道模式不再作为菜单处理,而是作为一种工作模式处理,减少了很多重复性代码。

  4. 新菜单框架在切换前,会调用该菜单的exit函数,并传入下一个菜单的ID;切换后,会调用切换后的菜单的enter函数,并传入上一个菜单的ID。类似于构造函数和析构函数。

7.1.4 业务逻辑

  1. 秉承”驱动只提供机制,不提供策略“原则,例如原框架会在触摸按键回调中进行一些业务层的处理,现在统一改为触摸按键设备驱动只发出SoftBus事件消息,由菜单捕获并处理;原先刷卡模块会提供解密错误等回调函数,在回调函数中直接处理业务,现在统一改为发送SoftBus事件消息,交由上层统一处理。
  2. 业务模块分工更明确。例如以前的强制锁定逻辑,是菜单调用错误次数判断,并进行锁定。现在是数据库服务在鉴权失败时主动发出鉴权失败的消息,由一个监控服务捕获该消息,如果达到了错误次数,则广播一条强制锁定的事件消息,产生报警,菜单捕获该消息后跳转到空闲菜单。低电量的实现也类似。
  3. 一些功能的开关使用easyflash的键值对功能(环境变量)来实现,非常容易新增和修改配置。而原先都是保存在EEPROM中,而且还需要提供一对读写函数,比较麻烦,扩展性也很差。

7.1.5 工程配置

  1. RTT支持使用Env进行配置,Env可以解决一些文件的依赖关系,并且自动生成rtconfig.h,无需手动修改。RTT使用Scons构建工具,自动生成工程并加载依赖的相关文件,无需手动添加或删除文件。SYS和HALIF需要手动去修改配置头文件,并手动添加工程文件。
    嵌入式消息订阅发布模式软件框架_第26张图片
    嵌入式消息订阅发布模式软件框架_第27张图片
    嵌入式消息订阅发布模式软件框架_第28张图片
    嵌入式消息订阅发布模式软件框架_第29张图片
  2. 将rt-thread内核、libraries、packages等文件作为基线SVN的库,其他项目引用这些路径,防止库代码被修改。原框架没有使用该功能,HALIF和SYS层都有被修改的迹象,后期维护困难。

7.2 现存问题及对策

7.2.1 服务主动性问题

服务只能被动接受消息,没有主动的操作,灵活性比较差。

这里提供一种思路,仅供参考。核心思路是让服务线程不要死等消息,而是在没有消息的情况下能主动去处理一些用户行为,即在服务线程中再内嵌一个类似协程的函数体。对rt_server进行改造,添加协程回调函数和回调函数入参。

typedef struct 
{
    rt_uint32_t             server_id;                          /* 服务ID */
    char                    *name;                              /* 服务名字 */
    msg_map_t               map;                                /* 消息处理表 */
    rt_uint16_t             map_size;                           /* 消息处理表的项数 */
    rt_uint16_t             stack_size;                         /* 服务的栈大小 */
    rt_uint8_t              prio;                               /* 服务的优先级 */
    void                    (*coroutine)(void *parameter);      /* 服务协程 */             
    void                    *parameter;                         /* 协程参数 */ 
}rt_server, *rt_server_t;

然后再改造服务线程的函数体,如下所示。else分支就是原先死等消息的情况,这里多了一种if分支,也就是在用户创建服务时,如果定义了协程,那么就直接执行用户的定义的协程,同时也会去尝试性地获取消息信号量(注意是非阻塞的)。同时考虑到协程中可能需要休眠等操作,而休眠又会引起服务不能及时地处理消息,因此还需要一个特殊的延时函数rt_server_mdelay。rt_server_mdelay的实现如下,它只能被用在协程中。

static void _rt_server_entry(void *p)
{
    _rt_server_t server = (_rt_server_t)(p);

    if(server->server.coroutine)
    {
        for (;;)
        {
            server->server.coroutine(server->server.parameter);
            if(!rt_sem_take(&server->msg_sem, RT_WAITING_NO))
            {
                _msg_proc(server);
            }
        }        
    }
    else
    {
        for (;;)
        {
            rt_sem_take(&server->msg_sem, RT_WAITING_FOREVER);
            _msg_proc(server);
        }
    }
}

void rt_server_mdelay(rt_uint16_t msec)
{
    rt_int32_t wait_tick = 0;
    rt_tick_t timeout_tick = 0;
    _rt_server_t server = (_rt_server_t)(rt_thread_self()->parameter);

    wait_tick = rt_tick_from_millisecond(msec);     /* 计算剩余等待时间 */
    timeout_tick = rt_tick_get() + wait_tick;       /* 计算超时时间 */

    while(wait_tick > 0)
    {
        if(!rt_sem_take(&server->msg_sem, wait_tick))
        {
            _msg_proc(server);
        } 

        wait_tick = timeout_tick - rt_tick_get();
    }
}

这样做有利有弊,利当然是解决了服务的主动性问题,弊则是协程的使用依然有较多的限制,例如除了rt_server_mdelay,不能使用其他阻塞函数,否则会导致消息处理不够及时。协程只能去处理一些简单的事情。

7.2.2 中断与发送消息

RT-Thread的设备IO框架中,rx_indicate回调可能是在中断里回调的,但中断不能申请内存,也就不能使用SoftBus来发送消息。例如wt588f音频芯片驱动就存在这个问题,当播放完成时会在中断回调rx_indicate,而

这种情况下,解决方法有两个。一是修改驱动,让rx_indicate在线程中回调,例如改成在驱动的中断里释放信号量,在驱动的线程里捕获信号量再调用rx_indicate。二是修改业务,专门开一个线程来实现方法一中的目的,原理和方法一是一样的,例如现在的Input线程。

7.2.3 菜单问题

问题:菜单有些地方没有考虑到未来可能的变化。例如现在没有人脸识别功能,以后肯定会有锁具备人脸识别功能,这时候就需要有对应的登记、删除等菜单。

思路:对容易引起变化的部分进行抽象,或对这部分使用Menuconfig来进行配置。本着开闭原则,我们可以在Menuconfig中再添加一个针对菜单的配置项,通过这个配置可以使能或关闭某些菜单。即我们在代码上实现一个全功能的业务,然后根据具体业务需求通过Menuconfig来配置菜单。对应的灯也需要菜单配置的变化而变化,例如人脸一般对应的是LED_NUM4,一般在每个菜单下都有一个LED_MASK,可以根据是否有人脸菜单来决定LED_MASK中是否应该包含LED_NUM4。

7.2.4 登记键问题

问题:有些锁是通过触摸按键0号键来发起登记的,而有些锁是通过机械按键来发起登记的,这会引起业务上很多处的不同。

思路:我们可以默认支持机械按键登记,也可以通过Menuconfig使能0号键登记来解决这个问题。在代码上需要考虑两者的兼容性和通用性。

7.2.5 组合开门问题

问题:组合开门有两大问题,一是代码实现上不复杂但要涉及的地方较多;二是组合开门的方式可能多种多样,例如密码+指纹,指纹+人脸,而来还可能会有密码+卡片等组合,我们需要充分考虑组合开门功能的通用性。

思路:不要在代码中写明具体是哪些组合方式。通过Menuconfig配置组合方式。在代码中定义相关的接口,例如这些接口包含如何提示用户进行识别(是密码就提示验证密码,是指纹就提示验证指纹)、识别成功后如何提示等,每种组合各自实现这些统一的接口,在初始化时实例化这些接口,后续的运行都依赖于接口,而不是具体的细节实现。

7.2.6 设备匹配问题

问题:菜单中依赖于许多设备,例如指纹设备、刷卡设备等。在菜单初始化时需要指定这些设备。目前设备是直接通过名称来查找的,显然违反了依赖倒置原则,菜单里面不应该体现细节。而且以后有极大可能会出现程序需要兼容多种模块的需求,例如同时兼容邦荣指纹和汇顶指纹。

思路:上述问题可分为两点,一是设备查找问题,二是设备兼容问题。

  1. 对于问题一,我们还是可以利用Menuconfig来配置,即在业务配置层里增加一个设备名配置项,这样就可以和底层设备进行名称匹配。
  2. 对于问题二,我们可以维护一个输入设备容器和输出设备容器,例如我们在指纹初始化时,可以先对邦荣指纹模块进行自检,如果自检失败就对汇顶指纹模块进行自检,自检成功后添加到输入设备容器中,菜单在初始化时,通过访问输入设备容器中的指纹类设备来获得对应的设备句柄,从而避免了在菜单中根据具体的设备名查找(菜单根本不知道接的是邦荣指纹还是汇顶指纹,它只知道是个指纹类设备)。总结一下:菜单依赖于抽象,模组自检时确定了抽象所依赖的细节,输入输出管理容器建立了抽象与依赖的关系(用细节实例化抽象)。目前菜单还依赖于输入管理容器,这里也是不对的,菜单是一个库,应该把输入管理容器也放到菜单层。

7.3 开发原则

在模块化编程中,团队分模块并行开发是必然的,为了实现模块间的解耦、提高代码质量,这里引入六大开发原则。

开闭原则告诉我们用抽象构建框架,用实现扩展细节:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。

7.3.1 开闭原则

开闭原则即当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。用抽象构建框架,用实现扩展细节。

我们在写代码的时候要充分考虑开闭原则,考虑哪些是不变的,哪些会变的。将相对稳定不变的代码固定为流程;将相对易变的代码写成抽象接口,方便日后修改。

例如我们的菜单框架,按登记键后会固定跳转到登录选择菜单,这基本是固定不变的,这就是流程。进入登录选择菜单后,我们需要点亮1和2号灯。由于每把锁的硬件不同,有的可能是直接使用GPIO驱动LED,有的可能是通过3218芯片驱动。因此这里我们将点灯进行抽象。抽象的方式有很多种,例如回调函数等。这里我们通过SoftBus发送点灯请求来完成抽象这一动作。至于具体如何点亮对应的灯,这些就属于细节了。这样做的好处无疑是巨大的,更换硬件时,无需修改菜单,只需修改点灯的驱动即可。

开闭原则是六大原则的核心,实际上六大法则是相辅相成的,也有些是有矛盾的。我们在编写程序时可能无意中就遵循了这些原则,编程设计的答案永远不是唯一的,但遵循这些原则可以让我们尽可能地写出“漂亮”的代码。

7.3.2 单一职责原则

单一职责原则定义是:不要存在多于一个导致类变更的原因。通俗地说,即一个类只负责一项职责。

这么说也许比较抽象,因为C语言本身是面向过程的,但面向对象是一种思想,而不受限于语言。单一职责同样适用于C语言,例如模块化编程。

举个例子,在原先的智能锁框架中,低功耗是通过轮询每个模块的busy接口来实现的,那么每当需要新增一个模块时,就都要在低功耗轮询这里再加一个轮询。甚至有些模块的低功耗存在互相依赖关系,加大了模块间的耦合。正确的做法应该是每个模块自己去维护的自身的低功耗,从而避免模块间耦合和不必要的轮询。

再例如原先的按键模块,我们在按键事件回调里进行了一些其他业务的操作,这无疑是将其他业务掺和进了按键模块,有了强耦合。SoftBus框架的做法是在按键事件回调中广播按键事件消息,由感兴趣的订阅者自身捕获消息并处理,这样便解除了耦合。

7.3.3 里氏替换原则

里氏替换原则即在使用基类的地方,改为使用子类来替换基类来实现不同的功能。

在C语言中,没有“类”这一概念,都是使用结构体来代替类。里氏转换实际上是C语言结构体的一大特性:结构体第一个成员的地址等于结构体的地址。一般在C语言中,“继承”是通过子类的第一个成员是基类来实现的,因为子类的地址和子类中基类的地址是相同的。

里氏转换在RT-Thread的设备IO框架里非常常见,例如我们在调用rt_device_control时传入的设备句柄,会在具体实现的地方被转换成该类的子类,从而调用子类中独有的一些方法。这样做的好处便是对于上层接口而言,设备驱动框架的API的入参保持了统一,但每个设备的功能都是独特的。这也就是多态的特性。

我们在编写程序时,应注意提高自己对事物的抽象能力,抽象能力不仅包括了字面意义上的抽象能力,还包括对以后功能扩展方便性的考虑。

7.3.4 依赖倒置原则

依赖倒置原则:

  1. 高层模块不应该依赖低层模块,两者都应该依赖其抽象。
  2. 抽象不应该依赖细节。
  3. 细节应该依赖抽象。

高层模块不应依赖低层模块。这点其实比较好理解。例如我们的菜单框架和指纹模组驱动。以往我们因为指纹模块多种多样,有邦荣、汇顶等,但我们分别实现了邦荣指纹和汇顶指纹的驱动,并在不同的项目(一个用了邦荣、一个用了汇顶)里分别直接调用他们的API。这就造成了代码的复用性极低,当需要更换指纹模组时,调用这些API的地方都需要修改一遍,因为这种情况已经是强耦合了。

本着面向接口编程的原则,我们可以利用RT-Thread设备IO框架将将邦荣指纹和汇顶指纹统一抽象为指纹设备,对上层提供统一的操作命令(详见fp.h),这样指纹模块就被抽象了,抽象的指纹模块时不依赖于它是何种指纹模块的,即抽象不依赖于细节。不同指纹模块的实现方法都不相同,但他们都是参照着我们抽象出来的指纹驱动来写的,即细节依赖于抽象——抽象指明了目标和结果,怎么实现要靠细节。

对于抽象不依赖于细节这一点,这里着重强调一下RT-Thread的设备IO框架。在Menuconfig中我们经常能看到一些宏,例如“RT_USING_XXX”、“BSP_USING_XXX”,这里我们可以把“RT_USING_XXX”看成是抽象、“BSP_USING_XXX”看成是细节。因为“RT_USING_XXX”宏所对应的文件一般是一个抽象的功能,“BSP_USING_XXX”对应的文件一般是某个设备具体的实例对象。另外,在相应的Kconfig中,选中“BSP_USING_XXX”会通过select来选中“RT_USING_XXX”,从这也可以看出他们之间的关系。

曾经还在我们团队的代码中看到设备驱动框架中出现了某个具体的GPIO引脚,这无疑是严重错误的,因为这已经违反了抽象不应该依赖于细节这一原则。

依赖倒置原则是六大开发原则里面最难实现也是最重要的原则,但只要记住依赖倒置原则的本质是面向接口编程即可。面向接口编程是实现开闭原则的重要手段。

7.3.5 接口隔离原则

接口隔离原则:类和类之间应该建立在最小接口的上。

接口隔离原则容易和单一职责混淆,接口隔离原则侧重于方法设计的专业性(尽可能少),单一职责原则侧重于接口职责的单一性。接口隔离原则和单一职责原则有所冲突,应首先保证单一职责原则。

我们可以将接口隔离原则简单地理解为模块与模块之间的依赖应尽可能颗粒化、函数的入参应尽可能的少。如果设计的过于臃肿,在需求变化时将会非常被动。因为开闭原则要求只増不改,如果一开始就设计的很臃肿,后期很难做到只增不改。

这里举个例子,SoftBus中有一条请求消息是请求鉴权,在卡片登记时,需要验证当前卡片是否登记过,此处会发起鉴权,鉴权失败多次后会导致锁定,即多次登记未登记过的卡片会导致锁定。解决方法有二:一是在请求时告诉接收方即使鉴权失败也没关系,不要锁定;二是另起一条不同的消息,接收方收到这条消息只是去验证不会导致锁定。哪种更好呢?从接口隔离原则角度来看,显然是第二种方法更好,颗粒度更细。对于方法一,如果后续再来一个配置项,就会导致一条消息携带多条配置,无论是发送方还是接收方的处理复杂度都会成倍增加。

7.3.6 迪米特原则

迪米特原则:一个对象应当对其他对象有尽可能少地了解,简称类间解耦。

迪米特原则应该是最好理解的一个原则。一个类尽量减少自己对其他对象的依赖,原则是低耦合,高内聚,只有使各个模块之间的耦合尽量的低,才能提高代码的复用率。

还是以按键事件举例,菜单模块关心的只是按键事件本身,并不关心按键的种类(机械按键还是触摸按键)、按键如何触发(长按2秒还是长按3秒),使用SoftBus很好地减小了菜单模块与按键模块之间的依赖关系。

以菜单和蜂鸣器举例:菜单直接调用蜂鸣器接口是强耦合的,因为这样就意味着菜单是默认蜂鸣器是存在的。而事实上有些锁并没有蜂鸣器,可能是使用一小段语音来代替蜂鸣器。这时候改为使用SoftBus发送单向请求就比较合适,这样菜单就无须知道蜂鸣器是否存在,也无需知道蜂鸣声是如何发出的。

在编写代码时,我们需要尽最大可能地减少全局变量的使用,如果非要使用全局变量也尽可能地在单一模块内使用(加static修饰),尽可能地使用各种设计模式来避免全局变量的使用。

你可能感兴趣的:(STM32,软件框架,RTOS,软件框架,消息订阅者,解耦框架)