简介:基于C++实现的D3D11最新HOOK源码包,同时支持X86与X64双平台,适合从事图形渲染逆向、游戏辅助开发及D3D11编程研究的开发者。包体共84个文件,约4.35MB,内含h/inc头文件、cpp源文件、vcxproj工程配置、lib/obj/pdb编译产物,以及BeaEngine反汇编引擎的Win32/Win64头文件与静态库,覆盖工程编译与运行调试的完整链路。源码拆分为inject注入器、Dll1注入模块、hook核心挂钩三大部分,配合sln解决方案,便于直接打开编译,理解D3D11渲染管线中函数钩取、参数拦截与修改的实现思路。已有2676人浏览学习,适合具备C++与DirectX基础的读者对照源码尝试编译运行,并扩展自己的图形编程项目。
1. D3D11 HOOK 到底在解决什么问题
做画面叠加、录屏、帧数统计或者滤镜调试的开发者,大概率都卡过同一个问题:D3D11 不像 D3D9 那样有现成的 EndScene 可以挂,想在渲染完成之后插一脚,得自己找稳定挂点。所谓的「D3D11 最新 HOOK 源码支持 X86_X64」,本质就是一套同时跑在 32 位和 64 位进程里的代码骨架,通过拦截 IDXGISwapChain::Present 这类呈现函数,在画面被提交到显示器之前把渲染结果截下来,或者把自己的绘制指令插入到同一帧里。对我这样的图形和调试类开发者来说,这是做游戏内叠加层、HUD、自动化测试截帧最可靠的一条路,比反复读前台窗口截图高效得多。它适合的人也很明确:已经会用 C++ 调用 D3D11 接口,但没从零搭过 Hook 框架,或者搭过但被位数差异折磨过的人。
2. 呈现链上的关键挂点:为什么几乎所有方案都盯上 Present
2.1 D3D11 的呈现链是怎么走到屏幕的
D3D11 的渲染结果并不像很多新手想的那样直接进显存帧缓冲。一个典型的交换链建立起来之后,绘制流程大概是:应用先通过 ID3D11DeviceContext 执行各种 Draw 调用,把图元交给显卡;显卡在幕后把结果写到后台缓冲区(Back Buffer);当应用调用 IDXGISwapChain::Present 时,驱动把后台缓冲区的内容按交换链的呈现模型交给合成器或显示器。也就是说,Present 是每一帧里「所有绘制已经完成、画面还未上屏」的那个边界,过了这个边界,画面就归显示系统管了,你再想画任何东西都晚了。
理解了这条链,就知道为什么社区里讲 D3D11 HOOK 时翻来覆去只说一个接口:Present。它承担了两个天然优势。第一,它是每个真正渲染出来的帧都会经过的必经之路,包括那些没被窗口消息驱动的帧;第二,它被调用的时刻,后台缓冲里的内容已经是完整的一帧,你不会截到半成品。对于录制、截屏、叠加层这类需求,这就是全流程里最理想的观测点。
2.2 交换链的虚表:用调试器确认 Present 的槽位
D3D11 的 Hook 不像传统 API hook 那样必须去改写函数开头字节。IDXGISwapChain 是一个 COM 接口,它本质上是一张虚函数表,每一个方法在虚表里对应一个槽位。Present 也不例外。我们要做的核心动作,就是找到 Present 在虚表里的第几个槽,然后把这个槽里的函数指针换成我们自己的代理函数。
这里有一个经验性的定位方法。我们在注入完成后,先创建一个假的或者临时的交换链(或者直接拿目标进程已有的交换链),在调试器里打印它的虚表。IDXGISwapChain 的虚表布局由继承关系决定:前面 3 个槽是 IUnknown 的 QueryInterface、AddRef、Release;接下来是 IDXGIObject 的 SetPrivateData、SetPrivateDataInterface、GetPrivateData、GetParent;再往后是 IDXGIDeviceSubObject 的 GetDevice;从第 8 个槽开始才是 IDXGISwapChain 自己的方法,GetDesc 是第 8 个,Present 在第 9 个。你可以写一段很小的代码把 0 到 17 的槽位全部打印出来:
#include <d3d11.h> #include <dxgi.h> #include <stdio.h> void DumpSwapChainVTable(IDXGISwapChain* pSwapChain) { if (!pSwapChain) return; void** vtbl = *(void***)pSwapChain; // 打印 0~17 号槽位,方便人工确认 Present 的位置 for (int i = 0; i < 18; i++) { printf("[%02d] %p\n", i, vtbl[i]); } }这段代码的核心就两行:第一行*(void***)pSwapChain是把 COM 对象指针解引用成虚表指针,这和 C++ 虚函数的底层原理完全一致;第二行是按槽位打印,用来看替换前每个函数地址。参数上的注意点是:不同驱动版本和不同 D3D11 运行时下,虚表里的地址可以不同,但槽位顺序由接口定义决定,不会变。不过实际项目里我不建议你把这个槽位数字写死,更稳的做法是运行时用第一张临时交换链自动扫描一遍,并对比 GetDesc 之后的第一个槽来确认 Present。
2.3 为什么 Hook Present 比 Hook 其他 API 更稳
有些刚入门的开发者会问:为什么不直接 Hook ID3D11DeviceContext 的 DrawIndexed 或 ExecuteCommandList?原因很简单:Draw 类函数在一帧里会被调用成百上千次,你在里面做任何叠加操作,成本和翻车率都太高;而且很多引擎有自己的命令队列,Draw 被调用时图元未必已经提交到显卡。还有一部分人习惯参考 D3D9 时代的思路去找 EndScene,但 D3D11 并没有 EndScene 这个公开接口,硬要类比的话,Present 才是那个「所有渲染命令都执行完了」的信号。
另外值得留意的是 PresentFlags 参数。Present 的调用形式是HRESULT Present(UINT SyncInterval, UINT Flags),第二个参数里如果带DXGI_PRESENT_TEST,说明调用方只是在测试是否准备好上屏,并不是真的要呈现画面。如果你的代理函数不做区分,就可能截到一帧空画面。常见做法是在代理函数里检查 flags 是否包含DXGI_PRESENT_TEST,是就直接转调原函数并返回,不插入任何渲染逻辑。
3. Hook 方案的选型与一套通用源码骨架
3.1 三种路线对比:VMT 替换、Inline Hook、代理 DLL
「支持 X86_X64」的源码听起来像一个大而全的工程,但落到实现层面,主流路线就三种,选型决定了你后面要踩多少坑。第一种叫 VMT 替换,直接改写 IDXGISwapChain 虚表里 Present 那个槽位的指针。它不碰代码段,不涉及指令重编译,天然适合 X86 和 X64 两套架构,因为槽位替换只跟指针宽度有关;缺点是如果你想把某个具体函数内部的调用也拦住,VMT 做不到。
第二种是 Inline Hook,也叫指令级 Hook,它需要把函数开头几个字节改写成一条跳转指令。X86 下可以用短跳转,X64 下没有 64 位直接短跳,得用jmp qword ptr [rip + 偏移]配合一个附近的内存槽才能跳到任意地址。Inline Hook 的优势在于能 Hook 任何有固定字节码的函数,代价是它极度依赖 CPU 架构和指令长度,稍不留神就把原函数的指令截断了。
第三种是代理 DLL,常见的做法是伪装成 dxgi.dll 的替身,接管导出函数后再转发给真实的库。这套方案连代码都不需要改,但工程上要维护一堆转发声明,DLL 搜索顺序还会被系统安全机制干扰。我自己的经验是:对于 D3D11 叠加层和截帧这类需求,VMT 替换永远是最优先的选择;只有当目标程序使用了一些不受 DXGI 控制的绘制路径时,才考虑在 VMT 基础上叠加 Inline Hook。下面这张表能帮你快速做决策:
| 方案 | 实现难度 | 位数兼容性 | 动态修改指令 | 适用场景 |
|---|---|---|---|---|
| VMT 替换 | 低 | 天然兼容 | 不涉及 | 挂 Present、ResizeBuffers、GetBuffer |
| Inline Hook | 高 | 需分别处理跳转 | 涉及 | Hook 任意内部函数 |
| 代理 DLL | 中 | 天然兼容 | 不涉及 | 替换整个 DXGI 入口,隐藏注入痕迹 |
3.2 用 VMT 替换 Present:可直接落地的 C++ 骨架
下面这套代码是我常用的 VMT 替换骨架。它的思路是:备份虚表里的原函数指针,把槽位改成代理函数,代理函数里做我们要做的事,最后调用原函数。这个结构同时支持 X86 和 X64,因为代码里没有任何平台相关的汇编。
#include <d3d11.h> #include <dxgi.h> #include <windows.h> // 原函数类型:必须和 IDXGISwapChain::Present 完全一致 typedef HRESULT(__stdcall* PFN_Present)(IDXGISwapChain*, UINT, UINT); // 保存被替换掉的原函数指针 PFN_Present g_OriginalPresent = nullptr; // 我们的代理函数,替换后所有 Present 调用都会先经过这里 HRESULT __stdcall HookPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { // 在这里拿到后台缓冲、绘制叠加层、记录帧数都可以 // 注意:不要在这里做耗时操作,否则帧率会肉眼可见地下降 // 调用原函数,把帧正常提交 return g_OriginalPresent(pSwapChain, SyncInterval, Flags); } bool InstallPresentHook(IDXGISwapChain* pSwapChain) { if (!pSwapChain) return false; // 拿到虚表指针,虚表是 COM 对象的第一项 void** vtbl = *(void***)pSwapChain; // Present 槽位通常在第 9 个(按接口继承顺序数) // 上线前务必用第一节的方法验证这个数字 const int kPresentIndex = 9; g_OriginalPresent = reinterpret_cast<PFN_Present>(vtbl[kPresentIndex]); // 修改虚表页的写保护属性,COM 对象虚表默认是只读的 DWORD oldProtect = 0; VirtualProtect(&vtbl[kPresentIndex], sizeof(void*), PAGE_READWRITE, &oldProtect); // 原子替换成代理函数 InterlockedExchangePointer( reinterpret_cast<volatile PVOID*>(&vtbl[kPresentIndex]), reinterpret_cast<PVOID>(&HookPresent) ); // 恢复页保护属性 VirtualProtect(&vtbl[kPresentIndex], sizeof(void*), oldProtect, &oldProtect); return true; }这段代码的关键逻辑说明如下:reinterpret_cast<PFN_Present>(vtbl[kPresentIndex])负责把槽位里的原始地址转成类型安全的函数指针,后续代理函数通过它转调原函数;VirtualProtect必须做,因为大多数编译器和系统把虚拟函数表放在只读段,直接写会抛访问违例;InterlockedExchangePointer是原子写的关键,如果不开多线程守护,替换到一半被另一个线程调 Present 会直接崩溃。参数SyncInterval控制是否垂直同步,0 表示不等待、可能撕裂画面;Flags控制呈现行为,前面提到的DXGI_PRESENT_TEST就在这里判断。
3.3 函数签名与调用约定:X86 和 X64 的第一个分岔
VMT 替换难度不高,但签名写错会导致栈不平衡,这是 64 位工程里最常见的翻车点。X86 时代,DXGI 接口用的是__stdcall调用约定,参数从右到左压栈,被调函数负责清理栈;X64 下,微软调用约定统一成一种,前四个整数参数放 RCX、RDX、R8、R9,剩下参数压栈,没有__stdcall和__cdecl之分,__stdcall关键字在 X64 编译下会被忽略。
所以如果你在 X64 工程里写了__stdcall,编译器不会报错,但很容易让新手误以为两套约定在源码里都生效了。正确做法是:函数类型声明里直接写明__stdcall,X86 下它起实质性作用,X64 下它变成空操作,这样同一份源码两种架构都能编译。同理,HRESULT在所有受支持的 Windows 架构下都是 32 位整数,跨位数 hook 时不要用long代替,因为 Windows 上long在 64 位模式下仍然是 32 位,Linux 才是 64 位,这个坑在混用平台时特别容易踩。
4. 一套源码编译出 X86 和 X64 两个版本:工程配置与汇编跳板
4.1 同一份源码,两个平台工具集怎么协调
拿到或写完 D3D11 Hook 源码后,最磨人的不是 Hook 逻辑,而是工程配置。Visual Studio 里一个项目默认只有一个平台配置,要在 X86 和 X64 之间切换,需要单独配置两个平台。我一般会做两件事:第一,把平台工具集统一成同一套版本,避免两边的标准库和运行时有差异;第二,用_WIN64这个预定义宏把平台相关的代码隔开。
#ifdef _WIN64 // x64 专属逻辑,比如指向函数地址的变量用 64 位整数 using HookFuncPtr = uintptr_t; #else // x86 专属逻辑 using HookFuncPtr = uint32_t; #endif这段代码里_WIN64是编译器自动定义的宏,写 64 位目标时必然存在,不需要手动加。注意点是:这里的HookFuncPtr只用于存放地址相关的临时数据;真正调用函数时仍然要用函数指针类型,不要把地址转成整数再转回来调用,否则在 64 位下可能被指针撕裂。另一个容易被忽略的地方是链接库:两个平台都要链接d3d11.lib和dxgi.lib,但链接器的库搜索路径会随平台切换,确认工程配置里都指向对应架构的 SDK 库目录,否则报LNK2019 无法解析的外部符号,绝大多数都是这个原因。
4.2 x64 没有内联汇编:用 MASM 写一个跳板函数
如果你不需要 Inline Hook,只看 VMT 替换,可能完全碰不到汇编。但很多现成的「最新 HOOK 源码」会把 Inline Hook 也打包进来,这时候位数的差异就躲不掉了。X86 下 Visual Studio 编译器允许__asm关键字直接写汇编;X64 下这个功能被移除了,不是语法限制,而是编译器后端不再支持。替代方案是给工程加一个.asm文件,用 MASM(微软宏汇编器)写跳板。
一个 x64 下典型的跳板长这样:它的作用是把控制流无感地交给目标函数,同时保证指令对齐和返回地址栈不被破坏。
; thunk_x64.asm .code ; 跳板:跳到真正的目标函数地址 ; 目标地址存储在下方数据段,通过 RIP 相对寻址取出 PresentThunk PROC jmp QWORD PTR [rip + present_target] PresentThunk ENDP .data present_target DQ 0000000000000000h END对应的 C++ 侧声明要加上extern "C",否则会出现名字修饰问题:
extern "C" void PresentThunk(); // 把目标地址填进跳板数据段 void SetThunkTarget(void* pTarget) { // 用指针宽度来判断位数,避免硬编码 8 字节 if (sizeof(void*) == 8) { // 对应 asm 里的 present_target 标签 // 这里通过截取跳板代码里的目标槽,或者用导出符号地址写 uintptr_t* pSlot = reinterpret_cast<uintptr_t*>( reinterpret_cast<unsigned char*>(&PresentThunk) + 20 ); *pSlot = reinterpret_cast<uintptr_t>(pTarget); } }这里有一个很血腥的经验数字:&PresentThunk + 20这个偏移不是拍脑袋定的,它取决于jmp QWORD PTR [rip + 偏移]这条指令的长度。按 Intel 编码,这种形式通常是 6 字节指令加上 4 字节的偏移量描述,但不同汇编器生成结果可能略有差异。更稳的做法是把目标地址槽单独定义成一个导出变量,在 C++ 里直接extern "C" { extern unsigned char present_target; }来访问,而不是算偏移。上面这种写法只用来示意,生产代码里我强烈建议用导出变量。
4.3 编译顺序与导出后的自查
整个工程建议按这个顺序来:先编 Debug 版的 X64,把所有 Hook 逻辑跑通,再切 Release X64,最后编 X86。理由很简单,VMT 替换在 Debug 和 Release 下都会有虚表差异,先 Debug 能让你更快定位是不是优化导致的地址偏移问题。两个版本编完后,导出的 DLL 最好用依赖查看工具检查导出表,确认入口是你要导出的函数,而不是临时生成的限位符。
我还会在注入前做一次最小烟雾测试:写一个控制台程序加载这个 DLL,调用导出函数,打印返回值和原函数指针是否恢复。这样可以把注入环境问题隔离开,避免直接进游戏程序里黑屏退不出来的时候毫无头绪。
5. 常见问题排查:我在这条路上踩过的五类坑
5.1 DllMain 里装 Hook:进了大门却永远出不来
这是很多人的第一个坑:把InstallPresentHook的调用直接写在DllMain里,结果注入后程序直接卡死或崩溃。现象往往是进程启动后无响应,或者注入后瞬间闪退。原因是 Windows 在调用 DllMain 时握着加载锁(loader lock),而VirtualProtect、InterlockedExchangePointer这些 API 在锁内调用会和其他线程产生互锁;更糟的是 Hook 一旦装上,别的线程马上调 Present,而 DllMain 还没返回,代理函数走进一个未初始化完成的模块。
解决方法是 DllMain 里不要做任何实质 Hook 工作,只记一个标志或者创建一条线程。比如在DLL_PROCESS_ATTACH分支里CreateThread一个初始化函数,在线程函数里延迟几百毫秒再装 Hook,等进程的 D3D11 设备和交换链都创建完。这招虽然看起来土,但确实能规避绝大多数启动期冲突。
5.2 Hook 装上后画面全黑或者白屏
第二种常见现象:能进游戏,画面也动了,但屏幕上什么都没有,或者只有一片白。原因一般是代理函数把渲染时序破坏了。常见错误是在代理函数里调用了GetBuffer拿到后台缓冲,然后创建了 render target view,但忘了这帧的上下文状态;或者你在 Present 之前做了 Clear,把前一帧的数据清掉了。还有一部分情况是代理函数里抛了异常,原函数没被调用。
解决路径分两步:第一确认代理函数每个分支都走到了g_OriginalPresent;第二,不要在代理函数里轻易做任何 RTV 的 Clear 或 Resolve,先保持原样只做截帧,等画面正常了再逐步加叠加逻辑。定位时可以用调试器在代理函数入口设断点,看pSwapChain和Flags是否正常,而不是盲目猜绘制代码。
5.3 全屏切换时崩溃:没有处理 ResizeBuffers
窗口模式切全屏、显示器分辨率改变、硬件缓冲数量变化,都会触发IDXGISwapChain::ResizeBuffers。如果只挂 Present,不挂 ResizeBuffers,那么后台缓冲群的尺寸和格式被重建后,之前创建的 render target view 和 texture 全部失效,下一帧 Present 里拿到的缓冲就变成一个悬空对象。
现象是窗口模式下一切正常,切全屏必崩。原因就是没挂第 16 个槽位的ResizeBuffers。解决方法是把 ResizeBuffers 也替换掉,在代理函数里先调用原函数,然后销毁并重新创建所有依赖后台缓冲的资源。这里要注意顺序:一定先调原 ResizeBuffers,等它返回后再重建资源,因为原函数执行期间缓冲已经被替换。
5.4 x64 编译报错 LNK2019:名字修饰和库路径双杀
很多源码在 X86 下编得过,切到 X64 就报一堆 LNK2019。这类错误的背景是:x64 下所有导出和外部符号默认按名字原样修饰,但如果你在 C++ 里声明了一个 extern "C" 函数,汇编文件里却没加对应的 PUBLIC 指令,链接器就找不到符号。另一个高频原因是 x64 平台没有链接 dxgi.lib,或者链接了 32 位版本的库,导致所有 DXGI 导出函数都解析不了。
解决方法是先检查工程链接器的附加依赖项里,是否有且仅有正确架构的 lib 文件;然后查汇编文件头,给跳板函数加PUBLIC PresentThunk。我通常还会在链接器命令行里开/VERBOSE:LIB,看它到底去找哪个目录的库,这一步能省下大量冤枉时间。
5.5 帧率暴跌:代理函数被过度开发
Hook 本身几乎不影响帧率,真正拖垮帧率的是你在代理函数里做的事。比如每次 Present 都调一次D3D11CreateTexture2D创建截图纹理,或者每次都用QueryInterface去拿设备,这些操作都涉及驱动级调用,几百帧下来性能全耗在这些重复动作上了。还有一种更隐蔽的翻车:你用ID3D11DeviceContext::Map去读后台缓冲,而 Map 是同步阻塞操作,CPU 会一直等到 GPU 完成渲染,垂直同步一开,帧率直接锁死在刷新率的一半。
解决方法是把一切能缓存的对象缓存起来,创建纹理和 RTV 只做一次,标志位变化时才重建;把真正耗时的读取操作放到独立线程,代理函数里只做拷贝和入队。如果实在要做逐帧读像素,优先考虑COPY标志和精确的资源使用状态,不要无脑Map。
6. 进阶用法:把 Hook 变成可开关的叠加层并验证生效
到这一步,基础 Hook 已经能跑起来了,我通常会把代码往两个方向再推一步。第一个方向是做一个可开关的叠加模块:在代理函数里用一个原子标志位控制是否执行叠加绘制,标志位由外部模块通过一个小函数置位。这样游戏运行中你随时可以关掉叠加层,而不必卸载重注入。第二个方向是主动验证 Hook 是否真的生效,而不是靠肉眼看画面有没有变化。
先看验证方法。Hook 装上后,最可靠的验证不是看叠加层是否画出来了,而是打印原函数地址和代理函数地址。你可以在代理函数里用OutputDebugStringA输出一行包含pSwapChain地址和Flags信息的日志,然后用调试器查看是否每帧都有输出。这个日志如果稳定出现,说明替换的槽位是真正的 Present;如果一帧只出现一次或者间隔奇怪,说明你的槽位可能是 ResizeBuffers 或者其他低频函数。
再看叠加层的实现骨架。拿到后台缓冲后,可以用ID3D11Device创建 render target view,然后用 D3D11 自己的绘图命令画一个全屏三角形。为了不干扰原画面,常见做法是先备份当前上下文状态,叠加绘制完成后立刻恢复:
// 代理函数内部:叠加层绘制的最小步骤 void DrawOverlay(IDXGISwapChain* pSwapChain) { // 通过交换链查询 D3D11 设备 ID3D11Device* pDevice = nullptr; pSwapChain->GetDevice(__uuidof(ID3D11Device), reinterpret_cast<void**>(&pDevice)); if (!pDevice) return; // 获取当前后台缓冲区 ID3D11Texture2D* pBackBuffer = nullptr; pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&pBackBuffer)); // 创建针对后台缓冲的渲染目标视图,叠加内容画到它上面 ID3D11RenderTargetView* pRtv = nullptr; pDevice->CreateRenderTargetView(pBackBuffer, nullptr, &pRtv); // 真正画叠加内容的逻辑,比如先保存原视口、混合状态,画完再恢复 // ... // 清理资源 pRtv->Release(); pBackBuffer->Release(); pDevice->Release(); }这段代码里的参数说明:GetBuffer的第一个参数是后台缓冲序号,双缓冲交换链一般只用 0;第二个参数__uuidof(ID3D11Texture2D)是请求的接口类型,必须和实际缓冲类型一致;CreateRenderTargetView的第三个参数可以传 nullptr 表示默认视图属性。注意这只是在代理函数里做叠加层的最小骨架,生产代码里你还要考虑pDevice的引用计数、渲染状态保存恢复、以及多次调用时 RTV 是否要复用。
我的个人习惯是:每次写完这类 Hook 代码,都把原函数指针地址打到日志里留档。这样某个驱动版本更新后行为变了,我可以先看原函数地址是否和当初记录的一致,再决定是换槽位还是换挂点。这套做法帮我避免过很多次「驱动升级后黑屏玄学」的排查。希望帮到你。
本文还有配套的精品资源,点击获取