ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内存加载DLL全流程:PE映射、重定位与IAT修复

内存加载DLL全流程:PE映射、重定位与IAT修复

简介:围绕内存加载DLL这一Windows开发主题,整理了一套可直接编译调试的完整示例代码,适合具备C/C++与Win32基础、希望研究PE模块加载、免落盘调用DLL及资源嵌入加载流程的开发者参考。压缩包共22个文件,约87KB,以C/C++源码与头文件为核心,附带VC工程配置、预编译DLL、静态库、可执行文件及说明文本,覆盖测试DLL与调用端两个工程。示例中测试DLL提供导出函数,调用端演示从资源直接加载该DLL,并通过MemoryLoadLibrary、MemoryGetProcAddress、MemoryFreeLibrary完成模块载入、导出函数定位与释放,配套的MemoryModule实现便于理解内部机制。已有1511人学习,可作为内存加载DLL的入门实验框架与调试参照,帮助读者梳理资源嵌入、模块映射、函数调用和释放清理的关键环节。

1. 为什么要自己实现一套 DLL 加载流程

LoadLibrary把「打开文件、校验头、映射节区、修重定位、填导入表、调入口点」这一长串动作封进了一次 API 调用,顺手把模块挂进进程的模块链表。而所谓内存加载 DLL,是把同一套动作在用户态手工重做一遍:PE 文件当普通字节数组读进来,自己按节表铺到内存,自己打重定位补丁,自己填 IAT,最后自己调用DllMain。听起来像黑魔法,拆开看不过是三张表加两次循环。

值得搞懂它的原因不在「炫技」:当加载器从黑匣子变成一个你能复现的流程,模块崩溃、全局对象没构造、x64 下异常展开失败这些日常玄学问题,才第一次有了解释。插件热更新、单文件绿色部署、多版本模块并行,这些真需求也是从这条路走出来的。

2. PE 结构:加载器眼里的 DLL 到底是什么形状

PE 文件不是一块可以随便挪位的二进制,它是一份带自描述的清单。文件头声明「镜像我需要多大、入口在哪、有哪些数据目录」,节表声明「哪段字节应该落到内存的哪个偏移」。加载器做的第一件事从来不是拷贝,而是读清单——这正是手动映射和memcpy的根本区别:前者按清单重排,后者只是搬砖。

2.1 DOS 头、NT 头与节表:三张表决定一切

定位路径是三跳。第一跳落在IMAGE_DOS_HEADER的e_magic,必须是0x5A4D;同一个结构体里的e_lfanew是 NT 头的文件偏移,这是唯一可信的入口坐标,硬编码0x3C在绝大多数文件上能碰巧跑通,但那属于玄学而不是规范。第二跳是IMAGE_NT_HEADERS的Signature,必须是0x00004550;它的FileHeader给出节数量和机器类型,OptionalHeader给出镜像大小、首选基址、两套对齐粒度和 16 个数据目录。第三跳是紧跟 OptionalHeader 的节表,共NumberOfSections条IMAGE_SECTION_HEADER,每条给出VirtualAddress、VirtualSize、PointerToRawData、SizeOfRawData和Characteristics。

下面这段解析代码值得先跑通,它不加载任何东西,只把关键字段打印出来,是后面所有步骤的坐标系:

// 只读解析:确认坐标,不做任何映射 bool InspectPe(const std::vector<BYTE>& raw) { if (raw.size() < sizeof(IMAGE_DOS_HEADER)) return false; auto dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(raw.data()); if (dos->e_magic != IMAGE_DOS_SIGNATURE) return false; // "MZ" // e_lfanew 才是真实偏移,别写 0x3C auto nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(raw.data() + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return false; // "PE\0\0" const auto& opt = nt->OptionalHeader; printf("ImageBase = %p\n", (void*)opt.ImageBase); // 首选基址 printf("SizeOfImage = 0x%X\n", opt.SizeOfImage); // 映射所需总长度 printf("SizeOfHeaders = 0x%X\n", opt.SizeOfHeaders); // 头部整块搬运长度 printf("EntryPoint RVA = 0x%X\n", opt.AddressOfEntryPoint); printf("SectionAlign = 0x%X\n", opt.SectionAlignment); printf("FileAlign = 0x%X\n", opt.FileAlignment); auto sec = IMAGE_FIRST_SECTION(nt); for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sec) { // 8 字节名字不一定有 \0,用 %.8s 打印更安全 printf(" %-8.8s VA=0x%-6X VSize=0x%-6X Raw=0x%-6X RawSize=0x%X\n", sec->Name, sec->VirtualAddress, sec->Misc.VirtualSize, sec->PointerToRawData, sec->SizeOfRawData); } return true; }

这段代码的价值在于把「文件视角」和「内存视角」的字段并排摆出来。PointerToRawData是文件里的字节偏移,VirtualAddress是 RVA,两个数字在数值上永远不相等(除了极少数对齐巧合)。后面每一次拷贝、每一次写补丁,都要明确自己用的是哪一套坐标系,混用就是最常见的翻车起点。

几个字段值得单独拎出来记:

字段所属结构手动加载时为什么关键
SizeOfImageOptionalHeaderVirtualAlloc的唯一尺寸依据,分配偏小会把镜像铺到别人的内存上
ImageBaseOptionalHeader与实际分配地址的差值delta,是重定位的唯一输入
SectionAlignmentOptionalHeader节在内存中的对齐粒度,决定每个节允许占用多少空间
SizeOfHeadersOptionalHeader头部整块搬运的字节数,已按文件对齐向上取整
AddressOfEntryPointOptionalHeader是 RVA,加基址后才是DllMain的真实地址
DataDirectory[16]OptionalHeader导入、导出、重定位、TLS、异常表全靠它索引

2.2 导出表与导入表:地址簿的正反面

导出表是一组平行数组:AddressOfFunctions存函数 RVA,AddressOfNames存名字字符串的 RVA,AddressOfNameOrdinals存「名字下标 → 函数下标」的映射,类型是WORD。名字数组按字典序排好,所以系统加载器能二分查找;手动实现线性扫一遍也没问题,一个模块的导出通常也就几十到几百个。

查一个函数要绕一次弯:在名字数组里找到下标i,用NameOrdinals[i]换成函数数组下标,再取functions[下标] + base。少绕这一步,直接拿i去索引函数数组,得到的是一堆张冠李戴的地址,而且往往前几个函数还能「碰巧对」,排查起来格外痛苦。

// 按名字取导出地址:注意 ordinals 中转这一步 FARPROC GetExportByName(BYTE* base, const IMAGE_NT_HEADERS* nt, const char* want) { const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; if (dir.Size == 0) return nullptr; // 没有导出表 auto exp = reinterpret_cast<const IMAGE_EXPORT_DIRECTORY*>(base + dir.VirtualAddress); auto names = reinterpret_cast<const DWORD*>(base + exp->AddressOfNames); auto ords = reinterpret_cast<const WORD*>(base + exp->AddressOfNameOrdinals); auto funcs = reinterpret_cast<const DWORD*>(base + exp->AddressOfFunctions); for (DWORD i = 0; i < exp->NumberOfNames; ++i) { const char* name = reinterpret_cast<const char*>(base + names[i]); if (strcmp(name, want) != 0) continue; const DWORD rva = funcs[ords[i]]; // 先用序号换下标 // 导出转发:RVA 落在导出目录内部,说明值是 "DLL.函数" 字符串 if (rva >= dir.VirtualAddress && rva < dir.VirtualAddress + dir.Size) { return nullptr; // 本例不处理转发 } return reinterpret_cast<FARPROC>(base + rva); } return nullptr; }

导入表是它的反面:IMAGE_IMPORT_DESCRIPTOR数组,以全零项结尾。每个描述符带OriginalFirstThunk(INT)、FirstThunk(IAT)和Name。INT 与 IAT 结构完全一样,都是一串IMAGE_THUNK_DATA:最高位为 1 表示按序号导入,否则该值是IMAGE_IMPORT_BY_NAME的 RVA。

关键理解在这里:磁盘上的 IAT 和 INT 内容相同,都只是「线索」;真实函数地址只在运行时才存在。系统加载器读完 INT,把解析结果写进 IAT,程序里的call [IAT 项]才能跳到正确位置。手动加载必须自己完成这次回填,这也是「导入表修复」这个说法的由来。

2.3 文件对齐与内存对齐:两套粒度为什么必须分开

FileAlignment通常是0x200(磁盘扇区),SectionAlignment通常是0x1000(内存页)。设计成两套粒度的动机很朴素:磁盘按扇区读写,内存按页做保护,强行统一要么浪费磁盘空间(每个节都得凑够 4KB),要么牺牲内存保护的粒度。代价是同一份数据在两处偏移不一致——.text在文件里可能从0x400开始,在内存里却从0x1000开始。

所以映射必须分两步:先把头部SizeOfHeaders字节整块搬过去(头部区域在两种对齐下基本等价,且必须搬),再逐节以VirtualAddress为落点、PointerToRawData为来源搬运。这一步做错的表现非常有辨识度:加载不报错,函数也能调进去,但一访问字符串常量就是乱码或者访问违例。

注意:SizeOfRawData是文件对齐后的尺寸,可能大于VirtualSize,拷贝长度取两者的较小值更稳;否则尾部填充字节会压到下一个节的起始位置。

3. 手动映射:从文件字节到可执行镜像的搬运过程

映射阶段可以概括成一句话:先按SizeOfImage拿一块可执行内存,再让每个节落在它自己声明的 RVA 上。听起来简单,但这里每一步都有隐含契约,缺一条就是「加载成功、调用崩溃」。

3.1 分配镜像空间与按节拷贝

分配用VirtualAlloc一次MEM_COMMIT | MEM_RESERVE到位,长度取SizeOfImage而不是文件大小——前者已经包含所有节按SectionAlignment对齐后的总长,后者只是磁盘占用。

// 把文件字节铺成内存镜像,返回基址 BYTE* MapImage(const std::vector<BYTE>& raw, size_t* outSize) { auto dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(raw.data()); if (dos->e_magic != IMAGE_DOS_SIGNATURE) return nullptr; auto nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(raw.data() + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return nullptr; const DWORD imageSize = nt->OptionalHeader.SizeOfImage; // 先给读写执行,拷贝和打补丁都要写;严谨做法是事后按节收权 BYTE* base = static_cast<BYTE*>(VirtualAlloc( nullptr, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)); if (!base) return nullptr; // 第一步:头部整块搬(按文件对齐计算的尺寸) memcpy(base, raw.data(), nt->OptionalHeader.SizeOfHeaders); // 第二步:逐节搬,内存落点用 VirtualAddress,文件来源用 PointerToRawData auto sec = IMAGE_FIRST_SECTION(nt); for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sec) { if (sec->SizeOfRawData == 0) continue; // .bss 之类无文件数据 const DWORD copyLen = min(sec->Misc.VirtualSize, sec->SizeOfRawData); memcpy(base + sec->VirtualAddress, raw.data() + sec->PointerToRawData, copyLen); } *outSize = imageSize; return base; }

参数上只有一个容易写错的地方:copyLen取min(VirtualSize, SizeOfRawData)。前者是节在内存里需要的长度,后者是文件里实际有的长度,两者在大多数情况下相等,但遇到压缩过或者尾部补齐的节就不等。取大值会越界踩下一节,取小值最多丢掉无意义的填充字节。

权限处理上,更讲究的写法是先给PAGE_READWRITE,等重定位和导入全部改完,再遍历节表,对Characteristics里带IMAGE_SCN_MEM_EXECUTE的节单独VirtualProtect成PAGE_EXECUTE_READ。这一步不做也能跑,但等于自愿放弃 DEP 提供的最后一道防线。

3.2 基址重定位:为什么要给每个绝对地址打补丁

链接器生成 DLL 时假设模块会落在ImageBase,凡是「某个全局变量的绝对地址」、switch跳转表里的绝对项、字符串常量指针,都按这个基址写死。一旦真实落点不同,这些位置全部作废。重定位表就是这些位置的清单,它是唯一能告诉你「哪里写死了基址」的元数据。

结构上它是一串IMAGE_BASE_RELOCATION块:VirtualAddress是这一批补丁所在的页 RVA,SizeOfBlock是整块长度,后面跟一串 16 位项,高 4 位是类型,低 12 位是页内偏移。低 12 位不是随意定的——一页 4KB,页内偏移恰好 12 位,块的粒度天然等于内存页,这是 PE 格式里少见的优雅设计。

// 按 delta 修正所有写死的绝对地址 bool ApplyRelocations(BYTE* base, const IMAGE_NT_HEADERS* nt, ULONG_PTR delta) { if (delta == 0) return true; // 撞上首选基址,白干一场 const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (dir.Size == 0) return false; // 没有重定位表却挪了位置,只能失败 auto reloc = reinterpret_cast<const IMAGE_BASE_RELOCATION*>(base + dir.VirtualAddress); BYTE* end = base + dir.VirtualAddress + dir.Size; while (reinterpret_cast<BYTE*>(reloc) < end && reloc->SizeOfBlock) { const int count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); auto item = reinterpret_cast<const WORD*>(reloc + 1); for (int i = 0; i < count; ++i, ++item) { const WORD type = *item >> 12; // 高 4 位类型 const WORD offset = *item & 0x0FFF; // 低 12 位页内偏移 BYTE* patch = base + reloc->VirtualAddress + offset; switch (type) { case IMAGE_REL_BASED_HIGHLOW: // 32 位:改 4 字节 *reinterpret_cast<DWORD*>(patch) += static_cast<DWORD>(delta); break; case IMAGE_REL_BASED_DIR64: // 64 位:改 8 字节 *reinterpret_cast<ULONG_PTR*>(patch) += delta; break; case IMAGE_REL_BASED_ABSOLUTE: // 填充项,用于块对齐,跳过 break; default: return false; // 不认识的类型,宁可失败也别乱改 } } reloc = reinterpret_cast<const IMAGE_BASE_RELOCATION*>( reinterpret_cast<const BYTE*>(reloc) + reloc->SizeOfBlock); } return true; }

delta的计算方式是实际基址 - nt->OptionalHeader.ImageBase。注意这里是减法,方向搞反的后果是符号错乱:本来该加正的偏移变成负偏移,指针会指向镜像外的内存,而且只在访问时才炸。

遇到未知类型直接返回失败,这条经验是踩坑换来的。某些工具处理过的文件会带非标准重定位类型,硬着头皮往下改,等于把镜像改成一堆随机数,之后所有崩溃都溯源不到重定位这一步。失败得早,比崩溃得晚好得多。

3.3 导入表修复:IAT 是怎么被一条条填上的

重定位解决「自己内部的地址」,导入表解决「外部函数的地址」。做法是遍历描述符,对每个Name调LoadLibraryA拿到模块句柄,再遍历 thunk 数组调GetProcAddress,把结果写进对应的 IAT 槽。

// 遍历导入描述符,把每个 thunk 解析成真实地址写入 IAT bool FixImports(BYTE* base, const IMAGE_NT_HEADERS* nt) { const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (dir.Size == 0) return true; // 无导入,正常 auto desc = reinterpret_cast<const IMAGE_IMPORT_DESCRIPTOR*>(base + dir.VirtualAddress); for (; desc->Name; ++desc) { // 全零项结尾 HMODULE hmod = LoadLibraryA(reinterpret_cast<const char*>(base + desc->Name)); if (!hmod) return false; // 优先读 INT;为 0 时退回 IAT(某些链接器不产出 INT) auto thunk = reinterpret_cast<const IMAGE_THUNK_DATA*>( base + (desc->OriginalFirstThunk ? desc->OriginalFirstThunk : desc->FirstThunk)); auto iat = reinterpret_cast<IMAGE_THUNK_DATA*>(base + desc->FirstThunk); for (; thunk->u1.AddressOfData; ++thunk, ++iat) { FARPROC fn = nullptr; if (IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal)) { // 按序号导入:取低 16 位,用 MAKEINTRESOURCEA 包装 fn = GetProcAddress(hmod, MAKEINTRESOURCEA(IMAGE_ORDINAL(thunk->u1.Ordinal))); } else { auto ibn = reinterpret_cast<const IMAGE_IMPORT_BY_NAME*>( base + thunk->u1.AddressOfData); fn = GetProcAddress(hmod, reinterpret_cast<const char*>(ibn->Name)); } if (!fn) return false; iat->u1.Function = reinterpret_cast<ULONG_PTR>(fn); } } return true; }

这里有个容易被忽略的细节:LoadLibraryA加载的是系统模块(kernel32.dll、user32.dll之类)。这不是「作弊」,系统 DLL 本来就在已知搜索路径里,为它们再实现一遍 PE 解析毫无收益,而且GetProcAddress拿到的地址需要和系统加载器保持一致的语义。手动映射通常只针对自己的模块。

OriginalFirstThunk优先这条规则必须坚持。磁盘上 INT 和 IAT 内容一致,但只要你(或别的代码)往 IAT 里写过一次,两者就分家了;再拿 IAT 当线索源读,读到的是绝对地址,被当成 RVA 解释后必然得到垃圾结果。

4. DllMain、TLS 与异常表:加载器做的那些隐形工作

映射和导入做完,镜像在内存里「长得像」一个模块了,但还不能用。系统加载器在这里还有三件隐形工作:调用入口点、执行 TLS 回调、注册异常表。漏掉任何一件,症状都是「加载成功、运行崩溃」,而且崩溃点往往和真正的原因隔了好几层调用。

4.1 DllMain 的调用契约:PROCESS_ATTACH 不只是初始化

入口点原型是BOOL WINAPI DllMain(HINSTANCE, DWORD, LPVOID)。手动加载要自己调,fdwReason传DLL_PROCESS_ATTACH,lpvReserved传nullptr(表示非静态加载,而不是通过LoadLibrary加载)。

返回FALSE意味着初始化失败,此时必须回滚:释放整个镜像、清掉已注册的异常表。因为 IAT 和重定位都改在镜像自身的内存里,直接释放整块内存反而是最干净的还原方式——这也是手动映射比系统加载器更容易做「原子回滚」的地方。

顺序不能乱。正确的链条是:映射 → 重定位 → 修复导入 → 执行 TLS 回调 → 注册异常表 → 调用DllMain。系统加载器同样是先完成导入和 TLS,再调DllMain;把顺序倒过来,DllMain里一访问导入函数就是空指针,一抛异常就是进程终止。

4.2 TLS 回调:全局对象构造函数藏在哪

C++ 的全局对象、静态成员、std::string类型的全局配置,构造时机并不在DllMain里。编译器为这些内容生成一个 TLS 回调,由加载器在调用DllMain之前执行。手动加载如果跳过这一步,症状极其典型:模块加载成功,导出函数也能取到,但一调用就访问空指针——因为那个全局单例压根没构造。

// 在 DllMain 之前模拟加载器的 TLS 回调调用 void RunTlsCallbacks(BYTE* base, const IMAGE_NT_HEADERS* nt, DWORD reason) { const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (dir.Size == 0) return; // 没有 TLS 目录,直接返回 auto tls = reinterpret_cast<const IMAGE_TLS_DIRECTORY*>(base + dir.VirtualAddress); // 注意:AddressOfCallBacks 是 VA 而不是 RVA,重定位之后才是有效地址 auto cb = reinterpret_cast<PIMAGE_TLS_CALLBACK*>(tls->AddressOfCallBacks); for (; cb && *cb; ++cb) { (*cb)(base, reason, nullptr); // ATTACH 正序,DETACH 应逆序 } }

AddressOfCallBacks是绝对地址(VA),不是 RVA,这是 TLS 目录里唯一按 VA 存的字段。磁盘上它按ImageBase写死,通常被重定位表覆盖,所以「必须先做重定位、再读 TLS 目录」是硬约束。顺着这个约束还能推出一条:卸载时回调要逆序调用,构造顺序的反面是析构顺序,正序析构会拿到已被销毁的依赖。

4.3 异常表注册:x64 下不注册 .pdata 会怎样

x64 没有基于栈帧链的 SEH,异常分发完全依赖.pdata里的RUNTIME_FUNCTION表(数据目录索引 3)。系统加载器加载模块时自动调用RtlAddFunctionTable完成注册,异常分发器才能顺着表找到每个函数的展开信息。

手动映射的镜像不在模块链表里,没人替你注册。后果是:模块内部抛出的 C++ 异常无法展开栈,进程要么直接终止,要么走进错误的catch;在调试器里看到的现象是「异常出现在一个没有 unwind 信息的地址」,非常像内存被踩了,但其实是元数据缺失。

// x64 必须注册 .pdata,否则模块内异常无法展开 bool RegisterUnwind(BYTE* base, const IMAGE_NT_HEADERS* nt) { #ifdef _M_X64 const auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION]; if (dir.Size == 0) return true; auto fn = reinterpret_cast<PRUNTIME_FUNCTION>(base + dir.VirtualAddress); // 表项内部是 RVA,注册时必须给出重定位后的实际基址 return RtlAddFunctionTable(fn, dir.Size / sizeof(RUNTIME_FUNCTION), reinterpret_cast<DWORD_PTR>(base)) != FALSE; #else return true; // x86 走栈帧链,不需要 #endif }

RtlAddFunctionTable的第三个参数是模块基址,表项里的 RVA 会加上它换算成绝对地址,所以必须在重定位完成之后调用,且地址要与实际分配到的基址一致。卸载时对称调用RtlDeleteFunctionTable,忘了这一步,后续在该地址范围内分配的模块可能继承一张无效的展开表,崩溃现场会变得完全不可解释。

5. 避坑与排查:内存加载最容易翻车的五类现场

上面每一步单独看都成立,串起来跑就未必了。下面五条是从实际调试记录里整理出来的,每条按「现象 → 原因 → 解决」写,遇到问题先对着症状定位,比逐行读代码快得多。

5.1 内存与对齐类

第一条:调用导出函数,第一次访问字符串常量就访问违例。

现象很固定:函数能进去,执行几条指令后崩,崩溃地址指向一段看起来像乱码的内存。原因是整块memcpy了整个文件,或者按文件偏移而不是按 RVA 拷贝节区,导致.rdata落在了错误位置。解决办法就是 3.1 里的两步搬运:头部按SizeOfHeaders整块搬,节按VirtualAddress落点搬。

第二条:分配到的基址和ImageBase差很远,某些全局变量的值匪夷所思。

现象是程序不崩,但读出来的配置项、函数指针全是垃圾。原因多半是重定位没做,或者delta算错——比如VirtualAlloc首选地址失败后重试,却把第一次的返回值当成基址参与计算。解决办法是把delta打印出来,非零就必须走重定位流程;若镜像本身没有.reloc目录(链接时固定了基址),只能接受命中首选基址,否则老老实实失败返回。

5.2 导入与重定位类

第三条:IAT 填完了,调用某个 API 却跳到垃圾地址。

现象是call指令跳向一个不属于任何模块的地址。原因通常是把FirstThunk(IAT)当成线索源来读。刚映射完时两者内容确实一样,但只要有人往 IAT 写过一次,IAT 里就是绝对地址,再被当作 RVA 解释就会得到随机结果。解决办法是永远优先读OriginalFirstThunk,为 0 时才退回FirstThunk。

第四条:按序号导入的 API 全部失效,按名字导入的却正常。

这种「一半好一半坏」的现象很好定位。原因是没有判断IMAGE_SNAP_BY_ORDINAL标志位,把序号当成 RVA 去解引用;或者判断了却把整数直接传给GetProcAddress。正确做法是用IMAGE_SNAP_BY_ORDINAL(thunk->u1.Ordinal)判断,取低 16 位后用MAKEINTRESOURCEA包装。

第五条:重定位循环跑完没报错,但镜像行为异常。

原因是遇到未知重定位类型时用了default: break;悄悄跳过。看似「容错」,实际留下了一堆没打补丁的绝对地址。解决办法是default分支直接返回失败,让上层放弃加载——早失败远好过晚崩溃,这条算是血泪经验。

5.3 生命周期与清理类

第六条:卸载后再加载同一个模块,行为异常甚至直接崩。

原因是卸载时只调了VirtualFree,没有走DllMain(DLL_PROCESS_DETACH),TLS 回调没逆序执行,RtlDeleteFunctionTable也没调。残留的注册表项、全局资源句柄、异常表都会污染下一次加载。正确的拆除顺序是前面加载链条的镜像:DllMain(DETACH)→ TLS 回调(逆序) →RtlDeleteFunctionTable→VirtualFree。

提示:拆除前必须确认没有线程还在镜像地址范围内执行。用一个计数器在入口加、出口减,比事后猜要可靠得多。

6. 设计取舍:什么场景值得自己搬这套镜像

把两套方案摊在表格里,差距一眼可见:

维度LoadLibrary手动映射
模块可见性进入进程模块链表,枚举、调试器都可见不进入链表,需要自己维护句柄表
依赖解析自动递归加载依赖 DLL只解析导入表,间接依赖要自己补齐
全局对象构造加载器执行 TLS 回调必须自己按顺序调
异常展开(x64)自动注册.pdata自己调RtlAddFunctionTable
卸载FreeLibrary走引用计数自己逆序清理,无引用计数保护
调试符号调试器识别模块与 PDB无模块信息,断点只能按地址下
文件占用文件被映射,无法直接覆盖不占用文件,可随时替换
多版本并行同名模块只能存在一份可同时加载多个版本

收益集中在后半张表:文件不被占用,可以做原子热更新;同名模块可以多实例并存,隔离不同版本的插件;镜像可以来自压缩包、加密资源甚至网络字节流,不必落盘。代价集中在前半张表:调试成本明显上升,依赖链、TLS、异常表这些隐形契约都得自己复刻,编译器版本变化还可能带来新的节类型或新的数据目录——这条路没有「一次写对、永远可用」的后悔药。

我一般的判断顺序是:能用LoadLibraryEx加独立目录加版本号命名解决热更新,就别上手动映射;只有当需求明确落在「文件不能落地」或「同名多实例隔离」上时,这套代码才值得付出维护成本。选定之后,务必要做的不是把加载流程写完,而是把这套流程的错误分支写全——回滚路径、清理顺序、失败日志,比重定位算法本身更容易出问题。

从那以后我每写一个内存加载流程,都强制在DllMain入口、每个导出函数首行、每次卸载前后各打一条带基址的日志,跑通一次再删。这个习惯救过我很多次,也希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进