Preamble
前言
在我们使用某些语言时都用过某些调试器。你使用过的调试器可能用C++、C#、Java或者其它语言编写的。它可能是独立的,像WinDbg,或者内嵌在一个像Visual Studio 的IDE中。然而你是否会对“调试器如何工作”的感到好奇?
好,这篇文档展示了调试器如何工作的隐藏亮点。这篇文章仅包含编写Windows上的调试器。请注意,我在这仅关注“调试器”,而不是编译器、链接器或调试器的扩展。因此,我们仅调试可执行的(像WinDbg)。这篇文章假设读者对多线程有基本的理解(阅读我的关于多线程的文章)
1. How to Debug a Program?
1. 怎样调试一个程序?
两步:
1. 使用DEBUG_ONLY_THIS_PROCESS
或DEBUG_PROCESS
标志启动进程。
2. 设置调试器的循环,那将处理调试事件。
在进一步阅读之前,请记住:
1. 调试器是调试其它进程(目标进程)的进程/程序。
2. 被调试者是被调试器调试的进程。
3. 一个被调试者仅可以与一个调试器关联。然而,一个调试器可以调试多个进程(在不同线程中)。
4. 仅仅创建/产生被调试者的线程可以调试目标进程。因此,CreateProcess
和调试循环必须在同一个线程中。
5. 当调试线程终止时,被调试者也终止。然而调试进程可能保持运行。
6. 当调试器的调试线程正在忙于处理一个调试事件时,在被调用者(目标进程)所有的线程保持挂起状态。后面会有更多讨论。
A. Starting the process with the debugging flag
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. The debugger loop
B. 调试循环
调试循环式调试器的中心区域!这个循环围绕着WaitForDebugEvent
API运行。这个API需要两个参数:一个指向DEBUG_EVENT
结构体的指针和一个DWORD类型的超时参数。对于超时,我们简单的指定无限(
INFINITE
)
。这个
API
在
kernel32.dll中,因此我们不需要连接任何库了。
DEBUG_EVENT
结构体包括调试事件信息。它有4个成员:调试事件代码,进程ID,线程ID和事件信息。只要WaitForDebugEvent
一返回,我们就处理接收到的调试事件,最后调用ContinueDebugEvent
。这有一个最小的调试循环:
DEBUG_EVENT debug_event = {0};
for(;;)
{
if (!WaitForDebugEvent(&debug_event, INFINITE))
return;
ProcessDebugEvent(&debug_event); //用户定义的函数,不是API
ContinueDebugEvent(debug_event.dwProcessId,
debug_event.dwThreadId,
DBG_CONTINUE);
}
使用ContinueDebugEvent
API,我们要求操作系统继续运行被调试者。dwProcessId
和dwThreadId
分别指定进程和线程。这些值和我们从WaitForDebugEvent
得到的相同。最后一个参数指定是否需要继续运行。这个参数仅用于判断是否有异常事件。我们将在后面讨论。在那之前,我们仅会利用DBG_CONTINUE
(另一个可能值是DBG_EXCEPTION_NOT_HANDLED
)。
2. Handling debugging events
2. 处理调试事件
这有9种不同的主要调试事件,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. Processing OUTPUT_DEBUG_STRING_EVENT
A. 处理OUTPUT_DEBUG_STRING_EVENT
程序员一般使用OutputDebugString
产生输出到调试器’输出’窗口的调试文本。根据你使用的语言/框架,你可能对TRACE,ATLTRACE
宏很熟悉。一个.NET程序员可能使用System.Diagnostics.
Debug.Print/System.
Trace.WriteLine方法
(或其它方法)。但是对于所有这些方法,
OutputDebugString API
都会被叫用,调试器也会接收到这个事件(除非它被
DEBUG
符号取消定义!)。
当这个事件到达时,我们工作在(work on)DebugString
成员变量。OUTPUT_DEBUG_STRING_INFO
结构体是这样定义的:
struct OUTPUT_DEBUG_STRING_INFO
{
LPSTR lpDebugStringData; // char*
WORD fUnicode;
WORD nDebugStringLength;
};
成员变量'nDebugStringLength
'指定了字符串的长度,包括终止空字符,以字符编码(不是字节)。变量'fUnicode
'指定了字符串是否Unicode(非零)或ANSI(零)。这意味着,如果字符串是ANSI编码,我们从'lpDebugStringData
'读取'nDebugStringLength
'个字节;否则,我们读取(nDebugStringLength
个字节。但是记住,'
x 2)lpDebugStringData
'指向的地址不是调试器内存的地址空间。这个地址是与被调试者内存相关的。因此,我们需要从被调试者进程内存中读取内容。
要从另一个进程内存中读取数据,我们使用ReadProcessMemory
函数。那要求调用进程需要有适当的权限。因为调试器仅创建了那个进程,我们当然有这个权利。这是处理这个调试事件的代码:
case OUTPUT_DEBUG_STRING_EVENT:
{
CStringW strEventMessage; // 强制 Unicode
OUTPUT_DEBUG_STRING_INFO & DebugString = debug_event.u.DebugString;
WCHAR *msg=new WCHAR[DebugString.nDebugStringLength];
// 不用关心字符串是不是ANSI编码,我们分配双倍空间...
ReadProcessMemory(pi.hProcess, // 被调试者的HANDLE
DebugString.lpDebugStringData, // 目标进程的正确指针
msg, // 复制这个地址空间
DebugString.nDebugStringLength, NULL);
if ( DebugString.fUnicode )
strEventMessage = msg;
else
strEventMessage = (char*)msg; // char*到CStringW(Unicode)的转换.
delete []msg;
// 使用 strEventMessage
}
What if the debuggee terminates before the debugger copies the memory contents?
如果被调试者在调试器复制内存内容时终止了?
好……在这种情况下,我想提醒你:当调试器处理一个调试事件时,挂起被调试者的所有线程。这个时候那个进程就无法杀死自己。并且,没有其它的方法可以终止那个进程(任务管理器,进程资源管理器,杀死效用(Kill utility)……)。然而,这些工具的杀死那个进程的企图将会按照调度终止进程。因此,调试器将会接收到的下一个事件是EXIT_PROCESS_DEBUG_EVENT
!
B. Processing CREATE_PROCESS_DEBUG_EVENT
B. 处理CREATE_PROCESS_DEBUG_EVENT
当那个进程(被调试者)产生时会引发这个事件。这是调试器(sedebugger)接收到的第一个事件。对于这个事件,CreateProcessInfo
是DEBUG_EVENT
的相关成员。这是CREATE_PROCESS_DEBUG_INFO
结构体的定义:
struct CREATE_PROCESS_DEBUG_INFO
{
HANDLE hFile; // 物理文件的句柄(.EXE)
HANDLE hProcess; //进程的句柄
HANDLE hThread; // 进程的main/初始化线程的句柄
LPVOID lpBaseOfImage; // 可执行映像的基址
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpThreadLocalBase;
LPTHREAD_START_ROUTINE lpStartAddress;
LPVOID lpImageName; // 指向映像名称第一个字节的指针(在被调试者中)
WORD fUnicode; // 映像名称是否Unicode编码.
};
请注意我们接收到的pi
(PROCESS_INFORMATION
)中的hProcess
和 hThread
可能不是一个句柄值。然而进程ID和线程ID可能是相同的。你获取的每个窗口(对于同一个资源)句柄和其它的句柄都是不同的,也有不同的目的。因此,调试器可能选择显示句柄或者ID。
通过hFile
和
lpImageName
都能获取正在被调试进程的文件名称。尽管我们已经知道了进程的名称是什么,因为我们仅仅创建了被调试者。但是定位的EXE或DLL模块名称是重要的,因为当处理LOAD_DLL_DEBUG_EVENT
消息时我们常常需要找到DLL的名称。
和你在MSDN上读到的一样,lpImageName
从不会直接返回文件名称,而且这个名称会在目标进程中。更进步的说,在目标进程中可能没有文件名称(例如,通过ReadProcessMemory
)。还有,文件名称可能不是完全符合要求(就像我曾经测试的)。因此,我们不会使用这个方法。我们将会从hFile
成员获取文件名称。
How to get the name of the file by HANDLE
怎样通过HANDLE获取文件名称
不幸的是,我们需要使用MSDN描述的方法,使用将近10个API调用来从句柄获取文件名称。我稍微修改了GetFileNameFromHandle
函数。为了简洁,这里没有显示代码,可以从和这篇文章关联的源代码文件中获取。还有,这时处理这个事件的基本代码:
{
CString strEventMessage =
GetFileNameFromHandle(debug_event.u.CreateProcessInfo.hFile);
// 使用 strEventMessage和CreateProcessInfo的其它成员
// 来暗示这个事件的用户.
}
你可能已经注意到了我没有讨论这个结构体的一些成员。我可能在这篇文章的下一个部分讨论它们的全部。
C. Processing LOAD_DLL_DEBUG_EVENT
C.处理LOAD_DLL_DEBUG_EVENT
这个事件和CREATE_PROCESS_DEBUG_EVENT
很相似,就像你能猜到的,这个事件会在
OS
载入了一个
DLL
时引发。无论什么时候载入了一个
DLL
这个都会引发这个事件,不论是隐式的还是明确的(当被调试者调用
LoadLibrary
时)。这个调试事件仅在系统第一次关联一个
DLL
到一个进程的虚拟地址空间发生。对于这个事件的处理,我们使用联合体的
'LoadDll
'成员。它的类型是LOAD_DLL_DEBUG_INFO
:
struct LOAD_DLL_DEBUG_INFO
{
HANDLE hFile; //DLL物理文件的句柄.
LPVOID lpBaseOfDll; // 进程中DLL实际导入地址.
DWORD dwDebugInfoFileOffset;
DWORD nDebugInfoSize;
LPVOID lpImageName; // 这两个成员和CREATE_PROCESS_DEBUG_INFO一样
WORD fUnicode;
};
想要获取这个文件名称,我们使用和CREATE_PROCESS_DEBUG_EVENT
事件中使用的
相同的函数GetFileNameFromHandle
。当我描述UNLOAD_DLL_DEBUG_EVENT
时我将会列出处理这个事件的代码,因为UNLOAD_DLL_DEBUG_EVENT
没有任何直接的可用信息来查找DLL文件的名称。
D. Processing CREATE_THREAD_DEBUG_EVENT
D.处理CREATE_THREAD_DEBUG_EVENT
无论何时被调试者创建了一个新的线程是这个调试事件就会产生。像CREATE_PROCESS_DEBUG_EVENT
,这个事件是在一个线程实际开始运行前触发的。我们使用联合体成员来获取关于这个事件的信息。这个变量的类型是CREATE_THREAD_DEBUG_INFO
:
struct CREATE_THREAD_DEBUG_INFO
{
//被调试者创建的新线程的句柄
HANDLE hThread;
LPVOID lpThreadLocalBase;
// 指向线程的起始地址的指针
LPTHREAD_START_ROUTINE lpStartAddress;
};
在DEBUG_EVENT::dwThreadId
中可以得到新到来的线程的线程ID。使用这个成员暗示用户直截了当:
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);
// 线程0xc(Id:7920)创建在: 0x77b15e58
}
'lpStartAddress
'和被调试者相关而不是调试器;我们只是为了完整的显示它。记住这个事件不是在进程的主/初始化线程中接收到的。仅在被调试者创建子线程时会接收到。
E. Processing EXIT_THREAD_DEBUG_EVENT
E. 处理EXIT_THREAD_DEBUG_EVENT
线程一返回就会引发这个事件,系统可以得到返回代码。DEBUG_EVENT
的成员
'dwThreadId
'指定了哪个线程退出了。要获得CREATE_THREAD_DEBUG_EVENT
事件中我们接收到的线程句柄和其它信息,我们需要在一些map中存储信息。这个事件和名称为'ExitThread
'的成员关联,类型是EXIT_THREAD_DEBUG_INFO
:
struct EXIT_THREAD_DEBUG_INFO
{
DWORD dwExitCode; // DEBUG_EVENT::dwThreadId的线程退出代码
};
这是事件处理代码:
case EXIT_THREAD_DEBUG_EVENT:
{
CString strEventMessage;
strEventMessage.Format( _T("%d 线程退出,代码: %d"),
debug_event.dwThreadId,
debug_event.u.ExitThread.dwExitCode); //2760线程退出,代码:0
}
F. Processing UNLOAD_DLL_DEBUG_EVENT
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
'。我们可以使用map(或你喜欢的其它的数据结构)来存储对应DLL基址的DLL名称。在处理UNLOAD_DLL_DEBUG_EVENT
时会接收到相同的基址。
应该注意到并不是所有的DLL装载事件都会获取DLL卸载事件;还有,我们还得把所有DLL名称保存到map中,因为LOAD_DLL_DEBUG_EVENT
不给我们提供DLL是怎么装载的信息。
这是处理这两个事件的代码:
std::map < LPVOID, CString > DllNameMap;
...
case LOAD_DLL_DEBUG_EVENT:
{
strEventMessage = GetFileNameFromHandle(debug_event.u.LoadDll.hFile);
// 将DLL名称存储到map中。Map的键值是基址。
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] ); // 从map中获取DLL名称
}
break;
G. Processing EXIT_PROCESS_DEBUG_EVENT
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"进程退出,代码:0x%x",
debug_event.u.ExitProcess.dwExitCode);
bContinueDebugging=false;
}
break;
H. Processing EXCEPTION_DEBUG_EVENT
H. 处理EXCEPTION_DEBUG_EVENT
在所有调试事件中这是一个很大的事件!摘自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
是很重要的。