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

李肖遥

共 7987字,需浏览 16分钟

 ·

2020-12-06 21:48

【说在前面的话】


在本系列的前一篇文章《嵌入式编程模块化——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;
externbyte_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);
externbool byte_queue_dequeue(byte_queue_t *ptObj, uint8_t *pchByte);
externuint_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进阶学习专辑

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

浏览 5
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报