一个调试器应该可以跟踪被调试程序执行到了什么地方,显示下一条将要执行的语句,显示各个变量的值,设置断点,进行单步执行等等,这些功能都需要一个基础设施的支持,那就是调试符号。
什么是调试符号
我们知道,在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函数,该函数声明如下:
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函数的声明如下:
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事件的代码大概像下面这样子:
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事件的代码:
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函数来获取这些信息。该函数的声明如下:
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函数:
该函数需要在调用SymInitialize之前调用。
所谓“真正使用的时候”究竟是什么时候,我也搞不清楚。我在开启了延迟加载调试符号的情况下调用SymGetLineFromAddr64获取源文件路径和行号信息时总是失败,而关闭了这个特性之后却成功了,这说明并不是所有需要访问调试符号的DbgHelp函数都会使调试符号加载进来。所以,为了确保DbgHelp函数可以正确执行,我建议不要开启这项特性。
清理调试符号
在被调试进程结束的时候必须删除与之对应的符号处理器,以及清理它占用的资源。只要在处理EXIT_PROCESS_DEBUG_EVENT事件的时候调用SymCleanup函数就可以完成这个操作,该函数接受一个符号处理器的标识符。
另外,在dll文件卸载的时候也应该清理与之相关的调试符号,避免占用内存。这要在处理UNLOAD_DLL_DEBUG_EVENT事件时调用SymUnloadModule64函数。该函数接受一个符号处理器的标识符,以及模块的基地址,我们可以直接使用UNLOAD_DLL_DEBUG_INFO结构体中唯一的字段lpBaseOfDll。
示例代码
示例代码按照本文的描述添加了对调试符号的加载和清理代码,改动不是很大。