• [Win32]一个调试器的实现(五)调试符号


    一个调试器应该可以跟踪被调试程序执行到了什么地方,显示下一条将要执行的语句,显示各个变量的值,设置断点,进行单步执行等等,这些功能都需要一个基础设施的支持,那就是调试符号。

    什么是调试符号

    我们知道,在exedll等可执行文件中保存的数据大部分都是二进制指令,CPU直接读取这些指令并执行。那么调试器是如何知道每条指令对应哪个源文件的哪一行代码呢?它又是如何知道每个变量和函数的名称,并显示变量的值呢?很显然,可执行文件的二进制数据中不可能包含这么多信息,这一切都是由调试符号来支持的。

    所谓符号,简单来说就是源代码中每个对象的名称。例如变量、函数、类型等,它们都有一个名称,以及其它的相关信息:变量有类型、地址等信息;函数有返回值类型、参数类型、地址等信息;类型有长度等信息。编译器在编译每个源文件的时候都会收集该源文件中的符号的信息,在生成目标文件的时候将这些信息保存到符号表中。链接器使用符号表中的信息将各个目标文件链接成可执行文件,同时将多个符号表整合成一个文件,这个文件就是用于调试的符号文件,它既可以嵌入可执行文件中,也可以独立存在。

    符号文件中包含的信息可多可少,这样可以避免泄露程序的信息。调试版程序的符号文件包含了所有的调试信息,而发行版程序的符号文件只包含非常少的调试信息,甚至没有符号文件。

    符号文件有多种不同的格式,不同的编译器可能使用不同的格式。目前Visual Studio默认使用的是PDB格式,生成项目之后,在Debug或者Release文件夹下都可以找到与生成的文件同名的PDB文件。本文以及接下来的文章中,均使用PDB格式的符号文件来进行调试。

    使用调试符号

    Windows提供了两种方法让我们可以访问调试符号,分别是DbgHelpDebug Help Library)和DIADebug Interface Access)。DIA是基于COM的,对于不熟悉COM的人使用起来会比较麻烦;而使用DbgHelp就像使用普通的Windows API那样,比较容易。本文以及接下来的文章中,使用的都是DbgHelp

    使用DbgHelp的程序需要加载DbgHelp.dll这个动态链接库,Windows自带这个文件,位于C:\Windows\System32。但是Windows自带的通常是较低版本的文件,所以最好是获取一个最新版本的,将其与程序的可执行文件放在同一个目录中,这样既可以使用最新的DbgHelp,又不需要改动系统文件。

    获取最新DbgHelp.dll的一个方法是下载Windows Debugging Tools,地址为http://msdn.microsoft.com/en-us/windows/hardware/gg463009.aspx。不过这个工具包很大,为了这一个小小的文件可能要下载很长时间。其实在Visual Studio 2010中已包含了最新版本的DbgHelp(至少在写作本文的时候是如此),路径是C:\Program Files\Microsoft Visual Studio 10.0\Common7\IDE\dbghelp.dll。(假设Visual Studio 2010安装在C:\Program Files

    为了在程序中使用DbgHelp,你需要先完成以下的事情:

    打开项目属性对话框,定位到“配置属性”-“链接器”-“输入”,在右边的“附加依赖项”中添加dbghelp.lib

    有一点需要注意,DbgHelp使用DBGHELP_TRANSLATE_TCHAR这个预定义标记来决定是否使用Unicode字符串,而不是UNICODE标记。所以,如果你的程序使用Unicode字符串,那就定位到“配置属性”-C/C++-“预处理器”,在右边的“预处理器定义”中添加DBGHELP_TRANSLATE_TCHAR

    最后,在需要使用DbgHelp的源文件中,包含Windows.hDbgHelp.h头文件即可。(Windows.h需要包含在DbgHelp.h的前面)

    加载调试符号

    一个进程会有多个模块,每个模块都有它自己的符号文件,有关符号文件的信息保存在模块的可执行文件中。DbgHelp通过符号处理器(Symbol Handler)来处理模块的符号文件。符号处理器位于调试器进程中,每个被调试的进程对应一个符号处理器。通常,调试器在被调试进程启动的时候创建符号处理器,在被调试进程结束的时候清理相应符号处理器占用的资源。

    创建一个符号处理器使用SymInitialize函数,该函数声明如下:

    1 BOOL WINAPI SymInitialize(
    2     HANDLE hProcess,
    3     PCTSTR UserSearchPath,
    4     fInvadeProcess
    5 );

     第一个参数是被调试进程的句柄,它是符号管理器的标识符,其它的DbgHelp函数都需要这样一个参数值指明使用哪个符号管理器。实际上这个参数不一定是句柄:当fInvadeProcess参数为TRUE时,它必须是一个有效的进程句柄;当fInvadeProcessFALSE时,它可以是任意一个唯一的数值。

    fInvadeProcess的作用是指示是否加载进程所有模块的调试符号,如果该参数为FALSE,那么SymInitialize只是创建一个符号处理器,不加载任何模块的调试符号,此时需要我们自己调用SymLoadModule64函数来加载模块;如果为TRUESymInitialize会遍历进程的所有模块,并加载其调试符号,所以在这种情况下hProcess必须是一个有效的进程句柄。

    fInvadeProcessTRUE时,第二个参数UserSearchPath指示SymInitialize函数去哪里寻找符号文件。使用PDB符号文件的可执行文件中已包含有符号文件的绝对路径,如果符号文件不存在,SymInitialize就会使用UserSearchPath指定的路径去寻找符号文件。该参数可指定多个路径,以分号(;)分割。如果该参数为NULL,那么SymInitialize会按照以下的顺序寻找符号文件:

    调试器进程的工作目录;

    _NT_SYMBOL_PATH环境变量指定的路径;

    _NT_ALTERNATE_SYMBOL_PATH环境变量指定的路径。

    如果在以上路径中仍然找不到符号文件,SymInitialize并不会返回FALSE,而是返回TRUE。也就是说,它成功创建了符号处理器,并且加载了模块的信息,但是没有加载调试符号(关于如何判断某个模块是否加载了调试符号,下文会有讲解)。实际上,SymInitialize几乎不会返回FALSE,然而在某种情况下它会这么做,下面会有关于这方面的说明。

    根据对SymInitialize的描述,有两种方法可以加载调试符号。第一种方法是在调用SymInitialize的时候第三个参数传入TRUE,由它负责加载每个模块的调试符号。这种方法的好处是方便,但是有一个前提:被调试进程必须初始化完毕。我曾经尝试在处理CREATE_PROCESS_DEBUG_EVENT事件的时候使用这种方法加载调试符号,但SymInitialize总是返回FALSEGetLastError返回-1。这是因为在处理CREATE_PROCESS_DEBUG_EVENT事件时,被调试进程需要的模块还未加载完成,处于一个不完整的状态。所以,应该等到被调试进程初始化之后才使用这种方法。由于每个进程在初始化完毕之后都会引发一个断点异常,所以加载调试符号的最好的时机就是在处理这个初始断点的时候。关于初始断点的内容在讲解断点的时候会提及。

    第二种方法是在调用SymInitialize的时候第三个参数传入FALSE,然后对每个模块调用SymLoadModule64函数加载调试符号。我们可以在处理CREATE_PROCESS_DEBUG_EVENTLOAD_DLL_DEBUG_EVENT事件时分别加载exe文件和dll文件的调试符号。SymLoadModule64函数的声明如下:

    1 DWORD64 WINAPI SymLoadModule64(
    2     HANDLE hProcess,
    3     HANDLE hFile,
    4     PCSTR ImageName,
    5     PCSTR ModuleName,
    6     DWORD64 BaseOfDll,
    7     DWORD SizeOfDll
    8 );

    第一个参数是符号处理器的标识符,也就是在调用SymInitialize时第一个参数的值。第二个参数是模块文件的句柄,该函数通过这个文件句柄来获取有关符号文件的信息。你可能记得在CREATE_PROCESS_DEBUG_INFOLOAD_DLL_DEBUG_INFO结构体中都有一个hFile的字段,这个字段刚好可以用在SymLoadModule64函数上。

    第三个参数ImageName用于指定模块文件的路径和名称,当第二个参数为NULL时,SymLoadModule64会通过这里指定的路径和名称去寻找模块文件。一般情况下都不会使用这个参数,因为我们可以使用更可靠的hFile参数。

    第四个参数ModuleName为该模块赋予一个名称,在使用其它DbgHelp函数的时候可以通过这个名称来引用模块。如果该参数为NULLSymLoadModule64会使用符号文件的文件名作为模块名称。

    第五个参数BaseOfDll是模块加载到进程地址空间之后的基地址。这个参数很重要,因为符号文件中每个符号的地址都是相对于模块基地址的偏移地址,而不是绝对地址,这样的话,不论模块被加载到哪个地址,它的符号文件都是可用的。当然,这一切的前提是你将正确的模块基地址传给了SymLoadModule64函数。幸运的是,CREATE_PROCESS_DEBUG_INFOLOAD_DLL_DEBUG_INFO结构体中已包含了一个lpBaseOfImage字段,我们直接使用即可,不必为了获取模块基地址而大动干戈。

    至于最后一个参数SizeOfDll,表示模块文件的大小。我还不知道这个参数的作用,也不知道应该传一个什么样的值给它。我一直都给它传一个0,即使如此SymLoadModule64也能正常工作。所以我们还是暂且将它放在一旁,将注意力转移到别的地方吧。

    添加了加载调试符号的代码之后,处理CREATE_PROCESS_DEBUG_EVENT事件的代码大概像下面这样子:

     1 BOOL OnProcessCreated(const CREATE_PROCESS_DEBUG_INFO* pInfo) {
     2 
     3     //初始化符号处理器
     4     //注意,这里不能使用pInfo->hProcess,因为g_hProcess和pInfo->hProcess
     5     //的值并不相同,而其它DbgHelp函数使用的是g_hProcess。
     6     if (SymInitialize(g_hProcess, NULL, FALSE) == TRUE) {
     7     
     8         //加载模块的调试信息
     9         DWORD64 moduleAddress = SymLoadModule64(
    10             g_hProcess,
    11             pInfo->hFile, 
    12             NULL,
    13             NULL,
    14             (DWORD64)pInfo->lpBaseOfImage,
    15             0);
    16 
    17         if (moduleAddress == 0) {
    18 
    19             std::wcout << TEXT("SymLoadModule64 failed: "<< GetLastError() << std::endl;
    20         }
    21     }
    22     else {
    23 
    24         std::wcout << TEXT("SymInitialize failed: "<< GetLastError() << std::endl;
    25     }
    26 
    27     CloseHandle(pInfo->hFile);
    28     CloseHandle(pInfo->hThread);
    29     CloseHandle(pInfo->hProcess);
    30 
    31     return TRUE;
    32 }

    处理LOAD_DLL_DEBUG_EVENT事件的代码:

     1 BOOL OnDllLoaded(const LOAD_DLL_DEBUG_INFO* pInfo) {
     2 
     3     //加载模块的调试信息
     4     DWORD64 moduleAddress = SymLoadModule64(
     5         g_hProcess,
     6         pInfo->hFile, 
     7         NULL,
     8         NULL,
     9         (DWORD64)pInfo->lpBaseOfDll,
    10         0);
    11 
    12     if (moduleAddress == 0) {
    13 
    14         std::wcout << TEXT("SymLoadModule64 failed: "<< GetLastError() << std::endl;
    15     }
    16 
    17     CloseHandle(pInfo->hFile);
    18 
    19     return TRUE;
    20 }

    判断符号文件的格式

    前面说过,SymInitialize在找不到符号文件的情况下仍然会返回TRUE,此时它只加载了模块的信息,而没有加载调试符号。SymLoadModule64函数同样如此。那么,如何知道某个模块是否含有调试信息呢?或者,如何知道某个模块的符号文件使用哪种格式呢?可以通过调用SymGetModuleInfo64函数来获取这些信息。该函数的声明如下:

    1 BOOL WINAPI SymGetModuleInfo64(
    2     HANDLE hProcess,
    3     DWORD64 dwAddr,
    4     PIMAGEHLP_MODULE64 ModuleInfo
    5 );

    第一个参数是符号处理器的标识符,现在你应该对它很熟悉了。第二个参数是模块的基地址,也就是在调用SymLoadModule64时传给BaseOfDll参数的值。第三个参数是指向IMAGEHLP_MODULE64结构体的指针,调用函数完成之后模块的信息将会保存到这个结构体中。

    IMAGEHLP_MODULE64结构体含有非常多的字段,不过我们一般只关心其中的一个:SymType。这个字段指示模块使用的是哪种格式的符号文件,其可能的取值如下:

    SymCoff

    COFF格式。

    SymCv

    CodeView 格式。

    SymDeferred

    调试符号是延迟加载的。下文会提及。

    SymDia

    DIA 格式。

    SymExport

    符号是从DLL文件的导出表中生成的。

    SymNone

    没有调试符号。

    SymPdb

    PDB格式。

    SymSym

    使用.sym类型的符号文件。

    SymVirtual

    SymLoadModuleEx函数的最后一个参数有关,还未知道什么意思。

     在调用SymGetModuleInfo64之前需要将IMAGEHLP_MODULE64结构体的SizeOfStruct字段设置为sizeof(IMAGEHLP_MODULE64)

    延迟加载调试符号

    在上面SymType的取值列表中有一个SymDeferred的值,它表示什么意思呢?DbgHelp支持延迟加载调试符号,意思是说在调用SymLoadModule64时,只加载模块信息,不加载调试符号,等到真正使用的时候才加载。这样做的好处是可以节省内存,避免加载了符号而不使用的情况。

    如果要开启这个特性,可以使用SymSetOptions函数:

    1 SymSetOptions(SYMOPT_DEFERRED_LOADS);

    该函数需要在调用SymInitialize之前调用。

    所谓“真正使用的时候”究竟是什么时候,我也搞不清楚。我在开启了延迟加载调试符号的情况下调用SymGetLineFromAddr64获取源文件路径和行号信息时总是失败,而关闭了这个特性之后却成功了,这说明并不是所有需要访问调试符号的DbgHelp函数都会使调试符号加载进来。所以,为了确保DbgHelp函数可以正确执行,我建议不要开启这项特性。

    清理调试符号

    在被调试进程结束的时候必须删除与之对应的符号处理器,以及清理它占用的资源。只要在处理EXIT_PROCESS_DEBUG_EVENT事件的时候调用SymCleanup函数就可以完成这个操作,该函数接受一个符号处理器的标识符。

    另外,在dll文件卸载的时候也应该清理与之相关的调试符号,避免占用内存。这要在处理UNLOAD_DLL_DEBUG_EVENT事件时调用SymUnloadModule64函数。该函数接受一个符号处理器的标识符,以及模块的基地址,我们可以直接使用UNLOAD_DLL_DEBUG_INFO结构体中唯一的字段lpBaseOfDll

    示例代码

    示例代码按照本文的描述添加了对调试符号的加载和清理代码,改动不是很大。

    https://files.cnblogs.com/zplutor/MiniDebugger5.rar


    作者:Zplutor
    出处:http://www.cnblogs.com/zplutor/
    本文版权归作者和博客园共有,欢迎转载。但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

  • 相关阅读:
    jmeter-plugins-manager安装
    JMeter安装
    charles
    url地址有哪些组成?
    服务器的响应状态码
    jQuery链式编程
    jQuery 实现单选反选以及三元运算
    jQuery选择器的基本和层级
    将jmeter返回的json数据转码
    DOM的事件
  • 原文地址:https://www.cnblogs.com/zplutor/p/1989783.html
Copyright © 2020-2023  润新知