编写一个基本的 Windows 调试器






4.92/5 (79投票s)
了解如何编写自己的 Windows 调试器。
前导码
我们在用某种语言编程时,都使用过某种形式的调试器。你使用的调试器可能是 C++、C#、Java 或其他语言的。它可能是 WinDbg 这样的独立程序,也可能是 Visual Studio 这样的 IDE 中的一部分。但你是否曾好奇过调试器是如何工作的?
好吧,本文将揭示调试器工作背后的精彩之处。本文仅涵盖在 Windows 上编写调试器。请注意,我这里只关注**调试器**本身,而不涉及编译器、链接器或调试扩展。因此,我们将只调试可执行文件(如 WinDbg)。本文假设读者对多线程有基本了解(请阅读我关于多线程的文章)。
1. 如何调试程序?
两个步骤
- 使用
DEBUG_ONLY_THIS_PROCESS
或DEBUG_PROCESS
标志启动进程。 - 设置调试器的循环,以处理调试事件。
在我们继续之前,请记住
- 调试器 是用于调试其他进程(目标进程)的进程/程序。
- 被调试程序 是被调试器调试的进程。
- 一个被调试程序只能附加一个调试器。但是,一个调试器可以同时调试多个进程(在单独的线程中)。
- 只有创建/生成被调试程序的线程才能调试目标进程。因此,
CreateProcess
和调试器循环必须在同一个线程中。 - 当调试器线程终止时,被调试程序也将终止。然而,调试器进程可能仍然在运行。
- 当调试器的调试线程忙于处理调试事件时,被调试程序(目标进程)中的**所有**线程都会被挂起。稍后将对此进行更详细的讨论。
A. 使用调试标志启动进程
使用 CreateProcess
启动进程,将 DEBUG_ONLY_THIS_PROCESS
指定为第六个参数(dwCreationFlags
)。使用此标志,我们要求 Windows 操作系统就所有调试事件(包括进程创建/终止、线程创建/终止、运行时异常等)与*本线程*进行通信。下面将进行详细说明。请注意,本文将使用 DEBUG_ONLY_THIS_PROCESS
。它基本上意味着我们只想调试我们创建的进程,而不调试我们创建的进程可能创建的任何子进程。
STARTUPINFO si;
PROCESS_INFORMATION pi;
ZeroMemory( &si, sizeof(si) );
si.cb = sizeof(si);
ZeroMemory( &pi, sizeof(pi) );
CreateProcess ( ProcessNameToDebug, NULL, NULL, NULL, FALSE,
DEBUG_ONLY_THIS_PROCESS, NULL,NULL, &si, &pi );
在此语句之后,你会在任务管理器中看到该进程,但该进程尚未启动。新创建的进程处于挂起状态。不,我们不需要调用 ResumeThread
,而是需要编写调试器循环。
B. 调试器循环
调试器循环是调试器的核心区域!循环围绕着 WaitForDebugEvent
API 运行。该 API 接受两个参数:指向 DEBUG_EVENT
结构的指针和 DWORD
超时参数。对于超时,我们将简单地指定 INFINITE
。该 API 存在于*kernel32.dll* 中,因此我们无需链接任何库。
BOOL WaitForDebugEvent(DEBUG_EVENT* lpDebugEvent, DWORD dwMilliseconds);
DEBUG_EVENT
结构包含调试事件信息。它有四个成员:调试**事件代码**、**进程 ID**、**线程 ID** 和**事件信息**。一旦 WaitForDebugEvent
返回,我们就处理收到的调试事件,然后最终调用 ContinueDebugEvent
。这是一个最小化的调试器循环
DEBUG_EVENT debug_event = {0};
for(;;)
{
if (!WaitForDebugEvent(&debug_event, INFINITE))
return;
ProcessDebugEvent(&debug_event); // User-defined function, not API
ContinueDebugEvent(debug_event.dwProcessId,
debug_event.dwThreadId,
DBG_CONTINUE);
}
使用 ContinueDebugEvent
API,我们要求操作系统继续执行被调试程序。dwProcessId
和 dwThreadId
指定进程和线程。这些值与我们从 WaitForDebugEvent
收到的值相同。最后一个参数指定是否继续执行。此参数仅在收到异常事件时相关。稍后将对此进行讨论。在此之前,我们将仅使用 DBG_CONTINUE
(另一个可能的值是 DBG_EXCEPTION_NOT_HANDLED
)。
2. 处理调试事件
有九个主要的调试事件,以及异常事件类别下的 20 个不同的子事件。我将从最简单的开始讨论它们。这是 DEBUG_EVENT
结构
struct DEBUG_EVENT
{
DWORD dwDebugEventCode;
DWORD dwProcessId;
DWORD dwThreadId;
union {
EXCEPTION_DEBUG_INFO Exception;
CREATE_THREAD_DEBUG_INFO CreateThread;
CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
EXIT_THREAD_DEBUG_INFO ExitThread;
EXIT_PROCESS_DEBUG_INFO ExitProcess;
LOAD_DLL_DEBUG_INFO LoadDll;
UNLOAD_DLL_DEBUG_INFO UnloadDll;
OUTPUT_DEBUG_STRING_INFO DebugString;
RIP_INFO RipInfo;
} u;
};
WaitForDebugEvent
在成功返回时,会填充此结构中的值。dwDebugEventCode
指定发生了哪种调试事件。根据收到的事件代码,联合体**u
** 的一个成员包含事件信息,我们应该只使用相应的联合体成员。例如,如果调试事件代码是 OUTPUT_DEBUG_STRING_EVENT
,则 OUTPUT_DEBUG_STRING_INFO
成员将有效。
A. 处理 OUTPUT_DEBUG_STRING_EVENT
程序员通常使用 OutputDebugString
来生成调试文本,这些文本将显示在调试器的“输出”窗口中。根据你使用的语言/框架,你可能熟悉 TRACE
、ATLTRACE
宏。.NET 程序员可能使用 System.Diagnostics.Debug.Print/
System.Diagnostics.Trace.WriteLine 方法(或其他方法)。但所有这些方法都会调用
OutputDebugString
API,调试器将收到此事件(除非它被 DEBUG
符号未定义所掩盖!)。
当收到此事件时,我们将处理 DebugString
成员变量。OUTPUT_DEBUG_STRING_INFO
结构定义为
struct OUTPUT_DEBUG_STRING_INFO
{
LPSTR lpDebugStringData; // char*
WORD fUnicode;
WORD nDebugStringLength;
};
'nDebugStringLength
' 成员变量指定字符串的长度(包括终止的 null),以字符为单位(而不是字节)。'fUnicode
' 变量指定字符串是否为 Unicode(非零)或 ANSI(零)。这意味着,如果字符串是 ANSI,我们读取 'lpDebugStringData
' 中的 'nDebugStringLength
' 字节;否则,我们读取 (nDebugStringLength x 2)
字节。但请记住,'lpDebugStringData
' 指向的地址*不是*来自调试器内存地址空间。该地址与被调试程序内存相关。因此,我们需要*从被调试程序的进程内存中读取内容*。
要从另一个进程的内存中读取数据,我们使用 ReadProcessMemory
函数。它要求调用进程拥有适当的权限。由于调试器刚刚创建了进程,我们拥有该权限。以下是处理此调试事件的代码
case OUTPUT_DEBUG_STRING_EVENT:
{
CStringW strEventMessage; // Force Unicode
OUTPUT_DEBUG_STRING_INFO & DebugString = debug_event.u.DebugString;
WCHAR *msg=new WCHAR[DebugString.nDebugStringLength];
// Don't care if string is ANSI, and we allocate double...
ReadProcessMemory(pi.hProcess, // HANDLE to Debuggee
DebugString.lpDebugStringData, // Target process' valid pointer
msg, // Copy to this address space
DebugString.nDebugStringLength, NULL);
if ( DebugString.fUnicode )
strEventMessage = msg;
else
strEventMessage = (char*)msg; // char* to CStringW (Unicode) conversion.
delete []msg;
// Utilize strEventMessage
}
如果调试程序在调试器复制内存内容之前终止了怎么办?
嗯……在这种情况下,我想提醒你:当调试器正在处理调试事件时,被调试程序中的**所有**线程都会被挂起。此时进程无法以任何方式杀死自己。此外,其他任何方法也无法终止该进程(任务管理器、进程浏览器、Kill 工具...)。尝试从这些工具中杀死该进程确实会安排终止进程。因此,调试器将收到 EXIT_PROCESS_DEBUG_EVENT
作为下一个事件!
B. 处理 CREATE_PROCESS_DEBUG_EVENT
当进程(被调试程序)被生成时,会引发此事件。这将是调试器收到的第一个事件。对于此事件,DEBUG_EVENT
的相关成员将是 CreateProcessInfo
。这是 CREATE_PROCESS_DEBUG_INFO
的结构定义
struct CREATE_PROCESS_DEBUG_INFO
{
HANDLE hFile; // The handle to the physical file (.EXE)
HANDLE hProcess; // Handle to the process
HANDLE hThread; // Handle to the main/initial thread of process
LPVOID lpBaseOfImage; // base address of the executable image
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpThreadLocalBase;
LPTHREAD_START_ROUTINE lpStartAddress;
LPVOID lpImageName; // Pointer to first byte of image name (in Debuggee)
WORD fUnicode; // If image name is Unicode.
};
请注意,hProcess
和 hThread
可能与我们在 pi
(PROCESS_INFORMATION
)中收到的句柄值不同。然而,进程 ID 和线程 ID 将是相同的。Windows 为(同一资源)获得的每个句柄都与其他句柄不同,并且具有不同的目的。因此,调试器可以选择显示句柄或 ID。
hFile
和 lpImageName
都可以用来获取被调试进程的文件名。虽然我们已经知道进程的名称,因为我们只创建了被调试程序。但定位 EXE 或 DLL 的模块名称很重要,因为在处理 LOAD_DLL_DEBUG_EVENT
消息时,我们总是需要找到 DLL 的名称。
正如你在 MSDN 上所读到的,lpImageName
永远不会直接返回文件名,并且该名称将在目标进程中。此外,它在目标进程中可能也没有文件名(即,通过 ReadProcessMemory
)。此外,文件名可能不是完全限定的(正如我测试的那样)。因此,我们不会使用此方法。我们将从 hFile
成员中检索文件名。
如何通过句柄获取文件名
不幸的是,我们需要使用 MSDN 中描述的方法,该方法使用大约 10 个 API 调用来从句柄中获取文件名。我稍微修改了 GetFileNameFromHandle
函数。为简洁起见,此处未显示代码,它在本文附带的源代码文件中可用。无论如何,这是处理此事件的基本代码
case CREATE_PROCESS_DEBUG_EVENT:
{
CString strEventMessage =
GetFileNameFromHandle(debug_event.u.CreateProcessInfo.hFile);
// Use strEventMessage, and other members
// of CreateProcessInfo to intimate the user of this event.
}
你可能会注意到我没有涵盖该结构中的几个成员。我可能会在本篇文章的下一部分中涵盖所有这些内容。
C. 处理 LOAD_DLL_DEBUG_EVENT
此事件与 CREATE_PROCESS_DEBUG_EVENT
类似,正如你可能猜到的,当操作系统加载 DLL 时会引发此事件。每当加载 DLL 时,都会引发此事件,无论是隐式加载还是显式加载(当被调试程序调用 LoadLibrary
时)。此调试事件仅在系统首次将 DLL 附加到进程的虚拟地址空间时发生。对于此事件处理,我们使用联合体的 'LoadDll
' 成员。它属于 LOAD_DLL_DEBUG_INFO
类型
struct LOAD_DLL_DEBUG_INFO
{
HANDLE hFile; // Handle to the DLL physical file.
LPVOID lpBaseOfDll; // The DLL Actual load address in process.
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpImageName; // These two member are same as CREATE_PROCESS_DEBUG_INFO
WORD fUnicode;
};
为了检索文件名,我们将使用与我们在 CREATE_PROCESS_DEBUG_EVENT
中使用的相同的函数 GetFileNameFromHandle
。我将在描述 UNLOAD_DLL_DEBUG_EVENT
时列出处理此事件的代码,因为 UNLOAD_DLL_DEBUG_EVENT
没有直接信息可用于查找 DLL 文件名。
D. 处理 CREATE_THREAD_DEBUG_EVENT
每当在被调试程序中创建新线程时,都会生成此调试事件。与 CREATE_PROCESS_DEBUG_EVENT
一样,此事件在线程实际开始执行之前引发。为了获取有关此事件的信息,我们使用 'CreateThread
' 联合体成员。此变量属于 CREATE_THREAD_DEBUG_INFO
类型
struct CREATE_THREAD_DEBUG_INFO
{
// Handle to the newly created thread in debuggee
HANDLE hThread;
LPVOID lpThreadLocalBase;
// pointer to the starting address of the thread
LPTHREAD_START_ROUTINE lpStartAddress;
};
新到达线程的线程 ID 可在 DEBUG_EVENT::dwThreadId
中获得。使用此成员通知用户非常简单
case CREATE_THREAD_DEBUG_EVENT:
{
CString strEventMessage;
strEventMessage.Format(L"Thread 0x%x (Id: %d) created at: 0x%x",
debug_event.u.CreateThread.hThread,
debug_event.dwThreadId,
debug_event.u.CreateThread.lpStartAddress);
// Thread 0xc (Id: 7920) created at: 0x77b15e58
}
'lpStartAddress
' 与被调试程序相关,而不是与调试器相关;我们仅为完整性而显示它。请记住,此事件*不会*为进程的主/初始线程接收。它仅为被调试程序中的后续线程创建而接收。
E. 处理 EXIT_THREAD_DEBUG_EVENT
一旦线程返回,系统就会提供返回值,从而引发此事件。DEBUG_EVENT
的 'dwThreadId
' 成员指定了哪个线程已退出。为了获取我们在 CREATE_THREAD_DEBUG_EVENT
中收到的线程句柄和其他信息,我们需要将信息存储在某个映射中。此事件有一个名为 'ExitThread
' 的相关成员,它属于 EXIT_THREAD_DEBUG_INFO
类型
struct EXIT_THREAD_DEBUG_INFO
{
DWORD dwExitCode; // The thread exit code of DEBUG_EVENT::dwThreadId
};
这是事件处理程序代码
case EXIT_THREAD_DEBUG_EVENT:
{
CString strEventMessage;
strEventMessage.Format( _T("The thread %d exited with code: %d"),
debug_event.dwThreadId,
debug_event.u.ExitThread.dwExitCode); // The thread 2760 exited with code: 0
}
F. 处理 UNLOAD_DLL_DEBUG_EVENT
当然,当 DLL 从被调试程序的内存中卸载时,会发生此事件。但等等!它*仅*针对 FreeLibrary
调用生成,而不是当系统卸载 DLL 时。被调试程序可能多次调用 LoadLibrary
,因此只有最后一次调用 FreeLibrary
才会引发此事件。这意味着,隐式加载的 DLL 在进程退出时卸载时不会收到此事件。(你可以在你喜欢的调试器中验证此断言!)
对于此事件,你使用联合体的 'UnloadDll
' 成员,它属于 UNLOAD_DLL_DEBUG_INFO
类型
struct UNLOAD_DLL_DEBUG_INFO
{
LPVOID lpBaseOfDll;
};
正如你所见,只有 DLL 的基地址(一个简单的指针)可供我们处理此事件。这就是为什么我推迟给出 LOAD_DLL_DEBUG_EVENT
代码的原因。在 DLL 加载事件中,我们也获取了 'lpBaseOfDll
'。我们可以使用映射(或你喜欢的其他数据结构)来存储*DLL 名称*与*DLL 基地址*的对应关系。在处理 UNLOAD_DLL_DEBUG_EVENT
时,将收到相同的基地址。
应注意,并非所有 DLL 加载事件都会收到 DLL 卸载事件;但我们仍必须将所有 DLL 名称存储到映射中,因为 LOAD_DLL_DEBUG_EVENT
没有提供关于*如何*加载 DLL 的信息。
以下是处理这两个事件的代码
std::map < LPVOID, CString > DllNameMap;
...
case LOAD_DLL_DEBUG_EVENT:
{
strEventMessage = GetFileNameFromHandle(debug_event.u.LoadDll.hFile);
// Storing the DLL name into map. Map's key is the Base-address
DllNameMap.insert(
std::make_pair( debug_event.u.LoadDll.lpBaseOfDll, strEventMessage) );
strEventMessage.AppendFormat(L" - Loaded at %x", debug_event.u.LoadDll.lpBaseOfDll);
}
break;
...
case UNLOAD_DLL_DEBUG_EVENT:
{
strEventMessage.Format(L"DLL '%s' unloaded.",
DllNameMap[debug_event.u.UnloadDll.lpBaseOfDll] ); // Get DLL name from map.
}
break;
G. 处理 EXIT_PROCESS_DEBUG_EVENT
这是最简单的调试事件之一,正如你可以评估的那样,当进程退出时会发生。无论进程如何退出,此事件都会发生——正常退出、外部终止(任务管理器等),或被调试程序自身故障导致崩溃。
我们使用 'ExitProcess
' 成员,它属于 EXIT_PROCESS_DEBUG_INFO
类型
struct EXIT_PROCESS_DEBUG_INFO
{
DWORD dwExitCode;
};
一旦发生此事件,我们还将结束调试器循环并终止调试线程。为此,我们可以使用一个变量来控制循环(第一页中显示的“for”循环),并设置其值以指示循环终止。请下载附件文件以查看完整代码。
bool bContinueDebugging=true;
...
case EXIT_PROCESS_DEBUG_EVENT:
{
strEventMessage.Format(L"Process exited with code: 0x%x",
debug_event.u.ExitProcess.dwExitCode);
bContinueDebugging=false;
}
break;
H. 处理 EXCEPTION_DEBUG_EVENT
这是所有调试事件中最重要(prodigious)的事件!来自 MSDN
当调试进程中发生异常时,会生成此事件。可能的异常包括尝试访问无权访问的内存、执行断点指令、尝试除以零,或结构化异常处理中注意到的任何其他异常。
DEBUG_EVENT
结构包含一个EXCEPTION_DEBUG_INFO
结构。该结构描述了导致调试事件的异常。
此调试事件需要单独的文章来完整(或部分)讨论!因此,我将只讨论一种类型的异常事件,并对此事件本身进行介绍。
成员变量 'Exception
' 包含有关刚刚发生的异常的信息。它属于 EXCEPTION_DEBUG_INFO
类型
struct EXCEPTION_DEBUG_INFO
{
EXCEPTION_RECORD ExceptionRecord;
DWORD dwFirstChance;
};
'ExceptionRecord
' 成员的此结构包含有关异常的详细信息。它属于 EXCEPTION_RECORD
类型
struct EXCEPTION_RECORD
{
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; // 15
};
详细信息被放入此子结构中,因为异常可能出现嵌套,并且会以链表的方式相互链接。目前讨论嵌套异常已超出范围。
在我们深入研究 EXCEPTION_RECORD
之前,讨论 EXCEPTION_DEBUG_INFO::dwFirstChance
很重要。
异常是否提供机会?
不完全是!当进程被调试时,调试器总是在被调试程序收到异常之前收到它。你在调试 Visual C++ 模块时可能看到过“在 SomeModule 中发生首次异常,地址为 0x00412882:...”。这被称为*首次异常*。相同的异常可能会也可能不会伴随第二次异常。
当被调试程序收到异常时,它被称为*二次异常*。被调试程序可以处理该异常,或者可能直接崩溃。这些类型的异常不是 C++ 异常,而是 Windows 的 SEH(结构化异常处理)机制。我将在本文的下一部分进一步讨论。
调试器首先收到异常(首次异常),以便在将其交给被调试程序之前对其进行处理。断点异常是一种与调试器相关的异常,而不是与被调试程序相关的。某些库还会生成首次异常以辅助调试器和调试过程。
关于 ContinueDebugEvent 的说明
此函数(dwContinueStatus
)的第三个参数仅在收到异常事件后才相关。对于我们讨论的非异常事件,系统会忽略传递给此函数的值。
异常事件处理后,应使用以下值调用 **ContinueDebugEvent
**
- 如果调试器已成功处理异常事件,则为
DBG_CONTINUE
。被调试程序不需要采取任何操作,并且可以正常运行。 - 如果被调试程序未处理/解决此事件,则为
DBG_EXCEPTION_NOT_HANDLED
。调试器可以仅记录此事件、通知调试器用户或执行其他操作。
请注意,对于不正确的调试事件返回 DBG_CONTINUE
会在调试器中引发相同的事件,并且相同的事件会无限期地到来。由于我们正处于编写调试器的早期阶段,我们应该谨慎行事,并返回 EXCEPTION_NOT_HANDLED
(放弃标志!)。本文的例外是断点事件,我将在下面讨论。
异常代码
EXCEPTION_RECORD::ExceptionCode
变量保存收到的异常代码,并且可以具有以下代码之一(忽略嵌套异常!):
EXCEPTION_ACCESS_VIOLATION
EXCEPTION_ARRAY_BOUNDS_EXCEEDED
EXCEPTION_BREAKPOINT
EXCEPTION_DATATYPE_MISALIGNMENT
EXCEPTION_FLT_DENORMAL_OPERAND
EXCEPTION_FLT_DIVIDE_BY_ZERO
EXCEPTION_FLT_INEXACT_RESULT
EXCEPTION_FLT_INVALID_OPERATION
EXCEPTION_FLT_OVERFLOW
EXCEPTION_FLT_STACK_CHECK
EXCEPTION_FLT_UNDERFLOW
EXCEPTION_ILLEGAL_INSTRUCTION
EXCEPTION_IN_PAGE_ERROR
EXCEPTION_INT_DIVIDE_BY_ZERO
EXCEPTION_INT_OVERFLOW
EXCEPTION_INVALID_DISPOSITION
EXCEPTION_NONCONTINUABLE_EXCEPTION
EXCEPTION_PRIV_INSTRUCTION
EXCEPTION_SINGLE_STEP
EXCEPTION_STACK_OVERFLOW
放松!我不会讨论所有这些,但只讨论一个:**EXCEPTION_BREAKPOINT
**。好的,这是代码
case EXCEPTION_DEBUG_EVENT:
{
EXCEPTION_DEBUG_INFO& exception = debug_event.u.Exception;
switch( exception.ExceptionRecord.ExceptionCode)
{
case STATUS_BREAKPOINT: // Same value as EXCEPTION_BREAKPOINT
strEventMessage= "Break point";
break;
default:
if(exception.dwFirstChance == 1)
{
strEventMessage.Format(L"First chance exception at %x, exception-code: 0x%08x",
exception.ExceptionRecord.ExceptionAddress,
exception.ExceptionRecord.ExceptionCode);
}
// else
// { Let the OS handle }
// There are cases where OS ignores the dwContinueStatus,
// and executes the process in its own way.
// For first chance exceptions, this parameter is not-important
// but still we are saying that we have NOT handled this event.
// Changing this to DBG_CONTINUE (for 1st chance exception also),
// may cause same debugging event to occur continously.
// In short, this Debugger does not handle debug exception events
// efficiently, and let's keep it simple for a while!
dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;
}
break;
}
你可能知道什么是断点。从标准的调试器角度来看,断点可以通过 DebugBreak
API、{int 3}
汇编指令,或者 .NET Framework 中的 System.Diagnostics.Debugger.Break
来实现。当这些操作在正在运行的进程中发生时,调试器将收到 Debug-exception 代码 STATUS_BREAKPOINT
(与 EXCEPTION_BREAKPOINT
相同)。调试器通常使用此事件来中断正在运行的进程,并可能显示事件发生源文件的位置。但在我们基本的调试器中,我们只会向用户显示此事件。不显示源代码或指令位置。我们将在本文的下一部分讨论显示源代码。
从未被调试的进程中引发断点会导致应用程序崩溃,或者可能显示 JIT 对话框。这就是我使用
if ( !IsDebuggerPresent() )
AfxMessageBox(L"No debugger is attached currently.");
else
DebugBreak();
作为对这个最简单的调试异常事件的最后说明:EXCEPTION_DEBUG_EVENT
将由内核本身首次引发,并且总是会到达。像 Visual Studio 这样的调试器会忽略这个第一次断点异常,但像 WinDbg 这样的调试器总会向你显示这个事件。
收尾...
使用任何进程进行调试,或使用附加的被调试程序 DebugMe
此处附加的二进制文件(EXE)使用 **Visual Studio 2005 Service Pack 1** 编译。你可能没有相同版本的 VC++ 运行时库。你可以从 Microsoft.com 下载它们,或从你的 IDE 中重新构建项目。
后续