现在的位置: 首页 > 综合 > 正文

关于用户层调试器编写。

2013年08月16日 ⁄ 综合 ⁄ 共 10212字 ⁄ 字号 评论关闭

Preamble

前言

在我们使用某些语言时都用过某些调试器。你使用过的调试器可能用C++、C#、Java或者其它语言编写的。它可能是独立的,像WinDbg,或者内嵌在一个像Visual Studio 的IDE中。然而你是否会对“调试器如何工作”的感到好奇?

好,这篇文档展示了调试器如何工作的隐藏亮点。这篇文章仅包含编写Windows上的调试器。请注意,我在这仅关注“调试器”,而不是编译器、链接器或调试器的扩展。因此,我们仅调试可执行的(像WinDbg)。这篇文章假设读者对多线程有基本的理解(阅读我的关于多线程的文章)

1. How to Debug a Program?

1. 怎样调试一个程序?

两步:

1. 使用DEBUG_ONLY_THIS_PROCESSDEBUG_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这个APIkernel32.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,我们要求操作系统继续运行被调试者。dwProcessIddwThreadId分别指定进程和线程。这些值和我们从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_EVENTOUTPUT_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)接收到的第一个事件。对于这个事件,CreateProcessInfoDEBUG_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。

通过hFilelpImageName都能获取正在被调试进程的文件名称。尽管我们已经知道了进程的名称是什么,因为我们仅仅创建了被调试者。但是定位的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是很重要的。

抱歉!评论已关闭.