简介:面向C++开发者与游戏逆向爱好者的D3D11钩子完整工程,支持三十二位和六十四位双平台,围绕接口拦截、动态库注入与渲染分析等常见需求展开,适合具备一定图形编程或系统底层基础的学习者。工程共包含八十四个文件,压缩包仅四点三五兆,除解决方案与编译说明外,还提供了C++源文件、头文件与inc定义、中间文件、日志文件及静态库。目录结构按照注入器、主模块、钩子核心三个部分组织,打开解决方案后即可选择对应位数配置进行编译,能够清楚看到注入、加载与挂钩回调之间的协作关系。反汇编引擎也被整合进来,并附带Delphi、Python、PureBasic等多种语言绑定文件,可以服务于不同开发环境下的指令解码。压缩包内同时保留编译过程产生的中间产物和依赖库,便于对照源码理解构建流程。已有两千六百七十六人学习下载。整体来看,这套工程既能帮助理解底层钩取原理,也能作为二次开发框架直接改造,实用性较强。
1. D3D11 HOOK:渲染层拦截到底在解决什么问题
做图形调试、帧率统计、画面辅助显示这类工具时,你最需要的不是截帧器,而是能在渲染管线的出口处插一脚的手段。D3D11 HOOK 就是在有 D3D11 图形接口运行时的进程里,把关键渲染函数替换成自己的实现,从而在画面提交给显示器之前拿到控制权。这个方向用 C++ 写源码,同时兼容 X86 和 X64,是很多渲染调试工具和视觉增强 Demo 的共同底座。
这篇文章不是泛泛讲 HOOK 原理,而是围绕一套可复现代码展开:先讲清楚为什么盯住 IDXGISwapChain::Present 就能截住画面,再给出 x86/x64 都兼容的跳板写法,最后把那些让你调试到怀疑人生的坑一个个拆开。无论你想做一个 Overlay 叠加层、一个帧率监测器,还是在老项目里做画面后期处理,这套思路都能直接套用。
2. 先学会盯住 Present:D3D11 HOOK 的核心原理与选型
2.1 为什么是 IDXGISwapChain::Present:虚表索引与函数签名
D3D11 的画面提交路径比较固定:程序每一帧把所有渲染命令提交给命令队列,最后调用呈现接口把后台缓冲区翻转(或拷贝)到前台。这个“呈现”动作就是 IDXGISwapChain::Present。只要把 Present 拦下来,就能在画面真正交给显示环节之前,往后台缓冲区里画自己的内容,或者记录这一帧的渲染耗时。
Present 是 COM 接口 IDXGISwapChain 的虚函数,因此它位于虚表(vtable)的某一个槽位上。COM 的虚函数布局遵循继承顺序:IDXGISwapChain 继承自 IDXGIDeviceSubObject,而后者又继承自 IDXGIObject。从 IDXGIObject 开始数,依次是 QueryInterface、AddRef、Release、SetPrivateData、SetPrivateDataInterface、GetParent,然后 IDXGIDeviceSubObject 增加 GetDevice,IDXGISwapChain 自己增加的第一个函数就是 Present。所以 Present 的虚表索引是 7,也就是第 8 个槽位。还有一个和 Present 同样重要的函数 GetBuffer,索引是 3?(注意这里指的是 IDXGISwapChain 自己的 GetBuffer,在完整虚表里索引对应关系需要按继承链累加),它用来拿到后台缓冲区,绘制 Overlay 基本离不开它。
函数签名方面,Present 的原型是:
HRESULT Present(UINT SyncInterval, UINT Flags);SyncInterval 控制垂直同步间隔,传 0 表示立即呈现、不等待;Flags 通常传 0,需要全屏切换时传 DXGI_PRESENT_TEST 一类标记。自己实现替换函数时,签名必须和原函数完全一致,否则在 x64 下会因为参数数量不匹配导致栈或寄存器状态错乱。这里有个容易踩的误区:Windows 上 x64 调用约定规定前 4 个参数依次放入 RCX、RDX、R8、R9,this 指针作为第一个参数走 RCX 寄存器,而不是像 x86 那样压栈。也就是说,自己写的 Hook 回调虽然是普通成员函数风格,但实际接收的第一个参数就是 IDXGISwapChain* 本身。
2.2 vtable hook 与内联 hook:两条路线的边界和取舍
实现 Present HOOK 有两条常见路线,它们的实现复杂度、稳定性和适用场景差别很大。
第一类是虚表替换(vtable hook),做法是把 vtable 里第 7 个槽位的函数指针,从原来的 Present 改成自己的 MyPresent。这个方案实现最简单,几行代码就能搞定,不需要处理跳板指令、不需要关心 CPU 指令集差异。但它的局限也很明显:如果一个程序在运行时修改了虚表,或者使用跳板方式包装了原函数,虚表替换就可能被绕过;另外虚表替换只能拦截接口层面的调用,无法拦截直接调用内部实现的情况。
第二类是内联 HOOK(inline hook),做法是在原函数入口处改写机器码,让执行流先跳到自己的代码,执行完再跳回去。这需要构造跳板 trampoline 来保存原始入口指令,并且保证被改写的指令能原样恢复。内联 HOOK 通常有两种实现路径:一种是自写跳板 + VirtualProtect 修改页面保护,适合理解底层原理时自己动手做;另一种是直接用现成的钩子库,很多开源库都是这个路线,稳定性和兼容性做得更完善。
两条路线的取舍建议:自己学习、做 Demo,用虚表替换就够了;但如果要做一个长时间稳定运行的辅助工具,或者要面向不同版本 D3D11 运行库做兼容,内联 HOOK 是更可靠的方向。x64 下的内联 HOOK 跳板比 x86 复杂得多,注册表基址、检查指令长度、处理指令边界这些细节,都是翻车高发区。
为了看得清楚,我一般先写一个虚表读取的小工具来确认索引和地址,验证无误后再上内联 HOOK。下面就是一个最小虚表读取代码。
2.3 把虚表地址读出来:一个最小可行的定位代码
这段代码用 C++ 在目标进程内部获取 IDXGISwapChain 的 vtable,并打印 Present 槽位的地址。关键在于 COM 对象的内存布局里,第一个成员就是虚表指针,因此取对象地址的前 8 个字节(x64)就能拿到虚表基址。
#include <windows.h> #include <dxgi.h> #include <d3d11.h> #include <cstdio> void DumpSwapChainVtbl(IDXGISwapChain* pSwapChain) { if (!pSwapChain) return; // COM 对象首个成员是虚表指针 void** pVtbl = *reinterpret_cast<void***>(pSwapChain); // Present 在完整虚表中的槽位 void* pPresent = pVtbl[7]; std::printf("[vtbl] base = 0x%p\n", pVtbl); std::printf("[vtbl] Present[7] = 0x%p\n", pPresent); } int main() { // 这里用一个已有的 swapchain 做演示,实际运行时由 D3D11CreateDeviceAndSwapChain 返回 // IDXGISwapChain* pChain = ...; // DumpSwapChainVtbl(pChain); return 0; }代码逻辑说明:reinterpret_cast<void***> 把 IDXGISwapChain 指针解释为“指向指针数组的指针”,取 * 得到虚表数组 pVtbl,再按下标 7 取函数地址。这个写法在 x86 和 x64 下通用,因为虚表指针都是内存里的一个指针值。注意,这里假设虚表顺序是标准的 COM 继承布局,不同版本的图形驱动可能会插入额外的方法,但 Present 索引 7 在 D3D11 的公开接口里是稳定约定,实测驱动层也没有变。如果你不放心,可以在程序运行后打印 pVtbl[0] 到 pVtbl[9] 的值,和已有工具链对比验证。
3. 写自己的 HOOK 回调:X86_X64 兼容的跳板设计与参数处理
3.1 回调签名:64 位下的 RCX 传参与调用约定差异
自己实现的回调函数本质上是替换原 Present 的入口,因此签名必须严格对齐。D3D11 的接口在 Windows 平台默认使用 __stdcall,在 x86 下参数从右往左压栈;在 x64 下只有一套统一调用约定,前 4 个整型/指针参数分别放入 RCX、RDX、R8、R9,多余的才压栈。由于 COM 方法的第一个参数是 this 指针,所以在 x64 下 RCX 里放的就是 IDXGISwapChain*,RDX 是 SyncInterval,R8 是 Flags。
x86 和 x64 的差异还体现在返回值上:两者都通过 RAX/EAX 返回 HRESULT,但 x64 要求调用者负责在栈上分配至少 32 字节的阴影空间(shadow space),即便函数只有 4 个以内参数也必须预留。写内联 HOOK 时,你的跳板代码如果在栈上动了手脚,必须保证最后能原样恢复这些字节,否则被 Hook 的程序会在返回时崩溃。
一个稳妥的写法是在回调函数开头用 __declspec(noinline) 和标准调用约定明确声明:
// x86 和 x64 都支持的声明方式 extern "C" HRESULT __stdcall MyPresent( IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { // 在这里处理每一帧逻辑 return OriginalPresent(pSwapChain, SyncInterval, Flags); }在 x86 下,__stdcall 让函数自己平衡栈;在 x64 下,调用约定被统一为 Microsoft x64,__stdcall 关键字会被忽略,但写法上依然兼容。关键是这个回调函数绝不能声明成 __cdecl,否则x86下栈不平衡,一帧之内必然崩溃。
3.2 x86 与 x64 的跳板生成:从 E9 到 mov rax/jmp rax
内联 HOOK 的核心工作是改掉原函数入口机器码。x86 下最常见的跳板是 5 字节的相对跳转:E9 + 4 字节相对偏移。相对偏移大小等于目标地址减去当前指令下一跳地址,计算时记住当前指令长度为 5。
x64 下无法在一个 mov 指令里把 64 位绝对地址加载到寄存器然后跳转,常用的方案是 12 字节跳板:mov rax, imm64(3 字节前缀 + 8 字节立即数)+ jmp rax(2 字节)。这里 imm64 直接填入目标函数地址。这个序列在 x64 下不需要考虑相对偏移范围问题,是最稳妥的写法。
下面是一段生成跳板的代码,分别在 x64 和 x86 下选择对应指令序列,并用 VirtualProtect 放开目标内存写权限:
#include <windows.h> #include <cstdint> #include <cstring> static bool ApplyHookJump(void* pTarget, void* pDetour) { DWORD dwOld = 0; if (!VirtualProtect(pTarget, 16, PAGE_EXECUTE_READWRITE, &dwOld)) { return false; // 目标地址不可写 } #ifdef _M_X64 // 64 位跳板:mov rax, imm64; jmp rax uint8_t jump[] = { 0x48, 0xB8, // mov rax, imm64 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0 // jmp rax }; uint64_t addr = reinterpret_cast<uint64_t>(pDetour); memcpy(jump + 2, &addr, 8); #else // 32 位跳板:E9 rel32 uint8_t jump[] = { 0xE9, 0x00, 0x00, 0x00, 0x00 }; uintptr_t src = reinterpret_cast<uintptr_t>(pTarget); uintptr_t dst = reinterpret_cast<uintptr_t>(pDetour); uint32_t rel = static_cast<uint32_t>(dst - src - 5); memcpy(jump + 1, &rel, 4); #endif memcpy(pTarget, jump, sizeof(jump)); DWORD dwIgnored = 0; VirtualProtect(pTarget, 16, dwOld, &dwIgnored); FlushInstructionCache(GetCurrentProcess(), pTarget, 16); return true; }这段代码的跳板用了 16 字节的改写空间,但实际上 x64 只需 12 字节,多余空间是为了保证被覆盖指令不会截断到中间,某些函数入口最短也要保持 8 字节对齐。参数说明:pTarget 是原 Present 地址,pDetour 是自己回调的地址。VirtualProtect 第一次调用把 PAGE_EXECUTE_READWRITE 放开,写入后立刻恢复原页面属性。最后的 FlushInstructionCache 是 x64 下必须做的,否则指令缓存里可能还保留旧代码,导致跳转不生效。
注意,这里直接覆盖入口的写法对原函数入口处的前若干字节是破坏性的。如果入口处不是有效的指令边界(比如明显是数据),这个跳板就会让程序直接崩。稳妥做法是先反汇编确认入口前 12 字节的指令边界,但这个主题展开太长,一般实践里用钩子库自动处理。
3.3 得到原始函数指针:保存与调用原 Present 的正确姿势
内联 HOOK 会覆盖原函数入口,所以在跳转之前必须把原入口的指令完整保存下来,构造一个“原始函数”副本,供回调函数内部调用。这个副本不是简单保存字节就行,而是要处理一个关键问题:跳板里保存的那几条指令中,如果有相对跳转、相对调用、RIP 相对寻址,直接复制字节会导致相对地址失效。
常见做法是:分配一块可执行内存,把原入口的前 12 么字节(x64)拷贝过去,然后在拷贝区域的末尾追加一个跳转指令,让执行流回到原函数入口 + 12 字节处。这样,原始函数副本就能正常执行这一段。这段副本需要用 VirtualAlloc 申请 PAGE_EXECUTE_READWRITE 内存,不能直接用一个静态数组,因为静态数组的地址可能超过跳转范围。
回调函数调用原函数时,直接调用保存的原始函数指针:
static uint8_t* g_pOriginalBytes = nullptr; // 保存的原始指令 static void* g_pOriginalPresent = nullptr; // 原始函数入口地址 HRESULT __stdcall MyPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { // 你的每帧逻辑 // 帧计数、Overlay 绘制等 auto pfnOriginal = reinterpret_cast<HRESULT(__stdcall*)(IDXGISwapChain*, UINT, UINT)>(g_pOriginalBytes); return pfnOriginal(pSwapChain, SyncInterval, Flags); }参数说明:g_pOriginalPresent 是 ApplyHookJump 之前拿到并保存的原函数入口;g_pOriginalBytes 是保存原入口指令的备份副本。这里我把逻辑抽象了一下:如果替换目标入口前 12 字节,那么在副本里执行完原指令后还需要跳回原函数入口偏移 12 字节的位置,这个跳转也需要和 3.2 节一样的跳板。
4. 一个可跑的 D3D11 HOOK 最小示例:从初始化到绘制叠加层
4.1 初始化:拿到 Device 和 SwapChain 的正确姿势
HOOK 的目标是谁,决定了初始化的方式。如果你是自己写的渲染程序,那流程最直接:创建窗口,用 D3D11CreateDeviceAndSwapChain 创建设备和交换链,拿到 IDXGISwapChain* 再执行 HOOK。如果要 Hook 别人的进程,常规做法是在目标进程内先模仿一遍 D3D11 的初始化,等待它实际创建出公共的交换链地址,然后从某个共享位置拿到这个指针。
这里给一个自包含的最小初始化代码,创建一个 1280x720 后台缓冲区的交换链。注意创建时的参数必须和目标进程真实参数匹配,后台缓冲区格式、数量、交换效果都会影响 Present 行为。
#include <d3d11.h> #include <dxgi.h> bool CreateD3D11DeviceAndSwapChain(HWND hWnd, ID3D11Device** ppDevice, IDXGISwapChain** ppSwapChain) { DXGI_SWAP_CHAIN_DESC sd = {}; sd.BufferCount = 2; // 双缓冲 sd.BufferDesc.Width = 1280; sd.BufferDesc.Height = 720; sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; sd.BufferDesc.RefreshRate = {60, 1}; sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow = hWnd; sd.SampleDesc = {1, 0}; // 不开启 MSAA sd.Windowed = TRUE; sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH; D3D_FEATURE_LEVEL levels = D3D_FEATURE_LEVEL_11_0; HRESULT hr = D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, &levels, 1, D3D11_SDK_VERSION, &sd, ppSwapChain, ppDevice, nullptr, nullptr); return SUCCEEDED(hr); }逻辑说明:D3D11CreateDeviceAndSwapChain 是 D3D11 里一次性创建设备、交换链的入口,返回两个 COM 对象,这是整个 HOOK 的起点。参数说明:levels 使用的是单层特性等级数组,如果你的目标环境只支持 D3D11 而实际回退到旧版特性等级,HOOK 的是同一个运行时接口,一般不受影响。BufferCount 用 2,即双缓冲,Present 翻转的是两个后台缓冲区之一;如果你在 MyPresent 里对缓冲区做渲染,要确认自己拿到的是当前显示的前台还是后台,翻转后内容才会稳定。
4.2 安装 HOOK 与卸载 HOOK:顺序和时机
安装 HOOK 的流程顺序很重要:先拿到交换链指针,再读取虚表地址,然后保存原入口指令,最后套用跳板。卸载则相反:把保存的原始指令写回目标入口,并把跳板内存释放。
这里有个关键时机问题:不要在 D3D11CreateDeviceAndSwapChain 返回之前安装 HOOK。很多新手在这段初始化完成的瞬间立刻 Hook 第一帧,结果 Present 还没来得及走一遍,有些驱动内部状态还没准备好,安装进去的跳板可能被驱动后续初始化覆盖。我一般会在交换链创建完成后,调用一次 Present 确保驱动稳定,再安装 HOOK。
InstallAndUninstall 函数如下,也是模拟项目X的核心部分:
class PresentHook { public: bool Install(IDXGISwapChain* pChain) { if (m_bInstalled) return true; void** vtbl = *reinterpret_cast<void***>(pChain); void* pPresent = vtbl[7]; m_pOriginalFunc = pPresent; // 保存原入口字节(用于卸载恢复) memcpy(m_originalBytes, pPresent, 16); // 构造原始函数副本(把原字节复制到新内存,末尾跳回原地址+len) m_pTrampoline = BuildTrampoline(pPresent, 16); if (!ApplyHookJump(pPresent, &MyPresent)) return false; m_bInstalled = true; return true; } void Uninstall() { if (!m_bInstalled) return; memcpy(m_pOriginalFunc, m_originalBytes, 16); FlushInstructionCache(GetCurrentProcess(), m_pOriginalFunc, 16); if (m_pTrampoline) VirtualFree(m_pTrampoline, 0, MEM_RELEASE); m_bInstalled = false; } private: void* m_pOriginalFunc = nullptr; void* m_pTrampoline = nullptr; uint8_t m_originalBytes[16]; bool m_bInstalled = false; };代码说明:这里的 MyPresent 在 4.3 节里定义,原型必须与 Present 完全一致,否则调用约定的任何一个偏差都是崩溃源。Install 末尾的 ApplyHookJump 直接覆盖入口 16 字节,这个长度在 x64 下足够容纳 mov rax/jmp rax 的 12 字节跳板。Trampoline 的构建我没有展开细节,核心是把原入口复制到 VirtualAlloc 后的内存,再在末尾写一条跳回原函数 + 16 的指令。注意,如果原入口前 16 字节里有相对跳转指令,这个 Trampoline 方式会破坏相对地址,需要用重定位方案,这是内联 HOOK 较麻烦的部分。
4.3 把帧计数画出来:验证 HOOK 生效的最小 DEMO
HOOK 装好以后,怎么确定真的生效了?最简单的办法就是在 MyPresent 里累加一个帧计数器,并尝试在画面上画一行文字。绘制这块如果完全从零写需要大量 D3D11 状态设置,所以这里用一个简化的精灵渲染器思路:每一帧先用 GetBuffer 拿到后台缓冲区,创建渲染目标视图,绑定到输出合并阶段,再画一个颜色渐变的矩形,最后 Present 原函数。
static volatile LONGLONG g_frameCount = 0; HRESULT __stdcall MyPresent(IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { g_frameCount++; // 帧计数 // 简单的 Overlay:改变后台缓冲区的某个区域颜色 ID3D11Texture2D* pBackBuffer = nullptr; pSwapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&pBackBuffer)); ID3D11Device* pDevice = nullptr; pSwapChain->GetDevice(__uuidof(ID3D11Device), reinterpret_cast<void**>(&pDevice)); // 为后台缓冲区创建渲染目标视图(每一帧处理,效率敏感时需要缓存) ID3D11RenderTargetView* pRTV = nullptr; pDevice->CreateRenderTargetView(pBackBuffer, nullptr, &pRTV); ID3D11DeviceContext* pCtx = nullptr; pDevice->GetImmediateContext(&pCtx); pCtx->OMSetRenderTargets(1, &pRTV, nullptr); // 用清屏颜色表示当前帧计数,低字节是计数,方便肉眼确认变化 float color[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; color[0] = (g_frameCount & 0xFF) / 255.0f; pCtx->ClearRenderTargetView(pRTV, color); pCtx->Release(); pRTV->Release(); pDevice->Release(); pBackBuffer->Release(); // 必须调用原始函数,否则画面不会呈现 auto pfnOriginal = reinterpret_cast<HRESULT(__stdcall*)(IDXGISwapChain*, UINT, UINT)>(g_pTrampoline); return pfnOriginal(pSwapChain, SyncInterval, Flags); }这段代码说明了三个必带步骤:拿缓冲区、绑定渲染目标、调用原函数。前两个决定你的 Overlay 画得出来,第三个决定程序不会卡死。这里的 GetBuffer 参数 0 表示取后台缓冲区队列里第一个缓冲区,在双缓冲模式下就是当前后台缓冲,如果交换链有多个缓冲区,这个索引需要和实际使用兼容。注意这段逻辑里每次创建渲染目标视图是极低效的写法,一个正常工具应该缓存 RTV 并在 ResizeBuffers 时重建,这个问题在避坑章节里还会展开。
4.4 编译配置:x86/x64 两套编译需要的宏与注意事项
代码写完后,编译环节还有不少坑。x86 和 x64 需要分别使用对应架构的编译配置,Visual Studio 里切换 Win32 和 x64 平台即可,但代码层面的差异要提前处理。
第一是类型宽度问题。所有地址计算必须用 uintptr_t,不要用 unsigned long。Windows 上 unsigned long 在 32 位和 64 位下宽度不同,而 uintptr_t 始终占用与指针相同宽度。第二是跳板代码的宏区分,用 _M_X64 宏判断当前架构,这个宏在 32 位编译时未定义。第三是调用约定:x86 下回调明确写 __stdcall,x64 下保持默认即可,但如果你手动改成了 __cdecl,x86 下必然栈不平衡。
还需要注意一个细节:HOOK 代码所在模块的内存页属性。如果代码放在只读区,VirtualProtect 之后写跳板没问题,但如果你的回调函数在优化后被编译器内联或者放进写保护区,或者你的跳板内存管理用了 VirtualAlloc,要统一处理。我一般把整个 HOOK 类放在独立编译单元,并且给关键函数加上 __declspec(noinline) 防止优化器把跳板绕过去。
5. D3D11 HOOK 避坑指南:5 个必踩的坑与排查手段
5.1 坑一:一装上 HOOK 画面就黑屏或卡死
现象:跳板装上后程序还能运行,但画面不再刷新,或者窗口直接变成纯色块。
原因:最常见的两个。一是你安装跳板之后没有调用原始函数,后台缓冲区永远不会被提交,画面当然不动。二是你调用了原始函数但参数传错,比如把 SyncInterval 从 0 改成了 1,垂直同步把 Present 阻塞住,造成一种“卡死”的假象。
解决:先确认回调最后的 pfnOriginal 调用参数和传入完全一致,尤其是 SyncInterval 和 Flags 不要做任何转换。排查时可以在回调入口立刻写一个 OutputDebugString 或日志文件,输出 SyncInterval 和 Flags 的原始值,再和原程序的调用习惯对比。黑屏的状态下加日志是最好的定位手段。
5.2 坑二:x64 下回调刚进入就崩溃,高 32 位寄存器被踩掉
现象:32 位编译完全正常,切到 x64 后,跳板能跳到回调,但回调里访问任何指针都会崩,或者跳回原函数时栈损坏。
原因:x64 下跳板是 mov rax, imm64 + jmp rax,如果手写跳板时把地址当成两个 32 位半段写入,或使用 E9 相对跳转直接跳 64 位目标,地址高 32 位全丢,CPU 跳到错误地址后立即非法访问。另一个常见来源是回调函数里用了 32 位寄存器和 64 位指针混用,比如把一个指针值先转成 int 再转回指针。
解决:代码里所有地址运算统一用 uintptr_t 和 uint64_t,跳板里填入完整 8 字节地址而不是写两个高 4 字节在别的内存。检查点:调用的瞬间在回调入口用调试器查看 RAX、RCX 的值是否和预期一致,如果 RCX 不是交换链地址,大概率跳板写坏了。
5.3 坑三:窗口拉伸后 Overlay 错位,甚至直接失效
现象:Overlay 正常绘制,但把窗口从 1280x720 拖拽到其他大小时,绘制内容会错位、拉伸或干脆消失。程序没有崩溃。
原因:后台缓冲区尺寸变了,但你缓存的 RTV 和交换链大小不匹配。D3D11 的 SwapChain 在窗口尺寸变化后会调用 ResizeBuffers 重建缓冲区,你的缓存 RTV 还是旧大小,绘制出来的坐标自然对不上。更隐蔽的是,如果目标程序在 ResizeBuffers 之后重新创建了自己的渲染目标,它会覆盖你的状态,Overlay 直接失效。
解决:一定要同时 HOOK IDXGISwapChain::ResizeBuffers,虚表里 ResizeBuffers 在 Present 之后的下一个槽位,索引通常为 8。在 ResizeBuffers 回调里释放缓存的 RTV,并记录新的缓冲区尺寸,下次 Present 时重新创建。窗口尺寸变化时如果 ResizeBuffers 没被调用,那么后台缓冲区尺寸没变,Overlay 应该还能保持。
5.4 坑四:画面闪烁,或退到后台再回来颜色被清掉
现象:Overlay 画的内容有时出现、有时消失,闪烁频率和窗口激活/失活一致。
原因:闪烁不是 HOOK 本身的问题,而是你的绘制状态没有在每一帧都完整重建。D3D11 的渲染状态是全局的,目标程序每一帧都会设置自己的 RenderTarget、Viewport、深度模板状态。如果你只在一帧里绑定了一次 RTV,下一帧目标程序自己绑定了别的状态,你的 Overlay 就被覆盖,看起来就是在闪。另一个因素是后台缓冲区索引,如果 SwapEffect 是 FLIP_DISCARD,GetBuffer(0) 拿到的缓冲顺序和 DISCARD 模式不同,写入目标不一定是最终提交的那一块。
解决:在 MyPresent 里不要做一次性的状态设置,应该把它当成每帧都会重新执行的过程。每次绘制前重新绑定 RTV 和 Viewport,绘制后不要释放状态,直接交给原函数。闪烁问题还要检查 vSync 是否一致,SyncInterval 变化会导致抓到的缓冲时序不同,在 MyPresent 里用日志记录每一帧的 Flags 值,对排查时序问题很有帮助。
5.5 坑五:调用约定错误,x86 下运行几秒就崩溃,无异常提示
现象:代码在 Debug 下偶尔正常,Release 下运行几秒后程序崩溃,或直接抛访问违规。用调试器看栈帧,发现返回地址是随机值。
原因:这是内联 HOOK 最常见的隐蔽问题。x86 下的 __stdcall 和 __cdecl 栈平衡方式不同,如果回调写成 __cdecl,但原函数是 __stdcall,回调结束后栈没有恢复,反汇编一看栈顶指针已经错位。Release 优化还会把这种错误掩盖到函数调用深处,导致问题看起来毫无规律。
解决:把回调函数强制声明为 __stdcall 或直接使用与目标函数相同的调用约定,并在编译后查看生成的汇编代码,确认函数结尾是 ret 8 或 ret 16 这类带栈清理的指令,而不是裸的 ret。一个好的习惯是给自己的回调加一个函数指针类型变量,并显式指定调用约定,这样编译期就能检查函数原型匹配。
6. 完善验证与进阶:让 HOOK 从能跑到站稳
HOOK 能跑通只是第一步,真正要长期稳定运行,验证机制一个也不能省。这里有几个我长期使用的做法。
第一,数据流验证。不要只靠肉眼观察画面变化,加一个输出通道,把帧时间和帧计数写成结构化日志。可以在 MyPresent 里取 QueryPerformanceCounter,算出上一帧耗时,每 100 帧写一行,这样能直观量化 HOOK 是否增加额外开销。曾经在模拟项目X里排查过一个问题:Overlay 渲染导致帧耗时从 2 毫秒涨到 9 毫秒,靠日志才发现是我每帧新建 RTV 带来的开销,而问题代码肉眼完全看不出来。
第二,把 HOOK 包装成单例管理。全局函数和静态变量的组合在退出时很难正确释放,我习惯用一个单例类包装安装、卸载、状态查询。卸载时先恢复到原始指令,再释放跳板内存,顺序不能反过来,否则内存释放后入口地址里就是野指针。退出的顺序在多个 DLL 同时存在时尤其重要,释放顺序要严格保证和安装顺序相反。
第三,寄存器状态的保持。自己的回调里如果调用了绘制函数,务必确保没有破坏原函数期望保留的寄存器。x64 调用约定里 RBX、RBP、RDI、RSI、RSP、R12-R15 是非易失寄存器,如果你的回调里用了它们,编译器会帮你保存,但如果是手写汇编跳板,就要自己压栈恢复。记住这条能省下一晚上的调试时间。
第四,指令边界问题。入口跳板覆盖前 16 字节,如果原函数入口处有奇怪的指令顺序,直接覆盖会截断指令中间,跳到原始函数副本时执行流就乱了。稳妥做法是先用一个反汇编器查清入口前 16 字节的指令边界,再把整个指令序列搬进跳板,不要只按长度硬切。
最后一个习惯是从实践中踩出来的:有一回切换编译平台,x86 下一切正常,x64 下秒崩,查了整整一个晚上,最后发现是某个静态库的移植用了错误的指针截断,跟 HOOK 逻辑毫无关系。从那以后我每次调试这类问题都会先把自己的代码和库代码分开测试,避免冤枉跳板。希望这篇笔记能帮你在 D3D11 HOOK 上少走这些弯路。
本文还有配套的精品资源,点击获取