对很多人来,嵌入式软件开发过程中 模块化(Modularization)是一个海市蜃楼、是一个书面词汇、是一个过气的时尚——模块化似乎从未真正的实现过。吹牛时人们常不屑的说:没吃过猪肉,但还没看过猪跑么?事实上,如果讨论的对象是嵌入式软件,很多人可能真的没有看过猪跑。在话题变得更像都市传说的之前,我想问一个问题:
为什么要模块化?
有经验的人会说:
为了代码复用(Code Reuse)
进一步——“为什么模块化可以实现代码复用呢?”很多人会说:
你这不是抬杠吗?明摆着的,代码做成了模块,那么别的项目就可以直接使用了,模块里的这部分代码就得到了复用。
更进一步——“代码复用又是为了什么呢?”听到这里项目经理们深吸了最后一口烟屁股,顺手丢到脚边、踩灭、起身准备离开:
代码复用可以节省开发时间,加快项目研发速度。
为了把嘴边的那句“你们慢慢聊,我还有事”噎回去,我们再问一个问题:
实际项目开发中,用模块的时候,项目的进度真的加快了么?时间真的节省了么?
项目经理们不动了,抬起到半空中的屁股慢慢的坐了下来。这次,他们的语气是认真的:
不,使用模块通常并不一定能加快项目进度。老实说,用别人的模块,程序员常常要认真理解模块的功能和代码才能在调试的时候确认问题的范围。你知道,很多时候看懂他人代码所用的时间比自己重新设计一个更长。
周围不少程序员都投来赞同的眼光,有的甚至很认真的点了点头。实际上,这里我们已经发现,在实践中,抛开用于模块化的技术不谈,使用模块实现代码复用本身往往并不能加快一个团队的开发速度——那么我们要模块化做什么?
下结论还为时尚早。从项目经理们的描述可以看出:
代码复用的目的或者说动机是节省开发时间
实际执行中,程序员因为种种原因,在使用模块时总是要花费大量时间读懂了代码才能“放心地”去使用它。
根据我们在《什么是嵌入式(上)》中的描述,程序(软件)是“程序员尝试去固化的自己的思维”;而模块(硬件)则是“业已固化的逻辑”,读懂一段程序,实际上就是要通过死的代码逻辑去反推模块构作者的思维,这是一个逆向过程,这是一个人与人之间用代码进行间接交流的过程,当逻辑本身较为复杂时,显然比将自己的思维直接翻译成程序(重新开发一个)更为困难。
通过上面的分析,很容易看出,模块化就是为了通过复用代码来加快开发速度,而正是程序员阅读要复用的代码让这一努力付之东流。由此,我们可以非常直接的得出结论:
使用模块时,必须阻止程序员阅读要复用的代码
或者换一种说法:
使用模块时,必须专注于模块的使用,而必须有意忽视模块的实现逻辑,必须要在心理上信任模块。简而言之,必须把模块视作黑盒子!
很容易发现,上面的结论是站在项目经理的视角得出的,因为项目经理关注的是项目本身,是各类资源的合理利用,是项目的进度——项目经理唯一不需要也不应该关注的是具体的技术实现细节。那么从第一线程序员的视角来看这个问题:
为什么程序员要阅读模块的代码实现呢?
笔者问过不同从业时间/经验的程序员,从过来的的角度来看,无非是以下几个原因:
学习目的——想知道别人是怎么实现的。很多程序员认为通过阅读别人的代码能够快速的学习他人的经验从而提升自己。
然而,从项目管理的角度来看这个问题,程序员利用业余时间阅读他人的代码来提升自己无可厚非,或者说是值得提倡的,但牺牲宝贵的项目时间来阅读模块的实现代码而不是专注于模块的使用(使用模块快速的实现项目所需的功能),这对项目本身是弊远大于利的——阅读代码带来的是程序员的能力提升,这是对团队来说的远期利好,但这一利好对项目本身的时效性却微乎其微——俗话说远水不解近渴就是这个意思。
实际上,项目经理通常要根据程序员的已有能力来分配任务,而不会寄希望于程序员通过阅读模块代码获得提升以后再来回报眼前这个火烧眉毛的项目——如果真有项目经理这么做了,那只能说,进度慢了完全不是程序员阅读模块代码的错,而是他最直接的用人问题——我也只能相信,也许他真的无人可用了。
所以结论就是:严禁工作时间以学习为目的阅读模块源代码。
调试目的——也许并非所有的程序员都对自己的代码质量天然的自信,但几乎所有的程序员都对别人写的代码(模块)天然的不放心——就像孔乙己一样,必须亲眼看了酒保从黄酒坛子里舀出酒来而没有掺水才放心——所以程序出了问题,必然要怀疑模块,而且甚至有很多不负责任的程序员天然的会首先怀疑模块——不是自己写的,怎么能放心——所以调试的时候必然:
要有源代码,否则就不会调试了
必然要阅读模块的代码,否则就不知道究竟这个源代码是不是对的
必然要读懂模块的代码,否则怎么能体“自己的程序出错完全是模块的代码写的不好”。
对于这种情况,就我个人来说,只有一条准则——不提供源代码!只提供库文件——相信我,通常面对汇编代码熟手无策的程序员会在调试的时候自动忽视模块的实现细节,专注于模块接口的输入输出行为——给什么输入,期望什么输出,实际获得什么输出——一目了然,简单直接。如果真的期望输出和观察到的实际输出不同,问题也就找到了:要么是文档没有读好,对输入输出的理解有误;要么是输入就有错;要么就是模块有问题。这绝对比读懂源代码以后再来调试要快得多!——除非这个别人写的模块需要你来维护……所以说,调试的时候
根!本!不!需!要!读!模!块!的!源!代!码!
根!本!不!需!要!读!模!块!的!源!代!码!
根!本!不!需!要!读!模!块!的!源!代!码!
以调试作为阅读模块的源代码的理由,根本就站不住脚!
仿制目的——这个目的没啥好说,别人把源代码给你就是个错误。请大家自觉抵制无视他人知识产权的行为。从技术上来说,因为要实现自己的版本而需要阅读他人的实现,理解他人的思维,这是一种白盒子行为,因而并不属于正常使用模块的范畴,属于普通的开发范畴。
既然在模块的使用过程中,无论是学习目的还是调试目的都不需要阅模块的源代码,那么可以明确的得出结论:程序员在使用模块的过程中完全不需要,也不应该浪费项目的时间来阅读源代码。一个团队只有做到了这一点,才能借助代码复用加快项目开发的速度。
当一个团队的项目经理理解了“阅读模块代码”对项目的巨大危害,并以制度的形式对程序员的这一行为予以了制止——移除了模块化实践的绊脚石;那么技术经理应该如何理解、设计和实践适合于当前团队和项目需求的模块化架构呢?
在工程开发中进行模块化的本来目的——为了复用已有的代码,节省当前项目的开发时间;
实际操作过程中遇到的尴尬问题——模块的具体实现原本应该被视作黑盒子,程序员因为各种心理上的原因要阅读代码;
以及
“原则上”的解决方案——严禁程序员在项目开发过程中阅读模块的具体实现代码。
【正文】
从具体操作层面来说,所谓Service模型并不复杂。 首先,每一个模块都有一个属于自己的专门的文件夹,文件夹的名称与模块名相同:
其次,每一个模块中都有一个专门的头文件,用于提供给模块的使用者来包含(#include);该头文件的名称必须与模块的名称相同。
需要特别强调和说明的是:
该头文件用于“从模块内部向模块外部”提供使用模块所必须的“最小信息”;
任何人要使用模块,必须且只能包含该头文件;
我们把这类向模块的使用者提供必要信息的头文件称之为接口头文件;
接口头文件遵循“最小信息公开原则”,即,该头文件中只存放用户使用模块最少最少所必须知道的信息。实际操作中,类型定义、宏定义、函数和全局变量声明都应该首先放置在对应的源代码中(或是后面会提到的模块内私有的接口头文件中);当且仅当我们发现用户要使用模块的某一功能必须要用到某一信息时,才“极不情愿”地、“抠门”的、且尽可能将其它能剥离和隐藏的信息剥离开后,放置到接口头文件中。
该头文件用于“从模块外部向模块内部”输入配置信息;
如无特殊说明或安排,该头文件应该固定命名为 app_cfg.h (没有额外的前缀和后缀);
如无特殊说明或安排,该头文件应该仅包含配置信息,例如:宏定义、类型定义(在极其特殊的情况下,偶尔出现的全局变量或者函数声明);
我们把这类头文件称之为“配置头文件”;
为了实现这一点,一个模块内部 app_cfg.h 的固定内容格式为:
//! 作为模块的用户,不要修改这里的任何内容#include "../app_cfg.h"/* app_cfg.h 的防重复包含的保护宏 *//* 请将 XXXXXX 替换为模块的名称,并删除本注释 */#ifndef __XXXXXX_APP_CFG_H__#define __XXXXXX_APP_CFG_H__...#endif /* app_cfg.h 文件的结尾 */
一个模块的接口头文件,其内部格式可能为:
//! 作为模块的用户,不要修改这里的任何内容/* 模块接口头文件防重复包含的保护宏 *//* 请将 XXXXXX 替换为模块的名称,并删除本注释 */#ifndef __XXXXXX_H__#define __XXXXXX_H__/* 模块的接口头文件在一开始要包含当前模块的 app_cfg.h, * 这里的 "./" 不可以省略 */#include "./app_cfg.h" /* 其它include */...#endif /* 接口头文件的结尾 */
可以很容易注意到,当使用某一模块时,用户可以很方便的在模块外部定义一个属于自己的 app_cfg.h 来向模块提供配置信息——而无论如何修改这一文件,都不会破坏黑盒子本身的内容。
再次,一个模块往往拥有一个或多个C源文件,它只需要包含模块的接口头文件,就可以共享一些“对外公开的信息”。
这里有个朋友会问了:根据最小信息公开原则,接口头文件中只包含了一些最小信息,如果模块内的多个C源文件之间需要共享一些非公开的私有信息,该怎么处理呢?
为了解决这一问题,我们一般会 引入一个以双下划线为前缀的接口头文件(比如,叫做__common.h), 并视其为模块的私有财产。如下图所示, 这一头文件是仅供模块内的源代码包含的——无论是模块的接口头文件还是模块的配置头文件都不应该对其进行包含——以防信息泄露:一个典型的 __common.h 内容如下:
/*! 作为模块的用户,不要修改这里的任何内容,理论上也不应该关心这 * 里出现的任何内容。 * 对模块的作者来说,如果模块以 lib 的形式提供,请务必将本文件删除 */#ifndef __XXXXXX_COMMON_H__#define __XXXXXX_COMMON_H__...#endif /* 私有接口头文件的结尾 */
基于这一规则,模块内一个可能的C源文件内容如下:
//! 作为模块的用户,不要修改这里的任何内容/* 首先包含模块的接口头文件,模块的配置头文件也会间接的被引入进来 */#include "./xxxxx.h" #include "./__common.h"/* 当前C源文件私有且不想跟模块内其它C文件共享的内容: 宏、类型定义等等 */.../* 函数实现等等 */...
最后,一个模块内是允许包含其它子模块的,对于这种嵌套情况,仅需要两步骤就可以完成部署:
将子模块拷贝到父模块中,或者按照前述的模块构建规则,在父模块中建立一个子模块;
父模块的接口头文件包含子模块的接口头文件;
少数情况下,如果子模块与父模块高度耦合(一般来说就是在父模块中从头开始建立一个新的子模块时会发生这种情况)——比如子模块依赖父模块的 __common.h 中提供的信息,则应该在子模块中也建立一个 __common.h,并仿照 app_cfg.h 的做法,在头文件的一开始首先向上包含父模块的 __common.h;
如果父模块包含__common.h,而子模块并不需要这一信息,则子模块无需在做任何特殊修改。
对app_cfg.h来说,由于子模块原本就会自动包含上一级的app_cfg.h,因此,我们无须做任何特殊操作,子模块就可以透过父模块的app_cfg.h自动从外界获取配置信息——这就像是一种标准化的水管安装。
【后记】
Service模型本身是完全本着 简化用户操作的宗旨,以 实用性为重中之重,同时也避免一切“反直觉”的设定。对用户来说,这一模型是非常友好的:
只需要拷贝模块目录就可以完成部署;
只需要在模块的外部额外添加一个app_cfg.h就可以实现对模块的配置;
所有关于模块的使用信息(使用说明书)都放置在一个唯一的、与模块同名的接口头文件中;且这里包含的信息对用户来说都是可用的(没有无用信息,也没有多余信息);
对模块的开发者来说:
这一模型是高度遵守黑盒子原则的;
用户使用模块,是不需要“用脏手染指”自己宝贵的代码的(无需修改);
对制作 Library 非常友好,只需要保留接口头文件,而将其它所有文件(包括源代码和私有接口头文件)删除并保留一个固化好的app_cfg.h即可。
模块是非常容易迁移和嵌套的。
当然,这一Service模型也有一个小缺点(可能有些人也对此无法容忍),即,用某些工程管理工具将头文件的包含关系展开时,通常会看到海量的app_cfg.h(尽管他们内部都使用了模块特有的保护宏进行区别)——对于这一问题,在真刀真枪模块化的后续内容中,将提供一个较为完美的解决方案,这里就先卖个关子——对普通用户来说,现有的Service模型足够了。
原创不易,如果你喜欢我的思维、觉得我的文章对你有所启发,
请务必 “点赞、收藏、转发” 三连,这对我很重要!谢谢!
欢迎订阅 裸机思维
结束语
好了,这里是公众号:“最后一个bug”,一个为大家打造的技术知识提升基地。
推荐好文 点击蓝色字体即可跳转
☞【MCU】把"安卓HAL层思想"引到单片机软件开发中
☞【MCU】可怕,别人把我MCU固件给反汇编了!(逆向)
☞ 【MCU】寄存器、标准库、HAL库、LL库,这么多库!你叫我怎么选?
☞ 【重磅】剖析MCU的IAP升级软件设计(设计思路篇)