ReShade 注入式后处理 · 第 二 篇

代理 DLL 与导出表挂钩:一次调用是怎么被接过去的

2026-10-1014,677 字25 张配图本文由@溯光 出品,转载请注明出处。

站点链接:技术博客页 | 公众号:溯光的图形笔记

题图:ReShade 自己的日志页
题图ReShade 自己的日志页(本机现开现拍):探针有没有被加载、挂上了哪几个 API、走的是哪条路径,都在这份 log 里逐行写着。@ 本机现开现拍

切口:进来之后,它是怎么动的

好了,上一篇我们讲清了位置:注入式后处理站在引擎后处理链之后、交换链呈现之前那个缺口上。但"站在那个位置上"和"能接管那个位置"是两件事——它得先进到进程里,再把该拦的调用拦住。

OK,这一篇就往下追这三件事:

  1. 凭什么轮到你被加载——同一个库名,操作系统按什么顺序去找?为什么程序目录里的那个会赢?(第一章)
  2. 导出表里到底有什么——一个库对外提供的函数是怎么被登记、被找到的;(第二章)
  3. 同一批导出函数,三种接法——按导出表重定向、按函数体跳转、改虚表,各自在动什么;以及那套"自动安装挂钩"与 MinHook 的关系。(第三章)

第四章交代一件工程上绕不过去的事:多个包装者怎么共存。第五章给边界、规则与风险。

0.1 全系列目录总览(十一篇)

# 篇目 这一篇要回答的那一问
一 总论:注入式后处理与管线内后处理的边界 它到底动的是哪一段?和引擎自带的后处理差在哪?
二 本篇:代理 DLL、加载顺序与导出表挂钩 它凭什么进得来?进来之后怎么把调用接过去?
三 ReShade FX:语言血统、独立编译器与纹理语义 效果是拿什么写的?编译器为什么能单独拿出去用?
四 effect runtime:technique / pass 图、render state 与运行时数据源 一个 effect 从源码到画面,中间过了几道?
五 跨 API 抽象:D3D9/10/11/12、OpenGL、Vulkan 的差异与统一 同一套东西怎么盖住六种图形 API?
六 Add-on API:事件模型、跨 API 资源交换与两种构建的分野 想在运行时插手更深的事,接口长什么样?
七 深度缓冲的工程学:自动关闭、MSAA、UI 缺失与子矩形错位 为什么深度类效果时灵时不灵?
八 效果生态与许可地图:同一份清单里的 MIT 与"禁止再分发" 那些效果到底能不能用、能不能转?
九 自跑一:注入本地 D3D11 demo——逐效果前后对比 真跑一遍是什么样子?
十 自跑二:效果栈与帧耗账,外加自写一个小 .fx 代价是多少?自己写一个要几步?
十一 合规与工程取舍的合集:把十一篇的账并到一处 这条路线值不值得用、边界在哪?

阅读路径:只关心"这东西为什么能进到我的进程里"的朋友,读第一章就够;要弄清"挂钩"这件事的技术形态,第二章与第三章是主线;只想知道"会不会出事",直接跳第五章。

0.2 本篇的三层证据

言归正传,按本站惯例把证据分层,免得把不同来源的话混着说:

  • 官方文字(带版本号):微软的搜索顺序与 PE 格式文档;ReShade 官方论坛的作者答复、官方 ini 配置文档与发布说明;MinHook 的 README。凡引原话一律给英文逐字;
  • 本机实测:复用上一篇那个自建的 D3D11 场景(evidence/probe/ep01_demo.cpp),新加一个探针 DLL(ep02_loadorder_probe.c)与一套导出表 dump。本篇的证明类图位全部来自这里;
  • 社区与竞品:只作现象与旁证,逐条给出处、标明日期。

0.3 本篇不涉及什么(边界声明)

不涉及 为什么
机器码级的挂钩细节——跳转指令怎么编码、长度反汇编怎么做 那是另一条线(逆向工程)的题域;本篇只讲三种方式的分工与适用面,不落到指令字节
反作弊的名单与判定 各家名单不公开,任何"名单"写法都只能靠推测;本篇只引官方公开口径,不发图、不列表
内核侧(驱动、微过滤、内核回调) 本篇讲的是用户态的一次调用怎么被接过去;内核侧属平台安全题域
后处理算法与效果写法 前者归《实时渲染管线探析》,后者是本系列第三、四篇的事

参数口径一句话:所有本机读数都来自同一台机器(Windows 11 build 26200、RTX 3060 Laptop);全部实验在自有开源 demo 上做,一个商业游戏都不碰。

0.4 四条读法约定(沿用本系列体例)

写这个系列时,有四条约定帮我们避开了四类误读,先摊在这里:

  1. 官方文字优先,二手转述只作线索。 本篇有两处正面对上:一处是"代理链是什么时候来的"(第四章 4.4 节),另一处是 MinHook 该不该叫"内联挂钩"(第三章 3.3 节);两处都按官方可核的文字写,并说明流传说法为什么不准确。
  2. "能进来"与"能接住"必须分开说。 前者是加载顺序问题,后者是函数地址问题。中文资料里常把它们合成一句"注入",于是排查时无从下手——本篇把这两件事分成第一章与第二章。
  3. "没复现"就是"没复现"。 本机做不到的(比如商业程序上的真实挂钩清单),一律写"本机测不了"或"只看官方日志",不拿别人的截图替自己作证。
  4. 实测必带反向对照。 本篇每一处"本机证明"都配了一次"去掉变量再跑一遍":探针放进去 / 移出来、名字用对 / 用错。只有正向结果不算证据——你得证明不这么做时它就不会发生。

一、凭什么轮到你被加载

1.1 一次查找要按顺序问哪些位置

说白了,先看官方的原话。微软文档《Dynamic-Link Library Search Order》给出的标准顺序里,第 7 条与第 8 条是这一篇的两位主角:

"7. The folder from which the application loaded. 8. The system folder. Use the GetSystemDirectory function to retrieve the path of this folder." —— Dynamic-Link Library Search Order(页脚标注 Last updated 2023-02-09)

"程序所在目录"排在"系统目录"前面——这就是同名替换能成立的根因,一个字都不需要额外解释。

言归正传,另外值得指出的是顺序里的一个反直觉之处:系统目录并不是第一个被问的地方。在它前面还有六步:DLL 重定向(第 1 步)、API 集(第 2 步)、清单重定向(第 3 步)、已加载模块的同名项(第 4 步)、系统保留库(第 5 步),以及 Windows 11 21H2 起才有的包依赖图(第 6 步)。这也是为什么"把文件放进程序目录"这件事在绝大多数程序上有效,而在少数已经加载了同名的程序上无效——不是因为第 7 条不成立,而是因为流程更早就在别处被满足过了。第 7、8 两条之所以值得记住,是因为它们代表了"有文件就轮到你"与"没文件就用系统那份"这一分界。

一次查找要按顺序问哪几个位置:程序目录与系统目录谁先被问,以及安全搜
图 2-1一次查找按顺序问哪几个位置:程序所在目录在第 7 位、系统目录在第 8 位,当前目录默认落在第 11 位——关掉安全搜索模式才会把它提到第 8 位,而第 7 位自始至终没被挪动@ 本系列绘制(依据官方《DLL 搜索顺序》清单)

1.2 三条例外,任一条都能把结论翻过来

依然是惯例,只有上一条还不够。真正要用好它,得同时知道哪三种情况会让它失效:

第一,安全搜索模式只挪一个人的位置。 官方在原页写着:

"Safe DLL search mode (which is enabled by default) moves the user's current folder later in the search order." "If safe DLL search mode is disabled, then the search order is the same except that the current folder moves from position 11 to position 8 in the sequence (immediately after step 7. The folder from which the application loaded)."

请注意它挪的是"当前目录"(进程的工作目录),不是"程序所在目录"。这条设置默认开启,很多人以为它会挡住同名替换——它挡不住,因为第 7 条压根没被挪动。这也解释了为什么改名换位这种玩法一直有效。

第二,系统保留库清单(Known DLLs)不听程序目录的。 官方原话:

"Known DLLs. If the DLL is on the list of known DLLs for the version of Windows on which the application is running, then the system uses its copy of the known DLL (and the known DLL's dependent DLLs, if any)." 清单位置:HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs

这是唯一真正意义上的例外:清单里的库,系统直接用自己那份,程序目录里放同名文件也没用。第五章会把本机这份清单读出来看看。

第三,DLL 重定向——官方《Dynamic-Link Library Redirection》给的是同一条规则的另一种表述,但补了一个听起来很反直觉的事实:

"The DLL loader looks in the folder that the calling process was loaded from (the executable's folder) before it looks in other file system locations." "The contents of a redirection file are ignored; its presence alone causes the DLL loader to check the executable's folder first whenever it loads a DLL."

最后那句值得划下来:重定向文件的内容被忽略,只要它存在。文件名本身就是一个开关。

官方搜索顺序的十二步里,第 7 步与第 8 步是这一篇的主角;旁标三
图 2-2官方那张清单共 12 步:前六步是先决判定(重定向、API 集、清单重定向、已加载模块、系统保留库、包依赖图),第 7 与第 8 步才是文件系统查找里决定胜负的两条@ 本系列绘制(依据官方《DLL 搜索顺序》清单)
Windows 系统的分层底图(Commons,CC BY 4.0,
图 2-3Windows 系统的分层底图(原图 3053×2172,本篇作大图):代理库既不是应用的一部分、也不是驱动的一部分,它把自己塞进了「应用要用的那个库」的位置上@ Wikimedia Commons · Pngwen(CC BY 4.0)

上面这张分层图放在这里,是为了把"第 7 条赢了第 8 条"这件事放到系统结构里看:代理库既不是应用的一部分,也不是驱动的一部分,它把自己塞进了"应用要用的那个库"的位置上。位置一换,从应用看过去,它和真正的图形库没有任何区别。

1.3 官方作者自己的说法:它不"运行在进程里",它"假装是驱动"

好了,社区里最容易被记住的一句解释,来自 ReShade 作者本人在官方论坛的答复:

"ReShade doesn't run in a process. It pretends to be the DirectX or OpenGL driver, so that games load ReShade instead of the actual driver and send all render commands to ReShade. ReShade then passes those commands on to the real driver while adding and modifying some commands to do its thing." "…Fortunately the latter has a higher priority than the former, so if a game attempts to load "dxgi.dll", it will first check if that file exists in the game directory, before falling back to the actual one in system32." —— ReShade 官方论坛

"假装是驱动"这个说法不是修辞,它在源码里有对应实现。它靠读自己的模块名判断自己是谁(source/dll_main.cpp):

const bool is_d3d    = _wcsnicmp(module_name.c_str(), L"d3d", 3) == 0;
const bool is_dxgi   = _wcsicmp(module_name.c_str(), L"dxgi") == 0;
const bool is_opengl = _wcsicmp(module_name.c_str(), L"opengl32") == 0;
const bool is_dinput = _wcsnicmp(module_name.c_str(), L"dinput", 6) == 0;
const bool is_asi    = g_reshade_dll_path.extension() == L".asi";

OK,下面对这几行特别说明三点:

  • 不查配置、不看目录,只看自己这个文件叫什么名字。所以"改个文件名就换 API"不是巧合,是设计;
  • 因此命名族是有限的:d3d*(含 d3d9/10/11/12)/dxgi/opengl32/dinput*/.asi——挑一个不在其中的名字,等于白装;
  • 同一个文件还显示:即使以输入库名加载,它也会额外注册系统目录里的 dinput.dll 与 dinput8.dll 用于输入侧挂钩——这就是第三章里"挂了 21 处 user32"那批的来源。

言归正传,社区侧的经验与之完全吻合。PCGamingWiki 直接给出换热名的做法:

"If you have already installed ReShade in the game, but not for DirectX 9, you may simply rename dxgi.dll or opengl32.dll to d3d9.dll."

而官方论坛上那些"装了没反应"的帖子,多数也都落在命名上:

"The game does not load after installing Reshade and I've narrowed down the problem to dxgi.dll... The game does start after renaming it to d3d11.dll or dinput8.dll but it doesn't load Reshade." —— ReShade 论坛排障帖

所以第一章的答案是这样的:同名替换之所以成立,是"程序目录排第 7、系统目录排第 8"这条规则给的;而它之所以不总是成立,是三条例外(安全模式挪的是别人、系统保留库不听、重定向靠文件名本身)与一个现实约束(名字必须落在命名族里)共同决定的。

1.4 命名族为什么是这几个

OK,上一节那五行判断读起来像随手写的,其实对应得很整齐——每一个名字都对应"操作系统会去加载的那一类库":

名字族 对应什么 为什么它值得占一个位置
d3d* Direct3D 9 / 10 / 11 / 12 各自的运行库 老 API 时代每个版本一个库名,是历史留下的缝隙
dxgi Direct3D 11/12 共用的呈现层 新版本把呈现统一到这一层,于是它成了最常被用的入口
opengl32 OpenGL 在 Windows 上的入口库 三十年前的命名沿用至今,宽而不变
dinput* 输入设备侧 与画面无关,但输入事件也是"运行时能插手的地方"
.asi 老式插件扩展名 早期 ENB 时代的遗留通道,官方保留支持

落差在这里。 社区帖里给出的"备选名清单"比官方源码认的多——例如有人建议试 dsound.dll 与 ddraw.dll:

"You can try giving the DLL another name such as d3d11.dll, dinput.dll, dinput8.dll, dsound.dll, ddraw.dll and see whether it loads."

但上一节引的那五行判断里没有 dsound 也没有 ddraw。于是会出现一种很典型的现象:文件确实被加载了(因为第 7 条照样生效),但工具不知道自己是谁,什么也不做。这正是第五章那个"误解一"的机制来源——被加载 ≠ 生效,两者之间隔着"名字必须落在被支持的那几族里"这一道。

1.5 本机实验:把"程序目录优先"证成

说白了,上一节讲的是文档,这一节把它做成可复现的实验。实验只有三件东西:

第一件,靶子。 用上一篇自建的最小 D3D11 场景(evidence/probe/ep01_demo.cpp,一个带棋盘地面的房间加六个方块)。选它的理由有三条:它是自有源码、画面简单到"开了什么一眼看得出"、而且上一篇已经用它跑通了整套注入流程——环境不用重建。

第二件,探针。 新写一个 DLL(evidence/probe/ep02_loadorder_probe.c),只做一件事:在被加载时,把自己完整路径与加载者进程的完整路径写进目标程序目录下的日志。它同时导出两个真函数——不是为了被调用,而是为了让自己的导出表非空:本篇讲的是导出表,探针若无导出,实验就与论点脱节了。

第三件,两次运行。 把探针命名为 dxgi.dll 放进程序目录跑一次;然后把它移出去再跑一次。

结果就是题图左半边那张日志:放进去时,日志里出现一行,路径写着程序目录下的那一份;移出去之后,程序照常启动,但日志不再新增。两次运行的差别只有一个变量——这一条链上没有任何别的解释空间。

依然是惯例,这里有两点必须说明,否则实验会被误读:

  • 为什么不用商业游戏做这个实验。 除了风控与规则问题,还有一个更实际的原因:商业程序不可控。它什么时候去加载哪个库、有没有做自己的检查、版本一变行为是否还一样,我们全都不知道。用一个我们自己的程序做,才能把"变量只有一个"这件事做到位。
  • 为什么日志要记"加载者进程"这一列。 只记"我被加载了"是不够的——因为同一个探针也可能被别的进程(或别的路径)加载。加上加载者进程的完整路径,才能把这行日志绑定到这一次实验的那一次运行上。

好了,按本系列惯例,这一处也把做不到的事说明白:这个实验证明的是"程序目录里的同名文件会被优先拿到",它不证明"这样可以接管任何程序的任何库"——后者还要看目标程序的加载方式与系统保留库清单(第五章)。

二、导出表里到底有什么

2.1 三张表,一套叫法

说白了,进了进程只是拿到身份,真正要动的是函数。而"一个库对外提供哪些函数"这件事,由 PE 格式里的导出目录登记。

官方的英文正文本次抓取受限(Learn 页与官方规范文档都在前段被截断),这里不以二手转述代替原文,只给可核位置:《PE Format》的 "The .edata Section (Image Only)",与官方规范文档 Revision 9.3(2015-12-29)的 §6.3.1–§6.3.5(第 44–47 页)。已取到的官方中文版对序号表的口径是:

"导出序号表是导出地址表中 16 位无偏差索引的数组。序号按导出目录表的"序号基址"字段进行偏置。换句话说,必须从序号中减去序号基址才能获取导出地址表中的真实索引。导出名称指针表和导出序号表将形成两个并行数组……"

导出目录的三张表怎么串起来:导出地址表给 RVA、名称指针表给名字、
图 2-4导出目录的三张表怎么串起来:地址表给 RVA、名称指针表给名字、序号表把名字与序号对上;函数数与名字数不等时,差值就是「只有序号、没有名字」的导出@ 本系列绘制(依据官方 PE 规范导出目录各小节)

依然是惯例,用一张图把三张表的关系说清:地址表按位置给地址,名称指针表给名字,序号表把"第几个名字"与"第几项地址"对上。名字与序号不是两套函数,是同一批函数的两种叫法。

2.2 本机 dump:先看一个最能说清的样本

好了,比起抽象的字段表,不如直接看一份真东西。我们自己写了一个探针 DLL(evidence/probe/ep02_loadorder_probe.c),只导出两个函数,然后用 dumpbin 把它读出来:

Section contains the following exports for ep02_loadorder_probe.dll

    00000000 characteristics
    FFFFFFFF time date stamp
        0.00 version
           1 ordinal base
           2 number of functions
           2 number of names

    ordinal hint RVA      name
          1    0 00001060 ProbeMarker
本机读数面板:探针 DLL 的导出表(dumpbin 原文),含序号
图 2-5本机读数:探针的导出表(dumpbin 原文),序号基址 1、函数 2、名字 2——它是一份有真导出、因而可按名被引用的库,不是随便一个文件@ 本机读数(dumpbin /EXPORTS 原文)
本机读数面板:探针被加载的日志原文,完整路径与加载者进程两列并排——
图 2-6本机读数:探针被加载时的自记日志原文,本模块名与加载者进程两列并排——「谁把谁加载进来」这件事在这里是可读的@ 本机读数(自建探针 DLL 的日志原文)

上面两张图分别是这份导出表的表头,与探针被加载时的日志。它们合起来把第一章那句话证完了:探针只要命名为 dxgi.dll 放进程序目录,程序启动时被加载的就是它——日志里记着完整路径与加载者进程,两列并排,没有推断成分。把它移出目录再跑一次,日志不再新增一行,这是反向对照。

2.3 那,一个系统图形库的导出表有多大

探针只有两个导出,说明不了"挂钩要在这张表里挑"的分量。我们把它换成真的:直接 dump 系统里的 d3d11.dll 与 dxgi.dll——这两份表在下面两张图里,最该看的是表头的三个数(序号基址、函数数、名字数)。

本机读数面板:系统 d3d11.dll 的导出表表头读数(函数数与名
图 2-7本机读数:系统 d3d11.dll 的导出表表头与头几项,函数数与名字数都是 51——每一个导出都带名字,挂钩方按名就能对上@ 本机读数(dumpbin /EXPORTS 原文)
本机读数面板:系统 dxgi.dll 的导出表,与探针、与 ReSh
图 2-8本机读数:系统 dxgi.dll 的导出表 20 项全列,项项有名字、序号基址为 1;注入器在这张表上做的是与自己那份导出表求交集@ 本机读数(dumpbin /EXPORTS 原文)

读法只有一句:d3d11 是 51 个函数对 51 个名字,dxgi 是 20 对 20——两份都是"每个导出都带名字"。要挂钩图形调用,你面对的是一张 20 到 51 项、且项项有名字的表。这与"挂钩输入侧"完全是两种规模,2.7 节会把另一份表摆出来做对照。

2.4 名字与序号,两条路径

导出表的最后一件要交代的事,是"调用方怎么用它"。官方把这件事拆成两页文档:《Exporting from a DLL》与《Exporting Functions from a DLL by Ordinal Rather Than by Name》。前者的原话是:

"A DLL file has a layout very similar to an .exe file, with one important difference — a DLL file contains an exports table. The exports table contains the name of every function that the DLL exports to other executables."

按名调用与按序号调用两条路径对照:前者查名称指针表再读地址表,后者直
图 2-9同一批导出的两种叫法:按名查找要先过名称指针表拿到下标、再读地址表;按序号则用「基址+下标」直接取地址——少一步,也少一层可读性@ 本系列绘制(依据官方导出与按序号导出两页文档)

按名调用的好处是可读、可改:换一份实现,只要名字还在,调用方不用重新编译。按序号调用的好处是省一步查找、也更容易藏:调用方只记着一个数字。这两条路径在挂钩这件事上的后果不同——按序号导出的函数,用名字去挂钩会挂空,而按名导出的函数,序号一变就会错位。这也是第三章里"按导出表重定向"为什么要同时读三张表的原因。

2.5 把一次查找串起来

三张表分开看都简单,合起来才是"导出表"这件事的全貌。我们把一次按名查找按顺序走一遍:

  1. 调用方拿着名字(比如 CreateDXGIFactory)来找;
  2. 加载器在名称指针表里找这个名字,拿到它在表里的下标 i;
  3. 用同一个下标 i 去读序号表,得到一个序号;
  4. 用这个序号去读导出地址表,拿到函数的 RVA;
  5. 加上模块基址,得到函数的真实地址;
  6. 调用它。

关键在于第 3 步:名称指针表与序号表是两个平行数组——"第几个名字"与"第几项地址"就是靠这一对表对上的。所以"改一项地址"这件事,改的是第 4 步读到的那个格子;而"按序号调用"的调用方直接从第 4 步开始,跳过了第 2、3 步。

这就解释了为什么挂钩实现必须同时读三张表:只读地址表,你不知道哪个格子对应哪个名字;只读名称表,你改不到地址。

2.6 导出表的两个边界

边界一:转发。 导出表里的一项可以不指向本模块的代码,而是写着"去另一个模块找这个名字"。这种情况下的"挂钩"要在转发链的另一端做,只在本地改登记表是无效的。

边界二:序号会变。 按序号调用的前提是"序号稳定",而序号是由导出表的排列决定的。版本一变、函数一增删,序号就可能错位——这也是为什么官方专门写了一页文档劝人别滥用按序号导出。对挂钩方来说,这一条反过来成了线索:看到一串没有名字的空档(序号有、名字没有),那多半就是有意隐藏的导出。

本机三份 dump(探针、d3d11、dxgi)里都没有出现转发项,所以本篇不为转发单独出图——没有一手证据的论点,按规矩只写一句话,不画图。

2.7 三份导出表并排读:数字差在哪里

把本机 dump 出来的四份表放在一起,能读出比"表有多大"更有用的东西(探针两份在前,系统库两份在后):

库 序号基址 函数数 名字数 有无名函数吗
探针 ep02_loadorder_probe.dll 1 2 2 无
d3d11.dll 1 51 51 无
dxgi.dll 1 20 20 无
user32.dll 1502 1318 1050 有 268 个

前两份图形库"函数数 = 名字数",说明每一个导出都带名字——这对挂钩方是好消息:按名就能对上。而 user32 那一行不一样:1318 个函数里只有 1050 个有名字,差出来的 268 个是"只有序号、没有名字"的导出——占它全部导出的约 20%(268 / 1318)。

这一栏正好解释了两件事:

  • 为什么"按序号导出"这个机制值得在第二章单独讲(2.4 节):那 268 个函数,按名调用是拿不到的,只能按序号拿;
  • 为什么挂钩方要靠"求交集"(3.5 节):它无法假定"有名字的都能挂"——对方的调用方式、以及自己是否也导出了同名项,两者合起来才决定可挂的集合。

另外留意 user32 的序号基址是 1502,不是 1。别的三份都是 1。这恰好印证了 2.5 节第 3 步那句话:序号不是数组下标,而是"基址 + 下标"——直接把序号当下标去读地址表,在这份表上会整体错位 1501 项。

顺手把量级也摆出来,因为它决定了"挂钩"这件事在两边的难度:user32 的函数数是 dxgi 的 66 倍(1318 比 20),而本机实测的挂钩数在这两处分别是 21 处与 3 处——7 倍。两个比值说明的是同一件事:输入侧要挂的面宽而碎,图形侧要挂的点少而准,所以一个注入器的复杂度其实主要由前者贡献。

2.8 为什么不用自己加载库的办法

读到这里,一个自然的疑问是:既然要让目标程序用上我们的代码,直接自己调 LoadLibrary 把它塞进去不就行了?为什么非要绕道"让它自己来拿"?

区别在两件事上:时机与调用方。

自己加载时,加载动作是我们发起的——它进的是我们的调用栈,得到的也只是"我们手里多了一个模块句柄"。而目标程序自己的调用点,仍然指向它原来要用的那个库。想让它的调用点转向,还得再动别的(改导入表、改函数地址)。

同名替换反过来:加载动作是目标程序自己发起的,因为它的导入表里写的就是这个名字。它把调用交给"那个库"时,交到的就是我们手上。从头到尾,我们没有往对方的内存里写任何东西——这正是它与"外部注入"在风险面上被分开的原因(第五章引的 PCGamingWiki 那条就是针对后者):

  • 同名替换:落在系统的正常加载路径上,撤走也只需删掉一个文件;
  • 外部注入:需要主动往对方进程里塞代码,触发安全软件的概率更高。

按本系列的规矩补一句边界:这一节讲的是两条路线的差别,不是"哪条更高明"。工具选哪条,取决于它要接管什么、以及能不能接受"必须放在对方目录里"这个约束。

三、同一批导出函数,三种接法

3.1 源码里的三行枚举

言归正传,ReShade 把"挂钩"这件事分成三种,这不是外人的归纳,是源码里的一个枚举(source/hook_manager.cpp):

enum class hook_method { export_hook, function_hook, vtable_hook };

三种方式干的是同一件事——让"调用方以为在调 A,实际执行到了 B"——但动的位置完全不同。它们各自的合法性也不同:第一种不需要改任何可执行内存,后两种都要。

三种挂钩方式对照:按导出表重定向、按函数体跳转、改虚表,各自的改动位
图 2-10三种接法的分工:按导出表重定向动的是登记表里那一项,按函数体跳转动的是函数开头那几个字节,改虚表动的既不是代码也不是导出表@ 本系列绘制(依据 hook_manager.cpp 的三值枚举)

3.2 第一种:按导出表重定向

说白了,这是最"干净"的一种:不改任何指令,只改登记表里的那一项地址。调用方照常按名去查,查到的地址已经被指到别处;转发过去之后再决定要不要调原函数。

按导出表重定向的调用路径:调用方照常按名去查,查到的那一项已经被指到
图 2-11按导出表重定向的调用路径:调用方照常按名去查,查到的那一项已经被改指到别处——代码段一个字节都没动@ 本系列绘制

依然是惯例,源码里这条路对应的分支注释只有一句,但很关键:

case hook_method::export_hook: → // Export functions are always called directly

"总是被直接调用"——意思是这类函数在二进制里没有中间层,调用方拿到的地址就是函数本体,因此改登记表就等于改调用目标。而它也有天然的限制:只能挂那些真的按导出表被调用的函数。如果调用方拿到地址之后自己存了起来,或者调用的是虚函数,这条路就断了。

3.3 第二种:按函数体跳转

好了,第二种不做登记表的文章,而是把函数开头那几个字节换掉,让执行流先跳到自己的实现,再通过一段可调用的"跳板"回去执行被替换掉的原始指令。

按函数体跳转的分层:原函数开头被换成跳转、跳转目标是自己的实现、原指
图 2-12按函数体跳转的分层:原函数开头被换成一条跳转、跳转目标是注入器自己的实现,被搬走的原指令待在跳板里负责回来@ 本系列绘制(依据 MinHook 的 trampoline 说明)

本系列第一篇里那句"它内部就是用 MinHook",到这里能说清了——官方 README 对这个文件的描述是:

"|hook.cpp |Wrapper around MinHook which tracks associated function pointers |"

source/hook.cpp 里最核心的一行是:

const MH_STATUS status_code = MH_CreateHook(target, replacement, &trampoline);

钩子与跳板是一次性一起建的——这正是 MinHook 设计上的要点(它的变更记录里写着:"Changed the interface to create a hook and a trampoline function in one go to prevent the detour function from being called before the trampoline function is created.",v1.1,2009-11-26)。写库的人早就踩过"跳板还没建好就被调进来"的坑。

OK,顺手把 MinHook 本身交代清楚(本篇与它只在这一节有交集):MIT 之外的 BSD 2-Clause 许可(v1.3-beta,2014-07-17 由三条款改为两条款)、最新版本 v1.3.4(2025-03-28)、仓库最近一次推送 2026-09-07、约 6,025 颗星;自述原文是:

"The Minimalistic x86/x64 API Hooking Library for Windows"

一处用词纪律:它的 README 里没有出现 "inline hook" 这个词——中文内容里普遍这么叫,本篇按原文措辞("API Hooking Library" + "trampoline function")来写。

3.4 第三种:改虚表

言归正传,第三种针对的是虚函数:对象本身一个字不改,换掉的是它那张"函数指针表"里某一格的指向。源码里这条分支的注释说明了它的前置动作:

case hook_method::vtable_hook: → // Make virtual function table memory writable before modifying it

改虚表前后对照:对象不变,换掉的是那张表里某一格指向的函数
图 2-13改虚表前后对照:对象本身一个字没动,换掉的是那张函数指针表里某一格的指向——而图形程序里大量调用走的正是这张表@ 本系列绘制

三种方式的分工可以这样记:按导出表重定向要动登记表但不动代码;按函数体跳转对任何函数都行、但要改代码;改虚表只对虚函数有用、但连代码带登记表都不用动。这也是为什么一个成熟的注入器三种都要有——目标程序用哪种调用方式,不是你说了算的。

3.5 本机实测:它到底挂了多少

说白了,上面讲的都是源码里的设计。真实跑起来挂了多少?官方的注入日志把这件事逐条打印出来了(本机实测,ReShade 6.7.3):

Registering hooks for 'user32.dll' ...
> Libraries loaded.
> Found 21 match(es). Installing ...
Registering hooks for 'C:\WINDOWS\system32\d3d11.dll' ...
> Libraries loaded.
> Found 3 match(es). Installing ...
本机读数面板:注入日志里的装载与挂钩段落原文,含逐模块的命中数与安装
图 2-14本机读数:注入日志的挂钩装载段原文,逐模块给出命中数与安装动作;为排版去掉了每行的时间戳与进程号前缀,其余逐字未改@ 本机读数(ReShade 6.7.3 日志原文)
本机读数面板:挂钩清单(按模块分组,输入侧与图形侧各多少处),说明「
图 2-15本机读数:把逐模块命中数整理成清单——输入侧 21 处、图形侧 3 处、后加载 2 处、导出表 5 处;末行报告卸载 40 处,比逐模块合计多 9 处@ 本机读数(同一份日志的逐模块行)

上面两张图一份是日志原文,一份是把它整理成清单的样子。这里有一个容易被忽略的读法:它挂的不是"整个 d3d11",而是在两张导出表里找出同时存在的项——源码注释写得很直白:

// Load export tables from both modules // Analyze export tables and find entries that exist in both modules // Hook all matching exports

"两个模块"里的另一个是它自己:ReShade 为了当代理,本身也导出了同名的转发函数。于是"挂钩"这件事在实现上变成了一次导出表求交集:交集里的每一项,都是既能被调用方按名查到、又能在它这边接住的那一批。日志里 Found 21 match(es) 与 Found 3 match(es) 就是交集的大小。

还有一处必须一起说清,否则那两个数会被误读成"它总共装了多少":日志逐模块宣布的命中数合计是 31 处(21+3+2+5),而日志末尾那行报告的是卸载 40 处——差额 9 处,相当于逐模块合计的 29%。也就是说,还有一部分挂钩没有出现在逐模块段落里。本机把这一段连着跑了五次,每次都是宣布 31、卸载 40——它是稳定出现的差额,不是偶然。所以引用时得先问一句:要的是"逐模块能对上账的那部分",还是"它实际装了多少"。

这两个数字的差(21 比 3)要多读一眼:输入侧要挂的多得多。原因是输入库的调用面比图形库碎——user32 里的窗口、消息、光标、输入状态全是一个个小函数;而图形侧真正需要接住的就集中在呈现与资源创建那几处。这其实是个好消息:要接管一帧,不需要接管整个图形库,只需要接住那几个关键点。第三篇讲的"画面在哪里被加料",接的就是这几处。

接口这一层是什么(Commons,CC BY-SA 4.0,1052
图 2-16接口这一层是什么(原图 1052×744,本篇按原理小图用):调用方依赖的是名字与约定,不是实现——这正是「按名查表」能被打断的前提@ Wikimedia Commons · Dew1978(CC BY-SA 4.0)

3.6 还没完:后加载的模块怎么办

依然是惯例,上面这套只处理"进程启动时已经在了"的库。但图形库往往是之后才被加载的——那就得再挂一次。源码里的做法是监听模块加载通知:

static VOID CALLBACK DllNotificationCallback(ULONG NotificationReason, const LDR_DLL_NOTIFICATION_DATA *NotificationData, PVOID) Installing delayed hooks for '%s' (Just loaded via LoadLibrary('%s')) ...

"延迟挂钩"这四个字,把这一类实现的边界交代清楚了:它不是一次性动作,而是一套持续在跑的登记机制。这也是为什么这类工具能覆盖"游戏启动后才切渲染后端"这种情形。

3.7 既然按导出表就够,为什么还要另外两种

一个自然的疑问是:第一章刚说了"这类函数总是被直接调用",那按导出表重定向岂不是最省事、最安全的一种?为什么还要冒着改代码的风险去做另外两种?

答案是目标程序不按你的假设走。至少有三类情形让第一种失效:

  • 调用方把地址存起来了。 有些程序在初始化时拿到函数地址、存进自己的结构里,之后一直用那个副本——你在导出表里怎么改,它都不会再去看;
  • 调用方根本不经过导出表。 图形程序里大量使用虚函数(把交换链、设备、上下文都做成对象),虚调用走的是对象自己的函数指针表,与库的导出表无关;
  • 模块是后加载的。 启动时不在的库,导出表重定向的时机就错过了(这正是 3.6 节那套延迟挂钩要解决的问题)。

反过来说,第二种(按函数体跳转)虽然"什么函数都能挂",代价也最直接:它改的是可执行内存。三种方式凑在一起,才是一个"目标程序用哪种调用方式都不怕"的实现。源码里那个三值枚举,本质上是对目标程序可能性的枚举,不是对技术的枚举。

3.8 三种失败模式,症状长得一样

挂钩这件事的失败,几乎都会表现为同一句话:"装了,没反应"。但原因至少有三种,而且它们要用不同的办法区分:

失败一:挂得太早。 需要接住的库在启动时还没被加载,挂钩的时机就错过了。这正是 3.6 节那套"延迟挂钩"要解决的问题——它的做法是监听模块加载通知,后到的模块再挂一次。

失败二:挂得太晚。 目标程序在初始化阶段就把函数地址取出来存进了自己的结构里,之后一直用那个副本。这时候你在导出表里改得再对,它也不会再看一眼。

失败三:挂上了,但没走到。 钩子确实装好了,可调用方走的不是那条路——比如它用虚函数调用(见 3.4 节),而你的钩子挂在导出表上。两边的实现都在,就是碰不上。

三种失败在日志上很像——"挂钩成功"和"挂钩生效"是两件事,日志只能证前者。区分它们靠的不是更多的日志,而是知道目标程序走哪条调用路径。这也是为什么一个成熟的注入器要三种方式都备着(3.7 节):它无法预知对方走哪条路,只能把三条路都占上。

3.9 一个常被忽略的前提:挂钩是可撤销的

讲挂钩时,几乎所有人都在讲"怎么挂上去",很少有人讲"怎么摘下来"。而这件事在这类工具上占比不小——因为它需要在不退出目标程序的前提下开关功能。

源码里对此有两句很朴素的注释:

// Only leave MinHook active as long as any hooks exist
MH_QueueEnableHook(target);
MH_QueueDisableHook(target);
bool reshade::hook::apply_queued_actions() { return MH_ApplyQueued() == MH_OK; }

"只要有钩子存在,就让 MinHook 保持活动"——这句话背后是一个判断:挂钩库本身也是负担,不该常驻。而"排队 + 统一提交"(queue/apply)这套做法,是为了避免在不同线程上一个个改地址造成的竞态。对读者来说,这里有两条可迁移的经验:改地址是不可逆操作里较危险的一类,所以要有摘要(排队)与统一提交(apply);开关功能的最低成本实现,是把钩子挂与摘分开,而不是改画面逻辑。

四、多个包装者怎么共存

4.1 先分清"包装"是什么

好了,在讲共存之前,得先把"包装"这件事本身讲清。Commons 上有一对现成的 UML 图可以借用(都是公有领域,尺寸只有 1000×500,只能作原理小图):

委托式的包装(Commons,Public domain,1000×
图 2-17委托式的包装(原图 1000×500,本篇按原理小图用):包装者持有一份被包装者的引用,把调用转出去——代理库与真库之间就是这种关系@ Wikimedia Commons · Trashtoy(Public domain)
继承式的包装(Commons,Public domain,1000×
图 2-18继承式的包装(原图 1000×500,本篇按原理小图用):与委托式成对,说明同一件事的两种实现取向@ Wikimedia Commons · Trashtoy(Public domain)

OK,两张图讲的是同一件事的两种实现取向:委托式的包装者手里拿着被包装者的引用,把调用转出去;继承式的包装者直接把被包装者的接口继承过来再改写。本文这一族工具用的是前者——因为它要包装的往往是一个已经存在的具体对象(比如那块交换链),而不是一个可以继承的类型。

代理链的三跳:最外一层收下调用、按配置交给下一层、最后一层才把调用交
图 2-19代理链的三跳:最外一层收下调用、按配置交给下一层、最后一层才把调用交给真正的系统库——位置是排他的,所以只能排队@ 本系列绘制(依据官方 ini 文档与源码实现)

4.2 代理链:一层不够就叠两层

言归正传,一旦你发现"程序目录里那个同名文件"这个位置是排他的,问题就来了:翻译层也想占这个位置、注入器也想占这个位置,怎么办?

ReShade 的解法是在配置里加一段代理链。官方的 ini 文档(作者本人在论坛整理)原文:

EnableProxyLibrary=<0/1> Enable chain-loading of the next wrapper DLL. ProxyLibrary=<path> Path to the DLL that should be chain-loaded next, when ReShade is installed as a wrapper DLL. ModulePath=<path> Path to a directory that should be used to load proxied system DLLs from (e.g. when installed as dxgi.dll, the original dxgi.dll will be loaded from this path). This can be used to set up proxy chains.

源码侧对应实现只有三行:

if (config.get("PROXY", "EnableProxyLibrary") &&
    config.get("PROXY", "ProxyLibrary", export_module_path))
{ reshade::hooks::register_export_module(g_reshade_base_path / export_module_path); }
本机读数面板:代理链实验日志——两个包装者先后被加载的先后顺序,就是
图 2-20本机读数:代理链实验的日志——探针在本机唯一的加载途径就是 ReShade 的 [PROXY];把开关改成 0 再跑,探针记录消失,构成单变量对照@ 本机实测(ReShade 链式加载 + 自建探针 DLL)

上面这张是本机的代理链实验日志:两个包装者先后被加载,顺序就是链的顺序。

顺序这件事,在代理链里不是"先后"那么简单,它直接决定最终画面是谁改的。把顺序反过来会怎样?假设链上第一环拿到了原始调用却不往下转,那么后面那一环就永远收不到调用——它看起来"装了",实际上一次都没被调用。反过来,如果某一环在往下转之前就把调用处理掉了,那么后面的环节看到的是"已经被改过的结果"。

所以这条链上有两条必须写清的口径:

  • 链的顺序 = 生效顺序:前一环的输出是后一环的输入。这一点与上一篇讲效果栈时那句"顺序即执行顺序"是同一条道理,只不过这里串的是程序,上一篇串的是画面;
  • "被加载"不等于"被调用":日志能证明前者(本篇的 V16 就是证据),证明后者要靠别的读数——这一条我们照实说,本篇只证前者。

4.3 与本系列第一篇那条链的接口

回头对一下上一篇留下的口子。第一篇讲"一帧交出去之后还有谁在动它"时,把这一段的机制明确留了出来——当时那句话是:拦下、加料、放行这三拍里,"拦下"这一步的具体机制留到第二篇。

现在可以把它接上了:"拦下"不是一个动作,而是三个动作叠起来的——

  1. 位置:靠加载顺序把文件放到"程序会先问的那一格"(第一章);
  2. 登记:靠导出表把自己的转发函数登记成同名项,于是目标程序查到的地址落在自己这边(第二章);
  3. 接住:靠三种挂钩方式之一,把真正要拦的那些函数换成自己的实现(第三章)。

三件事齐了,才谈得上"拦下"。而这三件事合起来的效果,就是上一篇那张图里"缺口上的第三个格子"——从画面的角度看,那里什么都没有;从调用的角度看,那里已经换了一整套人。

4.4 一处版本考据:这段配置是什么时候来的

说白了,中文资料里常看到"5.0 起引入代理链"这句话。按官方可核的文字,这句话不准确:

  • [PROXY] 这两个键本身,在 5.x 时期就已经由作者在官方论坛的配置文档里列出(本笔记 §1.7 引的就是那份文档);
  • 而把"支持多个包装者链式加载"作为特性条目写进发行说明,是 6.7:

"- Added proxy library option to ReShade.ini to support chaining of multiple wrappers: [PROXY] / EnableProxyLibrary=1 / ProxyLibrary=d3d9_dxvk.dll" —— ReShade 6.7 发布说明

按本系列的规矩,这种"键早就在、只是当年没写进发布说明"的情形要说明白,而不是跟着转述。

4.5 一个有意思的旁证

依然是惯例,另一个更有意思的事实是:ENB 系列也有一套同名同形的配置——EnableProxyLibrary / InitProxyFunctions / ProxyLibrary。两家工具在没有任何隶属关系的情况下,各自演化出同一个机制、同一个段名。

这说明"多个包装者共存"不是某一家的发明,而是这条路线绕不过去的工程问题:位置是排他的,需求是串行的,最后只能靠"谁先谁后、往下交给谁"来解。

4.6 边界:这条路对 Vulkan 失效

讲完叠加,得给一条硬边界——同名替换不是万能的注入器。官方在这个问题上的表述很明确:

"Unlike other APIs, Vulkan cannot be injected into using a DLL file."

Vulkan 走的是另一条路:把自己注册成一个图层(layer),需要管理员权限写注册表,再让驱动在启动时把它挂上;副本放在 C:\ProgramData\ReShade。这条路径与"程序目录优先"毫无关系——它压根不经过 DLL 查找顺序。

两条进入方式的对照:同名替换要程序主动去加载它,而图层路线由驱动侧在
图 2-21两条进入方式的对照:同名替换靠文件名被加载器选中,Vulkan 图层靠注册表登记由驱动侧挂上——后者换文件名得不到@ 本系列绘制(依据官方 Vulkan 图层说明)
API 家族里的对象模型(Commons,CC BY-SA 3.0,
图 2-22API 家族里的对象模型(原图 1003×1180,本篇按原理小图用):同一套概念在不同图形接口上的名字并不统一——所以才有「同名替换」这种按文件名分流的做法@ Wikimedia Commons · Sven Gothel(CC BY-SA 3.0)

五、边界、规则与风险

5.1 把系统保留库的清单读出来

第一章提到"唯一真正的例外是系统保留库"。这句话有多硬,读一眼清单就知道:

本机读数面板:系统保留库清单的读数——清单里的库不听程序目录的,同名
图 2-23本机读数:系统保留库清单共 38 条;user32 与 gdi32 在清单里(同名替换对它们无效),而 d3d11、dxgi、d3d9、opengl32 都不在清单里@ 本机读数(reg query 输出原文 + 脚本比对)

官方给的位置是注册表键 HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs。这份清单里的库,程序目录里放同名文件也没用——系统直接用自己那份。这解释了一个常见现象:有些程序(尤其系统组件)你怎么放都不生效,而另一些一放就中。

这张清单上,本篇点名的七个库名里只有两个命中,命中率 29%(2 / 7):user32、gdi32 在清单里,同名替换对它们无效;而 d3d11、dxgi、d3d9、d2d1、opengl32 五个都不在。这也正好解释了为什么这类注入器的命名族(d3d* / dxgi / opengl32)全都落在清单之外——清单里的库根本替换不动,能替换的才轮得到你。

5.2 名字挑错,等于完全没装

第二条边界更朴素,但它是实际使用中出问题最多的一条:代理名不能随便挑。

上游的命名族是有限的(d3d*/dxgi/opengl32/dinput*/.asi),而程序实际去加载哪一个由它自己的实现决定。这两件事一对不上,就是你放了文件、什么都发生了、但画面上什么都没有。

本机读数面板:同一次实验用两种命名各跑一遍的日志对照——名字挑错,就
图 2-24本机读数:同一个探针文件、同一个宿主、同一个目录,只改文件名——命名为 dxgi.dll 时被采纳,命名为 dxgi2.dll 时连加载都没发生过@ 本机实测(host_load.log 两次运行对照)

上面这张是同一套实验、两种命名的日志对照:用对名字时探针被加载、用错名字时一行都没有。论坛上那些"换了个名字就能进、进不去 ReShade"的帖子,症状与此完全一致——文件被加载了,但加载它的不是你以为的那个入口。

5.3 规则侧:三条官方口径

第一条,来自另一个同类工具的官方警告,也是最重的一条。 DXVK 的 README 里写着:

"Manipulation of Direct3D libraries in multi-player games may be considered cheating and can get your account banned. This may also apply to single-player games with an embedded or dedicated multiplayer portion. Use at your own risk." —— DXVK README

第二条,来自 ReShade 作者(2018 年的帖子,引用须带日期):

"ReShade is whitelisted by most major anti-cheating software. Although this is true only for specific ReShade releases, so that the latest version may not always be recognized." "They won't ban you for its usage though, they simply won't start with it installed." "All of this only applies to the official release of ReShade (can be easily checked by comparing the file hashes). What people do with the open ReShade source code I cannot control."

这三句里有两处要分开看:"只对特定发行版有效"(白名单不是对你手里任意一份文件有效),以及"不会封号,只是装了就不启动"——这是两种强度完全不同的措施,中文传播里常被合成一句"会封号"。

第三条,关于"外部注入器"这条替代路线。 PCGamingWiki 对它的评价是:

"This method uses an external injector that may cause some anti-cheat software to be triggered, so use at your own risk."

有意思的是:"同名替换"与"外部注入"在反作弊眼里并不是同一件事——前者至少还落在"正常加载路径"上,后者是直接往进程里塞东西。

5.4 本系列的红线

  • 全部实验在自有开源 demo 上做,一个商业游戏都不碰;
  • 不写任何绕过做法:含绕过反作弊、绕过检测、绕过深度限制;
  • 不提供任何二进制或着色器下载——官方明令不得分发("Do NOT share the binaries or shader files. Link users to this website instead."),本篇只给官网链接;
  • 引用必带日期与来源:尤其是"白名单""不会封号"这类会被读者当结论用的话。

5.5 三个常见误解

这一章的结论会被读者直接拿去用,所以把三个高频误解一次说清:

误解一:"占了位置就等于拿下了一切。" 位置只解决"被加载"。加载之后能不能真的接住调用,取决于目标程序怎么调——第一章讲的是门,第三章讲的是手,两件事分开。实际使用中"装了没反应"的多数情形,落在这两件事的交界处:文件被加载了(门开了),但程序走的调用路径没经过它能接住的那几个点。

误解二:"换个名字总能试出一个能用的。" 从技术上说,挨个试确实可能碰上;但每一次试都是一个非官方发行版在非官方场景下的运行——按作者的原话,白名单"只对特定发行版有效",而"别人拿开源代码做了什么我控制不了"。本系列因此把这一条写成红线:不教试名字,只讲清名字为什么必须匹配。

误解三:"这只影响联机游戏。" 官方那条最重的警告(5.3 节引的 DXVK 原话)里有一句常被忽略:它也适用于"带内嵌或独立联机部分的单机游戏"。一个表面上单机、内部带服务器架构的程序,风险与联机程序同档。

5.6 "上周还能进、这周不行"的机制解释

上一篇讲在线边界时提过一个现象:同一个工具、同一款游戏,昨天能进、更新完就进不去。当时只说了"这不是封号",这一篇可以给出机制:

白名单是逐发行版的。作者的原话已经把这一点写在明面上——"只对特定的 ReShade 发行版有效,最新版本不一定总被识别"。工具每发一版,文件哈希就变了,加白是一个需要重新走一遍的动作。而反作弊侧要不要为某个版本加白、什么时候加,取决于它自己。

于是会出现这样一条时间线:工具更新 → 新哈希尚未被加白 → 用户侧表现为"进不去"。它既不是封禁,也不是游戏更新导致的——它是"版本号变了"这一件事的下游后果。

这条解释有两处实用价值:一是排查时先看版本,而不是先怀疑自己配置错了;二是它把"信任"这件事从人品问题变成了版本管理问题——这也是本系列第二篇与第七篇都会回到的一条线。

5.7 三种读者可以带走的三种用法

这一篇的结论对不同人用处不同,分开说:

如果你是使用者,只记一条就够:出问题时先看版本、再看名字。版本决定它是否被当前环境接受(5.6 节),名字决定它有没有被真正加载(5.2 节)——这两条能解释绝大多数"装了没反应"。

如果你在排查别人的环境,请把"被加载"与"被调用"分开问(3.8 节那三种失败模式的症状是一样的)。一个最快的分诊办法是:先确认日志里有没有加载记录——没有,是加载层的问题(名字或顺序);有,才轮到挂钩层。

如果你在写工具,这一篇给的是三条工程约束:位置是排他的(所以要处理代理链)、可挂的集合是自己与目标的导出表交集(所以要先枚举再挑)、以及挂钩必须是可撤销的(3.9 节)——开关功能靠摘钩子,而不是靠改画面逻辑。

六、本篇三条结论

  1. "能进来"这件事,来自一条排序规则而不是一个漏洞。 程序所在目录排第 7、系统目录排第 8,这是官方文档写明的事实;真正需要小心的反而是那三条例外(安全模式挪的是"当前目录"、系统保留库不听程序目录、重定向靠文件名本身)。
  2. "能接住"这件事,靠的是导出表求交集。 它挂的不是"整个图形库",而是在自己的导出表与系统的导出表里同时存在的那一批函数——本机实测:输入侧 21 处、图形侧 3 处。这个数字本身就是"接住了多少"的答案。
  3. "能共存"这件事,只能靠排队。 位置是排他的、需求是串行的,所以才有代理链;而这条链一旦成立,它就与"谁先加载"彻底绑定——顺序即行为。

6.1 本篇的量化小结

把本篇出现的数字收在一处(全部可复现,命令与条件在对应小节里):

项 值 出处
搜索顺序里"程序目录"与"系统目录"的位置 第 7 / 第 8 官方文档原话
安全搜索模式挪动的是 "当前目录"(第 11 → 第 8) 官方文档原话
探针导出表 函数 2 / 名字 2 / 序号基址 1 本机 dumpbin
本机实测的挂钩数 逐模块 21/3/2/5,合计 31;同一日志末行报卸载 40 注入日志
代理链的键何时出现在官方文档 / 何时写进发布说明 5.x / 6.7 论坛配置文档、6.7 发布说明
MinHook 许可与版本 BSD 2-Clause / v1.3.4(2025-03-28) 项目 README
外部示意图的交付形态 1 张 1920px 宽的大图 + 4 张 1280px 宽的原理小图 素材盘点与尺寸审计

这一篇的区别就在这里:每个"结论"后面都跟着一个可核的数,而不是一个形容词。

6.2 本篇的方法学小结

本篇一共做了四组本机实测,每一组都遵守同一条约定:只改一个变量,并给出反向对照。

实验 变量 正向结果 反向对照
加载顺序 探针在不在程序目录里 日志出现一行,路径为程序目录 移出后日志不再新增
命名匹配 用对名字 / 用错名字 用对名字时被加载 用错名字时连加载都没有
导出表 换成真实系统库 读出函数数与名字数 与只有两个导出的探针成数量级对照
代理链 链上第二环是否被转到 第二环被加载 不带链时它不出现在日志里

为什么要给反向对照:正向结果单独看,永远可能被别的解释吃掉——"它被加载了"可能是别的东西顺带加载的,"画面变了"可能是别的因素。只有把变量去掉、结果随之消失,才能把因果钉住。 这是这一篇能给出"本机实测"这四个字的底气,也是它比"引用一段别人的结论"更值得读的地方。

6.3 三处本篇没能回答的

按本系列惯例,把做不到的也摆出来:

第一,我们证明了"被加载",没有证明"被调用"。 探针日志能证明它被加载了(这是加载顺序那一条的直接证据),但"程序确实把图形调用交给了它"这件事,需要另一类读数(比如被调用次数)。本篇照实写:前者有图,后者只有推断,所以不画图。

第二,真实程序上的挂钩清单,本机测不了。 3.5 节那两个数(输入侧 21、图形侧 3)来自官方自己打印的日志,不是我们逆向出来的。这在证据等级上要标清楚:它是官方发行版的自我报告,可信但仍是单一来源;要用本机复现同样的清单,就得进到商业程序里去看——那是本系列红线之外的事。

第三,转发(forwarding)没有一手样本。 本机这四份 dump 里都没有转发项,所以第二章 2.6 节只写了一句机制说明,没有配图。有朝一日遇到真实样本,这一节该补一张图——但在没有样本之前,补图就是编。

6.4 与后面九篇的接口

  • 本篇讲入口(进来、接住),不展开"接住之后跑什么"——那是第三、四篇(效果语言与运行时);
  • 本篇只点名"跨 API 差异"的一个角(Vulkan 不走同名替换),完整的六 API 对照是第五篇;
  • 本篇的三种挂钩方式只讲分工与适用面,机器码级细节不在本篇也不在本系列;
  • 本篇的实测基础是上一篇那个自建场景,第九、十篇的自跑会回到同一个场景上,届时读数可直接对比。

一句话:这一篇把"门是怎么进的、手是怎么伸进去的"讲完,从第三篇开始讲"进去之后拿这些东西做了什么"。

资源清单

官方一手

工具源码与文档

社区与竞品

图片素材(Wikimedia Commons,逐张核过许可)

下篇预告

下一篇是《ReShade FX:语言血统、独立编译器与纹理语义》。前两篇走的是"它怎么进来、怎么接住";下一篇换到它接住之后做了什么:效果是用什么写的、这门语言的血统从哪来、为什么它的编译器可以单独拿出去用,以及纹理语义(比如深度纹理)为什么是这门语言里最特殊的一类声明。

落款

本文由@溯光 出品,转载请注明出处。本文同步发布于公众号「溯光的图形笔记」与个人博客 yyy3d.online。

这一篇把"门是怎么进的、手是怎么伸进去的"讲完了;希望能对大家有所帮助——尤其是那三条"会让结论翻过来的例外",它们比那条排序规则本身更值得记住。