傻孩子(GorgonMeducer) · 2020年12月01日

真刀真枪模块化(2.5)—— 君子协定

作者: GorgonMeducer 傻孩子
首发:裸机思维

image.png
【说在前面的话】


在本系列的前一篇文章《真刀真枪模块化(2)——图解Service模型》中,我们介绍了一种模块化封装的模型——Service模型。该模型的设计理念实际上服务于一个叫做“黑盒子哲学”的设计思维,其核心思想是:

  • 将模块视作一个黑盒子:模块的设计者不用向外透露黑盒子的实现细节;同时模块的使用者也无法看到黑盒子的内部
  • 模块的设计者和模块的使用者完全通过“接口”来进行约定和沟通。这里所有的接口约定都是通过接口头文件来进行描述和传递的。
  • 接口(及接口头文件)遵循“最小信息公开原则”,即,任何跟使用模块所提供的服务无关的、或者非必要(可有可无)信息都应该从接口头文件中删除。

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

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

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

  • 只允许用户使用结构体的大小对齐信息——这样用户可以自由的定义变量,或是通过malloc这样的函数进行动态分配;
  • 以某种“通过实际手段强制了的君子协定”的形式——仅在语法层面——阻止用户直接访问结构体的成员。

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


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

typedef struct byte_queue_t byte_queue_t;

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

typedef struct byte_queue_cfg_t {

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

...

image.png

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

typedef struct byte_queue_t 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;

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

借助宏的力量,我们可以成功的隐藏住 struct \_\_byte\_queue\_t 的存在。


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


#define declare_class(__name)     \

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

...

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

...

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


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

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

准备阶段

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png
NOTE:如果你使用的是 arm compiler 6,在开启微软扩展以后,还需要额外定义一个宏 \_MSC\_VER 来避免底层库中的一些不必要的编译错误。
至此,我们就完成了 PLOOC 在你工程中的部署。

如何在模块中部署

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

image.png

  • 打开接口头文件 byte\_queue.h 并在靠近结构体定义的地方其中添加以下内容:
/*! \NOTE: Make sure #include "plooc_class.h" is close to the class definition 

这里,我们定义了两个很重要的宏 \_\_BYTE\_QUEUE\_CLASS\_IMPLEMENT 和 \_\_BYTE\_QUEUE\_CLASS\_INHERIT\_\_。容易看出,他们分别是根据 

__<模块名称>_CLASS_IMPLEMENT

和 

__<模块名称>_CLASS_INHERIT__

的形式改写而成的。前者的作用是给 C 源代码标记“我是这个类的实现,我是类的主人”的身份用的;后者的作用是给 C代码标记“我是派生类的实现,我派生自基类”。具体使用方法,后面会具体介绍。
需要特别强调的是,一定不要忘记在接口头文件的尾部将这两个宏都undef掉

...
  • 在 byte\_queue.h 里定义目标类:
//! \name class byte_queue_t

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

  • 打开 byte\_queue.c,在文件的最开始通过定义宏 \_\_BYTE\_QUEUE\_CLASS\_IMPLEMENT 来标记自己“类主人”的身份,当然,别忘记包含自己的接口头文件:
#define __BYTE_QUEUE_CLASS_IMPLEMENT
  • 在 byte\_queue.c 中,如果某个函数(类的方法)试图访问类的成员,则应该首先借助 class\_internal() 来“脱下马甲”。方法跟前文一样,这里就不再赘述。

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

【后记】


掩码结构体是一种全新的方法,可以在语法层面上限制模块的使用者对关键的结构体(类)成员的访问。相比大家熟悉的“不完全类型”,掩码结构体携带了足够的信息(大小信息和对齐信息),从而允许模块的使用者自由的定义变量或是动态分配,这与“不完全类型”必须依赖动态分配的缺点形成了鲜明的对比。
曾几何时,掩码结构体还有“模块的.c不能包含模块的接口头文件” 这样的限定,在最新的PLOOC中,这一问题已经得到了彻底的解决——再也不用担心 ".c" 和 ".h" 中的类型描述不一致导致的运行时错误。
最后,需要强调一下,对 service 模型来说,掩码结构体,或者说PLOOC的使用只是“锦上添花”——并非必须。读者完全可以根据自己的喜好来决定模块的实现方式。如果你喜欢或者对PLOOC使用有什么建议,欢迎在 github上提交你的issue。


专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维
版权归裸机思维(傻孩子图书工作室旗下公众号)所有,
所有内容原创,严禁任何形式的转载。
推荐阅读
关注数
1466
内容数
108
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息