这是一篇关于 COM 基础开发的笔记,主要讲 in-process dllout-of-process server 开发时,值得注意的一些细节,完整的代码请参考 github

注:

  • 该工程目前只配置了 Debug x64 位。
  • 建议读者看本文前,先阅读 Windows 10 System Programming_part2 的第二十一章,学习开发 COM 的基础知识。本文可当做开发 COM 的细节指导。

# 背景

最近看了 Pavel YosisofichWindows 10 System Programming_part2 这本书,并实践了第二十一章:COM,于是有了本文。

# COM 简述

COM 是 Component Object Model 的缩写,它提供了二进制级别的接口,而 c/c++ 提供的是源代码级别的接口。

COM 的诞生是为了解决 c/c++ 接口存在的诸多不便,以下举一个简单的例子。

我们使用一个 DLL 时(如果是我们开发的),会导入它的 lib,加上 dll 的头文件(包含导出函数)。比如这个 DLL 有一个导出函数 GetOneString,用户端调用如下:

//dll.h
extern "c" __declspec(dllimport) std::string GetOneString();
// client's main.cpp
#include "dll.h"
#pragma comment(lib, "dll.lib")
int main(){
    std::string a = GetOneString();
    std::cout << a << "\n";
    return 0;
}

这里,我们从 DLL 获取了一个 std::string 类型的变量,当 main 函数退出时, a 这个变量会被释放,如果这个变量表示的字符串足够长,那么 a 变量持有的堆块指针也会被释放。这时你的程序可能就崩了。因为 DLL 可能管理着自己的堆分配,然后用户端也管理着自己的堆分配。 a 变量析构时会用用户端的运行时库来释放 DLL 管理的堆块。详细的描述可参考 runtime 运行时库在 MT 与 MD 之间的差别

Windows 10 System Programming_part2 书中也有一个例子,其大意是在更新 DLL 二进制文件时,用户端无感知,在栈上分配的类实例大小与以前的不匹配,从而可能导致崩溃。

详情可参考书中的第二十一章。

而 COM 是二进制级别的接口,用户端是看不到 DLL 的实现的,只能看到 DLL 的接口,因此以上的问题都可以规避。比如第一个例子,COM 规定客户端与服务端使用字符串时,必须使用 HSTRING 类型。因为不使用 std::string 了,自然第一个例子的问题就不存在了。关于第二个例子,因为客户端看不到服务端代码的实现,所以从服务端返回类实例时,客户端只能获取到一个指针,这样就不存在类实例在客户端栈上分配的情况了。

# In-process DLL 服务端的细节

# In-process DLL 的实现

关于 COM 开发的详细流程指导,读者可参考 Windows 10 System Programming_part2 的第二十一章,本文重点记录了书中未提到的一些细节部分。

通过 DLL 方式提供功能的服务端是比较简单,容易理解的,它包含两个类和一个接口(结构体):

  • RPNCalculator.h::实现接口的类
  • RPNCalculatorInterfaces.h:定义导出的方法
  • RPNCalculatorFactory.h:工厂类

为了注册 DLL,DLL 需要自己实现以下三个函数(详情参考 dllmain.cpp):

  • DllGetClassObject
  • DllRegisterServer
  • DllUnregisterServer

# 客户端对 DLL 服务的调用

客户端的大致代码框架如下:

auto hr = CoInitialize(NULL);
//...
CComPtr<IRPNCalculator> spCalc;
spCalc.CoCreateInstace(...);
//...
CoUninitialize();

指定实现接口的类的 CLSID 时,一般有两种方法:

CComPtr<IRPNCalculator> spCalc;
//hr = spCalc.CoCreateInstance(__uuidof(RPNCalculator));
hr = spCalc.CoCreateInstance(CLSID_RPNCalculator);
if (FAILED(hr)) {
    std::cout << "last error" << (LPVOID)hr << "\n";
    return -1;
}
  • 通过__uuidof 的方式

    这种方式需要在 DLL 接口的头文件中声明类的 CLSID:

    class __declspec(uuid("D4B830A5-7DFC-4C81-9268-8BB0BEA7CACE")) RPNCalculator;
  • 通过定义 CLSID_RPNCalculator 变量的方式(类型是 GUID)

    DEFINE_GUID(CLSID_RPNCalculator,
    	0xd4b830a5, 0x7dfc, 0x4c81, 0x92, 0x68, 0x8b, 0xb0, 0xbe, 0xa7, 0xca, 0xce);

    这种方式需要注意的是,客户端在包含文件时,应该如下:

    // Come first, initguid.h has a INITGUID macro, which assigns 
    // DEFINE_GUID a command to define CLSID_RPNCalculator variable
    #include <initguid.h> 
    #include <Windows.h>
    #include <stdio.h>
    // cguid.h comes before any atl*.h header to get rid of no difinition error of GUID_NULL
    #include <cguid.h>
    #include <atlcomcli.h>
    #include <iostream>

# Out-of-process EXE server(local server)的细节

关于如何写 EXE Server, Windows 10 System Programming_part2 书中并没有阐述,于是我在网上搜索,不过相关的资料是出奇的少。。。。最后几经周折,我参考了 COM技术内幕———微软组件对象模型 书中的描述,完成了 EXE Server 的实验。

因为客户端和服务端在不同的进程里,因此存在跨进程的交互。为了统一地、标准地、简洁地处理这个场景,COM 使用了 proxy and stub(代理和残根)。由于 out-of-processin-process 在代码上没有多大变化,这里会重点描述新增的操作。

# 1 添加 IDL 文件

IDL 是 Interface Deginition Language 的缩写,其作用是通过微软的 MIDL 编译器生成接口类,便于跨平台(比如生成类型库,在 c++、C# 等不同的平台运行)、简化接口编写(比如代理和残根代码的自动化生成,在 out-of-process EXE server 的情况下,代理 / 残根的 DLL 是必需的),例子如下:

import "unknwn.idl";
[
	object,
	uuid("F24C4FC4-3667-421D-A144-0AC0DF90D0AF"),
	helpstring("Calculator interface"),
	pointer_default(unique)
]
interface IRPNCalculator : IUnknown
{
	HRESULT push([in] double value);
	HRESULT pop([out] double* value);
	HRESULT add();
	HRESULT subtract();
};

如果没有 proxy/stub 的 DLL,那么在客户端调用 CoCreateInstance 时,由于参数在跨进程的传输中没有定义传输方式(比如变量是输出参数还是输入参数),服务端收到的接口类 GUID 会是错误的,即不是 CoCreateInstance 指定的 GUID(在 win10 上测试是这样的)。

关于 IDL 的语法请参考 MSDN 或 COM技术内幕 一书。

# 2 用 MIDL 编译 IDL 文件

运行 VS 的命令行,执行以下命令:

midl idl-file-name.idl

以上命令会生成代理 / 残根需要的所有代码(代理和残根可以自己实现,MIDL 提供默认的实现)。生成的文件包括:

  • XX.h: 包含接口的定义
  • XX_i.c: 包含接口的 GUID 变量
  • XX_p.c: 包含代理和残根的代码实现
  • dlldata.c: 提供 DLL 所必需的导出函数(代理和残根的 DLL 所需要的,用于在注册表注册自己,注意只会生成一个 DLL,这个 DLL 包含了代理和残根的实现)

# 3 编写 Makefile,生成 DLL

all: midl app
.PHONY: all
PROXYSTUBOBJS = dlldata.obj \
				CalculatorTypeInfo_p.obj \
				CalculatorTypeInfo_i.obj
PROXYSTUBLIBS = kernel32.lib \
				rpcns4.lib \
				rpcrt4.lib \
				uuid.lib
# rpcndr.lib   -> rpcndr.lib is deprecated
midl:
# generate all parts(headers and source files) that our proxy dll need
	midl CalculatorTypeInfo.idl
app: $(PROXYSTUBOBJS) proxy_stub.def 
# generate proxy.dll used for proxy and stub.
	link /dll /out:proxy.dll /def:proxy_stub.def \
		$(PROXYSTUBOBJS) $(PROXYSTUBLIBS)
# regsvr32 by default writes registry configurations to HKLM/Software/CLSID
# , and the operation need administrator privilege.
# Therefore, you should run a cmd with administrator privilege.
	regsvr32 /s proxy.dll 
dlldata.obj: dlldata.c
	cl /c /DWIN32 /DREGISTER_PROXY_DLL dlldata.c
CalculatorTypeInfo_p.obj: CalculatorTypeInfo_p.c
	cl /c /DWIN32 /DREGISTER_PROXY_DLL CalculatorTypeInfo_p.c
CalculatorTypeInfo_i.obj: CalculatorTypeInfo_i.c
	cl /c /DWIN32 /DREGISTER_PROXY_DLL CalculatorTypeInfo_i.c
clean:
	del *.obj *.exp *.lib

注意 REGISTER_PROXY_DLL 这个宏,这个宏会自动生成 DLL 必需的导出函数。另外,makefile 里有一行命令:

regsvr32 /s proxy.dll

这行命令会将代理 / 残根的 DLL (proxy.dll) 注册到注册表中,这样客户端就可以像 in-process DLL 正常使用服务了。

# 4 客户端对服务的调用

包含 MIDL 生成的 XX.h 和 XX_i.c 文件,由于客户端还需要实现接口的类的 CLSID,因此我们需要在 XX_i.c 文件中加入对应的 CLSID,如下:

MIDL_DEFINE_GUID(IID, CLSID_RPNCalculator,0xd4b830a5, 0x7dfc, 0x4c81, 0x92, 0x68, 0x8b, 0xb0, 0xbe, 0xa7, 0xca, 0xce);

另外,因为 MIDL 生成的文件已包含了一些关于 GUID 的头,之前在 in-process DLL 添加的 initguid.hcguid.h 头文件就不需要包含了。