嵌入式编程模块化—— 君子协定

【说在前面的话】


在本系列的前一篇文章《嵌入式编程模块化——6张图来解析实用型Service模型》中,我们介绍了一种模块化封装的模型——Service模型。该模型的设计理念实际上服务于一个叫做“黑盒子哲学”的设计思维,其核心思想是:

  • 将模块视作一个黑盒子:模块的设计者不用向外透露黑盒子的实现细节;同时模块的使用者也无法看到黑盒子的内部

  • 模块的设计者和模块的使用者完全通过“接口”来进行约定和沟通。这里所有的接口约定都是通过接口头文件来进行描述和传递的。

  • 接口(及接口头文件)遵循“最小信息公开原则”,即,任何跟使用模块所提供的服务无关的、或者非必要(可有可无)信息都应该从接口头文件中删除。

实践中,要想实现黑盒子,我们实际上要完成两大任务:

  1. 如何隐藏模块的实现,或者说隐藏源代码;

  2. 接口头文件中数据结构的保护,或者说如何阻止用户绕开模块所提供的API而直接访问关键结构体的内部(私有)成员

对于第一条来说,我们只需要把模块编译成library,连同接口头文件一起提供给客户使用就可以做到;而对于第二条要想实现起来却并非那么简单——虽然我们常常说C语言可以通过结构体来模拟类的概念,但它却无法像C++的类那样提供对私有(private)和受保护(protected)成员的隐藏。换句话说,在实践“最小信息公开原则”的时候,如果用户调用服务的时候,确实需要用到结构体(这个结构体是最小信息),如何防止结构体的定义信息被“非法使用”,就成了一个切实的难题。

为了让后续的讨论更为清晰,我们不妨具体的定义一下我们的任务:

  • 只允许用户使用结构体的大小对齐信息——这样用户可以自由的定义变量,或是通过malloc这样的函数进行动态分配;

  • 以某种“通过实际手段强制了的君子协定”的形式——仅在语法层面——阻止用户直接访问结构体的成员。

要想同时做到以上两点,离不开今天索要介绍的主角:掩码结构体(Masked Structure)。

【什么是掩码结构体】


要想理解掩码结构体,抛开复杂和抽象的文字描述,我们不妨来看一个具体的例子:假设我们做了一个字节队列的模块,其中最核心的结构体 byte_queue_t 的定义如下:

typedef struct byte_queue_t byte_queue_t;
struct byte_queue_t {
    uint8_t *pchBuffer;
    uint16_t hwSize;
    
    uint16_t hwHead;
    uint16_t hwTail;
    uint16_t hwCount;
};

针对这一结构体(或者叫类)我们提供一系列API(或者叫类的方法),比如:

typedef struct byte_queue_cfg_t {
    uint8_t *pchBuffer;
    uint16_t hwSize;                       
} byte_queue_cfg_t;


extern
byte_queue_t * byte_queue_init(byte_queue_t *ptObj, byte_queue_cfg_t *ptCFG);


extern 
bool byte_queue_enqueue(byte_queue_t *ptObj, uint8_t chByte);


extern
bool byte_queue_dequeue(byte_queue_t *ptObj, uint8_t *pchByte);


extern
uint_fast16_t byte_queue_count(byte_queue_t *ptObj);

为了保证模块的正常工作,防止运行期间,用户为了自身的便利,直接”外科手术式的“访问 byte_queue_t 的成员导致不必要的问题(比如用户说:我知道你遵循的是最小信息公开原则,也就是说,只要你放了结构体在接口头文件里,我当然理解为我可以任意使用咯?),我们想将整个 byte_queue_t 都保护起来——这就好比,我们试图引入一个“蒙版”,遮住结构体的成员信息然后在客户的耳边念起魔咒:

你什么都看不到,你看到了也没法用……

你什么都看不到,你看到了也没法用……

你什么都看不到,你看到了也没法用……

...

要想实现这样的“蒙版效果”其实并不困难,只需要知道要屏蔽的部分实际占用memory的大小,再根据这一大小来定义数组即可,因此,我们可以修改对应的定义为:

typedef struct byte_queue_t byte_queue_t;


struct __byte_queue_t {
    uint8_t *pchBuffer;
    uint16_t hwSize;
    
    uint16_t hwHead;
    uint16_t hwTail;
    uint16_t hwCount;
};


struct byte_queue_t {
    uint8_t chMask[sizeof(struct __byte_queue_t)];
};

这里,我们实际上是给原来的类型重命名为__byte_queue_t,并建立了一个内部只使用数组来“滥竽充数”的替身——也就是我们所说的掩码结构体。

如果你看过我之前的文章《漫谈C变量——对齐(3)》,你会注意到,上述替身实际上丢失了结构体 __byte_queue_t 的对齐信息——容易注意到 struct __byte_queue_t 的结构体整体是对齐到 4 字节的,而掩码结构体中数组chMask本身是对齐到字节的——这会导致当用户使用掩码结构体来定义变量时,由编译器分配的空间可能无法满足原结构体对对齐的要求,造成非对齐访问——轻则性能下降,重则hardfault。

要解决这一问题也并不复杂,只需要借助GCC扩展的运算符 __alignof__() 提取目标类型的对齐信息,再使用 __attribute__((aligned())) 来设置掩码数组的对齐要求就可以了:

typedef struct byte_queue_t byte_queue_t;


struct __byte_queue_t {
    uint8_t *pchBuffer;
    uint16_t hwSize;
    
    uint16_t hwHead;
    uint16_t hwTail;
    uint16_t hwCount;
};


struct byte_queue_t {
    uint8_t chMask[sizeof(struct __byte_queue_t)] 
        __attribute__((aligned(
            __alignof__(struct __byte_queue_t)
        )));
};

至此,掩码结构体 byte_queue_t 拥有了和原本的结构体 struct __byte_queue_t 一样的尺寸和对齐;同时还在“语法”层面阻止了用户直接访问结构体成员的可能(当然,这也只能防君子不防小人),我们原本设立的两个目标都已成功达成。然而,聪明的你会在脑海里浮现出一个疑问——要想掩码结构体能正常工作,上述信息都必须放置到接口头文件中,难道用户是傻子,看不到结构体 __byte_queue_t 么?

借助宏的力量,我们可以成功的隐藏住 struct __byte_queue_t 的存在。


下面的宏只是为了演示一种简单的实现方法,暂时的打消你的疑虑,而实际在后面我们将要介绍的PLOOC模板中所使用的技法则更为复杂。由于本文只是着重于实际工程实践中如何简单的应用掩码结构体,而不在于介绍复杂的宏技巧,因此我们将不在讨论 PLOOC的实现细节。


#define declare_class(__name)     \
    typedef __name __name;        


#define def_class(__name, ...)                   \
    struct __##__name {                          \
        __VA_ARGS__                              \
    };                                           \
    struct __name {                              \
        uint8_t chMask[sizeof(struct __##__name)]\
            __attribute__((aligned(              \
                __alignof__(struct __##__name)   \
            )));                                 \
    };
    
/* 这只是一个为未来预留的语法糖 */
#define end_def_class(...)
    
#define class_internal(__obj_ptr, __ptr, __type) \
    struct __##__type * __ptr =                  \
        (struct __##__type *)(__obj_ptr)

借助上述宏,我们可以将接口头文件 byte_queue.h 中代码简化为:

...
declare_class(byte_queue_t)


def_class(byte_queue_t,
    uint8_t *pchBuffer;
    uint16_t hwSize;
    
    uint16_t hwHead;
    uint16_t hwTail;
    uint16_t hwCount;
)


end_def_class(byte_queue_t)
...

而模块源代码中,则可以使用 class_internal() 来获取原本的结构体类型:

...
#include "./byte_queue.h"
...


#undef this
#define this    (*ptThis)


bool byte_queue_enqueue(byte_queue_t *ptObj, uint8_t chByte)
{
    /* initialise "this" (i.e. ptThis) to access class members */
    class_internal(ptObj, ptThis, byte_queue_t);
    ...
    if (    (this.hwHead == this.hwTail)
        &&  (0 != this.hwCount)) {
        //! queue is full
        return false;
    }
    ...
}

【如何使用PLOOC来简化开发】


PLOOCProtected Low-overhead Object-Oriented programming with ANSI-C 的英文缩写,意为:为(类)提供保护的、低开销的、面向对象C语言开发。它是我在 Github 上的一个开源项目(https://github.com/GorgonMeducer/PLOOC)。PLOOC 是目前已知唯一使用掩码结构体对私有(private)和受保护(protected)的成员提供隐藏的OOPC模板;除此以外,通过几近于0的额外资源消耗来实现面向对象封装特性,也是PLOOC的一大卖点。

虽然PLOOC自带的 MDK 例子工程演示了常见的面向对象特性,但处于时间问题,仍然没有来得及提供一份简单直接的手把手使用教程。这里我们仍然以 byte_queue_t 为例,为大家介绍一下如何在自己的工程中部署 PLOOC,并应用到 service模型中。

准备阶段

  • 从Github上下载最新的 release 版本。

  • 解压缩后重命名目录为 PLOOC,并复制到你的目标工程中

  • 在你的工程中添加对PLOOC目录的引用

  • 在工程配置中打开对 C99 的支持,如果可能,直接开启 C11和GNU扩展的支持:

  • 如果你使用的是 gcc, clang 或是 arm compiler 6,你还需要打开对微软扩展的支持(-fms-extensions)并屏蔽一些恼人且无害的 warning:

-fms-extensions -Wno-microsoft-anon-tag -Wno-empty-body

NOTE:如果你使用的是 arm compiler 6,在开启微软扩展以后,还需要额外定义一个宏 _MSC_VER 来避免底层库中的一些不必要的编译错误。

至此,我们就完成了 PLOOC 在你工程中的部署。

如何在模块中部署

仍以 byte_queue 模块为例,假设你已经根据 service 模型构建好了目录结构:

  • 打开接口头文件 byte_queue.h 并在靠近结构体定义的地方其中添加以下内容:

/*! \NOTE: Make sure #include "plooc_class.h" is close to the class definition 
 */   
#if     defined(__BYTE_QUEUE_CLASS_IMPLEMENT)
#   define __PLOOC_CLASS_IMPLEMENT__
#elif   defined(__BYTE_QUEUE_CLASS_INHERIT__)
#   define __PLOOC_CLASS_INHERIT__
#endif   


#include "plooc_class.h"

这里,我们定义了两个很重要的宏 __BYTE_QUEUE_CLASS_IMPLEMENT 和 __BYTE_QUEUE_CLASS_INHERIT__。容易看出,他们分别是根据 

__<模块名称>_CLASS_IMPLEMENT

和 

__<模块名称>_CLASS_INHERIT__

的形式改写而成的。前者的作用是给 C 源代码标记“我是这个类的实现,我是类的主人”的身份用的;后者的作用是给 C代码标记“我是派生类的实现,我派生自基类”。具体使用方法,后面会具体介绍。

需要特别强调的是,一定不要忘记在接口头文件的尾部将这两个宏都undef掉

...
#ifndef __PLOOC_EXAMPLE_BYTE_QUEUE_H__
#define __PLOOC_EXAMPLE_BYTE_QUEUE_H__
...


/*! \NOTE: Make sure #include "plooc_class.h" is close to the class definition 
 */   
#if     defined(__BYTE_QUEUE_CLASS_IMPLEMENT)
#   define __PLOOC_CLASS_IMPLEMENT__
#elif   defined(__BYTE_QUEUE_CLASS_INHERIT__)
#   define __PLOOC_CLASS_INHERIT__
#endif   


#include "plooc_class.h"
...




/* 头文件的尾部 */


/*! \note it is very important to undef those macros */
#undef __BYTE_QUEUE_CLASS_INHERIT
#undef __BYTE_QUEUE_CLASS_IMPLEMENT__


#endif


  • 在 byte_queue.h 里定义目标类:

//! \name class byte_queue_t
//! @{
declare_class(byte_queue_t)


def_class(byte_queue_t,


    private_member(
        uint8_t *pchBuffer;
        uint16_t hwSize;
    )
    
    protected_member(
        uint16_t    hwHead;                 //!< head pointer
        uint16_t    hwTail;                 //!< tail pointer
        uint16_t    hwCount;                //!< byte count
    )
)


end_def_class(byte_queue_t) /* do not remove this for forward compatibility  */
//! @}

值得注意的是,这里我们用 private_member()protected_member() 的形式规定了成员变量的属性:其中private的成员是只有类的主人自己可见;而 protected的成员是类的主人以及派生类都可见。如果你想指定某些成员是公共可见的,则可以使用 public_member()

  • 打开 byte_queue.c,在文件的最开始通过定义宏 __BYTE_QUEUE_CLASS_IMPLEMENT 来标记自己“类主人”的身份,当然,别忘记包含自己的接口头文件:

#define __BYTE_QUEUE_CLASS_IMPLEMENT


#include "./byte_queue.h"
  • 在 byte_queue.c 中,如果某个函数(类的方法)试图访问类的成员,则应该首先借助 class_internal() 来“脱下马甲”。方法跟前文一样,这里就不再赘述。

完整的例子在 PLOOC 的example目录下:诸如派生类应该如何处理函数重载应该如何实现等等问题,大家可以打开MDK的例子工程后“细品”。

【后记】


掩码结构体是一种全新的方法,可以在语法层面上限制模块的使用者对关键的结构体(类)成员的访问。相比大家熟悉的“不完全类型”,掩码结构体携带了足够的信息(大小信息和对齐信息),从而允许模块的使用者自由的定义变量或是动态分配,这与“不完全类型”必须依赖动态分配的缺点形成了鲜明的对比。

曾几何时,掩码结构体还有“模块的.c不能包含模块的接口头文件” 这样的限定,在最新的PLOOC中,这一问题已经得到了彻底的解决——再也不用担心 ".c" 和 ".h" 中的类型描述不一致导致的运行时错误。

最后,需要强调一下,对 service 模型来说,掩码结构体,或者说PLOOC的使用只是“锦上添花”——并非必须。读者完全可以根据自己的喜好来决定模块的实现方式。如果你喜欢或者对PLOOC使用有什么建议,欢迎在 github上提交你的issue。


扫描下方微信,加作者微信进技术交流群,请先自我介绍喔。



推荐阅读:
嵌入式编程专辑Linux 学习专辑C/C++编程专辑
Qt进阶学习专辑

如果你喜欢我的思维,欢迎订阅 裸机思维

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值