Windows桌面实现之八(DirectX HOOK 方式截取特殊的全屏程序之二)

                                                                  by fanxiushu 2019-04-18 转载或引用请注明原始作者。
接上文。
WIN7以上系统WDDM虚拟显卡开发(WDDM Filter/Hook Driver 显卡过滤驱动开发之一) 这篇文章,曾经提到过:
windows的应用程序中,绘图的基础图形库包括 GDI, DirectX, OpenGL(最新的可能还包括Vulkan)。
一切的windows界面都是这三种图形库绘制出来的。
GDI牵涉到windows的方方面面,GDI加速是在WDDM驱动内核进行硬件加速的。
而其中DirectX和OpenGL有对应的应用层驱动,也就是复杂图形基本上都是在应用层渲染的。
从理论上说,我们如果能截取到每个应用层程序的绘图操作,比如给每个界面程序注入一个DLL,截取绘图函数,
比如GDI中BitBlt以及相关WM_PAINT等消息, DirectX中Present,OpenGL中wglSwapBuffers等,获取到对应的RGB图像数据。
然后计算每个窗口Z序,判断遮挡情况,判断哪些该裁剪掉,然后再把这些RGB图像数据混合起来,就能获取整个屏幕的图像了。
然而这只是理论上,实际上这等同于是在做上篇文章所讲的,类似于windows桌面管理器一样的东西了。
一开始的时候,我也是这么想的,想给xdisp_virt增加另一种抓屏功能,就是类似HOOK的方式,
对DirectX,OpenGL窗口HOOK方式截图,然后判断这些DX,OpenGL窗口位置,然后整个桌面中剩下区域的全用GDI来截取。
结果做下来效果并不好,因此取消了。只专心处理全屏独占这种一般抓屏办法无法截取的情况。

把DLL注入到别的进程来截图,会增加被挂进程的运算负担,影响被挂进程的运行效率。容易造成被挂进程崩溃,
尤其是全世界各类程序那么多,虽然基础图形库就那么三个,但是使用它们的方式千奇百怪,因此截取的时候,
容易因为没考虑到某些情况而直接造成程序crash。
既然HOOK也不是好的方式,为何不一劳永逸的从驱动去解决。
可是也非常可惜,在驱动中的处理情况只会更加糟糕。WDDM驱动版本不断在升级,从WIN7的1.1 到WIN8的1.2, 1.3,再到
WIN10 的2.0一直到现在2.5,像是坐火箭炮似的。而且WDDM驱动中并没一个通用的类似于简单的 FrameBuffer 的接口。

而实际上GDI画的图我们都可以使用BitBlt 截取,
DirectX,OpenGL画的图,如果是一般的窗口模式,因为要经过桌面管理器的混合,也一样可以使用BitBlt截取。
剩下的基本就是独占模式,窗口管理器失效这种情况了。
可是WIN8以上的系统使用DXGI Desktop Duplication 也能处理这种全屏独占。
(我没测试过所有情况,因为我安装WIN10 的机器是intel集成显卡,不清楚独显的情况,尤其是很强劲的独显,几乎支持所有显卡硬件加速。
况且在测试 “鬼泣2” 这个游戏时候,使用DXGI时好时坏,有时能成功截取,有时又会失败,还不如mirror稳定--是使用xdisp_virt程序测试的)

再来看看BitBlt跟DXGI效率问题比较。
其实不管是BitBlt还是DXGI,都需要把RGB图像数据从显存Copy到内存中,
而这个Copy速度谁也不会比谁高明,都是老老实实的数据复制。
都是使用DMA传输数据。
在以前文章讲到 DXGI截屏方式,其中 ID3D11DeviceContext->CopyResource 就是复制整个 ID3D11Texture2D 纹理,
DXGI截屏中的两个纹理,一个在显存,一个在内存,就会启动DMA传输,把整个RGB图像数据复制到内存。
CopyResource好处是异步传输,函数会马上返回, 我们接着可以做别的事,电脑在后台处理传输数据。
而BitBlt是阻塞的,直到传输完成才会返回。
DXGI截屏还有一个很大好处,就是能实时捕获绘图操作,因为电脑可能很长时间都不会绘图,处于静止状态。
这也是跟mirror驱动一样值得称道的地方。可以想想,假设电脑在5秒内什么绘图操作都没发生,
按照30fps的速度,BitBlt因为不知道是否发生了绘图操作,需要在这5秒内截图 5*30=150 次,而DXGI在这5秒内啥都不用做。

回到正题,独占模式都是DirectX和OpenGL绘图的专利。
而DirectX的版本非常多,8, 9, 10, 11, 12 一共五个版本,其实还有之前的 DirectX7, 6, 5等,
因为太老,现在的WIN7以上平台中已经不存在了,WINXP中也找不到他们的身影了。不过DDRAW还保留着。
因此我们也是从DX8开始进行HOOK,至于OpenGL因为HOOK方法比DX简单得多,也好处理。

先别被这么多的DirectX版本吓到,其实只需要掌握他们的核心处理思路就好办多了,
我们这里只是截取经过渲染之后的RGB图像数据,而不是要我们自己去渲染图像。

早在DDRAW中,或者我们使用GDI绘制普通桌面程序的时候,都曾使用到一种叫后台缓存技术,
就是先创建一个后台 DC, 
memdc = CreateCompatibleDC(displaydc); 其中memdc是后台DC,displaydc就是显示dc,
然后我们在memdc中绘制各类图像,
最终使用 BitBlt 把 memdc 翻转到 displaydc中,这样屏幕中就呈现了我们绘制的图像。
因为如果直接在displaydc上画图的话,屏幕闪烁得非常厉害,闪烁得简直是无法直视。
这也是我们绘图的通用做法:就是在后台缓存中先画图,画好之后以极快的速度提交到前台显示,
然后接着在后台画图,然后再提交,如此循环往复。
现在谁也不会傻到直接在前台绘图,尤其是动画。

既然是绘图的通用做法,DirectX和OpenGL也不例外,都是按照这种思路来展现图像的。这就是核心思路所在。
我们只要在DirectX和OpenGL把后台缓存数据展现到前台之前,获取到它画到后台缓存中的RGB图像数据,就能截取到程序绘制的图像了。
他们把后台数据展现到前台,总得需要调用一个函数,就像GDI中BitBlt一样。
在Directx中是Present函数,而且各个版本的DX中都有这样的函数,而OpenGL中的是wglSwapBuffers 。
我们只要HOOK这样的函数,就能成功截取到图像了。

下图摘自MSDN关于WDDM的介绍。WIN7以上平台关于DirectX和WDDM的交换流程
https://docs.microsoft.com/en-us/windows-hardware/drivers/display/windows-vista-and-later-display-driver-model-operation-flow
Windows桌面实现之八(DirectX HOOK 方式截取特殊的全屏程序之二)_第1张图片

这个图很复杂,简单解释一下。
首先我们创建一个3D设备,比如DirectX11 中
D3D11CreateDevice 函数创建一个DX11设备,
调用此函数的时候,Direct3DRuntime(应用层的3D系统组件,以下简称 DxRuntime)就会与
DIrectX Graphic Kernel System(就是dxgkrnl.sys内核系统组件,以下简称dxgkrnl)通讯,然后dxgkrnl就会调用显卡内核驱动的DxgkDdiCreateDevice回调函数,然后显卡就会创建各种内核资源。
成功之后,DxRuntime接着调用应用层的显卡驱动,应用层显卡驱动的CreateDevice回调函数就会被调用,
在CreateDevice回调函数中接着调用 DxRuntime的 pfnCreateContextCb 来分配一个设备资源,然后做些其他初始化工作。
这样一个3D设备就创建成功了。
当我们在DX要创建一个表面,比如DirectX11调用 CreateTexture2D 创建一个纹理,
DxRuntime就会调用显卡应用层驱动的CreateResource回调函数,在CreateResource中再次调用 DxRuntime的pfnAllocateCb,
此函数中,DxRuntime 会跟dxgknrl通讯,让dxgknrl调用显卡内核驱动的DxgkDdiCreateAllocation回调函数分配相关的内核资源。

接着就是绘图,渲染等等各种3D绘图指令。

画完图之后,上面说过的,需要把画好的后台缓存图像最终提交到显示终端,
于是在应用程序中调用DirectX的Present函数,比如 IDXGISwapChain->Present ,
于是 DxRuntime会调用显卡应用层驱动的Present回调函数,
在这个Present回调函数中做些其他工作,然后反过来再调用DxRuntime的pfnPresentCb, 于是DxRuntime提交命令到dxgknrl,
dxgknrl调用显卡的内核驱动的DxgkDdiPresent回调函数, 接下来全是显卡内核驱动需要完成的事。
上图从 10-16,主要目标把这个后台图像缓存提交到最终的显示终端。

可以看出流程之复杂。
还好,我们只需要折腾应用层中的Present就可以了,上面的图示主要为了加深对Present的认识理解,不理解也不影响后面的HOOK。
接着看看Present都隐身在各个DirectX版本的何处。
开始前,先来理解一个叫交换链的概念。

前面说过了,我们在电脑上画图,基本上都是在后台画,画好之后才最终把整个画好的提交到前台显示。
假设前台是 front_buffer,后台可能有多个备用buffer,比如 back_buffer1,back_buffer2,形成一个连接队列链条
back_buffer1画好之后提交给front_buffer,提交完成后再挂载到队列尾部。
这个时候原来的back_buffer2变成1, 原来的back_buffer1跑到后面去了。如此循环。
而如果只交换指针的Flip,就是back_buffer1画完之后,直接把前台指针指向back_buffer1,这个时候它变成 fron_buffer了,
原来的front_buffer再被挂载到队列尾部。这样其实就形成了一个环形队列,称为交换链,就是不停在做数据交换。
Present函数内部就是维护着这样的一个环形队列,每调用一次Present,相应的Buffer就改变一次。

DirectX8中,Present主要出现在 IDirect3DDevice8 接口中,每个IDirect3DDevice8 设备至少包含一个隐含的主交换链,
同时可以调用 CreateAdditionalSwapChain 创建一个附加交换链,交换链接口是 IDirect3DSwapChain8。
DirectX9 跟DirectX8比较类似,Present主要出现在 IDirect3DDevice9中,每个IDirect3DDevice9至少包含一个隐含主交换链。
同时DirectX9中,我们还可以调用GetSwapChain 获取到这个隐含的主交换链,这是跟DX8不同的地方。
同时可以调用 CreateAdditionalSwapChain  创建一个附加交换链,接口是 IDirect3DSwapChain9,
这里还有一个地方,就是 IDirect3DDevice9Ex接口中还提供了一个叫PresentEx的扩展函数。
可以看出 ,DirectX8,DirectX9中,交换链和具体的3D设备是不分家的,混杂在一起。
而到了DX10以后,微软专门把 交换链独立出来专门实现,取名叫DXGI,接口名字叫 IDXGISwapChain。
具体实现在 dxgi.dll 动态库中, 我们可以从接口变化中看出了他们对DirectX设计得进步和完善。

现在来总结一下在各个版本的DirectX中主要HOOK哪些函数:
DirectX8: 
         IDirect3DDevice8接口中的Present函数, Reset函数(在设备丢失的时候清理我们自己创建的资源)
         IDirect3DSwapChain8中的Present 函数,
         还可以HOOK Release函数,用于在设备释放之后清理我们创建的资源。
         对应动态库是d3d8.dll

DirectX9:
         IDirect3DDevice9接口中的Present函数, Reset函数,
         IDirect3DDevice9Ex接口中的PresentE下函数, ResentEx函数
         IDirect3DSwapChain9接口中的 Present函数。
         还可以HOOK Release函数,用于在设备释放之后清理我们创建的资源
        对应动态库是 d3d9.dll

DirectX10,11,12:
        IDXGISwapChain 接口中的Present函数 ,ResizeBuffer函数,
        还可以HOOK Release函数,用于在设备释放之后清理我们创建的资源
        对应动态库是 dxgi.dll

HOOK采用inline的方式,就是替换函数的前5个字节实现直接跳转,使用微软的detours开源库,当然可以使用任何其他一样的开源库,
比如 minihook, easyhook等,都是一样的。
以detours为例,
void* real_msgbox= MessageBoxA; //设置原来的函数地址
int CALLBACK my_MessageBox( HWND hWnd, const char* lpText,const char*  lpCaption, UINT uType)
{  
   printf("this is my msgbox.\n");
   return real_msgbox(hWnd, lpText, lpCation, uType);
}

DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach((PVOID*)&real_msgbox, my_MessageBox);  // inline hook
DetourTransactionCommit();
就这么简单。

DirectX使用的COM库,都是C++的纯虚类,而inline HOOK都是具体的C标准格式的函数。看起来不好HOOK。
其实也是非常简单。
把HOOK相关代码写到 .c 后缀的纯 c源代码文件中,所有接口调用都变成C函数格式了。比如:
IDXGISwapChain* swap;
swap->lpVtbl->Present(swap, .....);
获取到swap对象之后,简单的说就是IDXGISwapChain数据结构的swap变量之后,
Present函数地址就是 swap->lpVtbl->Present
这比要定位C++虚拟表,然后一个个的去数Present在哪个位置,然后硬编码要好得多。

前一篇文章中简单提到,把我们的dll注入到别的进程的办法,当注入dll后,DllMain入口函数就会被调用。
我们在 DLL_PROCESS_ATTACH 中创建一个线程,这个线程就是定时检测并且HOOK对应的DX。
当然,还包括其他一些数据处理,毕竟需要把别的进程中获取到的图像数据传递到我们自己的进程中。

如何检测并且Hook呢?以DirectX11 为例。
当某个进程是以DirectX11画图的,它必定会调用d3d11.dll这个动态库。于是检测的时候调用 GetModuleHandle("d3d11.dll");
判断是否加载了d3d11.dll,如果加载了,再判断 dxgi.dll是否加载,因为DX10以后的Present函数都实现在dxgi.dll中。
如果都加载了,说明这个程序是使用D3D11来画图。
于是调用 D3D11CreateDeviceAndSwapChain获取到 IDXGISwapChain的接口变量swap,从而进一步获取到 Present函数地址。
接着调用detours来HOOK这个Present函数。
这里可能会有个疑问,担心这个Present函数只是我们自己调用D3D11CreateDeviceAndSwapChain创建的函数地址,
而不是程序中其他同样的交换链的Present地址,其实熟悉C++特性的,都不会这么问,同一个类的函数,
被编译器最终生成的都是同一个固定的函数,只是第一个参数是this指针而已。

HOOK成功了之后,当程序要交换后台数据,我们hook的myPresent就会被调用,然后我们在myPresent中判断是否第一次调用,
是的话,创建相关的资源,比如创建一个CPU可以访问的Texture2D纹理表面,以及其他相关初始化。
然后调用 IDXGISwapChain->GetBuffer获取到第一个后台缓存,这个就是即将要被展现到前台的后台图像数据缓存区。
然后调用CopyResource复制RGB数据到我们创建的Texture2D纹理中,接着Map这个纹理,就可以从中获取到原始的RGB图像数据了。
获取到图像数据之后,需要传递到我们自己的进程中,这个时候使用共享内存方式是最高效的。
但是有些程序,比如UWP程序,是不能访问共享内存的,可以使用WM_COPYDATA传递消息方式,但是这种方式效率比较低。

DirectX10,DirectX9,DirectX8,基本是类似做法。DirectX12接口比较新,但是可以把它转成DirectX11的方式进行处理。

至于OpenGL的HOOK这里也就不再赘述了,差不多都是一样。
只是关注的是主要HOOK wglSwapBuffers函数,以及怎样从OpenGL后台缓存获取图像数据。

需要注意的是,inline hook之后,就不要 unhook了,同时注入的dll,除非进程自己结束,也不要中途退场了。
否则程序崩溃的几率更大,尤其是inline HOOK之后,因为你也HOOK,他也HOOK,无非就是更加增加程序的运行负担。
HOOK了不UnHook倒也没什么大问题,会形成一个调用链。
UnHook并且UnHook顺序不对,就会造成程序崩溃。

下图是xdisp_virt程序采用DXHOOK的方式远程显示WIN7中 ”极品飞车17“ 的画面:
Windows桌面实现之八(DirectX HOOK 方式截取特殊的全屏程序之二)_第2张图片

不过目前因为鼠标键盘是在应用层进行模拟的,所有暂时还无法在浏览器中用鼠标键盘控制游戏。
新版本的xidsp_virt还支持多显示器的显示,如下三个显示器合并在一起,大小是 4468X2160
整个画面像狗啃了似的:
Windows桌面实现之八(DirectX HOOK 方式截取特殊的全屏程序之二)_第3张图片

有兴趣可关注:
https://github.com/fanxiushu/xdisp_virt
上的最新版本。
 

你可能感兴趣的:(windows,音视频,截屏,C++,windows,多媒体,音视频)