简介:针对从内存加载DLL并调用导出函数的需求,一套完整可运行的示例代码包含两个关联工程:xDll负责编译生成测试用DLL,testLoadDll演示将DLL作为资源读入内存,再通过MemoryLoadLibrary加载、MemoryGetProcAddress查找函数、MemoryFreeLibrary释放。适合需要规避磁盘释放、实现动态插件注入或加固分发的Windows开发者参考,也适合学习PE加载原理的进阶者。
压缩包共22个文件,以h/cpp源码、dsp/dsw工程配置为主,同时包含编译好的DLL、lib、exe及说明文档,整体仅87KB,结构紧凑;源码与工程分离,Release产物可直接运行验证。核心MemoryModule.c/h实现封装清晰,可在自己的项目中直接复用,或按需修改加载逻辑。
目前已有1506人学习浏览。读者参照代码即可理解内存模块的加载原理、导出表解析流程与释放机制,改造为资源型插件或隐蔽加载方案时能少走弯路,还可进一步研究PE镜像映射、重定位与导入表修复等底层细节。
1. 从内存加载 DLL:为什么必须自己写一个装载器
“从内存加载DLL”这件事,很多人的第一反应是:把DLL读成BYTE数组,然后调用LoadLibrary就行。实际上LoadLibrary只接收文件路径,底层会走 CreateFile → 创建 Section → NtMapViewOfSection → LdrpLoadDll 的链路,根本没有“从字节流加载”这个选项。所以只要你手里只有一段DLL数据(网络接收、解密得到、资源释放前),就必须自己写一个 PE Loader,手动完成系统装载器做的事:分配镜像内存、复制节区、修复重定位、解析导入表、调用入口点。这个方案在插件热升级、DLL保护与运行时模块分发场景里非常实用,也是很多内存模块机制的底层。下面的章节给出一套可复现的完整C++实现,并把踩过的坑按现象、原因、解决的方式写清楚,新手可以照着抄,熟手可以拿来做边界校验。
2. 加载前的功课:PE 数据目录决定内存加载的成败
2.1 为什么不能直接把字节流丢给 LoadLibrary
系统加载DLL的标准链是:调用 LoadLibrary → 内部调用 LdrLoadDll → 校验文件路径 → 创建 Section 对象 → NtMapViewOfSection 到进程地址空间 → 链接导入表 → 执行初始化。整个链路最初的输入是一个文件路径或一个已映射的文件句柄,手里只有一块裸内存字节流时,系统根本没有对象可以映射,所以必然要自己重造中间那一层。
有人会想:先把字节 WriteFile 写进临时目录再 LoadLibrary,这不就“加载”了吗?那仍然不是内存加载,而且会留下磁盘痕迹,临时文件还要考虑清理,防病毒实时扫描经常拦截新释放的DLL。做插件热升级时也希望新版本直接进内存替换旧版本,避免文件被占用、避免闪断磁盘IO。这才是“从内存加载DLL”真正的价值所在。
手工装载器的本质,是把磁盘上的 PE 镜像(File 布局)转换成内存里的 PE 镜像(Memory 布局)。两者最大的区别在于三个地方:节区对齐由 FileAlignment 变成 SectionAlignment;所有 RVA 字段必须被解释为“基址 + RVA”;可写的导入表、重定位地址需要被真正写入内存。
2.2 需要处理的数据目录清单
PE 的 OptionalHeader 里有 16 个数据目录(DataDirectory),但并不是每个都能在内存加载时忽略。我按“内存加载器处理优先级”排了一张表,照着这张表检查自己的装载器,基本不会漏。
| 目录索引 | 名称 | 内存加载是否必须处理 | 不处理的后果 |
|---|---|---|---|
| 0 | ExportTable | 不需要主动处理 | GetProcAddress 直接解析该目录,无需特殊处理 |
| 1 | ImportTable | 必须 | 所有导入函数地址无效,调用即崩溃 |
| 2 | ResourceTable | 建议处理 | FindResource / 图标 / 对话框模板加载异常 |
| 5 | RelocationTable | 必须 | 加载基址与 ImageBase 不一致时整体不可用 |
| 9 | TLS | 建议处理 | __declspec(thread) 变量行为不可预知 |
| 13 | DelayImportTable | 可选处理 | 延迟加载函数首次调用时崩溃 |
| 其他 | 调试 / 安全目录 | 可忽略 | 通常不影响运行 |
为什么重定位不能省:每种PE都有一个默认基址 ImageBase,64位下一般是 0x0000000140000000,32位一般是 0x00400000。当用 VirtualAlloc 分配镜像时,系统几乎不可能恰好给你这个地址。如果没有把代码里的绝对地址按“实际基址 - ImageBase”修正,所有全局变量与函数指针都指向旧地址区,跑起来第一句就可能段错误。
2.3 32 位与 64 位装载的差异
写装载器最容易翻车的地方就是位数。建议先确认“编译平台位数”和“目标 DLL 位数”一致,再读结构体:32位用 IMAGE_NT_HEADERS32,64位用 IMAGE_NT_HEADERS64;OptionalHeader.Magic 是 0x10b 还是 0x20b。
差异点集中在三处:
- 重定位类型:32位是 IMAGE_REL_BASED_HIGHLOW(值3),64位是 IMAGE_REL_BASED_DIR64(值10),代码里两种都要判断;
- IAT Thunk 宽度:32位是4字节,64位是8字节,遍历导入表时按位数决定步长;
- 地址计算:DWORD 只能存32位地址,64位下所有指针变量都用 ULONGLONG,否则重定位增量写一半截断。
如果你希望一个执行文件同时兼容32/64位的DLL(也就是内部解析两套结构),结构体要用联合体加位宽判断,代码复杂度会明显上升。我的建议是:第一版先做“编译平台与目标一致”,跑通之后再抽壳成通用解析器,否则很容易在结构体 offset 上翻车。
3. 手工映射 DLL 的完整代码:从字节流到可调用的 HMODULE
这一章是主干。以“64位进程加载64位DLL”为最小示例,代码可直接保存为 MemoryModule.cpp,编译时不需要额外第三方库,只要 Windows.h。
3.1 函数签名与基础结构
先定义入口函数:
// MemoryModule.h #pragma once #include <windows.h> HMODULE LoadLibraryFromMemory(const unsigned char* dllData, size_t dataSize); BOOL FreeLibraryFromMemory(HMODULE module);这段只是接口声明。返回值用 HMODULE 是因为调用方后续要用 GetProcAddress、GetModuleHandle 等习惯 API;本质上 HMODULE 就是装载器返回的镜像基址。LoadLibraryFromMemory 负责完成完整装载,FreeLibraryFromMemory 负责调用 DllMain(DLL_PROCESS_DETACH) 并释放镜像。
我一般会额外要求 dllData 在整个装载期间保持有效,至少在复制完所有节区数据之前不能释放源缓冲区,因为导入表、重定位表都需要按源镜像偏移读取。复制完成后源缓冲区就可以抹掉或释放。
3.2 申请镜像内存与复制节区
这是装载器的第一步,也是最容易写错的一步。磁盘镜像里各节是紧挨着按 FileAlignment 对齐存放的,而内存镜像要求每个节按 SectionAlignment 对齐到自身 VirtualAddress 位置,所以不能整块整体复制,必须逐节操作。
// MemoryModule.cpp #include "MemoryModule.h" #include <winnt.h> static IMAGE_NT_HEADERS* GetNtHeaders(const unsigned char* data) { const IMAGE_DOS_HEADER* dos = (const IMAGE_DOS_HEADER*)data; if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL; IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(data + dos->e_lfanew); if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL; return nt; } HMODULE LoadLibraryFromMemory(const unsigned char* dllData, size_t dataSize) { IMAGE_NT_HEADERS* nt = GetNtHeaders(dllData); if (!nt) return NULL; SIZE_T imageSize = nt->OptionalHeader.SizeOfImage; BYTE* imageBase = (BYTE*)VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!imageBase) return NULL; // 先复制 PE 头到内存镜像 memcpy(imageBase, dllData, nt->OptionalHeader.SizeOfHeaders); // 逐个节区复制,按磁盘布局搬到内存布局 IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt); for (DWORD i = 0; i < nt->FileHeader.NumberOfSections; ++i) { if (sec[i].SizeOfRawData > 0 && sec[i].PointerToRawData > 0) { memcpy(imageBase + sec[i].VirtualAddress, dllData + sec[i].PointerToRawData, sec[i].SizeOfRawData); } } // ... 后续修复 }逻辑说明:GetNtHeaders 先验证 MZ 和 PE 两个签名,避免把任意字节当成PE解析。SizeOfImage 是装载器最终占用的虚拟内存大小,必须由 VirtualAlloc 提供整块连续的提交区域。我用 PAGE_EXECUTE_READWRITE 是为了避免段属性切分太细,开发阶段最省事,生产环境建议按节区的 Characteristics 分配,将可读、可写、可执行分别设保护属性。
参数说明:dllData 是指向原始PE字节的指针,dataSize 目前仅在调试时用,真正对齐复制时依赖的是节区里的 PointerToRawData 与 SizeOfRawData,不依赖 dataSize。因此如果调用方给的 dataSize 小于 SizeOfRawData 的目标区域,越界读会发生在你头上——这也是为什么我建议调用方保证缓冲区完整。
3.3 重定位与导入表修复
镜像搬完,接下来是最核心的两步:重定位与导入表修复。这两步做完,DLL 才具备可执行条件。
// 在 LoadLibraryFromMemory 内继续: // === 1) 重定位修复 === IMAGE_DATA_DIRECTORY* relocDir = &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC]; if (relocDir->Size > 0) { ULONGLONG delta = (ULONGLONG)imageBase - nt->OptionalHeader.ImageBase; if (delta != 0) { BYTE* p = imageBase + relocDir->VirtualAddress; DWORD remaining = relocDir->Size; while (remaining >= sizeof(IMAGE_BASE_RELOCATION)) { IMAGE_BASE_RELOCATION* block = (IMAGE_BASE_RELOCATION*)p; if (block->VirtualAddress == 0) break; DWORD count = (block->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries = (WORD*)(p + sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i = 0; i < count; ++i) { WORD type = entries[i] >> 12; WORD offset = entries[i] & 0xFFF; BYTE* patchAddr = imageBase + block->VirtualAddress + offset; if (type == IMAGE_REL_BASED_DIR64) { // 10 *(ULONGLONG*)patchAddr += delta; } else if (type == IMAGE_REL_BASED_HIGHLOW) { // 3 *(DWORD*)patchAddr = (DWORD)(*(DWORD*)patchAddr + (DWORD)delta); } // IMAGE_REL_BASED_ABSOLUTE(0) 不需要处理 } p += block->SizeOfBlock; remaining -= block->SizeOfBlock; } } } // === 2) 导入表修复 === IMAGE_DATA_DIRECTORY* importDir = &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (importDir->Size > 0) { IMAGE_IMPORT_DESCRIPTOR* desc = (IMAGE_IMPORT_DESCRIPTOR*)(imageBase + importDir->VirtualAddress); for (; desc->Name != 0; ++desc) { const char* dllName = (const char*)(imageBase + desc->Name); HMODULE dep = LoadLibraryA(dllName); // 依赖库可以正常落盘加载 if (!dep) return NULL; BYTE* iat = imageBase + desc->FirstThunk; BYTE* oft = desc->OriginalFirstThunk ? imageBase + desc->OriginalFirstThunk : iat; for (ULONGLONG* t = (ULONGLONG*)oft; *t != 0; ++t, iat += sizeof(ULONGLONG)) { FARPROC proc = NULL; if (*t & IMAGE_ORDINAL_FLAG64) { WORD ordinal = (WORD)(*t & 0xFFFF); proc = GetProcAddress(dep, (LPCSTR)(ULONG_PTR)ordinal); } else { IMAGE_IMPORT_BY_NAME* ibn = (IMAGE_IMPORT_BY_NAME*)(imageBase + (DWORD)*t); proc = GetProcAddress(dep, (LPCSTR)ibn->Name); } *(ULONGLONG*)iat = (ULONGLONG)proc; } } }逻辑说明:重定位表是一组连续的重定位块,每个块包含一个 VirtualAddress(页基址)和一组16位项。每项高4位是类型、低12位是页内偏移。遍历时把每个 DIR64/HIGHLOW 类型的地址按 delta 做一次原地修正。导入表部分,每个导入描述符对应一个依赖DLL,名字在 desc->Name;OriginalFirstThunk 是导入名称表(INT),FirstThunk 是导入地址表(IAT),加载器拿到依赖库句柄后,将 IAT 的每一项从“符号描述”改写为“函数真实地址”。
参数说明:如果 DLL 明确标记为可被重定位但重定位目录是空的,装载器需要在 VirtualAlloc 时尽量靠近原 ImageBase,否则只能失败退出,不能静默继续。依赖库这里直接用了 LoadLibraryA,会让依赖DLL正常落盘;如果希望整套依赖都不落盘,需要递归地对依赖库也走内存加载,复杂度会成倍上升,一般场景没必要。
3.4 TLS 回调与 DllMain 的调用顺序
导入表完成后,DLL 还不能直接开始用,因为初始化顺序是固定的:先 TLS 回调,再 DllMain(DLL_PROCESS_ATTACH)。这里有个细节容易忽略——TLS 目录里的 AddressOfCallBacks 在磁盘上是“按默认基址计算的绝对地址”,实际装载时必须重定位。
// === 3) TLS 回调 === IMAGE_DATA_DIRECTORY* tlsDir = &nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]; if (tlsDir->Size > 0) { IMAGE_TLS_DIRECTORY* tls = (IMAGE_TLS_DIRECTORY*)(imageBase + tlsDir->VirtualAddress); // 修正 TLS 目录中保存的绝对 VA ULONGLONG delta = (ULONGLONG)imageBase - nt->OptionalHeader.ImageBase; tls->AddressOfIndex += delta; tls->AddressOfCallBacks += delta; PIMAGE_TLS_CALLBACK* cb = (PIMAGE_TLS_CALLBACK*)tls->AddressOfCallBacks; if (cb) { while (*cb) { (*cb)(imageBase, DLL_PROCESS_ATTACH, NULL); ++cb; } } } // === 4) 调用 DllMain === typedef BOOL(WINAPI *fnDllMain)(HINSTANCE, DWORD, LPVOID); fnDllMain entry = (fnDllMain)GetProcAddress((HMODULE)imageBase, "DllMain"); if (entry) { entry((HINSTANCE)imageBase, DLL_PROCESS_ATTACH, NULL); } return (HMODULE)imageBase;逻辑说明:TLS 目录里的 AddressOfIndex、AddressOfCallBacks 在链接器生成时是“默认基址下的绝对地址”,实际装载到别的基址后必须用 delta 修正,否则回调地址全部无效。很多简单实现会漏掉这一步,症状是 DLL 里的 __declspec(thread) 变量错乱或线程退出时崩掉。DllMain 第一参数是模块基址,第二个是 DLL_PROCESS_ATTACH,第三个保留参数必须为 NULL。如果 DLL 没有导出 DllMain,说明是纯资源DLL,直接跳过入口点调用即可。
把 3.2 到 3.4 的三段代码拼在一起,就是完整的 LoadLibraryFromMemory。调用方只要拿到字节流,就能得到 HMODULE,然后用 GetProcAddress 找导出函数,用法和 LoadLibrary 几乎一致。
4. 让内存模块更像“正常模块”:资源、句柄与模块链表
4.1 GetProcAddress 对内存模块的有效性边界
上一章已经用 GetProcAddress 找了 DllMain,这一步有效。但熟手会问:GetProcAddress 内部不校验句柄是不是登记的模块吗?严格说,系统 API 实现上没有强制校验,它直接按 HMODULE 指向的 PE 头去走导出目录。所以在纯净环境里对内存模块调用 GetProcAddress 是完全可行的。
不过我在某些带安全软件的机器上遇到过返回 NULL 的情况,原因通常是安全软件在应用层钩挂了 GetProcAddress,会先做一次模块链表查找,发现句柄不在链表里就直接返回失败。这个现象不是必现,但在做端侧方案时要有心理准备。对应手段是本章 4.3 讲的“登记模块链表”,把这个句柄放进系统模块链表中,才能让这一层校验也放行。
4.2 DLL 内部加载资源的根因
如果内存DLL里用到了自己的资源,比如图标、字符串表、对话框模板,最直接的问题不是 GetProcAddress,而是 FindResource / LoadResource 这一族 API。它们在解析时通常会走加载器数据区检查模块合法性,一部分实现还会根据 hModule 查模块链,查不到就返回 ERROR_RESOURCE_DATA_NOT_FOUND。“资源明明在DLL里,装载器返回的HMODULE也没问题,但加载资源就是失败”——这是内存加载方案里出镜率最高的玄学问题。
一般情况下,我会把资源加载坑放到开发中期再处理:先确认业务里有没有用到 LoadString、LoadIcon、DialogBox 这类函数。如果只是纯逻辑插件,不读资源,这一章可以跳过;一旦要读,就得在装载器里补一个“资源重定向层”或按 4.3 登记模块链表。
4.3 手动登记 LDR_DATA_TABLE_ENTRY:让模块进入系统模块链表
最常见的成熟做法是把这块内存模块插到进程的模块链表中,让它拥有一个“合法身份”。系统模块链表的入口在 PEB->Ldr->InLoadOrderModuleList,里面每个节点是 LDR_DATA_TABLE_ENTRY,DllBase 指向模块基址。构造新节点,填好 DllBase、EntryPoint、SizeOfImage、FullDllName,然后挂到链表尾部即可。
这里只给核心思路代码,具体字段兼容建议以目标系统的 ntdll 头文件为准:
// 简化结构,实际按目标系统的 ntdll 展开 typedef struct _LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; ULONG Flags; USHORT LoadCount; // ... 不同版本后面字段有差异 } LDR_DATA_TABLE_ENTRY; // 挂接时: PPEB peb = NtCurrentTeb()->ProcessEnvironmentBlock; PLIST_ENTRY head = &peb->Ldr->InLoadOrderModuleList; PLIST_ENTRY tail = head->Blink; InsertTailList(tail, &entry->InLoadOrderLinks);逻辑说明:PEB 是进程环境块,通过 NtCurrentTeb() 可以拿到。链表是双向链表,插入尾部不影响其它模块遍历。完成这一步后,GetModuleHandleA、FindResource 以及部分安全校验都会认为模块已正常加载。
参数说明:FullDllName 和 BaseDllName 在登记时必须给一个合理的“虚拟路径”,因为后续可能有代码要读它;我会给一个形如 L"memory:\module123.dll" 的名称,既不落盘又能识别来源。这个登记动作属于对系统加载器数据结构的手工修改,写坏链表会导致进程直接崩溃;生产环境一定要做好异常保护,并兼容多个 Windows 版本的结构差异。
4.4 不登记模块链表时的务实取舍
如果你暂时不想碰 PEB 这种高危结构,也可以把登记这步省掉,但代价是三件事不能做:DLL 内部调 GetModuleFileName 拿真实路径会拿不到;FindResource 大概率失败;GetModuleHandle 用名字找不到。反过来,只要业务里这三样都没依赖,纯导出函数调用模式不登记也能正常运行。
我通常的取舍是:先跑通不登记的最小版本,验证业务功能;需要资源或模块名时再补登记。这样出了问题能立刻分清楚是“装载器错误”还是“模块身份问题”,排查效率高很多。
5. 避坑记录:内存加载 DLL 最容易翻车的 5 个位置
5.1 现象:加载成功但调用导出函数立即崩溃
加载函数返回了非 NULL,但一调用导出函数就崩溃。原因几乎都是重定位没生效或者节区复制错位。最容易发生的是:编译DLL时开了 /DYNAMICBASE(默认开),重定位表存在;但装载器遍历重定位时把 delta 当成了 DWORD,高32位被截断,于是所有修正都错位。也可能是复制节区时漏了 SectionAlignment 对齐,导致函数里的代码还在,但全局变量地址镜像不对。
解决:先在 VirtualAlloc 的返回值上校验,确认镜像是完整 SizeOfImage;再在重定位循环里对每个 patchAddr 打日志,看写入前后的值。把 DLL 的 ImageBase 强制改为与 VirtualAlloc 结果相同,可以临时遮蔽问题,但正式代码一定要把重定位修好。
5.2 现象:32位DLL在64位进程里加载失败
本质不是代码问题,是结构体位宽问题。64位进程用 IMAGE_NT_HEADERS 解析一个32位PE时,OptionalHeader 读取长度不匹配,后面所有数据目录都错位。就算硬解析完成了,32位代码依赖32位系统调用约定,64位进程也跑不起来。
解决:确认进程位数与目标 DLL 位数一致。对加载器来说,编译时位数就决定了它能加载的PE位数。要做一个同时支持两种位数的加载器,建议拆成两个模块用子进程承载,而不是在同一个进程里同时跑两套。
5.3 现象:DLL 内部的 GetModuleFileName 返回空或错误路径
这个现象在登记模块链表之前必然出现。原因是 GetModuleFileName 内部会根据 HMODULE 反查模块链表拿 FullDllName,链表里没有这个条目,自然拿不到。结果是 DLL 内部写日志、定位配置目录、读同目录资源全部失败,但又不会直接崩溃,排查起来很耗时间。
解决:按 4.3 登记模块链表;或者在模块进入前把需要的外部路径作为参数传入,不依赖 GetModuleFileName。我在封装时倾向于后者,少依赖系统状态,逻辑更可控。
5.4 现象:DllMain 里加载其它内存模块导致死锁
这条是血泪经验:内存加载的 DLL 在 DllMain 中又去调用 LoadLibraryFromMemory 加载它的子插件,进程 LoaderLock 已被持有,子插件装载器再申请锁就会挂起。表现出来就是程序启动后卡死无响应,没有任何异常输出。
解决:把嵌套加载行为移出 DllMain,在插件主函数里做;或者做依赖预加载,把所有依赖在调用入口点之前批量装载完成。
5.5 现象:重复释放或重复加载导致内存泄漏
内存模块没有系统引用计数,FreeLibraryFromMemory 不能用系统的释放路径。不少人在收尾时顺手调用 FreeLibrary(hMem),系统会尝试在模块链表里找这个句柄然后走正常的卸载流程——如果之前登记过链表,这会造成节点被系统误删;如果没登记,FreeLibrary 直接返回失败。两种结果都可能导致内存泄漏或后续崩溃。
解决:只使用装载器配套的释放函数,先调 DllMain(DLL_PROCESS_DETACH),再释放 TLS 资源,最后 VirtualFree 镜像。重复加载同一个 DLL 字节流时,建议维护模块实例缓存:同一个基址二次加载直接返回旧句柄。
6. 验证技巧与进阶方向:把内存加载包装成可靠的插件机制
要验证“从内存加载 DLL”真的没落盘,光靠代码里不调 WriteFile 不够,建议用工具从外部观测。我常用的验证组合是 Process Monitor 加调试器模块列表。Process Monitor 里过滤进程路径,观察 CreateFile 事件中是否出现该 DLL 对应的文件名;如果全程没有 name 等于目标 DLL 的 CreateFile,说明没有落盘动作。调试器里用lm或!dlls查看模块列表,已登记的内存模块会显示路径为 memory 伪路径或自定义路径;不登记则在列表里看不到。
进阶方向是把装载器往“模块管理器”方向收敛几步。核心接口可以做成:
struct ModuleSpec { const unsigned char* data; size_t size; const wchar_t* virtualName; // 供登记模块链表使用 bool registerToLoader; // 是否需要登记模块链表 };然后把 LoadLibraryFromMemory、FreeLibraryFromMemory、GetProcAddress、GetModuleHandle 包装成统一的插件加载接口,同时维护引用计数和依赖表。这样插件热更新时可以直接加载新版本字节流,用新句柄替换旧句柄,旧模块先走 DllMain(DLL_PROCESS_DETACH) 再释放,实现无感替换。
最后给个个人习惯:每次加载成功、进入正式使用前,我会把源字节流 memset 掉。这个动作虽然不是为了防复现,但能让内存里不藏多余的PE副本,排查内存占用时更容易确认镜像只有一份。内存加载方案本身不算复杂,但重定位、导入表、TLS 回调、模块链表这些点一个都不能省。希望这份完整代码和踩坑记录能帮到你。
本文还有配套的精品资源,点击获取