ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手动映射加载EXE:内存中运行程序的实现源码解析

手动映射加载EXE:内存中运行程序的实现源码解析 简介一套围绕内存DLL与内存加载EXE的完整源码包面向熟悉Windows编程、希望深入进程注入与PE加载原理的开发者可用于学习如何摆脱传统磁盘路径直接在内存中加载并执行EXE同时以DLL接口形式对外提供调用。包内共17个文件主要包含cpp、h源码和rc资源脚本另有dsp、dsw工程文件、def导出定义、lib/exp链接文件及一个可运行的样例DLL/EXE整体约942KB。已有192人学习过。源码展示了从内存中解析PE结构、处理重定位、申请内存并创建线程执行入口点的关键步骤也给出了封装接口函数的具体实现参考价值较高。通过阅读工程代码读者可掌握CreateRemoteThread、VirtualAllocEx等API的典型用法并为自行开发内存加载或安全分析工具打下基础。1. 从磁盘启动到内存执行这份DLL源码到底在解决什么问题有一次我在排查一个进程频繁闪退的问题查了半天发现根本不是程序自身的问题——是有人把另一个EXE当成DLL直接塞进了这个进程的地址空间里执行。当时我就意识到内存DLL、内存加载EXE这套玩法在实际项目中远比教科书写得要复杂得多。这份“DLL源码启动”压缩包就是这么一套完整的工程调用方宿主、被加载的EXE、签名库全配齐适合想做PE加载器、调试注入逻辑、研究手动映射技术的人。它的核心价值在于让你不再依赖 CreateProcess 去拉起一个新进程而是把 EXE 当作一组二进制数据在当前进程里完成装载、修复、执行。这套技能在接口封装、功能改造、逆向调试和特殊场景部署里都绕不开。2. 为什么不用 LoadLibrary手动映射的原理与选型2.1 LoadLibrary 的硬限制路径、落盘和加载回调传统的 LoadLibrary / LoadLibraryEx必须给一个磁盘路径。Windows 会沿着这个路径找到文件读入系统缓存再交给装载器做映射。在这个过程中文件路径会出现在进程模块列表里DLL 的加载事件会被 ETW 记录而且 DLL 里的 DllMain 会因为进程线程状态的变化被多次调用参数从 DLL_PROCESS_ATTACH 到 DLL_THREAD_ATTACH 轮番上阵。如果你只是想“在当前进程里跑一段现成的 EXE 逻辑”LoadLibrary 这条路根本行不通——它只能加载 DLL加载不了 EXE。就算你把 EXE 强行改名成 DLL装载器会因为校验 PE 头、检查 Subsystem 字段等原因拒绝加载。就算你用 LoadLibraryEx 加 LOAD_LIBRARY_AS_DATAFILE 把它当数据文件读入那也只是把二进制读进内存并不能执行。这就要一个更底层的方案手动映射也就是业内常说的 Manual Map。手动映射的本质是绕开系统的加载流程自己把 PE 文件的每个节区复制到内存里修复重定位表和导入表然后直接跳到入口点。整个过程里磁盘上可以没有这个 EXE。2.2 手动映射的六个标准步骤第一步读取文件内容。直接用 CreateFile ReadFile 把 EXE 的二进制读进一块缓冲区这块缓冲区只是原始数据不参与执行。第二步解析 DOS 头和 PE 头。校验 e_magic 是否为 MZ、Signature 是否为 PE\0\0从这里拿到节区表、可选头的入口点地址、镜像基址、数据目录。第三步按 SizeOfImage 分配一块可读可写可执行的内存。这里有个容易踩的坑分配粒度要按 SectionAlignment 对齐不能直接拿文件大小来分配。第四步按节区表的 VirtualAddress 和 PointerToRawData把每个节区从文件偏移复制到内存偏移同时把文件头复制到镜像起始位置。第五步判断模块被加载到的新地址是否和 ImageBase 一致。不一致就遍历重定位表把所有绝对地址加上一个 Delta 值。第六步遍历导入表逐个 LoadLibrary 和 GetProcAddress把导入地址表 IAT 替换成真实函数地址。最后跳到 AddressOfEntryPoint 执行。整个流程看起来不复杂但每一步都有很多细节。一旦某一步写错程序不会给你报错只会静默崩溃。而且崩溃点往往不在出错的那一步而是拖到执行入口点之后才爆发排查起来非常难受。我做这个功能时习惯在每个步骤后加一个状态输出把 Section 数量、ImageBase、入口点地址、重定位表大小这些关键参数先打出来确认每一步都有据可查。2.3 三个关键选型点入口点、重定位、导入表入口点要不要作为线程启动常见做法是 CreateThread传入入口地址作为起始地址。好处是加载器线程可以立即返回不会阻塞调用方坏处是如果 EXE 的 WinMain 里做了大量初始化线程生命周期就脱离了加载器的控制。我一般倾向用 CreateThread 启动然后 WaitForSingleObject 等待入口点函数返回这样不会出现加载器线程已经退出、入口点还在另一个线程里跑的情况。但要注意如果目标 EXE 内部调用了 ExitProcess那整个进程还是会结束这一点没得商量。重定位是否必须做如果 EXE 的 ImageBase 和 VirtualAlloc 分配到的地址恰好一致重定位理论上可以跳过。但这么想的基本都会翻车——VirtualAlloc 能分到的地址由系统决定不能保证一致。尤其是开启了 ASLR 的系统EXE 头里声明的 ImageBase 基本不可能被满足。所以重定位处理是所有内存加载器里最不能省的逻辑。导入表修复则是另一个生死线它决定了 EXE 里能不能调用系统 API。如果导入表不修复加载出来的 EXE 只要碰到一个外部函数调用就会瞬间崩掉而且崩在 ntdll 里看起来像是系统问题。2.4 手动映射和进程注入的区别很多人把手动映射和进程注入混为一谈。实际上进程注入强调的是“把代码放进别人的进程空间”手法可以是 CreateRemoteThread、SetWindowsHookEx、APC 注入而手动映射强调的是“不通过系统加载器来装载 PE”两者可以结合使用。比如把手动映射出来的 DLL 通过远程线程注入到目标进程那就是一套完整的注入方案。但如果只是在自己进程里加载一个 EXE那只需要手动映射不需要远程线程。这份资源里的做法更接近后者宿主和加载目标都在同一进程里。3. 压缩包内的文件布局PotPlayer 工程、HWSignature 与 SGDownload 的分工打开这个压缩包第一眼看上去文件很多其实大部分是编译生成物。怎么把它们分层、找出来源是能不能看懂这份源码的关键。3.1 文件清单与角色判断从文件后缀和命名看这是基于 Visual C 6.0 工程.dsp / .dsw的一套方案。我把文件分成几类分类文件角色宿主源码PotPlayer.dsp / PotPlayer.dsw / PotPlayer.cpp / StdAfx.cpp / StdAfx.h调用方工程主体资源与定义Potplayer.rc / resource.h / Potplayer.def界面资源与导出符号定义编译生成物Release\HWSignature.exp / HWSignature.lib / HWSignature.dll签名库的导入库和 DLL目标与临时Bin\HWSignature.dll / Bin\temp / Release\SGDownload.exe运行时依赖与目标加载对象清理脚本删除临时文件.bat清理运行后残留PotPlayer 是这个工程的宿主名字。为什么叫 PotPlayer一种可能是它借了播放器的外壳实际干的却是加载器的活。PotPlayer.cpp 是主入口里面应该封装了内存加载逻辑和接口调用。注意 Potplayer.def 这个文件它在 DLL 工程里用来声明哪些函数可以被外部程序调用如果一个 EXE 工程里出现了 .def很可能是想让 EXE 也导出函数给别的模块使用或者用它来重定向入口点。HWSignature.dll 从名字看是“硬件签名”相关模块可能的作用是校验被加载的 EXE 是否合法或者在加载完成后做签名检查。这一块在调试时可以直接忽略但务必保证它存在因为宿主程序加载时很可能通过 LoadLibrary 依赖了它。SGDownload.exe 是典型的目标程序——被加载者。从命名看它可能是一个下载器程序这里被当成内存加载的对象。在真实使用场景里这个文件不应该常驻磁盘而是由宿主程序在内存中直接执行。3.2 调用方与被加载方的连接方式内存加载和普通 DLL 调用最大的不同在于DLL 可以通过 GetProcAddress 找导出函数而内存加载的 EXE 没有标准的导出机制。那么调用方怎么和 SGDownload.exe 沟通常见做法是在调用方里定义接口结构体。比如定义一个结构体 SGDOWNLOAD_INTERFACE里面放函数指针。然后调用方在内存加载完成后找到 EXE 的入口点给它传一个结构体地址让它把自身的函数指针填入这个结构体。这样调用方就能像调用本地函数一样调用加载到内存中的 EXE 功能。这个思路在工程里可能以 PotPlayer.cpp 里的一段全局结构体形式存在// InterfaceDef.h typedef struct _SGDOWNLOAD_INTERFACE { int nStructSize; // 结构体大小用于版本协商 DWORD dwFunctionFlags; // 支持的功能位掩码 BOOL (*SetSourceURL)(const char* lpszUrl); // 设置下载地址 BOOL (*StartDownload)(const char* lpszSavePath, DWORD dwTimeout); BOOL (*StopDownload)(void); DWORD (*GetProgress)(void); // 返回下载进度百分比 void (*Release)(void); // 释放内部资源 } SGDOWNLOAD_INTERFACE;这段代码定义了调用方和内存中 EXE 之间的接口契约。nStructSize 用于版本协商避免两边结构体对齐方式不一致导致字段错位dwFunctionFlags 让调用方在调用前先确认目标支持哪些功能。SetSourceURL 和 StartDownload 是业务函数。通过这种结构体传参方式内存加载的 EXE 也能像 DLL 一样提供清晰的函数入口。参数里我没有写路径和文件句柄因为内存模式下不依赖落盘所有地址都是函数指针。3.3 哪些文件是源码、哪些是生成物去掉 Release、Debug、Bin 这些目录后纯净的源码其实只有十来个文件。当你拿到这样的压缩包时第一件事是重建工程结构新建一个目录从 dsp / dsw 恢复 VC6 工程把 cpp / h / rc / def 放进去再编译。编译时注意 HWSignature.lib 要能链接上否则会报“无法解析的外部符号”。Release 目录下那几个 .exp / .lib 是为了和 HWSignature.dll 一起链接用的如果只是看加载器的逻辑不需要深究它的签名算法。还要留意 PotPlayer.plg 这个文件它是 VC6 的编译日志记录的是某一次编译的输出信息。这个文件基本没有保留价值但有时能从里面看到工程文件的编译顺序和依赖关系比如要不要先编译 HWSignature 再编译 PotPlayer。删除临时文件.bat 这个文件值得多说一句。它清理的是 Bin\temp 目录下的临时文件。内存加载虽然不需要目标 EXE 常驻磁盘但在某些方案里为了配合下载流程会先把 EXE 写到临时目录再立即用内存方式读入并删除。这个批处理就是把没删干净的残骸清理掉。4. 手写一版内存加载器从 PE 解析到入口点执行这一章是落地章节用 C 把整个手动映射流程拆开写一遍。代码以 Windows API 为主编译环境可以是 VS2022也可以退回 VC6核心逻辑不变。4.1 把 EXE 读进内存并校验 PE 头// LoadFile.cpp HANDLE hFile CreateFileA(SGDownload.exe, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (hFile INVALID_HANDLE_VALUE) { return FALSE; // 文件不存在或没有权限 } DWORD fileSize GetFileSize(hFile, NULL); BYTE* pBuffer new BYTE[fileSize]; DWORD bytesRead 0; ReadFile(hFile, pBuffer, fileSize, bytesRead, NULL); CloseHandle(hFile); // 校验 DOS 头和 PE 头 IMAGE_DOS_HEADER* pDos (IMAGE_DOS_HEADER*)pBuffer; if (pDos-e_magic ! IMAGE_DOS_SIGNATURE) { // MZ delete[] pBuffer; return FALSE; } IMAGE_NT_HEADERS* pNt (IMAGE_NT_HEADERS*)(pBuffer pDos-e_lfanew); if (pNt-Signature ! IMAGE_NT_SIGNATURE) { // PE\0\0 delete[] pBuffer; return FALSE; }这里把 EXE 整体读成一块缓冲区然后强转成 IMAGE_DOS_HEADER 并校验 e_magic再通过 e_lfanew 跳转到 NT 头校验 Signature。注意 e_lfanew 这个字段是 DOS 头里指向 PE 头的文件偏移不能直接假设它固定是 0x80。用 ImageNtHeaders 宏也能做转换但这里手动写更直观也方便打印中间变量。读文件时我用了 FILE_SHARE_READ这样即使目标 EXE 被其他进程打开了也不会因为共享冲突读失败。4.2 按节区分配内存并复制数据// AllocImage.cpp IMAGE_OPTIONAL_HEADER32* pOpt pNt-OptionalHeader; BYTE* pImageBase (BYTE*)VirtualAlloc(NULL, pOpt-SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pImageBase NULL) { delete[] pBuffer; return FALSE; } // 复制文件头部分 memcpy(pImageBase, pBuffer, pOpt-SizeOfHeaders); // 遍历节区表并按节区实际数据大小复制 IMAGE_SECTION_HEADER* pSec IMAGE_FIRST_SECTION(pNt); for (int i 0; i pNt-FileHeader.NumberOfSections; i) { memcpy(pImageBase pSec[i].VirtualAddress, pBuffer pSec[i].PointerToRawData, pSec[i].SizeOfRawData); }分配内存用的是 VirtualAlloc分配大小是 SizeOfImage 而不是文件大小。SizeOfImage 是加载到内存后整个镜像的虚拟大小包含节区对齐后的空隙和头部对齐所以它一般会比文件大小大。如果只分配文件大小PE 头里所有基于 VirtualAddress 的地址都会越界等执行到入口点附近就会触发访问冲突。节区复制时用 PointerToRawData 去文件里取数据写入 VirtualAddress 对应的位置。这里特意用了 PAGE_EXECUTE_READWRITE是为了避免节区权限不同导致后续写入时崩溃正常做法是后面按节区属性重新设置保护属性。4.3 修复重定位表// FixReloc.cpp DWORD delta (DWORD)((BYTE*)pImageBase - pOpt-ImageBase); if (delta ! 0) { DWORD relocRVA pOpt-DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; DWORD relocSize pOpt-DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size; if (relocRVA ! 0) { DWORD offset 0; while (offset relocSize) { IMAGE_BASE_RELOCATION* pReloc (IMAGE_BASE_RELOCATION*)(pImageBase relocRVA offset); DWORD count (pReloc-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD); WORD* entries (WORD*)((BYTE*)pReloc sizeof(IMAGE_BASE_RELOCATION)); for (DWORD j 0; j count; j) { if ((entries[j] 0x3000) IMAGE_REL_BASED_HIGHLOW) { DWORD* pAddr (DWORD*)(pImageBase pReloc-VirtualAddress (entries[j] 0x0FFF)); *pAddr delta; } } offset pReloc-SizeOfBlock; } } }delta 是实际加载地址和 PE 头里声明的首选基址之间的差。重定位表里的每一项都是一个块结构VirtualAddress 指明这个块作用于哪个页面SizeOfBlock 是这个块的总字节数后面的 WORD 数组每一项的高 4 位表示重定位类型低 12 位是页面内的偏移。HIGHLOW 类型表示这是一个 32 位地址需要加上 delta。如果 EXE 没有重定位表也就是 Base Relocation Directory 的 Size 为 0那这个 EXE 就只能加载到它的 ImageBase 上否则一定会崩。这种情况在 VC6 编译的 Release EXE 里比较常见遇到时不要硬加载要么用 LoadLibrary 走正常路要么给工程加上 /DYNAMICBASE 重新编译。4.4 修复导入表// FixImport.cpp DWORD importRVA pOpt-DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress; if (importRVA ! 0) { IMAGE_IMPORT_DESCRIPTOR* pImp (IMAGE_IMPORT_DESCRIPTOR*)(pImageBase importRVA); for (; pImp-Name ! 0; pImp) { char* dllName (char*)(pImageBase pImp-Name); HMODULE hDll LoadLibraryA(dllName); if (hDll NULL) { continue; // 依赖的 DLL 不在系统路径或当前目录 } IMAGE_THUNK_DATA* pThunk (IMAGE_THUNK_DATA*)(pImageBase pImp-FirstThunk); IMAGE_THUNK_DATA* pOrig (IMAGE_THUNK_DATA*)(pImageBase pImp-OriginalFirstThunk); for (; pThunk-u1.AddressOfData ! 0; pThunk, pOrig) { if (pOrig-u1.AddressOfData 0x80000000) { // 按序号导入的函数 DWORD ordinal pOrig-u1.AddressOfData 0x7FFFFFFF; pThunk-u1.Function (DWORD)GetProcAddress(hDll, (char*)ordinal); } else { // 按名称导入的函数 IMAGE_IMPORT_BY_NAME* pName (IMAGE_IMPORT_BY_NAME*)(pImageBase pOrig-u1.AddressOfData); pThunk-u1.Function (DWORD)GetProcAddress(hDll, pName-Name); } } } }导入表修复的逻辑是把 EXE 里声明的导入函数从“函数名称”替换为实际加载到进程空间的函数地址。OriginalFirstThunk 指向原始导入名称表FirstThunk 是 IAT两者在编译时可能指向同一块数据但修复时只能改 IAT不能动 OriginalFirstThunk 指向的内容。按序号导入时AddressOfData 的最高位为 1低位就是函数序号GetProcAddress 可以直接用序号查找。按名称导入则要解析 IMAGE_IMPORT_BY_NAME里面是 Hint 加函数名字符串。如果 LoadLibrary 返回 NULL说明目标 EXE 依赖的这个 DLL 不在系统的搜索路径里这时候要自己先 LoadLibrary 一个绝对路径或者把 DLL 目录加到当前进程的搜索路径中。4.5 执行入口点并管理线程生命周期// RunEntry.cpp BYTE* entryPoint pImageBase pOpt-AddressOfEntryPoint; HANDLE hThread CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)entryPoint, (LPVOID)pInterface, // 传给入口点的参数 0, NULL); if (hThread NULL) { VirtualFree(pImageBase, 0, MEM_RELEASE); delete[] pBuffer; return FALSE; } WaitForSingleObject(hThread, INFINITE); CloseHandle(hThread); // 线程结束后再释放镜像 VirtualFree(pImageBase, 0, MEM_RELEASE); delete[] pBuffer;入口点地址是 AddressOfEntryPoint 相对于镜像基址的偏移。对于 EXE 来说这个入口点一般会经过 C 运行时初始化最终调用 WinMain。用 CreateThread 而不是直接函数调用的好处是线程有自己的栈空间入口点里即使对栈做了大量操作也不会污染加载器所在线程的栈。另一个好处是可以在外部等待线程结束便于管理生命周期。这里我传入了 pInterface 结构体指针也就是上一章定义的接口结构目标 EXE 在入口点阶段往里面填充函数指针。注意VirtualFree 释放镜像内存必须在所有相关线程退出之后否则就是在代码还没有停止执行的时候先把代码卸载了。4.6 参数怎么传、返回值怎么拿传参数给内存加载的 EXE 是一个容易忽略的问题。CreateProcess 可以通过命令行传参数而内存加载的入口点函数签名理论上只有一个参数也就是我们常说的 lpParameter。这个自由度其实很大——你可以传一个整数可以传一个字符串指针也可以传一个结构体指针。我一般把所有参数包成一个结构体里面放业务需要的 URL、保存路径、超时时间、回调函数等。返回值通过结构体里的指针带回来比如结构体里放一个 int* pResult目标程序执行完把结果写到这个地址。如果目标 EXE 的入口点是标准 WinMain那它的返回值要通过 ExitProcess 或者 return 返回。在线程场景下入口点 return 的值会变成线程退出码可以通过 GetExitCodeThread 拿到。注意拿返回值要在 WaitForSingleObject 之后调用线程没结束就拿退出码结果是不确定的。5. 常见问题与排错加载器翻车的五条血泪经验5.1 加载后进程活着但主窗口没出现现象加载返回成功线程也创建了但目标 EXE 的主窗口没弹出来进程却在后台跑着任务管理器里能看到进程存在。原因最常见的是入口点用的不是正常的 WinMain 入口流程。很多 EXE 在 WinMain 被调用前有一大段 CRT 初始化逻辑包括解析命令行、初始化全局变量、设置错误模式。如果直接跳到 AddressOfEntryPoint这些初始化步骤就被跳过了导致窗口创建失败或者窗口类没有注册。解决确认目标 EXE 是否使用动态链接 CRT。如果是它的入口点通常是 mainCRTStartup 或 wWinMainCRTStartup这类入口点会调用 _cexit 清理逻辑。在调试器里设断点看第一次异常发生在哪一步如果停在 CRT 源码里就说明初始化路径不对。另一个排查方向是检查目标 EXE 是否依赖当前目录下的 DLL如果 DLL 没找到入口点可能在 LoadLibrary 阶段就失败返回了。5.2 一执行就报 0xC0000005 崩溃定位到重定位段现象加载成功但入口点执行不到三行就崩溃调试器停在一条 mov 指令上崩溃地址指向一个明显不属于任何模块的地址。原因大概率是重定位被跳过了。如果 EXE 的 ImageBase 是 0x400000而 VirtualAlloc 返回的是 0x1A0000那么所有绝对地址都指向了错误的位置。还有一种情况是重定位表解析循环写错了比如 SizeOfBlock 计算错误导致跳过了某些块修复了错误地址。解决打印 delta 值确认它不为 0。同时在重定位修复后把导入表地址随机抽几个打印出来和 GetProcAddress 的结果比对。如果发现 AddressOfData 指向的地址不在 pImageBase 到 pImageBase SizeOfImage 的范围内基本就是重定位没修对。还有一种玄学情况目标 EXE 是 x64 的你用了 32 位重定位逻辑去解析类型值根本匹配不上。遇到这种就先确认目标架构和加载器架构一致。5.3 页面权限导致的访问冲突RWX 段被 DEP 拦截现象重定位和导入表都修了运行时依然会偶发崩溃尤其是调用一些全局变量或者回调函数的时候。有时候加载器自己没问题换了台机器就崩。原因有的 EXE 代码段和数据段对权限要求不同。全部用 PAGE_EXECUTE_READWRITE 虽然能跑但某些 API 比如 VirtualProtect 或 DEP 策略会对 RWX 段做特殊处理导致执行到代码段时被强制拦截。尤其是开启了 CFG控制流保护的系统对间接调用目标有校验RWX 内存块不在白名单里就会被拒绝。解决按节区属性分别设置内存权限。节区表里每个 Section 有 Characteristics 字段IMAGE_SCN_MEM_EXECUTE 表示可执行IMAGE_SCN_MEM_READ 表示可读IMAGE_SCN_MEM_WRITE 表示可写。分配内存时先用 MEM_RESERVE 保留整个 SizeOfImage 地址空间再按节区用 MEM_COMMIT 逐段提交然后根据 Characteristics 用 VirtualProtect 设置对应权限。这样代码段是 PAGE_EXECUTE_READ数据段是 PAGE_READWRITE更接近系统正常加载的行为。5.4 杀毒软件隔离 Bin 目录下的 DLL 和 EXE现象编译好的 Bin 目录里HWSignature.dll 和 SGDownload.exe 偶尔会神秘消失或者被隔离到病毒隔离区。重新编译后过一阵子又出现同样情况。原因手动映射是一种高级监控技术很多杀毒软件会把“在内存中分配 RWX 内存 写入代码 跳转到入口”识别为后门行为。工程里的 HWSignature.dll 如果做了签名校验逻辑也容易被静态特征误判。这不算加载器的逻辑错误但会直接影响复现。解决本地做加载器研发时把整个工程目录加入杀毒软件白名单或者干脆在虚拟机里做测试。不要在装有重要资料的机器上反复编译这类工程。如果你的目标是做正常的功能封装建议优先考虑标准 LoadLibrary GetProcAddress只有确实需要内存加载时才动用这套方案。5.5 删除临时文件.bat 把正在运行的目标 EXE 删了现象加载器运行过程中执行一次批处理之后目标程序突然闪退或者下次运行提示找不到 SGDownload.exe。原因批处理清理的是 Bin\temp 目录。如果加载器把 EXE 写进 temp 后还没来得及读入批处理就先把它删了加载自然就失败。如果加载器运行完后批处理立刻清理又可能删掉一个还在被映射到内存的文件。更隐蔽的问题是第一次加载没有释放文件句柄批处理删不掉文件但把它标记为“待删除”进程退出后文件就消失了第二次运行就找不到。解决延迟清理时机。批处理里加一段 timeout 等待加载器完全退出或者让加载器自己负责删除临时文件而不是依赖外部脚本。我现在的习惯是凡是需要落盘再读入的场景文件删除统一放在加载函数返回之后用 try-finally 的结构保证任何分支退出时都会执行清理同时清理前校验句柄已关闭。6. 进阶用法把加载器封装成带接口的 DLL到这一步手动加载已经能跑通了。但实际项目里不会把加载逻辑写死在 EXE 里而是封装成 DLL对外暴露接口让宿主程序通过普通 LoadLibrary 调用。这样既方便复用也让内存加载的黑匣子只对调用方开一个小口。接口设计大概是这样// MemLoader.h typedef struct _MEMLOADER_PARAMS { const char* lpFilePath; // 目标 EXE 路径 void* lpReserved; // 保留参数 HANDLE hThread; // 返回的主线程句柄 DWORD dwLoadFlags; // 0 表示只用内存加载1 表示加载后落盘清理 } MEMLOADER_PARAMS; BOOL __stdcall MemLoadExe(MEMLOADER_PARAMS* pParams); BOOL __stdcall MemUnloadExe(HANDLE hThread);MemLoadExe 内部完成整条加载链路成功返回 TRUE同时把主线程句柄存到参数里。MemUnloadExe 负责通知目标 EXE 退出并等待线程结束。如果需要更复杂的状态反馈可以再加一个回调函数指针让宿主主动感知加载进度。调用方只需要HMODULE hLoader LoadLibraryA(MemLoader.dll); typedef BOOL(__stdcall *PFN_Load)(MEMLOADER_PARAMS*); PFN_Load pfnLoad (PFN_Load)GetProcAddress(hLoader, MemLoadExe); MEMLOADER_PARAMS params {0}; params.lpFilePath SGDownload.exe; params.dwLoadFlags 0; BOOL bOK pfnLoad(params);封装之后还有一个好处可以把接口结构体版本化。如果目标 EXE 升级了函数指针数量变化了通过 nStructSize 判断两边版本是否匹配不匹配直接返回错误而不是等到调用时崩溃。这些话听起来简单但真去封装的时候你会发现内存加载和 DLL 生命周期管理纠缠在一起。比如卸载时不能在目标 EXE 的线程内部调用 VirtualFree 释放镜像否则会先把自己正在执行的代码释放掉。正确做法是从外部线程释放而且要先让目标 EXE 的所有子线程退出。从那以后我每次封装加载器都会强制走一遍“加载 → 运行 → 卸载 → 再次加载”的回归测试特别是卸载后马上再加载同一份镜像的场景。这个流程能暴露绝大多数内存管理问题希望帮到你。本文还有配套的精品资源点击获取
返回列表