文章目录
- 如何在云电脑串流中实现多屏操作——WDDM虚拟显示器开发
- 1. 概述
- 2. DxgkInitialize
- 3. HOOK框架
- 4. VIDPN
- 5. 虚拟显示器
- 6. 实现效果
如何在云电脑串流中实现多屏操作——WDDM虚拟显示器开发
“虚拟显示器”是一种新型的计算机图形显示端口技术,它可以将一台计算机的屏幕,通过虚拟化出多个独立的区域单独显示出来,每个显示器都可以同时运行多个应用程序,以便用户可以更容易地管理和操作他们。虚拟显示器在当前的云电脑或办公应用中,有着这十分重要的作用,可以解决如下的场景问题:
场景1:显示器需要进行多屏拓展,但外接端口不足
通常,我们看到的显示器都是连接在HDMI或者VGA等接口的。但是,在市场上我们可以看到一些产品,通过USB转HDMI(VGA)拓展坞,连接显示器,可以扩展显示适配器上面原有接口的限制,可以实现复制屏或者扩展屏
场景2:高性能GPU的云电脑如何获取显卡的显示数据
在云桌面的应用中,往往需要在1台宿主机上通过vGPU的给10台虚拟机提供算力,这种方式对通常的办公来说没什么问题;
但是近年基于AI的浮点算力需求,特别是这2年的高GPU性能的VDI里,需要给3D设计,游戏引擎等行业提供充足的算力,所以经常会采用显卡透传的方式,让每块显卡给每个虚拟机单独享用。这情况下就需要在虚拟机/宿主机上安装显卡的硬件驱动,这也会面临着如何采集数据的问题。
场景3:远程控制时进行云办公,或者云游戏时,希望多屏操作
在远程控制虚拟机或者宿主机时,我们想通过平板、手机多设备联合进行多屏进行操作,提升办公效率;或者玩云游戏时,游戏分辨率不想被拉伸,需要深度适配当前屏幕分辨率,这就需要用到虚拟显示器,来进行windows的虚拟显示器插入
那么,这些解答以上问题,都可以通过“虚拟显示器”技术完成,这里有两项技术需要解决:
1. USB可以将图像等信息通过转接线转换成HDMI等信号,让显示器显示数据。
2. 系统需要创建一个虚拟的显示器,让系统识别到有插入两个显示器。
本文我们探究一下虚拟显示器的实现技术:
这里是基于Windows 10 之前版本的WDDM虚拟显示器的开发,在Windows 10 1067版本之后,Windows主动支持了显卡过滤框架,可以直接通过Indirect Display Driver来实现。
但是在Win7这些系统,微软并没有提供相关框架;只能采用mirror驱动,因为显卡特性是,得插入显示器后显卡驱动才能正常工作,所以我们这里提供一种HOOK显卡驱动,然后模拟DXGK的各种请求,伪造一个显示器来实现虚拟显示器,从过滤驱动获取到显示画面
这样就可以通过USB或者网线等更广泛廉价的接口,或者完成透传显卡
1. 概述
我们先来看一下WDDM显示驱动模型的框架图,如下:
通过这个图我们可以发现,无论是D3D,OpenGL还是GDI绘图,最终在内核层都是通过Dxgkrnl.sys模块来实现;Dxgkrnl.sys取代了XP下面的Win32k.sys,成为了新一代的图像显示子系统库。
DirectX图形库都需要在应用层提供用户模式的驱动,这样做的目的是可以在应用层渲染图像,这样大量的图像相关计算全在用户层完成。
无论是D3D,OpenGL还是GDI绘图最终的图像都是通过Dxgkrnl.sys驱动,提交给内核模式的显卡驱动去管理和分配(Display Moinport Driver)。内核模式的驱动的功能是对资源的管理分配,显存和内存之间的DMA数据传输, GPU管理等。
对于当前的显卡设备按照功能将它分成显示和计算两类,因此针对我们的驱动来讲一般可以有如下三种:
- Display Only Driver仅仅用作显示,并不支持运算功能。
- Render Only Driver仅仅支持运输,不支持显示(一般这种我们很少见)。
- Full Graphics Driver(Complete Function Driver)全功能驱动,既支持显卡的显示功能,又支持运算功能。
2. DxgkInitialize
对于Display Miniport Driver驱动,通过调用DxgkInitialize
来注册回调函数来实现的,这个是Miniport驱动的基本框架。在MSDN上面我们可以找到实例:
NTSTATUS
DriverEntry(IN PDRIVER_OBJECT DriverObject,IN PUNICODE_STRING RegistryPath)
{DRIVER_INITIALIZATION_DATA DriverInitializationData = {0};PAGED_CODE();//...DriverInitializationData.Version = DXGKDDI_INTERFACE_VERSION;DriverInitializationData.DxgkDdiAddDevice = AtiAddDevice;DriverInitializationData.DxgkDdiStartDevice = AtiStartDevice;DriverInitializationData.DxgkDdiStopDevice = AtiStopDevice;//...return DxgkInitialize(DriverObject,RegistryPath,&DriverInitializationData);
}
DxgkInitialize
作为一个核心函数,我们看一下这个函数的具体流程;这个也是我们虚拟显示器需要实现的核心点。
NTSTATUS DxgkInitialize(_DRIVER_OBJECT *DriverObject, _UNICODE_STRING *RegistryPath, _DRIVER_INITIALIZATION_DATA *DriverInitializationData)
{NTSTATUS Status;//...Status = DlpLoadDxgkrnl(&FileObject, &DxgDeviceObject);if (Status >= 0 || Status == STATUS_IMAGE_ALREADY_LOADED){Status = DlpGetIoctlCode(&Information);if (Status < 0){goto Exit;}for (i = Information; ; i = 2293823){Status = DlpCallSyncDeviceIoControl(DxgDeviceObject, i, 1, 0, 0, &DpiInitialize, 4u, &Information);Status = v9;if (v9 != STATUS_INVALID_DEVICE_REQUEST)break;}}if (Status >= 0){//...return DpiInitialize(DriverObject, RegistryPath, DriverInitializationData);}//...
}
这里我们通过DlpCallSyncDeviceIoControl
来获取到一个函数地址DpiInitialize
,这里实际调用的就是DpiInitialize
函数了。
3. HOOK框架
因此我们的HOOK原理比较简单了,就是替换DpiInitialize
函数达到HOOK的目的。因为DpiInitialize
这个函数通过DeviceIoControl
来获取,因此我们拦截IOCTRL的请求,就可以获取到函数地址了。
这个拦截过程是非常简单的,属性DWM驱动的就非常熟悉了:
IoCreateDevice
创建一个设备对象。- 通过
IoAttachDeviceToDeviceStack
挂载到DXGRNL.SYS上面即可。
因此我们可以得到HOOK基本框架
NTSTATUS HookDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{PIO_STACK_LOCATION IoStackLocation = IoGetCurrentIrpStackLocation(Irp);switch (IoStackLocation->MajorFunction){case IRP_MJ_CREATE:break;case IRP_MJ_CLEANUP:break;case IRP_MJ_CLOSE:break;case IRP_MJ_INTERNAL_DEVICE_CONTROL:if (irpStack->Parameters.DeviceIoControl.IoControlCode == IOCTL_XXXX){//HOOK 函数地址return STATUS_SUCCESS;}break;}return DispatchToLower(DeviceObject, Irp);
}
因此我们得到了DpiInitialize
的函数地址,通过替换我们就可以进行具体显卡驱动回调函数的HOOK了。
NTSTATUS HookDpiInitialize(PDRIVER_OBJECT DriverObject,PUNICODE_STRING RegistryPath,DRIVER_INITIALIZATION_DATA* DriverInitData)
{//...DriverInitData->DxgkDdiAddDevice = DxgkDdiAddDevice;DriverInitData->DxgkDdiRemoveDevice = DxgkDdiRemoveDevice;DriverInitData->DxgkDdiStartDevice = DxgkDdiStartDevice;DriverInitData->DxgkDdiStopDevice = DxgkDdiStopDevice;//...
}
4. VIDPN
上面我们已经完成了HOOK框架了,可以开始进行显卡回调函数的编写了;但是在完成显卡回调函数的实现之前,我们需要掌握一个概念VIDPN(Video Present Network),这个是一个非常抽象的概念。MSDN上面说的比较详细,下面用最简单的方式来介绍一下,希望能够理解。
VIDPN是一个视频网络的软件抽象模型,在这个抽象模型中包括如下概念:
- 拓扑图。
- 路径。
- 源模式集合。
- 目标模式集合。
在上图中,可以得到如下关系:
- 一个VIDPN中包括一个拓扑。
- 一个拓扑对映多个路径。
- 一个路对应一个目标。
- 一个源可以在多个路径中对应多个目标。
- 一个VIDPN包括多个源模式集合(每个源有自己的模式集合)。
- 一个VIDPN包括多个目标模式集合(每个目标也有自己的模式集合)。
VIDPN在驱动中通过D3DKMDR_HVIDPN
来标识,我们通过DXGKRNL提供的函数DxgkcbQueryvidpninterface
来获取DXGK_VIDPN_INTERFACE
:
DXGKCB_QUERYVIDPNINTERFACE DxgkcbQueryvidpninterface;NTSTATUS DxgkcbQueryvidpninterface(IN_CONST_D3DKMDT_HVIDPN hVidPn,IN_CONST_DXGK_VIDPN_INTERFACE_VERSION VidPnInterfaceVersion,DEREF_OUT_CONST_PPDXGK_VIDPN_INTERFACE ppVidPnInterface
)
{...}
DXGK_VIDPN_INTERFACE
结构定义如下:
typedef struct _DXGK_VIDPN_INTERFACE {DXGK_VIDPN_INTERFACE_VERSION Version;DXGKDDI_VIDPN_GETTOPOLOGY pfnGetTopology;DXGKDDI_VIDPN_ACQUIRESOURCEMODESET pfnAcquireSourceModeSet;DXGKDDI_VIDPN_RELEASESOURCEMODESET pfnReleaseSourceModeSet;DXGKDDI_VIDPN_CREATENEWSOURCEMODESET pfnCreateNewSourceModeSet;DXGKDDI_VIDPN_ASSIGNSOURCEMODESET pfnAssignSourceModeSet;DXGKDDI_VIDPN_ASSIGNMULTISAMPLINGMETHODSET pfnAssignMultisamplingMethodSet;DXGKDDI_VIDPN_ACQUIRETARGETMODESET pfnAcquireTargetModeSet;DXGKDDI_VIDPN_RELEASETARGETMODESET pfnReleaseTargetModeSet;DXGKDDI_VIDPN_CREATENEWTARGETMODESET pfnCreateNewTargetModeSet;DXGKDDI_VIDPN_ASSIGNTARGETMODESET pfnAssignTargetModeSet;
} DXGK_VIDPN_INTERFACE;
从DXGK_VIDPN_INTERFACE
这个结构出发,我们能够获取到VIDPN包括的所有信息(拓扑,路径,模式等)。
5. 虚拟显示器
启动显卡适配器的时候,Dxkgrnl会调用回调函数BddDdiStartDevice
,在DOD示例代码中这个函数实现如下:
NTSTATUS BASIC_DISPLAY_DRIVER::StartDevice(_In_ DXGK_START_INFO* pDxgkStartInfo,_In_ DXGKRNL_INTERFACE* pDxgkInterface,_Out_ ULONG* pNumberOfViews,_Out_ ULONG* pNumberOfChildren)
{PAGED_CODE();//...*pNumberOfViews = MAX_VIEWS;*pNumberOfChildren = MAX_CHILDREN;return STATUS_SUCCESS;
}
在这个函数中有两个参数返回的是外接子设备的信息:
pNumberOfViews
返回的源数目。pNumberOfChildren
返回子设备的数目(显示接口)。
我们通过这个函数大致可以实现如下来新增一个显示器:
NTSTATUS BddDdiStartDevice(_In_ DXGK_START_INFO* pDxgkStartInfo,_In_ DXGKRNL_INTERFACE* pDxgkInterface,_Out_ ULONG* pNumberOfViews,_Out_ ULONG* pNumberOfChildren)
{PAGED_CODE();//CallOrgBddDdiStartDevice*pNumberOfViews += 1;*pNumberOfChildren +=1;return STATUS_SUCCESS;
}
新增显示器之后我们还需要做的事情是:
BddDdiQueryChildRelations
虚拟显示器信息。BddDdiQueryChildStatus
虚拟显示器状态。BddDdiQueryDeviceDescriptor
虚拟显示器的EDID信息。BddDdiEnumVidPnCofuncModality
调整虚拟显示器的VIDPN关系。
通过上述接口的实现之后,我们就完成了显示器的虚拟化。
6. 实现效果
通过调整虚拟显示器的信息,我们可以实现如下常见场景:
1、在虚拟机里,插入任意分辨率的显示器,以适配我们的当前显示的屏幕(如ipadmini6-2266x1488),不会在渲染时,产生分辨率拉伸畸变(图6.1);
2、我们在用远程控制连接受控端时,通过热插拔虚拟显示器,实现远程双屏操作的 (图6.2)
图6.1——在虚拟机里插入通用显示器图6.2-这里用todesk软件远程连接云端服务器,由于通过这款软件没有虚拟显示器方案,只能串流桌面,所以我们的云端实现了双屏后,利用todesk自带的多屏串流切换功能,能够精准识别到2个分辨率显示器,实现双屏展示操作
后续我们还将介绍如何通过虚拟显示器,实现窗口化独立串流的分享