【摘要】组件对象模型(Component Object Model,COM) 是微软公司首推的编程模型。它将软件分为接口和实现两个独立的部分,由接口定义抽象的功能,一个实现模块则可能实现多个接口,来提供实际的功能。这样在一个大型软件中,可以将模块之间的耦合大幅度降低,降低模块管理的难度。有的嵌入式操作系统中也引入了COM,比如vxWorks。但是,COM为了兼顾分布式(DCOM),实现二进制级的对象模型交换,显得比较复杂,基本代码量比较大。本文试图引入一种轻量级的组件对象模型,完全用c实现,核心部分非常小,甚至可以在51单片机这种级别的计算平台上运行,有利于实现多种平台下的代码共享,这里将这个模型称为轻量级组件对象模型(LCOM:Light Component Object Model)。
1.概述
所谓接口,可以理解为一组相关的函数指针,用来描述某一组功能。用一组函数来表达一组功能,这种方式在编程实践中是经常用的,比如Linux中Mesa的OpenGL的Dispatch API,就是一组较为复杂的函数指针,Windows下的DirectX的相关功能,包括Linux下的块设备驱动,都是定义好的一组一组的函数指针。
LCOM与COM一样,用一个128位的ID号标识一组函数指针,也就是一个接口。接口一经发布,以后接口中的函数个数,声明顺序,函数名称,参数表,返回值等就不再修改,这样可以让应用程序能够有效兼容不同版本的实现,不同版本实现时只要提供版本的升级库,应用程序甚至都不用重新连接,就能够直接使用。
接口的稳定性其实是非常重要的,目前开源社区的一个问题是兼容性问题,一个软件模块的版本前后往往不兼容,甚至既不向前兼容,也不向后兼容,每个版本神出鬼没地改了点东西,给使用者带来很大的困惑。Windows下的接口相对稳定性要好很多,从上个世纪80年代开始的Win32编程模型,到现在基本上还能用,都到64位系统了,还叫Win32。这对在Windows下提供服务的软件团队,减少了很多维护的费用,也减少了很多软件人员的培训开销。
基于LCOM的应用程序可以通过一个公共的所有对象都规定要实现的接口来查询对象是否实现指定的接口,并通过这个接口来管理对象的生存周期(实际上用引用计数来实现),对象的生成,销毁,引用,是否有效(内部一致性),以及一些附加的比如对象实现的版本号,都由对象统一接口实现。
比如对于OpenGL的系统,可以定义OpenGL 1.1接口,OpenGL 1.3接口,OpenGL 1.5接口,OpenGL 2.1接口, OpenGL 3.2接口,OpenGL 4.0接口等等,这样一个驱动实际上就是实现一个对象,它支持什么接口就可以通过一个公共的接口来询问。可以设计专门的测试系统来测试一个驱动对象对各个版本的API是否支持。
LCOM虽然只是一组函数指针,我们还需要支持从接口直接访问对象,这样减轻应用程序的负担,不需要分别维护数据和函数组。应用程序只要维护一个接口,就可以访问到对象的所有功能,通过任意一个接口都可以查询到对象支持的其他接口,并能访问对象的所有数据。
LCOM还提供一组公共的函数和宏,来实现对象的管理,将应用程序从复杂的对象生命周期管理中解脱出来。
LCOM在对象的创建方面提供统一的办法,这样可以将对象的描述放在一个比如xml的文件中,用一个简单的解析软件就可以将应用系统需要的对象模块全部创建出来,实现软件模块的灵活管理。这样处理给对象的存储与传输也带来了便利,对象只要存储对象的类ID,以及实例化参数和对象,就能够存在存储器中,并通过通信系统传递,然后用统一的对象生成接口重新在远程生成。
LCOM用形式化的方法定义接口,定义的接口可读性,无二义性,完备性,稳定性都有保证,便于团队之间的交流,可以让软件的设计,实现,测试,验证,调试,评估等工作能够由不同的专业团队来完成。特别对一些通用的接口,可以由各种专业团队来发布各自专业的服务软件。
比如定义一个I2C接口,这样不同的硬件环境下面用不同的对象来实现同一个I2C接口,但是它的测试,验证,评估(性能评估?)由另外的专业团队来维护,基于I2C的上层应用则稳定地依靠这个接口运行。这样做减少了团队之间的交流开销,让团队之间的信任变得更加简单,甚至可能出现网络上根本不认识的团队的高效协作。
如果有个中心化的组件交易组织,可以提供基于网络组件或接口的需求发布,设计,实现,测试等团队,提供组件级别的交易,解决应用软件的整合工作量。
LCOM用ANSI C编程,以便于用于一些小型的系统中(比如51单片机,其编译器可能不支持c++),其最小系统占用的存储器可以剪裁到非常小,尽可能减轻系统开销。
2.GUID
LCOM跟COM一样,用GUID来标识LCOM内部的接口(IID),对象(CLSID),参数(PARAMID),一般而言,一个GUID标识的目标一旦发布,其意义以后就不再做任何修改,这样有助于应用的兼容性,保护用户的先期投入。你可以不支持某个接口,只要声称支持,就应该满足这个接口发布时的所有功能和逻辑关系。如果要支持新的特征,那只能用另外一个接口来表达。Windows下提供的DirectX系列模块,比如DIrectPlay,就有DIrectPlay2, DirectPlay3等接口。
GUID定义如下:
#undef DEFINE_GUID
#ifdef IMPLEMENT_GUID
#if _BYTE_ORDER == _BIG_ENDIAN
#define DEFINE_GUID(name, L, s1, s2, b1, b2, b3, b4, b5, b6, b7, b8) \
const uint32_t name[4] = {L, \
((s2) << 0) | (s1 << 16), \
((b1) << 0) | ((b2) << 8) | ((b3) << 16) | ((b4) << 24), \
((b5) << 0) | ((b6) << 8) | ((b7) << 16) | ((b8) << 24), \
};
#else
#define DEFINE_GUID(name, L, s1, s2, b1, b2, b3, b4, b5, b6, b7, b8) \
const uint32_t name[4] = {L, \
((s1) << 0) | (s2 << 16), \
((b4) << 0) | ((b3) << 8) | ((b2) << 16) | ((b1) << 24), \
((b8) << 0) | ((b7) << 8) | ((b6) << 16) | ((b5) << 24), \
};
#endif /*_BYTE_ORDER == _BIG_ENDIAN*/
#else
#define DEFINE_GUID(name, L, s1, s2, b1, b2, b3, b4, b5, b6, b7, b8) \
extern const uint32_t name[4];
#endif
可以看到GUID由128位组成,放在4个32位的无符号整数中。
GUID的生成有很多工具,windows下推荐使用微软的GUID生成工具guidgen.exe,可以根据运行的计算机硬件状态和运行的时间,生成一个128位的数字,据说可以保证任何两次生成(包括不同的计算机同时生成)的ID号都不相同。这样你可以放心的生成一个独一无二的ID号来命名你的接口或者对象。
这个工具生成后可以选择下面的格式:
// {290C21DB-06A6-4752-A90C-75DF92A5CF48}
DEFINE_GUID(<<name>>,
0x290c21db, 0x6a6, 0x4752, 0xa9, 0xc, 0x75, 0xdf, 0x92, 0xa5, 0xcf, 0x48);
把其中的名字换成想定义的名字即可,比如一般在c头文件中定义如下:
DEFINE_GUID(IID_XXXX, 0x290c21db, 0x6a6, 0x4752, 0xa9, 0xc, 0x75, 0xdf, 0x92, 0xa5, 0xcf, 0x48);
DEFINE_GUID这个宏定义实际上由两个实现,一个是实际将GUID的128值存储在一个全局变量中的实现,在外部定义IMPLEMENT_GUID时实现,一个只是声明一个外部变量,供其他模块引用。在引用模块中直接包含c头文件即可,在对象实现或者接口相关的唯一一个文件中则先定义IMPLEMENT_GUID,然后包含声明该GUID的头文件,然后马上取消IMPLEMENT_GUID定义,这样确保每个GUID能够由唯一的模块来实现。
在实际使用中,我们大量使用指向GUID的指针,因此定义一个指针类型,实际上很多情况下都存储或者传输指向GUID的指针,同一模块内部比较GUID是否相等时,由于都引用同一个值,可以直接比较指针是否相等即可,当然对外部传入的GUID指针,不能假设是直接的指针引用,因此定义一个宏来判断两个GUID是否相等:
typedef const uint32_t* IIDTYPE;
#define isGUIDEqual(n1, n2) (((n1)[0] == (n2)[0]) \
&& ((n1)[1] == (n2)[1]) \
&& ((n1)[2] == (n2)[2]) \
&& ((n1)[3] == (n2)[3]))
3.基本公共接口
3.1公共接口定义
LCOM定义所有对象必须实现的公共接口如下:
typedef struct sIObject {
int __thisoffset;
/*
功能:对象是否支持指定的接口
参数:
object -- 对象数据指针
iid -- 接口编号
pInterface -- 存放返回的接口指针
返回值:
EIID_OK: 成功
EIID_INVALIDOBJECT:object 是无效对象
EIID_INVALIDPARAM: 参数错误
EIID_UNSUPPORTEDINTERFACE : 对象不支持该接口
*/
int (*QueryInterface)(HOBJECT object, IIDTYPE iid, const void **pInterface);
/*
功能:增加对象的引用计数
参数:
object -- 对象数据指针
返回值:
>=0: 增加后的引用计数
EIID_INVALIDOBJECT:object 是无效对象
*/
int (*AddRef)(HOBJECT object);
/*
功能:减少对象的引用计数,引用计数为0时删除该对象
参数:
object -- 对象数据指针
返回值:
>= 0: 减少后的引用计数
EIID_INVALIDOBJECT:object 是无效对象
*/
int (*Release)(HOBJECT object);
/*
功能:判断对象是否是一个有效对象
参数:
object -- 对象数据指针
返回值:
0 -- 对象是无效的
1 -- 对象是有效的
*/
int (*IsValid)(HOBJECT object);
}IObject;
并规定每个对象的0偏移处必须是一个指向IObject的指针,这样指向对象的指针实际上是指向一个指向接口的指针。也就是说,LCOM中所谓的对象,其实是指向接口指针的一个指针,这个指针存放在对象的实现数据结构中。定义
typedef void * HOBJECT;
表示一个接口,该指针指向某个接口的指针,由于对象偏移值为0的地方存放的是指向IObject的一个指针,因此偏移值0的接口指针也是对象的指针。每个接口实现中记录了从接口对象到接口指针的偏移值,因此可以从接口得到得到对象指针。
#define objectThis(_obj) (((uint8_t *)(_obj))-((*(IObject **)(_obj))->__thisoffset))
为了实现对象的生存周期管理,还应该有一个引用计数变量,对象还应该保存一个对象类型的GUID,以便应用确认对象是否是它所想要的对象类型。
每一个接口的前面几个成员,都应该与IObject完全一样,这样每个一个接口都可以当作IObject使用,确保通过每个接口都能够调用对象的公共接口功能。
一个对象实现某个接口时,必须在对象中存储该接口的指针,注意到接口的第一个成员是__this_offset,这个成员记录该接口的指针到对象开始的偏移值。这样如果我们有对象中这个指针成员的指针,就可以根据这个偏移值得到对象的首地址,从而访问到对象本身。
3.2 公共接口实现样例
这样如果要实现一个对象xxxx,头文件中只要定义对象的CLSID即可,在实现c文件中定义结构如下:
typedef struct _sXXXX {
const IObject * __IObject_ptr;
int __object_refcount;
IIDTYPE __object_clsid;
}sXXXX;
static int xxxxQueryInterface(HOBJECT object, IIDTYPE iid, const void **pInterface);
static int xxxxAddRef(HOBJECT object);
static int xxxxRelease(HOBJECT object);
static int xxxxIsValid(HOBJECT object);
static void xxxxDestroy(HOBJECT object);
static int xxxxCreate(const PARAMITEM * pParams, int paramcount, HOBJECT* pObject);
static int xxxxValid(HOBJECT object);
static const IObject xxxx_object_interface = {
0, /* IObject的指针必须在对象的0偏移处 */
xxxxQueryInterface,
xxxxAddRef,
xxxxRelease,
xxxxIsValid
};
static const char * xxxxModuleInfo()
{
return "1.0.0 20210423.2118 xxxx module by RAOXIANHONG";
}
static int xxxxRegister()
{
return objectCreateRegister(CLSID_XXXX, xxxxCreate, xxxxModuleInfo);
}
FUNCPTR A_u_t_o_registor_xxxx = (FUNCPTR)xxxxRegister;
static int xxxxQueryInterface(HOBJECT object, IIDTYPE iid, const void **pInterface)
{
if (!objectIsClass(object, CLSID_XXXX))
return EIID_INVALIDOBJECT;
if (!objectIsValid(object))
return EIID_INVALIDOBJECT;
if (pInterface == 0)
return EIID_INVALIDPARAM;
*pInterface = 0;
if (isGUIDEqual(iid ,IID_OBJECT)) {
*pInterface = objectThis(object);
objectAddRef(object);
return EIID_OK;
}
return EIID_UNSUPPORTEDINTERFACE;
}
static int xxxxAddRef(HOBJECT object)
{
sXXXX * pD;
if (!objectIsValid(object))
return EIID_INVALIDOBJECT;
pD = (sXXXX *)objectThis(object);
pD->__object_refcount++;
return pD->__object_refcount;
}
static int xxxRelease(HOBJECT object)
{
sXXXX * pD;
int ret;
if (!objectIsValid(object))
return EIID_INVALIDOBJECT;
pD = (sXXXX *)objectThis(object);
pD->__object_refcount--;
ret = pD->__object_refcount;
if (pD->__object_refcount <= 0) {
pD->__object_refcount = 1;
/*为了保证在Destroy过程中不出现递归调用,这里将引用记数设置为1*/
xxxxDestroy(object);
}
return ret;
}
static int xxxxIsValid(HOBJECT object)
{
sXXXX * pD;
if (object == 0)
return 0;
pD = (sXXXX *)objectThis(object);
if (pD->__object_clsid != CLSID_XXXX)
return 0;
if (pD->__object_refcount < 1)
return 0;
return xxxxValid(object);
}
部分宏和函数在后面的章节中会做详细解释。
4.对象管理
LCOM提供了对对象类的管理功能,包括类的注册,类实例生成,类模块信息输出等。可以提供统一的类对象实例生成函数,应用程序只要持有类的CLSID,以及对应的实例化参数,就可以通过objectCreate或者objectCreateEx两个函数生成对象实例。
类模块信息输出函数可以将系统中注册的类的模块信息打印出来,这样由助于在系统调试,维护过程中方便地得到每个模块的版本信息,版本不匹配也是软件维护工程师的噩梦,前台维护工程师与后台开发工程师其实首先需要确认的往往就是版本信息,确保两个团队工作在一个版本上。
这样每个对象实现时除了实现LCOM的公共接口外,还实现了对象管理所需要的几个函数:
static void xxxxDestroy(HOBJECT object);
static int xxxxCreate(const PARAMITEM * pParams,