
简介这是一份围绕64位与32位Inline Hook的源码学习包面向已具备一定Windows底层编程和汇编基础、希望理解函数入口指令改写机制的开发者可用于系统监控、调试与性能分析等场景的实验参考。压缩包共6个文件包含2个c、2个cpp与2个头文件分别提供x86/x64的C语言实现和C类封装包体约6KB体量轻便便于逐行阅读与对照。已有1635人学习说明其作为Inline Hook实现参考具有一定认可度。读者可获得32位与64位下的钩子安装、卸载及原指令保存恢复思路并通过C类接口理解目标地址与自定义函数的管理方式64位部分还涉及RIP相对寻址、指令重定位等细节有助于建立从原理到代码落地的完整认知。1. 32/64 位 InlineHook 不是改几个字节那么轻松前段时间要把一套老系统的调用链监听起来源码不在手上最省事的办法就是在函数入口改几个字节——这就是 InlineHook。这份源码包提供的是一套完整实现同时支持 32 位和 64 位进程的 Hook 安装与卸载对外提供 C 语言函数接口和 C 类封装两套调用方式附带长度反汇编、指令重定位、内存页属性切换这些底层配套还有 C 和 C 两个可编译运行的示例工程。适合两类人一类是要在运行时拦截、日志、打点、复写第三方库行为又不想改源码的工程岗另一类是正在啃 x86/x64 指令编码想找一个能编译、能断点、能对着调试器逐条看跳转的实物参考。真正上手后会发现32 位下 5 个字节就够64 位下光是选哪种跳转编码就能卡住半天内存地址够不着、被覆盖指令跨边界这些坑都是亲身踩出来的。2. 工程骨架与构建一份在 x86 和 x64 下都能编过的目录结构这份源码能直接复现很大程度得益于目录划分平台相关的代码被拆成独立文件公共流程抽成一份示例工程和核心库分离。比起把所有#ifdef _WIN64写在同一个 .c 文件里这种拆法在调试时有明显优势32 位崩溃只看hook_x86.c64 位只看hook_x64.c不用在几百行预处理分支之间来回跳。2.1 目录分层公共头、平台实现、反汇编器、示例与测试解压后一般能看到这样的结构inline-hook/ ├── include/ │ ├── hook.h # C 语言对外接口全部 extern C │ ├── hook.hpp # C 类封装头文件即用 │ └── hook_types.h # 状态码、结构体、跨平台类型定义 ├── src/ │ ├── hook_core.c # 公共流程校验、跳板分配、安装、卸载 │ ├── hook_x86.c # 32 位补丁生成与 rel32 偏移计算 │ ├── hook_x64.c # 64 位绝对跳转、RIP 相对寻址修正 │ ├── hook_mem.c # 内存页属性、指令缓存刷新、可执行内存分配 │ ├── hook_thread.c # 改写窗口内的线程挂起与恢复 │ └── ldisasm.c # 精简版长度反汇编器 ├── samples/ │ ├── sample_c/ # C 接口示例hook 一个加法函数 │ └── sample_cpp/ # C 类示例hook 一个成员函数 ├── tests/ # 回归用例短函数、跨页函数、已有跳转的函数 └── CMakeLists.txthook_core.c里不放任何跟位宽相关的字节编码它只做三件事拿目标函数的入口地址、调长度反汇编确定要覆盖多少字节、调hook_alloc_trampoline申请跳板。真正产生机器码的动作全部下沉到hook_x86.c/hook_x64.c。这种分层带来的直接好处是想把它移植到别的指令集只需要重写这两个文件上层 API 一个字节都不用动。ldisasm.c是很多人容易忽略的一块。判断“入口处到底有几条指令、一共多少字节”不能靠数汇编行数必须按指令编码规则逐字节解析看前缀0x66、0x67、0xF2、0xF3、段前缀0x2E~0x3E、看 REX 前缀0x40~0x4F仅 64 位、看 opcode 是一字节还是0x0F两字节、再看 ModRM/SIB 是否带 disp、是否带 imm。这份实现覆盖面不算全但 x86/x64 的常见指令——push、mov、sub、test、jmp、jcc、call、lea——都能正确算长度对 Hook 场景已经够用。samples/下面两个工程的存在意义不只是演示它们是最好的验证环境先跑通 C 版本的加法函数 Hook确认跳转链没问题再去跑 C 版本的成员函数 Hook能快速判断问题出在底层改写还是接口封装。2.2 编译配置几个开关会直接决定 Hook 能否稳定生效用 CMake 生成工程时最容易忽略的是源码文件的选择和几个优化开关。核心库里通常这么写# CMakeLists.txt 关键片段 cmake_minimum_required(VERSION 3.15) project(inline_hook C CXX) add_library(hook STATIC src/hook_core.c src/hook_mem.c src/hook_thread.c src/ldisasm.c ) # 按目标位宽选平台实现而不是靠 #ifdef 全塞进一个文件 if(CMAKE_SIZEOF_VOID_P EQUAL 8) target_sources(hook PRIVATE src/hook_x64.c) target_compile_definitions(hook PUBLIC HOOK_ARCH_X641) else() target_sources(hook PRIVATE src/hook_x86.c) target_compile_definitions(hook PUBLIC HOOK_ARCH_X861) endif() # 静态链接运行库被注入到别人进程时不用额外带 CRT set_property(TARGET hook PROPERTY MSVC_RUNTIME_LIBRARY MultiThreaded)第一处选择用CMAKE_SIZEOF_VOID_P判断而不是自己加一个-DHOOK_64开关——指针大小是按目标平台自动算出来的交叉编译也不会判断错。第二处是运行库如果你的库最终要被塞进别人的进程动态 CRT 会在加载时找不到对应版本的运行时静态链接能省掉一整类“为什么注入后立刻退出”的问题。还有两个编译细节值得在工程里显式处理。一是关闭“函数级链接”和全局优化对目标函数的干扰/Gy会让每个函数独立成 COMDAT 段/GL加链接期代码生成会把小函数直接内联掉结果就是你在代码里写的target_func拿到的地址根本不是你预期的那个函数体开头。二是在示例工程里对目标函数加__declspec(noinline)保证它确实是一个独立函数能被稳定寻址。2.3 对外接口约定三个函数加一个枚举就能覆盖全部调用场景C 接口刻意做得很少一个 Hook 的生命周期就是“安装、拿原始函数指针、卸载”三步/* include/hook.h —— 对外只暴露这几样全部 extern C */ #ifndef HOOK_H #define HOOK_H #include stddef.h #ifdef __cplusplus extern C { #endif typedef enum { HOOK_OK 0, HOOK_ERR_NULL_ARG, /* 目标地址或代理函数为空 */ HOOK_ERR_PROLOGUE, /* 函数头字节数不足以容纳跳转 */ HOOK_ERR_ALLOC, /* 跳板内存申请失败 */ HOOK_ERR_PROTECT, /* 代码页属性修改失败 */ HOOK_ERR_EXISTS /* 该地址已被本模块挂过钩子 */ } hook_status; typedef struct hook_ctx hook_ctx; /* 不透明句柄内部结构不对外 */ /* 安装钩子target 为被 Hook 函数入口detour 为你的代理函数 */ hook_status hook_attach(void* target, void* detour, hook_ctx** out); /* 取跳板地址调用它等价于调用原函数 */ void* hook_trampoline(const hook_ctx* ctx); /* 卸载钩子恢复原始字节 */ hook_status hook_detach(hook_ctx* ctx); const char* hook_strerror(hook_status st); #ifdef __cplusplus } #endif #endif /* HOOK_H */结构体hook_ctx只声明不定义是刻意的调用方拿到的是不透明指针内部字段——目标地址、补丁原始字节备份、跳板地址、覆盖长度——全部藏在.c文件里。这样后续往里加链式 Hook 支持、加引用计数、加线程同步对象都不影响已经编译好的调用方。hook_status的粒度也值得注意它把最容易出问题的四类失败单独拆开了HOOK_ERR_PROLOGUE对应函数太短或被编译器优化得只剩一两条指令HOOK_ERR_ALLOC对应跳板地址分配不出来HOOK_ERR_PROTECT对应目标所在的页不可写HOOK_ERR_EXISTS对应重复挂载。新手最常犯的错是只看返回值非零就去查代码而实际上错误码已经清楚指出是哪一步失败。hook_trampoline返回void*而不是强类型函数指针是为了让调用方自己决定签名typedef int (*add_fn)(int, int); static add_fn g_orig_add NULL; static int hooked_add(int a, int b) { /* 在调用原函数前后插入自己的逻辑 */ return g_orig_add(a, b) 1000; } /* 安装完立刻取原始函数二者必须成对出现 */ hook_ctx* ctx NULL; if (hook_attach((void*)target_add, (void*)hooked_add, ctx) HOOK_OK) { g_orig_add (add_fn)hook_trampoline(ctx); }参数说明上两个地方要留心target传函数地址时必须强转void*成员函数要先算出真实入口后面章节会讲为什么取Class::method在部分编译器下拿不到可执行地址detour的签名必须与目标函数完全一致包括调用约定——32 位下__stdcall和__cdecl混用栈会在返回时直接失衡表现为函数返回后栈指针错乱、进程秒崩。3. 指令级改写5 字节相对跳转与 14 字节绝对跳转怎么选把控制流从 A 函数拽到 B 函数本质是在 A 的入口写入一条跳转指令。32 位下这件事很优雅E9加 4 字节相对偏移一共 5 个字节跳到 ±2GB 范围内任意地址。64 位下同样的思路直接失效——相对偏移仍然只有 32 位而用户态地址空间宽到 48 位你的代理函数大概率落在 2GB 之外偏移溢出。这一章把两种位宽的补丁生成和被覆盖指令的搬迁讲透。3.1 32 位E9 相对跳转的偏移计算与 5 字节硬约束x86 下的补丁就是一行jmp rel32偏移是相对于“下一条指令”的地址算的/* src/hook_x86.c —— 生成 5 字节相对跳转补丁 */ #define X86_JMP_SIZE 5 int hook_x86_build_patch(void* target, void* detour, unsigned char* out_patch, size_t* out_len) { /* 相对偏移 目标地址 - (当前指令地址 指令长度) */ int64_t delta (int64_t)((unsigned char*)detour - ((unsigned char*)target X86_JMP_SIZE)); /* 32 位进程地址空间只有 4GB理论上不会溢出 但做一次显式判断避免 64 位宿主下误用此路径 */ if (delta INT32_MIN || delta INT32_MAX) { return HOOK_ERR_PROLOGUE; } out_patch[0] 0xE9; /* jmp rel32 */ *(int32_t*)(out_patch 1) (int32_t)delta; *out_len X86_JMP_SIZE; return HOOK_OK; }delta用int64_t算完再收窄成int32_t是为了在调试版本里能一眼看出溢出方向——直接写int32_t delta ...一旦溢出就是未定义行为编译器还会因为你“检查自己刚写的值”而删掉判断。这段代码背后有一个硬约束目标函数前 5 个字节必须能完整覆盖。听着简单实际很麻烦——编译器生成的热点函数常见开头是push ebp; mov ebp, esp; sub esp, 0x20三条指令刚好 5 字节干净利落但如果是优化过的叶子函数开头可能是55 8B EC 83 EC 20之外的东西或者只有C3ret一个字节。所以hook_attach在生成补丁之前必须先跑长度反汇编把入口处的指令一条一条累加直到总长度 ≥ 5 且不切断任何一条指令这个累加长度才是真正的覆盖长度patch_len而不是固定 5。覆盖长度超过 5 时剩下的字节用0x90NOP填充保证跳转指令之后到patch_len之间不会残留半条旧指令被执行到。3.2 64 位FF 25 间接跳转、mov rax 组合与跳板内存布局64 位有两条常见路线。第一条是“寄存器中转”48 B8 imm64 mov rax, 目标地址 FF E0 jmp rax一共 12 字节。问题在于它动了rax。在 x64 调用约定里rax确实是易失寄存器但函数入口处这个寄存器不一定干净——最典型的是可变参数函数调用方会用al传入实际使用的向量寄存器个数你在入口把rax覆盖掉函数内部的va_start就会读到垃圾值表现为printf类的输出错乱但不崩。这种 bug 极难查所以我在能选的情况下不用这条路线。第二条路线用jmp [rip0]加一段紧跟着的 8 字节地址FF 25 00 00 00 00 jmp qword ptr [rip0] 8 字节目标地址 ; 紧跟在指令之后被 rip 直接引用总共 14 字节不动任何寄存器也不依赖地址距离。代价是覆盖长度比 32 位多出 9 个字节对函数头的要求更高——这也是 64 位 Hook 失败率明显高于 32 位的原因HOOK_ERR_PROLOGUE这个错误码在 64 位上出现的概率高一个数量级。/* src/hook_x64.c —— 生成 14 字节绝对间接跳转补丁 */ #define X64_JMP_SIZE 14 void hook_x64_build_patch(void* detour, unsigned char* out_patch) { /* FF 25 disp32jmp qword ptr [rip disp32] */ out_patch[0] 0xFF; out_patch[1] 0x25; *(int32_t*)(out_patch 2) 0; /* disp32 0即紧跟其后的 8 字节 */ /* 紧跟在指令后面的 8 字节绝对地址小端存储 */ *(uint64_t*)(out_patch 6) (uint64_t)detour; }disp32填 0 是这条编码的关键下一条指令的地址就是地址常量本身[rip0]正好指向它。这 14 个字节必须一次性写入目标函数入口中间不能被其他线程看到半成品后面第 4 章会专门讲这个写入窗口怎么处理。跳板trampoline的布局是另一处门槛。跳板的职责是“承接被覆盖的原始指令然后跳回原函数target patch_len继续执行”。它由两段组成前半段是搬迁过来的原指令副本后半段是一条返回跳转。返回跳转优先用E9 rel325 字节前提是跳板内存地址与目标函数相距在 ±2GB 内——所以申请跳板内存时通常会用VirtualAlloc带上一个靠近目标函数地址的提示值让系统尽量就近分配。拿不到就近地址时就退化成 14 字节绝对跳转功能上没区别只是跳板开销大一点。3.3 被覆盖指令的搬迁长度反汇编之后还要做相对位移修正把原指令按字节memcpy到跳板里十有八九会崩。原因很简单x86/x64 里大量指令带相对偏移指令换了位置原来的偏移量就指向了别处。需要修正的一共四类。第一类是E8call rel32和E9jmp rel32指令长 5 字节偏移字段在末尾 4 字节修正公式是“用旧指令算出真实目标地址再用新位置反算偏移”。第二类是0F 80~0F 8Fjcc rel32长 6 字节逻辑与第一类相同。第三类是70~7Fjcc rel8只有 1 字节偏移目标常常落在被覆盖区域之外直接搬过去必错常见做法是把它展开成0F 8x rel32的 6 字节形式并重新计算偏移。第四类是 RIP 相对寻址形如mod00, rm101的 ModRM 后面跟 4 字节disp32真实目标 原指令下一条地址 disp32搬迁后要用新的下一条地址重算。忽略这一类最容易被忽视因为它在 C 代码里看不出来只有编译器把全局变量mov成lea rax, [ripx]的时候才会出现。/* 搬迁过程中的单条修正以 E8 call rel32 为例 */ static void relocate_rel32(unsigned char* dst, const unsigned char* src, size_t written, uintptr_t src_base) { /* 原指令执行时的目标 源地址 指令内偏移处的下一条地址 偏移 */ int32_t old_rel *(const int32_t*)(src 1); uintptr_t old_next src_base written 5; uintptr_t abs_target old_next old_rel; /* 新位置下的下一条指令地址 */ uintptr_t new_next (uintptr_t)(dst written 5); /* 反算新偏移并写回跨出 ±2GB 时说明跳板分配得太远 */ *(int32_t*)(dst written 1) (int32_t)((int64_t)abs_target - (int64_t)new_next); }src_base传的是“被覆盖区域在原始函数里的起始地址”也就是targetwritten是当前已经搬完的字节数两者相加才是这条指令在原始上下文里的位置。少了这个基准算出来的abs_target会整体偏移patch_len字节跳板跳回原函数后执行的第一条指令就是错的现象是“Hook 装上后程序还能跑但结果莫名其妙不对”比直接崩溃更难定位。修完之后跳板尾部补一条跳回原函数的指令把“覆盖长度之后的第一条原始指令地址”作为目标。至此一次完整的 32/64 位 Hook 链路才算闭环入口被改写到代理函数代理函数调用跳板跳板执行原始指令并返回原函数继续。4. 内存页、线程与两套接口把补丁安全地落下去机器码生成对了只是完成了一半。代码段默认是不可写的写之前要改页属性改完要刷指令缓存写入的这十几个字节在别的线程眼里可能是一段半成品指令C 和 C 两套接口还要各自管好生命周期。这一章讲的都是“机器码没问题但程序照样崩”的那部分。4.1 VirtualProtect 与 FlushInstructionCache 的正确顺序代码页的默认属性通常是PAGE_EXECUTE_READ直接memcpy上去立刻触发访问违例。标准流程是先改属性、写字节、刷缓存、再改回来/* src/hook_mem.c —— 把补丁字节安全写入代码段 */ hook_status hook_write_code(void* target, const unsigned char* bytes, size_t len) { DWORD old_protect 0; if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, old_protect)) { return HOOK_ERR_PROTECT; /* 常见原因目标页不在本进程或已被保护 */ } memcpy(target, bytes, len); /* 关键一步告诉 CPU 该区域的指令缓存失效 */ FlushInstructionCache(GetCurrentProcess(), target, len); /* 属性务必恢复回去长期保持 RWX 会降低页保护强度 */ DWORD tmp 0; VirtualProtect(target, len, old_protect, tmp); return HOOK_OK; }VirtualProtect的第二个参数是长度必须按“包含整个补丁”来算而不是固定 5 或 14——补丁跨页时target距页尾不足patch_len朴素的写法会让VirtualProtect返回成功但实际只覆盖了当前页memcpy写后半段时越界崩溃。FlushInstructionCache这一句在 x86/x64 上理论上不是必须的——处理器保证指令修改在同一核上会被观察到跨核也有一致性协议——但这是血泪经验在别的线程上同步安装 Hook 时不刷缓存偶尔会出现“另一个核执行到的是旧指令”的诡异现象概率极低但一旦出现就是随机崩溃。多加这一句的成本几乎为零。属性恢复也有讲究。有些实现图省事直接把目标页永久改成PAGE_EXECUTE_READWRITE就不再管了短期跑起来没问题但页保护实际被削弱了而且一旦有安全检查扫描模块内存属性很容易被标红。正确做法是保存old_protect原样恢复卸载 Hook 时也走同一套流程。4.2 C 接口与 C 类封装谁管生命周期、谁管类型安全两套接口的目标不同C 接口给跨语言调用和嵌入式环境用要求零运行时依赖、显式管理C 类封装给业务代码用要求 RAII、异常安全、少写样板。类封装不应该重新实现一遍底层而应该只做一层薄壳/* include/hook.hpp —— 只做生命周期与类型转换底层仍走 C 接口 */ #include hook.h class InlineHook { public: InlineHook() default; InlineHook(void* target, void* detour) { Attach(target, detour); } ~InlineHook() { Detach(); } /* 析构自动卸载避免忘写 detach */ InlineHook(const InlineHook) delete; /* 句柄不可复制 */ InlineHook operator(const InlineHook) delete; InlineHook(InlineHook other) noexcept : ctx_(other.ctx_) { other.ctx_ nullptr; /* 移交所有权避免二次卸载 */ } bool Attach(void* target, void* detour) { if (ctx_) return false; /* 重复安装直接拒绝 */ return hook_attach(target, detour, ctx_) HOOK_OK; } bool Detach() { if (!ctx_) return false; hook_status st hook_detach(ctx_); ctx_ nullptr; return st HOOK_OK; } /* 模板参数让调用方自己指定签名避免每处都强转 */ template typename Fn Fn Original() const { return reinterpret_castFn(hook_trampoline(ctx_)); } private: hook_ctx* ctx_ nullptr; };三个设计点说明一下。移动构造函数里把源对象的ctx_置空是为了避免两个对象析构时对同一个句柄调用两次卸载——重复卸载在原实现里会触发“恢复的字节已经是补丁字节”的错误轻则返回错误码重则把跳板内存重复释放。析构里调Detach而非hook_detach是为了保证ctx_一定被清空即使底层返回失败对象也不会在之后被再次使用。OriginalFn()用模板而不是返回void*让每个调用点都能拿到正确签名编译器也能对参数类型做检查。成员函数 Hook 是 C 用户的常见诉求这里有个坑不同编译器对Class::method的表示不同某些情况下拿到的是成员函数指针的“内部编码”而不是可执行地址甚至指向一个只做this偏移修正的 thunk。稳妥做法是让示例工程里显式把成员函数转成普通函数指针再取地址或者对目标类加__declspec(novtable)避免虚表 thunk 干扰具体手段在 C 示例工程的注释里写得很清楚。4.3 多线程改写窗口挂起、原子写与恢复顺序补丁写入的这十几到几十纳秒里如果另一个线程正好执行到target的前patch_len个字节就会读到新旧混合的指令。E9变成E9 FF 25 ...这种组合CPU 解码出来可能是完全无关的指令直接跳到随机地址。这是 Hook 在单线程测试下百分百通过、一上多线程就随机崩的根本原因。常见做法是在写入窗口内把其它线程挂起/* src/hook_thread.c —— 挂起除自身外的所有线程写完后统一恢复 */ void hook_install_atomic(void* target, const unsigned char* patch, size_t len) { HANDLE snap CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); THREADENTRY32 te { sizeof(te) }; DWORD self_tid GetCurrentThreadId(); DWORD pid GetCurrentProcessId(); HANDLE suspended[256] {0}; int n 0; if (Thread32First(snap, te)) { do { if (te.th32OwnerProcessID ! pid || te.th32ThreadID self_tid) continue; HANDLE h OpenThread(THREAD_SUSPEND_RESUME, FALSE, te.th32ThreadID); if (h n 256) { SuspendThread(h); /* 该线程停在当前指令不会再取指 */ suspended[n] h; } } while (Thread32Next(snap, te)); } CloseHandle(snap); hook_write_code(target, patch, len); /* 此时没有其它线程在取指 */ /* 必须按打开顺序的逆序恢复减少等待时间 */ while (n-- 0) { ResumeThread(suspended[n]); CloseHandle(suspended[n]); } }SuspendThread的前提是“该线程不在内核态长时间阻塞”否则会等到它返回用户态安装 Hook 的调用方可能卡住几十毫秒。所以这段逻辑只适合在初始化阶段做一次不适合放进高频调用路径。挂起全部线程也是重型操作线程多时开销明显。轻量替代方案是如果patch_len不超过 8 字节且目标地址 8 字节对齐可以用一条对齐的 8 字节原子写把补丁一次性落下去靠硬件保证可见性但 64 位 14 字节的补丁放不进一个 8 字节写入只能拆两次中间依然有窗口。所以工程里两条路都保留由调用方通过编译开关选择默认走挂起方案。5. 排查与避坑五个把进程干崩的真实现场下面这几条都是调试器里一行一行追出来的按“现象 → 原因 → 解决”整理。遇到 Hook 装上就崩或者装上不崩但结果不对先来这里对号入座比从汇编开始重看快得多。5.1 现场一装上就崩在函数第一条指令现象是安装返回成功程序走到被 Hook 的函数时立刻访问违例调试器停的位置看起来像是“跳到了不存在的地址”。原因是覆盖长度算少了或者算多了。算少了跳转指令的后半截盖不住原指令残留在补丁后面继续被执行算多了把下一条指令的开头也覆盖了跳回时落在半条指令中间。两种情况的根因相同长度反汇编拿到的指令边界不对通常是对带前缀的指令处理有遗漏比如漏了0x66操作数前缀把它当成了独立指令长度算成 1。解决办法是在hook_attach里加一条硬校验累加长度必须大于等于补丁长度且连起来的每条指令起点都要落在反汇编器给出的边界上。再加一个调试开关把覆盖区域的原始字节 dump 出来用调试器反汇编看一眼边界对不对一目了然。这个后悔药我在后面每个项目里都留着。5.2 现场二x64 下程序不崩但返回值全错现象是 Hook 装上之后程序正常运行日志也在打但调用方拿到的返回值变成了莫名其妙的数值尤其是带浮点参数的可变参数函数。原因就是前面提到的mov rax, imm64方案吃掉了入口处的rax。x64 调用约定里al承载可变参数函数使用的向量寄存器个数入口处被覆盖后函数内部的参数遍历逻辑读到了错误计数。解决办法是换用FF 25加 8 字节地址的 14 字节方案不动任何通用寄存器如果因为函数头太短必须用 12 字节方案就在mov rax前后加push rax/pop rax但这样补丁又变长了实际上还是绕不开覆盖长度的问题。所以 64 位 Hook 的默认方案应该从一开始就定成绝对间接跳转。5.3 现场三Debug 正常、Release 崩溃或根本不生效现象是同一份代码在 Debug 配置下 Hook 得好好的切到 Release 后要么hook_attach返回成功但代理函数从不被调用要么直接崩。原因是链接器的合并优化。开启函数级链接后内容完全相同的函数会被折叠成同一份代码相同 COMDAT 折叠你 hook 的那个空实现函数可能和另一个函数共享地址更常见的是开启全局优化后小函数被内联target_func取到的地址是一个只有jmp的跳桩你改的是跳桩不是函数体。解决办法有两层。一是对确实要 Hook 的目标函数加__declspec(noinline)并且在工程设置里关掉该编译单元的/GL二是安装前先判断目标地址的头几个字节如果看到E9或FF 25这种明显已经是跳转的形态就要决定是顺着跳转跟到真实函数体还是明确拒绝安装并返回HOOK_ERR_EXISTS——后者更安全前者适合需要链式 Hook 的场景。5.4 现场四Detach 之后跑一会儿随机崩现象是卸载 Hook 的瞬间没报错但过几秒程序崩在原来的目标函数里调用栈看着完全正常。原因通常有两个。一是恢复字节之后没有FlushInstructionCache个别核上仍在执行补丁指令而跳板内存已经被释放跳转目标成了野指针。二是卸载时没有做任何线程同步正好有线程停在跳板的搬迁指令区域中间你恢复原字节的瞬间它还在跳板里跑等它执行到跳板尾部的“跳回原函数”指令时原函数的入口已经被恢复成原样逻辑上没问题——但如果跳板是先释放的就跳飞了。解决办法是把卸载顺序固定成先停用入口恢复原始字节并刷缓存再等一小段可观测的宽限期最后释放跳板内存。宽限期不需要精确给一个循环等待计数器就行目的是让还在跳板里跑的线程自然跑完。跳板内存压根不释放也是一种选择进程退出时统一回收用几 KB 换确定性。5.5 现场五跳板地址总是离目标函数太远现象是 32 位下一切正常64 位下hook_attach频繁返回HOOK_ERR_ALLOC或者跳板能分配但跳回原函数的那条E9偏移溢出。原因是VirtualAlloc不带地址提示时系统会随便找一块空闲的 64KB 对齐区域很可能落在离目标函数几十 GB 之外rel32根本够不着。跳板本身用绝对跳转没问题但跳回原函数那一段如果也用相对跳转就会溢出。解决办法是分配时带地址提示让系统尽量在目标函数附近找位置/* 在目标函数所在地址附近 2GB 内申请跳板保证 rel32 可达 */ void* hook_alloc_near(void* target, size_t size) { uintptr_t base (uintptr_t)target ~(uintptr_t)0xFFFF; const uintptr_t step 0x10000; /* 64KB 分配粒度 */ for (int i 1; i 512; i) { /* 先向低地址找再向高地址找命中率更高 */ void* p VirtualAlloc((void*)(base - i * step), size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (p) return p; p VirtualAlloc((void*)(base i * step), size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (p) return p; } return NULL; /* 实在找不到就退化成绝对跳转方案 */ }MEM_RESERVE | MEM_COMMIT一起传是为了避免先占位再提交的两步操作被别的分配打断。找不到就近地址时不能直接失败而应该退化成全文绝对跳转的跳板功能不受影响。6. 二次开发把这套 Hook 接进自己工程的三处改造核心库能跑通之后真正的工作量在于把它变成自己项目里“用得顺手、出事能查”的一块基础设施。我一般做三处改造改动量都不大但对长期维护影响很明显。第一处是补一张全局 Hook 注册表。库本身按句柄管理业务代码里句柄一多就没人知道谁挂了谁、挂在哪个地址上。加一个固定容量的表安装时登记目标地址、代理地址、跳板地址和安装时间卸载时摘除再配一个dump_all打印当前所有活跃 Hook调试时直接看输出/* hook_registry.c —— 轻量注册表只做记录与查询不干预安装流程 */ typedef struct { void* target; void* detour; void* trampoline; unsigned long tick; } hook_entry; static hook_entry g_table[128]; static size_t g_count 0; void hook_registry_add(void* t, void* d, void* tr) { if (g_count 128) return; /* 满了就丢记录不影响 Hook 本身 */ g_table[g_count] (hook_entry){ t, d, tr, GetTickCount() }; } const hook_entry* hook_registry_find(void* target) { for (size_t i 0; i g_count; i) if (g_table[i].target target) return g_table[i]; return NULL; }表容量固定成 128 是有意的真实的埋点场景很少超过这个量级用固定数组避免在安装路径上引入动态分配也避免分配失败这种额外的错误分支。第二处是把错误码翻译成人话。hook_strerror别写成return error每个状态码对应一句能直接指导排查的描述比如HOOK_ERR_PROLOGUE对应“函数头字节数不足检查是否被内联或函数过短”。新手拿到错误码后最缺的就是这句话。第三处是给测试用例加回归。前面提到的短函数、跨页函数、已被跳转过的函数、可变参数函数每类各写一个最小用例装完跑一遍、卸完再跑一遍比对结果是否与未 Hook 时一致。这套用例的价值要等到某次换了编译器版本、/GL默认打开之后才体现出来——那次我改完编译选项直接上机Debug 全绿 Release 全红最后对着hook_registry的表才发现有三个 Hook 装到了同一个折叠地址上。最后补一句验证方法任何一次要合并进主干的改动都必须在 x86 和 x64 两个配置、Debug 和 Release 两种优化级别、加上至少一个多线程压测用例下各跑一遍。少其中一个维度都可能把只在特定组合下出现的崩溃带到线上。从那以后我每次改这套库装完 Hook 的第一件事就是把覆盖区域和跳板的原始字节 dump 出来对一遍确认边界、确认跳转目标、确认恢复后的字节和安装前的完全一致再去做业务验证。希望帮到你。源码里 x86/x64 两份实现、C 与 C 两套示例和那批回归用例都在拿过去按自己的工程结构改一改比从零啃指令编码省事得多。本文还有配套的精品资源点击获取