用C实现的一个基本COM接口IFoo(来自COM Programmer's Cookbook)
把该文中实现的代码整理汇总到一个项目中。目前只是实现到一个中间阶段,重点在说明COM接口的实现原理,还没有包含类厂的部分。以后还需陆续添加类厂等高级功能。
文件组成:
ifoo.h COM接口IFoo,接口ID IID_IFoo 声明文件。
outside.c COM接口实现。这里实现IFoo的是一个结构体COutside.
util.h 一些宏定义、全局函数、变量声明文件。
main.c 笔者为实现项目添加的文件。提供main函数、内存管理函数Alloc,Free的实现(封装C运行库函数malloc和free.)、接口ID定义。
COM接口到底是什么?
COM接口是一个指向虚函数表的指针。通过这个指针可以访问内存中某处的各个功能块,执行预定义的功能,完成用户的任务。这些功能块以函数的形式存在(想不出还有其他形式:))并被调用。它们有一个共同点:都包含一个指针参数,指向这些功能要操作的数据地址。在C++中,这个地址就是对象的首地址,也就是类成员函数中隐含的this指针。在C函数中并没有这种现成的便利,因此代码实现中在接口定义时仍使用了接口指针(HRESULT (__stdcall * QueryInterface) (IFoo * This, const IID * const, void **)),而在接口函数实现时根据结构体布局结构,从这个接口指针推算得到对象实例指针。
typedef struct IFoo
{
struct IFooVtbl * lpVtbl;
} IFoo;
typedef struct IFooVtbl IFooVtbl;
struct IFooVtbl
{
HRESULT (__stdcall * QueryInterface) (IFoo * This, const IID * const, void **) ;
ULONG (__stdcall * AddRef) (IFoo * This) ;
ULONG (__stdcall * Release) (IFoo * This) ;
HRESULT (__stdcall * SetValue) (IFoo * This, int) ;
HRESULT (__stdcall * GetValue) (IFoo * This, int *) ;
};
COM接口的要求:
每一个COM接口(指向的虚函数表)的头三个函数必须是IUnknown接口的函数:QueryInterface,AddRef和Release。在C++中,称为从IUnknown接口继承。
对于调用QueryInterface响应查询IID_IUnknwon得到的接口指针值,同一个对象实现的所有接口必须相同。这是判断两个COM对象是否是同一个对象的标准。
宏定义“#define IUNK_VTABLE_OF(x) ((IUnknownVtbl *)((x)->lpVtbl))“说明
在预处理输出文件main.i中可以找到IUnknownVtbl和IFooVtbl的声明:
typedef struct IUnknownVtbl
{
HRESULT ( __stdcall *QueryInterface )(
IUnknown * This,
const IID * const riid,
void **ppvObject);
ULONG ( __stdcall *AddRef )(
IUnknown * This);
ULONG ( __stdcall *Release )(
IUnknown * This);
} IUnknownVtbl;
struct IUnknown
{
struct IUnknownVtbl *lpVtbl;
};
struct IFooVtbl
{
HRESULT (__stdcall * QueryInterface) (IFoo * This, const IID * const, void **) ;
ULONG (__stdcall * AddRef) (IFoo * This) ;
ULONG (__stdcall * Release) (IFoo * This) ;
HRESULT (__stdcall * SetValue) (IFoo * This, int) ;
HRESULT (__stdcall * GetValue) (IFoo * This, int *) ;
};
该宏定义的作用就是把IFoo接口中的IFooVtbl类型的指针拿出来((x)->lpVtbl)),并强制转换((IUnknownVtbl *))成IUnknownVtbl。
“强制转换”的结果是什么呢?是怎么做到的呢?
很明显,结果就是得到的指针不再是IFooVtbl *类型,而是变成了IUnknownVtbl *类型。至于做法,系统应该记录每一个变量、表达式的类型。当进行强制类型转换时,就(临时地)修改其类型为转换到的类型。
同理,QueryInterface, AddRef, Release宏定义中的(IUnknown *)也是这种用法。
可以看到,宏“IUNK_VTABLE_OF“的作用是供宏QueryInterface,宏AddRef,宏Release引用,把IFooVtbl *类型转换为IUnknownVtbl *类型,最终达到调用IUnknownVtbl中定义的三个QueryInterface,AddRef,Release函数。
那么,这种大费周章的目的是什么呢?为什么不以IFooVtbl中三个函数的定义形式(不通过强制转换来转换成必须的类型),直接调用IFooVtbl中定义的函数呢?虽然强制转换在参数值上并不会造成改变,最终调用的也是IFooVtbl定义的函数(FooQueryInterface,FooAddRef,FooRelease)。
为什么一定要通过IUnknown接口指针调用这三个函数呢?修改QueryInterface宏定义如下:
#define QueryInterface(pif, iid, pintf) \
(((pif)->lpVtbl)->QueryInterface(pif, iid, (void **)(pintf)))
即通过IFoo接口指针来调用由IUnknown引入的函数,有什么不对的地方吗?
试验表明,将QueryInterface宏定义如下也可以编译通过,执行起来也没有出现任何异常。
#define QueryInterface(pif, iid, pintf) \
(((pif)->lpVtbl)->QueryInterface(pif, iid, (void **)(pintf)))
对于IUnknown接口的三个函数,调用时传递的参数是IUnknown *类型(见QueryInterface, AddRef, Release宏定义),而函数定义中(FooQueryInterface, FooAddRef, FooRelease)声明的参数是IFoo *类型,这种不一致的情况是怎么出现的?这种不一致不会有问题吗?
这种不一致的产生是由于从不同的角度看待引起的。如果从IUnknown接口来看,那么接口函数中的第一个参数类型就是IUnknown *;如果从IFoo来看,那么第一个参数的类型就是IFoo *。
这种不一致性只是针对于编译器对于类型的编译要求有意义的,在接口实现及使用时,传递给lpVtbl->QueryInterface, lpVtbl->AddRef,lpVtbl->Release的第一个参数在值上都是相同的,都是实现该接口的内存地址(在本例中是COutside对象的首地址)。
一些语法现象回顾
函数指针变量定义、赋值及调用。
HRESULT (__stdcall * pQI) (IFoo * This, const IID * const, void **) ;
定义一个函数指针变量pQI,该变量指向“返回HRESULT,取3个参数分别为类型IFoo *,const IID * const, void **”的函数。
typedef HRESULT (__stdcall * QIType) (IFoo * This, const IID * const, void **) ;
定义一个函数指针类型,该类型的指针指向“返回HRESULT,取3个参数分别为类型IFoo *,const IID * const, void **”的函数。
HRESULT __stdcall QueryInterface(IFoo * This, const IID * const, void **) ;//函数声明示例
pQI = 0; // 函数指针赋值,0表示不指向任何函数。
pQI = QueryInterface; // 函数指针赋值,pQI指向QueryInterface。
pQI = &QueryInterface; // 与上面等价。
QueryInterface(&this->ifoo, riid, ppv); // 使用函数名直接调用
pQI(&this->ifoo, riid, ppv); // 函数指针调用
(*pQI)(&this->ifoo, riid, ppv); // 第二种函数指针调用方式
宏定义、展开规则
对于宏,一直有一种雾里看花的感觉,似乎很随意,怎么来都行,比如:
#define AddRef(pif) \
(IUNK_VTABLE_OF(pif)->AddRef((IUnknown *)(pif)))
宏定义应该是可以嵌套的,即宏定义的“内容“中还可以包含(嵌套)宏,如本例,“IUNK_VTABLE_OF”就是嵌套宏。在展开的时候,将嵌套的宏也一并展开(替换成定义的内容),直到不再有宏为止。
那么就有两个疑问:
1。如果被嵌套的宏包含(直接或间接)定义的宏,那么展开就没完没了,死循环了。
2。如果定义的内容中有跟定义的宏同名的字符串(比如上面的例子IUNK_VTABLE_OF),那么怎么区分这同名的东东是嵌套的宏(需要展开),还是一般的字符串(不需要展开)?
函数调用规范约定、main函数调用规范。
一开始把几个文件汇总到项目里时,编译通不过,错误提示大致意思是,不能把一种调用规范的函数指针转换成另一种调用规范的函数指针。后来把调用规范改为 /Gz(__stdcall),编译为(Compile As)改为/TC(Compile As C Code)就好了。
想来是对于.c文件,编译器缺省使用的是__cdecl,而IFoo中的接口宏定义在win32下展开成了__stdcall,所以出现了矛盾。而使用/Gz强制未声明调用规范的函数使用__stdcall,实现就与声明一致了。
(size_t)&(((s *)0)->m)
c++程序员也许都知道,访问地址“0”处的成员是一大忌,会造成GP。然而,取地址“0”处的成员的地址,却是个合法的操作。虽然地址“0”处并没有什么内容,但是,如果在地址0处存放一个内容,那么该内容中的成员也是有地址的。本例中正是巧妙地利用这种方法,从接口地址计算得出实现该接口的实例地址,进而访问实例的内部变量。
------------------------------------------------------------------------------------
2009年5月6日
附上源码:/Files/gracelee/outside.zip
代码执行结果:
在C实现COM接口系列1中实现的com接口IFoo与使用它的客户耦合在一起,没有实现在各自分离的模块,因此不符合模块化编程思想。本期添加类厂支持,以使接口的实现与接口的使用相分离。
---------------------------------------------------
类厂的作用到底是什么?
将接口的实现与客户使用分离开来吗?
不尽然。使用CoCreateInstance,客户可以完全不必知道类厂的存在,而创建组件,获取组件实现的接口并使用。
即COM库可以完全抛开类厂的概念,而是提供一个这样的函数原型:
CoCreateObject(REFID rclsid,...,REFID riid,void **ppItf);
用户在调用的时候可以对riid提供IID_Unknown或者特定于该对象的一个接口,直接获取该对象的IUnknown或特定的接口指针。
可以看到,这正是CoCreateInstance所作的事情。
1 类厂提供了间接创建类对象的方式:用户可以先获取并持有类厂接口指针,通过该指针所指向的类厂接口创建类对象。适用于需要创建多个(或重复创建)类对象的地方,减少了每次都要定位对象库并把对象库装入内存的开销。
2 类厂提供了保证组件库留在内存不被卸载出去的另一种方法:类厂接口函数LockServer。组件库维护一个库范围计数器,只有该计数器为0时,组件库才允许自己被卸载出内存。(与此相对,引用计数是类对象范围的,通过该类实现的各个接口来维护。如果一个类对象的引用计数达到0,那么该对象占有的内存就被释放,该对象上的接口指针也不再有效。)
除了调用LockServer锁定组件库以外,当创建的组件个数大于0时,组件库也不能被卸载。也可以说,调用一次LockServer()的作用相当于创建了一个组件。
-----------------------------------------------------------------------
客户一侧:
1 使用一个接口需要知道哪些信息?
备选:
接口IID
类对象(类厂)CLSID(或ProgID)
接口函数原型(参数个数,类型,返回值)
实现接口组件的线程模型(进程内、进程外、远程)?
类型库typelib信息?
服务一侧:
2 实现一个组件和接口以供客户调用,需要提供哪些东西?
备选:
所有客户使用组件和接口所需的内容
额外的还有:
--------------------------------------------------------------------
为dll添加.def文件与直接在需要导出的函数定义处指定_declspec( dllexport )有区别吗?如果有是什么区别?
我发现在outdll.c中这样指定:
__declspec( dllexport ) HRESULT DllGetClassObject (REFCLSID rclsid, REFIID riid, void **ppv)
会产生编译错误:
1>------ Build started: Project: outside, Configuration: Debug Win32 ------
1>Compiling...
1>outdll.c
1>d:\outside-cf\outside\outdll.c(19) : error C2375: 'DllGetClassObject' : redefinition; different linkage
1> c:\program files\microsoft visual studio 8\vc\platformsdk\include\objbase.h(833) : see declaration of 'DllGetClassObject'
1>Build log was saved at "file://d:\outside-cf\outside\Debug\BuildLog.htm"
1>outside - 1 error(s), 0 warning(s)
========== Build: 0 succeeded, 1 failed, 1 up-to-date, 0 skipped ==========
c2375的解释意思是出错的函数使用的链接指示符与之前声明的不同。
Compiler Error C2375
'function' : redefinition; different linkage
The function is already declared with a different linkage specifier.
objbase.h中声明了DllGetClassObject()函数:
STDAPI DllGetClassObject(IN REFCLSID rclsid, IN REFIID riid, OUT LPVOID FAR* ppv);
而使用.def文件就没有问题。
-----------------------------------------------------------------------------
初次执行结果:
问题就是总有一个分配的内存没有释放:
根据打印出来的内存地址可以判断,应该是先创建的类厂对象的内存没有释放。
检查代码,main()中并没有忘记调用Release(pCF)释放类厂对象。打印Release(pCF)的返回值,发现是1,即在类厂接口指针上少调用了一次Release,那么,究竟是哪里少的呢?
main()函数中有关类厂对象引用计数的地方就是CoGetClassObject和Release(CreateInstance跟类厂自己的引用计数无关),这是一对增加引用计数和减少引用计数的对应操作,所以,main()中应该没有问题。
那么,就只有创建类厂对象的时候了。下面看一下类厂对象是如何创建的。
首先,main调用CoGetClassObject,该函数就调用dll中的DllGetClassObject。由于是第一次调用(不考虑其他客户使用该dll的情况),程序执行到CreateClassFactory(...),该函数执行完后,类厂对象的引用计数是1。
由于创建成功,因此继续向下执行到QueryInterface,此时,类厂对象的引用计数变成了2。然后,DllGetClassObject返回,com库函数CoGetClassObject也应该返回。注意,此时的类厂对象引用计数已经是2了!
因此,问题就出在这里。main调用一次CoGetClassObject后,类厂对象的引用计数是2,而不是我想向中的1。于是,后面调用一次Release也就当然无法释放掉类场对象了。
1 HRESULT DllGetClassObject (REFCLSID rclsid, REFIID riid, void **ppv)
2 {
3 *ppv = 0;
4 if (IsEqualCLSID (rclsid, &CLSID_Outside))
5 {
6
7 if (!vpcfOutside)
8
9 {
10
11 HRESULT hr = CreateClassFactory (&CLSID_Outside, CreateOutside,
12 &IID_IClassFactory, &vpcfOutside);
13
14 if (hr != NOERROR)
15
16 return hr;
17 }
18
19 return QueryInterface (vpcfOutside, riid, ppv);
20
21 }
22
23 return E_FAIL;
24 }
找到了原因,改正就很容易了。这里我觉得需要把DllGetClassObject作如下修改:
1 HRESULT DllGetClassObject (REFCLSID rclsid, REFIID riid, void **ppv)
2 {
3 *ppv = 0;
4 if (IsEqualCLSID (rclsid, &CLSID_Outside))
5 {
6
7 if (!vpcfOutside)
8
9 {
10
11 HRESULT hr = CreateClassFactory (&CLSID_Outside, CreateOutside,
12 &IID_IClassFactory, &vpcfOutside);
13
14 if (hr != NOERROR)
15
16 return hr;
17
18 if(IsEqualIID(riid,&IID_IClassFactory))
19 {
20 *ppv = vpcfOutside;// Set *ppv to vpcfOutside directly instead of QueryInterface if first time creation
21 return NOERROR;
22 }
23 else
24 {
25 Release(vpcfOutside);// Any interface requested (riid) other than IID_ClassFactory and IID_Unknown not support by class factory,
26 // call Release to free the memory.
27 return E_FAIL;
28 }
29
30 }
31
32 return QueryInterface (vpcfOutside, riid, ppv);
33
34 }
35
36 return E_FAIL;
37 }
修改后在执行,内存都正常释放了。
-------------------------------------------------------------------------------------------
CreateClassFactory代码说明
1 HRESULT CreateClassFactory (REFCLSID rclsid,
2 HRESULT (*pfnCreate)(IUnknown *, REFIID, void **),
3 REFIID riid, void **ppv)
4 {
5 ClassFactory *this;
6 HRESULT hr;
7
8 *ppv = 0;
9 if (hr = Alloc (sizeof (ClassFactory), &this))
10 return hr;
11
12 this->icf.lpVtbl = &vtblClassFactory;
13 this->cRef = 1; // After this call, cRef==1
14
15 this->pfnCreate = pfnCreate;
16
17 hr = QueryInterface (&this->icf, riid, ppv); // After this call, cRef==2
18 Release (&this->icf); // Corresponds to "this->cRef = 1", ater this call, cRef==1
19
20 return hr;
21 }
可以看到,两行代码的效果是对引用计数增1及减1,这两行代码执行后,对引用计数的影响互相抵消,等于没有改变引用计数。那么,把这两行同时注释掉,是不是可以呢?
我的回答是:在本例中可以。因为这两行代码之间的QueryInterface总是可以执行成功的(因为是用IDD_ClassFactory来调用该函数的)。所以,即便把这两行代码同时注释掉,CreateClassFactory执行结束后,类厂对象的引用计数也增了1,以后调用Release就可以释放掉类厂对象占用的内存。
但是,如果CFQueryInterface的代码编写中除了错误,比如,像这样写:
1 static HRESULT CFQueryInterface (IClassFactory *pcf, REFIID riid, void **ppv)
2 {
3 ClassFactory *this = IMPL (ClassFactory, icf, pcf);
4
5 if (IsEqualIID (riid, &IID_IUnknown) ||
6 // IsEqualIID (riid, &IID_IClassFactory)) // Comment out this condition to create an error
7 *ppv = &this->icf;
8 else
9 {
10 *ppv = 0;
11 return E_NOINTERFACE;
12 }
13
14 AddRef ((IClassFactory *)*ppv);
15
16 return NOERROR;
17 }
那么,这两行代码之间的QueryInterface就会执行出错,那么类厂对象占用的内存就永远没有机会释放了。
也就是说,AddRef和Release虽然在作用上对引用计数来说相互抵消,但Release函数提供了释放对象内存的机会(当引用计数为0时),如果不成对的调用他们,也就失去了管理对象内存(释放对象占用的内存)的机会。
---------------------------------------------------------------------------
组件库outside文件说明:
IFoo.h IFoo接口声明
outside.c 组件对象、IFoo接口实现
cf.c 类厂对象、IClassFactory接口实现
outdll.c 组件库导出函数实现
outside.def 组件库模块定义文件,导出函数声明
outside.reg 组件库注册文件
----------------------------------------------------------------------------
源码: outside-cf