符号文件如何让断点发挥作用的?
调试符号文件(pdb)是一种很复杂的文件,由于这种文件格式微软并不公开,所以至今为止,并没有一篇文章或资料敢说自己对pdb文件进行了深入剖析。更重要的原因是,我们为了研究调试技术,需要知道一些系统(操作系统,编译器,连接器,调试器等)调试支持,仅仅知道即可,没必要深究微软为了实现调试而做出的每一个细节。
首先,我先问几个问题:
- 我们经常用的调试方法,下断点,是如何实现的呢?
- 我们可以在程序还没有执行起来的时候就可以下断点,等调试启动的时候,就可以命中这个断点。这个是怎么实现的?
- 当断点命中时,我们可以观察一个变量的值,这是怎么实现的?
先简单讲解本文中用到的两个概念:
- OFFSET,文件中的偏移。
- VA,程序加载到内存后的一个虚拟地址。
假设在一个EXE文件中,有一个全局变量a,距离文件起始的偏移为0x10,此时文件的起始位置为0x00000000,那么该全局变量a的OFFSET就是0x00000010。当这个exe执行起来,加载到内存后,这个exe本身所加载到的内存位置称为基地址。假设基地址为0x00400000,那么这个全局变量a的VA便是0x00400010。可见,exe本身所加载到的基地址不一样的话,那么a的VA就不能确定。
依然使用最简单的例子,来阐述原理,代码如下:
可以观察到,此时,笔者并未调试启动程序,而这个断点,就已经打上了。接下来我们调试启动程序,如下图:
此时,我们已经进入断点,并中断下来,我们可以观察到全局变量g_nVar的值。想必这个过程,有过VC++开发经验的开发者,再熟悉不过了。下面,详细分析一下这个过程。
当我们鼠标点击下断点的时候,我们的程序还没有启动,VS是不可能知道这个断点应该打在内存中的哪一条指令的地址处的(此时,VS顶多知道断点所在的OFFSET,但是无法知道断点所在的VA),但是VS可以记录到一条重要的信息,就是当前断点在哪个源文件的哪个行号上。
接下来,我们调试启动程序,exe的镜像加载到内存后,所有代码段的指令的VA便是真实可用的了。但此时调试器是如何根据断点所在源文件和行号,来找到断点所在的VA的呢?现在,你应该想到本文在讲什么,哈哈,就是pdb啦。那么pdb文件中到底存了什么,才让调试器可以根据源文件及行号来找到对应的VA呢?
默认情况下,在pdb文件中,保存了可执行文件中所有的符号(函数名、变量名等)所在源文件、行号、OFFSET等信息。但是这些信息,是在什么时间得到的呢?很明显是编译阶段,编译器在编译每个cpp的过程中,就可以把这些符号的相关信息收集起来,存放在各个cpp所生成的obj文件中,然后在链接的时候,提取每个obj中的这些信息,生成一个单独的pdb文件。这样,以后调试程序的时候,调试器只要找得到这个pdb,就可以知道可执行文件中,所有符号所在的源文件、行号和OFFSET了。反过来说,当给出一个源文件和行号,就可以拿到对应的OFFSET了,所以在还没有启动调试的时候,我们下的断点,实际上调试器是知道这个断点应该在哪个OFFSET上了,等启动调试的时候,用这个OFFSET加上这个模块所加载到的基地址值,就可以得到这个断点所在的VA了,然后在这个VA处强行写上int
3指令,并继续执行,当执行到这里,便中断下来给我们一个调试机会了。想想,如果没有pdb,这个断点还能用么?
当我们鼠标放在某个变量上时,调试器可以拿到这个变量的名称,根据我们前面说的,用这个名称去pdb中查找,自然就可以找到pdb文件中保存的OFFSET了,加上这个模块的基地址,就找到了这个变量所在内存的VA,剩下的就是读一下这个VA内存中的内容了。这样也就实现了观察变量值得功能。
下面证实一下,pdb文件中确实存储了源文件、行号、OFFSET等信息。将上面例子代码放到VC6中编译,然后到debug目录中使用dumpbin来查看CodeTest.obj文件中的符号信息,如图:
可见,add函数和main函数所在行号和起始行号和结束行号都是有记录的,那源文件是哪个呢?哈哈,当然是CodeTest.cpp了,我们查看的是CodeTest.obj文件嘛。。。
但是这里并没有add或者main函数的OFFSET啊,为什么呢?想想,此时只有一堆obj,真正的可执行模块还没有生成出来呢,何来的可执行模块的OFFSET呢。。。由此可以知道,这个OFFSET要在链接过程中,才可以确定。经过了链接之后,这些本来在obj里的调试信息,也就被收集到pdb文件中了,下面我们来找找add函数的OFFSET到底在哪里?使用SymView工具打开CodeTest.pdb文件,如下图:
可见,pdb中存储了add函数相关信息,不仅仅只有offset,而且此处并未直接记载add函数在哪个cpp里,这些关系都是通过索引来查找的,其实pdb文件的内部结构是很复杂的,要想解释清楚,其实很不容易,大家如果想知道pdb内部到底都有什么东西,可以参考一下《软件调试》第25章,但也是讲了个大概。
在我们观察的过程中,我们可以发现两个很主要的特征:
-
可执行模块中,保存了当前模块的调试符号文件的路径,而且是绝对路径,如下图:
- Pdb文件中保存了每个cpp文件的路径,而且也是用的绝对路径。如下图:
这样,我们可以得出一个结论:
- 在同一台开发者的机器上,如果被调试的exe放在了其他目录里,而pdb依然在原来生成时所在的位置,那么调试exe时,依然可以找到对应的pdb文件。
- 如果exe和pdb都换了路径,只要调试的时候,我们手动指定了pdb所在的位置,如果源码文件还在原来的路径,那么调试时,依然可以找得到源码文件。