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

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

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

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

什么是调试符号

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

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

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

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

使用调试符号

Windows提供了两种方法让我们可以访问调试符号,分别是DbgHelp(Debug Help Library)和DIA(Debug 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.h和DbgHelp.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时,它必须是一个有效的进程句柄;当fInvadeProcess为FALSE时,它可以是任意一个唯一的数值。

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

当fInvadeProcess为TRUE时,第二个参数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总是返回FALSE,GetLastError返回-1。这是因为在处理CREATE_PROCESS_DEBUG_EVENT事件时,被调试进程需要的模块还未加载完成,处于一个不完整的状态。所以,应该等到被调试进程初始化之后才使用这种方法。由于每个进程在初始化完毕之后都会引发一个断点异常,所以加载调试符号的最好的时机就是在处理这个初始断点的时候。关于初始断点的内容在讲解断点的时候会提及。

第二种方法是在调用SymInitialize的时候第三个参数传入FALSE,然后对每个模块调用SymLoadModule64函数加载调试符号。我们可以在处理CREATE_PROCESS_DEBUG_EVENT和LOAD_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_INFO和LOAD_DLL_DEBUG_INFO结构体中都有一个hFile的字段,这个字段刚好可以用在SymLoadModule64函数上。

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

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

第五个参数BaseOfDll是模块加载到进程地址空间之后的基地址。这个参数很重要,因为符号文件中每个符号的地址都是相对于模块基地址的偏移地址,而不是绝对地址,这样的话,不论模块被加载到哪个地址,它的符号文件都是可用的。当然,这一切的前提是你将正确的模块基地址传给了SymLoadModule64函数。幸运的是,CREATE_PROCESS_DEBUG_INFO和LOAD_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。

示例代码

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

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

时间: 2024-10-13 16:34:33

[Win32]一个调试器的实现(五)调试符号的相关文章

嵌入式调试器原理和各类调试器集锦(JLINK、STLINK、CCDEBUG)

工欲善其事,必先善其器.调试器在嵌入式开发调试中的重要性不言而喻,单步.断点和监察的效率远高于串口打印.但是,调试器对于一般开发人员往往是一个黑匣子.今天我们就来谈谈调试器的原理,顺便把自己的几类调试器接线和注意事项记录下来,以便查找.我常常要面对几个方案,而各个方案的调试器都不一样,接线有时连自己都记不住.所以这个帖子应值得嵌入式开发工程师收藏. 一.嵌入式调试多样性 我们先来回想调试的场景,思考一下这几个问题: 1. ARM开发环境有Keil.IAR.ADS等等,我们发现这几个平台都能用同一

嵌入式调试器原理和各类调试器集锦

工欲善其事,必先善其器.调试器在嵌入式开发调试中的重要性不言而喻,单步.断点和监察的效率远高于串口打印.但是,调试器对于一般开发人员往往是一个黑匣子.今天我们就来谈谈调试器的原理,顺便把自己的几类调试器接线和注意事项记录下来,以便查找.我常常要面对几个方案,而各个方案的调试器都不一样,接线有时连自己都记不住.所以这个帖子应值得嵌入式开发工程师收藏. 一.嵌入式调试多样性 我们先来回想调试的场景,思考一下这几个问题: 1. ARM开发环境有Keil.IAR.ADS等等,我们发现这几个平台都能用同一

调试器开发实例_调试器框架设计

作为一个安全开发人员离不开调试器,它可以动态显示程序的执行过程,对于解决程序问题有极大的帮助,这些文章记录了我开发一个调试器雏形的过程,希望对你有帮助.或许我写的代码很拙劣,还请大家多多见谅! 我们使用  Microsoft Visual Studio 6.0 VC编译器来作为我们的开发工具想对一个程序进行调试,首先要做的当然是启动这个程序,这要使用CreateProcess这个Windows API来完成.例如: 1 // LilisiDebug.cpp : Defines the entry

调试器开发实例_调试器事件处理(一.事件到达)

上一章既然说到了调试循环事件,那么接下来我们该说说对调试器事件的处理了. 调试器的事件处理虽然有很多,但是并不是每一个都用得上的,接下来的文章中我们挑选一些经常用到的来给大家说说. CREATE_PROCESS_DEBUG_EVENT  创建进程之后的第一个调试事件,CREATE_PROCESS_DEBUG_INFO结构体描述了该类调试事件的详细信息. 该结构体有三个字段是句柄,分别是hFile,hProcess和hThread,同样要记得使用CloseHandle关闭它们! EXIT_PROC

简单调试器的实现(一)调试循环与反汇编引擎

最近对调试器的原理感兴趣,自己写了一个简单的demo 打开调试进程: 要调试一个进程,需要在使用CreateProcess打开一个文件时,将第6个参数设为DEBUG_PROCESS. BOOL WINAPI CreateProcess( _In_opt_     LPCTSTR lpApplicationName, _Inout_opt_  LPTSTR lpCommandLine, _In_opt_     LPSECURITY_ATTRIBUTES lpProcessAttributes,

[Win32]一个调试器的实现(二)调试事件的处理

[Win32]一个调试器的实现(二)调试事件的处理 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 上一篇文章说到了调试循环的写法,这回讲一下调试器应该如何处理各种调试事件. RIP_EVENT 关于这种调试事件的文档资料非常少,即使提到也只是用“系统错误”或者“内部错误”一笔带过.既然如此,我们也不需要对其进行什么处理

[Win32]一个调试器的实现(四)读取寄存器和内存

[Win32]一个调试器的实现(四)读取寄存器和内存 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 在前几篇文章中,我实现的那个调试器只能被动接收调试事件并输出这些事件的信息.现在,我要将它修改成可以接收命令,并根据命令对被调试进程进行各种操作.首先从最基本的操作开始. 获取寄存器的值 每个线程都有一个上下文环境,它包

[Win32]一个调试器的实现(三)异常

[Win32]一个调试器的实现(三)异常 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 这回接着处理上一篇文章留下的问题:如何处理EXCEPTION_DEBUG_EVENT这类调试事件.这类调试事件是调试器与被调试进程进行交互的最主要手段,在后面的文章中你会看到调试器如何使用它完成断点.单步执行等操作.所以,关于这类调

[Win32]一个调试器的实现(一)调试事件与调试循环

[Win32]一个调试器的实现(一)调试事件与调试循环 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 前言 程序员离不开调试器,它可以动态显示程序的执行过程,对于解决程序问题有极大的帮助.如果你和我一样对调试器的工作原理很感兴趣,那么这一系列文章很适合你,这些文章记录了我开发一个调试器雏形的过程,希望对你有帮助.或许我

[Win32]一个调试器的实现(七)断点

[Win32]一个调试器的实现(七)断点 作者:Zplutor 出处:http://www.cnblogs.com/zplutor/ 本文版权归作者和博客园共有,欢迎转载.但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利. 断点是最基本和最重要的调试技术之一,本文讲解了如何在调试器中实现断点功能. 什么是断点 在进行调试的时候,只有被调试进程暂停执行时调试器才可以对它执行操作,例如观察内存内容等.如果被调试进程不停下来的话,调试器是什么也做不了的.要使