首页 / 资讯中心 / 文章详情

进程空中技术:手动实现Windows加载器,将exe从磁盘直接加载到内存执行

进程空中技术:手动实现Windows加载器,将exe从磁盘直接加载到内存执行 ★ FEATURED ARTICLE
简介这份资源围绕进程空中技术RunPE / Process Hollowing展开面向安全研究人员、逆向工程师以及希望深入理解 Windows 底层机制的学习者帮助读者掌握在不将 exe 写入磁盘的前提下于内存中动态加载并执行代码的核心思路。内容涵盖承载进程选择、目标 exe 节区读取、内存状态保存与清空、入口点重设以及 NtResumeThread 恢复执行等关键环节并延伸至隐蔽执行、逆向工程与动态调试等应用场景同时涉及异常进程行为检测等防御视角。资源包为 zip 格式仅含 1 个 cpp 源文件体积约 2KB属于轻量级代码示例便于直接阅读与二次实验。目前已有 1209 人学习下载适合具备一定 PE 文件格式与 Windows API 基础、希望从代码层面理解进程注入原理的读者参考。1. 进程空中技术把 exe 从磁盘里“抠”出来直接塞进内存跑第一次听到“进程空中技术”这个词很多人以为是某种网络层面的东西其实它干的事情非常具体把一个原本躺在磁盘上的 exe 文件读进内存不落盘、不经过常规的 CreateProcess 加载路径直接在目标进程的内存空间里把它“拼”起来执行。我第一次接触这个需求是因为一个自动化测试工具需要在客户机器上动态执行一段逻辑但客户环境有文件监控任何新写入磁盘的 exe 都会被拦截。当时试了好几种方案都翻车最后才走到内存加载这条路。这个项目适合两类人一是做安全工具、渗透测试、红队开发的工程师需要理解 PE 文件在内存中如何被解析和重定位二是做软件保护、反调试、壳开发的开发者想搞清楚 Windows 加载器到底做了哪些事情。它不是一个“拿来就能用”的成品工具而是一套完整的实现思路加代码骨架你需要自己编译、自己调试、自己适配目标环境。关键词“加载exe”在这里的含义不是双击运行而是手动完成 Windows 加载器的工作解析 PE 头、分配内存、拷贝节区、修复重定位、解析导入表、设置入口点最后跳过去执行。2. 核心原理拆解Windows 加载器到底替你做了哪些事2.1 PE 文件结构回顾从 DOS 头到节表要让 exe 在内存里跑起来首先得知道 Windows 加载器读的是什么。一个 PE 文件从偏移 0 开始是 DOS 头IMAGE_DOS_HEADER其中e_lfanew字段指向真正的 PE 签名位置。PE 签名之后是文件头IMAGE_FILE_HEADER和可选头IMAGE_OPTIONAL_HEADER可选头里藏着入口点 RVA、镜像基址、节区数量、导入表目录项等关键信息。节表紧跟在可选头后面每个节表项描述一个节区的名字、虚拟地址、虚拟大小、原始数据偏移和原始大小。加载器的工作就是把这些节区从文件偏移处拷贝到内存中对应的虚拟地址处。// 解析 PE 基本信息的核心代码 BOOL ParsePEHeaders(LPVOID pFileBuffer, PIMAGE_NT_HEADERS* ppNtHeaders) { PIMAGE_DOS_HEADER pDosHeader (PIMAGE_DOS_HEADER)pFileBuffer; // 校验 DOS 签名 MZ if (pDosHeader-e_magic ! IMAGE_DOS_SIGNATURE) { return FALSE; } // 通过 e_lfanew 定位 NT 头 PIMAGE_NT_HEADERS pNtHeaders (PIMAGE_NT_HEADERS)( (BYTE*)pFileBuffer pDosHeader-e_lfanew); // 校验 PE 签名 PE\0\0 if (pNtHeaders-Signature ! IMAGE_NT_SIGNATURE) { return FALSE; } *ppNtHeaders pNtHeaders; return TRUE; }这段代码做了两件事校验文件确实是 PE 格式以及把 NT 头的指针暴露出去供后续使用。e_lfanew是 DOS 头中唯一还有实际意义的字段它告诉加载器去哪里找真正的 PE 结构。参数pFileBuffer是完整读入内存的文件数据ppNtHeaders是输出参数调用方拿到后可以继续访问OptionalHeader和节表。2.2 内存分配与节区映射为什么不能直接 memcpy很多人第一反应是把文件读进来找到入口点直接跳过去不就行了不行。因为 PE 文件在磁盘上的布局和加载到内存后的布局是不一样的。磁盘上节区按文件对齐通常是 0x200内存中按页对齐通常是 0x1000。而且有些节区在内存中需要可写、可执行权限这些权限在文件里是不存在的。正确做法是先用VirtualAlloc在目标进程空间分配一块大小为SizeOfImage的内存基址优先选择ImageBase如果被占用就换一个地址。然后遍历节表把每个节区的数据从文件缓冲区拷贝到基址 VirtualAddress处。// 在目标进程中分配内存并映射节区 LPVOID MapImageToMemory(LPVOID pFileBuffer, PIMAGE_NT_HEADERS pNtHeaders) { DWORD imageSize pNtHeaders-OptionalHeader.SizeOfImage; LPVOID preferredBase (LPVOID)pNtHeaders-OptionalHeader.ImageBase; // 优先尝试加载到首选基址 LPVOID pImageBase VirtualAlloc(preferredBase, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pImageBase NULL) { // 首选基址被占用让系统随便选一个 pImageBase VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (pImageBase NULL) return NULL; } // 拷贝 PE 头 memcpy(pImageBase, pFileBuffer, pNtHeaders-OptionalHeader.SizeOfHeaders); // 逐节区拷贝 PIMAGE_SECTION_HEADER pSection IMAGE_FIRST_SECTION(pNtHeaders); for (WORD i 0; i pNtHeaders-FileHeader.NumberOfSections; i) { if (pSection[i].SizeOfRawData 0) { memcpy( (BYTE*)pImageBase pSection[i].VirtualAddress, (BYTE*)pFileBuffer pSection[i].PointerToRawData, pSection[i].SizeOfRawData ); } } return pImageBase; }这里有几个关键点SizeOfHeaders决定了 PE 头本身占多少字节必须完整拷贝SizeOfRawData是节区在文件中的实际大小可能小于VirtualSize剩余部分应该保持为零分配时直接给了PAGE_EXECUTE_READWRITE权限省去后续改权限的麻烦但在真实场景中应该按节区属性分别设置。2.3 重定位修复当 exe 没有加载到预期地址如果VirtualAlloc返回的地址和ImageBase不一致就必须修复重定位。重定位表.reloc 节记录了所有需要根据实际基址调整的硬编码地址。每一项是一个IMAGE_BASE_RELOCATION块块内包含多个 16 位条目高 4 位表示类型低 12 位表示偏移。// 修复重定位表 BOOL FixRelocations(LPVOID pImageBase, PIMAGE_NT_HEADERS pNtHeaders) { DWORD relocRva pNtHeaders-OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress; DWORD relocSize pNtHeaders-OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size; if (relocRva 0 || relocSize 0) { // 没有重定位表说明只能加载到首选基址 return FALSE; } // 计算实际基址与首选基址的差值 ULONG_PTR delta (ULONG_PTR)pImageBase - pNtHeaders-OptionalHeader.ImageBase; PIMAGE_BASE_RELOCATION pReloc (PIMAGE_BASE_RELOCATION) ((BYTE*)pImageBase relocRva); while ((BYTE*)pReloc (BYTE*)pImageBase relocRva relocSize) { DWORD entryCount (pReloc-SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2; PWORD pEntries (PWORD)((BYTE*)pReloc sizeof(IMAGE_BASE_RELOCATION)); for (DWORD i 0; i entryCount; i) { WORD type pEntries[i] 12; WORD offset pEntries[i] 0x0FFF; if (type IMAGE_REL_BASED_HIGHLOW) { // 32 位地址直接加上差值 PDWORD pPatch (PDWORD)((BYTE*)pImageBase pReloc-VirtualAddress offset); *pPatch (DWORD)delta; } // 64 位程序还需要处理 IMAGE_REL_BASED_DIR64 } pReloc (PIMAGE_BASE_RELOCATION)((BYTE*)pReloc pReloc-SizeOfBlock); } return TRUE; }delta是实际加载基址和首选基址的差值所有硬编码的绝对地址都要加上这个差值。IMAGE_REL_BASED_HIGHLOW是 32 位程序最常见的重定位类型64 位程序会用到IMAGE_REL_BASED_DIR64。如果 exe 没有重定位表编译时指定了/FIXED那就只能加载到首选基址否则直接失败。2.4 导入表解析把依赖的 DLL 拉进来exe 运行时需要调用系统 DLL 里的函数导入表记录了它需要哪些 DLL、每个 DLL 里的哪些函数。在内存加载场景下你需要手动遍历导入表用LoadLibrary和GetProcAddress把函数地址填到 IAT导入地址表里。// 解析导入表并填充 IAT BOOL ResolveImports(LPVOID pImageBase, PIMAGE_NT_HEADERS pNtHeaders) { DWORD importRva pNtHeaders-OptionalHeader .DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress; if (importRva 0) return TRUE; // 没有导入表 PIMAGE_IMPORT_DESCRIPTOR pImport (PIMAGE_IMPORT_DESCRIPTOR) ((BYTE*)pImageBase importRva); while (pImport-Name ! 0) { // 获取 DLL 名称并加载 char* dllName (char*)((BYTE*)pImageBase pImport-Name); HMODULE hDll LoadLibraryA(dllName); if (hDll NULL) return FALSE; // 获取 OriginalFirstThunk 和 FirstThunk PIMAGE_THUNK_DATA pOriginalThunk (PIMAGE_THUNK_DATA) ((BYTE*)pImageBase pImport-OriginalFirstThunk); PIMAGE_THUNK_DATA pThunk (PIMAGE_THUNK_DATA) ((BYTE*)pImageBase pImport-FirstThunk); while (pOriginalThunk-u1.AddressOfData ! 0) { FARPROC funcAddr NULL; if (IMAGE_SNAP_BY_ORDINAL(pOriginalThunk-u1.Ordinal)) { // 按序号导入 funcAddr GetProcAddress(hDll, (LPCSTR)IMAGE_ORDINAL(pOriginalThunk-u1.Ordinal)); } else { // 按名称导入 PIMAGE_IMPORT_BY_NAME pImportByName (PIMAGE_IMPORT_BY_NAME) ((BYTE*)pImageBase pOriginalThunk-u1.AddressOfData); funcAddr GetProcAddress(hDll, pImportByName-Name); } if (funcAddr NULL) return FALSE; // 把函数地址写入 IAT pThunk-u1.Function (ULONG_PTR)funcAddr; pOriginalThunk; pThunk; } pImport; } return TRUE; }OriginalFirstThunk指向导入名称表INTFirstThunk指向导入地址表IAT。加载器的工作就是遍历 INT拿到每个函数的名称或序号调用GetProcAddress获取真实地址然后写入 IAT 对应位置。注意OriginalFirstThunk可能为 0这时需要回退到FirstThunk来读取名称。3. 完整加载流程从文件到执行的每一步3.1 读取文件与校验第一步是把 exe 文件完整读入内存。可以用CreateFileGetFileSizeReadFile的组合也可以用内存映射文件。读进来之后先校验 DOS 头和 PE 签名确认是合法的 PE 文件。// 读取文件到内存缓冲区 LPVOID ReadFileToMemory(const char* filePath, DWORD* pFileSize) { HANDLE hFile CreateFileA(filePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) return NULL; DWORD fileSize GetFileSize(hFile, NULL); if (fileSize INVALID_FILE_SIZE) { CloseHandle(hFile); return NULL; } LPVOID pBuffer malloc(fileSize); if (pBuffer NULL) { CloseHandle(hFile); return NULL; } DWORD bytesRead 0; if (!ReadFile(hFile, pBuffer, fileSize, bytesRead, NULL) || bytesRead ! fileSize) { free(pBuffer); CloseHandle(hFile); return NULL; } CloseHandle(hFile); *pFileSize fileSize; return pBuffer; }这个函数返回的缓冲区是后续所有操作的输入。注意GetFileSize对大文件可能返回INVALID_FILE_SIZE需要结合GetLastError判断。读完后记得关闭文件句柄缓冲区由调用方负责释放。3.2 分配内存与拷贝节区拿到文件缓冲区后调用前面写的MapImageToMemory完成内存分配和节区拷贝。这一步的核心是确保内存布局和 Windows 加载器一致PE 头在开头各节区按 VirtualAddress 排列节区之间的空隙填零。// 完整的加载流程串联 LPVOID LoadExeToMemory(const char* exePath) { DWORD fileSize 0; LPVOID pFileBuffer ReadFileToMemory(exePath, fileSize); if (pFileBuffer NULL) return NULL; PIMAGE_NT_HEADERS pNtHeaders NULL; if (!ParsePEHeaders(pFileBuffer, pNtHeaders)) { free(pFileBuffer); return NULL; } LPVOID pImageBase MapImageToMemory(pFileBuffer, pNtHeaders); free(pFileBuffer); // 文件缓冲区不再需要 if (pImageBase NULL) return NULL; // 如果加载地址和首选基址不同修复重定位 if ((ULONG_PTR)pImageBase ! pNtHeaders-OptionalHeader.ImageBase) { if (!FixRelocations(pImageBase, pNtHeaders)) { VirtualFree(pImageBase, 0, MEM_RELEASE); return NULL; } } // 解析导入表 if (!ResolveImports(pImageBase, pNtHeaders)) { VirtualFree(pImageBase, 0, MEM_RELEASE); return NULL; } return pImageBase; }这个函数把前面所有步骤串起来了读文件、解析头、分配内存、拷贝节区、修复重定位、解析导入表。返回的pImageBase就是可以直接执行的镜像基址。注意pNtHeaders指向的是文件缓冲区里的数据free(pFileBuffer)之后不能再访问它所以需要在释放前把需要的信息比如ImageBase、AddressOfEntryPoint保存下来。3.3 执行入口点最后一步是计算入口点地址并跳过去执行。入口点 RVA 在OptionalHeader.AddressOfEntryPoint实际地址是pImageBase AddressOfEntryPoint。对于 exe入口点是mainCRTStartup或WinMainCRTStartup对于 DLL入口点是DllMain。// 执行加载后的镜像 BOOL ExecuteLoadedImage(LPVOID pImageBase, PIMAGE_NT_HEADERS pNtHeaders) { // 计算入口点实际地址 ULONG_PTR entryPoint (ULONG_PTR)pImageBase pNtHeaders-OptionalHeader.AddressOfEntryPoint; // 入口点函数签名exe 的标准入口 typedef int (WINAPI* EntryPointFunc)(void); EntryPointFunc pEntry (EntryPointFunc)entryPoint; // 直接调用入口点 // 注意这会阻塞当前线程直到 exe 的主逻辑返回 int result pEntry(); return TRUE; }直接调用入口点会阻塞当前线程因为 exe 的入口点通常不会立即返回。如果希望异步执行可以创建一个新线程来调用入口点。另外如果目标 exe 有图形界面直接调用入口点可能会导致窗口消息循环在当前线程运行需要根据实际场景决定是同步还是异步执行。注意直接调用入口点意味着 exe 和当前进程共享地址空间和资源。如果 exe 崩溃当前进程也会崩溃。在生产环境中更安全的做法是创建一个挂起的进程把镜像写入目标进程空间然后恢复执行。4. 避坑与常见问题那些让我熬夜的报错4.1 加载后一执行就崩溃错误码 0xC0000005现象内存分配成功节区拷贝完成导入表也解析了但一跳转到入口点就访问违例。原因最常见的是重定位没修对。如果 exe 加载到了非首选基址但重定位表解析时漏掉了某些类型的重定位项或者delta计算错误就会导致代码中引用的全局变量地址错误。另一个常见原因是节区权限不对比如 .text 节需要可执行权限但分配时只给了可读可写。解决先用调试器附加到目标进程看崩溃时的指令地址和寄存器值。如果崩溃地址在 .text 节范围内大概率是重定位问题。检查FixRelocations是否处理了所有重定位类型32 位程序主要看IMAGE_REL_BASED_HIGHLOW64 位程序看IMAGE_REL_BASED_DIR64。权限问题可以通过VirtualProtect按节区属性重新设置。4.2 导入表解析失败GetProcAddress 返回 NULL现象ResolveImports在某个函数上返回失败GetProcAddress拿不到地址。原因可能是 DLL 名称大小写问题或者目标 DLL 在当前进程中没有加载。另外某些 API 是转发导出forwarded exportGetProcAddress能处理但如果手动解析导出表就会漏掉。还有一种情况是OriginalFirstThunk为 0代码回退到FirstThunk时读取了已经被填充的 IAT导致解析错误。解决在GetProcAddress失败时打印 DLL 名称和函数名确认是否拼写错误。对于转发导出直接用GetProcAddress是最稳妥的。如果OriginalFirstThunk为 0需要在解析前先保存一份FirstThunk的副本或者直接用LoadLibraryGetProcAddress的方式绕过手动解析。4.3 目标 exe 有 TLS 回调加载后没执行现象exe 加载成功入口点也执行了但某些全局变量没有初始化导致逻辑异常。原因PE 文件如果有 TLS线程本地存储目录加载器需要在入口点执行前调用 TLS 回调。手动加载时如果跳过了这一步TLS 相关的初始化就不会执行。解决解析IMAGE_DIRECTORY_ENTRY_TLS目录项拿到AddressOfCallBacks数组在入口点执行前逐个调用回调函数。TLS 回调的签名是void NTAPI TlsCallback(PVOID, DWORD, PVOID)第二个参数是DLL_PROCESS_ATTACH时表示进程初始化。4.4 加载 64 位 exe 时重定位计算溢出现象32 位程序加载正常换成 64 位 exe 后重定位修复导致地址错乱。原因64 位程序的基址是 64 位delta需要用ULONG_PTR或ULONGLONG计算。如果代码里用了DWORD来存差值高位会被截断。另外64 位程序的重定位类型是IMAGE_REL_BASED_DIR64修补的是 8 字节地址不是 4 字节。解决把所有涉及地址计算的变量改成ULONG_PTR重定位修补时根据类型决定写 4 字节还是 8 字节。IMAGE_REL_BASED_HIGHLOW写DWORDIMAGE_REL_BASED_DIR64写ULONGLONG。4.5 杀软拦截 VirtualAlloc 执行现象代码逻辑没问题但一运行就被杀软拦截提示“可疑行为”。原因VirtualAlloc分配可执行内存后直接跳转执行这是恶意软件的典型行为特征杀软会重点监控。另外如果分配的内存页同时具有写和执行权限RWX也会触发告警。解决分配时先给PAGE_READWRITE写入完成后再用VirtualProtect改成PAGE_EXECUTE_READ。避免 RWX 权限同时存在。如果目标环境杀软严格可以考虑用NtAllocateVirtualMemory等底层 API 替代VirtualAlloc但这不是根本解决办法只是降低触发概率。5. 二次开发与改造让内存加载适配你的场景5.1 支持从内存缓冲区直接加载现在的实现是从文件路径读取 exe但很多场景下 exe 数据已经在内存里了比如从网络接收、从资源节读取、从加密数据解密。改造方法很简单把ReadFileToMemory替换成接收LPVOID pBuffer和DWORD dwSize的参数后续流程完全不变。// 改造后的接口直接从内存缓冲区加载 LPVOID LoadExeFromBuffer(LPVOID pFileBuffer, DWORD fileSize) { PIMAGE_NT_HEADERS pNtHeaders NULL; if (!ParsePEHeaders(pFileBuffer, pNtHeaders)) return NULL; // 保存关键信息因为后续可能释放文件缓冲区 DWORD imageSize pNtHeaders-OptionalHeader.SizeOfImage; ULONG_PTR preferredBase pNtHeaders-OptionalHeader.ImageBase; DWORD entryPointRva pNtHeaders-OptionalHeader.AddressOfEntryPoint; LPVOID pImageBase MapImageToMemory(pFileBuffer, pNtHeaders); if (pImageBase NULL) return NULL; if ((ULONG_PTR)pImageBase ! preferredBase) { if (!FixRelocations(pImageBase, pNtHeaders)) { VirtualFree(pImageBase, 0, MEM_RELEASE); return NULL; } } if (!ResolveImports(pImageBase, pNtHeaders)) { VirtualFree(pImageBase, 0, MEM_RELEASE); return NULL; } return pImageBase; }这个改造的关键是把文件缓冲区的生命周期和镜像的生命周期解耦。MapImageToMemory完成后文件缓冲区就可以释放了因为所有需要的数据已经拷贝到新分配的内存里。但要注意pNtHeaders指向的是文件缓冲区释放后不能再访问所以需要在释放前把后续需要的字段保存到局部变量。5.2 异步执行与线程管理直接调用入口点会阻塞当前线程如果目标 exe 有消息循环当前线程就被占住了。改造方法是创建一个新线程来执行入口点主线程继续做其他事情。// 线程参数结构体 typedef struct _EXEC_THREAD_PARAM { LPVOID pImageBase; DWORD entryPointRva; HANDLE hCompletionEvent; } EXEC_THREAD_PARAM; // 线程函数 DWORD WINAPI ExecThreadProc(LPVOID lpParam) { EXEC_THREAD_PARAM* pParam (EXEC_THREAD_PARAM*)lpParam; ULONG_PTR entryPoint (ULONG_PTR)pParam-pImageBase pParam-entryPointRva; typedef int (WINAPI* EntryPointFunc)(void); EntryPointFunc pEntry (EntryPointFunc)entryPoint; int result pEntry(); if (pParam-hCompletionEvent) { SetEvent(pParam-hCompletionEvent); } return result; } // 异步执行加载后的镜像 HANDLE ExecuteImageAsync(LPVOID pImageBase, DWORD entryPointRva) { EXEC_THREAD_PARAM* pParam (EXEC_THREAD_PARAM*)malloc(sizeof(EXEC_THREAD_PARAM)); pParam-pImageBase pImageBase; pParam-entryPointRva entryPointRva; pParam-hCompletionEvent CreateEvent(NULL, TRUE, FALSE, NULL); HANDLE hThread CreateThread(NULL, 0, ExecThreadProc, pParam, 0, NULL); if (hThread NULL) { CloseHandle(pParam-hCompletionEvent); free(pParam); return NULL; } return hThread; }这个改造让调用方可以选择同步或异步执行。异步模式下主线程可以通过WaitForSingleObject等待执行完成也可以完全不管。注意线程参数需要动态分配因为线程函数是异步执行的栈上的局部变量在函数返回后就失效了。5.3 验证加载结果用调试器确认每一步改造完成后怎么验证我一般会分三步走。第一步在MapImageToMemory返回后用调试器查看pImageBase处的内存确认 PE 头和节区数据正确。第二步在FixRelocations和ResolveImports返回后检查关键全局变量和 IAT 表项是否指向合理地址。第三步在入口点下断点单步执行几条指令确认没有立即崩溃。如果目标 exe 有符号文件PDB可以用 WinDbg 加载符号直接看函数名和源码行号。没有符号的话至少确认入口点处的指令是合法的通常是push rbp或sub rsp, xxx之类的函数序言。5.4 一个容易忽略的细节节区对齐与内存清零VirtualAlloc分配的内存默认是零但如果你用MEM_RESERVE之后又MEM_COMMIT了部分区域未提交的部分访问会触发异常。另外节区的VirtualSize可能大于SizeOfRawData多出来的部分应该保持为零。如果 exe 依赖这些零初始化数据比如 BSS 段拷贝时只拷贝SizeOfRawData就够了因为VirtualAlloc已经保证了零填充。但有一种情况需要注意如果节区的PointerToRawData为 0说明这个节区在文件中没有数据比如 .bss这时不应该执行memcpy否则会从文件头开始拷贝导致数据错乱。代码里加一个判断if (pSection[i].PointerToRawData ! 0 pSection[i].SizeOfRawData 0)。从那以后我每次做内存加载都会在MapImageToMemory之后强制走一遍内存 dump把加载后的镜像和磁盘文件按节区对比确认每个字节都对得上。这个习惯帮我省掉了至少三次深夜调试。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站