调试符号文件(pdb)是一种很复杂的文件,本文不可能对pdb文件进行非常细致的讲解。另外,由于这种文件格式微软并不公开,所以至今为止,并没有一篇文章或资料敢说自己对pdb文件进行了深入剖析。更重要的原因是,我们为了研究调试技术,需要知道一些系统(操作系统,编译器,连接器,调试器等)调试支持,仅仅知道即可,没必要深究微软为了实现调试而做出的每一个细节。 首先,我先问几个问题: 1.我们经常用的调试方法,下断点,是如何实现的呢? 2.我们可以在程序还没有执行起来的时候就可以下断点,等调试启动的时候,就可以命中这个断点。这个是怎么实现的? 3.当断点命中时,我们可以观察一个变量的值,这是怎么实现的? 如果以上问题,你都可以正确回答,那么恭喜你,本文可以直接跳过了。本文通过分析如上问题的答案,来讲解pdb文件存在的必要性。其目的是让所有开发者知道pdb的有无,会影响到什么。废话少说,进入正文。先简单讲解本文中用到的两个概念: 1.OFFSET,文件中的偏移。 2.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章,但也是讲了个大概,非常详细的细节,还得靠大家反汇编去分析了。暂时我是没有这个动力了。题外话: 在我们观察的过程中,我们可以发现两个很主要的特征: 1.可执行模块中,保存了当前模块的调试符号文件的路径,而且是绝对路径,如下图: 2.Pdb文件中保存了每个cpp文件的路径,而且也是用的绝对路径。如下图:这样,我们可以得出一个结论。1.在同一台开发者的机器上,如果被调试的exe放在了其他目录里,而pdb依然在原来生成时所在的位置,那么调试exe时,依然可以找到对应的pdb文件。2.如果exe和pdb都换了路径,只要调试的时候,我们手动指定了pdb所在的位置,如果源码文件还在原来的路径,那么调试时,依然可以找得到源码文件。 以上的结论可能不太好理解,下面说个开发过程中经常碰到的问题。比如说我自己写了一个dll,又写了一个exe文件,如果在同一个VS解决方案中建立的两个项目,那么我们可以调试exe的时候,进入到dll的源码中,进行调试。根据上面的结论,我们自己应该能想清楚是为什么了。 另外一种情况是,别人写了一个dll,而我们是写exe的,我们调用了别人写的dll,但是某天,他的dll出了问题,我们想调试一下看看,他dll里到底哪里错了,而这时,我们应该如何进行源码级调试呢?同样根据前面的结论,我想大家应该能想出办法。千万不要跟我说:把他的项目,添加到我自己的解决方案里就可以了。。。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课