您现在的位置是:首页 >技术杂谈 >d3d(Direct X)中的com技术详解网站首页技术杂谈

d3d(Direct X)中的com技术详解

杀神李 2024-06-17 11:19:03
简介d3d(Direct X)中的com技术详解

本文不会对Com进行非常详细的分析 因为这个技术分析起来难度还是非常大的 要想真正弄懂还是非常困难的 我只会针对d3d中使用到的com技术和comptr技术进行说明 所以看完本文后 可以熟练使用d3d中使用到的相应技术

comptr类似于c++11中的智能指针,用于管理com对象的生命周期,所以我们先会讲解com对象的知识,然后才会讲解comptr的相关内容

 为什么微软要推出COM技术?

一言以蔽之 为了彻底解决二进制兼容的问题(即生成的dll文件可以被不同语言和不同编译器生成的可执行文件所加载和调用,并且可以随便升级dll文件而不会影响到原来的老代码) 但是为了解决这个问题实在是有太多技术难点要克服。

所以导致com技术的实现原理现在异常复杂 那么问题就来到了 所谓的二进制兼容问题到底是什么?

假设我们现在使用c++代码编写了一个类 并且生成了dll文件提供给别人使用

我们的头文件mydll.h如下:

class __declspec(dllexport)  MyClass
{
public:
	MyClass(int i);
	int TestFunc();

private:
	int member_age_;
};

mydll.cpp源文件如下:

#include"mydll.h"

MyClass::MyClass(int i)
{
	member_age_ = i;
}

int MyClass::TestFunc()
{
	member_age_++;
	return member_age_;
}

我们生成为dll后 给别人使用 在别人的代码里会像这种方式来使用我们的代码:

#include"mydll.h"
int main()
{
    MyClass* temp=new MyClass(1);
    //做一些事情



    delete temp;
    return 0;
}

这段代码看似没有任何问题, 但是当我们更新了我们的dll文件过后 如在MyClass类中添加了新的成员变量,这段代码将直接导致问题,因为new的时候是先调用底层的malloc分配内存 而老版本的dll中只有一个int变量,新的则不是 所以malloc分配的内存大小就会出问题.直接就会导致你的内存访问越界

所以!如果我们想让用户能够不重新编译出新的exe 直接替换dll文件就能使用我们新的dll 那么我们就不能够让用户写的代码中来为我们写好的类分配内存,应该让我们自己来分配

所以现在我们改造了我们的头文件,导出了创造对象的函数,让用户的代码中不再能够直接new我们的类

mydll.h


class __declspec(dllexport)  MyClass
{
public:
	MyClass(int i);
	int TestFunc();

private:
	int member_age_;
};
__declspec(dllexport) MyClass*  create();

mydll.cpp

#include"mydll.h"

MyClass::MyClass(int i)
{
	member_age_ = i;
}

int MyClass::TestFunc()
{
	member_age_++;
	return member_age_;
}

MyClass* create()
{
	return new MyClass(1);
}

现在用户使用我们的类的方式如下:

#include"mydll.h"
int main()
{
	MyClass* i = create();
	i->TestFunc();
}

这样即使我们更新了我们的dll,用户exe文件也不需要重新编译,可以无缝直接使用

但是现在我们又面临一个问题,我们该如何让用户来释放这个由create函数申请的内存呢?

很多小伙伴可能直接就会把代码写成如下的样子

#include"mydll.h"
int main()
{
	MyClass* i = create();
	i->TestFunc();
    delete i;
}

但是这样是没法去真正释放内存的!为什么呢?因为每一家编译器厂商对于malloc的底层实现都不一样 所以delete会导致内存释放错误!delete是先调用析构函数,然后再调用free,问题就在于这个free,如果比较了解malloc的小伙伴就会知道,内存管理是一个比较麻烦的过程,会有很多额外的内存空间来记录当前内存块的状态,大小等东西 一块简化的由malloc申请的内存块如下所示


问题就在于什么呢?不同的c标准库对于该实现不是统一的!也就是说,可能msvc和gcc分配的内存块中他们的内存布局不一致,如果你的dll是使用msvc编译器编译的而用户的编译器是gcc那么调用free将会直接导致内存释放错误!

基于以上原因,我们现在也不能让用户手动的调用delete释放我们的内存了,我们继续改写我们的头文件如下


class __declspec(dllexport)  MyClass
{
public:
	MyClass(int i);
	int TestFunc();

private:
	int member_age_;
};
__declspec(dllexport) MyClass*  create();
__declspec(dllexport) void release(MyClass* temp);

我们的源文件如下

#include"mydll.h"

MyClass::MyClass(int i)
{
	member_age_ = i;
}

int MyClass::TestFunc()
{
	member_age_++;
	return member_age_;
}

MyClass* create()
{
	return new MyClass(1);
}

void release(MyClass* temp)
{
	delete temp;
}

用户的使用方式如下:

#include"mydll.h"
int main()
{
	MyClass* i = create();
	i->TestFunc();
    release(i);
}

现在似乎一切都安好了,用户不能直接使用new和delete 我们的dll可以无缝更新了,更新之后exe并不会收到影响,但是我们现在又有一个问题了,如何让用户的不同类中共享多份相同的实例呢?你可能会说,那不是很简单嘛?指针都是共享的,直接复制指针不就在不同类中共享多份实例了嘛?

但是问题就在于,你什么时候应该释放该资源呢?比如我A类有一个实例,B类也有一个实例,A类的析构函数会调用release释放内存,B类的也会,那么如果我们不加上控制,是不是直接就重复释放了呢?

基于这种情况 我们就应该给我们的实例加上引用计数

关于引用计数,详情可参考我的这篇博客,对于引用计数有详细解释,这里就不过多叙述了

本质和智能指针中的引用计数是一样的

是时候来点现代c++了 c++11之超级重要之smart pointer详细解析_std::shared_ptr<std::string> (new std::string());_杀神李的博客-CSDN博客

所以我们应该再在头文件加上如下的接口


class __declspec(dllexport)  MyClass
{
public:
	MyClass(int i);
	int TestFunc();

private:
	int member_age_;
};
__declspec(dllexport) MyClass*  create();
__declspec(dllexport) void release(MyClass* temp);
__declspec(dllexport) bool addref(MyClass* temp);

 当我们想共享实例的时候我们调用addref增加引用计数,我们想释放的时候用release减少引用计数,当引用计数为0,释放资源

目前为止,我们仿佛已经真正的解决二进制兼容的问题了,可以随意更新我们的dll文件

所以我们dll中所有的类是不是都应该有这三个接口呢?是的 微软也是这么觉得的 所以微软做了一个最底层的基类 所有的基于com技术的类都应该继承这个类 名字叫做IUnknown(interface unknown)意思就是接口未确定的 这个类是个纯虚类,只规定了所有类都应该有的方法 IUnknown类一共有三个接口

AddRef():增加引用计数

Release():减少引用计数

QueryInterface():用来检查是否可以做向下转型的,因为让用户直接使用强转是很危险的,所以向下转型应该由dll内部来判断

那么问题来了,微软如何判断是否能够安全的向下转型呢?

微软引用了三个ID,guid,iid,clsid来判断

从上来看,所有的COM类其实都继承了IUnknown。但是,我拿个IUnknown接口有毛用啊,我还是需要把它转为我的具体类才行。假设有汽车类Car,它继承于ICar,像这样:

IUnknown* pUnk = NULL;

CreateCar(&pUnk);//这个函数就是我们上文提到的头文件里的create函数

在d3d中该create函数就为D3D12CreateDevice等函数

ICar* pCar = (ICar*)pUnk;

这样,我们拿到ICar指针才有意义。

但是微软认为,直接由用户来转型是不安全的,比如,你怎么知道pUnk一定可以转成ICar*呢。除此之外,ICar这个类不具有唯一性,我们需要唯一的一个标识符来确定一个类,那么这个标识符就是GUID。类ID就叫作CLSID,接口ID就叫作IID。我们需要一个转型的函数叫QueryInterface

QueryInterface作为IUnknown中的一个纯虚函数,做的事情其实很简单,判断自己能不能转成某个GUID所指向的类而已。如果不可以,则返回E_NOTIMPL.可以的话返回S_OK,并将转换后的指针作为参数返回,代码类似如下,可以体会一下:

public class Car : IUnknown, ICar
{
HRESULT QueryInterface(REFIID riid, void **ppvObject)
{
if (ISEQUAL_IID(riid, IID_ICar)) //riid和ICar的IID相同,说明可以转换成ICar
{
*ppvObject = static_cast<ICar*>(this);
return S_OK;
}
else if (ISEQUAL_IID(riid, IID_IUnknown))
{
*ppvObject = static_cast<IUnknown*>(this);
return S_OK;
}
return E_NOTIMPL;
}
}

一个真正的QueryInterface要做的事情还要多一点,如增加引用计数等,这里就不多说了。

外部是这样调用:

ICar* pCar = NULL;

pUnk->QueryInterface(IID_ICar, (void**)&pCar);

这样,我们就从pUnk得到了个ICar*。

基于这种情况微软又做了封装,既然我每个类都有对应的ID了 那么我的create函数是否可以统一化呢?

所以微软常见的create函数就被封装为了下面这种形式:

HRESULT CoCreateInstance(
  [in]  REFCLSID  rclsid,
  [in]  LPUNKNOWN pUnkOuter,
  [in]  DWORD     dwClsContext,
  [in]  REFIID    riid,
  [out] LPVOID    *ppv
);

第一个参数:待创建组件的CLSID。

第二个参数:用于聚合组件。在d3d中用不上,不用了解,本文不再讨论

第三个参数:dwClsContext的作用是限定所创建的组件的执行上下文。在d3d中用不上,不用了解,本文不再讨论

第四个参数:iid为组件上待使用的接口的iid。

CoCreateInstance 将在最后一个参数中返回此接口的指针。通过将一个IID传给CoCreateInstance,客户将无需在创建组件之后去调用 其QueryInterface函数。

第一个参数就是指定了,你所需要的类的clsid,第四个参数指定了你所需要的接口的iid,但是这时候又有问题了?我怎么知道我需要的clsid和iid是多少啊? chatgpt回答如下,因为本文只涉及到d3d中的com技术,而com技术本身并不需要clsid,仅需要iid,而iid是可以通过msvc关键字__uuidof来获取的

 但是! 注意! 对于d3d中的com技术 均有特殊create函数使用,而不是依赖最普通最通用的CoCreateInstance函数,比如D3D12CreateDevice函数,所以我们并不需要去查询clsid,直接使用uuid去获取接口id即可!

 到现在我们真正的搞懂了com技术 现在来让我们看看com技术是如何在d3d中运用的吧,看一个小例子:

IUnknown* pDevice;
D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, __uuidof(ID3D12Device), (void**)&pDevice);
ID3D12Device* pDevice1;
pDevice->QueryInterface(__uuidof(ID3D12Device), (void**)&pDevice1);

现在我们就可以拿到pDevice1真正的子类指针去操作了!

恭喜 你现在彻底搞懂了d3d中使用到的com技术!

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。